Skip to content

Commit 217ba78

Browse files
feat(spec): an api trigger declares a third-party sender's signature scheme (config.signature), keyed by secretRef (#29995)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB3Ps3dJobVVkjf2zYiwJKE7)_ Part of #29983 — the contract half. The card's need (an `api` hook admits a DocuSign Connect post) is met only once the `api` trigger verifies the declaration; that is the `domain:server` slice listed below, which the seat files after this lands. Merging this PR must leave the card open. ## 维护者速读 - **改了什么:** `api` 触发流的开始节点可以声明 `config.signature`:第三方发送方的签名方式(头名、HMAC-SHA256、hex/base64、签的是原始 body 还是 `T.RAW_BODY`、时间戳在头里的哪个元素),密钥沿用同一节点的 `config.secretRef`。声明后它**取代**该 hook 的 `x-objectstack-signature` 校验,每个 hook 只有一个校验器。`ITriggerApiService` 合约文字写明实现方义务。只动 `@objectstack/spec`(minor),不动服务端。 - **为什么改:** hotclm 的电子签名步骤要接 DocuSign Connect 回调(`X-DocuSign-Signature-1`,raw body 的 base64 HMAC-SHA256),平台只认自己的签名,只能轮询。triage 定向为 api 触发器上的声明式校验器,合约先行。 - **风险与回滚:** 鉴权边界。服务端未实现前,声明了 `signature` 的 hook 仍只认 `x-objectstack-signature`,厂商的每次回调都 401,不会放行未验证请求(fail closed)。body-only 方案(DocuSign)没有防重放窗口,合约照实写明,不另造。回滚 = revert 本 PR。 - **先看:** `packages/spec/src/automation/api-trigger-signature.zod.ts`(形状、六类拒绝含保留头名、`apiTriggerVerifierOf` 三态);`packages/spec/src/contracts/trigger-api-service.ts` 新增的「declared scheme REPLACES that check」一条(常数时间比较、300 秒窗口、单一 401)。 - **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run src/automation/api-trigger-signature.test.ts`,预期 `26 passed`。 - **需要你做的:** 请审阅这份鉴权边界合约(形状、密钥只走 `secretRef`、body-only 无重放窗口这三点),通过后由席位落地并派服务端卡。 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up owed — skills/objectstack-automation/SKILL.md (the "outside the `api` door's HMAC" line), skills/objectstack-automation/references/integration-recipes.md (recipe 4, "cannot post here today"), skills/objectstack-api/SKILL.md (the "the hook checks only `x-objectstack-signature`" case), and a `config.signature` section in content/docs/automation/flows.mdx beside its `secretRef` paragraph. The three skills lines say correctly today that a vendor-signed callback cannot be admitted and stay correct until the server slice serves the key, so that slice teaches them (and binds the new `signs` union in `variant-docs.json` to the flows.mdx section), not this PR. Prior rulings read: api trigger signature,secretref,invalid_signature → 0 hits; none; thread: none; repo: objectstack-ai/objectstack. Widened set: signature,hmac,webhook,inbound hook,replay → ADR-0138 D7 (and its F1), ADR-0041 Tier 1, read; none reversed (see Acceptance notes). ADR-0087: not-required (no-migration-prescription), in the changeset. ## What changes **The shape** (`packages/spec/src/automation/api-trigger-signature.zod.ts`, exported from `@objectstack/spec/automation`). An `api` flow's start node `config` gains one optional key, `signature`: a discriminated union on `signs`, both arms closed (`strictObject`). DocuSign Connect, base64 HMAC-SHA256 of the raw body: ```ts config: { hookId: 'docusign', secretRef: 'DOCUSIGN_CONNECT_KEY', // the key, by name: one key per hook, never inline signature: { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64', }, } ``` Stripe-style, `Stripe-Signature: t=1700000000,v1=HEX` where HEX is the HMAC-SHA256 of `1700000000.RAW_BODY`: ```ts config: { hookId: 'payments', secretRef: 'PAYMENTS_WEBHOOK_KEY', signature: { signs: 'timestamp.body', header: 'Stripe-Signature', algorithm: 'hmac-sha256', encoding: 'hex', timestampElement: 't', // where the timestamp comes from digestElement: 'v1', // the element(s) carrying the digest }, } ``` GitHub-style costs one optional key on the body arm: `{ signs: 'body', header: 'X-Hub-Signature-256', algorithm: 'hmac-sha256', encoding: 'hex', prefix: 'sha256=' }`. | Key | Arm | Values | |:--|:--|:--| | `signs` | both (the discriminator) | `body` or `timestamp.body` | | `header` | both | an RFC 9110 field name, matched without regard to case; never a reserved name (`API_TRIGGER_SIGNATURE_RESERVED_HEADERS`, below) | | `algorithm` | both | `hmac-sha256` (closed enum, one value) | | `encoding` | both | `hex` or `base64` (RFC 4648 standard alphabet) | | `prefix` | `body` only, optional | visible ASCII, no space, no comma | | `timestampElement`, `digestElement` | `timestamp.body` only, required | a `NAME` of the header's comma-separated `NAME=value` elements | **The key.** A declared scheme verifies under the key the same start node's `config.secretRef` names, resolved exactly as it is today (the engine's `hookSecretResolver` reads `secretRef` through the credential resolver at every post, never the write-only channel). Measured against the current shape, "conflicting" means two sources for the hook's one key: a literal `config.secret` (inline, and a second source) beside `signature` is refused, and so is a `signature` with no `secretRef`. The write-only channel cannot conflict, because `secretRef` already wins over it at verification. **One verifier per hook.** `apiTriggerVerifierOf(config)` is the one reader the engine, trigger and lint share. It answers `objectstack-signature` (no `signature` key: today's behaviour, untouched), `declared` (the parsed scheme plus the `secretRef` name), or `unusable` (a `signature` that is present and refused). It decides on the key's presence, never on whether it parses, so a broken declaration cannot fall back to the platform check. **Refused at the flow parse** (`FlowSchema`'s `superRefine`, beside the `secretRef` arm, each anchored at `nodes.N.config.signature...`): no key reference; a literal `secret` beside it (the cleared `''` form included); an algorithm other than `hmac-sha256`; an encoding other than `hex`/`base64`; a `signs` that is neither form; `timestamp.body` with no `timestampElement` (no timestamp source) or no `digestElement`, or the same name for both; a body-only scheme naming a timestamp or digest element; a `prefix` on the timestamped arm; a header that is not a field name; a `header` naming, in any case, `x-objectstack-signature` (the platform's own header, which stays the platform's: one verifier per hook, and a body-only scheme on it would re-admit the `sha256=` form ruling B on #22805 refuses inbound, with no replay bound) or another name in the closed `API_TRIGGER_SIGNATURE_RESERVED_HEADERS` list (`authorization`, `proxy-authorization`, `cookie`, `host`, `content-length`, `transfer-encoding`, `x-idempotency-key`: the request's credentials, routing or framing, or the hook's delivery id), refused by the shape itself (a second `pattern` on `header`, built from the list with each letter in both cases, so each arm schema and the published JSON Schema refuse it too); a non-object value; and a `signature` on a flow that binds no `api` trigger. **The contract text** (`packages/spec/src/contracts/trigger-api-service.ts`, `handleInboundHook`). A new item states what the implementer owes for a declared scheme: it replaces `x-objectstack-signature` (never read on such a hook, so a valid one admits nothing; a post without the declared header is refused); the header parse for each arm, a field the sender repeats read as ONE value (the values joined with `, ` in order, what `Headers.get` answers), never the first or last field alone; the HMAC over the raw body as received, or over `T.RAW_BODY` with `T` exactly as received; decode by `encoding` and compare the 32 bytes **in constant time**, any one digest element matching; for `timestamp.body`, the same 300-second window as `x-objectstack-signature` (`HTTP_SIGNATURE_TOLERANCE_SECONDS` in `@objectstack/core`), never one of its own; for `body`, plainly **no replay window** (the sender offers nothing to bound one with, and the contract adds none); and every failure keeps the one `401 INVALID_SIGNATURE` with one body, an `unusable` declaration included (whether the trigger declines to arm it or refuses each post). It also states the planned state honestly: a trigger that predates this form verifies `x-objectstack-signature` alone, so it refuses every post a declared sender makes. **Ripples.** Type aliases `ApiTriggerSignature`, `ApiTriggerBodySignature`, `ApiTriggerTimestampedSignature` with three ADR-0122 isomorphism pins (828 to 831); `API_TRIGGER_SIGNATURE_RESERVED_HEADERS` exported (frozen, pinned whole), and `header`'s JSON Schema gains the reserved-name `pattern` (an `allOf` of two patterns); the two arms are exported as their own defs so `authorable-surface/automation.json` records their keys (a union def has no top-level `properties`, so without them the surface would not move); `api-surface`, `export-origins`, `declaration-map`, `json-schema.manifest`, the references pages and the strictness-ledger counts regenerated, plus one hand-written strictness-ledger triage row (`authorable`, strict from birth); `skills/objectstack-automation/references/_index.md` is the `gen:skill-refs` line for the new module (the generator-owned carve-out, ruling on #11705); `packages/spec/llms.txt` counts the module (218 schemas, automation 16); `packages/spec/variant-docs.json` declares the new `signs: body | timestamp.body` union `exempt: generated-reference-only` until the server slice writes the flows.mdx section and binds it. No server package is touched. ## Why Card #29983, from the hotclm app: its e-signature step needs DocuSign Connect's envelope-status callback, which signs the raw body with `X-DocuSign-Signature-1` (base64 HMAC-SHA256 under a Connect key). The `api` hook admits a post only by `x-objectstack-signature`, so the callback is refused `401 INVALID_SIGNATURE` and the app polls instead. Triage 6617546776 set the direction (a declarative verifier on the `api` trigger, contract first, `domain:spec`) and the floor (single verifier, constant-time compare, a replay bound where the sender offers one, never an inline key, the maintainer reviews the contract). ## Server slice (`domain:server`, filed by the seat once this lands) - [ ] `packages/triggers/trigger-api`: read `apiTriggerVerifierOf(binding.config)` at `start()`. `unusable` never arms a hook that admits a post and never falls back to `x-objectstack-signature`; whether the trigger declines to arm it or refuses each post, every post to it gets the one `401 INVALID_SIGNATURE`. - [ ] `serveInboundHook` / `handleRequest`: pass the declared header's value (today only `x-objectstack-signature` and `x-idempotency-key` reach `handleRequest`), on both faces (the self-hosted mount and `handleInboundHook`), read with `Headers.get` so a repeated field arrives as its one comma-joined value, never the first field alone. - [ ] The verifier per the contract text: header parse per arm (timestamp element once, decimal as `x-objectstack-signature` writes `t`; digest elements one or more; unknown elements ignored); HMAC-SHA256 over the exact body bytes (`request.text()` re-encodes valid UTF-8 byte for byte; a non-UTF-8 body mismatches and fails closed); decode hex/base64 to 32 bytes; `timingSafeEqual`; the window from core's `HTTP_SIGNATURE_TOLERANCE_SECONDS`. - [ ] Every failure through `refuseUnverified` with its own logged cause (header absent, malformed, prefix missing, no digest, mismatch, outside window, declaration unusable) and the one `401 INVALID_SIGNATURE` answer. - [ ] Pins: a DocuSign vector (real base64 HMAC) admitted; a Stripe-style vector admitted, with a second `v1` element (key roll) and an outside-window `t` refused; a valid `x-objectstack-signature` on a declared hook refused; a post without the declared header refused; a body-only header sent twice refused; every failure's status and body identical; an `unusable` declaration answers that same `401` and admits nothing. - [ ] Runtime `HttpDispatcher`: confirm the forwarded `Request` keeps the vendor header (one pin). - [ ] `validate-flow-trigger-readiness` (required): a hook with `secretRef` and `signature` does not grade ready while no trigger serves the key; once the trigger serves it, the grade names the verifier the hook has (`apiTriggerVerifierOf`), and an `unusable` declaration never grades ready. - [ ] Skills and docs from the `Skills-docs:` line above, and the `variant-docs.json` exemption replaced by a binding to the new flows.mdx section. ## Tests - `packages/spec/src/automation/api-trigger-signature.test.ts` (new, 26 cases): the three senders parse whole and are served as written; the reader answers one verifier per hook (absent: `objectstack-signature`; usable: `declared` with the `secretRef`; eight present-and-refused shapes: `unusable`, never `objectstack-signature`); no key can ask for a second check or a fallback (`optional`, `fallback`, `also` refused on both arms); the replay rule's two halves (`timestamp.body` without a source refused at `timestampElement`; a body-only scheme naming one refused); each parse refusal at its path with `code: 'custom'`; and the reserved headers (`x-objectstack-signature` in three spellings on both arms, the seat's `sha256=` shape included, refused at `header` and read `unusable`, the message leading with the reason both arms share and naming no issue id; the closed list pinned whole and frozen, each other name refused in three spellings on both arms; each arm schema alone and the union refusing every reserved name; near names such as `X-Objectstack-Signature-1` still parse). Ablated (the reserved-name pattern removed), the three reserved-header pins go red. - `packages/spec/src/type-alias-convention.pin.test.ts`: three `Iso_automation_apiTriggerSignature__*` pins, count 831. ## Acceptance notes - **The connector-level alternative, measured and not taken.** A connector-owned inbound webhook would revive a family `@objectstack/spec` 17 retired: the connector `triggers` array with `type: 'webhook'` (ADR-0049, `connector-triggers-retirement.test.ts`), whose ruling keeps connector-event triggers in ADR-0041's third tier as their own trigger package, with subscribe/unsubscribe, signature verification and a dedupe cursor. It would also need a second anonymous route on the ADR-0069 allow-list (ruling A admits exactly one), its own queue ingest, its own 401 policy and replay window. `connector.zod.ts` already directs an external event to "an `api` flow". The `api` trigger already owns the route, the allow-list row, queue ingest, the one 401 and the window, so the verifier belongs on it; I did not judge the alternative clearly better, so I built the brief. - **Liveness: no `planned` + `authorWarn` row is reachable, measured.** `flow.nodes.config` is `z.record(z.string(), z.unknown())`; `check-liveness.mts#childShape` answers a record by its value shape, which is not an object here, so no row can sit under `config` (a `children` row there would be an orphan). The sibling `secretRef` has no row for the same reason. What holds meanwhile: the describe text and the contract say a trigger that does not implement the form refuses every post `401`, and the current trigger does exactly that (it reads only `x-objectstack-signature`), so the key fails closed, never open. A lint warning until the slice lands would be the server card's to add. - **`metadata-form` reconciliation: not offered in a form.** The `api` start node's settings are edited in the Studio flow inspector (`FlowNodeInspector`, which offers the literal `secret`), not in a `platform-objects` metadata form, so the reconciliation pin does not move. Offering `secretRef` and `signature` there is a `domain:ui` follow-up, not in this card. - **ADR-0138 D7 / F1.** D7 kept the webhook signature vocabulary out of `ApiEndpointSchema` and named the choice between endpoints gaining it and webhooks keeping the signed inbound-hook channel as a follow-up filed with its executor. This places it on the inbound-hook channel; the executor is the server slice above, filed by the seat right after this lands, and the key fails closed until then. - **Ruling B on #22805** (the body-only `sha256=` form of `x-objectstack-signature` is refused inbound because it signs no time) is unchanged: it governs the platform's own header. A declared body-only scheme is the author stating what a vendor that offers nothing else signs, and the contract states its missing replay window. Since contract review round 1, a declaration cannot re-admit that form on the platform's own header: `header: 'x-objectstack-signature'` is refused in any case. - `prefix` lives on the body arm only; the timestamped arm locates its digest by `digestElement` (`v1`), which is also what lets a sender roll its key (two `v1` elements). - Security: nothing filed. The read of today's verifier found no weakness to report. ## Verification Head `bb371a5c3103db11490cb2cc4c39c378b588a755`, merge base `9049be26a`. `main` has moved four commits since (#29977, #29989, #29927 and a qa fix at `0b99c2b1`); none touches a file this PR changes (`git diff --name-only HEAD...origin/main` against this branch's files: no overlap), so the head is not merged with it. Every reading below is on `bb371a5c`, after `pnpm --filter @objectstack/spec build`: - `pnpm --filter @objectstack/spec exec vitest run src/automation/api-trigger-signature.test.ts`: 26 passed. - `pnpm --filter @objectstack/spec test` (under `os-verify-lock`): 809 files passed, 24303 tests passed, 1 todo, exit 0. - `pnpm --filter @objectstack/spec typecheck`: exit 0 (`check:test-typecheck` OK, debt unchanged at 131 pinned signatures). - `node scripts/check-doc-authoring.mjs`: exit 0 (no internal issue id in customer-facing spec text; the generated references page carries none). - `pnpm --filter @objectstack/spec run check:generated` after `--fix`: exit 0; the references page and the published JSON Schema carry the reserved-name `pattern` (`header`: `allOf` of the field-name pattern and the reserved-name lookahead). - eslint over the changed `.ts` files: exit 0. - `GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event` (this title and body): exit 0, level axis `yes (widening)` / `added`. - Ablation: with the reserved-name pattern removed from `header`, three pins go red (`x-objectstack-signature` in any case on both arms, the other reserved names on both arms, each arm schema and the union refusing every reserved name), 23 stay green; restored, 26 pass. - `pnpm --filter @objectstack/spec test:repo`: 89 files, 1524 tests passed, exit 0. <details><summary>Derived gates on <code>bb371a5c</code>: 107 derived, 107 run, 0 NOT-MEASURED (<code>dispatch-gates --ran</code> exit 0)</summary> Every command `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives for this diff (107, the same set as at `db0c0f86`), run once each on `bb371a5c` after a fresh spec build, with `@objectstack/formula` and `@objectstack/lint` built and the teardown self-test's positive-control commit fetched: all 107 exit 0. Reconciliation: `107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED`. Earlier rounds, for the record: at `db0c0f86` the first sweep found two real reds (`check:llms-txt` inventory counts, `check:variant-docs` the new union undeclared), fixed in that commit; CI on `ce07e1f6` found the doc authoring guard red (an issue id in a refusal message), fixed in `4194519e`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic (a parse refusal walk and a reader, the same kind `api-trigger-secret-ref.zod.ts` carries; the verifier is the server slice's). - [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 above; 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: its generated artifacts are regenerated and committed (`check:generated`: all 14 up to date). - [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_01WGufzggC8errMtPRYBUmoY Body refreshed by spec seat 3 (fix rounds 1 and 2, final readings on bb371a5): 2026-11-04T02:21Z --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 46d2b65 commit 217ba78

