Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/22773-automation-hooks-domain.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions packages/core/src/security/anonymous-deny.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/security/anonymous-deny.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
214 changes: 213 additions & 1 deletion packages/core/src/security/auth-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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/<id>/automation/hooks/<f>/<h>`.
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<string>();
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();
Expand Down
Loading
Loading