From d5c202db78f92bdb2a7a1e4f3e0655ea3367f9a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:37:48 +0000 Subject: [PATCH 1/2] fix(trigger-api,service-automation): refuse an api flow with no per-flow secret at arm time and at registration ADR-0041's trigger-api acceptance criteria name a per-flow secret and HMAC verification. ApiTrigger.start() now throws, naming the flow and config.secret, before it stores a hook or subscribes a consumer; the armed hook's secret is required by its type, so handleRequest verifies every post. AutomationEngine.registerFlow refuses the same binding at the publish seam, reading the binding the engine would hand the trigger. Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude --- .../20529-api-trigger-requires-secret.md | 45 ++++++ .../api-trigger-secret-registration.test.ts | 135 ++++++++++++++++++ .../service-automation/src/engine.test.ts | 4 +- .../services/service-automation/src/engine.ts | 59 ++++++++ .../flow-trigger-kind-shared-resolver.test.ts | 5 +- .../trigger-api/src/api-trigger.test.ts | 65 ++++++--- .../triggers/trigger-api/src/api-trigger.ts | 45 ++++-- .../src/trigger-api-route-ledger.ts | 4 +- 8 files changed, 324 insertions(+), 38 deletions(-) create mode 100644 .changeset/20529-api-trigger-requires-secret.md create mode 100644 packages/services/service-automation/src/api-trigger-secret-registration.test.ts diff --git a/.changeset/20529-api-trigger-requires-secret.md b/.changeset/20529-api-trigger-requires-secret.md new file mode 100644 index 00000000000..98ee07818fd --- /dev/null +++ b/.changeset/20529-api-trigger-requires-secret.md @@ -0,0 +1,45 @@ +--- +'@objectstack/trigger-api': minor +'@objectstack/service-automation': minor +--- + +fix(trigger-api,service-automation): an `api` flow with no per-flow secret is refused, at arm time and at registration (#20529) + +Clause-②: no (narrowing) + +**BREAKING** — shipped as `minor` under the launch-window convention +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by +this banner and the ADR-0087 disposition below, never by the level). + +ADR-0041's `trigger-api` acceptance criteria name a per-flow secret and HMAC +signature verification. The trigger used to arm a flow's inbound hook without a +secret, with only a warning, and that hook skipped signature verification. An +`api` flow whose start node carries no non-blank `config.secret` is now refused +in two places: + +- **At registration** (`@objectstack/service-automation`). `registerFlow` refuses + a flow whose binding resolves to the `api` trigger (`type: 'api'`, or a start + node with `triggerType: 'api'`) when the start node declares no non-blank + `config.secret`, whatever the flow's `status`. The error names the flow and + `config.secret`. The `/automation` create, update and clone doors answer it as + `400 VALIDATION_FAILED`, like every other registration refusal. At boot the + flow is skipped and the existing `[Automation] failed to register flow` warning + names it. +- **At arm time** (`@objectstack/trigger-api`). `ApiTrigger.start()` throws, + naming the flow and `config.secret`, before it stores a hook or subscribes a + queue consumer. The engine logs `Failed to bind flow` and the flow stays + unbound. This covers a host that binds the trigger without the engine. The + arm-time `armed WITHOUT a secret` warning is gone, since that state no longer + exists. Every armed hook verifies the signature on every post. + +**Fix.** Give the flow's start node a non-blank `config.secret` and sign each +post with it, as the `x-objectstack-signature` header already documents. A flow +that is only ever started explicitly (`engine.execute()`, or the `/automation` +trigger route) and is not meant to receive inbound posts is an `autolaunched` +flow. Declare it `type: 'autolaunched'`, with no `triggerType: 'api'` on its +start node, and it needs no secret. + +Unchanged: a flow that already carries a secret registers, arms and verifies +exactly as before. + + diff --git a/packages/services/service-automation/src/api-trigger-secret-registration.test.ts b/packages/services/service-automation/src/api-trigger-secret-registration.test.ts new file mode 100644 index 00000000000..d6bc62424ae --- /dev/null +++ b/packages/services/service-automation/src/api-trigger-secret-registration.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0041 — `trigger-api`'s acceptance criteria name "a per-flow secret; HMAC + * signature verification". A flow whose binding resolves to the `api` trigger + * and whose start node carries no non-blank `config.secret` is refused at + * REGISTRATION — the publish seam — so its author learns before deploying. + * (`@objectstack/trigger-api`'s own `start()` refuses the same binding for a + * host that binds without this engine; its tests pin that half.) + * + * Every refusal case asserts the substance, not only the throw: the flow is + * absent from the engine afterwards and the api trigger was never started. The + * contrast cases pin what stays legal — a signed `api` flow registers and its + * trigger receives that secret, and a flow that binds no `api` trigger needs + * none. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine } from './engine.js'; +import type { FlowTrigger, FlowTriggerBinding } from './engine.js'; + +function createTestLogger() { + return { debug() {}, info() {}, warn() {}, error() {} } as any; +} + +/** A minimal registrable flow whose start node carries `config`. */ +function flowWith( + name: string, + config: Record, + type: string = 'api', + extra: Record = {}, +) { + return { + name, + label: name, + type, + status: 'active', + ...extra, + nodes: [ + { id: 'start', type: 'start', label: 'Start', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; +} + +describe('ADR-0041 — an api flow registers only with its per-flow secret', () => { + let engine: AutomationEngine; + let started: FlowTriggerBinding[]; + let stopped: string[]; + + beforeEach(() => { + engine = new AutomationEngine(createTestLogger()); + started = []; + stopped = []; + // A recording `api` trigger, registered BEFORE the flows, so a flow that + // got past registration would be started on it at once. + const trigger: FlowTrigger = { + type: 'api', + start: (binding) => { + started.push(binding); + }, + stop: (flowName) => { + stopped.push(flowName); + }, + }; + engine.registerTrigger(trigger); + }); + + const refused: Array<{ label: string; flow: ReturnType }> = [ + { label: "a `type: 'api'` flow with no secret", flow: flowWith('no_secret', {}) }, + { label: "a `type: 'api'` flow with a blank secret", flow: flowWith('blank_secret', { secret: ' ' }) }, + { label: "a `type: 'api'` flow with a non-string secret", flow: flowWith('numeric_secret', { secret: 42 }) }, + { + label: "a start-node `triggerType: 'api'` flow with no secret", + flow: flowWith('token_no_secret', { triggerType: 'api', hookId: 'intake' }, 'autolaunched'), + }, + { + // Status-agnostic, like every other registration refusal: an + // `obsolete` flow is re-enabled by a toggle, not by re-registering. + label: "an obsolete `type: 'api'` flow with no secret", + flow: flowWith('obsolete_no_secret', {}, 'api', { status: 'obsolete' }), + }, + ]; + + for (const row of refused) { + it(`refuses ${row.label} at registration, naming the flow and config.secret, and arms nothing`, async () => { + const name = row.flow.name; + expect(() => engine.registerFlow(name, row.flow as never)).toThrow( + new RegExp(`Flow '${name}' rejected: .*\`api\` trigger.*config\\.secret`, 's'), + ); + + // Not registered: nothing to read back, nothing to run, nothing armed. + expect(await engine.getFlow(name)).toBeNull(); + expect(engine.getFlowRuntimeStates().map((s) => s.name)).not.toContain(name); + expect(started).toEqual([]); + }); + } + + it('registers and binds an api flow that carries its secret, handing the trigger that secret', async () => { + engine.registerFlow('signed_hook', flowWith('signed_hook', { hookId: 'intake', secret: 's3cret' }) as never); + + expect(await engine.getFlow('signed_hook')).not.toBeNull(); + expect(started).toHaveLength(1); + expect(started[0].flowName).toBe('signed_hook'); + expect(started[0].config).toMatchObject({ hookId: 'intake', secret: 's3cret' }); + const state = engine.getFlowRuntimeStates().find((s) => s.name === 'signed_hook'); + expect(state?.triggerType).toBe('api'); + }); + + it('requires nothing of a flow that binds no api trigger — an autolaunched flow registers without a secret', async () => { + engine.registerFlow('manual', flowWith('manual', {}, 'autolaunched') as never); + + expect(await engine.getFlow('manual')).not.toBeNull(); + expect(started).toEqual([]); + expect((await engine.execute('manual')).success).toBe(true); + }); + + it('refuses a re-registration that drops the secret, and the registered signed version stays armed', async () => { + engine.registerFlow('signed_hook', flowWith('signed_hook', { secret: 's3cret' }) as never); + expect(started).toHaveLength(1); + + expect(() => engine.registerFlow('signed_hook', flowWith('signed_hook', {}) as never)).toThrow( + /Flow 'signed_hook' rejected: .*config\.secret/s, + ); + + // The refused definition never replaced the stored one, and the trigger + // was neither stopped nor re-started with the unsigned binding. + const stored = await engine.getFlow('signed_hook'); + const start = stored?.nodes.find((n) => n.type === 'start'); + expect((start?.config as Record | undefined)?.secret).toBe('s3cret'); + expect(started).toHaveLength(1); + expect(stopped).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 613911d9034..c272aa4f4f4 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -1306,7 +1306,9 @@ describe('AutomationEngine - Execution History', () => { const simpleFlow = { name: 'test_flow', label: 'Test Flow', - type: 'api' as const, + // Started explicitly (`engine.execute`), never by an inbound post — + // an `api` flow is an inbound hook and needs a `config.secret` (ADR-0041). + type: 'autolaunched' as const, nodes: [ { id: 'start', type: 'start' as const, label: 'Start' }, { id: 'end', type: 'end' as const, label: 'End' }, diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index daac6de0730..679f73ddd8f 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -3433,6 +3433,19 @@ export class AutomationEngine implements IAutomationService { ): { triggerType: string; binding: FlowTriggerBinding } | undefined { const flow = this.flows.get(flowName); if (!flow) return undefined; + return this.deriveTriggerBinding(flowName, flow); + } + + /** + * {@link resolveTriggerBinding}'s body, over a flow that need not be + * registered yet — so {@link validateApiTriggerSecret} judges, at + * registration, the very binding {@link activateFlowTrigger} would hand the + * trigger, rather than a second reading of the start node. + */ + private deriveTriggerBinding( + flowName: string, + flow: FlowParsed, + ): { triggerType: string; binding: FlowTriggerBinding } | undefined { const startNode = flow.nodes.find(n => n.type === 'start'); const config = (startNode?.config ?? {}) as Record; const condition = (config.condition as FlowTriggerBinding['condition']) ?? undefined; @@ -4191,6 +4204,12 @@ export class AutomationEngine implements IAutomationService { // safe to run. this.validateFlowExpressions(name, parsed); + // ADR-0041 — an `api` flow's inbound hook requires a per-flow secret. + // Refused here, at the publish seam, so the author learns before + // deploying; `trigger-api`'s own `start()` refuses the same binding for + // a host that binds without this engine. + this.validateApiTriggerSecret(name, parsed); + // Version history management const history = this.flowVersionHistory.get(name) ?? []; history.push({ @@ -9349,6 +9368,46 @@ export class AutomationEngine implements IAutomationService { } } + /** + * ADR-0041 — the registration-time half of `trigger-api`'s acceptance + * criteria: "a per-flow secret; HMAC signature verification". A flow whose + * binding resolves to the `api` trigger (the kind {@link + * deriveTriggerBinding} answers — `type: 'api'` or a start-node + * `triggerType: 'api'`) and whose start node carries no non-blank + * `config.secret` is refused, whatever its `status`: such a flow can never + * be armed, and an author who wrote it should hear so at publish time, + * not from a boot audit. + * + * It reads the BINDING's `config` — the same object `trigger-api`'s + * `start()` reads the secret from — so the two refusals judge one input + * and cannot disagree about which flows need a secret. The rule is kept in + * both places deliberately: `@objectstack/trigger-api` does not depend on + * this package (nor this package on it), and the trigger's own refusal is + * what protects a host that binds without this engine. + * + * Hard-fail, like {@link validateNodeConfigKeys}: every `registerFlow` call + * site already try/catches per flow, so a refused flow is skipped loudly + * at boot, and the `/automation` write doors answer the throw as `400 + * VALIDATION_FAILED`. + */ + private validateApiTriggerSecret(flowName: string, flow: FlowParsed): void { + const resolved = this.deriveTriggerBinding(flowName, flow); + if (resolved?.triggerType !== 'api') return; + const config = (resolved.binding.config ?? {}) as Record; + if (typeof config.secret === 'string' && config.secret.trim() !== '') return; + const asks = [ + flow.type === 'api' ? "`type: 'api'`" : undefined, + config.triggerType === 'api' ? "start-node `config.triggerType: 'api'`" : undefined, + ].filter((s): s is string => s !== undefined); + throw new Error( + `Flow '${flowName}' rejected: it binds the inbound \`api\` trigger (${asks.join(' and ')}) but its ` + + `start node declares no \`config.secret\`. An inbound hook is armed only with a per-flow secret that ` + + `every post is HMAC-verified against (ADR-0041), so this flow could never be armed. Set a non-blank ` + + `\`config.secret\` on the start node. A flow that is only ever started explicitly and never receives ` + + `inbound posts is \`type: 'autolaunched'\`, with no \`triggerType: 'api'\` on its start node.`, + ); + } + /** * Walk `value` against `schema` in lockstep, collecting keys the schema does * not declare into `violations`. diff --git a/packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts b/packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts index ee3a03e4b81..e7d868ebbe6 100644 --- a/packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts +++ b/packages/services/service-automation/src/flow-trigger-kind-shared-resolver.test.ts @@ -115,12 +115,13 @@ describe('[#14328] the engine takes its trigger kind from spec.resolveFlowTrigge }, { case: "type: 'api'", - flow: flowWith('api_type', {}, 'api'), + // An `api` flow registers only with its per-flow secret (ADR-0041). + flow: flowWith('api_type', { secret: 'hook-secret' }, 'api'), expected: 'api', }, { case: "triggerType: 'api'", - flow: flowWith('api_token', { triggerType: 'api' }), + flow: flowWith('api_token', { triggerType: 'api', secret: 'hook-secret' }), expected: 'api', }, ]; diff --git a/packages/triggers/trigger-api/src/api-trigger.test.ts b/packages/triggers/trigger-api/src/api-trigger.test.ts index e69a5a38fcf..aa2fa3a5dad 100644 --- a/packages/triggers/trigger-api/src/api-trigger.test.ts +++ b/packages/triggers/trigger-api/src/api-trigger.test.ts @@ -10,14 +10,19 @@ function makeFakeQueue() { const subs = new Map Promise | void>(); const pending = new Map(); let n = 0; - const q: QueueServiceSurface & { deliver(): Promise; published: Array<{ queue: string; data: any; idempotencyKey?: string }> } = { + const q: QueueServiceSurface & { + deliver(): Promise; + published: Array<{ queue: string; data: any; idempotencyKey?: string }>; + subscribed: string[]; + } = { published: [], + subscribed: [], async publish(queue, data, options) { this.published.push({ queue, data, idempotencyKey: options?.idempotencyKey }); (pending.get(queue) ?? pending.set(queue, []).get(queue)!).push(data); return `msg_${++n}`; }, - async subscribe(queue, handler) { subs.set(queue, handler as any); }, + async subscribe(queue, handler) { this.subscribed.push(queue); subs.set(queue, handler as any); }, async unsubscribe(queue) { subs.delete(queue); }, async deliver() { let delivered = 0; @@ -78,14 +83,14 @@ describe('ApiTrigger', () => { }); it('answers 404 identically for unknown flows and wrong hookIds (no probing oracle)', async () => { - arm({ hookId: 'hk1' }); + arm({ hookId: 'hk1', secret: 's3cret' }); const a = await trigger.handleRequest({ flowName: 'nope', hookId: 'hk1', rawBody: '{}' }); const b = await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'wrong', rawBody: '{}' }); expect(a).toEqual(b); expect(a.status).toBe(404); }); - it('401s a missing or bad signature when the flow declares a secret', async () => { + it('401s a missing or bad signature', async () => { arm({ hookId: 'hk1', secret: 's3cret' }); const body = '{"x":1}'; expect((await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'hk1', rawBody: body })).status).toBe(401); @@ -94,36 +99,60 @@ describe('ApiTrigger', () => { })).status).toBe(401); }); - it('accepts unsigned posts when no secret is configured (and warned at arm time)', async () => { - arm({}); - const res = await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'default', rawBody: '{"x":1}' }); - expect(res.status).toBe(202); - expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('WITHOUT a secret')); - }); + // ADR-0041: a per-flow secret is required. A binding without a usable one + // is refused at arm time — nothing is stored, nothing is subscribed, and + // the flow has no hook for any post to reach. + for (const [label, config] of [ + ['no secret at all', {}], + ['a blank secret', { secret: ' ' }], + ['a non-string secret', { secret: 42 }], + ] as const) { + it(`refuses to arm a flow with ${label}: start() throws naming the flow, and no hook exists`, async () => { + expect(() => arm({ ...config })).toThrow(/'lead_intake'.*config\.secret/); + + // Nothing armed, nothing subscribed, nothing logged as armed. + expect(trigger.listHooks()).toEqual([]); + expect(queue.subscribed).toEqual([]); + expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('armed:')); + + // A post to the flow finds no hook: the same 404 an unknown flow + // gets, and nothing reaches the queue or the flow. + const res = await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'default', rawBody: '{"x":1}' }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'No such hook.' } }); + expect(queue.published).toEqual([]); + expect(await queue.deliver()).toBe(0); + expect(runs).toEqual([]); + }); + } it('400s non-object or invalid JSON bodies', async () => { - arm({}); - expect((await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'default', rawBody: 'not json' })).status).toBe(400); - expect((await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'default', rawBody: '[1,2]' })).status).toBe(400); + arm({ secret: 's3cret' }); + expect((await trigger.handleRequest({ + flowName: 'lead_intake', hookId: 'default', rawBody: 'not json', signatureHeader: sig('s3cret', 'not json'), + })).status).toBe(400); + expect((await trigger.handleRequest({ + flowName: 'lead_intake', hookId: 'default', rawBody: '[1,2]', signatureHeader: sig('s3cret', '[1,2]'), + })).status).toBe(400); }); it('passes x-idempotency-key through to the queue dedup window', async () => { - arm({}); + arm({ secret: 's3cret' }); await trigger.handleRequest({ - flowName: 'lead_intake', hookId: 'default', rawBody: '{}', idempotencyKey: 'evt_42', + flowName: 'lead_intake', hookId: 'default', rawBody: '{}', signatureHeader: sig('s3cret', '{}'), idempotencyKey: 'evt_42', }); expect(queue.published[0].idempotencyKey).toBe('evt_42'); }); it('503s when no queue service is registered', async () => { const t = new ApiTrigger(() => null, logger as any); - t.start({ flowName: 'f', config: {} }, async () => {}); - const res = await t.handleRequest({ flowName: 'f', hookId: 'default', rawBody: '{}' }); + t.start({ flowName: 'f', config: { secret: 's3cret' } }, async () => {}); + const res = await t.handleRequest({ flowName: 'f', hookId: 'default', rawBody: '{}', signatureHeader: sig('s3cret', '{}') }); expect(res.status).toBe(503); }); it('stop() disarms the hook and unsubscribes the queue', async () => { - arm({ hookId: 'hk1' }); + arm({ hookId: 'hk1', secret: 's3cret' }); trigger.stop('lead_intake'); const res = await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'hk1', rawBody: '{}' }); expect(res.status).toBe(404); diff --git a/packages/triggers/trigger-api/src/api-trigger.ts b/packages/triggers/trigger-api/src/api-trigger.ts index c7dd6046b67..7e60a8ab5c6 100644 --- a/packages/triggers/trigger-api/src/api-trigger.ts +++ b/packages/triggers/trigger-api/src/api-trigger.ts @@ -47,11 +47,17 @@ export interface TriggerLogger { const QUEUE_PREFIX = 'flow-api'; -/** One armed inbound hook. */ +/** + * One armed inbound hook. `secret` is required by the type, 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. + */ interface ArmedHook { flowName: string; hookId: string; - secret?: string; + secret: string; queue: string; callback: (ctx: AutomationContext) => Promise; } @@ -79,14 +85,14 @@ export function verifySignature(secret: string, rawBody: string, header: string * `api` flow trigger (ADR-0041 Tier 1) — inbound webhook/HTTP. * * The engine binds every `type: 'api'` flow to this trigger; `start()` arms a - * hook (URL path + optional HMAC secret from the start node's `config`) and - * subscribes a queue consumer that runs the flow. The HTTP side + * hook (URL path + the required HMAC secret from the start node's `config`) + * and subscribes a queue consumer that runs the flow. The HTTP side * ({@link handleRequest}) validates and **enqueues** — it never executes the * flow in-band: * * POST /api/v1/automation/hooks/:flowName/:hookId * → 404 unknown flow / wrong hookId - * → 401 missing/bad HMAC signature (when the flow declares a `secret`) + * → 401 missing/bad HMAC signature * → 400 non-JSON body * → 202 { accepted, messageId } — queued; a consumer executes the flow * @@ -97,9 +103,12 @@ export function verifySignature(secret: string, rawBody: string, header: string * Start-node config keys: * - `hookId` — URL path token (default `'default'`); rotate it to revoke * old URLs without renaming the flow. - * - `secret` — HMAC-SHA256 shared secret. Strongly recommended; without it - * the endpoint accepts unsigned posts (the trigger logs a - * warning at arm time). + * - `secret` — HMAC-SHA256 shared secret. **Required** (ADR-0041): a + * binding with no non-blank `secret` is refused — `start()` + * throws naming the flow, and nothing is armed or subscribed. + * The automation engine refuses the same flow earlier, at + * registration, so an author learns before deploying; this + * refusal is what holds for a host that binds without it. */ export class ApiTrigger implements FlowTrigger { readonly type = 'api'; @@ -122,6 +131,17 @@ export class ApiTrigger implements FlowTrigger { const cfg = (binding.config ?? {}) as Record; const hookId = typeof cfg.hookId === 'string' && cfg.hookId.trim() ? cfg.hookId.trim() : 'default'; const secret = typeof cfg.secret === 'string' && cfg.secret.trim() ? cfg.secret.trim() : undefined; + // 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 + // reports the throw and its binding audit lists the flow as unbound. + if (!secret) { + throw new Error( + `[trigger-api] flow '${binding.flowName}' not armed: its start node declares no \`config.secret\`. ` + + `An inbound hook is armed only with a per-flow secret that every post is HMAC-verified ` + + `against (ADR-0041) — set a non-blank \`config.secret\` on the flow's start node.`, + ); + } const queue = `${QUEUE_PREFIX}:${binding.flowName}`; const hook: ArmedHook = { flowName: binding.flowName, hookId, secret, queue, callback }; @@ -148,13 +168,8 @@ export class ApiTrigger implements FlowTrigger { }); } - if (!secret) { - this.logger.warn( - `[trigger-api] flow '${binding.flowName}' armed WITHOUT a secret — endpoint accepts unsigned posts`, - ); - } this.logger.info( - `[trigger-api] armed: POST .../automation/hooks/${binding.flowName}/${hookId}${secret ? ' (HMAC required)' : ''}`, + `[trigger-api] armed: POST .../automation/hooks/${binding.flowName}/${hookId} (HMAC required)`, ); } @@ -184,7 +199,7 @@ export class ApiTrigger implements FlowTrigger { if (!hook || !safeEqual(hook.hookId, input.hookId)) { return { status: 404, body: { success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'No such hook.' } } }; } - if (hook.secret && !verifySignature(hook.secret, input.rawBody, input.signatureHeader)) { + if (!verifySignature(hook.secret, input.rawBody, input.signatureHeader)) { return { status: 401, body: { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } } }; } diff --git a/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts b/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts index 784ca4690f7..11714220abf 100644 --- a/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts +++ b/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts @@ -121,7 +121,7 @@ export const TRIGGER_API_ROUTE_LEDGER: readonly TriggerApiRouteLedgerEntry[] = [ + '`automation` namespace targets the dispatcher domain `/api/v1/automation`, and no client method builds a ' + '`/automation/hooks/*` URL. Not `public` either — that disposition means an anonymous BROWSER surface (public ' + 'forms, share-link resolution); this is machine-to-machine, the shape `service-storage` ledgers its HMAC-token ' - + '`_local/raw/:token` routes with. A flow that declares no `secret` accepts unsigned posts (warned at arm time): ' - + 'that is a flow-authoring posture, not an SDK disposition.', + + '`_local/raw/:token` routes with. Every hook this door serves is signed: a flow that declares no `secret` is ' + + 'refused at arm time and at registration (ADR-0041), so there is no unsigned posture for this row to record.', }, ]; From b73251341bdcb5d4d345a75af0a7602fdbe669af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 01:44:57 +0000 Subject: [PATCH 2/2] test(service-automation): give the activation-ledger api entry-path fixture its per-flow secret The fixture pins the ledger-disabled refusal on the api entry path; an api flow now registers only with a config.secret (ADR-0041), so the fixture declares one. Claude-Session: https://claude.ai/code/session_017B6YKCGu8CTY2KBWgwaHAs Co-authored-by: Claude --- .../service-automation/src/flow-activation-ledger.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/flow-activation-ledger.test.ts b/packages/services/service-automation/src/flow-activation-ledger.test.ts index 367b410b010..a7821267069 100644 --- a/packages/services/service-automation/src/flow-activation-ledger.test.ts +++ b/packages/services/service-automation/src/flow-activation-ledger.test.ts @@ -148,7 +148,8 @@ describe('ADR-0126 §7.2 — a ledger-disabled flow refuses at the execute() sea ['record-change', { objectName: 'lead', triggerType: 'record-after-create' }, 'record_change'], ['schedule', { schedule: '0 9 * * *' }, 'schedule'], ['time-relative', { timeRelative: { object: 'task', field: 'due_at' }, schedule: '0 * * * *' }, 'time_relative'], - ['api', { triggerType: 'api' }, 'api'], + // An `api` flow registers only with its per-flow secret (ADR-0041). + ['api', { triggerType: 'api', secret: 'hook-secret' }, 'api'], ]; for (const [label, startConfig, triggerKey] of entryPaths) {