23 files changed

Lines changed: 1102 additions & 19 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): an `api` flow's start node declares a third-party sender's signature scheme (`config.signature`), keyed by `config.secretRef` (#29983)
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A new optional key on an api start node's open config: nothing an author wrote before is refused, renamed or re-shaped, so there is nothing at rest for a migration to rewrite. -->
11+
12+
An inbound `api` hook admitted a post only by ObjectStack's own `x-objectstack-signature`, so a vendor that signs its callbacks its own way (DocuSign Connect, a Stripe-style `t=…,v1=…` header, a GitHub-style `sha256=` header) could not be admitted at all. The start node's `config` now carries `signature`, which declares how such a sender signs:
13+
14+
- `signs: 'body'`: the raw body alone, the whole header value (after an optional `prefix`) is the digest. DocuSign Connect: `{ signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' }`.
15+
- `signs: 'timestamp.body'`: `T.RAW_BODY`, the header being `NAME=value` elements whose `timestampElement` carries `T` and whose `digestElement` carries the digest. Stripe-style: `{ signs: 'timestamp.body', header: 'Stripe-Signature', algorithm: 'hmac-sha256', encoding: 'hex', timestampElement: 't', digestElement: 'v1' }`.
16+
17+
The key is the start node's `config.secretRef`, as for an ObjectStack-signed hook. Declared, the scheme REPLACES the `x-objectstack-signature` check for that hook (one verifier per hook, never optional). The flow parse refuses a `signature` with no `secretRef`, one beside a literal `secret`, an algorithm other than `hmac-sha256`, an encoding other than `hex` or `base64`, a `timestamp.body` scheme with no `timestampElement` (or no `digestElement`, or the same name for both), a body-only scheme that names a timestamp element, a `header` naming `x-objectstack-signature` or another reserved header (`authorization`, `proxy-authorization`, `cookie`, `host`, `content-length`, `transfer-encoding`, `x-idempotency-key`) in any case, and one on a flow that binds no `api` trigger.
18+
19+
New exports from `@objectstack/spec/automation`: `ApiTriggerSignatureSchema`, `ApiTriggerSignature`, its two arms `ApiTriggerBodySignatureSchema` / `ApiTriggerBodySignature` and `ApiTriggerTimestampedSignatureSchema` / `ApiTriggerTimestampedSignature`, `API_TRIGGER_SIGNATURE_KEY`, `API_TRIGGER_SIGNATURE_RESERVED_HEADERS`, `apiTriggerVerifierOf` (the one reader of which verifier a hook has: `objectstack-signature`, `declared` or `unusable`), `ApiTriggerVerifier`, `apiTriggerSignatureRefusals` and `ApiTriggerSignatureRefusal`. The `ITriggerApiService.handleInboundHook` contract text states what the implementer owes: a constant-time compare of the decoded 32 bytes, the same 300-second window as `x-objectstack-signature` for a scheme that signs a timestamp, no replay window for a body-only scheme, a repeated header read as its one comma-joined value, and the one `401 INVALID_SIGNATURE` for every failure, an unusable declaration included.
20+
21+
This release declares the key; the `api` trigger does not verify it yet. Until it does, a hook that declares `signature` is still verified by `x-objectstack-signature` alone, so every post its vendor sends is refused `401`, and none is admitted unverified.

‎content/docs/references/automation/api-trigger-secret-ref.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ refused as unavailable, never verified against nothing.
2626
The reference is NOT a credential: it is served with the definition like any
2727
other key, and only the literal `config.secret` is withheld on read.
2828

29+
It is also the key a third-party sender's declared scheme is verified under
30+
(`config.signature`, `api-trigger-signature.zod.ts`): one key per hook,
31+
whichever verifier the hook has.
32+
2933
Three refusals, each anchored on the start node's `config`:
3034

3135
- a reference that is not a name — `ApiTriggerSecretRefSchema`;
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
---
2+
title: Api Trigger Signature — Automation Protocol
3+
navTitle: Api Trigger Signature
4+
description: "An inbound api hook's verifier for a THIRD-PARTY sender, declared. Reference for ApiTriggerBodySignature and 2 more: every property with its type and default."
5+
---
6+
7+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
8+
9+
An inbound `api` hook's verifier for a THIRD-PARTY sender, declared.
10+
11+
An `api` flow's start node arms an inbound hook (ADR-0041 Tier 1, whose
12+
acceptance criteria name "HMAC signature verification (GitHub/Stripe style)
13+
and a constant-time compare"). By default the hook admits a post only by
14+
ObjectStack's own `x-objectstack-signature`: the timestamped HMAC-SHA256 of
15+
`<t>.<raw body>` under the hook's secret. A vendor that signs its callbacks
16+
its own way could not be admitted at all, whatever key it held: DocuSign
17+
Connect's `X-DocuSign-Signature-1` (base64 HMAC-SHA256 of the raw body), a
18+
Stripe-style `t=…,v1=…` header (hex HMAC-SHA256 of `<t>.<raw body>`), a
19+
GitHub-style `sha256=<hex>`. `config.signature` declares how such a sender
20+
signs: which header carries the digest, which HMAC, which encoding, and what
21+
is signed.
22+
23+
**The key is `config.secretRef`.** The same reference an ObjectStack-signed
24+
hook names, resolved the same way at every post: one key per hook, by name
25+
only. A vendor key is ⛔ never inline: a verifier beside a literal
26+
`config.secret` is refused, and so is one with no `secretRef` at all.
27+
28+
**One verifier per hook.** A declared `signature` REPLACES the
29+
`x-objectstack-signature` check for its hook. The hook then verifies the
30+
declared scheme and nothing else: it does not stack with the platform's own
31+
check, and it is not optional (a post without the declared header is refused
32+
like a post with a bad one). A start node with no `signature` is verified
33+
exactly as it was before this key. `apiTriggerVerifierOf` is the one
34+
reader that decides which verifier a hook has, and it decides on the key's
35+
PRESENCE: a `signature` that is present and unusable is never read as
36+
absent, so it can never fall back to the platform's check.
37+
38+
What the implementer owes (the constant-time compare, the replay window for
39+
a scheme that signs a timestamp and the absence of one for a scheme that
40+
does not, the one `401 INVALID_SIGNATURE` for every failure) is stated in
41+
`contracts/trigger-api-service.ts`, beside the platform's own verifier,
42+
because the member that verifies is declared there.
43+
44+
Refusals, each anchored on the start node's `config.signature`:
45+
46+
- a shape the schema refuses (`ApiTriggerSignatureSchema`): an
47+
unknown algorithm or encoding, a `signs` that is neither form, a
48+
`timestamp.body` scheme that names no timestamp source, a body-only
49+
scheme that names one, an undeclared key;
50+
- a `header` that names a reserved header, in any case, also refused by
51+
the schema (`API_TRIGGER_SIGNATURE_RESERVED_HEADERS`):
52+
`x-objectstack-signature`, the platform's own header, which stays the
53+
platform's (one verifier per hook; and a body-only scheme on it would
54+
re-admit the `sha256=` form the hook's own check refuses, with no replay
55+
bound), or the request's credentials, routing or framing, or the hook's
56+
idempotency key;
57+
- a `timestamp.body` scheme whose timestamp and digest are the same
58+
element of the header;
59+
- a verifier with no key reference (`config.secretRef` absent);
60+
- a verifier beside a literal `config.secret`: an inline key, and a second
61+
source for the one key the hook has;
62+
- a verifier on a flow that binds no inbound `api` trigger: nothing would
63+
ever read it.
64+
65+
This is the vocabulary ADR-0138 D7 left to a follow-up (F1: an HMAC, a
66+
timestamp, a replay window), placed on the signed inbound-hook channel
67+
rather than on `ApiEndpointSchema`.
68+
69+
<Callout type="info">
70+
**Source:** `packages/spec/src/automation/api-trigger-signature.zod.ts`
71+
</Callout>
72+
73+
## TypeScript Usage
74+
75+
```typescript
76+
import { ApiTriggerBodySignatureSchema, ApiTriggerSignatureSchema, ApiTriggerTimestampedSignatureSchema } from '@objectstack/spec/automation';
77+
import type { ApiTriggerBodySignature, ApiTriggerSignature, ApiTriggerTimestampedSignature } from '@objectstack/spec/automation';
78+
79+
// Validate data
80+
const result = ApiTriggerBodySignatureSchema.parse(data);
81+
```
82+
83+
---
84+
85+
## ApiTriggerBodySignature
86+
87+
A sender that signs the raw body alone (DocuSign Connect, a GitHub-style `sha256=` sender): the header value, after `prefix`, is the one digest. No replay window applies.
88+
89+
### Properties
90+
91+
| Property | Type | Required | Description |
92+
| :--- | :--- | :--- | :--- |
93+
| **signs** | `'body'` | ✅ | The sender signs the raw body alone. No replay window applies. |
94+
| **header** | `string` | ✅ | The HTTP header the sender puts its signature in (`X-DocuSign-Signature-1`, `Stripe-Signature`), matched without regard to case. The hook reads this header and never `x-objectstack-signature`. A reserved name is refused, in any case: `x-objectstack-signature`, `authorization`, `proxy-authorization`, `cookie`, `host`, `content-length`, `transfer-encoding` and `x-idempotency-key`. |
95+
| **algorithm** | `Enum<'hmac-sha256'>` | ✅ | The MAC the sender computes under the key `config.secretRef` names. `hmac-sha256` is the only one. |
96+
| **encoding** | `Enum<'hex' \| 'base64'>` | ✅ | How the sender writes the digest in the header: `hex` or `base64` (the RFC 4648 standard alphabet). The digest is decoded and compared as the 32 bytes of the HMAC. |
97+
| **prefix** | `string` | optional | Literal text the sender writes before the digest in the header value (`sha256=` for a GitHub-style sender), checked and removed before decoding. Absent: the whole value is the digest. |
98+
99+
100+
---
101+
102+
## ApiTriggerSignature
103+
104+
How a third-party sender signs its posts to this inbound hook, verified under the key `config.secretRef` names. Declared, it REPLACES the `x-objectstack-signature` check for this hook: the one verifier, never optional. A trigger that does not implement it refuses every post 401, never admits one unverified.
105+
106+
### Union Options
107+
108+
This schema accepts one of the following structures:
109+
110+
#### Option 1
111+
112+
A sender that signs the raw body alone (DocuSign Connect, a GitHub-style `sha256=` sender): the header value, after `prefix`, is the one digest. No replay window applies.
113+
114+
### Properties
115+
116+
| Property | Type | Required | Description |
117+
| :--- | :--- | :--- | :--- |
118+
| **signs** | `'body'` | ✅ | The sender signs the raw body alone. No replay window applies. |
119+
| **header** | `string` | ✅ | The HTTP header the sender puts its signature in (`X-DocuSign-Signature-1`, `Stripe-Signature`), matched without regard to case. The hook reads this header and never `x-objectstack-signature`. A reserved name is refused, in any case: `x-objectstack-signature`, `authorization`, `proxy-authorization`, `cookie`, `host`, `content-length`, `transfer-encoding` and `x-idempotency-key`. |
120+
| **algorithm** | `Enum<'hmac-sha256'>` | ✅ | The MAC the sender computes under the key `config.secretRef` names. `hmac-sha256` is the only one. |
121+
| **encoding** | `Enum<'hex' \| 'base64'>` | ✅ | How the sender writes the digest in the header: `hex` or `base64` (the RFC 4648 standard alphabet). The digest is decoded and compared as the 32 bytes of the HMAC. |
122+
| **prefix** | `string` | optional | Literal text the sender writes before the digest in the header value (`sha256=` for a GitHub-style sender), checked and removed before decoding. Absent: the whole value is the digest. |
123+
124+
---
125+
126+
#### Option 2
127+
128+
A sender that signs `<t>.<raw body>` and writes `t` beside the digest as `NAME=value` elements of one header (Stripe-style `t=…,v1=…`). The same replay window as `x-objectstack-signature` applies.
129+
130+
### Properties
131+
132+
| Property | Type | Required | Description |
133+
| :--- | :--- | :--- | :--- |
134+
| **signs** | `'timestamp.body'` | ✅ | The sender signs `<t>.<raw body>`, `t` read from `timestampElement`. The replay window applies. |
135+
| **header** | `string` | ✅ | The HTTP header the sender puts its signature in (`X-DocuSign-Signature-1`, `Stripe-Signature`), matched without regard to case. The hook reads this header and never `x-objectstack-signature`. A reserved name is refused, in any case: `x-objectstack-signature`, `authorization`, `proxy-authorization`, `cookie`, `host`, `content-length`, `transfer-encoding` and `x-idempotency-key`. |
136+
| **algorithm** | `Enum<'hmac-sha256'>` | ✅ | The MAC the sender computes under the key `config.secretRef` names. `hmac-sha256` is the only one. |
137+
| **encoding** | `Enum<'hex' \| 'base64'>` | ✅ | How the sender writes the digest in the header: `hex` or `base64` (the RFC 4648 standard alphabet). The digest is decoded and compared as the 32 bytes of the HMAC. |
138+
| **timestampElement** | `string` | ✅ | Where the timestamp comes from: the NAME of the header element `NAME=<unix seconds>` (`t`). Its value, exactly as received, is the `<t>` of the signed `<t>.<raw body>`. |
139+
| **digestElement** | `string` | ✅ | The NAME of the header element that carries the digest (`v1`). A sender may send it more than once while it rolls its key; the post verifies when one of them matches. |
140+
141+
---
142+
143+
144+
---
145+
146+
## ApiTriggerTimestampedSignature
147+
148+
A sender that signs `<t>.<raw body>` and writes `t` beside the digest as `NAME=value` elements of one header (Stripe-style `t=…,v1=…`). The same replay window as `x-objectstack-signature` applies.
149+
150+
### Properties
151+
152+
| Property | Type | Required | Description |
153+
| :--- | :--- | :--- | :--- |
154+
| **signs** | `'timestamp.body'` | ✅ | The sender signs `<t>.<raw body>`, `t` read from `timestampElement`. The replay window applies. |
155+
| **header** | `string` | ✅ | The HTTP header the sender puts its signature in (`X-DocuSign-Signature-1`, `Stripe-Signature`), matched without regard to case. The hook reads this header and never `x-objectstack-signature`. A reserved name is refused, in any case: `x-objectstack-signature`, `authorization`, `proxy-authorization`, `cookie`, `host`, `content-length`, `transfer-encoding` and `x-idempotency-key`. |
156+
| **algorithm** | `Enum<'hmac-sha256'>` | ✅ | The MAC the sender computes under the key `config.secretRef` names. `hmac-sha256` is the only one. |
157+
| **encoding** | `Enum<'hex' \| 'base64'>` | ✅ | How the sender writes the digest in the header: `hex` or `base64` (the RFC 4648 standard alphabet). The digest is decoded and compared as the 32 bytes of the HMAC. |
158+
| **timestampElement** | `string` | ✅ | Where the timestamp comes from: the NAME of the header element `NAME=<unix seconds>` (`t`). Its value, exactly as received, is the `<t>` of the signed `<t>.<raw body>`. |
159+
| **digestElement** | `string` | ✅ | The NAME of the header element that carries the digest (`v1`). A sender may send it more than once while it rolls its key; the post verifies when one of them matches. |
160+
161+
162+
---
163+

