Skip to content

feat(trigger-api): an api hook verifies the vendor signature its start node declares (config.signature) - #30048

Draft
objectstack-fleet[bot] wants to merge 4 commits into
mainfrom
claude/project-thread-2vidml
Draft

objectstack-fleet[bot] wants to merge 4 commits into
mainfrom
claude/project-thread-2vidml

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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 改为教这个键。
  • 为什么改: hotclm 的电子签名步骤要接 DocuSign Connect 回调,合约半边已合并,但服务端没实现,厂商每次回调都得到 401,只能轮询。
  • 风险与回滚: 这是入站鉴权边界,放宽了接受集(正确签名的厂商回调由 401 变 202)。所有失败都是同一个 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 with apiTriggerVerifierOf(binding.config), the reader the engine and lint share:
    • objectstack-signature (no signature key): armed exactly as before.
    • declared: armed with the parsed scheme and the engine's resolveSecret (the config.secretRef reader) as its only key source. A declared scheme bound without that reader throws at start() and leaves no hook (the same "declines to arm" path a secretless binding takes, so a post gets the unknown-flow 401).
    • unusable: armed to refuse. Every post is refused 401 INVALID_SIGNATURE with cause declared-unusable, before any secret is read (so never the secret-unavailable 503), nothing is subscribed, and it never falls back to x-objectstack-signature.
  • serveInboundHook hands handleRequest a readHeader(name) accessor on both faces: c.req.header(name) on the self-hosted Hono mount and request.headers.get(name) on handleInboundHook. Both are Headers.get, so a repeated field arrives as its one , -joined value. A declared hook reads only its declared header through it; x-objectstack-signature is never read there.
  • verifyDeclaredSignature (new declared-signature.ts), step for step per the contract:
    • body: the whole trimmed value, after prefix when declared (absent prefix → declared-prefix-missing), is the one digest, so a body-only header sent twice does not decode.
    • timestamp.body: NAME=value elements split at the first = (base64 padding survives); exactly one timestampElement in core's t form (decimal, no sign, no leading zero, ≤ 15 digits), one or more digestElements, other elements ignored.
    • HMAC-SHA256 under the key over the body exactly as received, or <t>.<raw body> with t as received; request.text() re-encodes valid UTF-8 byte for byte, and a non-UTF-8 body mismatches (pinned).
    • Each digest decoded strictly to 32 bytes: hex is 64 hex digits; base64 is 43 standard-alphabet characters plus = and must re-encode to itself (Node's lenient decoder would otherwise skip stray characters). A digest that does not decode matches nothing.
    • timingSafeEqual on the 32-byte buffers; every digest is compared (no early exit); one match admits.
    • For timestamp.body, after the HMAC verifies, |now − t| > HTTP_SIGNATURE_TOLERANCE_SECONDS (imported from @objectstack/core, no local constant) → declared-outside-window.
  • Every failure goes through refuseUnverified with 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 one unverifiedPostAnswer(). The log line names the cause and the flow, never the key, the header value or the body (pinned).

@objectstack/lint — readiness (required). validateFlowApiTriggerSecret reads the hook's verifier through apiTriggerVerifierOf:

  • declared → ready on its config.secretRef alone; no literal or withheld secret is consulted for it.
  • unusable → the new error flow-api-trigger-signature-unusable, located at the key the spec's own judge (apiTriggerSignatureRefusals) refuses (config.signature.algorithm, .header, .timestampElement, config.signature, or config.secretRef), one verdict per hook (never beside flow-api-trigger-secret-missing), with its os explain entry. 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 real dispatch() reaches the trigger-api member as the transport's own Request with X-DocuSign-Signature-1 as 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: a config.signature section beside the secretRef paragraph (both signs forms in a table, the one-verifier rule, the one 401, the 503 while the key is unfilled, the lint id).
  • packages/spec/variant-docs.json: the signs:body|timestamp.body exemption replaced by a binding to flows.mdx (check:variant-docs green, 13 governed).
  • skills/objectstack-automation/SKILL.md (the Signature bullet; the "outside the api door'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 only x-objectstack-signature" case).

Why

Card #30009 is the domain:server slice 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 declares signature is 401. This PR closes both cards.

Tests

All vectors go through the real hook route: inbound-hook-member.test.ts boots the real ApiTriggerPlugin on a LiteKernel with a real Hono app, and every row is posted through BOTH faces (the trigger-api member 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):

  • DocuSign: the real base64 vector (literal from 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 valid x-objectstack-signature under the same key, with no header, under the wrong hook id, and a non-UTF-8 body signed over its own bytes.
  • Stripe-style: the real hex vector (openssl literal) admitted; a key roll (two v1, the new key's second, and first with an unknown v0 ignored) admitted; two v1 neither under the key refused; t at 300 s admitted, at 301 s old and 301 s ahead refused (declared-outside-window); t moved, t repeated, no t, t with a leading zero, no v1, the header sent twice, only a valid x-objectstack-signature, and a wrong declared header beside a valid x-objectstack-signature all refused.
  • GitHub-style: sha256= admitted; the digest without its prefix refused.
  • unusable (an algorithm the spec refuses; a declaration beside a literal secret): a valid x-objectstack-signature and a correctly signed declared header both refused declared-unusable, the key reader never called.
  • Every refusal's status, headers and bytes equal an unknown flow's (9 distinct causes); admitted rows enqueue once per face; refused rows enqueue nothing and log exactly their cause once per face; no log line carries a key, a header value or a body.
  • A declared key that cannot be read → 503; a declared binding without the reader → start() throws, no hook; CONTROL: an undeclared hook still verifies x-objectstack-signature and 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 the os explain facts.

Ablations (each mutated through scripts/ablation-replace.mjs, run, restored; git diff HEAD empty after each)
  • Constant-time compare (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: blob 5f754714f815 == HEAD.
  • Window check (> 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.
  • The runtime pin pins existing pass-through behaviour (no dispatcher code changed), so there is no behaviour of this diff to ablate there.

Acceptance notes

  • Clause-② yes (widening), Metadata-protocol none, all patch. The accept set at the inbound door widens (a correctly signed vendor post to a declaring hook was 401, now 202). 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 no packages/spec/src file and no authorable-surface/ baseline (only variant-docs.json, which files[] does not ship). Precedent for yes (widening) + none + patch: .changeset/29041-push-notification-channel.md.
  • The narrowings measured, and why no (narrowing) arm. (a) On main since 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 declaring signature still admitted a valid x-objectstack-signature; it no longer does, which is exactly what the merged contract states ("it never reads x-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, at os validate and at the runtime publish gate alike, so no stack or write that passed before is refused now. Widening dominates; the seat may re-grade.
  • The interim readiness item ("not ready while no trigger serves the key") has no interim to grade: trigger-api and lint are in the same Changesets fixed group and land in one PR, so no released lint judges a released trigger that does not serve the key.
  • Frozen files untouched: packages/metadata-protocol/src/protocol.ts and packages/objectql/src/engine.ts are not in the diff. The automation engine needed no change: its hookSecretResolver already resolves config.secretRef for a declared hook, and FlowSchema already refuses an unusable declaration at registration.
  • Skills readings (skills/**, Tier H): objectstack-automation/SKILL.md 452 → 452 lines (tokens 6449 → 6449, ceiling 6449); references/integration-recipes.md 183 → 183 (2595 → 2594, ceiling 2595); objectstack-api/SKILL.md 444 → 446 (headroom 1186 before). All SKILL.md in the bundle: 4539 → 4541 lines. Paid for by trimming the Signature bullet and the loader-gap clause; check-skills-token-ratchet green, no ceiling moved.
  • Security: nothing filed; the read of today's x-objectstack-signature verifier found no weakness to report.

Verification

Readings on head 763d73589 (merge base c618e890e), run in this worktree:

  • pnpm --filter @objectstack/trigger-api exec vitest run → 3 files, 62 passed; src/inbound-hook-member.test.ts alone → 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).
  • typecheck for trigger-api, lint, runtime → exit 0; eslint over the changed .ts files → exit 0.
  • check:variant-docs, doc authoring, check-skills-token-ratchet → exit 0; check-changeset-no-major and check-adr-0087-registration with 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 is pnpm check:dual-build-cjs-loads (exit 3: it needs the whole-repo build, a prerequisite this box did not build; left to CI).
  • Three gates first exited non-zero and were re-run green: 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-react was built; green after, 283 examples), and check:test-source-alias (exit 1, a real finding: trigger-api/vitest.config.ts now aliases @objectstack/spec/automation to its source with an anchored find, so the declared-signature pins judge spec's source, not its dist/; green after).

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic (only variant-docs.json moves there).
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (eslint over the changed files, in Verification; the repo-wide run is CI's).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: no generated artifact moves (check:variant-docs green).
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 section is the Chinese summary for the maintainer).
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/spec, @objectstack/trigger-api, touching 23 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/variant-docs.json, packages/triggers/trigger-api/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via timestamp.body (literal, a string literal in a comment on a changed line))
  • content/docs/data-modeling/drivers.mdx (via TIMESTAMP (symbol, a top-level const object))
  • content/docs/kernel/contracts/auth-service.mdx (via handleRequest (symbol, a method of class ApiTrigger))
  • content/docs/permissions/authentication.mdx (via handleRequest (symbol, a method of class ApiTrigger))
  • content/docs/protocol/objectql/types.mdx (via TIMESTAMP (symbol, a top-level const object))
What this run could not see
  • 3 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/variant-docs.json, packages/triggers/trigger-api/vitest.config.ts) — pages documenting those are invisible to this run
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 170 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9ebca94787540aa5c35ea58b1cebec8cf4f4f21f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f8615809215450172a9e89db55c26c5dea4250cc — the merge of head 763d7358923c5dcb761feefbfd67733239558bb9 into base 9ebca94787540aa5c35ea58b1cebec8cf4f4f21f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9ebca94787540aa5c35ea58b1cebec8cf4f4f21f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 763d7358923c5dcb761feefbfd67733239558bb9
Local-runs: none
Scope: the whole git diff origin/main...pr-30048 (16 files).

  • trigger-api: declared-signature.ts, new, read whole. api-trigger.ts: the diff, plus refuseUnverified, usableSecret, safeEqual, verifyInboundSignature and stop at head. plugin.ts: both door faces. vitest.config.ts. inbound-hook-member.test.ts.
  • Runtime: automation-hooks.test.ts.
  • Lint: validate-flow-trigger-readiness.ts and its test, rule-explanations.ts, index.ts.
  • Docs and declarations: the changeset, content/docs/automation/flows.mdx, packages/spec/variant-docs.json, and the three skills files.

On main:

  • api-trigger-signature.zod.ts, whole.
  • trigger-api-service.ts (the REPLACES item).
  • core/src/security/http-signature.ts (TIMESTAMPED_FORM, the window compare).
  • hookSecretResolver, validateApiTriggerSecret, and validate-flow-region-shape.ts (FlowSchema at lint).
  • The .changeset/config.json fixed group, and the precedent 29041-push-notification-channel.md.
  • AGENTS.md step 3, check-changeset-no-major.mjs, check-adr-0087-registration.mjs, ADR-0112.

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 401 to 202. One narrowing arm was measured: a declaring hook no longer admits a valid x-objectstack-signature. That is the contract's own REPLACES rule on a key no release carries yet. @objectstack/spec is at 18.3.0, the #29983 spec changeset is still pending, and spec, trigger-api, lint and skills publish in one fixed group, so no published consumer sees the intermediate state.

① Derived judgments

  • One verifier per hook. start() calls apiTriggerVerifierOf(cfg) once and stores a closed HookVerifier union: objectstack-signature, declared or unusable. handleRequest branches on it alone, and x-objectstack-signature is read only in the platform branch. Pinned through the real route: a valid platform signature alone on a declared hook is refused. Holds.
  • unusable admits nothing and never falls back. It is stored with no secret and refused with declared-unusable before the secret read, so it always gets the one 401, never the 503. Pinned: zero reader calls. Both a correctly signed declared header and a valid platform signature are refused. Holds.
  • A declaration without a key reader fails closed. signature with no secretRef is unusable. With a usable secretRef but no resolveSecret on the binding, start() throws and leaves no hook, so posts get the unknown-flow 401. That is the "declines to arm" option the contract permits. It is reachable only by a host that binds outside the engine. Fail-closed and consistent.
  • Constant-time compare holds. decodeDigest returns only a 32-byte Buffer or undefined: hex needs exactly 64 digits, and base64 needs 43 standard characters plus = with a canonical re-encode. So timingSafeEqual only ever sees two 32-byte buffers and cannot throw or branch on length. anyDigestMatches checks every digest with no early exit. Decode refusals depend only on text the sender can already see.
  • The header parse follows the contract.
    • Body arm: the prefix is checked and stripped first, and exactly one digest is read. A doubled body-only header fails decode, pinned for both DocuSign and Stripe.
    • Timestamped arm: elements are split on , and each at its first =. There must be exactly one t matching core's pattern, and one or more digest elements. A key roll is pinned in both orders. Unknown elements are ignored, a repeated or missing t is malformed, and no digest gives declared-no-digest.
  • The signed payload is exact. The HMAC-SHA256 runs over rawBody (request.text(), read once on both faces), or over `${t}.${rawBody}`, keyed with utf-8 as core's signer does. A non-UTF-8 body is pinned to mismatch.
  • The window comes from core. The check is Math.abs(now - t) > HTTP_SIGNATURE_TOLERANCE_SECONDS, imported from @objectstack/core, with the same comparison and the same order as core's verifier. Pinned: 300 s is inside, 301 s old and 301 s ahead are outside. There is no local constant.
  • Both faces read the header the same way. The member face uses Headers.get and the mount face uses Hono's c.req.header, which is Headers.get. Every row is posted through both faces of one booted plugin and asserts equal status, headers and bytes. The repeated-header rows prove the comma-joined value. The runtime pin sends a DocuSign post through the real dispatch().
  • One answer, a distinct cause, no leak. Every refusal goes through refuseUnverified and gets a fresh unverifiedPostAnswer(). Nine distinct causes are pinned byte-equal to the unknown-flow answer. Log lines carry { cause, flowName } and never the key, the header value or the body.
  • Replay and the platform path. Body-arm schemes have no window, as the contract states and flows.mdx says plainly. x-idempotency-key is read after verification and only feeds the dedup window. A hook with no signature takes the old code unchanged, pinned by the CONTROL row.
  • The readiness lint uses the same reader. declared is ready on secretRef alone. unusable gets the new error flow-api-trigger-signature-unusable, which is registered in the never-fire family, the severity map and os explain. It is not a narrowing: validateFlowRegionShape already refuses every unusable shape through FlowSchema (since feat(spec): an api trigger declares a third-party sender's signature scheme (config.signature), keyed by secretRef #29995), so the new id only adds a better-located error on a stack that is already refused.
  • The pins cover the card checklist. Every vector runs through the real route: DocuSign base64 admitted, Stripe hex admitted with a key roll and an outside-window refusal, the platform signature refused on a declared hook, a missing header, a body-only header sent twice, identical failure bodies, unusable, and HttpDispatcher keeping the vendor header. Two ablations went red and were restored: the compare swapped for a length compare (2 red), and the window flipped to < 0 (2 red).
  • The Skills-docs owed by feat(spec): an api trigger declares a third-party sender's signature scheme (config.signature), keyed by secretRef #29995 are written. That covers the automation SKILL.md, integration-recipes recipe 4, the api SKILL.md and the flows.mdx config.signature section. The variant-docs.json exemption is replaced by a docs binding. trigger-api adds no new published export.

② Semver level

@objectstack/trigger-api, @objectstack/lint and @objectstack/skills are patch, with Clause-②: yes (widening) and Metadata-protocol: none. That is correct under AGENTS.md step 3. No authorable schema moves, since the spec half landed in #29995 and no packages/spec/src file is in this diff, and a non-protocol widening ships patch. The precedent 29041-push-notification-channel.md has the same shape. No (narrowing) arm is owed: REPLACES applies to a key born in this same release, and the lint id refuses only what FlowSchema already refuses. So there is no BREAKING banner and no ADR-0087 marker. ADR-0112 does not cover lint rule ids, and INVALID_SIGNATURE is unchanged.

③ Boundary flags

  • The body has Fixes #30009 and Fixes #29983, there were 0 MCP writes, and the frozen files are untouched. Tier H (by skills/** and the auth-boundary floor) means maintainer approval before the queue.
  • The spec contract is unchanged, and no ambiguity needed escalating.
  • At review, Build Core, Build & E2E UI, Test Core 1–8, Test UI 1–16, Type Check UI and Type Check · workspace were still in progress, with no failure. The seat lands only on all-green.

④ Security face

  • The door fails closed on every enumerated path: unknown flow, wrong hook id, unusable declaration, unreadable key (503, the contract's one residual), header absent, prefix missing, malformed, no digest, mismatch, outside window. A declared hook bound without a key reader is never armed.
  • The review found no weakness in the pre-existing x-objectstack-signature path.
  • Nit, not a boundary property: listHooks() labels an unusable hook signed: true.

Implemented-by: claude/project-thread-2vidml
Reviewed-by: session_01WGufzibcN9oHa8kDbTTnL5

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5

席位核对

Verdict: ACCEPT at head 763d7358923c5dcb761feefbfd67733239558bb9 · 2026-11-04T10:53Z

  • Shape: trigger-api now verifies a flow's declared vendor signature (config.signature), using one verifier per hook chosen at start() by apiTriggerVerifierOf.
    • The verifier does HMAC-SHA256 over the exact body bytes, or over t.body, with a strict hex or base64 32-byte decode and timingSafeEqual.
    • On a timestamped scheme it takes the window from core's HTTP_SIGNATURE_TOLERANCE_SECONDS.
    • Every failure gets the one 401 INVALID_SIGNATURE. unusable admits nothing.
    • The declared header reaches the verifier on both faces through Headers.get.
    • The readiness lint grades the declared verifier, and gives unusable its own error.
    • The skills and docs that feat(spec): an api trigger declares a third-party sender's signature scheme (config.signature), keyed by secretRef #29995 owed are written.
  • Review: CONTRACT_REVIEW_TIER security face, PASS at this head (record above).
  • Premise measured: the contract (feat(spec): an api trigger declares a third-party sender's signature scheme (config.signature), keyed by secretRef #29995) was merged and served by no trigger. trigger-api 62/62 (inbound-hook-member 18/18 through both faces), lint 7981, runtime 8615, typecheck and eslint 0. 101 of 102 gates ran with exit 0; the one NOT-MEASURED (check:dual-build-cjs-loads) is measured by CI. Two ablations went red: the compare swapped for a length compare, and the window flipped. Both were restored.
  • Declaration: Clause-②: yes (widening), Metadata-protocol: none, patch on trigger-api, lint and skills. Reviewed as correct.
  • Accepted deviations:
    • The harness-pinned branch claude/project-thread-2vidml.
    • A nit: listHooks() labels an unusable hook signed: true, which is diagnostics only.
    • Carried as follow-ups: a Studio inspector for secretRef / config.signature, and a QA checklist arm for the REPLACES rule.
  • Landing: this is an auth boundary and Tier H, so the maintainer's approval comes first. After it, on all-green at this head, the seat sends pr_ready + automerge_enable through the relay. This PR also closes An api-triggered flow can verify only an ObjectStack signature: a vendor webhook signed its own way (DocuSign Connect HMAC) cannot be admitted #29983.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant