Repository navigation
feat(trigger-api)!: the inbound hook door refuses the body-only signature form, so only the timestamped form verifies (ruling B on #22805, step 3) - #22887
Conversation
…ure form A post whose x-objectstack-signature is in the body-only form now gets the same 401 INVALID_SIGNATURE every unverified post gets, logged with the cause signature-body-only; the deprecation line goes with the branch. The judgement lives in one function both handleRequest and the exported verifySignature call, so the export agrees with the door. Core's verifyHttpSignature keeps both forms: it is the receiver half of the outbound body-only form. The ITriggerApiService.handleInboundHook docblock names the timestamped form as the only one accepted. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
…ws that accepted it The body-only signature, right for the secret, now answers the uniform 401 with its own logged cause and enqueues nothing, through the trigger, the uniform-refusal table and the member's hosted-shape arm table (the parity table follows it). Valid posts in the older describes sign the timestamped form. Controls beside them: the timestamped form inside the window is accepted, outside it is refused, and core's outbound signHttpBody output is unchanged. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
…amped form The dogfood credential-channel suite drives the real ApiTrigger and signed every post body-only, so each 202 it expects would now be a 401. The runtime dispatcher suite's member double carries the trigger's verifier; it now accepts the timestamped form only, as the door does, and its posts sign that form. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
… door Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 5252602bcd959d3d08f9e2fde0e45429bd0f7257 && git checkout 5252602bcd959d3d08f9e2fde0e45429bd0f7257
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388 78b92702d35fe6ca924a4c962d8c39190d009a11 && git checkout -B drift-repro 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388 && git merge --no-ff 78b92702d35fe6ca924a4c962d8c39190d009a11
node scripts/docs-audit/affected-docs.mjs --json 40bc51f3f948ddabaae2e6cdd35e98d5a0a69388
|
Contract reviewServed-tier: Rendered by an isolated reviewer subagent of the seat in Inputs, read 2026-10-11T21:19Z: card #22826 (body and all five comments: triage Check-runs on the head, read 2026-10-11T21:19Z: 39 runs, all completed: 33 success, 6 skipped, 0 failed. Latest per name: 30 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke: path or opt-in skips). All seven required contexts are ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22826
Clause-②: no (narrowing)
Step 3 of ruling B on #22805 (record
6108492902, maintainer 「同意」), at the position triage set (6108760218): the trigger-api inbound hook door refuses a verdict whoseschemeisbody-only. Core'sverifyHttpSignaturekeeps both forms, because it is the receiver half of the body-only form our own outbound senders sign.What changes
packages/triggers/trigger-api/src/api-trigger.ts). One private function,verifyInboundSignature, makes the door's signature judgement: core's verdict, then an exhaustive switch over the verifiedscheme.v1is accepted.body-onlyis refused assignature-body-only.handleRequestand the exportedverifySignatureboth call it, so the export answers what the door answers. The refusal runs right after verification, before the JSON parse and the enqueue, through the existingrefuseUnverified. So a body-only post gets the uniform401 INVALID_SIGNATUREbuilt by PR fix(trigger-api,spec): one answer for every inbound hook post that does not verify #22822, and nothing is enqueued, even when the value is the secret's HMAC of the body.bodyOnlyDeprecationLoggedand its initialiser.signature-body-only, in the same voice assignature-outside-window. Thesignature-malformedtext no longer says "neither accepted form", because one form is accepted now.ITriggerApiService.handleInboundHookdocblock (packages/spec) names the timestamped form as the only one accepted and the body-only form as refused. This is the relay6108557878on [PM seat] domain:spec — 🟢 os-warren · session_019G5ZnRrTMiwcHG6iZM19zf #6017. The type is unchanged..changeset/22826-refuse-body-only-signature.md.@objectstack/trigger-apiismajor: a breaking narrowing on the v18 line innextpre mode, the grade 22502/22565/22572 used.@objectstack/specispatch: the docblock reachesdist/contracts/index.d.ts. The changeset has the BREAKING banner and FROM → TO. Its ADR-0087 disposition isnot-required (no-migration-prescription): no metadata moves, and what narrows is a value a third-party sender computes.⛔ Not touched:
packages/core(verifyHttpSignature,signHttpBody), the outbound senders,webhooks.mdx§6, ADR-0138 (its D7 sentence waits for that ADR's next revision, per the ruling),content/docs/releases/.Pins beside the door (done-when, one by one)
api-trigger.test.ts"refuses a body-only signature that IS the secret's HMAC of the body". It first asserts core verifies the value as{ valid: true, scheme: 'body-only' }, so the refusal is the door's policy and not a broken fixture. It then asserts the full envelope, an empty queue, no flow run, and exactly onewarnper post, which is the refusal with causesignature-body-only.SIGNED_BUT_UNVERIFIEDrows, under the default hook id and a custom one. They feed "every unverified post gets the same status and the same envelope" and "the server log records the real cause of each refusal".inbound-hook-member.test.ts: the arm-table row202becomes401 INVALID_SIGNATURE, published false. The member/mount parity table compares the same row.signHttpBodyoutput unchangedsignHttpBodystill writes the body-only value byte-for-byte as the published recipe, and core still verifies it.warnper post" assertion above (ablation C below).verifySignatureagrees with the doorverifySignaturetests assertfalsefor the right secret's body-only value.Ablation: every negative pin, red, then restored
Each mutation went through
scripts/ablation-replace.mjs(wrap mode, anchor hit 1 → 0, blob changed) inside a wrapper that has its owntrap … EXIT INT TERMrestore. Each was restored withgit checkout HEAD -- ABSOLUTE_PATHand proven by blob equality withHEAD:path(aff2541dd93b) and an emptygit diff HEAD. The trigger-api suites import./api-trigger.js/./plugin.jsrelatively, straight from source, so no build ordist/leg applies. The dogfood suite aliases@objectstack/trigger-apito source.return { valid: true };forbody-only). 7 failed / 48 passed (55): bothverifySignaturetests ("expected true to be false"), the refusal test ("first post: expected { status: 202 … } to deeply equal { status: 401 … }"), both uniform-table tests ("the right secret, in the body-only form: expected { status: 202 … }" and "expected [] to deeply equal [ 'signature-body-only' ]"), the member arm table ("expected 202 to be 401"), and parity ("the mount's own answer: expected 202 to be 401"). Restored: OK.signature-mismatch). 2 failed / 53 passed: "expected [ 'signature-mismatch' ] to deeply equal [ 'signature-body-only' ]" in the refusal test and the cause-log test. The envelope pins stay green, as they should. Restored: OK.warnput back beside the refusal. 1 failed / 54 passed: "expected "vi.fn()" to be called 1 times, but got 2 times". Restored: OK.ApiTrigger. Restored: OK. This is why the suite's signer flips (below).Whole-repo pin sweep
Grepped for the signature header,
sha256=,signHttpBody,verifyHttpSignature,INVALID_SIGNATURE, the hook path andApiTriggeracross the repo.api-trigger.test.ts: the two "body-only still accepted" tests, the "both forms" control and the twoverifySignaturetests. Every valid post in the older describes now signs the timestamped form, and the duplicate localsigV1helpers became one file-level pair.inbound-hook-member.test.ts: the body-only202row.packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts: its signer was body-only against the real door, so every202it expects would be401; it now signs the timestamped form.packages/runtime/src/domains/automation-hooks.test.ts: the member double that "carries the trigger's verifier" now acceptsv1only, as the door does, and its posts signv1. It is outside the claim's declared surface; it is here because ruling B's sweep covers the whole repo, and the change is the double's fidelity, not a new pin.sha256=value carried with at=prefix (refused, no fallback), body-only under another secret (signature-mismatch),sha256=zz, and the503rows signed with a body-only value under a wrong secret.content/docs/**(outsidereleases/andreferences/), the showcase, the QA checklist or the skill tells a sender the inbound door accepts the body-only form. inbound webhook guidance: every place that teaches a sender teaches the timestampedt=…,v1=…signature only (ruling B on #22805, step 1) #22825 had already rewritten them. Nothing to correct.Measured against the dispatch's mechanism assumptions
verifyHttpSignaturereturns{ valid: true, scheme: 'v1' | 'body-only' }, andhandleRequestread it at the deprecation branch. No core change was needed.unknown-flow,wrong-hook-idand foursignature-*). Adding exactly one makes seven, five of themsignature-*. "The four-cause log line" is read as "the cause log line pins the new cause", and it does.Verification at
78b92702dpnpm --filter @objectstack/trigger-api exec vitest run --maxWorkers=2: 3 files, 55 passed.pnpm --filter @objectstack/trigger-api typecheck: exit 0 (the tsconfig includessrc/**/*, tests included).pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/automation-hooks.test.ts: 19 passed.pnpm --filter @objectstack/runtime typecheck: exit 0, test layer held in its ledger.pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/flow-credential-channel.dogfood.test.ts: 10 passed.pnpm --filter @objectstack/dogfood typecheck: exit 0.pnpm --filter @objectstack/spec exec vitest run src/contracts/trigger-api-service.test.ts: 1 passed.pnpm --filter @objectstack/spec typecheck: exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived on this change: 87 commands. That is a superset of the dispatch's 78; the nine extra ones are the changeset gates,check:route-envelopeand two release self-tests. All 87 ran, pluspnpm --filter @objectstack/spec check:generated("All 14 generated artifacts are up to date"), and every one exited 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: eight unrelated packages had nodist/). Those packages were restored from turbo's cache, and the re-run exited 0: "107 published require entry point(s) across 66 package(s) load".dispatch-gates --ran, given every command with its exit code: "87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED (a DERIVED zero — all 87 recorded an exit code and none of them is 3)".Declared narrowing: for
@objectstack/specthe change is TSDoc text in one interface, so its full suite is declared to CI and only the contract's own test, the typecheck and the derived spec gates ran here. For@objectstack/runtimeand@objectstack/dogfoodonly the edited test files ran, with each package's typecheck. Their full suites (the dogfood gate included) are CI's.Acceptance notes
packages/core/src/security/http-signature.ts's docblock ("so the body-only form stays accepted; refusing it is a decision of its own, not this definition's") stays true: the decision is now taken, at the door.CONTRACT_REVIEW_TIER) for the describe-only spec text and the breaking narrowing is the owning seat's, after this PR.Generated by Claude Code