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-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) { 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.', }, ];