Repository navigation
fix(trigger-api,spec): one answer for every inbound hook post that does not verify - #22822
Conversation
…es not verify An unknown flow, a wrong hook id, an absent signature and a bad one now all get the same 401 INVALID_SIGNATURE status and body from handleRequest, where the first two answered 404 RESOURCE_NOT_FOUND. The cause goes to the server log as a structured field only. The secret-unavailable 503 is unchanged and named as the residual; the hook is still matched before its secret is read, so that 503 answers only a post that names an armed flow and its hook id. The ITriggerApiService.handleInboundHook docblock states the one refusal and the residual (FROM -> TO), and defers the accepted signature forms to core's verifyHttpSignature. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
The accepted signature forms are outside this change. The verification bullet keeps its merged wording and only hands a missing or wrong signature to the one unverified-post refusal. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 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 e2ab4a3970311a22e165d9bacce50106cf8aa1d3 && git checkout e2ab4a3970311a22e165d9bacce50106cf8aa1d3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b7cd1af9df7ade29e165e838503cbbef05e4299e 9f82771b0fb6b4ba81632017a1600d5c09f2ab5f && git checkout -B drift-repro b7cd1af9df7ade29e165e838503cbbef05e4299e && git merge --no-ff 9f82771b0fb6b4ba81632017a1600d5c09f2ab5f
node scripts/docs-audit/affected-docs.mjs --json b7cd1af9df7ade29e165e838503cbbef05e4299e
|
Contract reviewServed-tier: Inputs, and nothing else: card #22806 (body, comments ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged.
Nothing in the diff touches core's ② Semver level
Clause-②: yes A published door's declared refusal contract changes; the accept-set does not; ③ Boundary flagsThe status question (the dev's one
Because the diff implements A, no patch round follows from this ruling. Every other dev flag, answered:
CI convergence on the head. Read from the check-runs on Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22806
Clause-②: yes
Dispatched by the
domain:servicesseat 1 PM (claim6107768529, branchclaude/issue-22806-uniform-hook-refusal). Direction: triage6107336164. Disclosure: the card issecurity, so this body names classes and positions only.What changes
ApiTrigger.handleRequest(packages/triggers/trigger-api/src/api-trigger.ts) now gives every inbound post that does not verify the same answer: one status and one body. That covers an unknown flow, a wrong hook id, an absent signature and a bad one, whatever hook id the flow is armed with. The answer is401 INVALID_SIGNATURE, the refusal a bad signature already got.404 RESOURCE_NOT_FOUND. They now answer the same401 INVALID_SIGNATUREas an absent or bad signature.503, the residual triage kept, still answers only a post that names an armed flow and its hook id. A wrong hook id never reaches the secret read, so it gets the uniform401even while the secret is unreadable.warnline with structured fields{ cause, flowName }.causeis one ofunknown-flow,wrong-hook-id, orsignature-followed by coreverifyHttpSignature's own reason (absent,malformed,mismatch,outside-window). The line never carries the secret, the signature value or the body.503as the one residual.packages/spec, docblock only, under the cross-domain exception triage named. TheITriggerApiService.handleInboundHookdocblock now states the one refusal (FROM → TO) and names the503as its residual. Types are unchanged. The spec's contract test (trigger-api-service.test.ts) pins types only and no status text, so it does not move..changeset/22806-uniform-hook-refusal.md,minorfor@objectstack/trigger-apiand@objectstack/spec. It states FROM → TO, what each kind of sender sees, the log fields and the503residual.Measurement at the door (step 1)
These are answers from the real
handleRequest, armed throughstart()as the engine arms it. They were read onc11b75870(the base) and on this branch, unsigned and with a bad signature.c11b75870404 RESOURCE_NOT_FOUND401 INVALID_SIGNATURE401 INVALID_SIGNATURE401 INVALID_SIGNATURE404 RESOURCE_NOT_FOUND401 INVALID_SIGNATURE401 INVALID_SIGNATURE401 INVALID_SIGNATURE503 SERVICE_UNAVAILABLE503 SERVICE_UNAVAILABLE(the residual, unchanged)404 RESOURCE_NOT_FOUND401 INVALID_SIGNATURE202202On the base, the unsigned answer separated armed from unarmed names only for a flow left on the default hook id. A custom hook id already closed it, as long as that hook id stayed unknown. With this change, the answer separates nothing in either case.
Boundary flag: the status (for the contract-tier review)
The status is the reviewer's call, not this PR's. Both candidates are already declared codes. This PR implements
401 INVALID_SIGNATUREand recommends it. If the review rules for404, that is a patch round.401 INVALID_SIGNATURE(implemented)404 RESOURCE_NOT_FOUND202, no change. A sender with a wrong secret, wrong signing code, re-serialised body or skewed clock still gets401, so it looks at its signature, which is right. A sender with a wrong URL (a flow that is not armed, a rotated hook id) gets401where it got404; thewarnline namesunknown-flow/wrong-hook-id. Measured: no SDK client method builds a/automation/hooks/*URL (TRIGGER_API_ROUTE_LEDGER), so no first-party consumer reads the old404.404 No such hook, which points it at the URL when the signature is what is wrong. A sender with a wrong URL gets404, as before.@objectstack/trigger-api: INVALID_SIGNATUREstays true with no ledger edit. Pending.changeset/22769-versioned-http-signature.md("the same401 INVALID_SIGNATUREa bad signature gets") stays true.INVALID_SIGNATUREloses its only emitter. The ledger row goes stale, so the spec needs a code retirement or keeps a dead row, which goes beyond a docblock edit. Pending22769's refusal sentence becomes false in the same release.401fixes the signature, the most common integration fault. A wrong URL is the rarer case, and the log names it.404rewrites the URL while the secret is wrong: the misleading direction lands on the most common fault.Recommendation: A. On every axis it serves the common failure correctly and keeps the declared vocabulary true without touching the ledger.
Pins (all on the real
handleRequest)In
packages/triggers/trigger-api/src/api-trigger.test.ts, the block "one answer for every post that does not verify" covers 24 posts. Five addresses (two unknown, three armed, including a custom hook id posted with another hook id) are each sent unsigned, malformed, and badly signed in both forms. Four more posts carry a right secret and still must not verify: the wrong hook id, another flow's secret, and a timestamp outside the window.401/INVALID_SIGNATURE/success: false. Message prose is not pinned. Nothing is enqueued.causefield, not prose. No logged argument contains either secret, the posted signature value, or a body marker.503residual is unchanged, signed or not. A wrong hook id on that flow gets the uniform401and never reads the secret.404now assert401/INVALID_SIGNATURE: the unknown-flow-versus-wrong-hookId pin, the three refused-to-arm pins, thestop()pin (now with a valid signature), and the read-time-secret wrong-hook-id pin.Red before green: on the base source, with the pins in place, 8 failed and 19 passed (27).
Ablations (committed state
f26b2f45f, throughscripts/ablation-replace.mjs, which wraps the run and restores)404 RESOURCE_NOT_FOUNDreturn. The anchor went from 1 hit to 0, and the blob went froma920e032dcdcto16afdd2829de. Result: 8 failed, 19 passed (27), the same eight pins as the base. Restored: blob equals HEAD (a920e032dcdc), andgit diff HEADis empty.causefield was removed from the refusal line's fields (anchor 1 hit to 0, bloba920e032dcdctoa7bb3a4e349f). Result: 1 failed, 26 passed (27), the log pin. Restored: blob equals HEAD, andgit diff HEADis empty.The pins import
./api-trigger.js(source).@objectstack/coreis aliased to source in this package'svitest.config.ts, so nodist/is on the path.Verification
All of the following ran at head
9f82771b0, after the last commit. Builds, tests and gates ran underscripts/pm/os-verify-lock.sh, and each exit code was recorded to disk before any pipe.pnpm --filter '@objectstack/trigger-api^...' build, exit 0. After the docblock commit, spec, trigger-api and the packages the prerequisite gates read were rebuilt (turbo run build, exit 0).pnpm --filter @objectstack/trigger-api test: 2 files, 42 passed.pnpm --filter @objectstack/trigger-api typecheck: exit 0. Itstsconfigincludessrc/**/*, which takes in the test file.pnpm --filter @objectstack/spec test: 648 files, 19342 passed, 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0, atf26b2f45f. The only later change is a comment.pnpm --filter @objectstack/spec check:generated: "All 14 generated artifacts are up to date".node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackgave 86 commands, the same list before and after the last commit. All 86 ran with exit 0. Reconciled with--ran(each line carrying:: exit N): "86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED (a DERIVED zero — all 86 recorded an exit code and none of them is 3)".check:error-code-provenance(spec),check:error-code-casing,check-changeset-fixed.mjsandcheck:route-ledger-census.dispatch-gateslists as outside its derivation, and the repo-widepnpm lint.Acceptance notes (noted, not filed)
.changeset/22772-spec-trigger-api-inbound-hook-member.md(unreleased) still says an unknown flow or wrong hook id answers404. If both ship in one release, this PR's changeset states the FROM → TO beside it. That changeset belongs to another card and is not edited here. Carrier: whoever compiles that release's notes.handleInboundHook's docblock still names only the body-only form, while the runtime has also accepted the timestamped form since7b0a9c93c. The accepted forms are outside this dispatch, so the merged wording is kept as is. Carrier: none.warnline per refused post means anonymous traffic can add lines at request rate. That is the price of keeping the cause visible to the operator. No deduplication is added.WWW-Authenticateheader. The route has never sent one with its401, and this change keeps that.Generated by Claude Code