Skip to content

Commit f9e5bef

Browse files
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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/trigger-api': patch
3+
'@objectstack/lint': patch
4+
'@objectstack/skills': patch
5+
---
6+
7+
feat(trigger-api): an `api` hook verifies the vendor signature its start node declares (`config.signature`)
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **`@objectstack/trigger-api`.** `start()` reads the hook's one verifier with `apiTriggerVerifierOf(binding.config)` (`@objectstack/spec/automation`). A start node that declares its sender's scheme (`config.signature`: DocuSign Connect's base64 `X-DocuSign-Signature-1`, a Stripe-style `t=…,v1=…`, a GitHub-style `sha256=`) now admits a correctly signed post that was answered `401` before. That scheme REPLACES the `x-objectstack-signature` check for its hook: the declared header is read on both faces of the door (the self-hosted mount and `handleInboundHook`) with `Headers.get`, so a repeated field is its one joined value; the HMAC-SHA256 is computed under the key `config.secretRef` names (read at every post through the engine's reader, `503` while it has no value), over the raw body or `<t>.<raw body>`; each digest is decoded strictly to 32 bytes and compared with `timingSafeEqual`, any one of several `v1` elements matching (a key roll); and a timestamped scheme is held to `HTTP_SIGNATURE_TOLERANCE_SECONDS` from `@objectstack/core`. A body-only scheme has no replay window, as the contract states. A valid `x-objectstack-signature` admits nothing on such a hook, and a post without the declared header is refused. A declaration the spec answers `unusable` arms a hook that refuses every post before any secret is read and never falls back to `x-objectstack-signature`; a declared scheme bound without the engine's `config.secretRef` reader is refused at `start()`. Every failure is the same `401 INVALID_SIGNATURE` with its own cause in the server log (`declared-header-absent`, `declared-header-malformed`, `declared-prefix-missing`, `declared-no-digest`, `declared-mismatch`, `declared-outside-window`, `declared-unusable`), never the key, the header value or the body. A hook that declares no scheme is verified exactly as before.
13+
- **`@objectstack/lint`.** `validateFlowApiTriggerSecret` grades an `api` hook by that same reader: a usable declaration is ready on its `config.secretRef` alone, and an unusable one is the new error `flow-api-trigger-signature-unusable` (with its `os explain` entry), located at the key the spec refuses. It fires only on a declaration the flow parse already refuses, so no stack that passed before is refused now.
14+
- **`@objectstack/skills`.** The automation and api skills teach `config.signature` where they said a vendor-signed callback cannot be admitted.

‎content/docs/automation/flows.mdx‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,57 @@ would send.
575575
A flow definition, including an `http` node's `url` and `headers`, is served to every member who can read flows. A token, API key or signed webhook url written there is readable by all of them. Route an outbound credential by where it sits, and call the connector with a `connector_action` node instead — see [Connectors](/docs/automation/connectors#authentication). A credential in a header goes to a declarative connector with `bearer`, `basic` or `api-key` auth, whose `auth.credentialRef` names the secret. A key in the query string goes to `api-key` auth with `paramName`. No `credentialRef` variant carries a secret in the url path, so an incoming-webhook url (whose path is the secret) cannot be routed that way: call the service through a token-authenticated connector instead, such as the `slack` connector, whose bot token is supplied to the plugin by host code rather than written in the flow (it is registered by that plugin, not declared as a `connectors:` instance). Otherwise such a url is served with the definition. Only `signingSecret` and a start node's `secret` are kept out of a flow saved through the metadata API: the metadata save door stores each in a write-only, encrypted flow credential store, a read never returns it, and the engine reads it only to verify an inbound post or sign an outbound request. A flow that ships in a package names its inbound hook secret instead of carrying it: the start node's `config.secretRef` holds the name the deployment fills (an environment variable of that name in the open tier, resolved through the same credential resolver as a connector's `auth.credentialRef`), and the engine reads it at every post, so no credential is published with the package. A post that arrives while the name has no value is answered `503`, never verified. The reference is a name, not a credential: it is served as written, it may not sit beside a literal `secret`, and it may not name a variable in the platform's `OS_` namespace. A packaged flow that carries a literal keeps it in its package source; the literal is withheld when the definition is served, and a credential store row for that flow, where one exists, wins when the engine verifies or signs. `url` and `headers` are stored and served as written.
576576
</Callout>
577577

