Repository navigation
Commit f9e5bef
feat(trigger-api): an api hook verifies the vendor signature its start node declares (config.signature) (#30048)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7J3TmFTYMYQrfpye6GQNg9)_
Fixes #30009
Fixes #29983
## 维护者速读
- **改了什么:** `api` 触发流的 hook 现在会校验开始节点声明的厂商签名 `config.signature`(#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
`digestElement`s, 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.
<details><summary>Ablations (each mutated through
<code>scripts/ablation-replace.mjs</code>, run, restored; <code>git diff
HEAD</code> empty after each)</summary>
- **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.
</details>
## 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 #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 #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
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic
(only `variant-docs.json` moves there).
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (eslint over the changed
files, in Verification; the repo-wide run is CI's).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: no generated artifact moves
(`check:variant-docs` green).
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the `维护者速读` section is
the Chinese summary for the maintainer).
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12
<!-- signed: domain:server#6 · os-support-ai ·
session_01WGufzeeFznzXxc61VmVH12 -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 04f36b6 commit f9e5bef
16 files changed
Lines changed: 896 additions & 46 deletions
File tree
- .changeset
- content/docs/automation
- packages
- lint/src
- runtime/src/domains
- spec
- triggers/trigger-api
- src
- skills
- objectstack-api
- objectstack-automation
- references
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
575 | 575 | | |
576 | 576 | | |
577 | 577 | | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
578 | 629 | | |
579 | 630 | | |
580 | 631 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
| 148 | + | |
148 | 149 | | |
149 | 150 | | |
150 | 151 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2066 | 2066 | | |
2067 | 2067 | | |
2068 | 2068 | | |
| 2069 | + | |
| 2070 | + | |
| 2071 | + | |
| 2072 | + | |
| 2073 | + | |
| 2074 | + | |
| 2075 | + | |
| 2076 | + | |
| 2077 | + | |
| 2078 | + | |
| 2079 | + | |
| 2080 | + | |
| 2081 | + | |
| 2082 | + | |
| 2083 | + | |
| 2084 | + | |
| 2085 | + | |
| 2086 | + | |
| 2087 | + | |
| 2088 | + | |
| 2089 | + | |
| 2090 | + | |
| 2091 | + | |
| 2092 | + | |
| 2093 | + | |
| 2094 | + | |
2069 | 2095 | | |
2070 | 2096 | | |
2071 | 2097 | | |
| |||
5661 | 5687 | | |
5662 | 5688 | | |
5663 | 5689 | | |
| 5690 | + | |
5664 | 5691 | | |
5665 | 5692 | | |
5666 | 5693 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
15 | 16 | | |
16 | 17 | | |
17 | 18 | | |
| |||
30 | 31 | | |
31 | 32 | | |
32 | 33 | | |
| 34 | + | |
33 | 35 | | |
34 | 36 | | |
35 | 37 | | |
| |||
1521 | 1523 | | |
1522 | 1524 | | |
1523 | 1525 | | |
| 1526 | + | |
| 1527 | + | |
| 1528 | + | |
| 1529 | + | |
| 1530 | + | |
| 1531 | + | |
| 1532 | + | |
| 1533 | + | |
| 1534 | + | |
| 1535 | + | |
| 1536 | + | |
| 1537 | + | |
| 1538 | + | |
| 1539 | + | |
| 1540 | + | |
| 1541 | + | |
| 1542 | + | |
| 1543 | + | |
| 1544 | + | |
| 1545 | + | |
| 1546 | + | |
| 1547 | + | |
| 1548 | + | |
| 1549 | + | |
| 1550 | + | |
1524 | 1551 | | |
1525 | 1552 | | |
1526 | 1553 | | |
| |||
1753 | 1780 | | |
1754 | 1781 | | |
1755 | 1782 | | |
| 1783 | + | |
| 1784 | + | |
| 1785 | + | |
| 1786 | + | |
| 1787 | + | |
| 1788 | + | |
| 1789 | + | |
| 1790 | + | |
| 1791 | + | |
| 1792 | + | |
| 1793 | + | |
| 1794 | + | |
| 1795 | + | |
| 1796 | + | |
| 1797 | + | |
| 1798 | + | |
| 1799 | + | |
| 1800 | + | |
| 1801 | + | |
| 1802 | + | |
| 1803 | + | |
| 1804 | + | |
| 1805 | + | |
| 1806 | + | |
| 1807 | + | |
| 1808 | + | |
| 1809 | + | |
| 1810 | + | |
| 1811 | + | |
| 1812 | + | |
| 1813 | + | |
| 1814 | + | |
| 1815 | + | |
| 1816 | + | |
| 1817 | + | |
| 1818 | + | |
| 1819 | + | |
| 1820 | + | |
| 1821 | + | |
| 1822 | + | |
| 1823 | + | |
| 1824 | + | |
| 1825 | + | |
| 1826 | + | |
| 1827 | + | |
| 1828 | + | |
| 1829 | + | |
| 1830 | + | |
| 1831 | + | |
| 1832 | + | |
| 1833 | + | |
| 1834 | + | |
| 1835 | + | |
| 1836 | + | |
| 1837 | + | |
| 1838 | + | |
| 1839 | + | |
| 1840 | + | |
| 1841 | + | |
| 1842 | + | |
| 1843 | + | |
| 1844 | + | |
| 1845 | + | |
| 1846 | + | |
| 1847 | + | |
| 1848 | + | |
| 1849 | + | |
| 1850 | + | |
| 1851 | + | |
| 1852 | + | |
| 1853 | + | |
| 1854 | + | |
| 1855 | + | |
| 1856 | + | |
| 1857 | + | |
| 1858 | + | |
| 1859 | + | |
| 1860 | + | |
| 1861 | + | |
| 1862 | + | |
| 1863 | + | |
| 1864 | + | |
| 1865 | + | |
1756 | 1866 | | |
1757 | 1867 | | |
1758 | 1868 | | |
| |||
1798 | 1908 | | |
1799 | 1909 | | |
1800 | 1910 | | |
| 1911 | + | |
1801 | 1912 | | |
1802 | 1913 | | |
1803 | | - | |
| 1914 | + | |
1804 | 1915 | | |
1805 | 1916 | | |
1806 | 1917 | | |
| |||
0 commit comments