From 8610b5c4144dcbae47fbbc97c5360b9f78e5d124 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 23:56:52 +0000 Subject: [PATCH 1/8] feat(spec): an api trigger declares a third-party sender's signature scheme (config.signature), keyed by secretRef A start node's config.signature declares how a vendor signs its posts to an inbound hook: the header, HMAC-SHA256, hex or base64, and whether it signs the raw body or . with the timestamp read from a named header element. Declared, it replaces the x-objectstack-signature check for that hook. The key is config.secretRef; a literal secret beside it, no key reference, an unknown algorithm or encoding, and a timestamp.body scheme with no timestamp source are refused at the flow parse. apiTriggerVerifierOf is the one reader of which verifier a hook has. Claude-Session: https://claude.ai/code/session_01WGufzggC8errMtPRYBUmoY Co-authored-by: Claude --- .../automation/api-trigger-signature.test.ts | 236 ++++++++++++ .../automation/api-trigger-signature.zod.ts | 350 ++++++++++++++++++ packages/spec/src/automation/flow.zod.ts | 9 + packages/spec/src/automation/index.ts | 2 + 4 files changed, 597 insertions(+) create mode 100644 packages/spec/src/automation/api-trigger-signature.test.ts create mode 100644 packages/spec/src/automation/api-trigger-signature.zod.ts diff --git a/packages/spec/src/automation/api-trigger-signature.test.ts b/packages/spec/src/automation/api-trigger-signature.test.ts new file mode 100644 index 00000000000..5e7ec8edc96 --- /dev/null +++ b/packages/spec/src/automation/api-trigger-signature.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { + API_TRIGGER_SIGNATURE_KEY, + ApiTriggerSignatureSchema, + apiTriggerSignatureRefusals, + apiTriggerVerifierOf, +} from './api-trigger-signature.zod'; +import { FlowSchema } from './flow.zod'; + +// `config.signature` — an `api` flow's start node declares how a third-party +// sender signs its posts, keyed by `config.secretRef`. These pins hold the +// contract half: the three senders the card names parse whole, the one reader +// answers exactly one verifier per hook, and every refusal lands on the start +// node's `config.signature`. What the verifier must do with a post (the +// constant-time compare, the replay window, the one 401) is the implementer's +// obligation in `contracts/trigger-api-service.ts`, pinned by the server slice. + +const DOCUSIGN = { + signs: 'body', + header: 'X-DocuSign-Signature-1', + algorithm: 'hmac-sha256', + encoding: 'base64', +} as const; + +const STRIPE_STYLE = { + signs: 'timestamp.body', + header: 'Stripe-Signature', + algorithm: 'hmac-sha256', + encoding: 'hex', + timestampElement: 't', + digestElement: 'v1', +} as const; + +const GITHUB_STYLE = { + signs: 'body', + header: 'X-Hub-Signature-256', + algorithm: 'hmac-sha256', + encoding: 'hex', + prefix: 'sha256=', +} as const; + +function flow(type: string, config: Record) { + return { + name: 'clm_envelope_status', + label: 'Envelope status', + type, + nodes: [ + { id: 'start', type: 'start', label: 'Start', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; +} + +const KEYED = { hookId: 'docusign', secretRef: 'DOCUSIGN_CONNECT_KEY' }; + +/** The refusals the flow parse raises at `nodes.0.config.signature` and below, by path. */ +function refusalsUnderSignature(result: ReturnType) { + expect(result.success).toBe(false); + const issues = result.error!.issues.filter((i) => i.path.join('.').startsWith('nodes.0.config.signature')); + expect(issues.length).toBeGreaterThan(0); + for (const issue of issues) expect(issue.code).toBe('custom'); + return issues; +} + +const pathsOf = (issues: ReadonlyArray<{ path: PropertyKey[] }>) => issues.map((i) => i.path.join('.')); + +describe('config.signature — the three senders the card names parse whole', () => { + it.each([ + ['DocuSign Connect (base64 HMAC-SHA256 of the raw body)', DOCUSIGN], + ['Stripe-style t=…,v1=… (hex HMAC-SHA256 of .)', STRIPE_STYLE], + ['GitHub-style sha256= (a prefix before the digest)', GITHUB_STYLE], + ])('%s: a type api flow keyed by secretRef parses, the declaration served as written', (_, signature) => { + const result = FlowSchema.safeParse(flow('api', { ...KEYED, signature })); + expect(result.success).toBe(true); + expect(result.data!.nodes[0]!.config).toEqual({ ...KEYED, signature }); + }); + + it("a start node triggerType: 'api' binds the hook too, so the declaration is read there", () => { + expect( + FlowSchema.safeParse(flow('autolaunched', { triggerType: 'api', secretRef: 'DOCUSIGN_CONNECT_KEY', signature: DOCUSIGN })) + .success, + ).toBe(true); + }); + + it('the key is spelled signature', () => { + expect(API_TRIGGER_SIGNATURE_KEY).toBe('signature'); + }); +}); + +describe('one verifier per hook — apiTriggerVerifierOf decides on the key, never on whether it parses', () => { + it('no signature: the hook verifies x-objectstack-signature exactly as before, whatever its secret source', () => { + for (const config of [{ secretRef: 'SRM_HEARTBEAT_SECRET' }, { secret: 'literal' }, {}, undefined, 'not a config']) { + expect(apiTriggerVerifierOf(config)).toEqual({ kind: 'objectstack-signature' }); + } + }); + + it('a usable declaration REPLACES that check: the reader answers the declared scheme and the key it names', () => { + expect(apiTriggerVerifierOf({ ...KEYED, signature: DOCUSIGN })).toEqual({ + kind: 'declared', + signature: DOCUSIGN, + secretRef: 'DOCUSIGN_CONNECT_KEY', + }); + expect(apiTriggerVerifierOf({ ...KEYED, signature: STRIPE_STYLE })).toMatchObject({ + kind: 'declared', + signature: STRIPE_STYLE, + }); + }); + + it('a declaration that is present and unusable is never read as absent, so it cannot fall back to the platform check', () => { + for (const config of [ + { ...KEYED, signature: { ...DOCUSIGN, encoding: 'base64url' } }, + { ...KEYED, signature: 'X-DocuSign-Signature-1' }, + { ...KEYED, signature: null }, + { signature: DOCUSIGN }, + { secret: 'connect-key', signature: DOCUSIGN }, + { secret: 'connect-key', secretRef: 'DOCUSIGN_CONNECT_KEY', signature: DOCUSIGN }, + { secretRef: 'OS_AUTH_SECRET', signature: DOCUSIGN }, + { ...KEYED, signature: { ...STRIPE_STYLE, digestElement: 't' } }, + ]) { + expect(apiTriggerVerifierOf(config)).toEqual({ kind: 'unusable' }); + } + }); + + it('is not optional and does not stack: the shape has no key that asks for a second check or a fallback', () => { + for (const extra of [{ optional: true }, { fallback: 'x-objectstack-signature' }, { also: 'x-objectstack-signature' }]) { + expect(ApiTriggerSignatureSchema.safeParse({ ...DOCUSIGN, ...extra }).success).toBe(false); + expect(ApiTriggerSignatureSchema.safeParse({ ...STRIPE_STYLE, ...extra }).success).toBe(false); + } + }); +}); + +describe('the replay bound follows what is signed', () => { + it('a scheme that signs a timestamp must say where it comes from: timestamp.body without a timestamp source is refused', () => { + const { timestampElement: _t, ...noSource } = STRIPE_STYLE; + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: noSource }))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.timestampElement']); + }); + + it('a body-only scheme signs no time, so it cannot name a timestamp source (no replay bound is invented for it)', () => { + for (const extra of [{ timestampElement: 't' }, { timestampElement: 't', digestElement: 'v1' }]) { + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...DOCUSIGN, ...extra } }))); + expect(issues).toHaveLength(1); + expect(issues[0]!.message).toMatch(/signs no time/); + } + }); + + it('a timestamp.body scheme names its digest element too, distinct from the timestamp, and takes no prefix', () => { + const { digestElement: _d, ...noDigest } = STRIPE_STYLE; + expect(pathsOf(refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: noDigest }))))).toEqual([ + 'nodes.0.config.signature.digestElement', + ]); + expect( + pathsOf(refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...STRIPE_STYLE, digestElement: 't' } })))), + ).toEqual(['nodes.0.config.signature.digestElement']); + expect( + refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...STRIPE_STYLE, prefix: 'v1=' } }))), + ).toHaveLength(1); + }); +}); + +describe('refused at parse, at the start node config.signature', () => { + it('a verifier with no key reference', () => { + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { hookId: 'docusign', signature: DOCUSIGN }))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature']); + expect(issues[0]!.message).toMatch(/names no key/); + }); + + it('a verifier beside a literal secret, the cleared form included: a vendor key is never inline', () => { + for (const secret of ['connect-key', '']) { + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { secret, signature: DOCUSIGN }))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature']); + expect(issues[0]!.message).toMatch(/never an inline one/); + } + }); + + it('an unknown algorithm: HMAC-SHA256 is the one closed value', () => { + for (const algorithm of ['hmac-sha1', 'HMAC-SHA256', 'sha256', 'hmac-sha512', '', 256, undefined]) { + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...DOCUSIGN, algorithm } }))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.algorithm']); + } + }); + + it('an unknown encoding: hex or base64 only', () => { + for (const encoding of ['base64url', 'HEX', 'binary', 'utf8', '', undefined]) { + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...DOCUSIGN, encoding } }))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.encoding']); + } + }); + + it('a signs that is neither form, or none', () => { + for (const signs of ['timestamp+body', 'raw', 'headers', undefined]) { + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...DOCUSIGN, signs } }))); + expect(issues).toHaveLength(1); + } + }); + + it('a header that is not an HTTP header name, or none', () => { + for (const header of ['', 'X DocuSign Signature', 'X-Signature:', 'X-Sigé', 42, undefined]) { + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...DOCUSIGN, header } }))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.header']); + } + }); + + it('a prefix with a space or a comma, or a blank one', () => { + for (const prefix of ['', 'sha256 =', 'a,b']) { + const issues = refusalsUnderSignature(FlowSchema.safeParse(withPrefix(prefix))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.prefix']); + } + }); + + it('a value that is not a declaration object', () => { + for (const signature of ['X-DocuSign-Signature-1', ['body'], null, 7]) { + refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature }))); + } + }); + + it('a verifier on a flow that binds no api trigger: nothing would read it', () => { + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('autolaunched', { ...KEYED, signature: DOCUSIGN }))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature']); + expect(issues[0]!.message).toMatch(/binds no inbound `api` trigger/); + }); + + it('a start node without signature raises nothing here', () => { + expect(apiTriggerSignatureRefusals(flow('api', { secretRef: 'SRM_HEARTBEAT_SECRET' }))).toEqual([]); + expect(apiTriggerSignatureRefusals(flow('api', { secret: 'x' }))).toEqual([]); + expect(apiTriggerSignatureRefusals('not a flow')).toEqual([]); + }); +}); + +/** A type api flow keyed by secretRef whose GitHub-style declaration carries `prefix`. */ +function withPrefix(prefix: unknown) { + return flow('api', { ...KEYED, signature: { ...GITHUB_STYLE, prefix } }); +} diff --git a/packages/spec/src/automation/api-trigger-signature.zod.ts b/packages/spec/src/automation/api-trigger-signature.zod.ts new file mode 100644 index 00000000000..f18ab5abb1d --- /dev/null +++ b/packages/spec/src/automation/api-trigger-signature.zod.ts @@ -0,0 +1,350 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { resolveFlowTriggerKind } from './flow-trigger-kind'; +import { API_TRIGGER_SECRET_REF_KEY, apiTriggerSecretRefOf } from './api-trigger-secret-ref.zod'; + +/** + * 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 + * `.` 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 `.`), a + * GitHub-style `sha256=`. `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. {@link 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 ({@link 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 `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`. + */ + +/** The start-node `config` key that declares a third-party sender's signature scheme. */ +export const API_TRIGGER_SIGNATURE_KEY = 'signature'; + +/** An HTTP field name: RFC 9110's `token`. */ +const HTTP_FIELD_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** Visible ASCII, `,` excepted: the literal text a sender writes before its digest. */ +const DIGEST_PREFIX = /^[\x21-\x2B\x2D-\x7E]+$/; + +/** The name of one `NAME=value` element of a signature header. */ +const ELEMENT_NAME = /^[A-Za-z0-9_-]+$/; + +const header = z + .string({ + error: () => + '`config.signature.header` names the HTTP header the sender puts its signature in, as a string ' + + '(`X-DocuSign-Signature-1`, `Stripe-Signature`).', + }) + .regex( + HTTP_FIELD_NAME, + '`config.signature.header` must be an HTTP header name: letters, digits and `!#$%&\'*+-.^_`|~`, nothing else ' + + '(`X-DocuSign-Signature-1`). It is matched without regard to case, as HTTP matches header names.', + ) + .describe( + '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`.', + ); + +const algorithm = z + .enum(['hmac-sha256'], { + error: () => + '`config.signature.algorithm` must be `hmac-sha256`, the one algorithm an inbound hook verifies. A sender that ' + + 'signs with anything else cannot be admitted by a declared verifier.', + }) + .describe('The MAC the sender computes under the key `config.secretRef` names. `hmac-sha256` is the only one.'); + +const encoding = z + .enum(['hex', 'base64'], { + error: () => + '`config.signature.encoding` must be `hex` or `base64`: how the sender writes the 32-byte digest in the header ' + + '(DocuSign Connect writes `base64`; a Stripe-style or GitHub-style sender writes `hex`).', + }) + .describe( + '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.', + ); + +const element = (key: 'timestampElement' | 'digestElement', what: string) => + z + .string({ + error: () => + `A scheme that signs \`timestamp.body\` reads its header as \`NAME=value\` elements separated by \`,\` and ` + + `names the element that carries ${what}: \`config.signature.${key}\`.`, + }) + .regex( + ELEMENT_NAME, + `\`config.signature.${key}\` is the NAME of one \`NAME=value\` element of the header: letters, digits, \`_\` ` + + `and \`-\`, without the \`=\`.`, + ); + +/** + * A body-only scheme: the sender signs the raw body and nothing else + * (DocuSign Connect, a GitHub-style `sha256=` sender). The whole header value, + * after `prefix`, is the one digest. It signs no time, so it carries no + * timestamp source and the hook bounds no replay for it: the sender offers + * nothing to bound one with. + */ +const BodyOnlySignatureSchema = z.strictObject( + { + signs: z.literal('body').describe('The sender signs the raw body alone. No replay window applies.'), + header, + algorithm, + encoding, + prefix: z + .string({ error: () => '`config.signature.prefix` is the literal text before the digest, as a string (`sha256=`).' }) + .regex( + DIGEST_PREFIX, + '`config.signature.prefix` must be visible ASCII with no space and no `,` (`sha256=`).', + ) + .optional() + .describe( + '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.', + ), + }, + { + error: (issue) => + issue.code === 'unrecognized_keys' && + (issue.keys.includes('timestampElement') || issue.keys.includes('digestElement')) + ? 'A body-only scheme (`signs: \'body\'`) signs no time, so it has no timestamp to bound a replay with and no ' + + 'element list to read: `timestampElement` and `digestElement` belong to `signs: \'timestamp.body\'`. ' + + 'Declare `signs: \'timestamp.body\'` when the sender signs `.`, or remove them.' + : undefined, + }, +); + +/** + * A timestamped scheme, Stripe-style: the sender signs `.` and + * writes `t` beside the digest in the one header, as `NAME=value` elements + * separated by `,` (`t=1700000000,v1=`). The timestamp is inside the + * signed material, so the hook refuses a `t` outside the same window the + * `x-objectstack-signature` check applies. + */ +const TimestampedSignatureSchema = z.strictObject( + { + signs: z + .literal('timestamp.body') + .describe('The sender signs `.`, `t` read from `timestampElement`. The replay window applies.'), + header, + algorithm, + encoding, + timestampElement: element('timestampElement', 'the timestamp (`t` for a Stripe-style `t=…,v1=…`)').describe( + 'Where the timestamp comes from: the NAME of the header element `NAME=` (`t`). Its value, exactly ' + + 'as received, is the `` of the signed `.`.', + ), + digestElement: element('digestElement', 'the digest (`v1` for a Stripe-style `t=…,v1=…`)').describe( + '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.', + ), + }, + { + error: (issue) => + issue.code === 'unrecognized_keys' && issue.keys.includes('prefix') + ? 'A `timestamp.body` scheme locates its digest by `digestElement` (`v1` for `t=…,v1=…`), not by a `prefix`. ' + + 'Remove `prefix`.' + : undefined, + }, +); + +/** + * `config.signature` — how a third-party sender signs its posts to this hook. + * Two forms, told apart by `signs`; see the module header for what declaring + * one changes. + * + * @example DocuSign Connect — base64 HMAC-SHA256 of the raw body + * { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' } + * + * @example Stripe-style — `Stripe-Signature: t=1700000000,v1=")>` + * { signs: 'timestamp.body', header: 'Stripe-Signature', algorithm: 'hmac-sha256', encoding: 'hex', + * timestampElement: 't', digestElement: 'v1' } + * + * @example GitHub-style — `X-Hub-Signature-256: sha256=` + * { signs: 'body', header: 'X-Hub-Signature-256', algorithm: 'hmac-sha256', encoding: 'hex', prefix: 'sha256=' } + */ +export const ApiTriggerSignatureSchema = z + .discriminatedUnion('signs', [BodyOnlySignatureSchema, TimestampedSignatureSchema], { + error: (issue) => + issue.code === 'invalid_union' + ? '`config.signature.signs` says what the sender signs: `body` (the raw body alone, as DocuSign Connect ' + + 'signs it) or `timestamp.body` (`.`, as a Stripe-style `t=…,v1=…` sender signs it).' + : issue.code === 'invalid_type' + ? '`config.signature` is an object declaring how the sender signs (`signs`, `header`, `algorithm`, ' + + '`encoding`), never a header name or a key on its own.' + : undefined, + }) + .describe( + '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.', + ); + +export type ApiTriggerSignature = z.input; + +/** + * Which verifier an `api` hook has. Exactly one, decided by whether the start + * node's `config` HAS a `signature` key: + * + * - `objectstack-signature`: no `signature`. The hook verifies + * `x-objectstack-signature` under its secret (a literal `secret`, the + * write-only flow credential store, or `secretRef`), as before this key. + * - `declared`: a usable `signature` and the `secretRef` naming its key. The + * hook verifies this scheme and nothing else. + * - `unusable`: a `signature` the flow parse refuses (an unparsable shape, no + * usable key reference, a literal `secret` beside it). A hook in this state + * admits no post: it is ⛔ never read as `objectstack-signature`, so a broken + * declaration cannot fall back to the platform's check, and never admitted + * unsigned. + */ +export type ApiTriggerVerifier = + | { readonly kind: 'objectstack-signature' } + | { readonly kind: 'declared'; readonly signature: ApiTriggerSignature; readonly secretRef: string } + | { readonly kind: 'unusable' }; + +/** One refusal, at a path relative to the flow (`['nodes', 0, 'config', 'signature', 'encoding']`). */ +export interface ApiTriggerSignatureRefusal { + readonly path: ReadonlyArray; + readonly message: string; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function has(config: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(config, key); +} + +/** + * Every refusal one start node's `config.signature` raises that does not + * depend on the flow around it, at paths relative to that `config`. Empty for + * a `config` with no `signature`. The judge {@link apiTriggerSignatureRefusals} + * and the reader {@link apiTriggerVerifierOf} share it, so the two cannot + * disagree about which declarations are usable. + */ +function signatureConfigRefusals(config: Record): ApiTriggerSignatureRefusal[] { + if (!has(config, API_TRIGGER_SIGNATURE_KEY)) return []; + const out: ApiTriggerSignatureRefusal[] = []; + const parsed = ApiTriggerSignatureSchema.safeParse(config[API_TRIGGER_SIGNATURE_KEY]); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + out.push({ path: [API_TRIGGER_SIGNATURE_KEY, ...(issue.path as Array)], message: issue.message }); + } + } else if (parsed.data.signs === 'timestamp.body' && parsed.data.timestampElement === parsed.data.digestElement) { + out.push({ + path: [API_TRIGGER_SIGNATURE_KEY, 'digestElement'], + message: + `\`config.signature\` names the element \`${parsed.data.digestElement}\` for both the timestamp and the digest. ` + + 'They are two elements of the header (`t` and `v1` in `t=…,v1=…`); name each one.', + }); + } + if (has(config, 'secret')) { + out.push({ + path: [API_TRIGGER_SIGNATURE_KEY], + message: + 'The start node declares `config.signature` beside a literal `config.secret`. A sender\'s signature is ' + + 'verified only under a key named by reference, never an inline one: name the key in `config.secretRef` (the ' + + 'deployment fills that name) and remove `secret`.', + }); + } else if (!has(config, API_TRIGGER_SECRET_REF_KEY)) { + out.push({ + path: [API_TRIGGER_SIGNATURE_KEY], + message: + '`config.signature` declares how the sender signs, but the start node names no key to verify it under. Name ' + + 'the key in `config.secretRef` (an environment variable\'s name the deployment fills, such as ' + + '`DOCUSIGN_CONNECT_KEY`). A vendor key is never written inline.', + }); + } + return out; +} + +/** + * The verifier a start node's `config` declares for its hook — the one reader + * the engine, the trigger and the lint share, so none restates the rule. See + * {@link ApiTriggerVerifier} for the three answers. Structural: a `config` that + * is not an object answers `objectstack-signature`, as it declares nothing. + */ +export function apiTriggerVerifierOf(config: unknown): ApiTriggerVerifier { + if (!isRecord(config) || !has(config, API_TRIGGER_SIGNATURE_KEY)) return { kind: 'objectstack-signature' }; + if (signatureConfigRefusals(config).length > 0) return { kind: 'unusable' }; + const secretRef = apiTriggerSecretRefOf(config); + // An unusable `secretRef` is refused by its own judge, not this one; it + // still leaves the verifier without a key. + if (secretRef === undefined) return { kind: 'unusable' }; + return { + kind: 'declared', + signature: ApiTriggerSignatureSchema.parse(config[API_TRIGGER_SIGNATURE_KEY]), + secretRef, + }; +} + +/** + * Every refusal the start nodes of `flow` raise for `config.signature` — the + * five in this module's header. Structural, like + * {@link resolveFlowTriggerKind}: a value that is not a flow yields none. + */ +export function apiTriggerSignatureRefusals(flow: unknown): ApiTriggerSignatureRefusal[] { + if (!flow || typeof flow !== 'object') return []; + const nodes = (flow as { nodes?: unknown }).nodes; + if (!Array.isArray(nodes)) return []; + const bindsApi = resolveFlowTriggerKind(flow) === 'api'; + const out: ApiTriggerSignatureRefusal[] = []; + nodes.forEach((node, index) => { + if (!node || typeof node !== 'object' || (node as { type?: unknown }).type !== 'start') return; + const config = (node as { config?: unknown }).config; + if (!isRecord(config) || !has(config, API_TRIGGER_SIGNATURE_KEY)) return; + const own = signatureConfigRefusals(config); + for (const refusal of own) out.push({ path: ['nodes', index, 'config', ...refusal.path], message: refusal.message }); + if (own.length === 0 && !bindsApi) { + out.push({ + path: ['nodes', index, 'config', API_TRIGGER_SIGNATURE_KEY], + message: + '`config.signature` declares how an inbound hook\'s sender signs, but this flow binds no inbound `api` ' + + 'trigger, so nothing ever reads it. Declare `type: \'api\'` (or start-node `triggerType: \'api\'`) for a ' + + 'flow that receives signed posts, or remove `signature`.', + }); + } + }); + return out; +} diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index bec185ae1c5..f94e4bb945c 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -27,6 +27,7 @@ import { collectFlowGraphs, parseFlowNodeRegions } from './control-flow.zod'; import { predicateSlotRefusal, resolveFlowNodeExpressions, type FlowNodeSiblingBlocks } from './flow-node-expression-paths'; import { flowNodeConfigRefusals } from './flow-node-config-refusals'; import { apiTriggerSecretRefRefusals } from './api-trigger-secret-ref.zod'; +import { apiTriggerSignatureRefusals } from './api-trigger-signature.zod'; import { saveNoticePlacementRefusals } from './save-notice-placement'; // [#23543 F3] The loose `config` keys of the two sibling-block node types — one // table, shared with the two D2 lifts that still run at rehydration. @@ -1701,6 +1702,14 @@ export const FlowSchema = lazySchema(() => strictObject( ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); } + // A third-party sender's signature scheme (`config.signature`), declared + // beside `config.secretRef`, which names its key. Declared, it replaces the + // `x-objectstack-signature` check for that hook; the five refusals and why + // each exists are `api-trigger-signature.zod.ts`'s header. + for (const refusal of apiTriggerSignatureRefusals(flow)) { + ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); + } + // [#28251] A `save_notice` node answers the write that fired the run, so it // stands only in a flow a create or update record trigger starts — anywhere // else no write waits on the run. Refused at each such node, at any region diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index a8bb57d9a41..a64470e56d0 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -48,6 +48,8 @@ export * from './time-relative-trigger.zod'; export * from './flow-trigger-kind'; // An `api` flow's hook secret named by reference — the deployment fills it. export * from './api-trigger-secret-ref.zod'; +// A third-party sender's signature scheme on an `api` hook, keyed by `secretRef`. +export * from './api-trigger-signature.zod'; // The acting-organization declaration a time-triggered flow carries, and the // one refusal sentence the schedule trigger and the time-relative sweep both // say it with (commit ecdfc9411). ⛔ `FlowSchema` does NOT emit that sentence: the key is From e94047769409b910e613d776f0b0560a520d5bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 00:17:17 +0000 Subject: [PATCH 2/8] chore(spec): regenerate the surfaces, references and strictness ledger for config.signature; export its two arms The discriminated union's two arms are exported as their own defs (with type aliases and isomorphism pins) so the authorable-surface ledger records their keys. The trigger-api contract text states what the implementer owes for a declared scheme. Claude-Session: https://claude.ai/code/session_01WGufzggC8errMtPRYBUmoY Co-authored-by: Claude --- .changeset/29983-api-trigger-signature.md | 21 +++ .../automation/api-trigger-secret-ref.mdx | 4 + .../automation/api-trigger-signature.mdx | 156 ++++++++++++++++++ content/docs/references/automation/index.mdx | 3 +- content/docs/references/automation/meta.json | 1 + content/docs/references/index.mdx | 9 +- .../automation.md | 9 +- .../2026-07-unknown-key-strictness-ledger.md | 1 + packages/spec/api-surface/automation.json | 11 ++ .../spec/authorable-surface/automation.json | 11 ++ packages/spec/declaration-map/automation.json | 6 + packages/spec/export-origins/automation.json | 11 ++ .../spec/json-schema.manifest/automation.json | 3 + .../automation/api-trigger-secret-ref.zod.ts | 4 + .../automation/api-trigger-signature.zod.ts | 20 ++- .../spec/src/contracts/trigger-api-service.ts | 54 +++++- .../src/type-alias-convention.pin.test.ts | 20 ++- .../references/_index.md | 1 + 18 files changed, 324 insertions(+), 21 deletions(-) create mode 100644 .changeset/29983-api-trigger-signature.md create mode 100644 content/docs/references/automation/api-trigger-signature.mdx diff --git a/.changeset/29983-api-trigger-signature.md b/.changeset/29983-api-trigger-signature.md new file mode 100644 index 00000000000..448c3279076 --- /dev/null +++ b/.changeset/29983-api-trigger-signature.md @@ -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 + + + +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, and one on a flow that binds no `api` trigger. + +New exports from `@objectstack/spec/automation`: `ApiTriggerSignatureSchema`, `ApiTriggerSignature`, `API_TRIGGER_SIGNATURE_KEY`, `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, and the one `401 INVALID_SIGNATURE` for every failure. + +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. diff --git a/content/docs/references/automation/api-trigger-secret-ref.mdx b/content/docs/references/automation/api-trigger-secret-ref.mdx index 76ff692e6e9..e67576f24eb 100644 --- a/content/docs/references/automation/api-trigger-secret-ref.mdx +++ b/content/docs/references/automation/api-trigger-secret-ref.mdx @@ -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`; diff --git a/content/docs/references/automation/api-trigger-signature.mdx b/content/docs/references/automation/api-trigger-signature.mdx new file mode 100644 index 00000000000..a4c2720fc89 --- /dev/null +++ b/content/docs/references/automation/api-trigger-signature.mdx @@ -0,0 +1,156 @@ +--- +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 +`.` 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 `.`), a +GitHub-style `sha256=`. `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 `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`. + + +**Source:** `packages/spec/src/automation/api-trigger-signature.zod.ts` + + +## 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`. | +| **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`. | +| **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 `.` 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` 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`. | +| **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=` (`t`). Its value, exactly as received, is the `` of the signed `.`. | +| **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 `.` 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` 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`. | +| **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=` (`t`). Its value, exactly as received, is the `` of the signed `.`. | +| **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. | + + +--- + diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index 8bbb6ca4b88..97ca0151636 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -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/. */} @@ -10,6 +10,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta + diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index 67a1ec3efe6..8520d7df938 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -14,6 +14,7 @@ "approval", "---More---", "api-trigger-secret-ref", + "api-trigger-signature", "assignment-rule", "builtin-node-config", "flow-function", diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index ab7006848a7..16bf933b4ea 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -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/. */} @@ -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. | @@ -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 | --- @@ -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` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md index d963ca15fbd..8822289acee 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `automation/` | 66 | 42 | 0 | 1 | 23 | +| `automation/` | 68 | 44 | 0 | 1 | 23 | ## `automation/` — sites @@ -31,6 +31,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | File | Sites | |---|---| +| `api-trigger-signature.zod.ts` | 2 | | `approval.zod.ts` | 4 | | `assignment-rule.zod.ts` | 3 | | `bpmn-interop.zod.ts` | 5 | @@ -44,7 +45,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `schemaless-node-config.zod.ts` | 4 | | `time-relative-trigger.zod.ts` | 1 | | `webhook.zod.ts` | 1 | -| **total** | **66** | +| **total** | **68** | ## `automation/` — open @@ -52,7 +53,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**23 strip of 66**, in 5 file(s). +**23 strip of 68**, in 5 file(s). | File | Strip | Sites | |---|---|---| @@ -61,7 +62,7 @@ over it is here. | `execution.zod.ts` | 12 | 12 | | `flow.zod.ts` | 1 | 11 | | `node-executor.zod.ts` | 4 | 4 | -| **total** | **23** | **66** | +| **total** | **23** | **68** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 948ee886e31..47f7ccb940b 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -751,6 +751,7 @@ column does not move and the `strip` column falls by the count of what left. | `time-relative-trigger.zod.ts` | authorable | **Undeclared until the #4001 re-measurement, and invisible for the worst possible reason**: `TimeRelativeTriggerSchema` is written `z\n .object({`, the old textual counter matched zero sites, and a zero-site file is SKIPPED by the coverage walk as "nothing to classify". So the gate whose whole promise is "no undeclared surface" reported green over an authorable schema — the same shape as `data/driver/`, one layer subtler, because this time the file was not hidden by the walk but by the counter feeding it. Classification is not a guess: the file's own `@example` blocks author it by hand into a flow start node (`config: { timeRelative: { object, dateField, offsetDays, filter } }`), which is the authoring door. A stripped key here means the sweep silently never matches — `offsetDay` for `offsetDays` returns a trigger that never fires, reported as configured. **Strict as of #4001 batch 11**, and closing it turned up one thing the triage did not predict: this schema is `safeParse`d at BIND time by `TimeRelativeTriggerPlugin` (`time-relative-trigger.ts`), not only at authoring — so the descriptor sitting under the deliberately-OPEN node `config` slot (ADR-0018) now has exactly one gate, and it is a runtime one. The behaviour change is the campaign's whole thesis in miniature: `{ …valid, offsetDay: 7 }` used to bind a sweep that ran daily with the author's narrowing discarded; it now refuses to bind and the plugin's warning carries the key and the rename | | `flow-function.zod.ts` | authorable | `FlowFunctionDeclarationSchema` (#4396) — the `{ handler, effect }` form of a `defineStack({ functions })` entry. Authored, but note what an undeclared key here would be: a sibling of a **live function**, not data. `defineStack`'s union already rejects a record whose `handler` is not callable, and the boot-path reader is the hand-written `normalizeFlowFunctionEntry` rather than a `.parse()` (re-validating a live handler every boot buys nothing), so strictness would bind at authoring only. **Strict as of #4001 batch 11**, and the verify-first pass confirmed that reading exactly — stated in the code rather than left implied, because a tightening must not claim reach it does not have. It is still worth having for the reason the reading first made it look pointless: `normalizeFlowFunctionEntry` takes TWO keys and ignores the rest **by construction**, so a misspelled `effect` was dropped at the schema and then not looked for by the reader — and the failure runs the quiet way. The function registers, runs, and its writes are counted as none, which is precisely what keeps #4354's broken-sweep query (`selected > 0 AND acted = 0 AND unmeasured = 0`) silent on the one run that needed it | | `assignment-rule.zod.ts` | authorable | **strict from birth (#25769, ADR-0142 D5)** — the rule, its entries and the entry target are `strictObject` from the first commit, the stack's `assignmentRules` being the authoring door; nothing ever relied on a strip. The entry's cross-field rules (`strategy` only with a queue target, `load` exactly with `least_loaded`) are `superRefine`s on the closed shape, not key gates | +| `api-trigger-signature.zod.ts` | authorable | **strict from birth** — the two arms of `ApiTriggerSignatureSchema` (`signs: 'body'` and `signs: 'timestamp.body'`) are `strictObject` from the first commit. The door is an `api` flow's start-node `config.signature`, judged by `FlowSchema`'s `superRefine` (`apiTriggerSignatureRefusals`) inside the open node `config`, so the open-slot exemption above does not extend to it. Closed on purpose: an undeclared key here would be a verifier setting nobody reads on an auth boundary (`optional`, a fallback), and the strict arm is what refuses it | `state-machine.zod.ts` had a row here (authorable, **strict as of #4001 批 10** — all six sites: `ActionRef` / `GuardRef` / `Transition` / `StateNode` + `.meta` / `StateMachine`) until **#21320 retired the file whole** (ADR-0049 enforce-or-remove; ruled D, retire, on objectstack-ai/cloud#2569). The row's own finding was that ADR-0020 had already removed every record-lifecycle door to this XState shape and that ONE authoring door survived — `ai/agent.zod.ts`'s `lifecycle` — so the file was authorable surface rather than dead surface, and was closed rather than reclassified. That door was then measured to have no reader in either repository: the agent conversation state machine was parsed and never enforced. `agent.lifecycle` became a `retiredKey()` tombstone (the ADR-0087 conversion `agent-lifecycle-removed` deletes it from stored rows and sources), the file lost its last importer, and its five published defs (`automation/StateMachine`, `StateNode`, `Transition`, `ActionRef`, `GuardRef`) entered `RETIRED_DEFS_BY_MAJOR` with the D3 entry `agent-lifecycle-retired`. A record's legal transitions remain the `state_machine` rule in `data/validation.zod.ts`, which never used this file. diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index d5b2f726088..2bb3a6d13f8 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -4,6 +4,7 @@ "exports": [ "API_TRIGGER_SECRET_REF_KEY (const)", "API_TRIGGER_SECRET_REF_RESERVED_PREFIX (const)", + "API_TRIGGER_SIGNATURE_KEY (const)", "APPROVAL_BRANCH_LABELS (const)", "APPROVAL_NODE_TYPE (const)", "APPROVAL_REVISE_NODE_TYPE (const)", @@ -21,9 +22,17 @@ "ActionDescriptorSchema (const)", "ActionParadigm (type)", "ActionParadigmSchema (const)", + "ApiTriggerBodySignature (type)", + "ApiTriggerBodySignatureSchema (const)", "ApiTriggerSecretRef (type)", "ApiTriggerSecretRefRefusal (interface)", "ApiTriggerSecretRefSchema (const)", + "ApiTriggerSignature (type)", + "ApiTriggerSignatureRefusal (interface)", + "ApiTriggerSignatureSchema (const)", + "ApiTriggerTimestampedSignature (type)", + "ApiTriggerTimestampedSignatureSchema (const)", + "ApiTriggerVerifier (type)", "ApprovalDecision (const)", "ApprovalDecision (type)", "ApprovalEscalation (type)", @@ -314,6 +323,8 @@ "analyzeRegion (function)", "apiTriggerSecretRefOf (function)", "apiTriggerSecretRefRefusals (function)", + "apiTriggerSignatureRefusals (function)", + "apiTriggerVerifierOf (function)", "approverTypeIsOrgScoped (function)", "builtinNodeConfigKeysJudged (function)", "canonicalApproverType (function)", diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 4bc52caf3a5..c922c6f7998 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -21,6 +21,17 @@ "automation/ActionDescriptor:supportsRetry", "automation/ActionDescriptor:type", "automation/ActionDescriptor:version", + "automation/ApiTriggerBodySignature:algorithm", + "automation/ApiTriggerBodySignature:encoding", + "automation/ApiTriggerBodySignature:header", + "automation/ApiTriggerBodySignature:prefix", + "automation/ApiTriggerBodySignature:signs", + "automation/ApiTriggerTimestampedSignature:algorithm", + "automation/ApiTriggerTimestampedSignature:digestElement", + "automation/ApiTriggerTimestampedSignature:encoding", + "automation/ApiTriggerTimestampedSignature:header", + "automation/ApiTriggerTimestampedSignature:signs", + "automation/ApiTriggerTimestampedSignature:timestampElement", "automation/ApprovalEscalation:action", "automation/ApprovalEscalation:enabled", "automation/ApprovalEscalation:escalateTo", diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json index d3a735284da..54cb29d3134 100644 --- a/packages/spec/declaration-map/automation.json +++ b/packages/spec/declaration-map/automation.json @@ -8,8 +8,14 @@ "ActionDescriptorSchema": "automation/ActionDescriptor", "ActionParadigm": "automation/ActionParadigm", "ActionParadigmSchema": "automation/ActionParadigm", + "ApiTriggerBodySignature": "automation/ApiTriggerBodySignature", + "ApiTriggerBodySignatureSchema": "automation/ApiTriggerBodySignature", "ApiTriggerSecretRef": "automation/ApiTriggerSecretRef", "ApiTriggerSecretRefSchema": "automation/ApiTriggerSecretRef", + "ApiTriggerSignature": "automation/ApiTriggerSignature", + "ApiTriggerSignatureSchema": "automation/ApiTriggerSignature", + "ApiTriggerTimestampedSignature": "automation/ApiTriggerTimestampedSignature", + "ApiTriggerTimestampedSignatureSchema": "automation/ApiTriggerTimestampedSignature", "ApprovalDecision": "automation/ApprovalDecision", "ApprovalEscalation": "automation/ApprovalEscalation", "ApprovalEscalationSchema": "automation/ApprovalEscalation", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index acdadef7504..61524afe0c0 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -4,6 +4,7 @@ "exports": { "API_TRIGGER_SECRET_REF_KEY": "src/automation/api-trigger-secret-ref.zod.ts#API_TRIGGER_SECRET_REF_KEY (const)", "API_TRIGGER_SECRET_REF_RESERVED_PREFIX": "src/automation/api-trigger-secret-ref.zod.ts#API_TRIGGER_SECRET_REF_RESERVED_PREFIX (const)", + "API_TRIGGER_SIGNATURE_KEY": "src/automation/api-trigger-signature.zod.ts#API_TRIGGER_SIGNATURE_KEY (const)", "APPROVAL_BRANCH_LABELS": "src/automation/approval.zod.ts#APPROVAL_BRANCH_LABELS (const)", "APPROVAL_NODE_TYPE": "src/automation/approval.zod.ts#APPROVAL_NODE_TYPE (const)", "APPROVAL_REVISE_NODE_TYPE": "src/automation/approval.zod.ts#APPROVAL_REVISE_NODE_TYPE (const)", @@ -21,9 +22,17 @@ "ActionDescriptorSchema": "src/automation/node-executor.zod.ts#ActionDescriptorSchema (const)", "ActionParadigm": "src/automation/node-executor.zod.ts#ActionParadigm (type)", "ActionParadigmSchema": "src/automation/node-executor.zod.ts#ActionParadigmSchema (const)", + "ApiTriggerBodySignature": "src/automation/api-trigger-signature.zod.ts#ApiTriggerBodySignature (type)", + "ApiTriggerBodySignatureSchema": "src/automation/api-trigger-signature.zod.ts#ApiTriggerBodySignatureSchema (const)", "ApiTriggerSecretRef": "src/automation/api-trigger-secret-ref.zod.ts#ApiTriggerSecretRef (type)", "ApiTriggerSecretRefRefusal": "src/automation/api-trigger-secret-ref.zod.ts#ApiTriggerSecretRefRefusal (interface)", "ApiTriggerSecretRefSchema": "src/automation/api-trigger-secret-ref.zod.ts#ApiTriggerSecretRefSchema (const)", + "ApiTriggerSignature": "src/automation/api-trigger-signature.zod.ts#ApiTriggerSignature (type)", + "ApiTriggerSignatureRefusal": "src/automation/api-trigger-signature.zod.ts#ApiTriggerSignatureRefusal (interface)", + "ApiTriggerSignatureSchema": "src/automation/api-trigger-signature.zod.ts#ApiTriggerSignatureSchema (const)", + "ApiTriggerTimestampedSignature": "src/automation/api-trigger-signature.zod.ts#ApiTriggerTimestampedSignature (type)", + "ApiTriggerTimestampedSignatureSchema": "src/automation/api-trigger-signature.zod.ts#ApiTriggerTimestampedSignatureSchema (const)", + "ApiTriggerVerifier": "src/automation/api-trigger-signature.zod.ts#ApiTriggerVerifier (type)", "ApprovalDecision": "src/automation/approval.zod.ts#ApprovalDecision (type)", "ApprovalEscalation": "src/automation/approval.zod.ts#ApprovalEscalation (type)", "ApprovalEscalationParsed": "src/automation/approval.zod.ts#ApprovalEscalationParsed (type)", @@ -307,6 +316,8 @@ "analyzeRegion": "src/automation/control-flow.zod.ts#analyzeRegion (function)", "apiTriggerSecretRefOf": "src/automation/api-trigger-secret-ref.zod.ts#apiTriggerSecretRefOf (function)", "apiTriggerSecretRefRefusals": "src/automation/api-trigger-secret-ref.zod.ts#apiTriggerSecretRefRefusals (function)", + "apiTriggerSignatureRefusals": "src/automation/api-trigger-signature.zod.ts#apiTriggerSignatureRefusals (function)", + "apiTriggerVerifierOf": "src/automation/api-trigger-signature.zod.ts#apiTriggerVerifierOf (function)", "approverTypeIsOrgScoped": "src/automation/approval.zod.ts#approverTypeIsOrgScoped (function)", "builtinNodeConfigKeysJudged": "src/automation/flow-node-config-refusals.ts#builtinNodeConfigKeysJudged (function)", "canonicalApproverType": "src/automation/approval.zod.ts#canonicalApproverType (function)", diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json index 0f09f72e53d..af7064ee146 100644 --- a/packages/spec/json-schema.manifest/automation.json +++ b/packages/spec/json-schema.manifest/automation.json @@ -5,7 +5,10 @@ "automation/ActionCategory", "automation/ActionDescriptor", "automation/ActionParadigm", + "automation/ApiTriggerBodySignature", "automation/ApiTriggerSecretRef", + "automation/ApiTriggerSignature", + "automation/ApiTriggerTimestampedSignature", "automation/ApprovalDecision", "automation/ApprovalEscalation", "automation/ApprovalNodeApprover", diff --git a/packages/spec/src/automation/api-trigger-secret-ref.zod.ts b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts index e92bc293655..034e8bc880f 100644 --- a/packages/spec/src/automation/api-trigger-secret-ref.zod.ts +++ b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts @@ -24,6 +24,10 @@ import { resolveFlowTriggerKind } from './flow-trigger-kind'; * 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 — {@link ApiTriggerSecretRefSchema}; diff --git a/packages/spec/src/automation/api-trigger-signature.zod.ts b/packages/spec/src/automation/api-trigger-signature.zod.ts index f18ab5abb1d..0bcede652ff 100644 --- a/packages/spec/src/automation/api-trigger-signature.zod.ts +++ b/packages/spec/src/automation/api-trigger-signature.zod.ts @@ -125,8 +125,11 @@ const element = (key: 'timestampElement' | 'digestElement', what: string) => * after `prefix`, is the one digest. It signs no time, so it carries no * timestamp source and the hook bounds no replay for it: the sender offers * nothing to bound one with. + * + * Exported as its own def so the published surface records its keys (a union's + * arms are otherwise invisible to the authorable-surface ledger). */ -const BodyOnlySignatureSchema = z.strictObject( +export const ApiTriggerBodySignatureSchema = z.strictObject( { signs: z.literal('body').describe('The sender signs the raw body alone. No replay window applies.'), header, @@ -153,6 +156,9 @@ const BodyOnlySignatureSchema = z.strictObject( 'Declare `signs: \'timestamp.body\'` when the sender signs `.`, or remove them.' : undefined, }, +).describe( + '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.', ); /** @@ -160,9 +166,10 @@ const BodyOnlySignatureSchema = z.strictObject( * writes `t` beside the digest in the one header, as `NAME=value` elements * separated by `,` (`t=1700000000,v1=`). The timestamp is inside the * signed material, so the hook refuses a `t` outside the same window the - * `x-objectstack-signature` check applies. + * `x-objectstack-signature` check applies. Exported as its own def for the + * same reason as {@link ApiTriggerBodySignatureSchema}. */ -const TimestampedSignatureSchema = z.strictObject( +export const ApiTriggerTimestampedSignatureSchema = z.strictObject( { signs: z .literal('timestamp.body') @@ -186,6 +193,9 @@ const TimestampedSignatureSchema = z.strictObject( 'Remove `prefix`.' : undefined, }, +).describe( + 'A sender that signs `.` 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.', ); /** @@ -204,7 +214,7 @@ const TimestampedSignatureSchema = z.strictObject( * { signs: 'body', header: 'X-Hub-Signature-256', algorithm: 'hmac-sha256', encoding: 'hex', prefix: 'sha256=' } */ export const ApiTriggerSignatureSchema = z - .discriminatedUnion('signs', [BodyOnlySignatureSchema, TimestampedSignatureSchema], { + .discriminatedUnion('signs', [ApiTriggerBodySignatureSchema, ApiTriggerTimestampedSignatureSchema], { error: (issue) => issue.code === 'invalid_union' ? '`config.signature.signs` says what the sender signs: `body` (the raw body alone, as DocuSign Connect ' + @@ -221,6 +231,8 @@ export const ApiTriggerSignatureSchema = z ); export type ApiTriggerSignature = z.input; +export type ApiTriggerBodySignature = z.input; +export type ApiTriggerTimestampedSignature = z.input; /** * Which verifier an `api` hook has. Exactly one, decided by whether the start diff --git a/packages/spec/src/contracts/trigger-api-service.ts b/packages/spec/src/contracts/trigger-api-service.ts index f1cd8b8a036..ae717c51726 100644 --- a/packages/spec/src/contracts/trigger-api-service.ts +++ b/packages/spec/src/contracts/trigger-api-service.ts @@ -54,7 +54,8 @@ export interface ITriggerApiService { * id gets this `503`, signed or not. It stays a `503` on purpose: it is * the deployment's fault, a sender retries it, and it lasts only while * the secret cannot be read. - * - It verifies the `x-objectstack-signature` header in its timestamped + * - Unless the hook declares its sender's own scheme (next item), it + * verifies the `x-objectstack-signature` header in its timestamped * form, the only one it accepts: `t=,v1=`, where the * hex is the HMAC-SHA256 of `.` under that secret, and a `t` * more than 300 seconds from the server's clock is refused. A missing or @@ -62,9 +63,50 @@ export interface ITriggerApiService { * (`sha256=` and the hex HMAC-SHA256 of the raw body alone, which signs * no time; refused even when it is the secret's HMAC of the body) get the * unverified-post refusal below. + * - **A sender's own scheme, declared, REPLACES that check** for its hook: + * the start node's `config.signature` + * (`automation/api-trigger-signature.zod.ts`, for a vendor that signs its + * callbacks its own way). `apiTriggerVerifierOf` is the one reader of + * which verifier a hook has, and every hook has exactly one. Such a hook + * verifies the declared scheme under the key its `config.secretRef` + * names, read at this moment like any hook secret (the `503` above when + * it cannot be read), and nothing else: ⛔ it never reads + * `x-objectstack-signature`, so a valid one admits nothing, and ⛔ it + * never admits a post that lacks the declared header. A hook whose + * declaration the reader answers `unusable` admits no post at all. The + * implementer: + * - reads the declared `header`, its name matched without regard to + * case. For `signs: 'body'` the whole trimmed value, after `prefix` + * when one is declared (a value without it fails), is the one digest. + * For `signs: 'timestamp.body'` the value is `NAME=value` elements + * separated by `,`, each trimmed and split at its first `=`: exactly + * one element named `timestampElement`, whose value is whole unix + * seconds written as `x-objectstack-signature` writes its `t` (decimal, + * no sign, no leading zero, at most fifteen digits), and one or more + * named `digestElement` (a sender rolling its key sends one per key). + * An element with any other name is ignored. A missing, repeated or + * malformed timestamp, or no digest, fails. + * - computes the HMAC-SHA256, under the key, of the raw body exactly as + * received for `body`, or of `.` for `timestamp.body`, + * `` being the timestamp element's value exactly as received. + * - decodes each received digest by `encoding` (`hex`, or `base64` in + * the RFC 4648 standard alphabet) and compares the 32 bytes with that + * HMAC **in constant time**. A digest that does not decode to 32 + * bytes fails. The post verifies when one digest matches. + * - for `timestamp.body`, then refuses a `t` more than 300 seconds from + * the server's clock: the same window the `x-objectstack-signature` + * check applies (`HTTP_SIGNATURE_TOLERANCE_SECONDS` in + * `@objectstack/core`), never a window of its own. + * A `body` scheme signs no time, so **no replay window applies to it**: + * a captured post verifies again for as long as the key is unchanged. + * The sender offers nothing to bound a replay with, and this contract + * adds no bound of its own. A trigger that predates this form verifies + * `x-objectstack-signature` alone, so it refuses every post a declared + * sender makes; it never admits one unverified. * - **One refusal for every post that does not verify** (#22806): an * unknown flow, a wrong hook id, an absent signature and a bad one all - * answer `401 INVALID_SIGNATURE`, with one body. So the answer does not + * answer `401 INVALID_SIGNATURE`, with one body. A post that fails a + * declared scheme, at any of its steps above, gets that same answer. So the answer does not * tell an armed flow name from an unarmed one, whichever hook id a flow * is armed with. The cause (which of those) goes to the server log, * ⛔ never to the sender. FROM → TO: an unknown flow and a wrong hook id @@ -94,13 +136,15 @@ export interface ITriggerApiService { * fail verification for every real sender. ⛔ Nor does the caller check, * pre-filter or short-circuit the signature: there is one verifier, and it * is behind this member. Anything the signing scheme carries beyond the - * body (the accepted form's timestamp, for one) arrives in the same - * forwarded request, so this signature gains no field for it. + * body (the accepted form's timestamp, for one, and a declared scheme's + * own header) arrives in the same forwarded request, headers as received, + * so this signature gains no field for it. * * **The signature is the only credential.** This member takes no * `ExecutionContext` and reads no session, cookie or `Authorization` header: * a third-party sender has no session, and the HMAC under the hook's own - * secret carries the whole authorization. A signed-in caller is judged + * secret (or, for a declared scheme, the key `config.secretRef` names) + * carries the whole authorization. A signed-in caller is judged * exactly as an anonymous one, as on the self-hosted mount, which has no * auth gate. * diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index a96abb63767..23dd07628fa 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -289,9 +289,11 @@ import type * as M197 from './api/push-delivery.zod.js'; import type * as M198 from './system/push-device.zod.js'; // The server -> push relay wire shape -- new module, next free index. import type * as M199 from './api/push-relay.zod.js'; +// A third-party sender's signature scheme on an api hook (`config.signature`) -- new module, next free index. +import type * as M200 from './automation/api-trigger-signature.zod.js'; // --------------------------------------------------------------------------- -// 828 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 831 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -748,6 +750,11 @@ export type Iso_api_websocket__WebSocketPresenceStatus = Assert, z.infer< typeof M191.ApiTriggerSecretRefSchema > >>; +// automation/api-trigger-signature.zod.ts +export type Iso_automation_apiTriggerSignature__ApiTriggerBodySignatureSchema = Assert, z.infer< typeof M200.ApiTriggerBodySignatureSchema > >>; +export type Iso_automation_apiTriggerSignature__ApiTriggerSignatureSchema = Assert, z.infer< typeof M200.ApiTriggerSignatureSchema > >>; +export type Iso_automation_apiTriggerSignature__ApiTriggerTimestampedSignatureSchema = Assert, z.infer< typeof M200.ApiTriggerTimestampedSignatureSchema > >>; + // automation/approval.zod.ts export type Iso_automation_approval__ApprovalDecision = Assert, z.infer< typeof M37.ApprovalDecision > >>; export type Iso_automation_approval__ApprovalNodeApproverSchema = Assert, z.infer< typeof M37.ApprovalNodeApproverSchema > >>; @@ -1776,7 +1783,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 828 isomorphic pins', () => { + it('still declares all 831 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2790,7 +2797,14 @@ describe('ADR-0122 type-alias convention', () => { // Its error schemas extend the house error envelope, whose `retryable` // defaults, so they carry `PushRelayErrorParsed` and // `PushRelayErrorResponseParsed` instead of a pin. - expect(pins).toHaveLength(828); + // 828 -> 831 is `ApiTriggerSignatureSchema` + // (`automation/api-trigger-signature.zod.ts`), the scheme a third-party + // sender signs an `api` hook's posts with, and its two arms + // (`ApiTriggerBodySignatureSchema`, `ApiTriggerTimestampedSignatureSchema`): + // a discriminated union of two strict objects of enums, literals and regex + // strings, no default and no transform + // (Iso_automation_apiTriggerSignature__*). +3 added. + expect(pins).toHaveLength(831); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index 80d4ac602c8..c4775792008 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -22,6 +22,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies - `node_modules/@objectstack/spec/src/automation/api-trigger-secret-ref.zod.ts` — An inbound `api` hook's secret, by reference. +- `node_modules/@objectstack/spec/src/automation/api-trigger-signature.zod.ts` — An inbound `api` hook's verifier for a THIRD-PARTY sender, declared. - `node_modules/@objectstack/spec/src/automation/control-flow.zod.ts` — Structured control-flow constructs (ADR-0031) — the **native + AI-authored** - `node_modules/@objectstack/spec/src/automation/schemaless-node-config.zod.ts` — Config contracts for the **descriptor-schemaless** builtins whose designer - `node_modules/@objectstack/spec/src/data/context-tokens.zod.ts` — Context Tokens — the declarative placeholders that resolve against the From 04c4ff19c3c9658b77a9f9c259b47fcac89fc698 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 00:20:39 +0000 Subject: [PATCH 3/8] refactor(spec): build config.signature's two arms on the shared strictObject helper The arms' unknown-key prescriptions move from hand-written error maps into strictObject guidance, so the alias-integrity audit registers them. Claude-Session: https://claude.ai/code/session_01WGufzggC8errMtPRYBUmoY Co-authored-by: Claude --- .../automation/api-trigger-signature.zod.ts | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/spec/src/automation/api-trigger-signature.zod.ts b/packages/spec/src/automation/api-trigger-signature.zod.ts index 0bcede652ff..1aaed99f18e 100644 --- a/packages/spec/src/automation/api-trigger-signature.zod.ts +++ b/packages/spec/src/automation/api-trigger-signature.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +import { strictObject } from '../shared/strict-object'; import { resolveFlowTriggerKind } from './flow-trigger-kind'; import { API_TRIGGER_SECRET_REF_KEY, apiTriggerSecretRefOf } from './api-trigger-secret-ref.zod'; @@ -71,6 +72,15 @@ const DIGEST_PREFIX = /^[\x21-\x2B\x2D-\x7E]+$/; /** The name of one `NAME=value` element of a signature header. */ const ELEMENT_NAME = /^[A-Za-z0-9_-]+$/; +const BORN_CLOSED = + 'Declared closed from its first day: a key a verifier does not read is refused, never dropped, because an ' + + 'ignored setting on an auth boundary reads as a check that is not made.'; + +const NO_TIMESTAMP_ON_BODY = + 'A body-only scheme (`signs: \'body\'`) signs no time, so it has no timestamp to bound a replay with and no ' + + 'element list to read: `timestampElement` and `digestElement` belong to `signs: \'timestamp.body\'`. Declare ' + + '`signs: \'timestamp.body\'` when the sender signs `.`, or remove them.'; + const header = z .string({ error: () => @@ -129,7 +139,14 @@ const element = (key: 'timestampElement' | 'digestElement', what: string) => * Exported as its own def so the published surface records its keys (a union's * arms are otherwise invisible to the authorable-surface ledger). */ -export const ApiTriggerBodySignatureSchema = z.strictObject( +export const ApiTriggerBodySignatureSchema = strictObject( + { + surface: '`config.signature` (`signs: \'body\'`)', + history: BORN_CLOSED, + guidanceSets: [ + { name: 'TIMESTAMPED_SIGNATURE_ELEMENT_KEYS', keys: ['timestampElement', 'digestElement'], prescription: NO_TIMESTAMP_ON_BODY }, + ], + }, { signs: z.literal('body').describe('The sender signs the raw body alone. No replay window applies.'), header, @@ -147,15 +164,6 @@ export const ApiTriggerBodySignatureSchema = z.strictObject( 'sender), checked and removed before decoding. Absent: the whole value is the digest.', ), }, - { - error: (issue) => - issue.code === 'unrecognized_keys' && - (issue.keys.includes('timestampElement') || issue.keys.includes('digestElement')) - ? 'A body-only scheme (`signs: \'body\'`) signs no time, so it has no timestamp to bound a replay with and no ' + - 'element list to read: `timestampElement` and `digestElement` belong to `signs: \'timestamp.body\'`. ' + - 'Declare `signs: \'timestamp.body\'` when the sender signs `.`, or remove them.' - : undefined, - }, ).describe( '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.', @@ -169,7 +177,16 @@ export const ApiTriggerBodySignatureSchema = z.strictObject( * `x-objectstack-signature` check applies. Exported as its own def for the * same reason as {@link ApiTriggerBodySignatureSchema}. */ -export const ApiTriggerTimestampedSignatureSchema = z.strictObject( +export const ApiTriggerTimestampedSignatureSchema = strictObject( + { + surface: '`config.signature` (`signs: \'timestamp.body\'`)', + history: BORN_CLOSED, + guidance: { + prefix: + 'A `timestamp.body` scheme locates its digest by `digestElement` (`v1` for `t=…,v1=…`), not by a `prefix`. ' + + 'Remove `prefix`.', + }, + }, { signs: z .literal('timestamp.body') @@ -186,13 +203,6 @@ export const ApiTriggerTimestampedSignatureSchema = z.strictObject( 'rolls its key; the post verifies when one of them matches.', ), }, - { - error: (issue) => - issue.code === 'unrecognized_keys' && issue.keys.includes('prefix') - ? 'A `timestamp.body` scheme locates its digest by `digestElement` (`v1` for `t=…,v1=…`), not by a `prefix`. ' + - 'Remove `prefix`.' - : undefined, - }, ).describe( 'A sender that signs `.` 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.', From 27e25152e08c2c26be8bc3a2977f22c32aef66f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 00:26:07 +0000 Subject: [PATCH 4/8] chore(spec): list config.signature's two arm exports in the changeset The changeset now names ApiTriggerBodySignatureSchema and ApiTriggerTimestampedSignatureSchema with their type aliases beside the union schema. Claude-Session: https://claude.ai/code/session_01WGufzggC8errMtPRYBUmoY Co-authored-by: Claude --- .changeset/29983-api-trigger-signature.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/29983-api-trigger-signature.md b/.changeset/29983-api-trigger-signature.md index 448c3279076..9e3661332cd 100644 --- a/.changeset/29983-api-trigger-signature.md +++ b/.changeset/29983-api-trigger-signature.md @@ -16,6 +16,6 @@ An inbound `api` hook admitted a post only by ObjectStack's own `x-objectstack-s 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, and one on a flow that binds no `api` trigger. -New exports from `@objectstack/spec/automation`: `ApiTriggerSignatureSchema`, `ApiTriggerSignature`, `API_TRIGGER_SIGNATURE_KEY`, `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, and the one `401 INVALID_SIGNATURE` for every failure. +New exports from `@objectstack/spec/automation`: `ApiTriggerSignatureSchema`, `ApiTriggerSignature`, its two arms `ApiTriggerBodySignatureSchema` / `ApiTriggerBodySignature` and `ApiTriggerTimestampedSignatureSchema` / `ApiTriggerTimestampedSignature`, `API_TRIGGER_SIGNATURE_KEY`, `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, and the one `401 INVALID_SIGNATURE` for every failure. 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. From db0c0f86b017b8d0400c2571277e46fc38a40b89 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 00:50:13 +0000 Subject: [PATCH 5/8] chore(spec): count config.signature in llms.txt and declare its signs union in variant-docs llms.txt's schema inventory moves to 218 (automation 16). The new signs: body | timestamp.body union is exempt generated-reference-only until the server slice writes the flows.mdx section and binds it. Claude-Session: https://claude.ai/code/session_01WGufzggC8errMtPRYBUmoY Co-authored-by: Claude --- packages/spec/llms.txt | 4 ++-- packages/spec/variant-docs.json | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index d85e5268767..66fefa64ec7 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (217 schemas) +## 3. Schema Inventory by Domain (218 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -90,7 +90,7 @@ here is verifiable from the installed package. | data | 32 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube, Seed | | api | 35 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page, App Metadata Bundle, Push Delivery, Push Relay | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | -| automation | 15 | Flow, Approval, Assignment Rule, BPMN Interop, Control Flow, Webhook, Schedule Organization | +| automation | 16 | Flow, Approval, Assignment Rule, BPMN Interop, Control Flow, Webhook, Schedule Organization | | shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | | ai | 12 | Agent, Build Progress, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | | identity | 5 | Identity, Organization, Position, SCIM, Eval User | diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json index 3bbdd9a46cf..36f56560bfa 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -208,6 +208,12 @@ "content/docs/api/metadata-api.mdx" ], "note": "The per-kind entry of GET /packages/:id/customizations `contributions` (#27928, ruling 6566915346 letter E). Not authored (the server answers it), but GOVERNED rather than exempt, as the flow registration verdict is: Studio draws each kind differently, and api/metadata-api.mdx names the list and its `nav` kind. The `field` / `flow` arm discriminates by a `z.enum`; the walker reads its members into the key, so adding or dropping a kind there moves the key and sends the author back to that doc." + }, + { + "key": "signs:body|timestamp.body", + "label": "api trigger declared signature scheme", + "exempt": "generated-reference-only", + "reason": "An api flow start node's `config.signature` (#29983, the contract half): content/docs/references/automation/api-trigger-signature.mdx is generated from ApiTriggerSignatureSchema. No hand-written guide teaches it yet on purpose: the api trigger does not verify the key until the domain:server slice lands, so a guide written now would teach a hook that refuses every post. That slice writes the automation/flows.mdx section naming `body` and `timestamp.body` beside the `secretRef` paragraph and binds it here, replacing this exemption." } ] } From ce07e1f687134e38a3b902bd0eb6c02b35d92038 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:15:32 +0000 Subject: [PATCH 6/8] fix(spec): config.signature refuses x-objectstack-signature and the reserved headers as its header Contract review round 1. A declared header naming x-objectstack-signature, in any case, re-admitted the body-only sha256= form that ruling B on #22805 refuses inbound; it is now refused at parse, as is a closed list of credential, routing, framing and idempotency headers (API_TRIGGER_SIGNATURE_RESERVED_HEADERS). The contract states that a repeated header is verified as its one comma-joined value, and that an unusable declaration answers the same 401 INVALID_SIGNATURE. Claude-Session: https://claude.ai/code/session_01WGufzggC8errMtPRYBUmoY Co-authored-by: Claude --- .changeset/29983-api-trigger-signature.md | 4 +- .../automation/api-trigger-signature.mdx | 14 ++- packages/spec/api-surface/automation.json | 1 + packages/spec/export-origins/automation.json | 1 + .../automation/api-trigger-signature.test.ts | 43 +++++++++ .../automation/api-trigger-signature.zod.ts | 93 ++++++++++++++++--- .../spec/src/contracts/trigger-api-service.ts | 18 +++- 7 files changed, 150 insertions(+), 24 deletions(-) diff --git a/.changeset/29983-api-trigger-signature.md b/.changeset/29983-api-trigger-signature.md index 9e3661332cd..9c419cdf88c 100644 --- a/.changeset/29983-api-trigger-signature.md +++ b/.changeset/29983-api-trigger-signature.md @@ -14,8 +14,8 @@ An inbound `api` hook admitted a post only by ObjectStack's own `x-objectstack-s - `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, and one on a flow that binds no `api` trigger. +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`, `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, and the one `401 INVALID_SIGNATURE` for every failure. +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. diff --git a/content/docs/references/automation/api-trigger-signature.mdx b/content/docs/references/automation/api-trigger-signature.mdx index a4c2720fc89..8f33deb745d 100644 --- a/content/docs/references/automation/api-trigger-signature.mdx +++ b/content/docs/references/automation/api-trigger-signature.mdx @@ -49,6 +49,12 @@ Refusals, each anchored on the start node's `config.signature`: scheme that names one, an undeclared key; - a `timestamp.body` scheme whose timestamp and digest are the same element of the header; + - a `header` that names `x-objectstack-signature`, in any case: the + platform's own header, whose body-only `sha256=` form ruling B on #22805 + refuses inbound, so no declaration may re-admit it there without a + replay bound; or another reserved header + (`API_TRIGGER_SIGNATURE_RESERVED_HEADERS`: the request's + credentials, routing or framing, or the hook's idempotency key); - 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; @@ -84,7 +90,7 @@ A sender that signs the raw body alone (DocuSign Connect, a GitHub-style `sha256 | 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`. | +| **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. | @@ -109,7 +115,7 @@ A sender that signs the raw body alone (DocuSign Connect, a GitHub-style `sha256 | 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`. | +| **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. | @@ -125,7 +131,7 @@ A sender that signs `.` and writes `t` beside the digest as `NAME=v | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **signs** | `'timestamp.body'` | ✅ | The sender signs `.`, `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`. | +| **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=` (`t`). Its value, exactly as received, is the `` of the signed `.`. | @@ -145,7 +151,7 @@ A sender that signs `.` and writes `t` beside the digest as `NAME=v | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **signs** | `'timestamp.body'` | ✅ | The sender signs `.`, `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`. | +| **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=` (`t`). Its value, exactly as received, is the `` of the signed `.`. | diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 2bb3a6d13f8..b11e147d815 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -5,6 +5,7 @@ "API_TRIGGER_SECRET_REF_KEY (const)", "API_TRIGGER_SECRET_REF_RESERVED_PREFIX (const)", "API_TRIGGER_SIGNATURE_KEY (const)", + "API_TRIGGER_SIGNATURE_RESERVED_HEADERS (const)", "APPROVAL_BRANCH_LABELS (const)", "APPROVAL_NODE_TYPE (const)", "APPROVAL_REVISE_NODE_TYPE (const)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 61524afe0c0..4a6d1d8853e 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -5,6 +5,7 @@ "API_TRIGGER_SECRET_REF_KEY": "src/automation/api-trigger-secret-ref.zod.ts#API_TRIGGER_SECRET_REF_KEY (const)", "API_TRIGGER_SECRET_REF_RESERVED_PREFIX": "src/automation/api-trigger-secret-ref.zod.ts#API_TRIGGER_SECRET_REF_RESERVED_PREFIX (const)", "API_TRIGGER_SIGNATURE_KEY": "src/automation/api-trigger-signature.zod.ts#API_TRIGGER_SIGNATURE_KEY (const)", + "API_TRIGGER_SIGNATURE_RESERVED_HEADERS": "src/automation/api-trigger-signature.zod.ts#API_TRIGGER_SIGNATURE_RESERVED_HEADERS (const)", "APPROVAL_BRANCH_LABELS": "src/automation/approval.zod.ts#APPROVAL_BRANCH_LABELS (const)", "APPROVAL_NODE_TYPE": "src/automation/approval.zod.ts#APPROVAL_NODE_TYPE (const)", "APPROVAL_REVISE_NODE_TYPE": "src/automation/approval.zod.ts#APPROVAL_REVISE_NODE_TYPE (const)", diff --git a/packages/spec/src/automation/api-trigger-signature.test.ts b/packages/spec/src/automation/api-trigger-signature.test.ts index 5e7ec8edc96..fe03ee80777 100644 --- a/packages/spec/src/automation/api-trigger-signature.test.ts +++ b/packages/spec/src/automation/api-trigger-signature.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { API_TRIGGER_SIGNATURE_KEY, + API_TRIGGER_SIGNATURE_RESERVED_HEADERS, ApiTriggerSignatureSchema, apiTriggerSignatureRefusals, apiTriggerVerifierOf, @@ -204,6 +205,48 @@ describe('refused at parse, at the start node config.signature', () => { } }); + it('a header naming x-objectstack-signature, in any case: the body-only sha256= form stays refused on it (ruling B on #22805)', () => { + for (const header of ['x-objectstack-signature', 'X-Objectstack-Signature', 'X-OBJECTSTACK-SIGNATURE']) { + // The platform's own header in its body-only `sha256=` form, declared: what the hook's own check refuses. + const declared = { ...KEYED, signature: { ...GITHUB_STYLE, header } }; + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', declared))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.header']); + expect(issues[0]!.message).toMatch(/ruling B on #22805/); + expect(apiTriggerVerifierOf(declared)).toEqual({ kind: 'unusable' }); + const timestamped = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...STRIPE_STYLE, header } }))); + expect(pathsOf(timestamped)).toEqual(['nodes.0.config.signature.header']); + } + }); + + it('a header that carries the request credentials, routing or framing, or the idempotency key: a closed list, in any case', () => { + expect([...API_TRIGGER_SIGNATURE_RESERVED_HEADERS]).toEqual([ + 'x-objectstack-signature', + 'authorization', + 'proxy-authorization', + 'cookie', + 'host', + 'content-length', + 'transfer-encoding', + 'x-idempotency-key', + ]); + expect(Object.isFrozen(API_TRIGGER_SIGNATURE_RESERVED_HEADERS)).toBe(true); + for (const name of API_TRIGGER_SIGNATURE_RESERVED_HEADERS.slice(1)) { + for (const header of [name, name.toUpperCase(), name.replace(/(^|-)([a-z])/g, (_, dash, c) => dash + c.toUpperCase())]) { + const declared = { ...KEYED, signature: { ...DOCUSIGN, header } }; + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', declared))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.header']); + expect(issues[0]!.message).toMatch(/never a sender's signature/); + expect(apiTriggerVerifierOf(declared)).toEqual({ kind: 'unusable' }); + } + } + }); + + it('a header whose name only contains a reserved one is a sender header, not reserved', () => { + for (const header of ['X-Objectstack-Signature-1', 'X-Authorization-Signature', 'Signature', 'X-Cookie-Sig']) { + expect(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...DOCUSIGN, header } })).success).toBe(true); + } + }); + it('a prefix with a space or a comma, or a blank one', () => { for (const prefix of ['', 'sha256 =', 'a,b']) { const issues = refusalsUnderSignature(FlowSchema.safeParse(withPrefix(prefix))); diff --git a/packages/spec/src/automation/api-trigger-signature.zod.ts b/packages/spec/src/automation/api-trigger-signature.zod.ts index 1aaed99f18e..f9ad88b528c 100644 --- a/packages/spec/src/automation/api-trigger-signature.zod.ts +++ b/packages/spec/src/automation/api-trigger-signature.zod.ts @@ -49,6 +49,12 @@ import { API_TRIGGER_SECRET_REF_KEY, apiTriggerSecretRefOf } from './api-trigger * scheme that names one, an undeclared key; * - a `timestamp.body` scheme whose timestamp and digest are the same * element of the header; + * - a `header` that names `x-objectstack-signature`, in any case: the + * platform's own header, whose body-only `sha256=` form ruling B on #22805 + * refuses inbound, so no declaration may re-admit it there without a + * replay bound; or another reserved header + * ({@link API_TRIGGER_SIGNATURE_RESERVED_HEADERS}: the request's + * credentials, routing or framing, or the hook's idempotency key); * - 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; @@ -72,6 +78,57 @@ const DIGEST_PREFIX = /^[\x21-\x2B\x2D-\x7E]+$/; /** The name of one `NAME=value` element of a signature header. */ const ELEMENT_NAME = /^[A-Za-z0-9_-]+$/; +/** + * The header names a declared scheme may not read its signature from, + * lower-case, compared without regard to case. A closed list: + * + * - `x-objectstack-signature`: the platform's own header. The hook's own + * check accepts it only in its timestamped form; a declared body-only + * scheme on it would re-admit the `sha256=` form ruling B on #22805 + * refuses inbound, with no replay bound, and a hook that declares a scheme + * never reads it. + * - `authorization`, `proxy-authorization`, `cookie`: the request's + * credentials. The hook reads no session, cookie or `Authorization` header + * (`contracts/trigger-api-service.ts`), and a declaration cannot make it. + * - `host`, `content-length`, `transfer-encoding`: the request's routing and + * framing, which a proxy may rewrite, never a sender's digest. + * - `x-idempotency-key`: the hook's delivery id, which it reads for its own + * purpose. + */ +export const API_TRIGGER_SIGNATURE_RESERVED_HEADERS = Object.freeze([ + 'x-objectstack-signature', + 'authorization', + 'proxy-authorization', + 'cookie', + 'host', + 'content-length', + 'transfer-encoding', + 'x-idempotency-key', +] as const); + +const RESERVED_HEADERS: ReadonlySet = new Set(API_TRIGGER_SIGNATURE_RESERVED_HEADERS); + +/** The refusal a reserved `header` raises, or `undefined` for a header a sender may sign in. */ +function reservedHeaderMessage(header: string): string | undefined { + const name = header.toLowerCase(); + if (!RESERVED_HEADERS.has(name)) return undefined; + if (name === 'x-objectstack-signature') { + return ( + `\`config.signature.header\` names \`${header}\`, the platform's own signature header. A hook that declares ` + + 'its sender\'s scheme never reads that header, and the hook\'s own check accepts it only in its timestamped ' + + 'form (`t=…,v1=…`): a declared scheme on it would re-admit the body-only `sha256=` form ruling B on #22805 ' + + 'refuses inbound, with no replay bound. Remove `signature` to verify `x-objectstack-signature`, or name the ' + + 'header the sender writes its own signature in.' + ); + } + return ( + `\`config.signature.header\` names \`${header}\`, which carries the request's credentials, routing or framing ` + + '(or, for `x-idempotency-key`, the hook\'s delivery id), never a sender\'s signature: the hook reads no ' + + 'session, cookie or `Authorization` header. Name the header the sender writes its signature in ' + + '(`X-DocuSign-Signature-1`).' + ); +} + const BORN_CLOSED = 'Declared closed from its first day: a key a verifier does not read is refused, never dropped, because an ' + 'ignored setting on an auth boundary reads as a check that is not made.'; @@ -94,7 +151,9 @@ const header = z ) .describe( '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`.', + '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`.', ); const algorithm = z @@ -253,11 +312,13 @@ export type ApiTriggerTimestampedSignature = z.input): ApiTriggerSig for (const issue of parsed.error.issues) { out.push({ path: [API_TRIGGER_SIGNATURE_KEY, ...(issue.path as Array)], message: issue.message }); } - } else if (parsed.data.signs === 'timestamp.body' && parsed.data.timestampElement === parsed.data.digestElement) { - out.push({ - path: [API_TRIGGER_SIGNATURE_KEY, 'digestElement'], - message: - `\`config.signature\` names the element \`${parsed.data.digestElement}\` for both the timestamp and the digest. ` + - 'They are two elements of the header (`t` and `v1` in `t=…,v1=…`); name each one.', - }); + } else { + const reserved = reservedHeaderMessage(parsed.data.header); + if (reserved !== undefined) out.push({ path: [API_TRIGGER_SIGNATURE_KEY, 'header'], message: reserved }); + if (parsed.data.signs === 'timestamp.body' && parsed.data.timestampElement === parsed.data.digestElement) { + out.push({ + path: [API_TRIGGER_SIGNATURE_KEY, 'digestElement'], + message: + `\`config.signature\` names the element \`${parsed.data.digestElement}\` for both the timestamp and the digest. ` + + 'They are two elements of the header (`t` and `v1` in `t=…,v1=…`); name each one.', + }); + } } if (has(config, 'secret')) { out.push({ @@ -343,7 +408,7 @@ export function apiTriggerVerifierOf(config: unknown): ApiTriggerVerifier { /** * Every refusal the start nodes of `flow` raise for `config.signature` — the - * five in this module's header. Structural, like + * six in this module's header. Structural, like * {@link resolveFlowTriggerKind}: a value that is not a flow yields none. */ export function apiTriggerSignatureRefusals(flow: unknown): ApiTriggerSignatureRefusal[] { diff --git a/packages/spec/src/contracts/trigger-api-service.ts b/packages/spec/src/contracts/trigger-api-service.ts index ae717c51726..ad28f90b6f0 100644 --- a/packages/spec/src/contracts/trigger-api-service.ts +++ b/packages/spec/src/contracts/trigger-api-service.ts @@ -72,12 +72,22 @@ export interface ITriggerApiService { * names, read at this moment like any hook secret (the `503` above when * it cannot be read), and nothing else: ⛔ it never reads * `x-objectstack-signature`, so a valid one admits nothing, and ⛔ it - * never admits a post that lacks the declared header. A hook whose - * declaration the reader answers `unusable` admits no post at all. The + * never admits a post that lacks the declared header. The flow parse + * refuses a declared `header` that names `x-objectstack-signature` or + * another reserved header (`API_TRIGGER_SIGNATURE_RESERVED_HEADERS`: the + * request's credentials, routing or framing, or the idempotency key), in + * any case, so a declared scheme never reads one. A hook whose + * declaration the reader answers `unusable` admits no post at all: every + * post to it gets the one `401 INVALID_SIGNATURE` below, whether the + * trigger declines to arm the declaration or refuses each post. The * implementer: * - reads the declared `header`, its name matched without regard to - * case. For `signs: 'body'` the whole trimmed value, after `prefix` - * when one is declared (a value without it fails), is the one digest. + * case. A field the sender repeats is ONE value: the field values + * joined with `, ` in the order received (what `Headers.get` + * answers), and that joined value is the one verified, ⛔ never the + * first or the last field alone. For `signs: 'body'` the whole + * trimmed value, after `prefix` when one is declared (a value without + * it fails), is the one digest, so a repeated body-only header fails. * For `signs: 'timestamp.body'` the value is `NAME=value` elements * separated by `,`, each trimmed and split at its first `=`: exactly * one element named `timestampElement`, whose value is whole unix From 4194519e9650f9b41cafb1fa8ad64e6d8c03a0b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:23:46 +0000 Subject: [PATCH 7/8] fix(spec): config.signature's reserved-header message names no internal issue id The doc authoring guard refuses an internal issue id in customer-facing spec text. The ruling reference moves to a comment beside the message, and the test pins the new wording plus the absence of any id. Claude-Session: https://claude.ai/code/session_01WGufzggC8errMtPRYBUmoY Co-authored-by: Claude --- packages/spec/src/automation/api-trigger-signature.test.ts | 5 ++++- packages/spec/src/automation/api-trigger-signature.zod.ts | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/automation/api-trigger-signature.test.ts b/packages/spec/src/automation/api-trigger-signature.test.ts index fe03ee80777..5132d793835 100644 --- a/packages/spec/src/automation/api-trigger-signature.test.ts +++ b/packages/spec/src/automation/api-trigger-signature.test.ts @@ -211,7 +211,9 @@ describe('refused at parse, at the start node config.signature', () => { const declared = { ...KEYED, signature: { ...GITHUB_STYLE, header } }; const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', declared))); expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.header']); - expect(issues[0]!.message).toMatch(/ruling B on #22805/); + expect(issues[0]!.message).toMatch(/re-admit that refused form, with no replay bound/); + // Customer-facing text names no internal issue id (the doc authoring guard). + expect(issues[0]!.message).not.toMatch(/#\d/); expect(apiTriggerVerifierOf(declared)).toEqual({ kind: 'unusable' }); const timestamped = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...STRIPE_STYLE, header } }))); expect(pathsOf(timestamped)).toEqual(['nodes.0.config.signature.header']); @@ -236,6 +238,7 @@ describe('refused at parse, at the start node config.signature', () => { const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', declared))); expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.header']); expect(issues[0]!.message).toMatch(/never a sender's signature/); + expect(issues[0]!.message).not.toMatch(/#\d/); expect(apiTriggerVerifierOf(declared)).toEqual({ kind: 'unusable' }); } } diff --git a/packages/spec/src/automation/api-trigger-signature.zod.ts b/packages/spec/src/automation/api-trigger-signature.zod.ts index f9ad88b528c..9bf7db252b1 100644 --- a/packages/spec/src/automation/api-trigger-signature.zod.ts +++ b/packages/spec/src/automation/api-trigger-signature.zod.ts @@ -113,12 +113,13 @@ function reservedHeaderMessage(header: string): string | undefined { const name = header.toLowerCase(); if (!RESERVED_HEADERS.has(name)) return undefined; if (name === 'x-objectstack-signature') { + // The body-only `sha256=` form on this header is refused inbound by ruling B on #22805. return ( `\`config.signature.header\` names \`${header}\`, the platform's own signature header. A hook that declares ` + 'its sender\'s scheme never reads that header, and the hook\'s own check accepts it only in its timestamped ' + - 'form (`t=…,v1=…`): a declared scheme on it would re-admit the body-only `sha256=` form ruling B on #22805 ' + - 'refuses inbound, with no replay bound. Remove `signature` to verify `x-objectstack-signature`, or name the ' + - 'header the sender writes its own signature in.' + 'form (`t=…,v1=…`) because the body-only `sha256=` form signs no time: a declared scheme on it would ' + + 're-admit that refused form, with no replay bound. Remove `signature` to verify `x-objectstack-signature`, ' + + 'or name the header the sender writes its own signature in.' ); } return ( From bb371a5c3103db11490cb2cc4c39c378b588a755 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 01:37:18 +0000 Subject: [PATCH 8/8] fix(spec): config.signature's shape itself refuses a reserved header Delta review should-fixes. The header field carries a second pattern, built from API_TRIGGER_SIGNATURE_RESERVED_HEADERS with each letter spelled in both cases, so each arm schema and its published JSON Schema refuse a reserved name and the flow walk no longer adds its own check. The x-objectstack-signature message leads with the reason that holds on both arms, and the module header bullet that projects into the references page names no issue id. Claude-Session: https://claude.ai/code/session_01WGufzggC8errMtPRYBUmoY Co-authored-by: Claude --- .../automation/api-trigger-signature.mdx | 13 ++-- .../automation/api-trigger-signature.test.ts | 40 +++++++++-- .../automation/api-trigger-signature.zod.ts | 66 +++++++++++-------- .../spec/src/contracts/trigger-api-service.ts | 4 +- 4 files changed, 80 insertions(+), 43 deletions(-) diff --git a/content/docs/references/automation/api-trigger-signature.mdx b/content/docs/references/automation/api-trigger-signature.mdx index 8f33deb745d..b223380040b 100644 --- a/content/docs/references/automation/api-trigger-signature.mdx +++ b/content/docs/references/automation/api-trigger-signature.mdx @@ -47,14 +47,15 @@ Refusals, each anchored on the start node's `config.signature`: 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 `header` that names `x-objectstack-signature`, in any case: the - platform's own header, whose body-only `sha256=` form ruling B on #22805 - refuses inbound, so no declaration may re-admit it there without a - replay bound; or another reserved header - (`API_TRIGGER_SIGNATURE_RESERVED_HEADERS`: the request's - credentials, routing or framing, or the hook's idempotency key); - 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; diff --git a/packages/spec/src/automation/api-trigger-signature.test.ts b/packages/spec/src/automation/api-trigger-signature.test.ts index 5132d793835..16a5d7d036c 100644 --- a/packages/spec/src/automation/api-trigger-signature.test.ts +++ b/packages/spec/src/automation/api-trigger-signature.test.ts @@ -4,7 +4,9 @@ import { describe, expect, it } from 'vitest'; import { API_TRIGGER_SIGNATURE_KEY, API_TRIGGER_SIGNATURE_RESERVED_HEADERS, + ApiTriggerBodySignatureSchema, ApiTriggerSignatureSchema, + ApiTriggerTimestampedSignatureSchema, apiTriggerSignatureRefusals, apiTriggerVerifierOf, } from './api-trigger-signature.zod'; @@ -211,12 +213,15 @@ describe('refused at parse, at the start node config.signature', () => { const declared = { ...KEYED, signature: { ...GITHUB_STYLE, header } }; const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', declared))); expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.header']); - expect(issues[0]!.message).toMatch(/re-admit that refused form, with no replay bound/); + // The reason that holds on both arms leads; the body-only consequence is stated as such. + expect(issues[0]!.message).toMatch(/which stays the platform's: a hook has one verifier/); + expect(issues[0]!.message).toMatch(/Declared `signs: 'body'`, it would also re-admit the body-only `sha256=` form/); // Customer-facing text names no internal issue id (the doc authoring guard). expect(issues[0]!.message).not.toMatch(/#\d/); expect(apiTriggerVerifierOf(declared)).toEqual({ kind: 'unusable' }); const timestamped = refusalsUnderSignature(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...STRIPE_STYLE, header } }))); expect(pathsOf(timestamped)).toEqual(['nodes.0.config.signature.header']); + expect(timestamped[0]!.message).toBe(issues[0]!.message); } }); @@ -234,16 +239,37 @@ describe('refused at parse, at the start node config.signature', () => { expect(Object.isFrozen(API_TRIGGER_SIGNATURE_RESERVED_HEADERS)).toBe(true); for (const name of API_TRIGGER_SIGNATURE_RESERVED_HEADERS.slice(1)) { for (const header of [name, name.toUpperCase(), name.replace(/(^|-)([a-z])/g, (_, dash, c) => dash + c.toUpperCase())]) { - const declared = { ...KEYED, signature: { ...DOCUSIGN, header } }; - const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', declared))); - expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.header']); - expect(issues[0]!.message).toMatch(/never a sender's signature/); - expect(issues[0]!.message).not.toMatch(/#\d/); - expect(apiTriggerVerifierOf(declared)).toEqual({ kind: 'unusable' }); + for (const arm of [DOCUSIGN, STRIPE_STYLE]) { + const declared = { ...KEYED, signature: { ...arm, header } }; + const issues = refusalsUnderSignature(FlowSchema.safeParse(flow('api', declared))); + expect(pathsOf(issues)).toEqual(['nodes.0.config.signature.header']); + expect(issues[0]!.message).toMatch(/never a sender's signature/); + expect(issues[0]!.message).not.toMatch(/#\d/); + expect(apiTriggerVerifierOf(declared)).toEqual({ kind: 'unusable' }); + } } } }); + it('the shape itself refuses a reserved header: each arm schema alone, and the union, so its JSON Schema does too', () => { + for (const name of API_TRIGGER_SIGNATURE_RESERVED_HEADERS) { + for (const header of [name, name.toUpperCase()]) { + for (const [schema, arm] of [ + [ApiTriggerBodySignatureSchema, DOCUSIGN], + [ApiTriggerTimestampedSignatureSchema, STRIPE_STYLE], + [ApiTriggerSignatureSchema, DOCUSIGN], + [ApiTriggerSignatureSchema, STRIPE_STYLE], + ] as const) { + const result = schema.safeParse({ ...arm, header }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['header']); + } + } + } + expect(ApiTriggerBodySignatureSchema.safeParse(DOCUSIGN).success).toBe(true); + expect(ApiTriggerTimestampedSignatureSchema.safeParse(STRIPE_STYLE).success).toBe(true); + }); + it('a header whose name only contains a reserved one is a sender header, not reserved', () => { for (const header of ['X-Objectstack-Signature-1', 'X-Authorization-Signature', 'Signature', 'X-Cookie-Sig']) { expect(FlowSchema.safeParse(flow('api', { ...KEYED, signature: { ...DOCUSIGN, header } })).success).toBe(true); diff --git a/packages/spec/src/automation/api-trigger-signature.zod.ts b/packages/spec/src/automation/api-trigger-signature.zod.ts index 9bf7db252b1..9a4948e3049 100644 --- a/packages/spec/src/automation/api-trigger-signature.zod.ts +++ b/packages/spec/src/automation/api-trigger-signature.zod.ts @@ -47,14 +47,15 @@ import { API_TRIGGER_SECRET_REF_KEY, apiTriggerSecretRefOf } from './api-trigger * 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 ({@link 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 `header` that names `x-objectstack-signature`, in any case: the - * platform's own header, whose body-only `sha256=` form ruling B on #22805 - * refuses inbound, so no declaration may re-admit it there without a - * replay bound; or another reserved header - * ({@link API_TRIGGER_SIGNATURE_RESERVED_HEADERS}: the request's - * credentials, routing or framing, or the hook's idempotency key); * - 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; @@ -82,11 +83,11 @@ const ELEMENT_NAME = /^[A-Za-z0-9_-]+$/; * The header names a declared scheme may not read its signature from, * lower-case, compared without regard to case. A closed list: * - * - `x-objectstack-signature`: the platform's own header. The hook's own - * check accepts it only in its timestamped form; a declared body-only - * scheme on it would re-admit the `sha256=` form ruling B on #22805 - * refuses inbound, with no replay bound, and a hook that declares a scheme - * never reads it. + * - `x-objectstack-signature`: the platform's own header, which stays the + * platform's. A hook that declares a scheme never reads it (one verifier + * per hook), and the hook's own check accepts it only in its timestamped + * form; a declared body-only scheme on it would re-admit the `sha256=` + * form that check refuses, with no replay bound. * - `authorization`, `proxy-authorization`, `cookie`: the request's * credentials. The hook reads no session, cookie or `Authorization` header * (`contracts/trigger-api-service.ts`), and a declaration cannot make it. @@ -108,6 +109,18 @@ export const API_TRIGGER_SIGNATURE_RESERVED_HEADERS = Object.freeze([ const RESERVED_HEADERS: ReadonlySet = new Set(API_TRIGGER_SIGNATURE_RESERVED_HEADERS); +/** + * Matches every header name EXCEPT a reserved one, in any case: a negative + * lookahead over the list, each ASCII letter spelled `[xX]`. Built from the + * list, so the two cannot drift, and without a flag, so the published JSON + * Schema `pattern` refuses exactly what the runtime refuses. + */ +const NOT_A_RESERVED_HEADER = new RegExp( + `^(?!(?:${API_TRIGGER_SIGNATURE_RESERVED_HEADERS.map((name) => + name.replace(/[a-z]/g, (c) => `[${c}${c.toUpperCase()}]`), + ).join('|')})$)`, +); + /** The refusal a reserved `header` raises, or `undefined` for a header a sender may sign in. */ function reservedHeaderMessage(header: string): string | undefined { const name = header.toLowerCase(); @@ -115,11 +128,11 @@ function reservedHeaderMessage(header: string): string | undefined { if (name === 'x-objectstack-signature') { // The body-only `sha256=` form on this header is refused inbound by ruling B on #22805. return ( - `\`config.signature.header\` names \`${header}\`, the platform's own signature header. A hook that declares ` + - 'its sender\'s scheme never reads that header, and the hook\'s own check accepts it only in its timestamped ' + - 'form (`t=…,v1=…`) because the body-only `sha256=` form signs no time: a declared scheme on it would ' + - 're-admit that refused form, with no replay bound. Remove `signature` to verify `x-objectstack-signature`, ' + - 'or name the header the sender writes its own signature in.' + `\`config.signature.header\` names \`${header}\`, the platform's own signature header, which stays the ` + + 'platform\'s: a hook has one verifier, and a hook that declares its sender\'s scheme never reads ' + + '`x-objectstack-signature`. (Declared `signs: \'body\'`, it would also re-admit the body-only `sha256=` form ' + + 'the hook\'s own check refuses, with no replay bound.) Remove `signature` to have the hook verify ' + + '`x-objectstack-signature`, or name the header the sender writes its own signature in.' ); } return ( @@ -150,6 +163,7 @@ const header = z '`config.signature.header` must be an HTTP header name: letters, digits and `!#$%&\'*+-.^_`|~`, nothing else ' + '(`X-DocuSign-Signature-1`). It is matched without regard to case, as HTTP matches header names.', ) + .regex(NOT_A_RESERVED_HEADER, { error: (issue) => reservedHeaderMessage(String(issue.input)) }) .describe( '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, ' + @@ -314,7 +328,7 @@ export type ApiTriggerTimestampedSignature = z.input): ApiTriggerSig for (const issue of parsed.error.issues) { out.push({ path: [API_TRIGGER_SIGNATURE_KEY, ...(issue.path as Array)], message: issue.message }); } - } else { - const reserved = reservedHeaderMessage(parsed.data.header); - if (reserved !== undefined) out.push({ path: [API_TRIGGER_SIGNATURE_KEY, 'header'], message: reserved }); - if (parsed.data.signs === 'timestamp.body' && parsed.data.timestampElement === parsed.data.digestElement) { - out.push({ - path: [API_TRIGGER_SIGNATURE_KEY, 'digestElement'], - message: - `\`config.signature\` names the element \`${parsed.data.digestElement}\` for both the timestamp and the digest. ` + - 'They are two elements of the header (`t` and `v1` in `t=…,v1=…`); name each one.', - }); - } + } else if (parsed.data.signs === 'timestamp.body' && parsed.data.timestampElement === parsed.data.digestElement) { + out.push({ + path: [API_TRIGGER_SIGNATURE_KEY, 'digestElement'], + message: + `\`config.signature\` names the element \`${parsed.data.digestElement}\` for both the timestamp and the digest. ` + + 'They are two elements of the header (`t` and `v1` in `t=…,v1=…`); name each one.', + }); } if (has(config, 'secret')) { out.push({ diff --git a/packages/spec/src/contracts/trigger-api-service.ts b/packages/spec/src/contracts/trigger-api-service.ts index ad28f90b6f0..3899175fc86 100644 --- a/packages/spec/src/contracts/trigger-api-service.ts +++ b/packages/spec/src/contracts/trigger-api-service.ts @@ -72,8 +72,8 @@ export interface ITriggerApiService { * names, read at this moment like any hook secret (the `503` above when * it cannot be read), and nothing else: ⛔ it never reads * `x-objectstack-signature`, so a valid one admits nothing, and ⛔ it - * never admits a post that lacks the declared header. The flow parse - * refuses a declared `header` that names `x-objectstack-signature` or + * never admits a post that lacks the declared header. The declared shape + * itself refuses a `header` that names `x-objectstack-signature` or * another reserved header (`API_TRIGGER_SIGNATURE_RESERVED_HEADERS`: the * request's credentials, routing or framing, or the idempotency key), in * any case, so a declared scheme never reads one. A hook whose