‎content/docs/references/automation/index.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Automation Protocol — schema reference
33
navTitle: Automation Protocol
4-
description: "The ObjectStack Automation Protocol in 15 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
4+
description: "The ObjectStack Automation Protocol in 16 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -10,6 +10,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
1010

1111
<Cards>
1212
<Card href="/docs/references/automation/api-trigger-secret-ref" title="Api Trigger Secret Ref" description="Source: packages/spec/src/automation/api-trigger-secret-ref.zod.ts" />
13+
<Card href="/docs/references/automation/api-trigger-signature" title="Api Trigger Signature" description="Source: packages/spec/src/automation/api-trigger-signature.zod.ts" />
1314
<Card href="/docs/references/automation/approval" title="Approval" description="Source: packages/spec/src/automation/approval.zod.ts" />
1415
<Card href="/docs/references/automation/assignment-rule" title="Assignment Rule" description="Source: packages/spec/src/automation/assignment-rule.zod.ts" />
1516
<Card href="/docs/references/automation/bpmn-interop" title="Bpmn Interop" description="Source: packages/spec/src/automation/bpmn-interop.zod.ts" />

‎content/docs/references/automation/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"approval",
1515
"---More---",
1616
"api-trigger-secret-ref",
17+
"api-trigger-signature",
1718
"assignment-rule",
1819
"builtin-node-config",
1920
"flow-function",

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1620 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1623 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -22,7 +22,7 @@ counts are sums of the rows they head. Regenerate with
2222
| :--- | ---: | ---: | :--- |
2323
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
2424
| [API Protocol](/docs/references/api) | 36 | 486 | REST contracts, endpoints, routing, realtime, batch, discovery. |
25-
| [Automation Protocol](/docs/references/automation) | 15 | 78 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
25+
| [Automation Protocol](/docs/references/automation) | 16 | 81 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
2626
| [Data Protocol](/docs/references/data) | 31 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
2727
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
2828
| [Integration Protocol](/docs/references/integration) | 1 | 23 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
@@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 39 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3636
| [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **209** | **1620** | 14 protocol modules |
37+
| **Total** | **210** | **1623** | 14 protocol modules |
3838

3939
---
4040

@@ -110,13 +110,14 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
110110

111111
## Automation Protocol
112112

113-
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **15 pages, 78 schemas**
113+
**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **16 pages, 81 schemas**
114114

115115
Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records.
116116

117117
| File | Schemas |
118118
| :--- | :--- |
119119
| [`api-trigger-secret-ref.zod.ts`](/docs/references/automation/api-trigger-secret-ref) | `ApiTriggerSecretRef` |
120+
| [`api-trigger-signature.zod.ts`](/docs/references/automation/api-trigger-signature) | `ApiTriggerBodySignature`, `ApiTriggerSignature`, `ApiTriggerTimestampedSignature` |
120121
| [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` |
121122
| [`assignment-rule.zod.ts`](/docs/references/automation/assignment-rule) | `AssignmentRule`, `AssignmentRuleEntry`, `AssignmentStrategy`, `AssignmentTarget` |
122123
| [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` |

0 commit comments

Comments
 (0)