Skip to content

Commit 5fc57b3

Browse files
feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member (#22779)
Fixes #22772 Clause-②: yes (widening) ## What this does Segment 1 of 3 of ruling A on #22757 (director record `6105447950`, maintainer 「同意」). It declares the forward target that a dispatcher domain will call for the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`. On a kernel with no `http.server` (the hosted shape), a correctly signed anonymous sender gets `401 UNAUTHENTICATED` from the `/automation` anonymous floor today and never reaches the trigger's HMAC check. Nothing implements or calls the member yet. Segment 2 (#22773, the runtime domain and the parameterised allow-list row) and segment 3 (#22774, the trigger-api implementation) read this shape. - `packages/spec/src/contracts/trigger-api-service.ts` (new): the contract `ITriggerApiService` for the **`trigger-api`** service slot, which `@objectstack/trigger-api`'s `ApiTriggerPlugin` will register. It has one optional member: `handleInboundHook?(request: Request, hook: { readonly flowName: string; readonly hookId: string })`, returning a Promise of a web-standard `Response`. Its docblock names the slot key in prose, as `IWebhookService`'s does, and states: - **What it serves.** The trigger's answers, in the handler's order: `404 RESOURCE_NOT_FOUND` for an unknown flow or a wrong hook id (identical; the hook is matched before the signature is verified, so the hook id, not the flow name, keeps an armed flow unprobeable, and a flow left on the default hook id can be probed: a uniform refusal is #22769's question); `503 SERVICE_UNAVAILABLE` when the hook's secret cannot be read; `401 INVALID_SIGNATURE` for a missing or wrong `x-objectstack-signature`; `400 INVALID_REQUEST` for a verified body that is not valid JSON or not a JSON object; `202 { accepted: true, messageId }` once enqueued, with the flow running on the queue consumer and never in-band (ADR-0041 §5); `503 SERVICE_UNAVAILABLE` with no queue; and `503 ENQUEUE_FAILED` when the publish throws. - **The member is the boundary (ruling A, condition 2).** The implementer verifies inside the member, through the same verifier as the self-hosted mount: a per-hook secret, a constant-time comparison, and verification before anything is enqueued. It reads the raw body from the request itself. The caller never reads, parses, re-serialises, pre-checks or short-circuits it. - **The signature is the only credential.** The member takes no `ExecutionContext` and reads no session, cookie or `Authorization` header. - **Transport neutrality.** There is no Hono, raw-app or `http.server` type in the signature. - **The path parameters arrive as `hook`**, as the caller's route matched and decoded them. The member never re-parses the URL. - **Its one caller.** That is the runtime `HttpDispatcher`'s exact `POST /automation/hooks/:flowName/:hookId` domain. It forwards the body unread and answers a body the transport already consumed as a server fault. The raw-app mount is not a caller, and the path is never mounted twice. - **The absence rule.** An empty `trigger-api` slot or an absent member answers a typed 404 or 501, never `ROUTE_NOT_FOUND`. - **Replay protection is not here** (ruling A condition 3, which #22769 covers). The member has no timestamp or nonce field. Anything a later scheme signs arrives in the same forwarded request, so the signature does not grow for it. - `packages/spec/src/contracts/index.ts`: exports the contract from `@objectstack/spec/contracts`, placed beside `automation-service.js`. - `packages/spec/src/contracts/trigger-api-service.test.ts`: the type-level pin (details under Verification). - `packages/spec/api-surface/contracts.json` and `packages/spec/export-origins/contracts.json`: one regenerated line each, for the new export. - `.changeset/22772-spec-trigger-api-inbound-hook-member.md`: `@objectstack/spec` `minor`, with its own `Clause-②: yes (widening)` line. No runtime, core or plugin code changed. The signature check stays in the trigger. ## The slot: a new `trigger-api` contract, not `IAutomationService` ### What was measured first (at `origin/main` `996aa86e0a`, re-read at the merged head) 1. **Confirmed.** `ApiTriggerPlugin` registers no service of its own. At `kernel:ready` it calls `automation.registerTrigger(trigger)` (`packages/triggers/trigger-api/src/plugin.ts:75`), typed only by a structural `AutomationTriggerRegistry` slice (`:12`). It mounts `HOOKS_PATH` on the `http-server` slot's raw app only (`:77`–`:93`). The service keys a dispatcher domain would look up are: - (a) `trigger-api`, for a new contract. Nothing registers it yet: 0 hits for a `registerService` / `getService` / `resolveService` of `'trigger-api'` under `packages/` (git grep exit 1). - (b) `automation`, for `IAutomationService`. It is registered by `service-automation` (`packages/services/service-automation/src/plugin.ts:975`) on every kernel that composes automation, with or without trigger-api. 2. **The automation service implements none of the door, so (b) would be declared but unenforced.** The engine's `registerTrigger` (`engine.ts:3592`) stores the `FlowTrigger` by type and activates bound flows. For an `api` flow it builds a binding with `config` and, when the flow has a secret to read, a `resolveSecret` reader (`engine.ts:3788`–`3800`). Its own comment there reads: "The concrete trigger (`@objectstack/trigger-api`) mounts the endpoint and enqueues". `FlowTrigger` has no request member (`engine.ts:633`), and `registerTrigger` is not on `IAutomationService` at all. Every step of the door is `ApiTrigger.handleRequest` (`api-trigger.ts:218`–`275`): the hook lookup with `safeEqual`, the secret read, `verifySignature`, the JSON parse, the queue publish and the 202. So the #22754 reasoning applies: the door's owner registers the slot. 3. **Read.** The handler's input is `{ flowName, hookId, rawBody, signatureHeader, idempotencyKey }`. The raw mount fills them from `c.req.param(...)`, `c.req.text()` and the two headers (`plugin.ts:84`–`90`). Its output is `{ status, body }`, served by `c.json(out.body, out.status)`. The status and code list in the docblock is the handler's, line by line, including two details the dispatch's list did not have: the secret-unavailable 503 carries `SERVICE_UNAVAILABLE`, and an empty or whitespace-only body parses as the empty object. The three trigger-api codes (`ENQUEUE_FAILED`, `INVALID_REQUEST`, `INVALID_SIGNATURE`) are already in the error-code ledger under `@objectstack/trigger-api`. ### The four axes (slot) - **Real business need.** The measured need is the door on a hosted kernel that composes trigger-api, which is what ruling A grants. On the `trigger-api` slot, the door exists exactly where the trigger is composed. An `automation`-slot member would exist on every automation kernel, including one with no inbound-hook trigger, where no hook can ever be armed. - **Long-term soundness.** One route, one owner. The trigger keeps the door's whole rule set (lookup, secret, verifier, parse, enqueue, answers) in one place for both kernel shapes, and the dispatcher only forwards, as `/auth`, `/approvals/act` and `/webhooks/redeliver` do. Route (b) would need a new request member on `FlowTrigger`, an engine-side forwarding hop in `service-automation`, and a widened `IAutomationService`. That is a second package carrying the door, a package segment 3's card does not name. - **Preventing AI mistakes.** Declared means enforced: the member sits on the contract of the package that implements it. Route (b) would put "accepts and verifies a signed hook" on the flow engine's contract, so a reader of `IAutomationService` would place the verifier in the engine. That is exactly the boundary confusion that condition 2 exists to prevent. - **Startup-stage focus.** One new contract with one optional member (+1 export, type-only), the same cost #22754 paid. Route (b) widens a 1,278-line contract and adds runtime code in a lane this card does not own. **The slot key.** It is `trigger-api`, the package's own name. ADR-0041 §2 names the trigger family `trigger-*`, and the ruling and the three segment cards all call this door's owner "trigger-api", and the error-code ledger keys its codes by that package. Kebab-case is the majority spelling among the registry's multi-word slot keys (`http-server`, `file-storage`, `org-scoping`, `datasource-admin`). The contract name follows the slot, as `IWebhookService` follows `webhooks`. No platform capability token was used, because trigger-api has none of its own: it is an extra of `triggers`, whose token maps to `trigger-record-change`. **`ServiceSlotContracts` gains no entry**, for the reason the ledger itself states: an entry is made only where a provider registers the slot. Segment 3 adds `'trigger-api': ITriggerApiService` beside its registration. The `approvals` and `webhooks` precedents did the same. ## The member shape: `(request, hook)` returning a Promise of Response The dispatch allowed either a Request/Response member or a typed input/output pair. This PR takes the first, with the path parameters as a second argument. - **Real business need.** Measured inputs: the raw body, `x-objectstack-signature`, `x-idempotency-key`, and the two path parameters. A `Request` carries the first three, and the hook's address carries the other two. A typed pair would need fields for each, which is the card's "the headers" spelled as a fixed list. - **Long-term soundness.** It has the same forwarding shape as `handleActionPage` and `handleRedeliver`: the dispatcher forwards the request unread and returns the member's `Response` as it is. A typed `{ status, body }` result would make the dispatcher assemble the answer, which is a second place the trigger's answer is spelled. A later signing scheme (#22769) arrives inside the request, so the contract does not grow fields for it. - **Preventing AI mistakes.** This is the decisive axis. - With a `Request` in, the caller has nothing to hand over but the unread body, so it structurally cannot parse and re-serialise the body before verification. A typed `rawBody` would make the caller read the body, and parse-then-serialise is the natural way for that to go wrong: it fails verification for every real sender. - The second argument comes from the router that matched the route. A member that re-parsed the URL would meet the API prefix and an `/environments/:id` scope on the dispatcher shape (the dispatcher strips both before `DomainRequest.path`; `packages/runtime/src/domain-handler-registry.ts`), and could address a different hook than the four-segment allow-list row admitted. - The pin holds the address to exactly `flowName` and `hookId`, so moving the body, the signature or a timestamp out to the caller turns the pin red. - **Startup-stage focus.** One interface export. There are no input or output types to publish. **Against the two precedents.** Both took `(request: Request)` alone, because their doors carry nothing in the path: the action-page token rides in the query or form, and the `deliveryId` rides in the body. This door is the first whose address is in the path, so the one departure is the second argument. Everything else is kept: the docblock skeleton, the pin form, the barrel-identity pin and the changeset level. **Why `handleInboundHook`:** `handle…` is this package's spelling for a `Request`-to-`Response` member (`handleRequest`, `handleActionPage`, `handleRedeliver`), and "inbound" keeps it apart from ObjectQL data hooks and kernel lifecycle hooks. `ITriggerApiService` and `handleInboundHook` had 0 hits before this diff, in objectstack (git grep exit 1) and in objectui at `023f00d46` (exit 1; control `IAuthService`: 2 hits). ## Verification Heavy runs went through `scripts/pm/os-verify-lock.sh`, and the `VERDICT` line is quoted from each. The readings below were taken at the build head `6c636dca6e`, a merge of `origin/main` at `e84aeb36ce` (which carries #22767) on top of the change, followed by regeneration. Patch round 1 (`ad45100733`, the docblock sentence on probing, text only) re-ran the spec build, `check:generated`, typecheck, suite and gates with the same results; its readings are in the round-1 `os-dev-report` on #22772. - **The merge and regeneration.** `scripts/pm/os-regen-merge.sh` merged `origin/main`. The os-regen driver kept this branch's side of both `contracts.json` shards, dropping main's `IWebhookService` line: 0 occurrences after the driver, 1 after regeneration. Regeneration on the merged tree uses `pnpm --filter @objectstack/spec build` (`VERDICT command-exit 0`), then `gen:api-surface` and `gen:export-origins`. It restored both lines, and the net delta against `origin/main` is exactly one added line per shard (`ITriggerApiService`). - **Type-only.** In `packages/spec/dist`, `handleInboundHook` has 2 hits (`contracts/index.d.ts`, `.d.mts`) and 0 in any emitted `.js` / `.mjs` / `.cjs`. - **Tests at `6c636dca6e`.** The whole spec suite, `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`, gave `Test Files 646 passed (646)`, `Tests 19223 passed | 1 todo (19224)`, `VERDICT command-exit 0`. - **Typecheck at `6c636dca6e`.** `pnpm --filter @objectstack/spec typecheck` (`tsc --noEmit`, `check:scripts-typecheck`, `check:test-typecheck`) gave `VERDICT command-exit 0`, with `check:test-typecheck: OK` (246 errors held, equal to the debt ledger). `tsc -p tsconfig.test.json --listFiles` lists both `trigger-api-service.ts` and `trigger-api-service.test.ts`, with 0 diagnostics in either. - **The pins** (`trigger-api-service.test.ts`), exported type aliases compiled by `check:test-typecheck`: - `InboundHookIsOptional`: the `{} extends Pick` form. - `InboundHookTakesTheRequestAndTheAddress`: the parameters are exactly `[Request, { readonly flowName; readonly hookId }]`, with no execution context. - `HookAddressCarriesOnlyThePathParameters`: the address keys are exactly `flowName` and `hookId`. - `InboundHookAnswersAResponse`: the return is a Promise of `Response`. - `BarrelExportsTheContract`. - A value pin: an empty object typed as the contract compiles and carries no member. - No docblock prose is pinned, because no consumer parses it. - **Reverse verification**, run from the committed `476fd79e40` through `scripts/ablation-replace.mjs` in wrap mode. The subject is imported relatively, so the type program reads `src` and no dist rebuild is involved. The baseline is 0 errors in the test file (246 total, all ledgered). The merge changed nothing in either file. - **A, the member made required** (`handleInboundHook?(` became `handleInboundHook(`). The anchor went 1 → 0 and the blob `b44b329d9b6a` → `f61056dc7c08`. Expected red, observed red: `trigger-api-service.test.ts(36,44): error TS2344` (`InboundHookIsOptional`) and `(57,15): error TS2741: Property 'handleInboundHook' is missing`. - **B, a body field added to the address** (`readonly rawBody: string`). The anchor went 1 → 0 and the blob `b44b329d9b6a` → `d4254ab8327f`. Expected red, observed red: `(40,5): error TS2344` (`InboundHookTakesTheRequestAndTheAddress`) and `(44,62): error TS2344` (`HookAddressCarriesOnlyThePathParameters`). - **Restore legs.** The blob after restore is `b44b329d9b6a`, equal to the HEAD blob, and `git diff HEAD` is empty, both times. - **Gates at `6c636dca6e`.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; merge base `e84aeb36c`; 6 paths, 215 changed lines) derived 87 commands. - 86 exited 0. They include `check:api-surface` ("public API surface + factory signatures unchanged"), `check:generated` ("All 14 generated artifacts are up to date"), `check:export-origins`, `check:docs`, `check:authorable-surface`, `check:exported-any`, `check:dual-source-exports`, `check:entry-nameability`, `check-spec-docblock-symbol-anchors`, `check:slot-lookup`, `check:doc-authoring`, `check-issue-citations` ("every citation this change adds resolves", 3 of 3), `check:nul-bytes`, `check:cross-package-test-inputs`, `check:lean-entry-closure`, `check:type-check-debt`, and `check-adr-0087-registration` ("this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)"). - `check:doc-formula-expressions` and `check:lean-entry-closure` first refused with exit 3 (an unbuilt prerequisite, not a measurement). They were re-run green after `turbo run build --concurrency=2` of formula, lint and objectql (`Tasks: 14 successful`). - **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: it needs a full workspace build (88 packages without `dist/`), and this diff emits no JavaScript. CI's `Lint & Repo Gates` runs it. - `--ran` reconciliation: "87 derived famil(ies) accounted for — 86 run, 1 NOT-MEASURED". - ADR-0087: the changeset is `minor` with the `(widening)` arm, which the gate reads as non-breaking, so no disposition marker is owed and none is written. ## Acceptance notes - **For segments 2 and 3.** The slot key is `trigger-api` and the member is `handleInboundHook(request, { flowName, hookId })`, both named in the docblock. Segment 3 registers the slot in `ApiTriggerPlugin` and adds the `ServiceSlotContracts` entry beside the registration. Segment 2 resolves the same key and hands over the router's decoded parameters. - **Read only, not filed.** The trigger registry the plugin calls (`registerTrigger` / `unregisterTrigger`) is not on `IAutomationService`. Each trigger plugin declares a structural slice of its own instead (`plugin.ts:12`). This is an undeclared cross-package seam with no measured wrong answer. Carrier: none. - **Not run locally; these are CI's.** The `dispatch-gates` artifact-roster block (50 families), the declared wide-population families, the CI type-check lanes and the path-scheduled jobs (Test Core, Dogfood, Build Core, Temporal Conformance) were not run. Three PR-context guards need this PR to exist and were not run here: `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. - **Labels: none written.** The dispatch's write budget was a push, one draft `pr_create` and one report comment, and it names no label. This diff carries a changeset, so `skip-changeset` does not apply. --- _Generated by [Claude Code](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 179f7bf commit 5fc57b3

6 files changed

Lines changed: 221 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): a new trigger-api service contract declares an optional, transport-neutral inbound-hook member (`ITriggerApiService.handleInboundHook`)
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is new.** `@objectstack/spec/contracts` exports a new contract, `ITriggerApiService`, for the `trigger-api` service slot that `@objectstack/trigger-api` fills. It has one optional member, `handleInboundHook?(request: Request, hook: { flowName, hookId }): Promise<Response>`. It serves the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`, the door a third-party sender posts a signed event through to start an `api` flow.
10+
- **What it serves.** The same answers as the trigger's self-hosted route, in the same order. An unknown flow or a wrong hook id answers `404 RESOURCE_NOT_FOUND`, identically. A hook secret that cannot be read answers `503 SERVICE_UNAVAILABLE`. A missing or wrong `x-objectstack-signature` answers `401 INVALID_SIGNATURE`. Only a verified body is read as JSON: one that is not valid JSON, or not a JSON object, answers `400 INVALID_REQUEST`. An enqueued post answers `202` with `{ accepted: true, messageId }`, and the flow runs on the queue consumer, never in-band (ADR-0041 §5). No queue answers `503 SERVICE_UNAVAILABLE`, and a failed publish answers `503 ENQUEUE_FAILED`.
11+
- **The member is the signature boundary.** The implementer verifies inside the member, with the hook's own secret, in constant time, and before anything is enqueued. It reads the raw body from the request itself. The caller forwards the request with its body unread, and never parses, re-serialises or pre-checks it.
12+
- **Transport-neutral.** A web-standard `Request` goes in and a `Response` comes out: there is no Hono context, no raw app and no `http.server` type, and no execution context, because the signature is the only credential. The two path parameters arrive as the second argument, as the caller's route matched them, so the member never re-parses the URL. A hosted tenant kernel has no raw app to mount the route on, and the runtime dispatcher's exact `POST /automation/hooks/:flowName/:hookId` domain is the member's one caller.
13+
- **Why a trigger-api slot, not the automation service's.** The automation service holds the trigger registry that the `api` trigger registers into. The door (the hook lookup, the secret, the signature check, the body parse, the enqueue and every answer) belongs to the trigger, so the door exists exactly where `@objectstack/trigger-api` is composed.
14+
- **Absence.** The member is optional, so a trigger-api service without the door still satisfies the contract. Its caller answers a typed 404 or 501 when the `trigger-api` service or this member is absent, never `ROUTE_NOT_FOUND`.
15+
- **Nothing changes for existing code.** No existing contract or member moved. Nothing registers the `trigger-api` slot or calls the member yet: the trigger's implementation and the dispatcher domain land separately, and the trigger's self-hosted route stays as it is.

‎packages/spec/api-surface/contracts.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
"ISmsTransport (interface)",
164164
"IStorageService (interface)",
165165
"ITeamGraphService (interface)",
166+
"ITriggerApiService (interface)",
166167
"IWebhookService (interface)",
167168
"ImportObjectOpts (interface)",
168169
"ImportObjectResult (interface)",

‎packages/spec/export-origins/contracts.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
"ISmsTransport": "src/contracts/sms-service.ts#ISmsTransport (interface)",
164164
"IStorageService": "src/contracts/storage-service.ts#IStorageService (interface)",
165165
"ITeamGraphService": "src/contracts/sharing-service.ts#ITeamGraphService (interface)",
166+
"ITriggerApiService": "src/contracts/trigger-api-service.ts#ITriggerApiService (interface)",
166167
"IWebhookService": "src/contracts/webhook-service.ts#IWebhookService (interface)",
167168
"ImportObjectOpts": "src/contracts/external-datasource-service.ts#ImportObjectOpts (interface)",
168169
"ImportObjectResult": "src/contracts/external-datasource-service.ts#ImportObjectResult (interface)",

‎packages/spec/src/contracts/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export * from './metadata-service.js';
3131
export * from './metadata-service-roundtrip-conformance.js';
3232
export * from './auth-service.js';
3333
export * from './automation-service.js';
34+
// The inbound-hook trigger's own slot (`trigger-api`), beside the automation
35+
// service it registers its `api` trigger into (#22772).
36+
export * from './trigger-api-service.js';
3437
export * from './analytics-service.js';
3538
export * from './realtime-service.js';
3639
export * from './job-service.js';
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22772] `ITriggerApiService.handleInboundHook`: the inbound hook
4+
// `POST /api/v1/automation/hooks/:flowName/:hookId`, declared on the
5+
// `trigger-api` slot's contract transport-neutrally (the first segment of
6+
// ruling A on #22757).
7+
//
8+
// The card asks for a type-level pin, and these aliases are it: the contract
9+
// carries the member, OPTIONAL, at the web-standard shape (one `Request` and
10+
// the two matched path parameters in, a `Response` out; no Hono context, no
11+
// raw app, no `http.server` type, no execution context), and the `contracts`
12+
// barrel exports the same contract the next segments import. The address pin
13+
// holds the verifier's boundary in the type: the second argument carries the
14+
// flow name and the hook id and nothing else, so a body, a signature or a
15+
// timestamp handed over beside the request, which would move a read or a
16+
// check out to the caller, turns it red. The value-level pin below is the
17+
// optionality's other half: a service object without the member still
18+
// satisfies `ITriggerApiService`.
19+
//
20+
// The optionality pin is a type alias, not `expectTypeOf`: `toEqualTypeOf`
21+
// cannot tell an optional member from a REQUIRED one typed `T | undefined`,
22+
// while `{} extends Pick<…>` can. The aliases are exported so TS6196 does not
23+
// flag them as unused; `check:test-typecheck` compiles this file.
24+
25+
import { describe, it, expect } from 'vitest';
26+
import type { ITriggerApiService } from './trigger-api-service';
27+
import type { ITriggerApiService as ITriggerApiServiceFromBarrel } from './index';
28+
29+
type Eq<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
30+
type Assert<T extends true> = T;
31+
32+
type HandleInboundHook = NonNullable<ITriggerApiService['handleInboundHook']>;
33+
type HookAddress = Parameters<HandleInboundHook>[1];
34+
35+
/** Optional: a service may omit it. A required member turns this alias red. */
36+
export type InboundHookIsOptional = Assert<{} extends Pick<ITriggerApiService, 'handleInboundHook'> ? true : false>;
37+
38+
/** The forwarded `Request` and the matched path parameters: the whole input, with no execution context beside them. */
39+
export type InboundHookTakesTheRequestAndTheAddress = Assert<
40+
Eq<Parameters<HandleInboundHook>, [request: Request, hook: { readonly flowName: string; readonly hookId: string }]>
41+
>;
42+
43+
/** The address is the two path parameters and nothing else: no body, no headers, no signature, no timestamp. */
44+
export type HookAddressCarriesOnlyThePathParameters = Assert<Eq<keyof HookAddress, 'flowName' | 'hookId'>>;
45+
46+
/** A web-standard `Response` out, asynchronously: the trigger's own answer. */
47+
export type InboundHookAnswersAResponse = Assert<Eq<ReturnType<HandleInboundHook>, Promise<Response>>>;
48+
49+
/** The barrel (`@objectstack/spec/contracts`) exports this contract, not a look-alike. */
50+
export type BarrelExportsTheContract = Assert<Eq<ITriggerApiServiceFromBarrel, ITriggerApiService>>;
51+
52+
describe('ITriggerApiService.handleInboundHook: the inbound hook, declared transport-neutrally', () => {
53+
it('is optional: a service object without it still satisfies ITriggerApiService', () => {
54+
// The population the optionality is for: a trigger-api service with no
55+
// inbound-hook door, which the caller must answer with a typed refusal
56+
// rather than call.
57+
const service: ITriggerApiService = {};
58+
59+
expect(service.handleInboundHook).toBeUndefined();
60+
});
61+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @objectstack/spec/contracts/trigger-api-service
5+
*
6+
* Cross-package contract for the inbound-hook trigger's service slot. The
7+
* implementation lives in `@objectstack/trigger-api` (ADR-0041 Tier 1), and
8+
* its `ApiTriggerPlugin` is what registers it, as the `trigger-api` service.
9+
* The slot key is the package's own name, the name ADR-0041 §2 gives the
10+
* trigger family. That registration is a separate change: until it lands no
11+
* kernel fills the slot, and a caller answers the absence rule below.
12+
*
13+
* [#22772] Why the inbound-hook door lives on this slot and not on the
14+
* automation service's (`IAutomationService`, the `automation` slot): the
15+
* automation service holds the trigger REGISTRY. `ApiTriggerPlugin`
16+
* registers its `api` trigger there, and the engine binds every
17+
* `type: 'api'` flow to it with the hook's config and a reader for its
18+
* secret. It owns none of the door. Looking the hook up, reading its secret,
19+
* checking the signature, parsing the body, enqueuing, and every answer the
20+
* sender gets are all the trigger's own (`ApiTrigger.handleRequest`). A
21+
* member on the automation slot would be declared on a service that does not
22+
* implement it, would exist on kernels that compose no inbound-hook trigger,
23+
* and would put the verifier one forwarding hop away from the contract that
24+
* names it.
25+
*/
26+
27+
/**
28+
* The inbound-hook trigger's service: the occupant of the `trigger-api` slot.
29+
*
30+
* Every member is optional. A kernel without `@objectstack/trigger-api`
31+
* leaves the slot empty, and a caller probes for the member it needs before
32+
* calling it.
33+
*/
34+
export interface ITriggerApiService {
35+
/**
36+
* **The inbound hook, served transport-neutrally.** It is
37+
* `POST /api/v1/automation/hooks/:flowName/:hookId`, the door a third-party
38+
* sender (a payment provider, a code host) posts a signed event through to
39+
* start an `api` flow (ADR-0041 Tier 1). It is declared here as the first
40+
* segment of the bridge that the maintainer's ruling A on #22757 directs
41+
* for this endpoint.
42+
*
43+
* What it serves, exactly as the plugin's self-hosted mount serves it
44+
* today, in this order:
45+
* - It looks up the hook armed for `hook.flowName` and compares
46+
* `hook.hookId` with it in constant time. An unknown flow and a wrong
47+
* hook id answer identically, `404 RESOURCE_NOT_FOUND`. The hook is
48+
* matched BEFORE the signature is verified, so an unsigned post that
49+
* names an armed flow and its hook id gets the `503` or `401` below
50+
* instead of that `404`: the hook id, not the flow name, is what keeps
51+
* an armed flow unprobeable. A flow left on the default hook id
52+
* (`default`, when its start node's `config.hookId` names none) can be
53+
* probed that way. Whether every unverified post should get one uniform
54+
* refusal is the hardening card's question (#22769), not this contract's.
55+
* - It reads that hook's own secret, at this moment, so a rotation applies
56+
* to the next post. A secret that cannot be read answers
57+
* `503 SERVICE_UNAVAILABLE`. ⛔ It never verifies against no secret.
58+
* - It verifies the `x-objectstack-signature` header (`sha256=` and the hex
59+
* HMAC-SHA256 of the raw body under that secret). A missing or wrong
60+
* signature answers `401 INVALID_SIGNATURE`.
61+
* - Only then does it read the body as JSON. A body that is not valid JSON,
62+
* or not a JSON object, answers `400 INVALID_REQUEST`; an empty body is
63+
* the empty object.
64+
* - It enqueues the payload on the flow's queue, passing the
65+
* `x-idempotency-key` header through to the queue's dedup window, and
66+
* answers `202` with `{ accepted: true, messageId }`. The flow runs on the
67+
* queue consumer, ⛔ never in-band with the request (ADR-0041 §5). With no
68+
* queue service it answers `503 SERVICE_UNAVAILABLE`, and a publish that
69+
* throws answers `503 ENQUEUE_FAILED`.
70+
* - Every refusal is JSON in the `{ success: false, error: { code, message } }`
71+
* envelope.
72+
*
73+
* **The member is the boundary (ruling A, condition 2).** The implementer
74+
* verifies INSIDE this member, through the same verifier the self-hosted
75+
* mount uses: a per-hook secret, a constant-time comparison, and
76+
* verification before anything is enqueued, so no run starts for a post
77+
* that did not verify. It reads the raw body from `request` itself, once,
78+
* the way the self-hosted mount reads it, and verifies the signature over
79+
* that body. ⛔ The caller never reads, parses or re-serialises the body
80+
* first. The sender signed its own bytes, and a body parsed and serialised
81+
* again differs from them (key order, whitespace, escapes), so it would
82+
* fail verification for every real sender. ⛔ Nor does the caller check,
83+
* pre-filter or short-circuit the signature: there is one verifier, and it
84+
* is behind this member. Anything a later signing scheme adds (a
85+
* timestamp, for one) arrives in the same forwarded request, so this
86+
* signature gains no field for it.
87+
*
88+
* **The signature is the only credential.** This member takes no
89+
* `ExecutionContext` and reads no session, cookie or `Authorization` header:
90+
* a third-party sender has no session, and the HMAC under the hook's own
91+
* secret carries the whole authorization. A signed-in caller is judged
92+
* exactly as an anonymous one, as on the self-hosted mount, which has no
93+
* auth gate.
94+
*
95+
* **Transport-neutral, deliberately.** A web-standard `Request` goes in and
96+
* a `Response` comes out. There is no Hono context, no raw app and no
97+
* `http.server` type. A hosted tenant kernel owns no socket, so it has no
98+
* raw app to mount the route on. The dispatcher reaches that kernel
99+
* instead, and this signature is the one a dispatcher can call.
100+
*
101+
* **The path parameters arrive as `hook`, never re-parsed from the URL.**
102+
* The route the caller matched is the one authority for them. On the
103+
* dispatcher, the request's URL can still carry the API prefix and an
104+
* `/environments/:id` scope that its router strips, and the allow-list row
105+
* that admits an anonymous sender matches these same segments. A member
106+
* that parsed the URL again could pick a hook other than the one the route
107+
* matched. Both values are decoded path segments, as the self-hosted
108+
* mount's router decodes them.
109+
*
110+
* **Its one caller** is the runtime `HttpDispatcher`'s exact
111+
* `POST /automation/hooks/:flowName/:hookId` domain (the next segment of
112+
* ruling A). That domain resolves the `trigger-api` slot on the request's
113+
* kernel, forwards the request with its body unread, and returns this
114+
* member's `Response` as it is. A request whose body the transport already
115+
* read is a server fault, which the caller answers as one rather than
116+
* forward, because the member would read an empty body and tell the
117+
* sender its signature is wrong. Ruling A admits the route on the ADR-0069
118+
* auth-gate allow-list as one exact `POST` row of four segments (that
119+
* segment adds it), so an anonymous sender reaches this member and nothing
120+
* else under `/automation` opens. The plugin's self-hosted raw-app mount is
121+
* not a caller, and the path is never mounted twice.
122+
*
123+
* **Optional, and its absence is loud.** A trigger-api service without the
124+
* door still satisfies this contract, so the caller probes for presence.
125+
* An empty `trigger-api` slot or an absent member answers a typed 404 or
126+
* 501, ⛔ never `ROUTE_NOT_FOUND`: that answer would tell the sender the URL
127+
* is wrong, when what is missing is the service behind it.
128+
*
129+
* @param request - The forwarded `POST`, body unread: the signature and the
130+
* idempotency key are in its headers, and the signed payload is its body
131+
* @param hook - The two path parameters the caller's route matched: the
132+
* flow's name, and the hook id its start node's `config.hookId` arms
133+
* (`default` when it names none)
134+
* @returns The trigger's own JSON answer: `202` accepted, or its refusal
135+
*/
136+
handleInboundHook?(
137+
request: Request,
138+
hook: { readonly flowName: string; readonly hookId: string },
139+
): Promise<Response>;
140+
}

0 commit comments

Comments
 (0)