diff --git a/.changeset/22773-automation-hooks-domain.md b/.changeset/22773-automation-hooks-domain.md new file mode 100644 index 00000000000..7c365dcb726 --- /dev/null +++ b/.changeset/22773-automation-hooks-domain.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': minor +'@objectstack/core': minor +--- + +feat(runtime,core): inbound automation hooks work on a kernel with no raw HTTP app — the dispatcher admits `POST /automation/hooks/:flowName/:hookId` with no session, through to the trigger's own signature check + +Clause-②: yes + +On a self-hosted server, `@objectstack/trigger-api` serves a flow's inbound hook by mounting `POST /api/v1/automation/hooks/:flowName/:hookId` on the raw Hono app, where a third-party sender (a payment provider, a code host) posts a signed event with no session. A hosted tenant kernel has no raw app, so on that shape the path belonged to the dispatcher's `/automation` domain, whose anonymous floor refused every sender `401 UNAUTHENTICATED`. + +**What widens: one route passes the anonymous floor and the ADR-0069 gate with no session.** `@objectstack/core`'s auth-gate allow-list gains its first parameterised row: exactly `POST /automation/hooks/:flowName/:hookId` in the dispatcher's spelling (four segments, optionally under one `/environments/:id` scope), and nothing else. `/automation/hooks`, `/automation/hooks/x`, `/automation/hooks/x/y/z`, every other method on the route and every other `/automation` path stay gated exactly as before. + +- `@objectstack/core`: `isAuthGateAllowlisted(path, method?)` and `evaluateAuthGate(user, path, method?)` take an optional method. The existing rows are exempt for every method, as before, so a caller that passes no method gets the answer it always got. The new row is exempt for `POST` only, and only when a method is passed. `shouldDenyAnonymous` now passes its `method` to the allow-list beside its `path`. New export: `matchInboundHookRoute(path, method)`, the one reading of the route that the row matches through, returning the two path segments raw. +- `@objectstack/runtime`: the `/automation` domain's anonymous floor now asks the allow-list with its path and method. On the very next statement it hands the admitted route to the request kernel's `trigger-api` service (`ITriggerApiService.handleInboundHook`), ahead of every other `/automation` gate and service probe. The request goes with its body unread and the two path parameters decoded, and the service's `Response` comes back as it is. The service verifies the HMAC signature, so a correctly signed post is accepted and a wrongly signed or unsigned one is refused by that service, with its own answer. The membership gate skips the route for `POST` only, so a signed-in caller is judged exactly as an anonymous one, as on the self-hosted mount. +- No `trigger-api` service registered, or one without `handleInboundHook`: `501 NOT_IMPLEMENTED`, naming which, never `ROUTE_NOT_FOUND`. A service that throws, or a request whose body a transport already read: a sanitised `500 INTERNAL_ERROR`, logged. + +Nothing changes on a self-hosted server: the trigger's raw-app mount still serves the path there and never reaches the dispatcher, and the dispatcher mounts nothing that answers it. diff --git a/packages/core/src/security/anonymous-deny.test.ts b/packages/core/src/security/anonymous-deny.test.ts index 73543898042..b6f6cd9faad 100644 --- a/packages/core/src/security/anonymous-deny.test.ts +++ b/packages/core/src/security/anonymous-deny.test.ts @@ -72,6 +72,24 @@ describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567 expect(shouldDenyAnonymous({ method: 'OPTIONS', path: '' })).toBe(false); }); + // [#22773] The allow-list's parameterised row (the inbound automation hook) + // is honoured here only with the method beside the path, and only for POST: + // the `/automation` floor passes both, and a seam that passes neither is + // answered exactly as before. + it('honours the inbound-hook row for POST on the exact route only, and only with the method', () => { + expect(shouldDenyAnonymous({ path: '/automation/hooks/f/h', method: 'POST' })).toBe(false); + expect(shouldDenyAnonymous({ path: '/environments/env_1/automation/hooks/f/h', method: 'post' })).toBe(false); + for (const method of ['GET', 'PUT', 'DELETE', 'HEAD', undefined, null]) { + expect(shouldDenyAnonymous({ path: '/automation/hooks/f/h', method }), String(method)).toBe(true); + } + for (const path of ['/automation/hooks', '/automation/hooks/x', '/automation/hooks/x/y/z', '/automation/f/trigger', '/automation']) { + expect(shouldDenyAnonymous({ path, method: 'POST' }), path).toBe(true); + } + // ⭐ The pathless guard still decides first: a method alone exempts nothing. + expect(shouldDenyAnonymous({ method: 'POST' })).toBe(true); + expect(shouldDenyAnonymous({ path: '', method: 'POST' })).toBe(true); + }); + it('exposes a stable 401 body + status for seams to return', () => { expect(ANONYMOUS_DENY_STATUS).toBe(401); // [#9487] `code` carries the machine code — the documented key every other diff --git a/packages/core/src/security/anonymous-deny.ts b/packages/core/src/security/anonymous-deny.ts index ff7144592bc..affd7099c4e 100644 --- a/packages/core/src/security/anonymous-deny.ts +++ b/packages/core/src/security/anonymous-deny.ts @@ -53,6 +53,10 @@ * from the form view's own declaration; * - share links → the capability token, validated then read as SYSTEM; * - a `book.audience: 'public'` read → the ADR-0046 §6.7 audience gate (#3963); + * - an inbound automation hook (`POST /automation/hooks/:flowName/:hookId`) → + * the HMAC signature under the hook's own secret, verified by the + * trigger-api service; the allow-list's one parameterised row admits that + * exact route, `POST` only, past the `/automation` floor (#22773); * - MCP → an OAuth token or API key, never anonymous. * * Those run UPSTREAM of this function and set the execution context (a `userId`, @@ -141,6 +145,10 @@ export interface AnonymousDenyInput { * `undefined`, which this seam DENIES — see the guard below, which is this * seam's own contract for the pathless case (#7898) rather than a reading of * what the allow-list predicate does with a falsy argument. + * + * [#22773] The allow-list reads {@link method} beside it: its one + * parameterised row, the inbound automation hook, is exempt for `POST` only, + * so a seam that passes the path for that row must pass the method too. */ path?: string | null; } @@ -169,7 +177,7 @@ export function shouldDenyAnonymous(input: AnonymousDenyInput): boolean { // `true` before #7898). The redundancy is deliberate: this seam's contract // must not be re-derived from what the predicate happens to do with a falsy // argument, which is exactly how the hole #2567 closes was reopened once. - if (typeof input.path === 'string' && input.path.length > 0 && isAuthGateAllowlisted(input.path)) { + if (typeof input.path === 'string' && input.path.length > 0 && isAuthGateAllowlisted(input.path, input.method)) { return false; } return true; diff --git a/packages/core/src/security/auth-gate.test.ts b/packages/core/src/security/auth-gate.test.ts index 378f05d75fd..020a2cc6c13 100644 --- a/packages/core/src/security/auth-gate.test.ts +++ b/packages/core/src/security/auth-gate.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { isAuthGateAllowlisted, evaluateAuthGate, normalizeAuthGate } from './auth-gate'; +import { isAuthGateAllowlisted, evaluateAuthGate, normalizeAuthGate, matchInboundHookRoute } from './auth-gate'; describe('auth-gate (ADR-0069 session gate)', () => { describe('isAuthGateAllowlisted', () => { @@ -443,6 +443,218 @@ describe('auth-gate (ADR-0069 session gate)', () => { }); }); + // ── [#22773] The allow-list's first PARAMETERISED row ─────────────────── + // + // Ruling A on #22757 (director record `6105447950`): the inbound automation + // hook is admitted with no session, through to the trigger's own HMAC + // verifier, by ONE route-exact row in the dispatcher's spelling. Condition 1 + // binds its shape: exactly four segments (`automation`, `hooks`, one flow + // name, one hook id), `POST` only, no prefix and no wildcard beyond the two + // parameters — and `/automation/hooks`, `/automation/hooks/x`, + // `/automation/hooks/x/y/z` and every other `/automation` path still gated. + // + // ⛔ These pin the DECISION per (path, method), never the entry's spelling. + describe('[#22773] the inbound hook is exempt as the exact four-segment POST route, and nothing wider', () => { + it('PIN 1 — exempts POST on the exact route, unscoped or under one /environments/:id scope', () => { + for (const p of [ + '/automation/hooks/stripe_orders/default', // the dispatcher spelling: the hono adapter strips the app prefix + '/automation/hooks/f/h', + '/automation/hooks/f/h/', // trailing slashes stripped, as for every row + '/automation/hooks/f/h//', + '/automation/hooks/f/h?x=1', // query stripped, as for every row + '/automation/hooks/f%2Fx/h%20y', // parameters are any one segment, raw + '/automation/hooks/hooks/automation', // a parameter may spell a literal + '/environments/env_1/automation/hooks/f/h', // scoped: the dispatcher gates BEFORE its scoped-URL strip + ]) { + expect(isAuthGateAllowlisted(p, 'POST'), p).toBe(true); + expect(isAuthGateAllowlisted(p, 'post'), `${p} (method case)`).toBe(true); + } + }); + + it('PIN 2 — the method: POST only, and no method exempts nothing on this row', () => { + const p = '/automation/hooks/f/h'; + for (const method of ['GET', 'HEAD', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'CONNECT', 'TRACE', 'POSTX', '']) { + expect(isAuthGateAllowlisted(p, method), method).toBe(false); + } + expect(isAuthGateAllowlisted(p)).toBe(false); + expect(isAuthGateAllowlisted(p, undefined)).toBe(false); + expect(isAuthGateAllowlisted(p, null)).toBe(false); + // ⭐ CONTROL: the rows that were exempt for every method still are, with + // a method or without one. + for (const method of [undefined, 'GET', 'POST', 'DELETE']) { + expect(isAuthGateAllowlisted('/approvals/act', method), String(method)).toBe(true); + expect(isAuthGateAllowlisted('/api/v1/auth/sign-out', method), String(method)).toBe(true); + } + }); + + it('PIN 3 — condition 1: every other segment count, and every other /automation path, stays gated', () => { + for (const p of [ + // the card's own three, and the bare domain + '/automation/hooks', '/automation/hooks/x', '/automation/hooks/x/y/z', '/automation', + '/automation/hooks/x/y/z/w', '/automation/hooks/f/h/runs/r1/resume', + // the domain's real routes, at every depth + '/automation/trigger/f', '/automation/f/trigger', '/automation/f/toggle', '/automation/f/clone', + '/automation/f/runs', '/automation/f/runs/r1', '/automation/f/runs/r1/resume', + '/automation/hooks/trigger', '/automation/hooks/runs/r1/cancel', + // a sibling literal in either fixed position + '/automation/hooksx/f/h', '/automation/hook/f/h', '/automation/webhooks/f/h', '/automationx/hooks/f/h', + '/automation/f/hooks/h', '/hooks/automation/f/h', + // literals are matched unencoded and case-sensitively, as the router matches them + '/Automation/hooks/f/h', '/automation/HOOKS/f/h', '/automation/%68ooks/f/h', '/%61utomation/hooks/f/h', + '/automation/hooks%2Ff/h', + ]) { + expect(isAuthGateAllowlisted(p, 'POST'), p).toBe(false); + } + }); + + it('PIN 4 — the dispatcher spelling only: no mount base, no retired scope, no empty segment', () => { + for (const p of [ + // a mount base: the route exists on the dispatcher alone, whose path + // arrives base-stripped, so a based spelling there is a doubled prefix + // the router serves nothing at + '/api/v1/automation/hooks/f/h', '/api/automation/hooks/f/h', '/api/v1/environments/env_1/automation/hooks/f/h', + // the retired `projects` scope, which the dispatcher's scope strip does not remove + '/projects/env_1/automation/hooks/f/h', + // the route below a non-mount segment, whose value a tenant controls + '/data/automation/hooks/f/h', '/meta/automation/hooks/f/h', '/x/automation/hooks/f/h', + // two scopes, or a scope with no id (the id is `automation`) + '/environments/a/environments/b/automation/hooks/f/h', '/environments/automation/hooks/f/h', + // an empty segment anywhere: the scope strip and the domain claim do not drop it + '//automation/hooks/f/h', '/automation//hooks/f/h', '/automation/hooks//h', '/automation/hooks/f//h', + '/automation/hooks//f/h', '/environments//env_1/automation/hooks/f/h', '/environments/env_1//automation/hooks/f/h', + // not a path at all + 'automation/hooks/f/h', '', '/', + ]) { + expect(isAuthGateAllowlisted(p, 'POST'), p).toBe(false); + } + }); + + it('matchInboundHookRoute: the one reading, its parameters raw — and null wherever the row is', () => { + expect(matchInboundHookRoute('/automation/hooks/f/h', 'POST')).toEqual({ flowName: 'f', hookId: 'h' }); + expect(matchInboundHookRoute('/automation/hooks/f%2Fx/h%20y', 'POST')).toEqual({ flowName: 'f%2Fx', hookId: 'h%20y' }); + expect(matchInboundHookRoute('/environments/env_1/automation/hooks/f/h/?q=1', 'post')).toEqual({ flowName: 'f', hookId: 'h' }); + for (const [p, m] of [ + ['/automation/hooks/f/h', 'GET'], ['/automation/hooks/f/h', undefined], ['/automation/hooks/f', 'POST'], + ['/automation//hooks/f/h', 'POST'], ['/api/v1/automation/hooks/f/h', 'POST'], [undefined, 'POST'], + ] as const) { + expect(matchInboundHookRoute(p, m), `${p} ${m}`).toBeNull(); + } + // The row and the reading are one: they answer the same on every spelling PIN 1-4 name. + for (const p of ['/automation/hooks/f/h', '/automation/hooks/x', '//automation/hooks/f/h', '/projects/e/automation/hooks/f/h']) { + for (const m of ['POST', 'GET', undefined]) { + expect(isAuthGateAllowlisted(p, m), `${p} ${m}`).toBe(matchInboundHookRoute(p, m) !== null); + } + } + }); + + it('carries through `evaluateAuthGate`: a gated session passes the POST and stays gated on every other method', () => { + const gated = { id: 'u1', authGate: { code: 'MFA_REQUIRED', message: 'enrol' } }; + expect(evaluateAuthGate(gated, '/automation/hooks/f/h', 'POST')).toBeNull(); + expect(evaluateAuthGate(gated, '/environments/env_1/automation/hooks/f/h', 'POST')).toBeNull(); + // ⭐ The control: `GET /automation/hooks/runs/r1` is a run read of a flow + // named `hooks` — the reason the row is POST-only. + expect(evaluateAuthGate(gated, '/automation/hooks/runs/r1', 'GET')).toEqual({ code: 'MFA_REQUIRED', message: 'enrol' }); + expect(evaluateAuthGate(gated, '/automation/hooks/f/h')).toEqual({ code: 'MFA_REQUIRED', message: 'enrol' }); + expect(evaluateAuthGate(gated, '/automation/hooks/x', 'POST')).toEqual({ code: 'MFA_REQUIRED', message: 'enrol' }); + }); + + // ⭐ CLAUSE ② — the widening, measured rather than asserted. + // + // `preEntryAllowlisted` is this file's subject as it stood before the row, + // transcribed from `origin/main` 12b9daf7 (it takes no method: no row read + // one). Over every path of up to five segments drawn from a vocabulary that + // mixes the new route's tokens with the existing ones, plus the six-segment + // scoped shape, and over methods that include none at all, the set of + // (path, method) answers that moved must be EXACTLY the hook route — POST, + // in either case — unscoped or under one `/environments/:id` scope, every + // move a newly exempt answer, none newly gated. + it('BOUNDARY — newly exempt is exactly POST on the four-segment hook route, unscoped or scoped, nothing else', () => { + const PRE_MOUNT_BASES: readonly (readonly string[])[] = [['api', 'v1'], ['api'], []]; + const PRE_SCOPE_SEGMENTS: readonly string[] = ['environments', 'projects']; + const PRE_ALLOW_ROUTES: readonly (readonly string[])[] = [ + ['health'], ['ready'], ['discovery'], ['me', 'apps'], ['me', 'localization'], ['approvals', 'act'], + ]; + const startsWith = (segments: readonly string[], prefix: readonly string[]): boolean => { + if (segments.length < prefix.length) return false; + for (let k = 0; k < prefix.length; k++) if (segments[k] !== prefix[k]) return false; + return true; + }; + const preEntryAllowlisted = (rawPath: string | undefined | null): boolean => { + if (!rawPath) return false; + let path = rawPath.split('?')[0] || '/'; + let end = path.length; + while (end > 1 && path.charCodeAt(end - 1) === 47) end--; + path = path.slice(0, end) || '/'; + const segments = path.split('/').filter((s) => s !== ''); + for (const base of PRE_MOUNT_BASES) { + if (!startsWith(segments, base)) continue; + let i = base.length; + let scoped = false; + if (i + 1 < segments.length && PRE_SCOPE_SEGMENTS.includes(segments[i] as string)) { + i += 2; + scoped = true; + } + if (segments[i] === 'auth') { + if (i + 1 < segments.length) return true; + if (!scoped) return true; + } + for (const route of PRE_ALLOW_ROUTES) { + if (segments.length - i === route.length && startsWith(segments.slice(i), route)) return true; + } + } + return false; + }; + + const SEG = ['automation', 'hooks', 'environments', 'projects', 'env1', 'f', 'api', 'v1', 'health', 'runs']; + const corpus: string[] = ['', '/']; + const walk = (prefix: string, depth: number) => { + if (depth === 0) return; + for (const s of SEG) { + const p = `${prefix}/${s}`; + corpus.push(p); + walk(p, depth - 1); + } + }; + walk('', 5); + // The scoped shape is six segments deep: `/environments//automation/hooks//`. + const SCOPE_IDS = ['env1', 'hooks']; + for (const id of SCOPE_IDS) walk(`/environments/${id}`, 4); + const METHODS = ['POST', 'post', 'GET', 'DELETE', 'OPTIONS', undefined] as const; + + // Built independently of the predicate: (unscoped | scope × id) × route × method. + const expected = new Set(); + for (const scope of [[], ...SCOPE_IDS.map((id) => ['environments', id])]) { + for (const a of SEG) for (const b of SEG) { + for (const m of ['POST', 'post']) expected.add(`${m} /${[...scope, 'automation', 'hooks', a, b].join('/')}`); + } + } + + const newlyExempt: string[] = []; + const newlyGated: string[] = []; + for (const p of corpus) { + const before = preEntryAllowlisted(p); + for (const m of METHODS) { + const after = isAuthGateAllowlisted(p, m); + if (after && !before) newlyExempt.push(`${m} ${p}`); + if (!after && before) newlyGated.push(`${m} ${p}`); + } + } + expect(newlyGated).toEqual([]); + expect([...new Set(newlyExempt)].sort()).toEqual([...expected].sort()); + + // Anti-vacuity: the corpus reaches every expected path, both predicates + // answer both ways, and the card's own siblings are in it and gated. + const inCorpus = new Set(corpus); + for (const e of expected) expect(inCorpus.has(e.slice(e.indexOf(' ') + 1)), e).toBe(true); + expect(corpus.length).toBeGreaterThan(100_000); + expect(corpus.filter((p) => preEntryAllowlisted(p)).length).toBeGreaterThan(0); + for (const p of ['/automation/hooks', '/automation/hooks/f', '/automation/hooks/f/runs/health', '/automation/f/hooks/runs']) { + expect(inCorpus.has(p), p).toBe(true); + expect(isAuthGateAllowlisted(p, 'POST'), p).toBe(false); + } + }); + }); + describe('evaluateAuthGate', () => { it('returns null when the user carries no authGate', () => { expect(evaluateAuthGate({ id: 'u1' }, '/api/v1/data/x')).toBeNull(); diff --git a/packages/core/src/security/auth-gate.ts b/packages/core/src/security/auth-gate.ts index 264818d54c2..ca4e16c6146 100644 --- a/packages/core/src/security/auth-gate.ts +++ b/packages/core/src/security/auth-gate.ts @@ -132,6 +132,131 @@ const ALLOW_ROUTES: readonly (readonly string[])[] = [ ['approvals', 'act'], ]; +/** + * One PARAMETERISED row: a route of literal segments and `:name` parameters, + * exempt for the METHODS it names and no other. A parameter matches exactly one + * segment, of any value; a literal matches itself, unencoded. + */ +interface ParameterisedAllowRoute { + readonly segments: readonly string[]; + readonly methods: readonly string[]; +} + +/** + * `POST /automation/hooks/:flowName/:hookId` — the inbound hook a third-party + * sender posts a signed event through to start an `api` flow (ADR-0041 Tier 1), + * admitted with no session by ruling A on #22757 (director record `6105447950`). + * The self-hosted raw-app mount (`@objectstack/trigger-api`, `HOOKS_PATH`) has + * never passed through this gate, and the sender has no session at all: the + * HMAC over the body, under the hook's own secret, is its whole credential, and + * the trigger-api service verifies it (`ITriggerApiService.handleInboundHook`). + * This row gives the dispatcher's spelling of the same route the same + * signature-only authentication, read by every seam that asks this list — + * the ADR-0069 gate, and `shouldDenyAnonymous` (`anonymous-deny.ts`), which + * the `/automation` domain's anonymous floor calls with its path and method. + * + * ⛔ Ruling condition 1, exactness. Exactly four segments, `POST` only, no + * prefix and no wildcard beyond the two parameters: `/automation/hooks`, + * `/automation/hooks/x`, `/automation/hooks/x/y/z`, any other method and every + * other `/automation` path stay gated. Anything wider returns to #22757. + */ +const INBOUND_HOOK_ROUTE: ParameterisedAllowRoute = { + segments: ['automation', 'hooks', ':flowName', ':hookId'], + methods: ['POST'], +}; + +/** + * The parameterised rows. Unlike {@link ALLOW_ROUTES} they are read in the + * DISPATCHER's spelling only, by {@link matchParameterisedRoute}: the route + * exists on the dispatcher alone, and a row must not admit a spelling the + * dispatcher's router reads differently. + */ +const ALLOW_PARAMETERISED_ROUTES: readonly ParameterisedAllowRoute[] = [INBOUND_HOOK_ROUTE]; + +/** + * The segments of `rawPath` as a parameterised row reads them, or `null` for a + * spelling it does not read at all. Query and trailing slashes are stripped as + * for every row; nothing is percent-decoded. + * + * ⛔ A spelling with an EMPTY segment is not read, rather than read with the + * empty segments dropped as {@link matchesAllowlistedRoute} drops them. The + * dispatcher's scope strip (`/environments/:id`, which needs a non-empty id + * right after its slash) and its domain claims do not drop them, so a dropped + * reading could admit a path the router serves as a different route or as + * none: `/environments//env_1/automation/hooks/f/h` is a scoped hook here and an + * unrouted path there. One reading, the router's, and every other spelling + * stays gated. + */ +function canonicalRouteSegments(rawPath: string): string[] | null { + let path = rawPath.split('?')[0] ?? ''; + let end = path.length; + while (end > 1 && path.charCodeAt(end - 1) === 47) end--; + path = path.slice(0, end); + if (path.length < 2 || path.charCodeAt(0) !== 47) return null; + const segments = path.slice(1).split('/'); + return segments.includes('') ? null : segments; +} + +/** + * The parameters `route` binds on `rawPath` + `method`, RAW (still + * percent-encoded, as the path carries them), or `null` when it does not match. + * + * Read as the dispatcher's router reads it: no mount base (the dispatcher's path + * arrives with the app prefix stripped), and one optional environment scope in + * the one spelling its scope strip removes (`/environments/:id`, never the + * retired `projects`), because the ADR-0069 gate and the membership gate both + * run BEFORE that strip. The segment count is pinned by the route's own length. + * + * ⛔ No method, no match: an exemption the route earns only for the methods it + * names cannot be earned by a caller that does not say which method it asks for. + */ +function matchParameterisedRoute( + route: ParameterisedAllowRoute, + rawPath: string | undefined | null, + method: string | undefined | null, +): Record | null { + if (!rawPath || typeof method !== 'string' || !route.methods.includes(method.toUpperCase())) return null; + const segments = canonicalRouteSegments(rawPath); + if (!segments) return null; + const rest = segments[0] === 'environments' && segments.length > 1 ? segments.slice(2) : segments; + if (rest.length !== route.segments.length) return null; + const params: Record = {}; + for (let k = 0; k < rest.length; k++) { + const want = route.segments[k] as string; + const got = rest[k] as string; + if (want.startsWith(':')) params[want.slice(1)] = got; + else if (got !== want) return null; + } + return params; +} + +/** The inbound hook's two path parameters, as {@link matchInboundHookRoute} reads them. */ +export interface InboundHookRouteMatch { + /** The flow's name, raw: still percent-encoded as the path carries it. */ + readonly flowName: string; + /** The hook id, raw: still percent-encoded as the path carries it. */ + readonly hookId: string; +} + +/** + * Is `path` + `method` the inbound hook route the allow-list admits, and with + * which parameters? The ONE reading of that route: the allow-list row matches + * through it, and the dispatcher's `/automation` domain and membership gate ask + * it too, so the readers that admit the route and the reader that serves it + * cannot disagree on any spelling. Same reading as the row (dispatcher spelling, + * one optional `/environments/:id` scope, no empty segment, `POST` only). + * + * The parameters come back RAW. Decoding them is the serving domain's job, as + * for every dispatcher domain that reads a path segment. + */ +export function matchInboundHookRoute( + path: string | undefined | null, + method: string | undefined | null, +): InboundHookRouteMatch | null { + const params = matchParameterisedRoute(INBOUND_HOOK_ROUTE, path, method); + return params ? { flowName: params.flowName as string, hookId: params.hookId as string } : null; +} + /** Do `segments` start with every segment of `prefix`? */ function startsWithSegments(segments: readonly string[], prefix: readonly string[]): boolean { if (segments.length < prefix.length) return false; @@ -213,20 +338,30 @@ function matchesAllowlistedRoute(rawPath: string): boolean { * Normalising that `'' → '/'` at the dispatcher is the ruling's step 2, tracked * as #17625 (`domain:cli`, `Blocked-by: #7898`) — ⛔ not a tolerance to add back * here. + * + * ## `method` is read by the parameterised rows only (#22773) + * + * Every {@link ALLOW_ROUTES} row is exempt for every method, as before, so a + * caller that passes no method gets exactly the answer it always got. A + * parameterised row ({@link ALLOW_PARAMETERISED_ROUTES}) is exempt only for the + * methods it names, and only for a caller that names one: no method, no + * exemption on that row. */ -export function isAuthGateAllowlisted(rawPath: string | undefined | null): boolean { +export function isAuthGateAllowlisted(rawPath: string | undefined | null, method?: string | null): boolean { if (!rawPath) return false; - return matchesAllowlistedRoute(rawPath); + if (matchesAllowlistedRoute(rawPath)) return true; + return ALLOW_PARAMETERISED_ROUTES.some((route) => matchParameterisedRoute(route, rawPath, method) !== null); } /** * Returns the active gate when `sessionUser` carries an `authGate` AND `path` - * is not allow-listed; otherwise null. Anonymous users (no `authGate`) and + * (+ `method`, read as {@link isAuthGateAllowlisted} reads it) is not + * allow-listed; otherwise null. Anonymous users (no `authGate`) and * allow-listed paths always pass. */ -export function evaluateAuthGate(sessionUser: any, path: string): AuthGate | null { +export function evaluateAuthGate(sessionUser: any, path: string, method?: string | null): AuthGate | null { const gate = normalizeAuthGate(sessionUser); if (!gate) return null; - if (isAuthGateAllowlisted(path)) return null; + if (isAuthGateAllowlisted(path, method)) return null; return gate; } diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index f02b613f262..c73c21c12ed 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -221,7 +221,9 @@ export { isAuthGateAllowlisted, evaluateAuthGate, normalizeAuthGate, + matchInboundHookRoute, type AuthGate, + type InboundHookRouteMatch, } from './auth-gate.js'; // #2567 — the single anonymous-deny decision shared by every HTTP seam. diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 77f74858367..b7c207aeec0 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -25,7 +25,7 @@ // dispatcher domain files. // // The population comes from `packages/rest/src/rest-route-ledger.ts` (81 rows -// / 18 families) and `packages/runtime/src/route-ledger.ts` (86 rows / 23 +// / 18 families) and `packages/runtime/src/route-ledger.ts` (87 rows / 23 // domains) because those two are enumerated from a RUNNING server and guarded // in both directions by their own conformance tests — so a new family or // domain cannot be silently absent from them, and therefore cannot be silently @@ -244,6 +244,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // adds no claim the pinned gate did not already carry. covers: ['actions:domains/actions.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/actions'], note: 'A `type: \'script\'` action body runs `isSystem: true` (elevated), so an ungated POST was an anonymous privilege-escalating WRITE, not merely an information leak — before the gate, an anonymous `POST /actions/showcase_task/showcase_mark_done/:id` was measured answering 200 with the update applied. Internal dispatch is unaffected: this handler is a pure HTTP seam (the MCP `run_action` bridge enters through action-execution.invokeBusinessAction, declarative endpoints through the transport fallback seam with their own `authRequired` gate), so `authRequired: false` public endpoints stay public.' }, + // #22773 — the one route the floor admits (ruling A on #22757): named in the + // note's last sentences. ⛔ Ids stay in this comment, out of the row's strings. { id: 'anonymous-deny-automation', summary: 'anonymous-deny on the automation/flow surface (uniform anonymous posture, surface 3: refused 401 before dispatch, as `/data` and `/meta` are)', state: 'enforced', enforcement: 'runtime/domains/automation.ts handleAutomationRequest — shouldDenyAnonymous DOMAIN-WIDE at the top, and deliberately BEFORE the isServiceServeable probe so the 401/501 difference cannot be used to fingerprint whether a deployment mounts automation; per-route capability predicates run after this floor — `manage_metadata` for the five gated flow writes (create `POST /` / update `PUT /:name` / deregister `DELETE /:name`, the definition writes on the metadata plane, plus enablement `POST /:name/toggle` since the 2026-08-23 ruling that enablement is an authoring write, which measured that the enabled bit is not a ROW and so reaches every organization on the deployment, plus the ADR-0126 §7.1 clone `POST /:name/clone`, which registers flow metadata at environment scope exactly as create does), all selected by the ONE `isFlowAuthoringWrite` predicate, fail-closed by construction (an absent executionContext, an absent `systemPermissions` or an empty one all refuse) and answering 403 `PERMISSION_DENIED`, with only engine `isSystem` bypassing; the run-state reads (the `sys_automation_run` read grant) and `resume` (keyed on the node the run is suspended on, fail-closed for a node that declares no resumeAuthority) carry their own separate per-route predicates, and the execution doors (trigger / execute) sit outside all of them — including `POST /trigger/:name` for a flow literally NAMED `toggle`, which the toggle arm deliberately excludes so a name cannot cost a member its run door', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', @@ -252,7 +254,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // rather than each route is what keeps a newly added automation route from // arriving ungated"). covers: ['automation:domains/automation.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/automation'], - note: 'Ungated, an anonymous caller could start real flow runs (`POST /:name/trigger`), read the full flow inventory (`GET /automation`), and DEREGISTER a registered flow (`DELETE /:name` → `{deleted:true}`) — the destructive one, which the original anonymous-surface report did not record. Gating the DOMAIN rather than each route is what keeps a newly added automation route from arriving ungated. Engine-internal triggers (record-change, schedule) never speak HTTP and are untouched.' }, + note: 'Ungated, an anonymous caller could start real flow runs (`POST /:name/trigger`), read the full flow inventory (`GET /automation`), and DEREGISTER a registered flow (`DELETE /:name` → `{deleted:true}`) — the destructive one, which the original anonymous-surface report did not record. Gating the DOMAIN rather than each route is what keeps a newly added automation route from arriving ungated. Engine-internal triggers (record-change, schedule) never speak HTTP and are untouched. ONE route is admitted past the floor, by declaration rather than by omission: the floor asks the shared decision with its path and method, and the ADR-0069 allow-list\'s one parameterised row (core/security/auth-gate.ts) admits exactly `POST /automation/hooks/:flowName/:hookId`, the inbound hook, whose only credential is the HMAC signature over its body, verified by the trigger-api service (ITriggerApiService.handleInboundHook) that the domain hands the route to on the statement after the floor (runtime/domains/automation-hooks.ts). Every other path and method under /automation, the hook path\'s own non-POST methods included, stays at this floor, pinned in runtime domains/automation-hooks.test.ts and core security/auth-gate.test.ts.' }, // #7033 / #7023 — the SIXTH dispatcher domain to join the baseline. `/packages` // was the last routed domain with ZERO authorization predicates: a survey drove // a guest-principal caller to a 200 on the DESTRUCTIVE `discard-drafts` and the diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 75a5864b655..ccd56f0662a 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -106,7 +106,7 @@ // `RestServer.getRoutes()` on a booted server and guarded per route by // `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars; // this table reaches 1. -// `packages/runtime/src/route-ledger.ts`: 86 rows over 23 domains. Its +// `packages/runtime/src/route-ledger.ts`: 87 rows over 23 domains. Its // machine contract is DOMAIN-level, by live registry introspection // (`domainRegistry.list()`), the per-route rows being documentation. It // covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all @@ -430,11 +430,19 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // classified by `webhook-redeliver-session` rather than added to the // baseline. Controls moved with the row ("route: '" and "domain: '" // 85 -> 86, RouteLedgerEntry unchanged at 2). - population: 86, - reachable: 86, + // [#22773] 86 -> 87: the `POST /automation/hooks/:flowName/:hookId` row + // arrived with the inbound-hook door (segment 2 of ruling A on #22757). It + // carries `domain: '/automation'`, an EXISTING key — the `/automation` + // domain hands the route to `domains/automation-hooks.ts`, which declares + // no DomainRoute prefix — so `reachable` moves with `population`, + // `blindSpot` stays 0, `keys` stays 23 and the domain-file counts in the + // note below are unchanged. Controls moved with the row ("route: '" and + // "domain: '" 86 -> 87, RouteLedgerEntry unchanged at 2). + population: 87, + reachable: 87, blindSpot: 0, populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)', - controls: { "route: '": 86, "domain: '": 86, RouteLedgerEntry: 2 }, + controls: { "route: '": 87, "domain: '": 87, RouteLedgerEntry: 2 }, // [#22432] Re-derived on the merged ref: the note's two counts had stood at // 11 files / 5 classified / 16 baselined since before the `/analytics` // domain was classified (#21061), which named one more file and moved one diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts index d74b282a32f..9c08997031d 100644 --- a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -156,6 +156,15 @@ const UNEXERCISED_BY_THIS_BOOT: Record = { // `rawApp.post()` on the Hono app — never through the port. 'POST /api/v1/webhooks/redeliver': 'self-hosted, plugin-webhooks mounts the redeliver door with rawApp.post() directly on Hono, so it never passes through the IHttpServer port; the dispatcher domain this row ledgers serves only a kernel with no raw app. Both doors are driven, on the real plugin, by packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts', + // [#22773] Two doors, one path, a third time. This row ledgers the inbound + // hook as the DISPATCHER's `/automation` domain serves it, the door for a + // kernel with NO raw app. Self-hosted, `@objectstack/trigger-api`'s + // `ApiTriggerPlugin` serves the same path from `rawApp.post()` on the Hono + // app where it is composed — never through the port — and the dispatcher + // bridge mounts nothing there, which this pin's second half checks on every + // run: the path is never mounted twice. + 'POST /api/v1/automation/hooks/:flowName/:hookId': + 'self-hosted, the inbound-hook trigger (ApiTriggerPlugin) mounts this path with rawApp.post() directly on Hono, so it never passes through the IHttpServer port, and the dispatcher bridge mounts no route that answers it; the dispatcher domain this row ledgers serves only a kernel with no raw app. Pinned on the real HttpDispatcher by packages/runtime/src/domains/automation-hooks.test.ts', }; /** Segments a probe path uses for `:params` — must match no literal segment. */ diff --git a/packages/runtime/src/domains/automation-hooks.test.ts b/packages/runtime/src/domains/automation-hooks.test.ts new file mode 100644 index 00000000000..abf03a25166 --- /dev/null +++ b/packages/runtime/src/domains/automation-hooks.test.ts @@ -0,0 +1,526 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22773] The inbound automation hook, `POST /automation/hooks/:flowName/:hookId`, + * through the dispatcher — segment 2 of ruling A on #22757 (director record + * `6105447950`): admitted with no session, through to the trigger's own HMAC + * verifier and nothing else, on a kernel that registers no `http.server` (the + * hosted shape: a tenant kernel owns no socket). + * + * Driven through the REAL `HttpDispatcher.dispatch()`. The `trigger-api` member + * is a test double, because the real one is a separate card (#22774), but its + * verifier is not: it reads the raw body off the `Request` it is handed and + * checks the `x-objectstack-signature` with `verifyHttpSignature`, the very + * function `ApiTrigger.handleRequest` verifies with (`@objectstack/core`), and + * answers every post that does not verify with the trigger's one refusal. So a + * domain that handed it anything but the transport's own unread request, or a + * re-serialised body, would be caught by the verifier, not by an assertion + * about plumbing. + * + * What this pins: + * 1. a correctly signed post reaches the verifier and is accepted; a wrongly + * signed, unsigned, unknown-flow or wrong-hook post is refused BY THE + * VERIFIER — the member runs, and its answer comes back untouched; + * 2. the two parameters reach the member decoded; + * 3. condition 1: every other `/automation` path, and every other method on + * this one, stays at the anonymous floor (`401 UNAUTHENTICATED`, the answer + * each of them gave on the base commit), and the member never runs; + * 4. condition 2: nothing sits between the allow-list and the verifier — no + * `/automation` gate or probe reads anything first, and a non-`POST` meets + * the floor's refusal before any absence leaks (the #5519 ordering); + * 5. an absent `trigger-api` slot or member answers `501 NOT_IMPLEMENTED`, + * ⛔ never `ROUTE_NOT_FOUND`, resolved per request; + * 6. faults (a member that throws, a consumed body) are sanitised 500s; + * 7. the preamble gates are signature-only for this route, `POST` only: a + * signed-in non-member and a session owing an authentication step reach + * the verifier, while the same callers stay gated on every other method; + * 8. the pointer's extra self-test: the allow-list row and the route agree on + * every spelling they are shown — a spelling one admits, the other serves + * with the same parameters, and every other spelling stays gated; + * 9. CONTROL: a kernel WITH an `http.server` gets no mount for the path from + * the dispatcher (the trigger's own raw-app mount is its door there), so the + * path is never mounted twice. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { isAuthGateAllowlisted, matchInboundHookRoute, signHttpBody, verifyHttpSignature } from '@objectstack/core'; +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext, KernelResolver } from '../http-dispatcher.js'; +import { createDispatcherPlugin } from '../dispatcher-plugin.js'; + +const ORIGIN = 'http://tenant.example'; +const FLOW = 'stripe_orders'; +const HOOK = 'default'; +/** A probe sentinel, not a credential. */ +const SECRET = 'pin-22773-hook-secret-4be1'; +const ROUTE = `/automation/hooks/${FLOW}/${HOOK}`; +/** A spaced body: a re-serialised one would differ from the bytes the sender signed. */ +const BODY = '{ "id": "evt_1", "amount": 42, "currency": "usd" }'; +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: JSON_HEADERS }); +const REFUSAL = { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } }; + +/** + * A `handleInboundHook` member with the trigger's verifier inside it: the hook + * armed for each flow, the raw body read once off the request, the signature + * checked by core's `verifyHttpSignature`, one refusal for every post that does + * not verify, `202` for one that does. It records every call. + */ +function verifierMember(armed: Record = { [FLOW]: { hookId: HOOK, secret: SECRET } }) { + const calls: Array<{ request: Request; hook: { flowName: string; hookId: string }; body: string }> = []; + const accepted: string[] = []; + const handleInboundHook = vi.fn(async (request: Request, hook: { readonly flowName: string; readonly hookId: string }): Promise => { + const body = await request.text(); + calls.push({ request, hook: { ...hook }, body }); + const arm = Object.prototype.hasOwnProperty.call(armed, hook.flowName) ? armed[hook.flowName] : undefined; + const verdict = arm && arm.hookId === hook.hookId + ? verifyHttpSignature(body, arm.secret, request.headers.get('x-objectstack-signature') ?? undefined) + : { valid: false as const }; + if (!verdict.valid) return json(401, REFUSAL); + accepted.push(body); + return json(202, { accepted: true, messageId: `msg_${accepted.length}` }); + }); + return { handleInboundHook, calls, accepted }; +} + +/** A kernel with no `http.server` — only the services named. */ +function kernelWith(services: Record): any { + return { + getState: () => 'running', + getService: (n: string) => services[n] ?? null, + getServiceAsync: async (n: string) => services[n] ?? null, + context: { getService: (n: string) => services[n] ?? null }, + }; +} + +function makeDispatcher(services: Record) { + const logger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const dispatcher = new HttpDispatcher(kernelWith(services), undefined, { enforceProjectMembership: false }); + (dispatcher as any).domainDeps.logger = logger; + return { dispatcher, logger }; +} + +/** An `auth` service whose session is `user` (anonymous when absent). */ +const authAs = (user?: { id: string; authGate?: { code: string; message: string } }) => ({ + isAuthGateActive: () => user?.authGate !== undefined, + getApi: async () => ({ + getSession: async () => (user ? { user, session: { userId: user.id, activeOrganizationId: 'org_tenant' } } : null), + }), +}); + +const ctx = (request: Request): HttpProtocolContext => ({ request }); +function post(path = ROUTE, opts: { body?: string; signature?: string | null; method?: string } = {}) { + const body = opts.body ?? BODY; + const signature = opts.signature === undefined ? signHttpBody(body, SECRET) : opts.signature; + return new Request(`${ORIGIN}/api/v1${path}`, { + method: opts.method ?? 'POST', + headers: { ...JSON_HEADERS, ...(signature !== null ? { 'x-objectstack-signature': signature } : {}) }, + body, + }); +} + +/** `{ status, code }` of a dispatcher envelope answer. */ +const envelope = (r: any) => ({ status: r.response?.status, code: r.response?.body?.error?.code }); + +describe('[#22773] the inbound hook reaches the verifier on a kernel with no http.server', () => { + it('a correctly signed anonymous POST reaches the verifier and is accepted — its own request, body unread, the 202 returned as it is', async () => { + const member = verifierMember(); + // No `auth` service: the caller is anonymous. No `automation` service + // either: nothing on this route needs it. + const { dispatcher } = makeDispatcher({ 'trigger-api': { handleInboundHook: member.handleInboundHook } }); + const request = post(); + + // The parsed `body` a transport hands dispatch() is irrelevant: the + // domain never reads it, the verifier reads the request. + const result = await dispatcher.dispatch('POST', ROUTE, { id: 'not-this' }, {}, ctx(request)); + + expect(result.response, JSON.stringify(result.response?.body)).toBeUndefined(); + expect(member.handleInboundHook).toHaveBeenCalledTimes(1); + expect(member.calls[0]!.request).toBe(request); + expect(member.calls[0]!.hook).toEqual({ flowName: FLOW, hookId: HOOK }); + expect(member.calls[0]!.body).toBe(BODY); + expect(member.accepted).toEqual([BODY]); + const res = result.result as Response; + expect(res).toBeInstanceOf(Response); + expect(res.status).toBe(202); + expect(await res.json()).toEqual({ accepted: true, messageId: 'msg_1' }); + }); + + it('a wrongly signed, unsigned, unknown-flow or wrong-hook POST is refused BY THE VERIFIER — its one answer passed through', async () => { + const member = verifierMember(); + const { dispatcher } = makeDispatcher({ 'trigger-api': { handleInboundHook: member.handleInboundHook } }); + const cases: Array<[string, Request, string]> = [ + ['wrong secret', post(ROUTE, { signature: signHttpBody(BODY, 'not-the-secret') }), ROUTE], + ['body changed after signing', post(ROUTE, { body: `${BODY} `, signature: signHttpBody(BODY, SECRET) }), ROUTE], + ['unsigned', post(ROUTE, { signature: null }), ROUTE], + ['malformed signature', post(ROUTE, { signature: 'sha256=zz' }), ROUTE], + ['unknown flow', post('/automation/hooks/no_such_flow/default'), '/automation/hooks/no_such_flow/default'], + ['wrong hook id', post(`/automation/hooks/${FLOW}/other`), `/automation/hooks/${FLOW}/other`], + ]; + for (const [label, request, path] of cases) { + const result = await dispatcher.dispatch('POST', path, {}, {}, ctx(request)); + expect(result.response, label).toBeUndefined(); + const res = result.result as Response; + expect(res.status, label).toBe(401); + expect(await res.json(), label).toEqual(REFUSAL); + } + // Every one reached the verifier — nothing in front of it pre-filtered + // the signature — and none was accepted. + expect(member.handleInboundHook).toHaveBeenCalledTimes(cases.length); + expect(member.accepted).toEqual([]); + }); + + it('a signed-in caller is judged exactly as an anonymous one: the verifier decides, both ways', async () => { + const member = verifierMember(); + const { dispatcher } = makeDispatcher({ 'trigger-api': { handleInboundHook: member.handleInboundHook }, auth: authAs({ id: 'u_operator' }) }); + + const signed = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post())); + expect((signed.result as Response).status).toBe(202); + const forged = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post(ROUTE, { signature: signHttpBody(BODY, 'session-is-no-credential') }))); + expect((forged.result as Response).status).toBe(401); + expect(member.handleInboundHook).toHaveBeenCalledTimes(2); + }); + + it('the two parameters reach the member DECODED; an escape that does not decode is passed raw', async () => { + const member = verifierMember(); + const { dispatcher } = makeDispatcher({ 'trigger-api': { handleInboundHook: member.handleInboundHook } }); + for (const [path, hook] of [ + ['/automation/hooks/a%2Fb/h%20x%25', { flowName: 'a/b', hookId: 'h x%' }], + ['/automation/hooks/%E6%B5%81/default', { flowName: '流', hookId: 'default' }], + ['/automation/hooks/f%E0/h', { flowName: 'f%E0', hookId: 'h' }], + ] as const) { + member.calls.length = 0; + await dispatcher.dispatch('POST', path, {}, {}, ctx(post(path))); + expect(member.calls.map((c) => c.hook), path).toEqual([hook]); + } + }); +}); + +describe('[#22773] condition 1 — every other /automation path, and every other method, stays at the floor', () => { + // Measured on the base commit (12b9daf7) before any change: every one of + // these answered an anonymous caller `401 UNAUTHENTICATED` from the + // `/automation` domain's floor — and so did `POST` on the exact route. + const OTHER_PATHS = [ + '/automation', '/automation/hooks', '/automation/hooks/x', '/automation/hooks/x/y/z', + '/automation/hooks/f/h/runs', '/automation/hooks/trigger', '/automation/hooks/runs/r1/resume', + '/automation/f/trigger', '/automation/trigger/f', '/automation/f/runs/r1', '/automation/hooksx/f/h', + '/automation/hook/f/h', '/automation//hooks/f/h', '/automation/hooks//h', '/automation/hooks/f//h', + '/automation/%68ooks/f/h', '/automation/HOOKS/f/h', + ]; + + it('anonymous: every other /automation path, on every method, is the floor\'s 401 — the member never runs', async () => { + const member = verifierMember(); + const { dispatcher } = makeDispatcher({ 'trigger-api': { handleInboundHook: member.handleInboundHook } }); + for (const path of OTHER_PATHS) { + for (const method of ['POST', 'GET', 'PUT', 'PATCH', 'DELETE']) { + const request = new Request(`${ORIGIN}/api/v1${path}`, { method, ...(method === 'GET' ? {} : { body: BODY }) }); + const result = await dispatcher.dispatch(method, path, {}, {}, ctx(request)); + expect(envelope(result), `${method} ${path}`).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + } + } + expect(member.handleInboundHook).not.toHaveBeenCalled(); + }); + + it('anonymous: every non-POST on the exact route is the floor\'s 401, and the member never runs for it', async () => { + const member = verifierMember(); + const { dispatcher } = makeDispatcher({ 'trigger-api': { handleInboundHook: member.handleInboundHook } }); + for (const method of ['GET', 'HEAD', 'PUT', 'PATCH', 'DELETE']) { + const request = new Request(`${ORIGIN}/api/v1${ROUTE}`, { method }); + const result = await dispatcher.dispatch(method, ROUTE, undefined, {}, ctx(request)); + expect(envelope(result), method).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + } + expect(member.handleInboundHook).not.toHaveBeenCalled(); + }); +}); + +describe('[#22773] condition 2 — nothing sits between the allow-list and the verifier', () => { + it('no /automation gate or probe reads anything before the member: the automation and security services are never touched', async () => { + const member = verifierMember(); + const touched: string[] = []; + const tripwire = (name: string) => new Proxy({}, { + get: (_t, prop) => { + if (prop !== 'then') touched.push(`${name}.${String(prop)}`); + return undefined; + }, + }); + const { dispatcher } = makeDispatcher({ + 'trigger-api': { handleInboundHook: member.handleInboundHook }, + automation: tripwire('automation'), + security: tripwire('security'), + }); + + const result = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post())); + + expect((result.result as Response).status).toBe(202); + expect(touched).toEqual([]); + // ⭐ CONTROL: the tripwire does fire for an ordinary /automation request + // from a signed-in caller, so a silent tripwire is not why it is empty. + const authed = makeDispatcher({ automation: tripwire('automation'), auth: authAs({ id: 'u1' }) }); + await authed.dispatcher.dispatch('GET', '/automation/actions', undefined, {}, ctx(new Request(`${ORIGIN}/api/v1/automation/actions`))); + expect(touched.some((t) => t.startsWith('automation.')), touched.join(', ')).toBe(true); + }); + + it('the #5519 ordering: with neither service composed, a non-POST meets the floor\'s 401 — never a 501 that leaks what is mounted', async () => { + const { dispatcher } = makeDispatcher({}); + for (const method of ['GET', 'PUT', 'DELETE']) { + const request = new Request(`${ORIGIN}/api/v1${ROUTE}`, { method }); + const result = await dispatcher.dispatch(method, ROUTE, undefined, {}, ctx(request)); + expect(envelope(result), method).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); + } + }); +}); + +describe('[#22773] absence is a typed 501, never ROUTE_NOT_FOUND', () => { + it('no trigger-api slot on the kernel: 501 NOT_IMPLEMENTED, naming the service', async () => { + const { dispatcher } = makeDispatcher({}); + const result = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post())); + expect(envelope(result)).toEqual({ status: 501, code: 'NOT_IMPLEMENTED' }); + expect(result.response?.body?.error?.message).toContain('no trigger-api service is registered'); + expect(result.response?.body?.error?.message).toContain('@objectstack/trigger-api'); + }); + + it('a trigger-api service with no handleInboundHook member: 501 NOT_IMPLEMENTED, naming the member', async () => { + const { dispatcher } = makeDispatcher({ 'trigger-api': { somethingElse: vi.fn() } }); + const result = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post())); + expect(envelope(result)).toEqual({ status: 501, code: 'NOT_IMPLEMENTED' }); + expect(result.response?.body?.error?.message).toContain('does not implement handleInboundHook'); + }); + + it('the slot is read per request: one registered after a 501 is reached by the next request', async () => { + const services: Record = {}; + const dispatcher = new HttpDispatcher(kernelWith(services), undefined, { enforceProjectMembership: false }); + + const before = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post())); + expect(envelope(before)).toEqual({ status: 501, code: 'NOT_IMPLEMENTED' }); + + const member = verifierMember(); + services['trigger-api'] = { handleInboundHook: member.handleInboundHook }; + const after = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post())); + expect(after.response, JSON.stringify(after.response?.body)).toBeUndefined(); + expect((after.result as Response).status).toBe(202); + }); +}); + +describe('[#22773] faults are the server\'s, answered as such', () => { + it('a member that throws: a sanitised 500, its message withheld from the sender and logged', async () => { + const boom = new Error('SQLITE_ERROR: no such table: sys_flow_credential'); + const { dispatcher, logger } = makeDispatcher({ 'trigger-api': { handleInboundHook: vi.fn().mockRejectedValue(boom) } }); + + const result = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post())); + + expect(envelope(result)).toEqual({ status: 500, code: 'INTERNAL_ERROR' }); + expect(JSON.stringify(result.response?.body)).not.toContain('sys_flow_credential'); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0]![1]).toBe(boom); + }); + + it('a POST whose body the transport already consumed: 500 and logged, and the verifier is never asked', async () => { + const member = verifierMember(); + const { dispatcher, logger } = makeDispatcher({ 'trigger-api': { handleInboundHook: member.handleInboundHook } }); + const request = post(); + await request.text(); // what a transport that parsed the body off the raw request would leave + + const result = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(request)); + + expect(envelope(result)).toEqual({ status: 500, code: 'INTERNAL_ERROR' }); + expect(member.handleInboundHook).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(String(logger.error.mock.calls[0]![0])).toContain('request body was consumed'); + }); +}); + +describe('[#22773] the preamble gates are signature-only for the route, POST only', () => { + const ENV = 'env-tenant-1'; + const USER = 'u-signed-in'; + + /** + * A multi-tenant host: the resolver places every request in `ENV` on a + * tenant kernel whose session is a signed-in user (with an optional + * ADR-0069 gate posture) and whose `sys_environment_member` holds a row + * only when `member` is set. + */ + function makeTenantHost(opts: { member?: boolean; authGate?: { code: string; message: string } } = {}) { + const hook = verifierMember(); + const find = vi.fn(async (object: string) => (object === 'sys_environment_member' && opts.member + ? [{ environment_id: ENV, user_id: USER }] + : [])); + const objectql = { + find, + getObjects: vi.fn().mockReturnValue({}), + registry: { + getObject: vi.fn((name: string) => (name === 'sys_environment_member' ? { name } : null)), + getRegisteredTypes: vi.fn().mockReturnValue([]), + }, + }; + const tenant = kernelWith({ + objectql, + auth: authAs({ id: USER, ...(opts.authGate ? { authGate: opts.authGate } : {}) }), + 'trigger-api': { handleInboundHook: hook.handleInboundHook }, + }); + const resolver: KernelResolver = { + resolveKernel: async (c: HttpProtocolContext) => { + c.environmentId = ENV; + return tenant; + }, + }; + const dispatcher = new HttpDispatcher(kernelWith({}), undefined, { kernelResolver: resolver, enforceProjectMembership: true }); + const membershipReads = () => find.mock.calls.filter((call) => call[0] === 'sys_environment_member').length; + return { dispatcher, hook, membershipReads }; + } + + it('a signed-in caller who is NOT a member of the environment reaches the verifier, on both spellings — as on the self-hosted mount', async () => { + for (const path of [ROUTE, `/environments/${ENV}${ROUTE}`]) { + const { dispatcher, hook, membershipReads } = makeTenantHost(); + const result = await dispatcher.dispatch('POST', path, {}, {}, ctx(post(ROUTE))); + expect(result.response, `${path} ${JSON.stringify(result.response?.body)}`).toBeUndefined(); + expect((result.result as Response).status, path).toBe(202); + expect(hook.handleInboundHook, path).toHaveBeenCalledTimes(1); + expect(membershipReads(), path).toBe(0); + } + }); + + it('⭐ CONTROL: the same non-member stays membership-checked on every other method and every other hook-shaped path', async () => { + for (const [method, path] of [ + ['GET', '/automation/hooks/runs/r1'], // a run read of a flow named `hooks` + ['GET', ROUTE], ['DELETE', ROUTE], + ['POST', '/automation/hooks/x'], ['POST', '/automation/hooks/x/y/z'], ['POST', '/automation//hooks/f/h'], + ] as const) { + const { dispatcher, hook, membershipReads } = makeTenantHost(); + const request = new Request(`${ORIGIN}/api/v1${path}`, { method, ...(method === 'GET' ? {} : { body: BODY }) }); + const result = await dispatcher.dispatch(method, path, {}, {}, ctx(request)); + expect(envelope(result), `${method} ${path}`).toEqual({ status: 403, code: 'PROJECT_MEMBERSHIP_REQUIRED' }); + expect(membershipReads(), `${method} ${path}`).toBe(1); + expect(hook.handleInboundHook).not.toHaveBeenCalled(); + } + }); + + it('a session owing an authentication step (ADR-0069) reaches the verifier with its POST, and is gated on every other method', async () => { + const authGate = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }; + { + const { dispatcher, hook } = makeTenantHost({ member: true, authGate }); + const result = await dispatcher.dispatch('POST', ROUTE, {}, {}, ctx(post())); + expect(result.response, JSON.stringify(result.response?.body)).toBeUndefined(); + expect((result.result as Response).status).toBe(202); + expect(hook.handleInboundHook).toHaveBeenCalledTimes(1); + } + for (const method of ['GET', 'PUT', 'DELETE']) { + const { dispatcher, hook } = makeTenantHost({ member: true, authGate }); + const request = new Request(`${ORIGIN}/api/v1${ROUTE}`, { method, ...(method === 'GET' ? {} : { body: BODY }) }); + const result = await dispatcher.dispatch(method, ROUTE, {}, {}, ctx(request)); + expect(result.response?.status, method).toBe(403); + expect(result.response?.body?.error?.details?.code ?? result.response?.body?.error?.code, method).toBe('PASSWORD_EXPIRED'); + expect(hook.handleInboundHook).not.toHaveBeenCalled(); + } + }); +}); + +describe('[#22773] the allow-list row and the route agree on every spelling they are shown', () => { + // The segment-1 pointer on the card (`6106817103`): the allow-list's segment + // normalisation and this route must agree on every spelling, and a spelling + // one reader admits and the other reads differently stays gated. Each + // spelling is dispatched as the transport hands it; `admitted` is the + // allow-list's own answer for the path the gates see (the dispatcher strips + // one trailing slash first, and nothing else, before its gates run). + const SPELLINGS = [ + '/automation/hooks/f/h', '/automation/hooks/f/h/', '/automation/hooks/f/h//', '/automation/hooks/f/h?x=1', + '/automation/hooks/f%2Fx/h%20y', '/automation/hooks/hooks/automation', + '/environments/e1/automation/hooks/f/h', '/environments/e1/automation/hooks/f/h/', + '/environments/e1/automation/hooks/f%2Fx/h', + '//automation/hooks/f/h', '/automation//hooks/f/h', '/automation/hooks//f/h', '/automation/hooks/f//h', + '/automation/hooks//h', '/environments//e1/automation/hooks/f/h', '/environments/e1//automation/hooks/f/h', + '/projects/e1/automation/hooks/f/h', '/api/v1/automation/hooks/f/h', '/environments/automation/hooks/f/h', + '/environments/a/environments/b/automation/hooks/f/h', '/automation/%68ooks/f/h', '/Automation/hooks/f/h', + '/automation/hooks/f', '/automation/hooks/f/h/x', '/automation/hooks', + ]; + const gatePath = (spelling: string) => spelling.replace(/\/$/, ''); + const decoded = (raw: { flowName: string; hookId: string }) => ({ + flowName: decodeURIComponent(raw.flowName), hookId: decodeURIComponent(raw.hookId), + }); + + it('anonymous: admitted by the row ⟺ served by the route, with the same parameters; every other spelling is refused before the member', async () => { + let admittedCount = 0; + for (const spelling of SPELLINGS) { + const member = verifierMember({}); + const { dispatcher } = makeDispatcher({ 'trigger-api': { handleInboundHook: member.handleInboundHook } }); + const admitted = isAuthGateAllowlisted(gatePath(spelling), 'POST'); + const result = await dispatcher.dispatch('POST', spelling, {}, {}, ctx(post(ROUTE))); + const reached = member.handleInboundHook.mock.calls.length === 1; + expect(reached, spelling).toBe(admitted); + if (admitted) { + admittedCount++; + const raw = matchInboundHookRoute(gatePath(spelling), 'POST'); + expect(member.calls[0]!.hook, spelling).toEqual(decoded(raw!)); + } else { + // Gated: the floor's 401, or no route at all — never the member. + expect([401, 404], `${spelling} -> ${JSON.stringify(result.response?.body)}`).toContain(result.response?.status); + } + } + // Anti-vacuity: both answers occur. + expect(admittedCount).toBe(9); + expect(SPELLINGS.length - admittedCount).toBeGreaterThan(10); + }); + + it('a gated session (ADR-0069): exempted by the gate ⟺ served by the route — the gate never opens a spelling the route does not serve', async () => { + for (const spelling of SPELLINGS) { + const member = verifierMember({}); + const { dispatcher } = makeDispatcher({ + 'trigger-api': { handleInboundHook: member.handleInboundHook }, + auth: authAs({ id: 'u1', authGate: { code: 'MFA_REQUIRED', message: 'Enrol a second factor.' } }), + }); + const exempt = isAuthGateAllowlisted(gatePath(spelling), 'POST'); + const result = await dispatcher.dispatch('POST', spelling, {}, {}, ctx(post(ROUTE))); + const reached = member.handleInboundHook.mock.calls.length === 1; + expect(reached, spelling).toBe(exempt); + if (!exempt) expect(result.response?.status, spelling).toBe(403); + } + }); +}); + +describe('[#22773] CONTROL — a kernel with an http.server gets no second mount of the path', () => { + /** Records `VERB /path` for every registration the dispatcher plugin makes; mounts nothing. */ + function recordingServer() { + const routes: string[] = []; + const rec = (verb: string) => (path: string, _handler: unknown) => { routes.push(`${verb} ${path}`); }; + return { routes, server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') } }; + } + function pluginCtx(server: unknown) { + const kernel = { getService: () => undefined, getServiceAsync: async () => undefined }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? server : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; + } + /** Does a mounted `pattern` (`:param` one segment, a trailing `*` the rest) answer `path`? */ + function answers(pattern: string, path: string): boolean { + const want = pattern.split('/'); + const got = path.split('/'); + for (let i = 0; i < want.length; i++) { + if (want[i] === '*') return true; + if (i >= got.length) return false; + if (!want[i]!.startsWith(':') && want[i] !== got[i]) return false; + } + return want.length === got.length; + } + + it('the dispatcher plugin mounts nothing that answers POST /api/v1/automation/hooks/:flowName/:hookId — the trigger\'s raw-app mount is that kernel\'s door', async () => { + const { server, routes } = recordingServer(); + const plugin = createDispatcherPlugin({ securityHeaders: false }); + await plugin.start?.(pluginCtx(server)); + + // Anti-vacuity: the plugin mounted the /automation bridge at all. + expect(routes.filter((r) => r.includes('/automation')).length).toBeGreaterThan(5); + const hookPath = '/api/v1/automation/hooks/stripe_orders/default'; + const claimants = routes.filter((r) => { + const sp = r.indexOf(' '); + return ['POST', 'ALL'].includes(r.slice(0, sp)) && answers(r.slice(sp + 1), hookPath); + }); + expect(claimants, routes.filter((r) => r.includes('/automation')).join('\n')).toEqual([]); + // ⭐ The matcher itself is live: it does find the 3-segment POST door it should. + expect(routes.some((r) => r.startsWith('POST ') && answers(r.slice(5), '/api/v1/automation/f/trigger'))).toBe(true); + }); +}); diff --git a/packages/runtime/src/domains/automation-hooks.ts b/packages/runtime/src/domains/automation-hooks.ts new file mode 100644 index 00000000000..fcd9890923b --- /dev/null +++ b/packages/runtime/src/domains/automation-hooks.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST /automation/hooks/:flowName/:hookId` — the inbound automation hook, + * reached through the dispatcher (segment 2 of ruling A on #22757, director + * record `6105447950`; the `/webhooks/redeliver` and `/approvals/act` doors are + * the precedent this mirrors: one route bridges to one service slot). + * + * ## What it serves, and for whom + * + * The door a third-party sender (a payment provider, a code host) posts a + * signed event through to start an `api` flow (ADR-0041 Tier 1). A self-hosted + * kernel serves it already: `@objectstack/trigger-api`'s `ApiTriggerPlugin` + * mounts `POST /api/v1/automation/hooks/:flowName/:hookId` straight on the + * host's raw Hono app. A hosted tenant kernel owns no socket and therefore no + * raw app, so on that shape the path was the `/automation` domain's and its + * anonymous floor refused every sender `401 UNAUTHENTICATED`. This module is + * the hosted door: it forwards the request to the request kernel's + * `trigger-api` slot — the transport-neutral + * `ITriggerApiService.handleInboundHook(request, { flowName, hookId })` member — + * and returns that member's `Response` as it is. The raw-app mount is untouched + * and is not a caller; nothing here mounts the path a second time on a kernel + * that has one. + * + * ## How the route is reached: through the `/automation` domain, past its floor + * + * The `/automation` domain claims every path under `/automation`, and its first + * statement is the ADR-0056 D2 anonymous floor (`domains/automation.ts`). The + * floor asks the shared decision with its path and method, and the ADR-0069 + * allow-list's one parameterised row (`packages/core`, `auth-gate.ts`) admits + * exactly this route, `POST` only. The domain's very next statement hands the + * route here, so nothing runs between the allow-list and the verifier: not the + * run-read gate, not the authoring gate, not the `automation` service probe. + * Which paths are this route is answered ONCE, by `matchInboundHookRoute` + * (core) — the same reading the allow-list row matches through — so the floor + * that admits the route and the branch that serves it cannot disagree on any + * spelling. Every other `/automation` path, and every other method on this + * one, stays at the floor exactly as before. + * + * ## The signature is the only credential, and the member checks it + * + * No session read, no `ExecutionContext` handed on, and ⛔ no look at the + * signature header here: the member verifies the `x-objectstack-signature` + * HMAC over the raw body under the hook's own secret, in constant time, before + * anything is enqueued (ruling A, condition 2: the verifier is the boundary). + * A pre-check here would be a second verifier that could drift from the first. + * A signed-in caller is judged exactly as an anonymous one, as on the + * self-hosted mount, which has no auth gate: the ADR-0069 gate allow-lists the + * route and `HttpDispatcher.enforceProjectMembership` skips it, both through + * the same reading. + * + * ## The request is forwarded, never read + * + * The sender signed its own bytes. This module reads neither the body nor the + * parsed `body` the transport hands `dispatch()`: the member reads the raw body + * once, and the request it receives is the transport's own (`context.request`), + * which the `@objectstack/hono` catch-all leaves unread. A body the transport + * had already consumed would reach the member empty and be refused as a bad + * signature — a transport fault shown to the sender as theirs — so it is + * answered loudly here instead. + * + * ## The parameters are decoded here + * + * The matcher hands the two segments back raw, as the dispatcher's path carries + * them; the member takes them decoded, as the self-hosted mount's router hands + * them (`c.req.param()`). So this module decodes them, as the `/meta` domain + * decodes its name segments: an escape that does not decode is passed raw, + * where it names no armed hook and the member answers its ordinary refusal. + * + * ## The slot is resolved per request, never at registration + * + * The trigger's plugin wires itself at `kernel:ready` (`ApiTriggerPlugin.start` + * resolves the automation engine, the queue and the HTTP server there), so its + * service can arrive after the dispatcher is built. A lookup at registration + * would record an absence the same boot then contradicts (AGENTS.md, "Startup + * registry reads"), so the slot and its member are read on every request and + * nothing about their absence is kept. + * + * ## Absence is a typed answer, never `ROUTE_NOT_FOUND` + * + * `handleInboundHook` is optional on the contract, and the `trigger-api` slot + * is empty on a stack without the inbound-hook trigger. Either way the route IS + * mounted and what is missing is the service behind it, so the answer is + * `501 NOT_IMPLEMENTED` (the `/webhooks/redeliver` and `/approvals/act` answer + * to the same shape). `ROUTE_NOT_FOUND` would tell the sender the URL is wrong. + */ + +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; +import type { InboundHookRouteMatch } from '@objectstack/core'; +import type { ITriggerApiService } from '@objectstack/spec/contracts'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; + +/** The slot the member lives on — the trigger package's own name (ADR-0041 §2). */ +const TRIGGER_API_SLOT = 'trigger-api'; + +/** + * One raw path segment, decoded as the self-hosted router decodes a parameter. + * An escape that does not decode is kept raw rather than thrown on: a malformed + * segment names no armed hook, and the member gives it its ordinary answer. + */ +function decodeHookSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +/** + * Forward the request to the request kernel's `trigger-api.handleInboundHook` + * and return its `Response` untouched. `hook` is the route the caller matched, + * raw — see the module note on decoding. + */ +export async function handleInboundHookRequest( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + hook: InboundHookRouteMatch, +): Promise { + const service = await deps.resolveService(context, TRIGGER_API_SLOT, context.environmentId) as + Pick | undefined; + if (!service) { + return { + handled: true, + response: deps.error( + 'Inbound automation hooks are not available: no trigger-api service is registered for this ' + + 'environment. The inbound-hook trigger (@objectstack/trigger-api) serves this route through that ' + + 'service; compose it on the environment that owns the flow.', + 501, + ), + }; + } + if (typeof service.handleInboundHook !== 'function') { + return { + handled: true, + response: deps.error( + 'Inbound automation hooks are not available: the trigger-api service registered for this ' + + 'environment does not implement handleInboundHook.', + 501, + ), + }; + } + + const request = context.request as Request; + const logger = deps.logger ?? console; + + // The member verifies its signature over the raw body. A body the + // transport already read cannot be read again, so the member would refuse + // a correctly signed post as unsigned — a transport fault shown to the + // sender as their own. It is a server fault, answered as one and logged. + if (request?.bodyUsed === true) { + logger?.error?.( + '[automation-hooks] the request body was consumed before the inbound-hook route could forward it: ' + + 'the HTTP transport must hand the dispatcher the request with its body unread; the sender was ' + + 'answered 500 rather than the signature refusal the trigger would have given', + ); + return { handled: true, response: deps.error(INTERNAL_ERROR_MESSAGE, 500) }; + } + + const params = Object.freeze({ + flowName: decodeHookSegment(hook.flowName), + hookId: decodeHookSegment(hook.hookId), + }); + try { + return { handled: true, result: await service.handleInboundHook(request, params) }; + } catch (err) { + // As `/webhooks/redeliver` and `/approvals/act`: the member owns the + // reads and every answer it means to give, so a throw out of it is + // unattributable here and its message never reaches the sender. The + // original error goes to the server log. + logger?.error?.( + '[automation-hooks] the trigger-api service threw while serving an inbound hook; the sender was ' + + 'answered with a sanitised 500: the message is withheld unconditionally, and this line is where ' + + 'the original error is read', + err instanceof Error ? err : new Error(String(err)), + ); + return { handled: true, response: deps.error(INTERNAL_ERROR_MESSAGE, 500) }; + } +} diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index cf6bb5582ac..f5fee112bf9 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -12,7 +12,10 @@ import { shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, + matchInboundHookRoute, } from '@objectstack/core'; +// [#22773] The inbound-hook door, handed the one route the floor admits. +import { handleInboundHookRequest } from './automation-hooks.js'; // [ADR-0126 §5] The shared activation write-authority gate — one // implementation, one refusal envelope, per-door wording. See its header for // the posture rule and the measurement behind it (commit 02b41232d). @@ -2276,15 +2279,35 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // metadata-declared `type: 'flow'` endpoint keeps its own policy chain and // is untouched here. Internal engine triggers (record-change, schedule) // never speak HTTP at all. + // + // [#22773] The floor reads the shared allow-list with this route's path and + // method (ruling A on #22757). Its one parameterised row admits the inbound + // hook — `POST /automation/hooks/:flowName/:hookId`, exactly four segments + // — and no other `/automation` path or method, so for every other request + // this decision is the one it always was. The admitted route is handed off + // on the very next statement, below. + const routePath = `/automation${path === '' || path.startsWith('/') ? '' : '/'}${path}`; { const ec: any = (context as any)?.executionContext; - if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem, method })) { + if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem, method, path: routePath })) { return { handled: true, response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), }; } } + // [#22773] THE INBOUND HOOK — the route the floor above admits with no + // session, served by the trigger's own HMAC verifier and by nothing in this + // domain: it is handed off BEFORE the run-read gate, the authoring gate and + // the `automation` service probe below, so nothing sits between the + // allow-list and the verifier (ruling A, condition 2). One reading of the + // route, core's `matchInboundHookRoute`, is the one the allow-list row + // matches through, so the floor and this branch agree on every spelling. + // See `./automation-hooks.ts`. + { + const hook = matchInboundHookRoute(routePath, method); + if (hook) return handleInboundHookRequest(deps, context, hook); + } const m = method.toUpperCase(); const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index df1df5c3bc4..50cc85f0081 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2,6 +2,9 @@ import { ObjectKernel, getEnv, evaluateAuthGate, isAuthGateAllowlisted, + // [#22773] The inbound hook's one reading — the allow-list row's — read by + // the membership gate's skip below. + matchInboundHookRoute, // [#13906 decision 1 A] The identity step's net re-raises ONLY the loud // authz-store outage (the `.catch` shape `@objectstack/core` prescribes for // every seam between `resolveAuthzContext` and a door), and the tenancy @@ -1351,10 +1354,14 @@ export class HttpDispatcher { * an auth-policy gate (expired password / required MFA) on a non-allow-listed * path, else null. Mirrors the REST `enforceAuth` seam so REST + dispatcher * (MCP) enforce consistently. Fails open on any lookup error. + * + * [#22773] `method` is the request's: the allow-list's parameterised row + * (the inbound automation hook) is exempt for `POST` only, and without a + * method it exempts nothing. */ - private async enforceAuthGate(context: any, cleanPath: string): Promise { + private async enforceAuthGate(context: any, cleanPath: string, method?: string): Promise { try { - if (isAuthGateAllowlisted(cleanPath)) return null; + if (isAuthGateAllowlisted(cleanPath, method)) return null; // [#4127 FINDING, batch 5] // `auth` IS contracted, so this `any` is not legitimate. // `isAuthGateActive` is implemented (auth-manager.ts) and load-bearing @@ -1386,7 +1393,7 @@ export class HttpDispatcher { // a cookie request reads without renewal, because this door's // response never carries a renewed cookie. const session: any = await api.getSession(inProcessSessionReadInput(headers)).catch(() => undefined); - const gate = evaluateAuthGate(session?.user, cleanPath); + const gate = evaluateAuthGate(session?.user, cleanPath, method); if (!gate) return null; return this.error(gate.message, 403, { code: gate.code }); } catch { @@ -1397,6 +1404,7 @@ export class HttpDispatcher { private async enforceProjectMembership( context: HttpProtocolContext, path: string, + method?: string, ): Promise<{ status: number; body: any } | null> { if (!this.enforceMembership) return null; @@ -1453,6 +1461,23 @@ export class HttpDispatcher { // wider. Pinned in `domains/approvals-act.test.ts`. if (isApprovalsActPath(path)) return null; + // The inbound automation hook — the HMAC signature IS the authorisation + // (#22773; ruling A on #22757, "equal to self-hosted"): the trigger-api + // service verifies it, and a signed-in caller is judged exactly as an + // anonymous one, as on the self-hosted raw-app mount, which has no such + // gate. Without this line a signed-in non-member's post would stop here + // with `403 PROJECT_MEMBERSHIP_REQUIRED` before the verifier, so + // something would sit between the allow-list and the verifier. + // + // ⛔ EXACTLY the route, `POST` only, read by the one reading the + // ADR-0069 allow-list row matches through (core's + // `matchInboundHookRoute`, which also reads the environment-scoped + // spelling this gate sees). Every other method on the same path is an + // ordinary `/automation` request — `GET /automation/hooks/runs/:runId` + // reads a run of a flow named `hooks` — and stays checked. Pinned in + // `domains/automation-hooks.test.ts`. + if (matchInboundHookRoute(path, method)) return null; + const environmentId = context.environmentId; if (!environmentId) return null; // Unscoped legacy routes fall through. @@ -2690,8 +2715,9 @@ export class HttpDispatcher { // Block a gated session (expired password / required MFA) from // protected MCP/data routes, mirroring the REST seam. The core // allow-list keeps auth + remediation reachable. Skipped (no session - // lookup) when no gate feature is active. - const authGated = await this.enforceAuthGate(context, cleanPath); + // lookup) when no gate feature is active. [#22773] The method rides + // along: the allow-list's inbound-hook row is exempt for `POST` only. + const authGated = await this.enforceAuthGate(context, cleanPath, method); if (authGated) { return { handled: true, response: authGated }; } @@ -2700,7 +2726,7 @@ export class HttpDispatcher { // Once the environmentId is known, gate scoped data/meta/AI/automation // routes on `sys_environment_member`. Control-plane paths, the system // project, and platform-org members bypass this check. - const forbidden = await this.enforceProjectMembership(context, cleanPath); + const forbidden = await this.enforceProjectMembership(context, cleanPath, method); if (forbidden) { return { handled: true, response: forbidden }; } diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 28d9c23a594..fa086702757 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -277,7 +277,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [ /** * The ledger. * - * CENSUS (generated): this list holds 86 rows. + * CENSUS (generated): this list holds 87 rows. * * ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand. * `pnpm check:route-ledger-census` counts the rows below and fails when the two @@ -484,6 +484,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: "authored metadata, so `manage_metadata` gates it — the same `isFlowAuthoringWrite` door as `POST /automation` above: fail-closed on an absent executionContext, an absent `systemPermissions` or an empty one, refusing 403 `PERMISSION_DENIED`, with only `isSystem` bypassing" }, { route: 'DELETE /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.delete', note: "authored metadata, so `manage_metadata` gates it — the same `isFlowAuthoringWrite` door as `POST /automation` above, and the destructive member of the family: before the gate a tenant org owner WITHOUT the capability deregistered a registered flow, 200, and flow metadata is registered at ENVIRONMENT scope so the write crossed the tenant wall. Fail-closed on an absent executionContext, an absent `systemPermissions` or an empty one, refusing 403 `PERMISSION_DENIED`, with only `isSystem` bypassing" }, + // [#22773] The inbound hook's hosted door (segment 2 of ruling A on #22757). + { route: 'POST /automation/hooks/:flowName/:hookId', domain: '/automation', disposition: 'public', + note: 'the inbound hook a third-party sender posts a signed event through to start an `api` flow (ADR-0041 Tier 1). Session-less by design: the HMAC signature over the raw body, under the hook\'s own secret, is the only credential, and the trigger-api slot\'s ITriggerApiService.handleInboundHook verifies it before anything is enqueued. The ONE route under /automation the domain\'s anonymous floor admits: the ADR-0069 allow-list\'s one parameterised row (core auth-gate.ts, exactly these four segments, POST only) is read by the floor, the ADR-0069 gate and the membership gate\'s skip through one reading, and the domain hands the route to `domains/automation-hooks.ts` on the statement after the floor, ahead of every other /automation gate. Forwarded with its body UNREAD, the two path parameters decoded, the slot resolved per request, and the member\'s Response returned as it is. An absent slot or member answers 501 NOT_IMPLEMENTED, never ROUTE_NOT_FOUND; a body consumed before it got here is a transport fault, answered 500 and logged. Every other method on the path stays an ordinary /automation request at the floor. This is the door for a kernel with no raw app; a self-hosted kernel serves the same path from the trigger\'s raw-app mount, which never passes through the dispatcher. NOT JS-SDK surface: a sender is a third-party service, not a client of this SDK. Pinned in `domains/automation-hooks.test.ts`' }, // ── auth (better-auth passthrough) ──────────────────────────────────────── { route: '* /auth/**', domain: '/auth', disposition: 'sdk', client: 'auth.me', diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 305d5c82595..72351edbdf3 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -477,6 +477,12 @@ const DISPATCHER_DOMAINS = { // `deps.error`. 'approvals.ts': { handBuilt: 0 }, 'automation.ts': { handBuilt: 0 }, + // [#22773] The inbound-hook door the `/automation` domain hands its one + // admitted route to: the trigger-api member's own JSON `Response` is handed + // back as `result` (the `/webhooks/redeliver` shape), and every refusal it + // makes itself — the two 501s, the sanitised 500s — goes through + // `deps.error`. + 'automation-hooks.ts': { handBuilt: 0 }, // The `/security` domain's catalog activation door (ADR-0126 §3 regime C, // ADR-0131 D6), split out of `security.ts` beside the gate it calls. Every // answer it gives goes through `deps.success` / `deps.error`.