Repository navigation
feat(trigger-api): an api hook verifies the vendor signature its start node declares (config.signature) - #30048
objectstack-fleet[bot] wants to merge 4 commits into
Conversation
…signature) WIP: the verifier, the one-verifier-per-hook arming in start(), and the declared header read on both faces of the inbound-hook door. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12
…hrough the real door; readiness reads the hook's one verifier Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12
…d callback was said to be refused; bind the signs union to flows.mdx Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12
…the package's tests Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12
📓 Docs Drift CheckThis PR changes 3 package(s): 5 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 — 170 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 f8615809215450172a9e89db55c26c5dea4250cc && git checkout f8615809215450172a9e89db55c26c5dea4250cc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9ebca94787540aa5c35ea58b1cebec8cf4f4f21f 763d7358923c5dcb761feefbfd67733239558bb9 && git checkout -B drift-repro 9ebca94787540aa5c35ea58b1cebec8cf4f4f21f && git merge --no-ff 763d7358923c5dcb761feefbfd67733239558bb9
node scripts/docs-audit/affected-docs.mjs --json 9ebca94787540aa5c35ea58b1cebec8cf4f4f21f
|
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 Contract reviewServed-tier:
On main:
Cards and reports: #30009 and its comments, #29983, PR #29995 with records 6619711618 / 6620065679 / 6620688559, the seat order, the os-dev report 6628648600, and the head's check-runs. Face: an inbound authentication boundary, served as the contract wrote it. The widening dominates: a correctly signed vendor post to a declaring hook goes from ① Derived judgments
② Semver level
③ Boundary flags
④ Security face
Implemented-by: VERDICT: PASS |
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 席位核对Verdict: ACCEPT at head
|
Requested by Yibo Wang · project thread
Fixes #30009
Fixes #29983
维护者速读
api触发流的 hook 现在会校验开始节点声明的厂商签名config.signature(feat(spec): an api trigger declares a third-party sender's signature scheme (config.signature), keyed by secretRef #29995 落地的合约):DocuSign Connect 的 base64 body 签名、Stripe 式t=…,v1=…、GitHub 式sha256=都能被接收。声明后它取代x-objectstack-signature(每个 hook 只有一个校验器),密钥只走config.secretRef,常数时间比较,时间戳方案用 core 的 300 秒窗口。声明不可用的 hook 一律 401,绝不回退平台签名。lint 新增flow-api-trigger-signature-unusable;skills 与 flows.mdx 改为教这个键。401 INVALID_SIGNATURE,原因只进服务器日志,不含密钥、签名值或 body。未声明signature的 hook 行为不变。回滚 = revert 本 PR(回滚后声明了签名的 hook 回到全部 401,仍是 fail closed)。packages/triggers/trigger-api/src/declared-signature.ts(解析、严格解码到 32 字节、timingSafeEqual、窗口);packages/triggers/trigger-api/src/api-trigger.ts的start()与handleRequest(单一校验器、unusable在读密钥之前就拒绝);packages/triggers/trigger-api/src/inbound-hook-member.test.ts的[#30009]段(经真实 hook 路由、两个入口逐字节一致)。pnpm --filter @objectstack/trigger-api exec vitest run src/inbound-hook-member.test.ts,预期18 passed(其中 6 个是[#30009])。skills/**,Tier H):请审批后再进合并队列。席位会先跑一轮安全面契约复审。Clause-②: yes (widening)
Metadata-protocol: none
What changes
@objectstack/trigger-api— the verifier the contract owes (contracts/trigger-api-service.ts, "A sender's own scheme, declared, REPLACES that check";automation/api-trigger-signature.zod.ts). The spec contract is not changed.start()reads the hook's ONE verifier withapiTriggerVerifierOf(binding.config), the reader the engine and lint share:objectstack-signature(nosignaturekey): armed exactly as before.declared: armed with the parsed scheme and the engine'sresolveSecret(theconfig.secretRefreader) as its only key source. A declared scheme bound without that reader throws atstart()and leaves no hook (the same "declines to arm" path a secretless binding takes, so a post gets the unknown-flow401).unusable: armed to refuse. Every post is refused401 INVALID_SIGNATUREwith causedeclared-unusable, before any secret is read (so never the secret-unavailable503), nothing is subscribed, and it never falls back tox-objectstack-signature.serveInboundHookhandshandleRequestareadHeader(name)accessor on both faces:c.req.header(name)on the self-hosted Hono mount andrequest.headers.get(name)onhandleInboundHook. Both areHeaders.get, so a repeated field arrives as its one,-joined value. A declared hook reads only its declared header through it;x-objectstack-signatureis never read there.verifyDeclaredSignature(newdeclared-signature.ts), step for step per the contract:body: the whole trimmed value, afterprefixwhen declared (absent prefix →declared-prefix-missing), is the one digest, so a body-only header sent twice does not decode.timestamp.body:NAME=valueelements split at the first=(base64 padding survives); exactly onetimestampElementin core'stform (decimal, no sign, no leading zero, ≤ 15 digits), one or moredigestElements, other elements ignored.<t>.<raw body>withtas received;request.text()re-encodes valid UTF-8 byte for byte, and a non-UTF-8 body mismatches (pinned).=and must re-encode to itself (Node's lenient decoder would otherwise skip stray characters). A digest that does not decode matches nothing.timingSafeEqualon the 32-byte buffers; every digest is compared (no early exit); one match admits.timestamp.body, after the HMAC verifies,|now − t| > HTTP_SIGNATURE_TOLERANCE_SECONDS(imported from@objectstack/core, no local constant) →declared-outside-window.refuseUnverifiedwith its own logged cause (declared-header-absent,declared-header-malformed,declared-prefix-missing,declared-no-digest,declared-mismatch,declared-outside-window,declared-unusable) and the oneunverifiedPostAnswer(). The log line names the cause and the flow, never the key, the header value or the body (pinned).@objectstack/lint— readiness (required).validateFlowApiTriggerSecretreads the hook's verifier throughapiTriggerVerifierOf:declared→ ready on itsconfig.secretRefalone; no literal or withheld secret is consulted for it.unusable→ the new errorflow-api-trigger-signature-unusable, located at the key the spec's own judge (apiTriggerSignatureRefusals) refuses (config.signature.algorithm,.header,.timestampElement,config.signature, orconfig.secretRef), one verdict per hook (never besideflow-api-trigger-secret-missing), with itsos explainentry. It is in the never-fire family (one-line verdict ≤ 200 chars, pinned with the family).objectstack-signature→ the existing judgement, unchanged.Runtime
HttpDispatcher— one pin, no code change: a DocuSign-signed post through the realdispatch()reaches thetrigger-apimember as the transport's ownRequestwithX-DocuSign-Signature-1as sent, a repeated field as its one joined value, and verifies.Skills and docs (the
Skills-docs:follow-up #29995 owed):content/docs/automation/flows.mdx: aconfig.signaturesection beside thesecretRefparagraph (bothsignsforms in a table, the one-verifier rule, the one401, the503while the key is unfilled, the lint id).packages/spec/variant-docs.json: thesigns:body|timestamp.bodyexemption replaced by a binding to flows.mdx (check:variant-docsgreen, 13 governed).skills/objectstack-automation/SKILL.md(the Signature bullet; the "outside theapidoor's HMAC" clause),skills/objectstack-automation/references/integration-recipes.md(recipe 4's "cannot post here today"),skills/objectstack-api/SKILL.md(the "the hook checks onlyx-objectstack-signature" case).Why
Card #30009 is the
domain:serverslice of #29983 (hotclm's DocuSign Connect envelope-status callback). The contract landed in #29995; until a trigger serves it, every vendor post to a hook that declaressignatureis401. This PR closes both cards.Tests
All vectors go through the real hook route:
inbound-hook-member.test.tsboots the realApiTriggerPluginon aLiteKernelwith a real Hono app, and every row is posted through BOTH faces (thetrigger-apimember and the self-hosted mount), whose answers must be equal byte for byte. New top-level describe[#30009]in that shared harness (6 cases, 34 rows):openssl dgst -sha256 -hmac … -binary | base64) admitted, and in another header-name case; refused over another body, under another key, written in hex, padding dropped, header sent twice, without the declared header but with a validx-objectstack-signatureunder the same key, with no header, under the wrong hook id, and a non-UTF-8 body signed over its own bytes.v1, the new key's second, and first with an unknownv0ignored) admitted; twov1neither under the key refused;tat 300 s admitted, at 301 s old and 301 s ahead refused (declared-outside-window);tmoved,trepeated, not,twith a leading zero, nov1, the header sent twice, only a validx-objectstack-signature, and a wrong declared header beside a validx-objectstack-signatureall refused.sha256=admitted; the digest without its prefix refused.unusable(an algorithm the spec refuses; a declaration beside a literalsecret): a validx-objectstack-signatureand a correctly signed declared header both refuseddeclared-unusable, the key reader never called.503; a declared binding without the reader →start()throws, no hook; CONTROL: an undeclared hook still verifiesx-objectstack-signatureand ignores a vendor header.automation-hooks.test.ts(runtime): one[#30009]describe, the header pass-through pin above.validate-flow-trigger-readiness.test.ts(lint): one[#30009]describe (usable on both arms ready at the CLI and the runtime publish gate; seven unusable shapes each one error at its key and refused at the gate; a withheld-and-stored literal does not make a declared hook ready; controls), plus the new id in the severity map, the never-fire one-line family and theos explainfacts.Ablations (each mutated through
scripts/ablation-replace.mjs, run, restored;git diff HEADempty after each)if (timingSafeEqual(expected, digest)) matched = true;→if (expected.length === digest.length) matched = true;): 2 of 18 red —DocuSign: the vector over another body: expected 202 to be 401. Restored: blob5f754714f815== HEAD.> HTTP_SIGNATURE_TOLERANCE_SECONDS→< 0): 2 of 18 red —Stripe-style: correctly signed, t outside the window (301 s old): expected 202 to be 401. Restored: blob == HEAD.Acceptance notes
yes (widening), Metadata-protocolnone, allpatch. The accept set at the inbound door widens (a correctly signed vendor post to a declaring hook was401, now202). No authorable schema moves: the spec half landed in feat(spec): an api trigger declares a third-party sender's signature scheme (config.signature), keyed by secretRef #29995, and this diff touches nopackages/spec/srcfile and noauthorable-surface/baseline (onlyvariant-docs.json, whichfiles[]does not ship). Precedent foryes (widening)+none+patch:.changeset/29041-push-notification-channel.md.(narrowing)arm. (a) Onmainsince feat(spec): an api trigger declares a third-party sender's signature scheme (config.signature), keyed by secretRef #29995 (≈6 h, unreleased: 18.3.0 shipped before it), a hook declaringsignaturestill admitted a validx-objectstack-signature; it no longer does, which is exactly what the merged contract states ("it never readsx-objectstack-signature"), on a key no release has carried. (b) The lint's new id fires only on a declaration the flow parse (FlowSchema→apiTriggerSignatureRefusals) already refuses, atos validateand at the runtime publish gate alike, so no stack or write that passed before is refused now. Widening dominates; the seat may re-grade.fixedgroup and land in one PR, so no released lint judges a released trigger that does not serve the key.packages/metadata-protocol/src/protocol.tsandpackages/objectql/src/engine.tsare not in the diff. The automation engine needed no change: itshookSecretResolveralready resolvesconfig.secretReffor a declared hook, andFlowSchemaalready refuses an unusable declaration at registration.skills/**, Tier H):objectstack-automation/SKILL.md452 → 452 lines (tokens 6449 → 6449, ceiling 6449);references/integration-recipes.md183 → 183 (2595 → 2594, ceiling 2595);objectstack-api/SKILL.md444 → 446 (headroom 1186 before). AllSKILL.mdin the bundle: 4539 → 4541 lines. Paid for by trimming the Signature bullet and the loader-gap clause;check-skills-token-ratchetgreen, no ceiling moved.x-objectstack-signatureverifier found no weakness to report.Verification
Readings on head
763d73589(merge basec618e890e), run in this worktree:pnpm --filter @objectstack/trigger-api exec vitest run→ 3 files, 62 passed;src/inbound-hook-member.test.tsalone → 18 passed (6[#30009]).pnpm --filter @objectstack/lint exec vitest run→ 193 files, 7981 passed.pnpm --filter @objectstack/runtime exec vitest run→ 540 files, 8615 passed, 19 skipped (pre-existing skips).typecheckfor trigger-api, lint, runtime → exit 0; eslint over the changed.tsfiles → exit 0.check:variant-docs, doc authoring,check-skills-token-ratchet→ exit 0;check-changeset-no-majorandcheck-adr-0087-registrationwith a PR event file → exit 0.node scripts/pm/dispatch-gates.mjs --ran→ 102 derived, 101 run (all exit 0), 1 NOT-MEASURED, 0 UNRUN. The one NOT-MEASURED ispnpm check:dual-build-cjs-loads(exit 3: it needs the whole-repo build, a prerequisite this box did not build; left to CI).check-plugin-teardown-shape --self-test(exit 3 on the shallow clone; green after fetching its pinned commit),check:skill-examples(exit 3 before@objectstack/client-reactwas built; green after, 283 examples), andcheck:test-source-alias(exit 1, a real finding:trigger-api/vitest.config.tsnow aliases@objectstack/spec/automationto its source with an anchoredfind, so the declared-signature pins judge spec's source, not itsdist/; green after).Human Minimum
packages/specholds no runtime logic (onlyvariant-docs.jsonmoves there).pnpm lintpasses (eslint over the changed files, in Verification; the repo-wide run is CI's).main, one pull request.packages/spectouched: no generated artifact moves (check:variant-docsgreen).content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.维护者速读section is the Chinese summary for the maintainer).🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12