Repository navigation
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
Conversation
…r — one parameterised POST allow-list row, honoured by the /automation floor WIP: the allow-list's first parameterised row (exactly four segments, POST only, dispatcher spelling) and the inbound-hook door the /automation domain hands its one admitted route to, forwarding to the trigger-api slot's handleInboundHook. Pins follow. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
… reached, floor kept, gates signature-only, spellings agree Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
…tomation-hooks-domain
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 44 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2487558747e0f64e88587bde0fbd60373da6e75f && git checkout 2487558747e0f64e88587bde0fbd60373da6e75f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 55382dc02a2168585a024ba142b98d8bce9910e6 11fac6c95544408a05b44aa5a5e71e9f7ce0e1d8 && git checkout -B drift-repro 55382dc02a2168585a024ba142b98d8bce9910e6 && git merge --no-ff 11fac6c95544408a05b44aa5a5e71e9f7ce0e1d8
node scripts/docs-audit/affected-docs.mjs --json 55382dc02a2168585a024ba142b98d8bce9910e6
|
Contract reviewServed-tier: Reviewed against: card #22773 (body and all five comments; the seat's ACCEPT ① Derived judgmentsEach accept-set or public-surface change the diff implies, judged right or wrong:
② Semver level
Clause-②: yes (widening) — one exact parameterised ③ Boundary flagsEvery dev flag and open question from
Implemented-by: VERDICT: PASS |
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 nohttp.server, the inbound hookPOST /api/v1/automation/hooks/:flowName/:hookIdnow 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 memberITriggerApiService.handleInboundHookon slottrigger-api; the member's implementation is #22774, which is not onmainyet. #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/:idscope (the one spelling the dispatcher's scope strip removes),POSTonly. No mount base, no retiredprojectsscope, 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?)andevaluateAuthGate(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.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:shouldDenyAnonymouspasses itsmethodto the allow-list beside itspath. Its pathless guard is unchanged and still decides first.@objectstack/runtime(minor)domains/automation.ts: the/automationdomain's anonymous floor (ADR-0056 D2, the [17.0-rc2验收] 安全:REST /actions 与 /automation 派发路由缺少匿名拒绝门 —— 未认证调用者可触发 system 提权的 RLS/FLS 绕过写入 #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 theautomationservice probe. For every other/automationpath and method the floor's decision is the one it always was.domains/automation-hooks.ts(new): resolves the request kernel'strigger-apislot per request and forwardscontext.requestwith its body UNREAD. It hands the two parameters decoded (an escape that does not decode is passed raw, as the/metadomain does) and returns the member'sResponseas it is. An absent slot or member answers501 NOT_IMPLEMENTED, neverROUTE_NOT_FOUND. A member that throws, or a body a transport already consumed, answers a sanitised500, 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 forPOSTonly, through the same reading, so a signed-in caller is judged exactly as an anonymous one (the/approvals/actprecedent).GET /automation/hooks/runs/:runIdis a run read of a flow namedhooks, and stays checked.route-ledger.ts: onepublicrow,POST /automation/hooks/:flowName/:hookId, domain/automation; census 86 to 87.Bookkeeping (no published surface):
scripts/check-route-envelope.mjsdeclaresautomation-hooks.tswithhandBuilt: 0. The dogfood probe census moves 86 to 87 rows (an existing domain key, sokeysstays 23). Theanonymous-deny-automationmatrix 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
/automationclaims 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/automationdomain, right after its floor.domains/automation.tsis in the claim's surface for exactly this case ("only if the existing /automation domain must yield this exact route").domain-handler-registry.tsis untouched.Ruling A's three conditions, as measured
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 from12b9daf7. The set of (path, method) answers that moved is EXACTLY the hook route, POST in either case, unscoped or under one/environments/:idscope. Every move is newly exempt, none newly gated./automation/hooks,/automation/hooks/x,/automation/hooks/x/y/zand every other/automationpath stay gated, and on the wire every one of them still answers an anonymous caller the floor's401 UNAUTHENTICATED(the answer each gave on the base commit, measured before any change).The verifier is the boundary. Here is what sits between the allow-list and the verifier on the hosted shape (
createHonoAppcatch-all, thenHttpDispatcher.dispatch):resolveRequestScope) resolves an anonymous sender to no user and refuses nothing. Its one refusal, an authz-store outage503, is an infrastructure fault, not a gate on this route;/automationdomain is claimed, and the floor admits the POST through the row;A tripwire pin proves no
/automationgate or probe reads anything first: theautomationandsecurityservices are never touched, with a control showing the tripwire fires on an ordinary request. A non-POSTmeets the floor's refusal before any route shape leaks:401even with neither service composed, never a501. An unsigned POST reaches the verifier, whose one refusal ([finding] trigger-api: the inbound hook matches the flow and hook id before it verifies the signature, so an unsigned post's 401 versus 404 tells which flow names are armed #22806) does not tell an armed flow from an unarmed one.Replay protection is not read here, as the ruling directs. (Its own card has since landed core's timestamped scheme, feat(core,trigger-api): timestamped x-objectstack-signature form with a 300 s tolerance window; body-only still accepted, deprecated #22803. Nothing here depends on it.)
The PM's mechanism assumptions, as measured
ITriggerApiService.handleInboundHook?is onmainunder slottrigger-api, and its docblock names this domain as its one caller./webhooks/redeliversibling is the shape for per-request slot resolution, typed absence, sanitised faults and the no-double-mount control.12b9daf7, on a dispatcher-only kernel, the/automationdomain claims/automation/hooks/f/h. The anonymous floor runs INSIDE that domain, as its first statement: an anonymousPOSTanswered401 UNAUTHENTICATED, and a signed-inPOSTfell through unhandled.methodonisAuthGateAllowlisted/evaluateAuthGate, read by parameterised rows only. Every existing caller (RESTenforceAuth, the dispatcher gate,shouldDenyAnonymous) is unchanged byte for byte when it passes none.shouldDenyAnonymousalready carriedmethod, and now forwards it.http.serverbefore the alias, and keep the self-hosted raw-app mount byte-unchanged (trigger-api segment 3 of ruling A on #22757) #22774 is not onmain(origin/main55382dc02:trigger-apiregisters notrigger-apiservice yet). The pins use a member double whose verifier is core's realverifyHttpSignature, the functionApiTrigger.handleRequestverifies with. The end-to-end leg on the real member is owed by whichever of runtime + core: an exactPOST /automation/hooks/:flowName/:hookIddispatcher domain and its parameterised ADR-0069 allow-list row, anonymous through to the trigger's HMAC verifier only (trigger-api segment 2 of ruling A on #22757) #22773 / trigger-api: implement the declared inbound-hook member through the same verifier, readhttp.serverbefore the alias, and keep the self-hosted raw-app mount byte-unchanged (trigger-api segment 3 of ruling A on #22757) #22774 lands second (triage unlock6107344979).Pins
packages/runtime/src/domains/automation-hooks.test.ts(19), on the realHttpDispatcher, a kernel with nohttp.server:202), with its own request, the spaced body byte-exact, and the parameters decoded;401 INVALID_SIGNATUREpasses through;501s, read per request, and the sanitised500s;6106817103): over 25 spellings (trailing slashes, query, encoded parameters, empty segments anywhere, empty scope id,projectsscope, 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;http.server, the dispatcher plugin mounts no route that answersPOST /api/v1/automation/hooks/:flowName/:hookId, with a matcher control. The trigger's raw-app mount stays that kernel's one door:dispatcher-plugin.tsis untouched, and the showcase parity gate re-checks it on a real self-hosted boot.packages/core/src/security/auth-gate.test.ts(+7) andanonymous-deny.test.ts(+1).Ablations
Each was one-off, run from committed state, through
scripts/ablation-replace.mjsin WRAP mode. Each mutation landed (anchor count 1 to 0, blob changed) and each restore was proven: blob equal to HEAD,git diff HEADempty,git status --porcelainclean afterwards. The runtime suite resolves@objectstack/coretosrcthrough its vitest alias and imports the runtime by relative path, so nodist/sits on these paths.evaluateAuthGate, BOUNDARY,shouldDenyAnonymous//automation/hooks/f/hwould be admitted by the row and served by nothing)Verification (at
11fac6c955, aftergit merge origin/main,pnpm install --frozen-lockfileand a fullturbo 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).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives on this tree all exit 0,pnpm check:pm-dispatch-gatesandpnpm check:dual-build-cjs-loadsincluded.--rananswers "92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN".node scripts/check-plugin-teardown-shape.mjs --self-testfirst answered exit 3 (its pinned fixture commit was outside this shallow clone, not a measurement), and aftergit fetch origin 621a487607it answered exit 0, 48 cases.eslint --no-inline-config --format jsonover the 14 touched lintable files reads 14 results, 0 errors, 0 warnings.ESLint.isPathIgnoredsays all 14 are in the config's population. The config enables no type-aware linting and reads onlyscripts/slot-lookup-baseline.jsonandscripts/query-options-erasure-baseline.jsonat load, neither touched, so this diff cannot move any untouched file's verdict.packages/qa/http-conformanceis untouched: the end-to-end leg on the real member is the second lander's (above).Acceptance notes
/automation匿名门槛上开一个精确到路由的口子,与自托管一致? #22757, not acted on. On a kernel with notrigger-apiservice, an anonymous unsigned POST to the exact route answers the contract's501 NOT_IMPLEMENTED, while one with the service answers the verifier's401. So an anonymous caller can tell whether a deployment composes the inbound-hook trigger. That is the answer the contract declares (ITriggerApiServicedocblock: "a typed 404 or 501"). If the [17.0-rc2验收] 安全:REST /actions 与 /automation 派发路由缺少匿名拒绝门 —— 未认证调用者可触发 system 提权的 RLS/FLS 绕过写入 #5519 ordering is meant to cover service presence on this route too, that is a contract question for [Decision] 托管环境上 trigger-api 的入站 webhook(HMAC 签名)要不要在/automation匿名门槛上开一个精确到路由的口子,与自托管一致? #22757, not a change for this card.verifyHttpSignature. It is not trigger-api: implement the declared inbound-hook member through the same verifier, readhttp.serverbefore the alias, and keep the self-hosted raw-app mount byte-unchanged (trigger-api segment 3 of ruling A on #22757) #22774's member, which does not exist onmainyet.Generated by Claude Code