Repository navigation
feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member - #22779
Conversation
…l transport-neutral inbound-hook member A new contract for the `trigger-api` slot that @objectstack/trigger-api fills, with one optional member that serves POST /api/v1/automation/hooks/:flowName/:hookId from a web-standard Request plus the two matched path parameters, answering the trigger's own Response. The verifier stays inside the member (ruling A, condition 2): the caller forwards the body unread. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…iService Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
…bound-hook-member
…tree The os-regen driver kept one side of both contracts.json shards; the generators re-derive them from the merged source, carrying both IWebhookService (from main) and ITriggerApiService. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fbe6734643919347d23ec21a0985dc2af42e1d36 && git checkout fbe6734643919347d23ec21a0985dc2af42e1d36
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a2e94c2a053ab004b5cb9fea27824bb614e5759c ad451007333980b6df9668828b40beffaeb0b18d && git checkout -B drift-repro a2e94c2a053ab004b5cb9fea27824bb614e5759c && git merge --no-ff ad451007333980b6df9668828b40beffaeb0b18d
node scripts/docs-audit/affected-docs.mjs --json a2e94c2a053ab004b5cb9fea27824bb614e5759c
|
…es not hide An unknown flow and a wrong hook id answer one 404, but the hook is matched before the signature is verified, so the hook id (not the flow name) is what keeps an armed flow unprobeable, and a flow on the default hook id can be probed. A uniform refusal is the hardening card's question. Claude-Session: https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review against the held record on ① Derived judgments
② Semver level
③ Boundary flags
Gates on Implemented-by: VERDICT: PASS Generated by Claude Code |
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 nohttp.server(the hosted shape), a correctly signed anonymous sender gets401 UNAUTHENTICATEDfrom the/automationanonymous 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 contractITriggerApiServicefor thetrigger-apiservice slot, which@objectstack/trigger-api'sApiTriggerPluginwill register. It has one optional member:handleInboundHook?(request: Request, hook: { readonly flowName: string; readonly hookId: string }), returning a Promise of a web-standardResponse.Its docblock names the slot key in prose, as
IWebhookService's does, and states:404 RESOURCE_NOT_FOUNDfor 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 trigger-api: the inbound-hook HMAC signs the body alone, so its signed material carries no timestamp or tolerance window (the replay-protection hardening card condition 3 of ruling 6105447950 names) #22769's question);503 SERVICE_UNAVAILABLEwhen the hook's secret cannot be read;401 INVALID_SIGNATUREfor a missing or wrongx-objectstack-signature;400 INVALID_REQUESTfor 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_UNAVAILABLEwith no queue; and503 ENQUEUE_FAILEDwhen the publish throws.ExecutionContextand reads no session, cookie orAuthorizationheader.http.servertype in the signature.hook, as the caller's route matched and decoded them. The member never re-parses the URL.HttpDispatcher's exactPOST /automation/hooks/:flowName/:hookIddomain. 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.trigger-apislot or an absent member answers a typed 404 or 501, neverROUTE_NOT_FOUND.packages/spec/src/contracts/index.ts: exports the contract from@objectstack/spec/contracts, placed besideautomation-service.js.packages/spec/src/contracts/trigger-api-service.test.ts: the type-level pin (details under Verification).packages/spec/api-surface/contracts.jsonandpackages/spec/export-origins/contracts.json: one regenerated line each, for the new export..changeset/22772-spec-trigger-api-inbound-hook-member.md:@objectstack/specminor, with its ownClause-②: yes (widening)line.No runtime, core or plugin code changed. The signature check stays in the trigger.
The slot: a new
trigger-apicontract, notIAutomationServiceWhat was measured first (at
origin/main996aa86e0a, re-read at the merged head)ApiTriggerPluginregisters no service of its own. Atkernel:readyit callsautomation.registerTrigger(trigger)(packages/triggers/trigger-api/src/plugin.ts:75), typed only by a structuralAutomationTriggerRegistryslice (:12). It mountsHOOKS_PATHon thehttp-serverslot's raw app only (:77–:93). The service keys a dispatcher domain would look up are:trigger-api, for a new contract. Nothing registers it yet: 0 hits for aregisterService/getService/resolveServiceof'trigger-api'underpackages/(git grep exit 1).automation, forIAutomationService. It is registered byservice-automation(packages/services/service-automation/src/plugin.ts:975) on every kernel that composes automation, with or without trigger-api.registerTrigger(engine.ts:3592) stores theFlowTriggerby type and activates bound flows. For anapiflow it builds a binding withconfigand, when the flow has a secret to read, aresolveSecretreader (engine.ts:3788–3800). Its own comment there reads: "The concrete trigger (@objectstack/trigger-api) mounts the endpoint and enqueues".FlowTriggerhas no request member (engine.ts:633), andregisterTriggeris not onIAutomationServiceat all. Every step of the door isApiTrigger.handleRequest(api-trigger.ts:218–275): the hook lookup withsafeEqual, the secret read,verifySignature, the JSON parse, the queue publish and the 202. So the spec: declare the forward target forPOST /api/v1/webhooks/redeliver— an optional, transport-neutral service member a dispatcher domain can reach (webhooks segment 1 of #22564's stage 2) #22754 reasoning applies: the door's owner registers the slot.{ flowName, hookId, rawBody, signatureHeader, idempotencyKey }. The raw mount fills them fromc.req.param(...),c.req.text()and the two headers (plugin.ts:84–90). Its output is{ status, body }, served byc.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 carriesSERVICE_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)
trigger-apislot, the door exists exactly where the trigger is composed. Anautomation-slot member would exist on every automation kernel, including one with no inbound-hook trigger, where no hook can ever be armed./auth,/approvals/actand/webhooks/redeliverdo. Route (b) would need a new request member onFlowTrigger, an engine-side forwarding hop inservice-automation, and a widenedIAutomationService. That is a second package carrying the door, a package segment 3's card does not name.IAutomationServicewould place the verifier in the engine. That is exactly the boundary confusion that condition 2 exists to prevent.POST /api/v1/webhooks/redeliver— an optional, transport-neutral service member a dispatcher domain can reach (webhooks segment 1 of #22564's stage 2) #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 familytrigger-*, 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, asIWebhookServicefollowswebhooks. No platform capability token was used, because trigger-api has none of its own: it is an extra oftriggers, whose token maps totrigger-record-change.ServiceSlotContractsgains 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': ITriggerApiServicebeside its registration. Theapprovalsandwebhooksprecedents did the same.The member shape:
(request, hook)returning a Promise of ResponseThe 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.
x-objectstack-signature,x-idempotency-key, and the two path parameters. ARequestcarries 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.handleActionPageandhandleRedeliver: the dispatcher forwards the request unread and returns the member'sResponseas 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 (trigger-api: the inbound-hook HMAC signs the body alone, so its signed material carries no timestamp or tolerance window (the replay-protection hardening card condition 3 of ruling 6105447950 names) #22769) arrives inside the request, so the contract does not grow fields for it.Requestin, the caller has nothing to hand over but the unread body, so it structurally cannot parse and re-serialise the body before verification. A typedrawBodywould 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./environments/:idscope on the dispatcher shape (the dispatcher strips both beforeDomainRequest.path;packages/runtime/src/domain-handler-registry.ts), and could address a different hook than the four-segment allow-list row admitted.flowNameandhookId, so moving the body, the signature or a timestamp out to the caller turns the pin red.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 thedeliveryIdrides 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. WhyhandleInboundHook:handle…is this package's spelling for aRequest-to-Responsemember (handleRequest,handleActionPage,handleRedeliver), and "inbound" keeps it apart from ObjectQL data hooks and kernel lifecycle hooks.ITriggerApiServiceandhandleInboundHookhad 0 hits before this diff, in objectstack (git grep exit 1) and in objectui at023f00d46(exit 1; controlIAuthService: 2 hits).Verification
Heavy runs went through
scripts/pm/os-verify-lock.sh, and theVERDICTline is quoted from each. The readings below were taken at the build head6c636dca6e, a merge oforigin/mainate84aeb36ce(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-1os-dev-reporton #22772.scripts/pm/os-regen-merge.shmergedorigin/main. The os-regen driver kept this branch's side of bothcontracts.jsonshards, dropping main'sIWebhookServiceline: 0 occurrences after the driver, 1 after regeneration. Regeneration on the merged tree usespnpm --filter @objectstack/spec build(VERDICT command-exit 0), thengen:api-surfaceandgen:export-origins. It restored both lines, and the net delta againstorigin/mainis exactly one added line per shard (ITriggerApiService).packages/spec/dist,handleInboundHookhas 2 hits (contracts/index.d.ts,.d.mts) and 0 in any emitted.js/.mjs/.cjs.6c636dca6e. The whole spec suite,pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2, gaveTest Files 646 passed (646),Tests 19223 passed | 1 todo (19224),VERDICT command-exit 0.6c636dca6e.pnpm --filter @objectstack/spec typecheck(tsc --noEmit,check:scripts-typecheck,check:test-typecheck) gaveVERDICT command-exit 0, withcheck:test-typecheck: OK(246 errors held, equal to the debt ledger).tsc -p tsconfig.test.json --listFileslists bothtrigger-api-service.tsandtrigger-api-service.test.ts, with 0 diagnostics in either.trigger-api-service.test.ts), exported type aliases compiled bycheck:test-typecheck:InboundHookIsOptional: the{} extends Pickform.InboundHookTakesTheRequestAndTheAddress: the parameters are exactly[Request, { readonly flowName; readonly hookId }], with no execution context.HookAddressCarriesOnlyThePathParameters: the address keys are exactlyflowNameandhookId.InboundHookAnswersAResponse: the return is a Promise ofResponse.BarrelExportsTheContract.476fd79e40throughscripts/ablation-replace.mjsin wrap mode. The subject is imported relatively, so the type program readssrcand 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.handleInboundHook?(becamehandleInboundHook(). The anchor went 1 → 0 and the blobb44b329d9b6a→f61056dc7c08. Expected red, observed red:trigger-api-service.test.ts(36,44): error TS2344(InboundHookIsOptional) and(57,15): error TS2741: Property 'handleInboundHook' is missing.readonly rawBody: string). The anchor went 1 → 0 and the blobb44b329d9b6a→d4254ab8327f. Expected red, observed red:(40,5): error TS2344(InboundHookTakesTheRequestAndTheAddress) and(44,62): error TS2344(HookAddressCarriesOnlyThePathParameters).b44b329d9b6a, equal to the HEAD blob, andgit diff HEADis empty, both times.6c636dca6e.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths; merge basee84aeb36c; 6 paths, 215 changed lines) derived 87 commands.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, andcheck-adr-0087-registration("this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)").check:doc-formula-expressionsandcheck:lean-entry-closurefirst refused with exit 3 (an unbuilt prerequisite, not a measurement). They were re-run green afterturbo run build --concurrency=2of formula, lint and objectql (Tasks: 14 successful).check:dual-build-cjs-loads. Reason: it needs a full workspace build (88 packages withoutdist/), and this diff emits no JavaScript. CI'sLint & Repo Gatesruns it.--ranreconciliation: "87 derived famil(ies) accounted for — 86 run, 1 NOT-MEASURED".minorwith the(widening)arm, which the gate reads as non-breaking, so no disposition marker is owed and none is written.Acceptance notes
trigger-apiand the member ishandleInboundHook(request, { flowName, hookId }), both named in the docblock. Segment 3 registers the slot inApiTriggerPluginand adds theServiceSlotContractsentry beside the registration. Segment 2 resolves the same key and hands over the router's decoded parameters.registerTrigger/unregisterTrigger) is not onIAutomationService. 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.dispatch-gatesartifact-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-keywordandcheck-single-claim-paths.pr_createand one report comment, and it names no label. This diff carries a changeset, soskip-changesetdoes not apply.Generated by Claude Code