578+
#### An inbound hook that verifies its sender's own signature — `config.signature`
579+
580+
By default an `api` flow's hook admits a post only by ObjectStack's own
581+
`x-objectstack-signature`. A vendor that signs its callbacks its own way
582+
(DocuSign Connect's `X-DocuSign-Signature-1`, a Stripe-style `t=…,v1=…`
583+
header, a GitHub-style `sha256=` header) is admitted by declaring how it signs
584+
on the start node, as `config.signature`, beside the `config.secretRef` that
585+
names the key:
586+
587+
```ts
588+
export const EnvelopeStatus = defineFlow({
589+
name: 'envelope_status', label: 'Envelope status', type: 'api', runAs: 'system',
590+
nodes: [
591+
{ id: 'start', type: 'start', label: 'DocuSign Connect', config: {
592+
hookId: 'docusign',
593+
secretRef: 'DOCUSIGN_CONNECT_KEY',
594+
signature: { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' },
595+
} },
596+
{ id: 'end', type: 'end', label: 'End' },
597+
],
598+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
599+
});
600+
```
601+
602+
`signs` says what the sender signs, and picks the form:
603+
604+
| `signs` | The sender signs | The header carries | Replay window |
605+
|:--|:--|:--|:--|
606+
| `body` | the raw body alone (DocuSign Connect; GitHub-style with `prefix: 'sha256='`) | the one digest, after `prefix` when one is declared | none: the sender signs no time, so a captured post verifies again while the key is unchanged |
607+
| `timestamp.body` | `<t>.<raw body>` (Stripe-style) | `NAME=value` elements: one `timestampElement` (`t`) and one or more `digestElement`s (`v1`, one per key while the sender rolls its key) | the same 300 seconds as `x-objectstack-signature` |
608+
609+
`header` is matched without regard to case; a field the sender repeats is
610+
read as its one value, the copies joined with `, `, so a body-only header sent
611+
twice does not verify. `algorithm` is `hmac-sha256`, `encoding` is `hex` or
612+
`base64`, and the digest must decode to the 32 bytes of the HMAC, compared in
613+
constant time over the body exactly as received. The key is only ever the one
614+
`config.secretRef` names, read at every post like any hook secret (`503` while
615+
it has no value); a literal `secret` beside `signature` is refused.
616+
617+
A declared scheme **replaces** the `x-objectstack-signature` check for its hook:
618+
the hook has that one verifier, so a post that carries only a valid
619+
`x-objectstack-signature` is refused, and so is one without the declared
620+
header. Every refusal is the same `401 INVALID_SIGNATURE` an unsigned post
621+
gets, with its cause in the server log only. A declaration the flow parse
622+
refuses (an unknown algorithm, a reserved header such as `authorization` or
623+
`x-objectstack-signature`, a timestamped scheme without its elements) never
624+
falls back to the platform check: `os validate` reports it as
625+
`flow-api-trigger-signature-unusable`, and a hook armed with it admits no post.
626+
The declared keys are listed in
627+
[api-trigger-signature](/docs/references/automation/api-trigger-signature).
628+
578629
**Script:**
579630

580631
The built-in `script` executor never evaluates an arbitrary JavaScript string —

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export {
145145
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
146146
FLOW_TRIGGER_UNROUTABLE,
147147
FLOW_API_TRIGGER_SECRET_MISSING,
148+
FLOW_API_TRIGGER_SIGNATURE_UNUSABLE,
148149
} from './validate-flow-trigger-readiness.js';
149150
export type {
150151
FlowTriggerReadinessFinding,

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,32 @@ const FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION: RuleExplanation = {
20662066
],
20672067
};
20682068

