From 07dc391adc285684c87c14a6bbbb90d27644ffba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 09:41:55 +0000 Subject: [PATCH 1/4] feat(trigger-api): verify a hook's declared vendor signature (config.signature) WIP: the verifier, the one-verifier-per-hook arming in start(), and the declared header read on both faces of the inbound-hook door. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12 --- .../triggers/trigger-api/src/api-trigger.ts | 176 +++++++++++++++--- .../trigger-api/src/declared-signature.ts | 171 +++++++++++++++++ packages/triggers/trigger-api/src/plugin.ts | 15 +- 3 files changed, 336 insertions(+), 26 deletions(-) create mode 100644 packages/triggers/trigger-api/src/declared-signature.ts diff --git a/packages/triggers/trigger-api/src/api-trigger.ts b/packages/triggers/trigger-api/src/api-trigger.ts index 96225269d3a..f55a934b3c3 100644 --- a/packages/triggers/trigger-api/src/api-trigger.ts +++ b/packages/triggers/trigger-api/src/api-trigger.ts @@ -3,7 +3,11 @@ import { timingSafeEqual } from 'node:crypto'; import { HTTP_SIGNATURE_TOLERANCE_SECONDS, verifyHttpSignature } from '@objectstack/core'; import type { HttpSignatureVerdict } from '@objectstack/core'; +import { apiTriggerVerifierOf } from '@objectstack/spec/automation'; +import type { ApiTriggerSignature } from '@objectstack/spec/automation'; import type { AutomationContext } from '@objectstack/spec/contracts'; +import { verifyDeclaredSignature } from './declared-signature.js'; +import type { DeclaredSignatureCause } from './declared-signature.js'; /** * Structural mirror of the automation engine's `FlowTriggerBinding` — same @@ -66,16 +70,42 @@ type HookSecret = | { readonly kind: 'resolved'; readonly resolve: () => Promise }; /** - * One armed inbound hook. A secret is required by the type, not only by + * The ONE verifier an armed hook has, decided at {@link ApiTrigger.start} by + * `apiTriggerVerifierOf(binding.config)` (`@objectstack/spec/automation`), the + * reader the engine and the lint share: + * + * - `objectstack-signature`: no `config.signature`. `x-objectstack-signature` + * under the hook's secret, as before that key existed. + * - `declared`: the sender's own scheme, under the key `config.secretRef` + * names, read through the engine's reader (`resolve` only: a declared key is + * never a literal). It REPLACES the platform check, so such a hook never + * reads `x-objectstack-signature`. + * - `unusable`: a `config.signature` the spec refuses. Armed so that every + * post to it is refused with its own logged cause, before any secret is + * read, with the one `401 INVALID_SIGNATURE`; ⛔ never read as + * `objectstack-signature`, so a broken declaration cannot fall back to the + * platform check, and it carries no secret, so it has nothing to verify with. + * + * A secret is required by the type for both verifying kinds, not only by * {@link ApiTrigger.start}'s check: ADR-0041's `trigger-api` acceptance - * criteria name a per-flow secret and HMAC verification, so a hook without - * one has no legal shape to be stored in, and {@link ApiTrigger.handleRequest} - * has no unsigned branch to take. + * criteria name a per-flow secret and HMAC verification, so a hook without one + * has no legal shape to be stored in, and {@link ApiTrigger.handleRequest} has + * no unsigned branch to take. */ +type HookVerifier = + | { readonly kind: 'objectstack-signature'; readonly secret: HookSecret } + | { + readonly kind: 'declared'; + readonly scheme: ApiTriggerSignature; + readonly secret: Extract; + } + | { readonly kind: 'unusable' }; + +/** One armed inbound hook. */ interface ArmedHook { flowName: string; hookId: string; - secret: HookSecret; + verifier: HookVerifier; queue: string; callback: (ctx: AutomationContext) => Promise; } @@ -96,7 +126,12 @@ type UnverifiedSignatureCause = * so an operator can still tell a misconfigured sender's wrong URL from its * wrong secret, or from its retired signing form, while the sender cannot. */ -type UnverifiedPostCause = 'unknown-flow' | 'wrong-hook-id' | UnverifiedSignatureCause; +type UnverifiedPostCause = + | 'unknown-flow' + | 'wrong-hook-id' + | UnverifiedSignatureCause + | DeclaredSignatureCause + | 'declared-unusable'; const UNVERIFIED_POST_CAUSE_TEXT: Readonly> = { 'unknown-flow': 'no hook is armed for the flow it names', @@ -112,6 +147,24 @@ const UNVERIFIED_POST_CAUSE_TEXT: Readonly> `this door does not accept because it signs no time. The sender signs the timestamped form instead: ` + `t=,v1=.">, refused once t is more than ` + `${HTTP_SIGNATURE_TOLERANCE_SECONDS}s from this server's clock (@objectstack/core signHttpBodyAt)`, + // A hook that declares its sender's scheme (`config.signature`): the + // causes name the declared header, never its value. + 'declared-header-absent': "it carries no value in the header its hook's config.signature declares", + 'declared-header-malformed': + "the header its hook's config.signature declares does not hold the declared form: a digest that does not " + + 'decode to 32 bytes in the declared encoding (a body-only header sent twice is one of these), or a ' + + 'timestamp element that is missing, repeated or not whole unix seconds', + 'declared-prefix-missing': "the header its hook's config.signature declares does not start with the declared prefix", + 'declared-no-digest': "the header its hook's config.signature declares carries no element named as its digest", + 'declared-mismatch': + "no digest in the header its hook's config.signature declares is the HMAC-SHA256, under the key " + + 'config.secretRef names, of what that scheme signs', + 'declared-outside-window': + `its declared timestamped signature verified, with a timestamp more than ${HTTP_SIGNATURE_TOLERANCE_SECONDS}s ` + + `from this server's clock (a replay, or a sender clock that is wrong)`, + 'declared-unusable': + "its hook's config.signature is one the flow parse refuses (apiTriggerVerifierOf answers unusable), so " + + 'the hook admits no post at all and never falls back to x-objectstack-signature', }; /** @@ -234,6 +287,15 @@ export function verifySignature(secret: string, rawBody: string, header: string * unreadable held secret. This trigger never reads the name * itself — a host that binds without that engine has no * `resolveSecret`, and the flow is refused as above. + * - `signature` — a third-party sender's own scheme (DocuSign Connect's + * `X-DocuSign-Signature-1`, a Stripe-style `t=…,v1=…`), keyed by + * `secretRef` (`@objectstack/spec/automation`, + * `api-trigger-signature.zod.ts`). It REPLACES the + * `x-objectstack-signature` check for its hook: the hook has + * that one verifier (`verifyDeclaredSignature`), never both. + * A declaration the spec refuses arms a hook that refuses + * every post with the same `401`, before any secret is read, + * and never falls back to the platform check. */ export class ApiTrigger implements FlowTrigger { readonly type = 'api'; @@ -247,7 +309,8 @@ export class ApiTrigger implements FlowTrigger { /** Currently armed hooks (for diagnostics/tests). */ listHooks(): Array<{ flowName: string; hookId: string; signed: boolean }> { - // Every armed hook is signed: the type admits no unsigned one. + // Every armed hook is signed: the type admits no unsigned one, and a + // hook with an unusable declaration admits nothing at all. return [...this.hooks.values()].map(h => ({ flowName: h.flowName, hookId: h.hookId, signed: true, })); @@ -256,8 +319,49 @@ export class ApiTrigger implements FlowTrigger { start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise): void { const cfg = (binding.config ?? {}) as Record; const hookId = typeof cfg.hookId === 'string' && cfg.hookId.trim() ? cfg.hookId.trim() : 'default'; - const literal = usableSecret(cfg.secret); + const queue = `${QUEUE_PREFIX}:${binding.flowName}`; + // Which verifier this hook has, decided once, by the spec's one reader. + // It decides on the PRESENCE of `config.signature`, so a declaration + // that is present and broken is `unusable`, never the platform check. + const declaration = apiTriggerVerifierOf(cfg); + if (declaration.kind === 'unusable') { + // Armed to refuse: every post gets the one unverified-post answer, + // with its own cause in the log, before any secret is read. Nothing + // is subscribed, because nothing can ever be enqueued for it. + this.hooks.set(binding.flowName, { + flowName: binding.flowName, hookId, verifier: { kind: 'unusable' }, queue, callback, + }); + this.logger.warn( + `[trigger-api] flow '${binding.flowName}' armed to refuse every post: its start node's ` + + `\`config.signature\` is one the flow parse refuses, so its hook admits no post (401 ` + + `INVALID_SIGNATURE) and never falls back to x-objectstack-signature. Fix the declaration ` + + `(\`os validate\` names what is wrong), or remove it to verify x-objectstack-signature.`, + ); + return; + } const resolve = typeof binding.resolveSecret === 'function' ? binding.resolveSecret : undefined; + if (declaration.kind === 'declared') { + // The declared key is only ever the one `config.secretRef` names, + // which only the automation engine's reader resolves; a host that + // binds without it has no key to verify the sender's scheme with. + if (!resolve) { + throw new Error( + `[trigger-api] flow '${binding.flowName}' not armed: its start node declares its sender's ` + + `signature (\`config.signature\`), verified under the key \`config.secretRef\` names, and ` + + `this binding carries no reader for that key. Bind the flow through the automation engine, ` + + `which resolves \`config.secretRef\` at every post.`, + ); + } + this.arm(binding.flowName, hookId, queue, callback, { + kind: 'declared', scheme: declaration.signature, secret: { kind: 'resolved', resolve }, + }); + this.logger.info( + `[trigger-api] armed: POST .../automation/hooks/${binding.flowName}/${hookId} ` + + `(HMAC required: the sender's own scheme in ${declaration.signature.header}, declared)`, + ); + return; + } + const literal = usableSecret(cfg.secret); // ADR-0041 (`trigger-api` acceptance criteria): a per-flow secret and // HMAC verification. Refused BEFORE anything is stored or subscribed, // so a refused flow leaves no hook behind; the engine's bind catch @@ -271,16 +375,29 @@ export class ApiTrigger implements FlowTrigger { `resolves that name at every post.`, ); } - const queue = `${QUEUE_PREFIX}:${binding.flowName}`; - const secret: HookSecret = resolve ? { kind: 'resolved', resolve } : { kind: 'literal', secret: literal! }; - const hook: ArmedHook = { flowName: binding.flowName, hookId, secret, queue, callback }; - this.hooks.set(binding.flowName, hook); + this.arm(binding.flowName, hookId, queue, callback, { kind: 'objectstack-signature', secret }); + + this.logger.info( + `[trigger-api] armed: POST .../automation/hooks/${binding.flowName}/${hookId} (HMAC required)`, + ); + } + + /** Store a verifying hook and subscribe its queue consumer, which runs the flow. */ + private arm( + flowName: string, + hookId: string, + queue: string, + callback: (ctx: AutomationContext) => Promise, + verifier: Exclude, + ): void { + const hook: ArmedHook = { flowName, hookId, verifier, queue, callback }; + this.hooks.set(flowName, hook); const q = this.getQueue(); if (!q) { this.logger.warn( - `[trigger-api] no queue service — inbound posts for flow '${binding.flowName}' will be rejected until one is registered`, + `[trigger-api] no queue service — inbound posts for flow '${flowName}' will be rejected until one is registered`, ); } else { void q.subscribe<{ payload: Record }>(queue, async (message) => { @@ -297,10 +414,6 @@ export class ApiTrigger implements FlowTrigger { this.logger.warn(`[trigger-api] subscribe failed for '${queue}': ${err?.message ?? err}`); }); } - - this.logger.info( - `[trigger-api] armed: POST .../automation/hooks/${binding.flowName}/${hookId} (HMAC required)`, - ); } stop(flowName: string): void { @@ -323,7 +436,16 @@ export class ApiTrigger implements FlowTrigger { flowName: string; hookId: string; rawBody: string; + /** The `x-objectstack-signature` value, read only by a hook that declares no scheme of its own. */ signatureHeader?: string; + /** + * Reads one request header by name, its value as `Headers.get` answers + * it (a repeated field is ONE value, joined with `, `), `undefined` + * when absent. A hook that declares its sender's scheme reads its + * declared header through it, and nothing else; without it that hook + * finds no header and refuses the post. + */ + readHeader?: (name: string) => string | undefined; idempotencyKey?: string; }): Promise<{ status: number; body: Record }> { const hook = this.hooks.get(input.flowName); @@ -336,6 +458,10 @@ export class ApiTrigger implements FlowTrigger { // post that names an armed flow AND its hookId. if (!hook) return this.refuseUnverified('unknown-flow', input.flowName); if (!safeEqual(hook.hookId, input.hookId)) return this.refuseUnverified('wrong-hook-id', hook.flowName); + // A declaration the spec refuses admits nothing: refused here, before + // any secret is read, so it is the one 401 and never the 503 below. + const verifier = hook.verifier; + if (verifier.kind === 'unusable') return this.refuseUnverified('declared-unusable', hook.flowName); // The secret, read now: a rotation applies to the next post. One that // cannot be read is the deployment's fault, not the sender's, and is // never read as "no secret" — the post is refused without verifying. @@ -343,7 +469,9 @@ export class ApiTrigger implements FlowTrigger { // while the secret cannot be read. let secret: string | undefined; try { - secret = hook.secret.kind === 'literal' ? hook.secret.secret : usableSecret(await hook.secret.resolve()); + secret = verifier.secret.kind === 'literal' + ? verifier.secret.secret + : usableSecret(await verifier.secret.resolve()); } catch (err: any) { this.logger.warn(`[trigger-api] the secret of flow '${hook.flowName}' could not be read: ${err?.message ?? err}`); secret = undefined; @@ -354,10 +482,14 @@ export class ApiTrigger implements FlowTrigger { body: { success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'The hook secret is unavailable.' } }, }; } - // Verified before the body is parsed or anything is enqueued; a - // body-only signature is refused here like any other that does not - // verify, with its own cause in the log. - const verdict = verifyInboundSignature(input.rawBody, secret, input.signatureHeader); + // Verified before the body is parsed or anything is enqueued, by the + // hook's ONE verifier. A declared scheme reads its own header and never + // `x-objectstack-signature`, so a valid platform signature admits + // nothing there. A body-only `x-objectstack-signature` is refused like + // any other that does not verify, with its own cause in the log. + const verdict = verifier.kind === 'declared' + ? verifyDeclaredSignature(verifier.scheme, secret, input.rawBody, input.readHeader?.(verifier.scheme.header)) + : verifyInboundSignature(input.rawBody, secret, input.signatureHeader); if (!verdict.valid) return this.refuseUnverified(verdict.cause, hook.flowName); let payload: Record; diff --git a/packages/triggers/trigger-api/src/declared-signature.ts b/packages/triggers/trigger-api/src/declared-signature.ts new file mode 100644 index 00000000000..5f754714f81 --- /dev/null +++ b/packages/triggers/trigger-api/src/declared-signature.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { HTTP_SIGNATURE_TOLERANCE_SECONDS } from '@objectstack/core'; +import type { ApiTriggerSignature } from '@objectstack/spec/automation'; + +/** + * The verifier for a sender's own signature scheme, declared on an `api` + * hook's start node as `config.signature` (`@objectstack/spec/automation`, + * `api-trigger-signature.zod.ts`). What it owes is the contract text on + * `ITriggerApiService.handleInboundHook` ("A sender's own scheme, declared, + * REPLACES that check"), implemented here step for step: + * + * - the declared header's ONE value, a repeated field already joined with + * `, ` by `Headers.get` (the caller reads it so), trimmed; + * - `signs: 'body'`: after `prefix` when one is declared (a value without it + * fails), the whole value is the one digest, so a repeated body-only header + * does not decode and fails; + * - `signs: 'timestamp.body'`: `NAME=value` elements separated by `,`, each + * trimmed and split at its first `=`; exactly one `timestampElement`, + * written as `x-objectstack-signature` writes its `t` (decimal, no sign, no + * leading zero, at most fifteen digits); one or more `digestElement`s (a + * sender rolling its key sends one per key); any other element ignored; + * - the HMAC-SHA256, under the key, of the raw body exactly as received, or of + * `.` with `` exactly as received; + * - each received digest decoded by `encoding` to exactly 32 bytes (strictly: + * a value that is not the canonical spelling of 32 bytes does not decode) + * and compared with that HMAC in constant time (`timingSafeEqual`); the post + * verifies when one digest matches; + * - for `timestamp.body`, then a `t` more than + * `HTTP_SIGNATURE_TOLERANCE_SECONDS` from this clock is refused: core's + * window, the one the `x-objectstack-signature` check applies, ⛔ never one + * of this file's own. A `body` scheme signs no time, so no window applies + * to it (the contract states it; nothing here invents one). + * + * Every failure is a `cause` for the server log; the sender gets the one + * `401 INVALID_SIGNATURE` the trigger answers every unverified post with. The + * key, the header value and the body are ⛔ never part of a cause. + */ + +/** Why a declared scheme did not verify. Server-log only. */ +export type DeclaredSignatureCause = + | 'declared-header-absent' + | 'declared-header-malformed' + | 'declared-prefix-missing' + | 'declared-no-digest' + | 'declared-mismatch' + | 'declared-outside-window'; + +export type DeclaredSignatureVerdict = + | { readonly valid: true } + | { readonly valid: false; readonly cause: DeclaredSignatureCause }; + +/** The length of an HMAC-SHA256 digest, the only length a received digest may decode to. */ +const DIGEST_BYTES = 32; + +/** 32 bytes in hex: 64 hex digits, either case. */ +const HEX_DIGEST = /^[0-9A-Fa-f]{64}$/; + +/** + * 32 bytes in base64, the RFC 4648 standard alphabet: 43 characters and one + * `=` of padding. Node's decoder skips characters outside the alphabet, so the + * shape is checked before decoding and the canonical spelling after. + */ +const BASE64_DIGEST = /^[A-Za-z0-9+/]{43}=$/; + +/** A timestamp exactly as `x-objectstack-signature` writes its `t` (core's `TIMESTAMPED_FORM`). */ +const TIMESTAMP = /^(0|[1-9][0-9]{0,14})$/; + +/** + * The 32 bytes a received digest spells, or `undefined` when it spells + * anything else (another length, another alphabet, a non-canonical base64 + * tail): such a digest matches nothing. + */ +function decodeDigest(text: string, encoding: ApiTriggerSignature['encoding']): Buffer | undefined { + if (encoding === 'hex') { + return HEX_DIGEST.test(text) ? Buffer.from(text, 'hex') : undefined; + } + if (!BASE64_DIGEST.test(text)) return undefined; + const bytes = Buffer.from(text, 'base64'); + if (bytes.length !== DIGEST_BYTES || bytes.toString('base64') !== text) return undefined; + return bytes; +} + +/** + * Whether one of `digests` is `expected`, each compared in constant time. All + * are compared, so the time taken does not say which one matched. + */ +function anyDigestMatches(expected: Buffer, digests: readonly Buffer[]): boolean { + let matched = false; + for (const digest of digests) { + if (timingSafeEqual(expected, digest)) matched = true; + } + return matched; +} + +function hmacSha256(key: string, material: string): Buffer { + return createHmac('sha256', key).update(material, 'utf8').digest(); +} + +/** + * Verify one post against the hook's declared scheme. + * + * @param scheme - the parsed declaration (`apiTriggerVerifierOf(config).signature`) + * @param key - the key `config.secretRef` names, read for this post + * @param rawBody - the body exactly as received (`request.text()`) + * @param headerValue - the declared header's one value (`Headers.get`), or + * `undefined` when the post does not carry it + * @param nowSeconds - this server's clock, in unix seconds + */ +export function verifyDeclaredSignature( + scheme: ApiTriggerSignature, + key: string, + rawBody: string, + headerValue: string | undefined, + nowSeconds: number = Math.floor(Date.now() / 1000), +): DeclaredSignatureVerdict { + const value = headerValue?.trim(); + if (!value) return { valid: false, cause: 'declared-header-absent' }; + + if (scheme.signs === 'body') { + let digestText = value; + if (scheme.prefix !== undefined) { + if (!value.startsWith(scheme.prefix)) return { valid: false, cause: 'declared-prefix-missing' }; + digestText = value.slice(scheme.prefix.length); + } + const digest = decodeDigest(digestText, scheme.encoding); + if (!digest) return { valid: false, cause: 'declared-header-malformed' }; + return anyDigestMatches(hmacSha256(key, rawBody), [digest]) + ? { valid: true } + : { valid: false, cause: 'declared-mismatch' }; + } + + const timestamps: string[] = []; + const digestTexts: string[] = []; + for (const raw of value.split(',')) { + const element = raw.trim(); + if (!element) continue; + const eq = element.indexOf('='); + // Split at the first `=` and nothing more: base64 padding stays in the + // value, and a value is read exactly as received (`` is signed so). + const name = eq === -1 ? element : element.slice(0, eq); + const elementValue = eq === -1 ? undefined : element.slice(eq + 1); + if (name === scheme.timestampElement) { + // An element named as the timestamp with no value is a malformed timestamp. + timestamps.push(elementValue ?? ''); + } else if (name === scheme.digestElement) { + digestTexts.push(elementValue ?? ''); + } + // Any other element is the sender's own business, and ignored. + } + if (timestamps.length !== 1 || !TIMESTAMP.test(timestamps[0]!)) { + return { valid: false, cause: 'declared-header-malformed' }; + } + if (digestTexts.length === 0) return { valid: false, cause: 'declared-no-digest' }; + const digests = digestTexts + .map((text) => decodeDigest(text, scheme.encoding)) + .filter((d): d is Buffer => d !== undefined); + if (digests.length === 0) return { valid: false, cause: 'declared-header-malformed' }; + + const t = timestamps[0]!; + if (!anyDigestMatches(hmacSha256(key, `${t}.${rawBody}`), digests)) { + return { valid: false, cause: 'declared-mismatch' }; + } + // At most fifteen digits, so always a safe integer. Judged after the HMAC, + // as core judges `x-objectstack-signature`: only a signed `t` is weighed. + if (Math.abs(nowSeconds - Number(t)) > HTTP_SIGNATURE_TOLERANCE_SECONDS) { + return { valid: false, cause: 'declared-outside-window' }; + } + return { valid: true }; +} diff --git a/packages/triggers/trigger-api/src/plugin.ts b/packages/triggers/trigger-api/src/plugin.ts index b5239f40507..e744923633e 100644 --- a/packages/triggers/trigger-api/src/plugin.ts +++ b/packages/triggers/trigger-api/src/plugin.ts @@ -35,14 +35,15 @@ const TRIGGER_API_SLOT = 'trigger-api'; /** * [#22774] The slice of a Hono context the inbound-hook door reads and answers - * through, and nothing more: the raw body as text, two request headers, and - * `json(body, status)`. + * through, and nothing more: the raw body as text, request headers by name + * (`x-objectstack-signature`, `x-idempotency-key`, and the header a hook's + * `config.signature` declares), and `json(body, status)`. * * Both faces of the door hand {@link serveInboundHook} one of these. The * self-hosted mount hands it the real Hono context, so its answers are the ones * it always gave. The `trigger-api` service's `handleInboundHook` hands it * {@link requestDoorContext} over a web-standard `Request`. So both faces run - * ONE copy of the door: one body read, one pair of header names, one + * ONE copy of the door: one body read, one header read, one * {@link ApiTrigger.handleRequest} (the hook match, the secret read, the * verifier and every status), and one way of answering. */ @@ -97,6 +98,10 @@ async function serveInboundHook( hookId: hook.hookId, rawBody, signatureHeader: c.req.header('x-objectstack-signature'), + // A hook that declares its sender's scheme (`config.signature`) reads + // its declared header through this, by name, on both faces alike: + // `Headers.get`, so a repeated field is its one joined value. + readHeader: (name) => c.req.header(name), idempotencyKey: c.req.header('x-idempotency-key') || undefined, }); return c.json(out.body, out.status); @@ -116,7 +121,9 @@ async function serveInboundHook( * web-standard `Request`, for a kernel with no raw app; * - validates hookId + HMAC signature (`x-objectstack-signature`, the * timestamped `t=…,v1=…` form only, constant-time) against the flow's - * start-node config; + * start-node config, or, for a hook whose start node declares its sender's + * own scheme (`config.signature`), that scheme INSTEAD, in its declared + * header, under the key `config.secretRef` names; * - **enqueues** the payload (`queue` service) and ACKs 202 — flow * execution happens on the queue consumer, never in-band with the * inbound request (ADR-0041 §5: boundary triggers must not let a slow From 607a0d1ccb93de0d60957580dbe552fa9192518b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 09:49:53 +0000 Subject: [PATCH 2/4] test(trigger-api, runtime, lint): pin the declared vendor signature through the real door; readiness reads the hook's one verifier Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12 --- packages/lint/src/index.ts | 1 + packages/lint/src/rule-explanations.ts | 27 +++ .../validate-flow-trigger-readiness.test.ts | 113 +++++++++- .../src/validate-flow-trigger-readiness.ts | 81 ++++++- .../src/domains/automation-hooks.test.ts | 41 ++++ .../src/inbound-hook-member.test.ts | 212 ++++++++++++++++++ 6 files changed, 469 insertions(+), 6 deletions(-) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index ef0f252080e..b488a09319b 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -145,6 +145,7 @@ export { FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE, FLOW_API_TRIGGER_SECRET_MISSING, + FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, } from './validate-flow-trigger-readiness.js'; export type { FlowTriggerReadinessFinding, diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index a59f035ce87..79a1162be4b 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2066,6 +2066,32 @@ const FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION: RuleExplanation = { ], }; +const FLOW_API_TRIGGER_SIGNATURE_UNUSABLE_EXPLANATION: RuleExplanation = { + rule: 'flow-api-trigger-signature-unusable', + covers: 'why a hook with a broken sender signature admits nothing', + paragraphs: [ + 'An inbound api hook may declare how a third-party sender signs its posts, in its start node\'s ' + + '`config.signature`: the header the sender writes its digest in, `hmac-sha256`, `hex` or `base64`, and ' + + 'what is signed, the raw body (`signs: \'body\'`, as DocuSign Connect signs it) or `.` ' + + '(`signs: \'timestamp.body\'`, a Stripe-style `t=…,v1=…` header). The key is the one ' + + '`config.secretRef` names, never a literal. A declared scheme REPLACES the `x-objectstack-signature` ' + + 'check for its hook: the hook has that one verifier and never reads the platform header.', + 'The finding means the declaration is present and the spec refuses it: an unknown algorithm or ' + + 'encoding, a reserved header name, a timestamped scheme without its elements, no `config.secretRef`, ' + + 'or a literal `config.secret` beside it. The verdict names the key the spec refuses at; the flow ' + + 'parse prints the refusal itself in full.', + 'A broken declaration never falls back to `x-objectstack-signature` and is never admitted unsigned. The ' + + 'automation engine\'s flow parse refuses the flow at registration, and a trigger handed it anyway ' + + 'refuses every post to its hook with the one `401 INVALID_SIGNATURE` every unverified post gets, so ' + + 'the flow never receives a post. Fix the declaration, or remove `config.signature` to verify ' + + '`x-objectstack-signature` under the hook\'s secret.', + 'A usable declaration is ready: the hook verifies the sender\'s header under the named key, a ' + + 'timestamped scheme inside the same 300-second window `x-objectstack-signature` has, a body-only ' + + 'scheme with no replay window because the sender signs no time.', + FLOW_GATE_REACH, + ], +}; + // `flow-trigger-unknown-object` lives in the same rule file but is not one of // the five: its verdict depends on packages a build cannot see, so it warns — // except under a package closure the runtime publish gate states, where the @@ -5654,6 +5680,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [FLOW_TRIGGER_UNROUTABLE_EXPLANATION.rule]: FLOW_TRIGGER_UNROUTABLE_EXPLANATION, [FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION, [FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION.rule]: FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION, + [FLOW_API_TRIGGER_SIGNATURE_UNUSABLE_EXPLANATION.rule]: FLOW_API_TRIGGER_SIGNATURE_UNUSABLE_EXPLANATION, [FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION, [FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION, [VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION.rule]: VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION, diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 0f23dbe0102..80d9b140f22 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -12,6 +12,7 @@ import { FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE, FLOW_API_TRIGGER_SECRET_MISSING, + FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, validateFlowApiTriggerSecret as validateFlowApiTriggerSecretUnrecorded, schemaRefusalHead, } from './validate-flow-trigger-readiness.js'; @@ -30,6 +31,7 @@ const NEVER_FIRE_IDS: readonly string[] = [ FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE, FLOW_API_TRIGGER_SECRET_MISSING, + FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, FLOW_TRIGGER_UNKNOWN_EVENT, ]; const fired: Array<{ rule: string; message: string; hint: string }> = []; @@ -1521,6 +1523,31 @@ describe('validateFlowTriggerReadiness', () => { ], }, ], + // [#30009] A declared sender scheme the spec refuses: never-fire, like a missing secret. + [ + FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, + 'error', + { + objects: [candidateObject], + flows: [ + { + name: 'broken_vendor_hook', + type: 'api', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + config: { + secretRef: 'DOCUSIGN_CONNECT_KEY', + signature: { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha1', encoding: 'base64' }, + }, + }, + ], + }, + ], + }, + ], // ── The controls. Both are hedged, and the hedge is the whole reason the // promotion above is not "everything in this file is an error now". [ @@ -1753,6 +1780,89 @@ describe('validateFlowTriggerReadiness', () => { }); }); +// [#30009] A start node that declares its sender's own scheme (`config.signature`) +// is graded by the hook's ONE verifier, read through the spec's +// `apiTriggerVerifierOf` (the reader the engine and the trigger share): ready on +// the key `config.secretRef` names and nothing else, and never ready when the +// reader answers `unusable`. +describe('[#30009] api trigger readiness reads the hook\'s one verifier (config.signature)', () => { + const DOCUSIGN = { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' }; + const STRIPE = { + signs: 'timestamp.body', header: 'Stripe-Signature', algorithm: 'hmac-sha256', encoding: 'hex', + timestampElement: 't', digestElement: 'v1', + }; + const vendorFlow = (config: Record, type = 'api') => ({ + name: 'vendor_hook', + label: 'Vendor hook', + type, + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }); + const judge = (flow: Record) => cliFlowFamily({ objects: [candidateObject], flows: [flow] }); + const rules = (flow: Record) => judge(flow).map((f) => [f.rule, f.severity, f.path]); + + it('a usable declaration keyed by secretRef is ready, on both arms, at the CLI and the runtime publish gate', () => { + for (const signature of [DOCUSIGN, STRIPE, { ...DOCUSIGN, encoding: 'hex', prefix: 'sha256=' }]) { + const flow = vendorFlow({ hookId: 'vendor', secretRef: 'VENDOR_WEBHOOK_KEY', signature }); + expect(judge(flow), JSON.stringify(signature)).toEqual([]); + expect(runRuntimeAuthoringRules({ type: 'flow', item: flow }).errors, JSON.stringify(signature)).toEqual([]); + } + }); + + it('an unusable declaration is never ready: one error, at the key the spec refuses', () => { + const cases: Array<[string, Record, string]> = [ + ['an algorithm the spec refuses', { secretRef: 'K', signature: { ...DOCUSIGN, algorithm: 'hmac-sha1' } }, 'config.signature.algorithm'], + ['a reserved header', { secretRef: 'K', signature: { ...DOCUSIGN, header: 'x-objectstack-signature' } }, 'config.signature.header'], + ['a timestamped scheme with no timestamp element', { secretRef: 'K', signature: { ...STRIPE, timestampElement: undefined } }, 'config.signature.timestampElement'], + ['no key reference', { signature: DOCUSIGN }, 'config.signature'], + ['a literal secret beside it (an inline key)', { secret: 'whsec_inline', signature: DOCUSIGN }, 'config.signature'], + ['a literal secret AND a secretRef beside it', { secret: 'whsec_inline', secretRef: 'K', signature: DOCUSIGN }, 'config.signature'], + ['a secretRef that is not a usable name', { secretRef: 'OS_AUTH_SECRET', signature: DOCUSIGN }, 'config.secretRef'], + ]; + for (const [label, config, keyPath] of cases) { + const findings = judge(vendorFlow(config)).filter((f) => f.rule === FLOW_API_TRIGGER_SIGNATURE_UNUSABLE); + expect(findings.map((f) => [f.severity, f.path]), label).toEqual([['error', `flows[0].nodes[0].${keyPath}`]]); + expect(findings[0]!.message, label).toContain(keyPath); + expect(findings[0]!.message, label).toContain(`type: 'api'`); + // Never the missing-secret id beside it: one verdict per hook. + expect(judge(vendorFlow(config)).map((f) => f.rule), label).not.toContain(FLOW_API_TRIGGER_SECRET_MISSING); + // And never let through at the runtime publish gate. + expect( + runRuntimeAuthoringRules({ type: 'flow', item: vendorFlow(config) }).errors.map((f) => f.rule), + label, + ).toContain(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE); + } + }); + + it('a declared scheme is graded on its key reference alone: a withheld-and-stored literal secret does not make it ready', () => { + const flow = vendorFlow({ signature: DOCUSIGN }); + const findings = validateFlowApiTriggerSecret( + { objects: [candidateObject], flows: [flow] }, + { restoredCredentialPaths: new Set(['flows[0].nodes[0].config.secret']) }, + ); + expect(findings.map((f) => [f.rule, f.path])).toEqual([[FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, 'flows[0].nodes[0].config.signature']]); + }); + + it('CONTROL: a flow that binds no api trigger is not graded by this rule, and a hook with no declaration keeps its own verdicts', () => { + expect(rules(vendorFlow({ secretRef: 'K', signature: { ...DOCUSIGN, algorithm: 'hmac-sha1' } }, 'autolaunched')) + .map(([rule]) => rule)).not.toContain(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE); + expect(rules(vendorFlow({ hookId: 'vendor' }))).toEqual([[FLOW_API_TRIGGER_SECRET_MISSING, 'error', 'flows[0].nodes[0].config.secret']]); + expect(judge(vendorFlow({ secretRef: 'VENDOR_WEBHOOK_KEY' }))).toEqual([]); + }); + + it('`os explain flow-api-trigger-signature-unusable` says what a declared scheme is and why a broken one admits nothing', () => { + expect(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE).toBe('flow-api-trigger-signature-unusable'); + const text = explanationOf(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE); + for (const fact of ['config.signature', 'config.secretRef', 'REPLACES', 'never falls back', '401 INVALID_SIGNATURE', '300-second']) { + expect(text, fact).toContain(fact); + } + }); +}); + describe('[#22161] one-line verdicts — the never-fire family', () => { it('every verdict the cases above fired is one line of at most 200 characters', () => { // The coverage control first: each of the five ids fired at least once, so @@ -1798,9 +1908,10 @@ describe('[#22161] one-line verdicts — the never-fire family', () => { [FLOW_TRIGGER_UNROUTABLE]: ['`resolveTriggerBinding`', 'manual — nothing to bind', 'one higher than its bound count', 'RESOLVED type', 'record-{before,after}-{create,insert,update,delete,write}'], [FLOW_TRIGGER_UNKNOWN_EVENT]: ['record-{before,after}-{create,insert,update,delete,write}', 'bind-time warn', 'multi-event shape', '`record-after-write`'], [FLOW_API_TRIGGER_SECRET_MISSING]: ['ADR-0041', 'HMAC', '`registerFlow`', 'whatever the flow\'s `status`', 'never receives a post', 'withholds'], + [FLOW_API_TRIGGER_SIGNATURE_UNUSABLE]: ['REPLACES', 'never falls back', 'flow parse refuses the flow at registration', 'names the key the spec refuses at'], }; - it('covers exactly the five ids', () => { + it('covers exactly the never-fire ids', () => { expect(Object.keys(MOVED).sort()).toEqual([...NEVER_FIRE_IDS].sort()); }); diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index cf132f8f002..e3acac4216b 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -65,6 +65,12 @@ // rest of the family does not (#20611) — see its docblock for why, and see // `FLOW_API_TRIGGER_SECRET_MISSING` for why the judgement is carried here // rather than read from the runtime. +// [#30009] The same rule reads the hook's ONE verifier through the spec's +// `apiTriggerVerifierOf`, the reader the engine and the trigger share: a +// start node that declares its sender's own scheme (`config.signature`) +// is ready on the key `config.secretRef` names and nothing else, and one +// whose declaration the reader answers `unusable` is never ready +// (`flow-api-trigger-signature-unusable`). // // ⚠️ One more rule lived here and is RETIRED (#17396): // `flow-schedule-organization-missing`, a `warning` on a time-triggered @@ -180,6 +186,8 @@ import { API_TRIGGER_SECRET_REF_KEY, TimeRelativeTriggerSchema, apiTriggerSecretRefOf, + apiTriggerSignatureRefusals, + apiTriggerVerifierOf, resolveFlowTriggerKind, } from '@objectstack/spec/automation'; import { closureDependencyRemedy, recordsOf, suggestName } from './object-graph.js'; @@ -298,6 +306,17 @@ export const FLOW_TRIGGER_UNROUTABLE = 'flow-trigger-unroutable'; * with the engine, so this rule restates no grammar for it. */ export const FLOW_API_TRIGGER_SECRET_MISSING = 'flow-api-trigger-secret-missing'; +/** + * #30009 — a flow bound to the inbound `api` trigger whose start node declares + * its sender's own signature scheme (`config.signature`) in a form the spec + * refuses: `apiTriggerVerifierOf` answers `unusable`. The engine's flow parse + * refuses the flow at registration, and a trigger handed it anyway arms a + * hook that refuses every post `401 INVALID_SIGNATURE` and never falls back + * to `x-objectstack-signature`, so it never receives a post. The verdict is + * the spec's own reader, shared with the engine and the trigger, so this rule + * restates none of the declaration's grammar. + */ +export const FLOW_API_TRIGGER_SIGNATURE_UNUSABLE = 'flow-api-trigger-signature-unusable'; type AnyRec = Record; @@ -1055,17 +1074,30 @@ export function validateFlowApiTriggerSecret( const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined; const bindsApiTrigger = !isArrayRecordTriggerType(config) && resolveFlowTriggerKind(flow) === 'api'; const secretPath = start ? `flows[${flowIndex}].nodes[${start.index}].config.secret` : undefined; - // [#20611] Withheld and stored ⇒ present: the host restores the stored - // secret at exactly this position before the item is persisted. - if (secretPath !== undefined && options.restoredCredentialPaths?.has(secretPath)) return; - const secretProblem = bindsApiTrigger ? describeUnusableSecret(start, config) : undefined; - if (!secretProblem) return; // Which declaration binds it — the engine's own message names the same // two, so an author who meets both channels reads one story. const binds = [ flow.type === 'api' ? `type: 'api'` : undefined, triggerType === 'api' ? `start-node triggerType: 'api'` : undefined, ].filter((s): s is string => s !== undefined); + // [#30009] The hook's ONE verifier, by the spec's reader the engine and + // the trigger share. Judged before the withheld-secret shortcut below: a + // declared scheme's key is only ever `config.secretRef`, never the stored + // literal that shortcut stands for. + const verifier = bindsApiTrigger && start ? apiTriggerVerifierOf(config) : undefined; + if (verifier?.kind === 'unusable') { + findings.push(signatureUnusableFinding(flow, flowIndex, flowName, start!, binds)); + return; + } + // A declared scheme verifies under the key `config.secretRef` names, which + // `apiTriggerVerifierOf` has already found usable: the hook is ready, and + // no literal or withheld secret is consulted for it. + if (verifier?.kind === 'declared') return; + // [#20611] Withheld and stored ⇒ present: the host restores the stored + // secret at exactly this position before the item is persisted. + if (secretPath !== undefined && options.restoredCredentialPaths?.has(secretPath)) return; + const secretProblem = bindsApiTrigger ? describeUnusableSecret(start, config) : undefined; + if (!secretProblem) return; findings.push({ // `error` — the never-fire family, and the strongest verdict in it: the // engine does not merely leave this flow unfired, it refuses to register @@ -1089,3 +1121,42 @@ export function validateFlowApiTriggerSecret( }); return findings; } + +/** + * The 1h finding for an `api`-bound start node whose `config.signature` the + * spec's reader answers `unusable` ({@link FLOW_API_TRIGGER_SIGNATURE_UNUSABLE}). + * Located at the first refusal the spec's own judge raises for that node; a + * node the judge raises nothing for is unusable because its + * `config.secretRef` is, and is located there. The verdict names the key, not + * the refusal's prose: the flow parse already prints that in full, and a + * verdict here stays one line (#22161). + */ +function signatureUnusableFinding( + flow: AnyRec, + flowIndex: number, + flowName: string, + start: { node: AnyRec; index: number }, + binds: readonly string[], +): FlowTriggerReadinessFinding { + const nodePrefix = ['nodes', start.index, 'config']; + const own = apiTriggerSignatureRefusals(flow).find((r) => nodePrefix.every((seg, i) => r.path[i] === seg)); + const keyPath = own + ? own.path.slice(2).reduce((acc, seg) => (typeof seg === 'number' ? `${acc}[${seg}]` : `${acc}.${seg}`), '').slice(1) + : `config.${API_TRIGGER_SECRET_REF_KEY}`; + return { + // `error` — the never-fire family: the engine's flow parse refuses the + // flow at registration, and the trigger admits no post to such a hook. + severity: 'error', + rule: FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, + where: `flow "${flowName}" › start node`, + path: `flows[${flowIndex}].nodes[${start.index}].${keyPath}`, + // [#22161] One verdict sentence; what a declared scheme is, and why a + // broken one admits nothing, is `os explain flow-api-trigger-signature-unusable`. + message: + `binds the inbound api trigger (${binds.join(' and ')}) and declares config.signature, refused at ` + + `${keyPath}, so the engine refuses the flow and its hook admits no post`, + hint: + `Fix config.signature (its key named by config.secretRef only), or remove it to verify ` + + `x-objectstack-signature`, + }; +} diff --git a/packages/runtime/src/domains/automation-hooks.test.ts b/packages/runtime/src/domains/automation-hooks.test.ts index 5a0cbd72ddf..415e57bcc1f 100644 --- a/packages/runtime/src/domains/automation-hooks.test.ts +++ b/packages/runtime/src/domains/automation-hooks.test.ts @@ -529,3 +529,44 @@ describe('[#22773] CONTROL — a kernel with an http.server gets no second mount expect(routes.some((r) => r.startsWith('POST ') && answers(r.slice(5), '/api/v1/automation/f/trigger'))).toBe(true); }); }); + +/** + * [#30009] A hook may declare its sender's own scheme (`config.signature`): + * the trigger then verifies the header that scheme names, which can be any + * header the vendor writes (DocuSign Connect's `X-DocuSign-Signature-1`). The + * dispatcher forwards the transport's own request, so that header reaches the + * member as received, a repeated field included, with no allow-list of header + * names in between. The double verifies DocuSign's base64 HMAC-SHA256 of the + * raw body off the forwarded request, as the trigger's declared verifier does. + */ +describe('[#30009] the forwarded request keeps a vendor\'s own signature header', () => { + it('a DocuSign-signed POST reaches the member with X-DocuSign-Signature-1 as sent, a repeated field as its one joined value, and verifies', async () => { + const { createHmac } = await import('node:crypto'); + const KEY = 'pin-30009-docusign-connect-key'; + const vendorBody = '{"envelopeId":"env_30009","status":"completed"}'; + const digest = createHmac('sha256', KEY).update(vendorBody, 'utf8').digest('base64'); + const seen: Array = []; + const handleInboundHook = vi.fn(async (request: Request): Promise => { + const value = request.headers.get('x-docusign-signature-1'); + seen.push(value); + const body = await request.text(); + const expected = createHmac('sha256', KEY).update(body, 'utf8').digest('base64'); + return value === expected ? json(202, { accepted: true, messageId: 'msg_vendor' }) : json(401, REFUSAL); + }); + const { dispatcher } = makeDispatcher({ 'trigger-api': { handleInboundHook } }); + const vendorPost = (values: string[]) => new Request(`${ORIGIN}/api/v1${ROUTE}`, { + method: 'POST', + headers: new Headers([['Content-Type', 'application/json'], ...values.map((v) => ['X-DocuSign-Signature-1', v] as [string, string])]), + body: vendorBody, + }); + + const once = vendorPost([digest]); + const ok = await dispatcher.dispatch('POST', ROUTE, undefined, {}, ctx(once)); + expect(handleInboundHook.mock.calls[0]![0]).toBe(once); + expect((ok.result as Response).status).toBe(202); + + const twice = await dispatcher.dispatch('POST', ROUTE, undefined, {}, ctx(vendorPost([digest, digest]))); + expect((twice.result as Response).status).toBe(401); + expect(seen).toEqual([digest, `${digest}, ${digest}`]); + }); +}); diff --git a/packages/triggers/trigger-api/src/inbound-hook-member.test.ts b/packages/triggers/trigger-api/src/inbound-hook-member.test.ts index 3eb37ffaa01..fe39adcd1db 100644 --- a/packages/triggers/trigger-api/src/inbound-hook-member.test.ts +++ b/packages/triggers/trigger-api/src/inbound-hook-member.test.ts @@ -370,3 +370,215 @@ describe('[#22774] the composition — one mount where a raw app exists, read fr expect(plugin.lines('warn', 'trigger-api service not registered')).toHaveLength(1); }); }); + +/** + * [#30009] A sender's own scheme, declared (`config.signature`, + * `@objectstack/spec/automation`), verified through the REAL door: every row + * is posted through the `trigger-api` member AND the self-hosted Hono mount of + * one booted plugin, and the two faces must answer the same bytes. The flows + * are armed as the engine binds them: `config` as authored, the key through + * `resolveSecret` (the engine's `config.secretRef` reader). + * + * The vectors are the wire forms, not this package's functions: DocuSign + * Connect's base64 HMAC-SHA256 of the raw body and a Stripe-style `t=…,v1=…` + * hex HMAC-SHA256 of `.` are pinned as literals (computed with + * `openssl dgst -sha256 -hmac`), and every other digest is `createHmac`'s. + */ +describe('[#30009] a declared sender scheme (config.signature) — the one verifier of its hook, through both faces', () => { + const DOCUSIGN_KEY = 'pin-docusign-connect-key-30009'; + const STRIPE_KEY = 'pin-stripe-style-key-30009'; + const STRIPE_OLD_KEY = 'pin-stripe-style-old-key-30009'; + const GITHUB_KEY = 'pin-github-style-key-30009'; + const DOCUSIGN_BODY = '{"envelopeId":"93be49ab-afa0-4adb-933c-f752070d71ec","status":"completed"}'; + /** `openssl dgst -sha256 -hmac pin-docusign-connect-key-30009 -binary | base64` over DOCUSIGN_BODY. */ + const DOCUSIGN_VECTOR = 'kXOvpoyCVWjcNhIm7UAIrRvnl2Lq093nx/AmksV1z9Y='; + const STRIPE_BODY = '{"id":"evt_30009","type":"payment_intent.succeeded"}'; + /** `openssl dgst -sha256 -hmac pin-stripe-style-key-30009 -hex` over `1760000000.` + STRIPE_BODY. */ + const STRIPE_VECTOR = 'f62377afc10d67355a479fe927045e904ccdf17887ea2eaf306157a921275b75'; + + const DOCUSIGN = { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' }; + const STRIPE = { + signs: 'timestamp.body', header: 'Stripe-Signature', algorithm: 'hmac-sha256', encoding: 'hex', + timestampElement: 't', digestElement: 'v1', + }; + const GITHUB = { signs: 'body', header: 'X-Hub-Signature-256', algorithm: 'hmac-sha256', encoding: 'hex', prefix: 'sha256=' }; + + const b64 = (key: string, material: string) => createHmac('sha256', key).update(material, 'utf8').digest('base64'); + const hex = (key: string, material: string) => createHmac('sha256', key).update(material, 'utf8').digest('hex'); + const stripeSig = (key: string, body: string, t: number) => `t=${t},v1=${hex(key, `${t}.${body}`)}`; + + /** Arms the declared flows on a booted trigger; returns how often each key was read. */ + function armDeclared(trigger: ApiTrigger) { + const reads = { docusign: 0, stripe: 0, github: 0, unusable: 0, beside: 0 }; + const reader = (name: keyof typeof reads, key: string) => async () => { reads[name] += 1; return key; }; + trigger.start({ flowName: 'docusign', config: { hookId: 'hk_ds', secretRef: 'DOCUSIGN_CONNECT_KEY', signature: DOCUSIGN }, resolveSecret: reader('docusign', DOCUSIGN_KEY) }, async () => {}); + trigger.start({ flowName: 'stripe', config: { secretRef: 'PAYMENTS_WEBHOOK_KEY', signature: STRIPE }, resolveSecret: reader('stripe', STRIPE_KEY) }, async () => {}); + trigger.start({ flowName: 'github', config: { secretRef: 'GITHUB_HOOK_KEY', signature: GITHUB }, resolveSecret: reader('github', GITHUB_KEY) }, async () => {}); + // Unusable: an algorithm the spec refuses; the reader holds a key that would verify anything signed with it. + trigger.start({ flowName: 'unusable', config: { secretRef: 'SOME_KEY', signature: { ...DOCUSIGN, algorithm: 'hmac-sha1' } }, resolveSecret: reader('unusable', DOCUSIGN_KEY) }, async () => {}); + // Unusable: a well-formed declaration beside a literal secret (an inline key, and a second source). + trigger.start({ flowName: 'beside_secret', config: { secret: DOCUSIGN_KEY, signature: DOCUSIGN } }, async () => {}); + return reads; + } + + interface DeclaredRow { + label: string; + flowName: string; + hookId?: string; + body: string | Uint8Array; + headers: Array<[string, string]>; + /** `null`: admitted (`202`); otherwise the cause the log must record. */ + cause: string | null; + } + + const ROWS_30009: DeclaredRow[] = [ + // DocuSign Connect: base64 HMAC-SHA256 of the raw body. + { label: 'DocuSign: the real base64 vector', flowName: 'docusign', hookId: 'hk_ds', body: DOCUSIGN_BODY, headers: [['X-DocuSign-Signature-1', DOCUSIGN_VECTOR]], cause: null }, + { label: 'DocuSign: the header name in another case', flowName: 'docusign', hookId: 'hk_ds', body: DOCUSIGN_BODY, headers: [['x-docusign-signature-1', DOCUSIGN_VECTOR]], cause: null }, + { label: 'DocuSign: the vector over another body', flowName: 'docusign', hookId: 'hk_ds', body: DOCUSIGN_BODY.replace('completed', 'voided'), headers: [['X-DocuSign-Signature-1', DOCUSIGN_VECTOR]], cause: 'declared-mismatch' }, + { label: 'DocuSign: signed under another key', flowName: 'docusign', hookId: 'hk_ds', body: DOCUSIGN_BODY, headers: [['X-DocuSign-Signature-1', b64('not-the-key', DOCUSIGN_BODY)]], cause: 'declared-mismatch' }, + { label: 'DocuSign: the right digest written in hex, not the declared base64', flowName: 'docusign', hookId: 'hk_ds', body: DOCUSIGN_BODY, headers: [['X-DocuSign-Signature-1', hex(DOCUSIGN_KEY, DOCUSIGN_BODY)]], cause: 'declared-header-malformed' }, + { label: 'DocuSign: the right digest with its padding dropped', flowName: 'docusign', hookId: 'hk_ds', body: DOCUSIGN_BODY, headers: [['X-DocuSign-Signature-1', DOCUSIGN_VECTOR.slice(0, -1)]], cause: 'declared-header-malformed' }, + { label: 'DocuSign: the body-only header sent twice (one joined value)', flowName: 'docusign', hookId: 'hk_ds', body: DOCUSIGN_BODY, headers: [['X-DocuSign-Signature-1', DOCUSIGN_VECTOR], ['X-DocuSign-Signature-1', DOCUSIGN_VECTOR]], cause: 'declared-header-malformed' }, + { label: 'DocuSign: no declared header, a VALID x-objectstack-signature under the same key', flowName: 'docusign', hookId: 'hk_ds', body: DOCUSIGN_BODY, headers: [['x-objectstack-signature', sigV1(DOCUSIGN_KEY, DOCUSIGN_BODY, NOW)]], cause: 'declared-header-absent' }, + { label: 'DocuSign: no header at all', flowName: 'docusign', hookId: 'hk_ds', body: DOCUSIGN_BODY, headers: [], cause: 'declared-header-absent' }, + { label: 'DocuSign: the vector under the wrong hook id', flowName: 'docusign', hookId: 'default', body: DOCUSIGN_BODY, headers: [['X-DocuSign-Signature-1', DOCUSIGN_VECTOR]], cause: 'wrong-hook-id' }, + { + label: 'DocuSign: a body that is not UTF-8, signed over its own bytes (re-encoded on read: fails closed)', + flowName: 'docusign', hookId: 'hk_ds', body: new Uint8Array([0x7b, 0x22, 0x78, 0x22, 0x3a, 0xff, 0x7d]), + headers: [['X-DocuSign-Signature-1', createHmac('sha256', DOCUSIGN_KEY).update(new Uint8Array([0x7b, 0x22, 0x78, 0x22, 0x3a, 0xff, 0x7d])).digest('base64')]], + cause: 'declared-mismatch', + }, + // Stripe-style: `t=…,v1=…`, hex HMAC-SHA256 of `.`, core's window. + { label: 'Stripe-style: the real hex vector', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `t=${NOW},v1=${STRIPE_VECTOR}`]], cause: null }, + { label: 'Stripe-style: a key roll, the new key\'s v1 second', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `t=${NOW},v1=${hex(STRIPE_OLD_KEY, `${NOW}.${STRIPE_BODY}`)},v1=${STRIPE_VECTOR}`]], cause: null }, + { label: 'Stripe-style: a key roll, the new key\'s v1 first, an unknown v0 element ignored', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `t=${NOW},v1=${STRIPE_VECTOR},v1=${hex(STRIPE_OLD_KEY, `${NOW}.${STRIPE_BODY}`)},v0=deadbeef`]], cause: null }, + { label: 'Stripe-style: two v1 elements, neither under the key', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `t=${NOW},v1=${hex(STRIPE_OLD_KEY, `${NOW}.${STRIPE_BODY}`)},v1=${hex('other', `${NOW}.${STRIPE_BODY}`)}`]], cause: 'declared-mismatch' }, + { label: 'Stripe-style: correctly signed, t at the window\'s edge (300 s old)', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', stripeSig(STRIPE_KEY, STRIPE_BODY, NOW - 300)]], cause: null }, + { label: 'Stripe-style: correctly signed, t outside the window (301 s old)', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', stripeSig(STRIPE_KEY, STRIPE_BODY, NOW - 301)]], cause: 'declared-outside-window' }, + { label: 'Stripe-style: correctly signed, t outside the window (301 s ahead)', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', stripeSig(STRIPE_KEY, STRIPE_BODY, NOW + 301)]], cause: 'declared-outside-window' }, + { label: 'Stripe-style: the vector with t moved (the timestamp is signed)', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `t=${NOW + 1},v1=${STRIPE_VECTOR}`]], cause: 'declared-mismatch' }, + { label: 'Stripe-style: t repeated', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `t=${NOW},t=${NOW},v1=${STRIPE_VECTOR}`]], cause: 'declared-header-malformed' }, + { label: 'Stripe-style: no t', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `v1=${STRIPE_VECTOR}`]], cause: 'declared-header-malformed' }, + { label: 'Stripe-style: t with a leading zero', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `t=0${NOW},v1=${hex(STRIPE_KEY, `0${NOW}.${STRIPE_BODY}`)}`]], cause: 'declared-header-malformed' }, + { label: 'Stripe-style: no v1', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `t=${NOW},v0=${STRIPE_VECTOR}`]], cause: 'declared-no-digest' }, + { label: 'Stripe-style: the header sent twice (one joined value, t repeated)', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', `t=${NOW},v1=${STRIPE_VECTOR}`], ['Stripe-Signature', `t=${NOW},v1=${STRIPE_VECTOR}`]], cause: 'declared-header-malformed' }, + { label: 'Stripe-style: a VALID x-objectstack-signature only', flowName: 'stripe', body: STRIPE_BODY, headers: [['x-objectstack-signature', sigV1(STRIPE_KEY, STRIPE_BODY, NOW)]], cause: 'declared-header-absent' }, + { label: 'Stripe-style: the declared header AND a valid x-objectstack-signature, the declared one wrong', flowName: 'stripe', body: STRIPE_BODY, headers: [['Stripe-Signature', stripeSig('not-the-key', STRIPE_BODY, NOW)], ['x-objectstack-signature', sigV1(STRIPE_KEY, STRIPE_BODY, NOW)]], cause: 'declared-mismatch' }, + // GitHub-style: `sha256=` prefix, hex. + { label: 'GitHub-style: sha256= and the hex digest', flowName: 'github', body: STRIPE_BODY, headers: [['X-Hub-Signature-256', `sha256=${hex(GITHUB_KEY, STRIPE_BODY)}`]], cause: null }, + { label: 'GitHub-style: the hex digest without its declared prefix', flowName: 'github', body: STRIPE_BODY, headers: [['X-Hub-Signature-256', hex(GITHUB_KEY, STRIPE_BODY)]], cause: 'declared-prefix-missing' }, + // Unusable declarations: nothing admits, whatever is signed. + { label: 'unusable (an algorithm the spec refuses): a valid x-objectstack-signature under the reader\'s key', flowName: 'unusable', body: DOCUSIGN_BODY, headers: [['x-objectstack-signature', sigV1(DOCUSIGN_KEY, DOCUSIGN_BODY, NOW)]], cause: 'declared-unusable' }, + { label: 'unusable (an algorithm the spec refuses): the declared header, correctly signed', flowName: 'unusable', body: DOCUSIGN_BODY, headers: [['X-DocuSign-Signature-1', DOCUSIGN_VECTOR]], cause: 'declared-unusable' }, + { label: 'unusable (beside a literal secret): the declared header, correctly signed', flowName: 'beside_secret', body: DOCUSIGN_BODY, headers: [['X-DocuSign-Signature-1', DOCUSIGN_VECTOR]], cause: 'declared-unusable' }, + { label: 'unusable (beside a literal secret): a valid x-objectstack-signature under that secret', flowName: 'beside_secret', body: DOCUSIGN_BODY, headers: [['x-objectstack-signature', sigV1(DOCUSIGN_KEY, DOCUSIGN_BODY, NOW)]], cause: 'declared-unusable' }, + // The control every answer is compared with: a post that names no armed flow. + { label: 'CONTROL: an unknown flow', flowName: 'never_armed', body: DOCUSIGN_BODY, headers: [], cause: 'unknown-flow' }, + ]; + + const declaredRequest = (row: DeclaredRow): Request => { + const headers = new Headers([['Content-Type', 'application/json'], ...row.headers]); + return new Request(`${ORIGIN}/api/v1/automation/hooks/${row.flowName}/${row.hookId ?? 'default'}`, { + method: 'POST', headers, body: row.body, + } as RequestInit); + }; + + /** The cause each `refused an inbound post ()` line logged, in order. */ + const causesLogged = (plugin: ObservedApiTriggerPlugin) => + plugin.lines('warn', 'refused an inbound post').map((line) => /refused an inbound post \(([^)]+)\)/.exec(line)?.[1]); + + it('every row: the same status, headers and bytes through the member and the mount; admitted rows enqueue, refused rows log their own cause', async () => { + const { fixture, member, plugin, trigger } = await boot({ server: 'canonical' }); + armDeclared(trigger!); + for (const row of ROWS_30009) { + const before = fixture.published.length; + plugin.logs.length = 0; + const hook = { flowName: row.flowName, hookId: row.hookId ?? 'default' }; + const viaMember = await snapshot(await member(declaredRequest(row), hook)); + const viaMount = await snapshot(await fixture.canonicalApp.fetch(declaredRequest(row))); + expect(viaMember, `${row.label}: the two faces`).toEqual(viaMount); + if (row.cause === null) { + expect(viaMember.status, row.label).toBe(202); + expect(json(viaMember), row.label).toEqual({ accepted: true, messageId: MESSAGE_ID }); + expect(fixture.published.length - before, `${row.label}: enqueued once per face`).toBe(2); + expect(causesLogged(plugin), row.label).toEqual([]); + } else { + expect(viaMember.status, row.label).toBe(401); + expect(fixture.published.length - before, `${row.label}: enqueued`).toBe(0); + expect(causesLogged(plugin), `${row.label}: one cause per face`).toEqual([row.cause, row.cause]); + } + } + }); + + it('every refusal is the ONE answer: status, headers and bytes equal to an unknown flow\'s, and no line names the key, the header value or the body', async () => { + const { member, plugin, trigger } = await boot(); + armDeclared(trigger!); + const control = ROWS_30009.find((r) => r.cause === 'unknown-flow')!; + const one = await snapshot(await member(declaredRequest(control), { flowName: control.flowName, hookId: 'default' })); + expect(one.status).toBe(401); + expect(json(one)).toEqual({ success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } }); + const refused = ROWS_30009.filter((r) => r.cause !== null); + expect(new Set(refused.map((r) => r.cause)).size).toBe(9); + for (const row of refused) { + plugin.logs.length = 0; + const answer = await snapshot(await member(declaredRequest(row), { flowName: row.flowName, hookId: row.hookId ?? 'default' })); + expect(answer, row.label).toEqual(one); + const logged = JSON.stringify(plugin.logs); + for (const secret of [DOCUSIGN_KEY, STRIPE_KEY, STRIPE_OLD_KEY, GITHUB_KEY]) { + expect(logged.includes(secret), `${row.label}: a key`).toBe(false); + } + for (const [, value] of row.headers) expect(logged.includes(value), `${row.label}: a header value`).toBe(false); + if (typeof row.body === 'string') expect(logged.includes(row.body), `${row.label}: the body`).toBe(false); + } + }); + + it('an unusable declaration is refused before its key is read, so it is never the secret-unavailable 503; a usable one reads its key per post', async () => { + const { member, trigger } = await boot(); + const reads = armDeclared(trigger!); + for (const row of ROWS_30009.filter((r) => r.cause === 'declared-unusable')) { + const res = await member(declaredRequest(row), { flowName: row.flowName, hookId: 'default' }); + expect(res.status, row.label).toBe(401); + } + expect(reads.unusable).toBe(0); + const admitted = ROWS_30009.find((r) => r.flowName === 'docusign' && r.cause === null)!; + await member(declaredRequest(admitted), { flowName: 'docusign', hookId: 'hk_ds' }); + await member(declaredRequest(admitted), { flowName: 'docusign', hookId: 'hk_ds' }); + expect(reads.docusign).toBe(2); + }); + + it('a declared key that cannot be read answers the 503 every hook secret does, never a verdict', async () => { + const { member, trigger, fixture } = await boot(); + trigger!.start( + { flowName: 'docusign', config: { hookId: 'hk_ds', secretRef: 'DOCUSIGN_CONNECT_KEY', signature: DOCUSIGN }, resolveSecret: async () => { throw new Error('the name resolved to no value'); } }, + async () => {}, + ); + const res = await snapshot(await member(declaredRequest(ROWS_30009[0]!), { flowName: 'docusign', hookId: 'hk_ds' })); + expect(res.status).toBe(503); + expect(json(res)).toMatchObject({ success: false, error: { code: 'SERVICE_UNAVAILABLE' } }); + expect(fixture.published).toEqual([]); + }); + + it('a declared scheme arms only with the engine\'s reader for config.secretRef: a binding without one throws and leaves no hook', async () => { + const { member, trigger } = await boot(); + expect(() => trigger!.start( + { flowName: 'docusign', config: { hookId: 'hk_ds', secretRef: 'DOCUSIGN_CONNECT_KEY', signature: DOCUSIGN } }, + async () => {}, + )).toThrow(/config\.signature[\s\S]*config\.secretRef/); + expect(trigger!.listHooks().map((h) => h.flowName)).not.toContain('docusign'); + const res = await member(declaredRequest(ROWS_30009[0]!), { flowName: 'docusign', hookId: 'hk_ds' }); + expect(res.status).toBe(401); + }); + + it('CONTROL: a hook that declares no scheme still verifies x-objectstack-signature, and ignores a vendor header', async () => { + const { member, trigger } = await boot(); + armDeclared(trigger!); + const accepted = await member(hookRequest(FLOW, HOOK_ID, BODY, sig(BODY)), { flowName: FLOW, hookId: HOOK_ID }); + expect(accepted.status).toBe(202); + const vendorOnly = await member( + hookRequest(FLOW, HOOK_ID, BODY, { 'X-DocuSign-Signature-1': b64(SECRET, BODY) }), + { flowName: FLOW, hookId: HOOK_ID }, + ); + expect(vendorOnly.status).toBe(401); + }); +}); From 0b82ff3a183599c7bdc2269c2765f702747c69a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 09:53:49 +0000 Subject: [PATCH 3/4] docs(automation, skills): teach config.signature where a vendor-signed callback was said to be refused; bind the signs union to flows.mdx Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12 --- .../30009-api-trigger-declared-signature.md | 14 +++++ content/docs/automation/flows.mdx | 51 +++++++++++++++++++ packages/spec/variant-docs.json | 6 ++- skills/objectstack-api/SKILL.md | 14 ++--- skills/objectstack-automation/SKILL.md | 6 +-- .../references/integration-recipes.md | 6 +-- 6 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 .changeset/30009-api-trigger-declared-signature.md diff --git a/.changeset/30009-api-trigger-declared-signature.md b/.changeset/30009-api-trigger-declared-signature.md new file mode 100644 index 00000000000..8caad3b0f1a --- /dev/null +++ b/.changeset/30009-api-trigger-declared-signature.md @@ -0,0 +1,14 @@ +--- +'@objectstack/trigger-api': patch +'@objectstack/lint': patch +'@objectstack/skills': patch +--- + +feat(trigger-api): an `api` hook verifies the vendor signature its start node declares (`config.signature`) + +Clause-②: yes (widening) +Metadata-protocol: none + +- **`@objectstack/trigger-api`.** `start()` reads the hook's one verifier with `apiTriggerVerifierOf(binding.config)` (`@objectstack/spec/automation`). A start node that declares its sender's scheme (`config.signature`: DocuSign Connect's base64 `X-DocuSign-Signature-1`, a Stripe-style `t=…,v1=…`, a GitHub-style `sha256=`) now admits a correctly signed post that was answered `401` before. That scheme REPLACES the `x-objectstack-signature` check for its hook: the declared header is read on both faces of the door (the self-hosted mount and `handleInboundHook`) with `Headers.get`, so a repeated field is its one joined value; the HMAC-SHA256 is computed under the key `config.secretRef` names (read at every post through the engine's reader, `503` while it has no value), over the raw body or `.`; each digest is decoded strictly to 32 bytes and compared with `timingSafeEqual`, any one of several `v1` elements matching (a key roll); and a timestamped scheme is held to `HTTP_SIGNATURE_TOLERANCE_SECONDS` from `@objectstack/core`. A body-only scheme has no replay window, as the contract states. A valid `x-objectstack-signature` admits nothing on such a hook, and a post without the declared header is refused. A declaration the spec answers `unusable` arms a hook that refuses every post before any secret is read and never falls back to `x-objectstack-signature`; a declared scheme bound without the engine's `config.secretRef` reader is refused at `start()`. Every failure is the same `401 INVALID_SIGNATURE` with its own cause in the server log (`declared-header-absent`, `declared-header-malformed`, `declared-prefix-missing`, `declared-no-digest`, `declared-mismatch`, `declared-outside-window`, `declared-unusable`), never the key, the header value or the body. A hook that declares no scheme is verified exactly as before. +- **`@objectstack/lint`.** `validateFlowApiTriggerSecret` grades an `api` hook by that same reader: a usable declaration is ready on its `config.secretRef` alone, and an unusable one is the new error `flow-api-trigger-signature-unusable` (with its `os explain` entry), located at the key the spec refuses. It fires only on a declaration the flow parse already refuses, so no stack that passed before is refused now. +- **`@objectstack/skills`.** The automation and api skills teach `config.signature` where they said a vendor-signed callback cannot be admitted. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 95862d871a2..70fb9173dc3 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -575,6 +575,57 @@ would send. A flow definition, including an `http` node's `url` and `headers`, is served to every member who can read flows. A token, API key or signed webhook url written there is readable by all of them. Route an outbound credential by where it sits, and call the connector with a `connector_action` node instead — see [Connectors](/docs/automation/connectors#authentication). A credential in a header goes to a declarative connector with `bearer`, `basic` or `api-key` auth, whose `auth.credentialRef` names the secret. A key in the query string goes to `api-key` auth with `paramName`. No `credentialRef` variant carries a secret in the url path, so an incoming-webhook url (whose path is the secret) cannot be routed that way: call the service through a token-authenticated connector instead, such as the `slack` connector, whose bot token is supplied to the plugin by host code rather than written in the flow (it is registered by that plugin, not declared as a `connectors:` instance). Otherwise such a url is served with the definition. Only `signingSecret` and a start node's `secret` are kept out of a flow saved through the metadata API: the metadata save door stores each in a write-only, encrypted flow credential store, a read never returns it, and the engine reads it only to verify an inbound post or sign an outbound request. A flow that ships in a package names its inbound hook secret instead of carrying it: the start node's `config.secretRef` holds the name the deployment fills (an environment variable of that name in the open tier, resolved through the same credential resolver as a connector's `auth.credentialRef`), and the engine reads it at every post, so no credential is published with the package. A post that arrives while the name has no value is answered `503`, never verified. The reference is a name, not a credential: it is served as written, it may not sit beside a literal `secret`, and it may not name a variable in the platform's `OS_` namespace. A packaged flow that carries a literal keeps it in its package source; the literal is withheld when the definition is served, and a credential store row for that flow, where one exists, wins when the engine verifies or signs. `url` and `headers` are stored and served as written. +#### An inbound hook that verifies its sender's own signature — `config.signature` + +By default an `api` flow's hook admits a post only by ObjectStack's own +`x-objectstack-signature`. A vendor that signs its callbacks its own way +(DocuSign Connect's `X-DocuSign-Signature-1`, a Stripe-style `t=…,v1=…` +header, a GitHub-style `sha256=` header) is admitted by declaring how it signs +on the start node, as `config.signature`, beside the `config.secretRef` that +names the key: + +```ts +export const EnvelopeStatus = defineFlow({ + name: 'envelope_status', label: 'Envelope status', type: 'api', runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'DocuSign Connect', config: { + hookId: 'docusign', + secretRef: 'DOCUSIGN_CONNECT_KEY', + signature: { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' }, + } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); +``` + +`signs` says what the sender signs, and picks the form: + +| `signs` | The sender signs | The header carries | Replay window | +|:--|:--|:--|:--| +| `body` | the raw body alone (DocuSign Connect; GitHub-style with `prefix: 'sha256='`) | the one digest, after `prefix` when one is declared | none: the sender signs no time, so a captured post verifies again while the key is unchanged | +| `timestamp.body` | `.` (Stripe-style) | `NAME=value` elements: one `timestampElement` (`t`) and one or more `digestElement`s (`v1`, one per key while the sender rolls its key) | the same 300 seconds as `x-objectstack-signature` | + +`header` is matched without regard to case; a field the sender repeats is +read as its one value, the copies joined with `, `, so a body-only header sent +twice does not verify. `algorithm` is `hmac-sha256`, `encoding` is `hex` or +`base64`, and the digest must decode to the 32 bytes of the HMAC, compared in +constant time over the body exactly as received. The key is only ever the one +`config.secretRef` names, read at every post like any hook secret (`503` while +it has no value); a literal `secret` beside `signature` is refused. + +A declared scheme **replaces** the `x-objectstack-signature` check for its hook: +the hook has that one verifier, so a post that carries only a valid +`x-objectstack-signature` is refused, and so is one without the declared +header. Every refusal is the same `401 INVALID_SIGNATURE` an unsigned post +gets, with its cause in the server log only. A declaration the flow parse +refuses (an unknown algorithm, a reserved header such as `authorization` or +`x-objectstack-signature`, a timestamped scheme without its elements) never +falls back to the platform check: `os validate` reports it as +`flow-api-trigger-signature-unusable`, and a hook armed with it admits no post. +The declared keys are listed in +[api-trigger-signature](/docs/references/automation/api-trigger-signature). + **Script:** The built-in `script` executor never evaluates an arbitrary JavaScript string — diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json index 36f56560bfa..0de5e8c41a4 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -212,8 +212,10 @@ { "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." + "docs": [ + "content/docs/automation/flows.mdx" + ], + "note": "An api flow start node's `config.signature` (#29983; the trigger verifies it since #30009). automation/flows.mdx teaches both forms beside the `secretRef` paragraph, in its `signs` table." } ] } diff --git a/skills/objectstack-api/SKILL.md b/skills/objectstack-api/SKILL.md index e2a7b264d01..ee551eb0d46 100644 --- a/skills/objectstack-api/SKILL.md +++ b/skills/objectstack-api/SKILL.md @@ -129,16 +129,18 @@ policy layer over an existing pipeline, never a second execution dialect. | **`defineStack({ apis })`** | The endpoint is a *projection* of something the platform already executes: query/return records, or trigger a flow. No code, no deploy artifact, publish-gated. **Prefer this.** | | **A flow that runs your code** | The endpoint needs real handler CODE: point a `type: 'flow'` endpoint at a flow whose `script` node names a stack function (`defineStack({ functions })`), or whose `connector_action` node calls a declared connector (outbound). The URL stays declarative; the code runs where the automation surface already runs it. | -An inbound webhook from a sender you control is a `type: 'api'` flow -(objectstack-automation → **Inbound webhook (`api`) triggers**): it verifies -ObjectStack's own `x-objectstack-signature`, enqueues, and answers `202`. +An inbound webhook is a `type: 'api'` flow (objectstack-automation → +**Inbound webhook (`api`) triggers**): it verifies ObjectStack's own +`x-objectstack-signature`, or a vendor's own HMAC-SHA256 header its start node +declares as `config.signature` (DocuSign Connect, Stripe-style), enqueues, and +answers `202`. **An app never mounts on `http.server`** — that service is the platform plugins' own door-building primitive, and a code-mounted route gets no caller identity, auth gate, rate limit, namespace carve-out or OpenAPI entry. Three cases have no app surface today; say so rather than invent one: a third-party -callback verified by its own signature over the raw body (the hook checks only -`x-objectstack-signature`), a streaming response (a declared endpoint answers -one JSON envelope), and a protocol the platform does not speak. +callback signed in a form `config.signature` cannot declare (not HMAC-SHA256), +a streaming response (a declared endpoint answers one JSON envelope), and a +protocol the platform does not speak. ```typescript diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 0ee383ce4f1..b804decc7b6 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -355,7 +355,7 @@ node `config`**: | `hookId` | URL path token (default `'default'`). **Rotate it to revoke** a leaked endpoint | | `secretRef` / `secret` | HMAC-SHA256 secret, **one required** (else refused at registration and `os validate`). Ship `secretRef: 'NAME'`: an env var the deployment fills, not `OS_*`; unfilled ⇒ `503`. Never a literal | -- **Signature:** `x-objectstack-signature: t=,v1=` — `v1` = HMAC-SHA256 of `.` under the secret; refused when `t` is over 300 s from the server clock. +- **Signature:** `x-objectstack-signature: t=,v1=` — HMAC-SHA256 of `.`; `t` within 300 s. Vendor-signed: `config.signature` replaces it (recipe 4). - **Idempotency:** `x-idempotency-key` dedupes retries — author the flow to be idempotent (delivery is at-least-once). - **Queue-backed:** the endpoint ACKs `202` and enqueues; the flow runs on the consumer. - The JSON body surfaces to the flow as the trigger record (`record.*` / bare fields) plus `params`. @@ -414,8 +414,8 @@ credential hand-off, loader limits: [references/integration-recipes.md](./refere Platform code only when one holds — and then it is the platform's, filed as a card, the app still writing none: the protocol is not HTTP; the work writes identity objects (users, units, positions); it processes files locally; one fixed -action contract spans several vendors; the callback's signature is outside the -`api` door's HMAC. A document the loader cannot take yet (YAML, Swagger 2.0, +action contract spans several vendors; the callback is signed in no form +`config.signature` declares. A document the loader cannot take yet (YAML, Swagger 2.0, `$ref`) is a gap named against its platform card, never a reason to write code. diff --git a/skills/objectstack-automation/references/integration-recipes.md b/skills/objectstack-automation/references/integration-recipes.md index a95df3173b0..c7b9098c5ae 100644 --- a/skills/objectstack-automation/references/integration-recipes.md +++ b/skills/objectstack-automation/references/integration-recipes.md @@ -130,9 +130,9 @@ export const CarrierCallback = defineFlow({ ``` `requires: ['automation', 'triggers', 'queue']`. The sender signs each post -under `CARRIER_HOOK_SECRET` (SKILL.md *Signature* bullet). The door verifies no -other scheme: a vendor whose webhook signs its own header cannot post here -today — poll its status (recipe 1's shape) until vendor schemes verify. +under `CARRIER_HOOK_SECRET` (SKILL.md *Signature* bullet). A vendor signing +its own header adds, keyed by that ref, `signature: { signs: 'body', header: +'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' }`. ## 5 · Vendor with no machine-readable spec — write the document by hand From 763d7358923c5dcb761feefbfd67733239558bb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 10:24:04 +0000 Subject: [PATCH 4/4] test(trigger-api): resolve @objectstack/spec/automation to source in the package's tests Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzeeFznzXxc61VmVH12 --- packages/triggers/trigger-api/vitest.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/triggers/trigger-api/vitest.config.ts b/packages/triggers/trigger-api/vitest.config.ts index 5ba660de5ba..ce691775c25 100644 --- a/packages/triggers/trigger-api/vitest.config.ts +++ b/packages/triggers/trigger-api/vitest.config.ts @@ -36,6 +36,14 @@ export default defineConfig({ find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts'), }, + // `start()` reads each hook's one verifier with @objectstack/spec's + // `apiTriggerVerifierOf`, the reader the engine and the lint share, and + // the declared-signature pins judge what it answers. The same reason: + // without this entry those pins read spec's `dist/`, not its source. + { + find: /^@objectstack\/spec\/automation$/, + replacement: path.resolve(__dirname, '../../spec/src/automation/index.ts'), + }, ], }, });