Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/29983-api-trigger-signature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/spec': minor
---

feat(spec): an `api` flow's start node declares a third-party sender's signature scheme (`config.signature`), keyed by `config.secretRef` (#29983)

Clause-②: yes (widening)
Metadata-protocol: added

<!-- 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. -->

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:

- `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' }`.
- `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' }`.

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.

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.

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.
4 changes: 4 additions & 0 deletions content/docs/references/automation/api-trigger-secret-ref.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ refused as unavailable, never verified against nothing.
The reference is NOT a credential: it is served with the definition like any
other key, and only the literal `config.secret` is withheld on read.

It is also the key a third-party sender's declared scheme is verified under
(`config.signature`, `api-trigger-signature.zod.ts`): one key per hook,
whichever verifier the hook has.

Three refusals, each anchored on the start node's `config`:

- a reference that is not a name — `ApiTriggerSecretRefSchema`;
Expand Down
163 changes: 163 additions & 0 deletions content/docs/references/automation/api-trigger-signature.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
---
title: Api Trigger Signature — Automation Protocol
navTitle: Api Trigger Signature
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."
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

An inbound `api` hook's verifier for a THIRD-PARTY sender, declared.

An `api` flow's start node arms an inbound hook (ADR-0041 Tier 1, whose
acceptance criteria name "HMAC signature verification (GitHub/Stripe style)
and a constant-time compare"). By default the hook admits a post only by
ObjectStack's own `x-objectstack-signature`: the timestamped HMAC-SHA256 of
`<t>.<raw body>` under the hook's secret. A vendor that signs its callbacks
its own way could not be admitted at all, whatever key it held: DocuSign
Connect's `X-DocuSign-Signature-1` (base64 HMAC-SHA256 of the raw body), a
Stripe-style `t=…,v1=…` header (hex HMAC-SHA256 of `<t>.<raw body>`), a
GitHub-style `sha256=<hex>`. `config.signature` declares how such a sender
signs: which header carries the digest, which HMAC, which encoding, and what
is signed.

**The key is `config.secretRef`.** The same reference an ObjectStack-signed
hook names, resolved the same way at every post: one key per hook, by name
only. A vendor key is ⛔ never inline: a verifier beside a literal
`config.secret` is refused, and so is one with no `secretRef` at all.

**One verifier per hook.** A declared `signature` REPLACES the
`x-objectstack-signature` check for its hook. The hook then verifies the
declared scheme and nothing else: it does not stack with the platform's own
check, and it is not optional (a post without the declared header is refused
like a post with a bad one). A start node with no `signature` is verified
exactly as it was before this key. `apiTriggerVerifierOf` is the one
reader that decides which verifier a hook has, and it decides on the key's
PRESENCE: a `signature` that is present and unusable is never read as
absent, so it can never fall back to the platform's check.

What the implementer owes (the constant-time compare, the replay window for
a scheme that signs a timestamp and the absence of one for a scheme that
does not, the one `401 INVALID_SIGNATURE` for every failure) is stated in
`contracts/trigger-api-service.ts`, beside the platform's own verifier,
because the member that verifies is declared there.

Refusals, each anchored on the start node's `config.signature`:

- a shape the schema refuses (`ApiTriggerSignatureSchema`): an
unknown algorithm or encoding, a `signs` that is neither form, a
`timestamp.body` scheme that names no timestamp source, a body-only
scheme that names one, an undeclared key;
- a `header` that names a reserved header, in any case, also refused by
the schema (`API_TRIGGER_SIGNATURE_RESERVED_HEADERS`):
`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 the hook's own check refuses, with no replay
bound), or the request's credentials, routing or framing, or the hook's
idempotency key;
- a `timestamp.body` scheme whose timestamp and digest are the same
element of the header;
- a verifier with no key reference (`config.secretRef` absent);
- a verifier beside a literal `config.secret`: an inline key, and a second
source for the one key the hook has;
- a verifier on a flow that binds no inbound `api` trigger: nothing would
ever read it.

This is the vocabulary ADR-0138 D7 left to a follow-up (F1: an HMAC, a
timestamp, a replay window), placed on the signed inbound-hook channel
rather than on `ApiEndpointSchema`.

<Callout type="info">
**Source:** `packages/spec/src/automation/api-trigger-signature.zod.ts`
</Callout>

## TypeScript Usage

```typescript
import { ApiTriggerBodySignatureSchema, ApiTriggerSignatureSchema, ApiTriggerTimestampedSignatureSchema } from '@objectstack/spec/automation';
import type { ApiTriggerBodySignature, ApiTriggerSignature, ApiTriggerTimestampedSignature } from '@objectstack/spec/automation';

// Validate data
const result = ApiTriggerBodySignatureSchema.parse(data);
```

---

## ApiTriggerBodySignature

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.

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **signs** | `'body'` | ✅ | The sender signs the raw body alone. No replay window applies. |
| **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`. |
| **algorithm** | `Enum<'hmac-sha256'>` | ✅ | The MAC the sender computes under the key `config.secretRef` names. `hmac-sha256` is the only one. |
| **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. |
| **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. |


---

## ApiTriggerSignature

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.

### Union Options

This schema accepts one of the following structures:

#### Option 1

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.

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **signs** | `'body'` | ✅ | The sender signs the raw body alone. No replay window applies. |
| **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`. |
| **algorithm** | `Enum<'hmac-sha256'>` | ✅ | The MAC the sender computes under the key `config.secretRef` names. `hmac-sha256` is the only one. |
| **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. |
| **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. |

---

#### Option 2

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.

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **signs** | `'timestamp.body'` | ✅ | The sender signs `<t>.<raw body>`, `t` read from `timestampElement`. The replay window applies. |
| **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`. |
| **algorithm** | `Enum<'hmac-sha256'>` | ✅ | The MAC the sender computes under the key `config.secretRef` names. `hmac-sha256` is the only one. |
| **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. |
| **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>`. |
| **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. |

---


---

## ApiTriggerTimestampedSignature

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.

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **signs** | `'timestamp.body'` | ✅ | The sender signs `<t>.<raw body>`, `t` read from `timestampElement`. The replay window applies. |
| **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`. |
| **algorithm** | `Enum<'hmac-sha256'>` | ✅ | The MAC the sender computes under the key `config.secretRef` names. `hmac-sha256` is the only one. |
| **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. |
| **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>`. |
| **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. |


---

3 changes: 2 additions & 1 deletion content/docs/references/automation/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Automation Protocol — schema reference
navTitle: Automation Protocol
description: "The ObjectStack Automation Protocol in 15 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
description: "The ObjectStack Automation Protocol in 16 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
---

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

<Cards>
<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" />
<Card href="/docs/references/automation/api-trigger-signature" title="Api Trigger Signature" description="Source: packages/spec/src/automation/api-trigger-signature.zod.ts" />
<Card href="/docs/references/automation/approval" title="Approval" description="Source: packages/spec/src/automation/approval.zod.ts" />
<Card href="/docs/references/automation/assignment-rule" title="Assignment Rule" description="Source: packages/spec/src/automation/assignment-rule.zod.ts" />
<Card href="/docs/references/automation/bpmn-interop" title="Bpmn Interop" description="Source: packages/spec/src/automation/bpmn-interop.zod.ts" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"approval",
"---More---",
"api-trigger-secret-ref",
"api-trigger-signature",
"assignment-rule",
"builtin-node-config",
"flow-function",
Expand Down
9 changes: 5 additions & 4 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
description: Every schema published by @objectstack/spec — 1620 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1623 schemas across 14 protocol modules
---

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

---

Expand Down Expand Up @@ -110,13 +110,14 @@ REST contracts, endpoints, routing, realtime, batch, discovery.

## Automation Protocol

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

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

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