2069+
const FLOW_API_TRIGGER_SIGNATURE_UNUSABLE_EXPLANATION: RuleExplanation = {
2070+
rule: 'flow-api-trigger-signature-unusable',
2071+
covers: 'why a hook with a broken sender signature admits nothing',
2072+
paragraphs: [
2073+
'An inbound api hook may declare how a third-party sender signs its posts, in its start node\'s ' +
2074+
'`config.signature`: the header the sender writes its digest in, `hmac-sha256`, `hex` or `base64`, and ' +
2075+
'what is signed, the raw body (`signs: \'body\'`, as DocuSign Connect signs it) or `<t>.<raw body>` ' +
2076+
'(`signs: \'timestamp.body\'`, a Stripe-style `t=…,v1=…` header). The key is the one ' +
2077+
'`config.secretRef` names, never a literal. A declared scheme REPLACES the `x-objectstack-signature` ' +
2078+
'check for its hook: the hook has that one verifier and never reads the platform header.',
2079+
'The finding means the declaration is present and the spec refuses it: an unknown algorithm or ' +
2080+
'encoding, a reserved header name, a timestamped scheme without its elements, no `config.secretRef`, ' +
2081+
'or a literal `config.secret` beside it. The verdict names the key the spec refuses at; the flow ' +
2082+
'parse prints the refusal itself in full.',
2083+
'A broken declaration never falls back to `x-objectstack-signature` and is never admitted unsigned. The ' +
2084+
'automation engine\'s flow parse refuses the flow at registration, and a trigger handed it anyway ' +
2085+
'refuses every post to its hook with the one `401 INVALID_SIGNATURE` every unverified post gets, so ' +
2086+
'the flow never receives a post. Fix the declaration, or remove `config.signature` to verify ' +
2087+
'`x-objectstack-signature` under the hook\'s secret.',
2088+
'A usable declaration is ready: the hook verifies the sender\'s header under the named key, a ' +
2089+
'timestamped scheme inside the same 300-second window `x-objectstack-signature` has, a body-only ' +
2090+
'scheme with no replay window because the sender signs no time.',
2091+
FLOW_GATE_REACH,
2092+
],
2093+
};
2094+
20692095
// `flow-trigger-unknown-object` lives in the same rule file but is not one of
20702096
// the five: its verdict depends on packages a build cannot see, so it warns —
20712097
// except under a package closure the runtime publish gate states, where the
@@ -5661,6 +5687,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
56615687
[FLOW_TRIGGER_UNROUTABLE_EXPLANATION.rule]: FLOW_TRIGGER_UNROUTABLE_EXPLANATION,
56625688
[FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION,
56635689
[FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION.rule]: FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION,
5690+
[FLOW_API_TRIGGER_SIGNATURE_UNUSABLE_EXPLANATION.rule]: FLOW_API_TRIGGER_SIGNATURE_UNUSABLE_EXPLANATION,
56645691
[FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION,
56655692
[FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION,
56665693
[VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION.rule]: VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION,

‎packages/lint/src/validate-flow-trigger-readiness.test.ts‎

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
1313
FLOW_TRIGGER_UNROUTABLE,
1414
FLOW_API_TRIGGER_SECRET_MISSING,
15+
FLOW_API_TRIGGER_SIGNATURE_UNUSABLE,
1516
validateFlowApiTriggerSecret as validateFlowApiTriggerSecretUnrecorded,
1617
schemaRefusalHead,
1718
} from './validate-flow-trigger-readiness.js';
@@ -30,6 +31,7 @@ const NEVER_FIRE_IDS: readonly string[] = [
3031
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
3132
FLOW_TRIGGER_UNROUTABLE,
3233
FLOW_API_TRIGGER_SECRET_MISSING,
34+
FLOW_API_TRIGGER_SIGNATURE_UNUSABLE,
3335
FLOW_TRIGGER_UNKNOWN_EVENT,
3436
];
3537
const fired: Array<{ rule: string; message: string; hint: string }> = [];
@@ -1521,6 +1523,31 @@ describe('validateFlowTriggerReadiness', () => {
15211523
],
15221524
},
15231525
],
1526+
// [#30009] A declared sender scheme the spec refuses: never-fire, like a missing secret.
1527+
[
1528+
FLOW_API_TRIGGER_SIGNATURE_UNUSABLE,
1529+
'error',
1530+
{
1531+
objects: [candidateObject],
1532+
flows: [
1533+
{
1534+
name: 'broken_vendor_hook',
1535+
type: 'api',
1536+
status: 'active',
1537+
nodes: [
1538+
{
1539+
id: 'start',
1540+
type: 'start',
1541+
config: {
1542+
secretRef: 'DOCUSIGN_CONNECT_KEY',
1543+
signature: { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha1', encoding: 'base64' },
1544+
},
1545+
},
1546+
],
1547+
},
1548+
],
1549+
},
1550+
],
15241551
// ── The controls. Both are hedged, and the hedge is the whole reason the
15251552
// promotion above is not "everything in this file is an error now".
15261553
[
@@ -1753,6 +1780,89 @@ describe('validateFlowTriggerReadiness', () => {
17531780
});
17541781
});
17551782

1783+
// [#30009] A start node that declares its sender's own scheme (`config.signature`)
1784+
// is graded by the hook's ONE verifier, read through the spec's
1785+
// `apiTriggerVerifierOf` (the reader the engine and the trigger share): ready on
1786+
// the key `config.secretRef` names and nothing else, and never ready when the
1787+
// reader answers `unusable`.
1788+
describe('[#30009] api trigger readiness reads the hook\'s one verifier (config.signature)', () => {
1789+
const DOCUSIGN = { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' };
1790+
const STRIPE = {
1791+
signs: 'timestamp.body', header: 'Stripe-Signature', algorithm: 'hmac-sha256', encoding: 'hex',
1792+
timestampElement: 't', digestElement: 'v1',
1793+
};
1794+
const vendorFlow = (config: Record<string, unknown>, type = 'api') => ({
1795+
name: 'vendor_hook',
1796+
label: 'Vendor hook',
1797+
type,
1798+
status: 'active',
1799+
nodes: [
1800+
{ id: 'start', type: 'start', label: 'Start', config },
1801+
{ id: 'end', type: 'end', label: 'End' },
1802+
],
1803+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
1804+
});
1805+
const judge = (flow: Record<string, unknown>) => cliFlowFamily({ objects: [candidateObject], flows: [flow] });
1806+
const rules = (flow: Record<string, unknown>) => judge(flow).map((f) => [f.rule, f.severity, f.path]);
1807+
1808+
it('a usable declaration keyed by secretRef is ready, on both arms, at the CLI and the runtime publish gate', () => {
1809+
for (const signature of [DOCUSIGN, STRIPE, { ...DOCUSIGN, encoding: 'hex', prefix: 'sha256=' }]) {
1810+
const flow = vendorFlow({ hookId: 'vendor', secretRef: 'VENDOR_WEBHOOK_KEY', signature });
1811+
expect(judge(flow), JSON.stringify(signature)).toEqual([]);
1812+
expect(runRuntimeAuthoringRules({ type: 'flow', item: flow }).errors, JSON.stringify(signature)).toEqual([]);
1813+
}
1814+
});
1815+
1816+
it('an unusable declaration is never ready: one error, at the key the spec refuses', () => {
1817+
const cases: Array<[string, Record<string, unknown>, string]> = [
1818+
['an algorithm the spec refuses', { secretRef: 'K', signature: { ...DOCUSIGN, algorithm: 'hmac-sha1' } }, 'config.signature.algorithm'],
1819+
['a reserved header', { secretRef: 'K', signature: { ...DOCUSIGN, header: 'x-objectstack-signature' } }, 'config.signature.header'],
1820+
['a timestamped scheme with no timestamp element', { secretRef: 'K', signature: { ...STRIPE, timestampElement: undefined } }, 'config.signature.timestampElement'],
1821+
['no key reference', { signature: DOCUSIGN }, 'config.signature'],
1822+
['a literal secret beside it (an inline key)', { secret: 'whsec_inline', signature: DOCUSIGN }, 'config.signature'],
1823+
['a literal secret AND a secretRef beside it', { secret: 'whsec_inline', secretRef: 'K', signature: DOCUSIGN }, 'config.signature'],
1824+
['a secretRef that is not a usable name', { secretRef: 'OS_AUTH_SECRET', signature: DOCUSIGN }, 'config.secretRef'],
1825+
];
1826+
for (const [label, config, keyPath] of cases) {
1827+
const findings = judge(vendorFlow(config)).filter((f) => f.rule === FLOW_API_TRIGGER_SIGNATURE_UNUSABLE);
1828+
expect(findings.map((f) => [f.severity, f.path]), label).toEqual([['error', `flows[0].nodes[0].${keyPath}`]]);
1829+
expect(findings[0]!.message, label).toContain(keyPath);
1830+
expect(findings[0]!.message, label).toContain(`type: 'api'`);
1831+
// Never the missing-secret id beside it: one verdict per hook.
1832+
expect(judge(vendorFlow(config)).map((f) => f.rule), label).not.toContain(FLOW_API_TRIGGER_SECRET_MISSING);
1833+
// And never let through at the runtime publish gate.
1834+
expect(
1835+
runRuntimeAuthoringRules({ type: 'flow', item: vendorFlow(config) }).errors.map((f) => f.rule),
1836+
label,
1837+
).toContain(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE);
1838+
}
1839+
});
1840+
1841+
it('a declared scheme is graded on its key reference alone: a withheld-and-stored literal secret does not make it ready', () => {
1842+
const flow = vendorFlow({ signature: DOCUSIGN });
1843+
const findings = validateFlowApiTriggerSecret(
1844+
{ objects: [candidateObject], flows: [flow] },
1845+
{ restoredCredentialPaths: new Set(['flows[0].nodes[0].config.secret']) },
1846+
);
1847+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, 'flows[0].nodes[0].config.signature']]);
1848+
});
1849+
1850+
it('CONTROL: a flow that binds no api trigger is not graded by this rule, and a hook with no declaration keeps its own verdicts', () => {
1851+
expect(rules(vendorFlow({ secretRef: 'K', signature: { ...DOCUSIGN, algorithm: 'hmac-sha1' } }, 'autolaunched'))
1852+
.map(([rule]) => rule)).not.toContain(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE);
1853+
expect(rules(vendorFlow({ hookId: 'vendor' }))).toEqual([[FLOW_API_TRIGGER_SECRET_MISSING, 'error', 'flows[0].nodes[0].config.secret']]);
1854+
expect(judge(vendorFlow({ secretRef: 'VENDOR_WEBHOOK_KEY' }))).toEqual([]);
1855+
});
1856+
1857+
it('`os explain flow-api-trigger-signature-unusable` says what a declared scheme is and why a broken one admits nothing', () => {
1858+
expect(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE).toBe('flow-api-trigger-signature-unusable');
1859+
const text = explanationOf(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE);
1860+
for (const fact of ['config.signature', 'config.secretRef', 'REPLACES', 'never falls back', '401 INVALID_SIGNATURE', '300-second']) {
1861+
expect(text, fact).toContain(fact);
1862+
}
1863+
});
1864+
});
1865+
17561866
describe('[#22161] one-line verdicts — the never-fire family', () => {
17571867
it('every verdict the cases above fired is one line of at most 200 characters', () => {
17581868
// The coverage control first: each of the five ids fired at least once, so
@@ -1798,9 +1908,10 @@ describe('[#22161] one-line verdicts — the never-fire family', () => {
17981908
[FLOW_TRIGGER_UNROUTABLE]: ['`resolveTriggerBinding`', 'manual — nothing to bind', 'one higher than its bound count', 'RESOLVED type', 'record-{before,after}-{create,insert,update,delete,write}'],
17991909
[FLOW_TRIGGER_UNKNOWN_EVENT]: ['record-{before,after}-{create,insert,update,delete,write}', 'bind-time warn', 'multi-event shape', '`record-after-write`'],
18001910
[FLOW_API_TRIGGER_SECRET_MISSING]: ['ADR-0041', 'HMAC', '`registerFlow`', 'whatever the flow\'s `status`', 'never receives a post', 'withholds'],
1911+
[FLOW_API_TRIGGER_SIGNATURE_UNUSABLE]: ['REPLACES', 'never falls back', 'flow parse refuses the flow at registration', 'names the key the spec refuses at'],
18011912
};
18021913

1803-
it('covers exactly the five ids', () => {
1914+
it('covers exactly the never-fire ids', () => {
18041915
expect(Object.keys(MOVED).sort()).toEqual([...NEVER_FIRE_IDS].sort());
18051916
});
18061917

0 commit comments

Comments
 (0)