Skip to content

Commit fe58bbc

Browse files
feat(core,runtime): the inbound automation hook through the dispatcher — one parameterised POST allow-list row, honoured by the /automation floor, forwarded to the trigger-api verifier (#22864)
Fixes #22773 Clause-②: yes Segment 2 of the trigger-api half of ruling A on #22757 (director record `6105447950`, maintainer 「同意」): on the hosted shape, a kernel with no `http.server`, the inbound hook `POST /api/v1/automation/hooks/:flowName/:hookId` now admits an anonymous sender through to the trigger's own HMAC verifier, as the self-hosted raw-app mount does. Segment 1 (#22772, PR #22779) declared the member `ITriggerApiService.handleInboundHook` on slot `trigger-api`; the member's implementation is #22774, which is not on `main` yet. #22757 remains open. ## What changed **`@objectstack/core`** (`minor`, Clause-② widening) - `security/auth-gate.ts`: the allow-list's first PARAMETERISED row, `POST /automation/hooks/:flowName/:hookId`. It matches exactly four segments in the dispatcher's spelling, optionally under one `/environments/:id` scope (the one spelling the dispatcher's scope strip removes), `POST` only. No mount base, no retired `projects` scope, no spelling with an empty segment, no percent-decoding, literals case-sensitive. The segment count is pinned by the row's own length. - `isAuthGateAllowlisted(path, method?)` and `evaluateAuthGate(user, path, method?)` take an optional method. The six existing rows ignore it, so every caller that passes none is answered exactly as before. The new row needs it: no method, no exemption on that row. - New export `matchInboundHookRoute(path, method)`: the ONE reading of the route. The row matches through it, and the dispatcher's floor, branch and membership skip ask it too, so the reader that admits the route and the reader that serves it cannot disagree on any spelling. It returns the two segments raw. - `security/anonymous-deny.ts`: `shouldDenyAnonymous` passes its `method` to the allow-list beside its `path`. Its pathless guard is unchanged and still decides first. **`@objectstack/runtime`** (`minor`) - `domains/automation.ts`: the `/automation` domain's anonymous floor (ADR-0056 D2, the #5519 floor) now asks the shared decision with its route path and method, so the row is honoured BY THE FLOOR. The very next statement hands the admitted route to the new module, ahead of the run-read gate, the authoring gate and the `automation` service probe. For every other `/automation` path and method the floor's decision is the one it always was. - `domains/automation-hooks.ts` (new): resolves the request kernel's `trigger-api` slot per request and forwards `context.request` with its body UNREAD. It hands the two parameters decoded (an escape that does not decode is passed raw, as the `/meta` domain does) and returns the member's `Response` as it is. An absent slot or member answers `501 NOT_IMPLEMENTED`, never `ROUTE_NOT_FOUND`. A member that throws, or a body a transport already consumed, answers a sanitised `500`, logged. It adds no credential rule and no signature check of its own: the member is the one verifier. - `http-dispatcher.ts`: the ADR-0069 gate passes the request's method to the allow-list. The membership gate skips the route for `POST` only, through the same reading, so a signed-in caller is judged exactly as an anonymous one (the `/approvals/act` precedent). `GET /automation/hooks/runs/:runId` is a run read of a flow named `hooks`, and stays checked. - `route-ledger.ts`: one `public` row, `POST /automation/hooks/:flowName/:hookId`, domain `/automation`; census 86 to 87. **Bookkeeping (no published surface)**: `scripts/check-route-envelope.mjs` declares `automation-hooks.ts` with `handBuilt: 0`. The dogfood probe census moves 86 to 87 rows (an existing domain key, so `keys` stays 23). The `anonymous-deny-automation` matrix row's note names the one admitted route. The showcase live-mount parity gate pins the row as served by the trigger's raw-app mount self-hosted, with nothing on the port answering it. **Landing point, measured.** The registry is first-match and `/automation` claims its whole subtree (`match: 'segment'`), and its floor is the domain's first statement. So a separately registered route needs a parameterised claim the registry cannot express, and it would leave the allow-list row unread by any anonymous floor. The route therefore lives inside the `/automation` domain, right after its floor. `domains/automation.ts` is in the claim's surface for exactly this case ("only if the existing /automation domain must yield this exact route"). `domain-handler-registry.ts` is untouched. ## Ruling A's three conditions, as measured 1. **Exactness.** Pinned in `core/security/auth-gate.test.ts`: PIN 1 to 4 and a BOUNDARY sweep. The sweep covers 133,332 paths times six methods, method absent included, against a transcription of the pre-row predicate from `12b9daf7`. The set of (path, method) answers that moved is EXACTLY the hook route, POST in either case, unscoped or under one `/environments/:id` scope. Every move is newly exempt, none newly gated. `/automation/hooks`, `/automation/hooks/x`, `/automation/hooks/x/y/z` and every other `/automation` path stay gated, and on the wire every one of them still answers an anonymous caller the floor's `401 UNAUTHENTICATED` (the answer each gave on the base commit, measured before any change). 2. **The verifier is the boundary.** Here is what sits between the allow-list and the verifier on the hosted shape (`createHonoApp` catch-all, then `HttpDispatcher.dispatch`): - the catch-all parses a CLONE, so the raw request reaches the dispatcher unread; - the identity step (`resolveRequestScope`) resolves an anonymous sender to no user and refuses nothing. Its one refusal, an authz-store outage `503`, is an infrastructure fault, not a gate on this route; - the ADR-0069 gate exempts the POST through the row; - the membership gate skips the POST through the same reading; - the scope strip runs, the `/automation` domain is claimed, and the floor admits the POST through the row; - the next statement forwards to the member. A tripwire pin proves no `/automation` gate or probe reads anything first: the `automation` and `security` services are never touched, with a control showing the tripwire fires on an ordinary request. A non-`POST` meets the floor's refusal before any route shape leaks: `401` even with neither service composed, never a `501`. An unsigned POST reaches the verifier, whose one refusal (#22806) does not tell an armed flow from an unarmed one. 3. **Replay protection** is not read here, as the ruling directs. (Its own card has since landed core's timestamped scheme, #22803. Nothing here depends on it.) ## The PM's mechanism assumptions, as measured 1. **Held.** `ITriggerApiService.handleInboundHook?` is on `main` under slot `trigger-api`, and its docblock names this domain as its one caller. 2. **Held.** The `/webhooks/redeliver` sibling is the shape for per-request slot resolution, typed absence, sanitised faults and the no-double-mount control. 3. **Measured, and it decided the landing point.** On `12b9daf7`, on a dispatcher-only kernel, the `/automation` domain claims `/automation/hooks/f/h`. The anonymous floor runs INSIDE that domain, as its first statement: an anonymous `POST` answered `401 UNAUTHENTICATED`, and a signed-in `POST` fell through unhandled. 4. **The method.** The matcher took a path only. The narrowest seam is an OPTIONAL trailing `method` on `isAuthGateAllowlisted` / `evaluateAuthGate`, read by parameterised rows only. Every existing caller (REST `enforceAuth`, the dispatcher gate, `shouldDenyAnonymous`) is unchanged byte for byte when it passes none. `shouldDenyAnonymous` already carried `method`, and now forwards it. 5. **#22774 is not on `main`** (`origin/main` `55382dc02`: `trigger-api` registers no `trigger-api` service yet). The pins use a member double whose verifier is core's real `verifyHttpSignature`, the function `ApiTrigger.handleRequest` verifies with. **The end-to-end leg on the real member is owed by whichever of #22773 / #22774 lands second** (triage unlock `6107344979`). ## Pins - `packages/runtime/src/domains/automation-hooks.test.ts` (19), on the real `HttpDispatcher`, a kernel with no `http.server`: - a correctly signed anonymous POST reaches the verifier and is accepted (`202`), with its own request, the spaced body byte-exact, and the parameters decoded; - a wrongly signed, body-tampered, unsigned, malformed, unknown-flow or wrong-hook POST is refused BY THE VERIFIER, which runs every time, and its `401 INVALID_SIGNATURE` passes through; - a signed-in caller is judged as an anonymous one; - condition 1 on the wire, the tripwire and the #5519 ordering; - the typed `501`s, read per request, and the sanitised `500`s; - the gates: a signed-in non-member reaches the verifier on both spellings, and the same caller stays membership-checked on every other method and hook-shaped path. A session owing an authentication step passes with its POST and is gated on every other method; - the pointer's extra self-test (`6106817103`): over 25 spellings (trailing slashes, query, encoded parameters, empty segments anywhere, empty scope id, `projects` scope, doubled prefix, two scopes, an encoded literal, case, wrong counts), admitted by the row if and only if served by the route with the same decoded parameters, for an anonymous caller and for a gated session. Every other spelling is refused before the member; - CONTROL: on a kernel with an `http.server`, the dispatcher plugin mounts no route that answers `POST /api/v1/automation/hooks/:flowName/:hookId`, with a matcher control. The trigger's raw-app mount stays that kernel's one door: `dispatcher-plugin.ts` is untouched, and the showcase parity gate re-checks it on a real self-hosted boot. - `packages/core/src/security/auth-gate.test.ts` (+7) and `anonymous-deny.test.ts` (+1). ## Ablations Each was one-off, run from committed state, through `scripts/ablation-replace.mjs` in WRAP mode. Each mutation landed (anchor count 1 to 0, blob changed) and each restore was proven: blob equal to HEAD, `git diff HEAD` empty, `git status --porcelain` clean afterwards. The runtime suite resolves `@objectstack/core` to `src` through its vitest alias and imports the runtime by relative path, so no `dist/` sits on these paths. | mutation | result | |---|---| | the allow-list row removed | runtime pins 11 of 19 red: every anonymous reach answers the floor's 401, so the row is what admits the sender | | the branch after the floor removed | 14 of 19 red | | the membership skip removed | 1 of 19 red: the non-member reach pin | | the method dropped from the ADR-0069 gate call | 2 of 19 red: both gated-session pins | | the floor's path argument dropped | 10 of 19 red | | the core method check removed | core 5 of 43 red: PIN 2, the reading, `evaluateAuthGate`, BOUNDARY, `shouldDenyAnonymous` | | the empty-segment refusal replaced by a drop-empty reading | core PIN 4 and the reading red; runtime 4 of 19 red, the spelling-agreement pins among them (`//automation/hooks/f/h` would be admitted by the row and served by nothing) | ## Verification (at `11fac6c955`, after `git merge origin/main`, `pnpm install --frozen-lockfile` and a full `turbo run build`, 73 of 73) - `pnpm --filter @objectstack/core test`: 94 files, 2379 passed. `pnpm --filter @objectstack/runtime test`: 351 files, 5070 passed, 19 skipped. Both packages' `typecheck`, test layer included: exit 0. - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/authz-conformance.test.ts test/authz-probe-blind-spot.test.ts test/route-ledger-live-mount-parity.dogfood.test.ts`: 3 files, 100 passed (the parity gate booted the real showcase). - The 92 commands `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives on this tree all exit 0, `pnpm check:pm-dispatch-gates` and `pnpm check:dual-build-cjs-loads` included. `--ran` answers "92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN". `node scripts/check-plugin-teardown-shape.mjs --self-test` first answered exit 3 (its pinned fixture commit was outside this shallow clone, not a measurement), and after `git fetch origin 621a487` it answered exit 0, 48 cases. - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 14 touched lintable files reads 14 results, 0 errors, 0 warnings. `ESLint.isPathIgnored` says all 14 are in the config's population. The config enables no type-aware linting and reads only `scripts/slot-lookup-baseline.json` and `scripts/query-options-erasure-baseline.json` at load, neither touched, so this diff cannot move any untouched file's verdict. - `packages/qa/http-conformance` is untouched: the end-to-end leg on the real member is the second lander's (above). ## Acceptance notes - **An open question for #22757, not acted on.** On a kernel with no `trigger-api` service, an anonymous unsigned POST to the exact route answers the contract's `501 NOT_IMPLEMENTED`, while one with the service answers the verifier's `401`. So an anonymous caller can tell whether a deployment composes the inbound-hook trigger. That is the answer the contract declares (`ITriggerApiService` docblock: "a typed 404 or 501"). If the #5519 ordering is meant to cover service presence on this route too, that is a contract question for #22757, not a change for this card. - The member double in the runtime pins verifies with core's real `verifyHttpSignature`. It is not #22774's member, which does not exist on `main` yet. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 69d4218 commit fe58bbc

15 files changed

Lines changed: 1196 additions & 21 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/core': minor
4+
---
5+
6+
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
7+
8+
Clause-②: yes
9+
10+
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`.
11+
12+
**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.
13+
14+
- `@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.
15+
- `@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.
16+
- 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.
17+
18+
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.

‎packages/core/src/security/anonymous-deny.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567
7272
expect(shouldDenyAnonymous({ method: 'OPTIONS', path: '' })).toBe(false);
7373
});
7474

75+
// [#22773] The allow-list's parameterised row (the inbound automation hook)
76+
// is honoured here only with the method beside the path, and only for POST:
77+
// the `/automation` floor passes both, and a seam that passes neither is
78+
// answered exactly as before.
79+
it('honours the inbound-hook row for POST on the exact route only, and only with the method', () => {
80+
expect(shouldDenyAnonymous({ path: '/automation/hooks/f/h', method: 'POST' })).toBe(false);
81+
expect(shouldDenyAnonymous({ path: '/environments/env_1/automation/hooks/f/h', method: 'post' })).toBe(false);
82+
for (const method of ['GET', 'PUT', 'DELETE', 'HEAD', undefined, null]) {
83+
expect(shouldDenyAnonymous({ path: '/automation/hooks/f/h', method }), String(method)).toBe(true);
84+
}
85+
for (const path of ['/automation/hooks', '/automation/hooks/x', '/automation/hooks/x/y/z', '/automation/f/trigger', '/automation']) {
86+
expect(shouldDenyAnonymous({ path, method: 'POST' }), path).toBe(true);
87+
}
88+
// ⭐ The pathless guard still decides first: a method alone exempts nothing.
89+
expect(shouldDenyAnonymous({ method: 'POST' })).toBe(true);
90+
expect(shouldDenyAnonymous({ path: '', method: 'POST' })).toBe(true);
91+
});
92+
7593
it('exposes a stable 401 body + status for seams to return', () => {
7694
expect(ANONYMOUS_DENY_STATUS).toBe(401);
7795
// [#9487] `code` carries the machine code — the documented key every other

‎packages/core/src/security/anonymous-deny.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@
5353
* from the form view's own declaration;
5454
* - share links → the capability token, validated then read as SYSTEM;
5555
* - a `book.audience: 'public'` read → the ADR-0046 §6.7 audience gate (#3963);
56+
* - an inbound automation hook (`POST /automation/hooks/:flowName/:hookId`) →
57+
* the HMAC signature under the hook's own secret, verified by the
58+
* trigger-api service; the allow-list's one parameterised row admits that
59+
* exact route, `POST` only, past the `/automation` floor (#22773);
5660
* - MCP → an OAuth token or API key, never anonymous.
5761
*
5862
* Those run UPSTREAM of this function and set the execution context (a `userId`,
@@ -141,6 +145,10 @@ export interface AnonymousDenyInput {
141145
* `undefined`, which this seam DENIES — see the guard below, which is this
142146
* seam's own contract for the pathless case (#7898) rather than a reading of
143147
* what the allow-list predicate does with a falsy argument.
148+
*
149+
* [#22773] The allow-list reads {@link method} beside it: its one
150+
* parameterised row, the inbound automation hook, is exempt for `POST` only,
151+
* so a seam that passes the path for that row must pass the method too.
144152
*/
145153
path?: string | null;
146154
}
@@ -169,7 +177,7 @@ export function shouldDenyAnonymous(input: AnonymousDenyInput): boolean {
169177
// `true` before #7898). The redundancy is deliberate: this seam's contract
170178
// must not be re-derived from what the predicate happens to do with a falsy
171179
// argument, which is exactly how the hole #2567 closes was reopened once.
172-
if (typeof input.path === 'string' && input.path.length > 0 && isAuthGateAllowlisted(input.path)) {
180+
if (typeof input.path === 'string' && input.path.length > 0 && isAuthGateAllowlisted(input.path, input.method)) {
173181
return false;
174182
}
175183
return true;

‎packages/core/src/security/auth-gate.test.ts‎

Lines changed: 213 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
import { describe, it, expect } from 'vitest';
3-
import { isAuthGateAllowlisted, evaluateAuthGate, normalizeAuthGate } from './auth-gate';
3+
import { isAuthGateAllowlisted, evaluateAuthGate, normalizeAuthGate, matchInboundHookRoute } from './auth-gate';
44

55
describe('auth-gate (ADR-0069 session gate)', () => {
66
describe('isAuthGateAllowlisted', () => {
@@ -443,6 +443,218 @@ describe('auth-gate (ADR-0069 session gate)', () => {
443443
});
444444
});
445445

446+
// ── [#22773] The allow-list's first PARAMETERISED row ───────────────────
447+
//
448+
// Ruling A on #22757 (director record `6105447950`): the inbound automation
449+
// hook is admitted with no session, through to the trigger's own HMAC
450+
// verifier, by ONE route-exact row in the dispatcher's spelling. Condition 1
451+
// binds its shape: exactly four segments (`automation`, `hooks`, one flow
452+
// name, one hook id), `POST` only, no prefix and no wildcard beyond the two
453+
// parameters — and `/automation/hooks`, `/automation/hooks/x`,
454+
// `/automation/hooks/x/y/z` and every other `/automation` path still gated.
455+
//
456+
// ⛔ These pin the DECISION per (path, method), never the entry's spelling.
457+
describe('[#22773] the inbound hook is exempt as the exact four-segment POST route, and nothing wider', () => {
458+
it('PIN 1 — exempts POST on the exact route, unscoped or under one /environments/:id scope', () => {
459+
for (const p of [
460+
'/automation/hooks/stripe_orders/default', // the dispatcher spelling: the hono adapter strips the app prefix
461+
'/automation/hooks/f/h',
462+
'/automation/hooks/f/h/', // trailing slashes stripped, as for every row
463+
'/automation/hooks/f/h//',
464+
'/automation/hooks/f/h?x=1', // query stripped, as for every row
465+
'/automation/hooks/f%2Fx/h%20y', // parameters are any one segment, raw
466+
'/automation/hooks/hooks/automation', // a parameter may spell a literal
467+
'/environments/env_1/automation/hooks/f/h', // scoped: the dispatcher gates BEFORE its scoped-URL strip
468+
]) {
469+
expect(isAuthGateAllowlisted(p, 'POST'), p).toBe(true);
470+
expect(isAuthGateAllowlisted(p, 'post'), `${p} (method case)`).toBe(true);
471+
}
472+
});
473+
474+
it('PIN 2 — the method: POST only, and no method exempts nothing on this row', () => {
475+
const p = '/automation/hooks/f/h';
476+
for (const method of ['GET', 'HEAD', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'CONNECT', 'TRACE', 'POSTX', '']) {
477+
expect(isAuthGateAllowlisted(p, method), method).toBe(false);
478+
}
479+
expect(isAuthGateAllowlisted(p)).toBe(false);
480+
expect(isAuthGateAllowlisted(p, undefined)).toBe(false);
481+
expect(isAuthGateAllowlisted(p, null)).toBe(false);
482+
// ⭐ CONTROL: the rows that were exempt for every method still are, with
483+
// a method or without one.
484+
for (const method of [undefined, 'GET', 'POST', 'DELETE']) {
485+
expect(isAuthGateAllowlisted('/approvals/act', method), String(method)).toBe(true);
486+
expect(isAuthGateAllowlisted('/api/v1/auth/sign-out', method), String(method)).toBe(true);
487+
}
488+
});
489+
490+
it('PIN 3 — condition 1: every other segment count, and every other /automation path, stays gated', () => {
491+
for (const p of [
492+
// the card's own three, and the bare domain
493+
'/automation/hooks', '/automation/hooks/x', '/automation/hooks/x/y/z', '/automation',
494+
'/automation/hooks/x/y/z/w', '/automation/hooks/f/h/runs/r1/resume',
495+
// the domain's real routes, at every depth
496+
'/automation/trigger/f', '/automation/f/trigger', '/automation/f/toggle', '/automation/f/clone',
497+
'/automation/f/runs', '/automation/f/runs/r1', '/automation/f/runs/r1/resume',
498+
'/automation/hooks/trigger', '/automation/hooks/runs/r1/cancel',
499+
// a sibling literal in either fixed position
500+
'/automation/hooksx/f/h', '/automation/hook/f/h', '/automation/webhooks/f/h', '/automationx/hooks/f/h',
501+
'/automation/f/hooks/h', '/hooks/automation/f/h',
502+
// literals are matched unencoded and case-sensitively, as the router matches them
503+
'/Automation/hooks/f/h', '/automation/HOOKS/f/h', '/automation/%68ooks/f/h', '/%61utomation/hooks/f/h',
504+
'/automation/hooks%2Ff/h',
505+
]) {
506+
expect(isAuthGateAllowlisted(p, 'POST'), p).toBe(false);
507+
}
508+
});
509+
510+
it('PIN 4 — the dispatcher spelling only: no mount base, no retired scope, no empty segment', () => {
511+
for (const p of [
512+
// a mount base: the route exists on the dispatcher alone, whose path
513+
// arrives base-stripped, so a based spelling there is a doubled prefix
514+
// the router serves nothing at
515+
'/api/v1/automation/hooks/f/h', '/api/automation/hooks/f/h', '/api/v1/environments/env_1/automation/hooks/f/h',
516+
// the retired `projects` scope, which the dispatcher's scope strip does not remove
517+
'/projects/env_1/automation/hooks/f/h',
518+
// the route below a non-mount segment, whose value a tenant controls
519+
'/data/automation/hooks/f/h', '/meta/automation/hooks/f/h', '/x/automation/hooks/f/h',
520+
// two scopes, or a scope with no id (the id is `automation`)
521+
'/environments/a/environments/b/automation/hooks/f/h', '/environments/automation/hooks/f/h',
522+
// an empty segment anywhere: the scope strip and the domain claim do not drop it
523+
'//automation/hooks/f/h', '/automation//hooks/f/h', '/automation/hooks//h', '/automation/hooks/f//h',
524+
'/automation/hooks//f/h', '/environments//env_1/automation/hooks/f/h', '/environments/env_1//automation/hooks/f/h',
525+
// not a path at all
526+
'automation/hooks/f/h', '', '/',
527+
]) {
528+
expect(isAuthGateAllowlisted(p, 'POST'), p).toBe(false);
529+
}
530+
});
531+
532+
it('matchInboundHookRoute: the one reading, its parameters raw — and null wherever the row is', () => {
533+
expect(matchInboundHookRoute('/automation/hooks/f/h', 'POST')).toEqual({ flowName: 'f', hookId: 'h' });
534+
expect(matchInboundHookRoute('/automation/hooks/f%2Fx/h%20y', 'POST')).toEqual({ flowName: 'f%2Fx', hookId: 'h%20y' });
535+
expect(matchInboundHookRoute('/environments/env_1/automation/hooks/f/h/?q=1', 'post')).toEqual({ flowName: 'f', hookId: 'h' });
536+
for (const [p, m] of [
537+
['/automation/hooks/f/h', 'GET'], ['/automation/hooks/f/h', undefined], ['/automation/hooks/f', 'POST'],
538+
['/automation//hooks/f/h', 'POST'], ['/api/v1/automation/hooks/f/h', 'POST'], [undefined, 'POST'],
539+
] as const) {
540+
expect(matchInboundHookRoute(p, m), `${p} ${m}`).toBeNull();
541+
}
542+
// The row and the reading are one: they answer the same on every spelling PIN 1-4 name.
543+
for (const p of ['/automation/hooks/f/h', '/automation/hooks/x', '//automation/hooks/f/h', '/projects/e/automation/hooks/f/h']) {
544+
for (const m of ['POST', 'GET', undefined]) {
545+
expect(isAuthGateAllowlisted(p, m), `${p} ${m}`).toBe(matchInboundHookRoute(p, m) !== null);
546+
}
547+
}
548+
});
549+
550+
it('carries through `evaluateAuthGate`: a gated session passes the POST and stays gated on every other method', () => {
551+
const gated = { id: 'u1', authGate: { code: 'MFA_REQUIRED', message: 'enrol' } };
552+
expect(evaluateAuthGate(gated, '/automation/hooks/f/h', 'POST')).toBeNull();
553+
expect(evaluateAuthGate(gated, '/environments/env_1/automation/hooks/f/h', 'POST')).toBeNull();
554+
// ⭐ The control: `GET /automation/hooks/runs/r1` is a run read of a flow
555+
// named `hooks` — the reason the row is POST-only.
556+
expect(evaluateAuthGate(gated, '/automation/hooks/runs/r1', 'GET')).toEqual({ code: 'MFA_REQUIRED', message: 'enrol' });
557+
expect(evaluateAuthGate(gated, '/automation/hooks/f/h')).toEqual({ code: 'MFA_REQUIRED', message: 'enrol' });
558+
expect(evaluateAuthGate(gated, '/automation/hooks/x', 'POST')).toEqual({ code: 'MFA_REQUIRED', message: 'enrol' });
559+
});
560+
561+
// ⭐ CLAUSE ② — the widening, measured rather than asserted.
562+
//
563+
// `preEntryAllowlisted` is this file's subject as it stood before the row,
564+
// transcribed from `origin/main` 12b9daf7 (it takes no method: no row read
565+
// one). Over every path of up to five segments drawn from a vocabulary that
566+
// mixes the new route's tokens with the existing ones, plus the six-segment
567+
// scoped shape, and over methods that include none at all, the set of
568+
// (path, method) answers that moved must be EXACTLY the hook route — POST,
569+
// in either case — unscoped or under one `/environments/:id` scope, every
570+
// move a newly exempt answer, none newly gated.
571+
it('BOUNDARY — newly exempt is exactly POST on the four-segment hook route, unscoped or scoped, nothing else', () => {
572+
const PRE_MOUNT_BASES: readonly (readonly string[])[] = [['api', 'v1'], ['api'], []];
573+
const PRE_SCOPE_SEGMENTS: readonly string[] = ['environments', 'projects'];
574+
const PRE_ALLOW_ROUTES: readonly (readonly string[])[] = [
575+
['health'], ['ready'], ['discovery'], ['me', 'apps'], ['me', 'localization'], ['approvals', 'act'],
576+
];
577+
const startsWith = (segments: readonly string[], prefix: readonly string[]): boolean => {
578+
if (segments.length < prefix.length) return false;
579+
for (let k = 0; k < prefix.length; k++) if (segments[k] !== prefix[k]) return false;
580+
return true;
581+
};
582+
const preEntryAllowlisted = (rawPath: string | undefined | null): boolean => {
583+
if (!rawPath) return false;
584+
let path = rawPath.split('?')[0] || '/';
585+
let end = path.length;
586+
while (end > 1 && path.charCodeAt(end - 1) === 47) end--;
587+
path = path.slice(0, end) || '/';
588+
const segments = path.split('/').filter((s) => s !== '');
589+
for (const base of PRE_MOUNT_BASES) {
590+
if (!startsWith(segments, base)) continue;
591+
let i = base.length;
592+
let scoped = false;
593+
if (i + 1 < segments.length && PRE_SCOPE_SEGMENTS.includes(segments[i] as string)) {
594+
i += 2;
595+
scoped = true;
596+
}
597+
if (segments[i] === 'auth') {
598+
if (i + 1 < segments.length) return true;
599+
if (!scoped) return true;
600+
}
601+
for (const route of PRE_ALLOW_ROUTES) {
602+
if (segments.length - i === route.length && startsWith(segments.slice(i), route)) return true;
603+
}
604+
}
605+
return false;
606+
};
607+
608+
const SEG = ['automation', 'hooks', 'environments', 'projects', 'env1', 'f', 'api', 'v1', 'health', 'runs'];
609+
const corpus: string[] = ['', '/'];
610+
const walk = (prefix: string, depth: number) => {
611+
if (depth === 0) return;
612+
for (const s of SEG) {
613+
const p = `${prefix}/${s}`;
614+
corpus.push(p);
615+
walk(p, depth - 1);
616+
}
617+
};
618+
walk('', 5);
619+
// The scoped shape is six segments deep: `/environments/<id>/automation/hooks/<f>/<h>`.
620+
const SCOPE_IDS = ['env1', 'hooks'];
621+
for (const id of SCOPE_IDS) walk(`/environments/${id}`, 4);
622+
const METHODS = ['POST', 'post', 'GET', 'DELETE', 'OPTIONS', undefined] as const;
623+
624+
// Built independently of the predicate: (unscoped | scope × id) × route × method.
625+
const expected = new Set<string>();
626+
for (const scope of [[], ...SCOPE_IDS.map((id) => ['environments', id])]) {
627+
for (const a of SEG) for (const b of SEG) {
628+
for (const m of ['POST', 'post']) expected.add(`${m} /${[...scope, 'automation', 'hooks', a, b].join('/')}`);
629+
}
630+
}
631+
632+
const newlyExempt: string[] = [];
633+
const newlyGated: string[] = [];
634+
for (const p of corpus) {
635+
const before = preEntryAllowlisted(p);
636+
for (const m of METHODS) {
637+
const after = isAuthGateAllowlisted(p, m);
638+
if (after && !before) newlyExempt.push(`${m} ${p}`);
639+
if (!after && before) newlyGated.push(`${m} ${p}`);
640+
}
641+
}
642+
expect(newlyGated).toEqual([]);
643+
expect([...new Set(newlyExempt)].sort()).toEqual([...expected].sort());
644+
645+
// Anti-vacuity: the corpus reaches every expected path, both predicates
646+
// answer both ways, and the card's own siblings are in it and gated.
647+
const inCorpus = new Set(corpus);
648+
for (const e of expected) expect(inCorpus.has(e.slice(e.indexOf(' ') + 1)), e).toBe(true);
649+
expect(corpus.length).toBeGreaterThan(100_000);
650+
expect(corpus.filter((p) => preEntryAllowlisted(p)).length).toBeGreaterThan(0);
651+
for (const p of ['/automation/hooks', '/automation/hooks/f', '/automation/hooks/f/runs/health', '/automation/f/hooks/runs']) {
652+
expect(inCorpus.has(p), p).toBe(true);
653+
expect(isAuthGateAllowlisted(p, 'POST'), p).toBe(false);
654+
}
655+
});
656+
});
657+
446658
describe('evaluateAuthGate', () => {
447659
it('returns null when the user carries no authGate', () => {
448660
expect(evaluateAuthGate({ id: 'u1' }, '/api/v1/data/x')).toBeNull();

0 commit comments

Comments
 (0)