Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): consult the anonymous-deny gate before /ai/** capability answers (#7653) - #7910
Conversation
…y answers (#7653) `handleAIRequest` held the `shouldDenyAnonymous` gate INSIDE its per-route loop, reachable only once the AI service is serveable, while the `!isServiceServeable` branch returned above it. On an open-edition boot — where `@objectstack/service-ai` is absent by construction, it being a Cloud/Enterprise package — the whole `/ai/**` family therefore answered unauthenticated callers: `GET /api/v1/ai/agents` → 200 with the console's empty-list courtesy, every other route → 501 carrying the Cloud/EE remedy sentence. Serveability decided whether the gate ran at all, which inverts the contract this item exists to pin. The decision is now taken once at the top of the handler and consulted at the exits; there is no second copy of the rule. The route-level `auth: false` opt-out stays in the loop because it is a property of a REGISTERED route and can only be honoured where a route table exists — with no serveable service there is no route to declare it, so the family default (auth required) stands. The unpublished-route-table exit ("AI service routes not yet initialized", 503) takes the gate first for the same reason. The honest degradation is deliberately unchanged for authenticated and internal SYSTEM callers, and is pinned as hard as the fix: `/ai/models`, `/ai/conversations`, `/ai/usage` and `/ai/chat` still answer 501 with `serviceUnavailableMessage('ai')` verbatim (never 404, never 503), `/ai/agents` still returns the declared envelope with the payload relocated under `data.agents`, and the 501 body stays string-identical to what `/discovery` reports for the `ai` slot. Three existing degradation cases carried incidentally anonymous fixtures and so measured the courtesy through the hole; they now seed a principal, the same way the `/notifications` stub-slot case next door already did. The registry-path coverage they provided is kept as its own case asserting the 401. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YUpcFD7LkpqUy42CYzck51
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7653
Premise verified on current
origin/main(d91fad5), not taken on trustThe card's reading held exactly, with the line numbers drifted by 0:
ai.ts:68-70— the/ai/agentsempty-list courtesy returns at the top ofhandleAIRequestai.ts:79—capabilityUnavailable(deps, 'ai')returns immediately afterai.ts:128—shouldDenyAnonymoussits inside the per-route loop, reachable only once the AI service is serveableSo serveability decided whether the gate ran at all. On an open-edition boot — where
@objectstack/service-aiis absent by construction, it being a Cloud/Enterprise package — the whole/ai/**family answered unauthenticated callers before the gate was ever consulted.domains/automation.tsis the counter-example in the same directory: gate at:156,capabilityUnavailableat:174, with a comment giving this exact reason ("an anonymous caller should not learn from a 501-vs-401 whether this deployment mounts automation at all")./aiwas the odd one out.The fix
The decision is taken once, at the top of the handler, and consulted at the exits. There is no second copy of the rule — one
shouldDenyAnonymouscall, one refusal builder.The route-level
auth: falseopt-out stays in the loop, and that placement is deliberate rather than a leftover: it is a property of a registered route, so it can only be honoured where a route table exists. With no serveable service there is no route to declare it, so the family default (auth required) stands. This is why the gate is not simply short-circuited at the top — a blanket 401 would pass every positive assertion below and quietly close routes a Cloud deployment legitimately opens.One extra exit in the same handler took the same treatment: the
!routes503 (AI service routes not yet initialized) also preceded the gate, so a boot-race window disclosed "AI is mounted here" to anonymous callers. Same reasoning, same one-line consult. Flagged here because it is slightly beyond the two paths the issue measured.Verification
All commands run in a dedicated worktree off
origin/main@ d91fad5, full package closure built first (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) — staledist/*.d.tslie in both directions.Reverse-verification — the load-bearing measurement
With only the reordering reverted (
git show HEAD:packages/runtime/src/domains/ai.ts >the file, tests untouched):Every red is an anonymous-deny assertion; every degradation assertion stayed green. Named breakdown from
--reporter=verbose:GET /ai/models→ 401, not the 501 remedy sentenceexpected 501 to be 401GET /ai/agents→ 401, not the 200 empty-list courtesyexpected 200 to be 401/conversations,/usage,/chat,/status,/tools/:t/execute)expected 501 to be 401/ai/models,/conversations,/usage,/chatwith the remedy sentence/ai/agentsdeclared envelope, payload relocated underdata.agents/discovery'sservices.aimessageauth: trueroute (the control — the gate that always worked)auth: falseauth: truerouteexpected 503 to be 401Explicit statement: the degradation assertions stayed green in both directions. Group B and Group C never moved — which is the point, since a fix that 401s everyone would satisfy all of Group A and be a regression.
Group C is the unit-level form of the issue's
/datacontrol (anonymousGET /api/v1/data/showcase_task→ 401 on the same boot): with a serveable service this handler already denied anonymous callers before this PR, which is what makes the defect ordering rather than absence.Refusals assert both halves of the ADR-0112 envelope —
status === 401 === ANONYMOUS_DENY_STATUSanderror.code === 'UNAUTHENTICATED' === ANONYMOUS_DENY_CODE, pluserror.httpStatus. The test'sdeps.erroris the real envelope builder (apiErrorResponse, ashttp-dispatcher.tswires it), not a stub that drops the third argument — with a stub,details.codewould never be promoted and everycodeassertion would be vacuous.Gates
node scripts/pm/dispatch-gates.mjs packages/runtime/src/domains/ai.tsderived two local families; both run, plus the build closure, the package suite, and the convention-scoped gates:pnpm check:type-check-debt(the CI ratchet, which compiles*.test.tstoo) — run after building the closure, as the script itself demands:Measured per-file against
@objectstack/runtime's TEST_DEBT ceiling of 227: total exactly 227, unchanged, contributed by the touched files asai.ts0,ai-anonymous-deny-ordering.test.ts0,domain-handler-registry.test.ts0. The 18 inhttp-dispatcher.test.tsare pre-existingstubbed()duck-typing and possibly-undefined errors; the one now reported at 3131 is the same one previously at 3124, shifted by the added lines. No debt ceiling was raised or lowered.Existing tests that moved, and why that is not a silent scope grab
Three pre-existing degradation cases failed against the fix because their fixtures were incidentally anonymous — they were measuring the courtesy and the 501 through the very hole this closes. They now seed a principal, which is the convention the
/notificationsstub-slot case immediately above one of them already used (executionContext: { userId: 'usr_1' }):http-dispatcher.test.ts—/ai — a stub slot 501s per route and keeps the /ai/agents empty listdomain-handler-registry.test.ts—/ai/agents returns an empty list (not 404) when no AI service is configureddomain-handler-registry.test.ts—/ai routes 501 (service missing) for non-agents pathsThe last two ran through
dispatch(), which re-resolves identity off the auth-less mock kernel and so overwrites any seeded context (the same reason the/keysand/automationcases in that file call their delegate directly). They now callhandleAIwith a principal — and the end-to-end registry coverage they provided is kept, not dropped, as a new case asserting that/ai/**denies an anonymous caller through the full registry path with theUNAUTHENTICATEDenvelope.packages/runtime/src/domains/ai.tsand its tests.http-dispatcher.test.tsanddomain-handler-registry.test.tsare test files for the handler being changed, and neither is on the held list (rest-server.ts,domains/meta.ts,domains/packages.ts,sandbox/**,packages/mcp/**) — but they are named separately, so flagging the touch rather than burying it. Changes there are fixture-only; no assertion was weakened or deleted.Out of scope — filed separately, not fixed here
I checked every sibling in
packages/runtime/src/domains/for the same inversion.meta.ts,actions.ts,packages.tsandautomation.tsall gate correctly ahead of any capability answer.security.tsis inverted the same way —:72-74returns503 'Security service not available'before its gate at:92— filed as its own unassignedfindingissue rather than folded in.Changeset
.changeset/ai-anonymous-deny-ordering.md—@objectstack/runtime: patch. Nocontent/docs/releases/edit.Generated by Claude Code