From fb690cf2a81acbf18416efa03b006110ef7c4d23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 03:52:27 +0000 Subject: [PATCH] fix(runtime): consult the anonymous-deny gate before /ai/** capability answers (#7653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01YUpcFD7LkpqUy42CYzck51 --- .changeset/ai-anonymous-deny-ordering.md | 35 +++ .../src/domain-handler-registry.test.ts | 38 ++- .../ai-anonymous-deny-ordering.test.ts | 286 ++++++++++++++++++ packages/runtime/src/domains/ai.ts | 59 +++- packages/runtime/src/http-dispatcher.test.ts | 11 +- 5 files changed, 414 insertions(+), 15 deletions(-) create mode 100644 .changeset/ai-anonymous-deny-ordering.md create mode 100644 packages/runtime/src/domains/ai-anonymous-deny-ordering.test.ts diff --git a/.changeset/ai-anonymous-deny-ordering.md b/.changeset/ai-anonymous-deny-ordering.md new file mode 100644 index 0000000000..01ab0e1730 --- /dev/null +++ b/.changeset/ai-anonymous-deny-ordering.md @@ -0,0 +1,35 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): consult the anonymous-deny gate before `/ai/**`'s capability answers (#7653) + +On an open-edition boot — where `@objectstack/service-ai` is absent by +construction, it being a Cloud/Enterprise package — the whole `/ai/**` family +answered **unauthenticated** callers: `GET /api/v1/ai/agents` returned **200** +with the console's empty-list courtesy, and every other route returned **501** +carrying the Cloud/Enterprise remedy sentence. Both should have been **401**. + +`handleAIRequest` held the `shouldDenyAnonymous` gate *inside* its per-route +loop, which is reachable only once the AI service is serveable, while the +`!isServiceServeable` branch returned above it. So serveability decided whether +the gate ran at all — the inverse of the contract. `/ai` stands on the same +anonymous-deny floor as `/data`, `/meta`, `/security`, `/actions` and +`/automation` (ADR-0056 D2 → #3963), and `domains/automation.ts` already gates +ahead of its own `capabilityUnavailable` for exactly this reason: an anonymous +caller must not learn from a 501-vs-401 whether a deployment mounts AI at all. + +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 unchanged for authenticated and internal SYSTEM +callers: `/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. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index f06bd64cf7..e471f5a812 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -717,8 +717,19 @@ describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => { expect(JSON.stringify(result.response?.body ?? {})).not.toMatch(/mock_token_/); }); + // [#7653] The caller has to be AUTHENTICATED for this case to mean what it + // says. The courtesy is owed to the console — an authenticated surface — + // not to the wire: the anonymous gate is consulted BEFORE `/ai/**`'s + // capability answers now, so an unauthenticated caller is denied 401 ahead + // of it. This used to run through `dispatch()`, which re-resolves identity + // off the auth-less mock kernel and therefore measured the courtesy as an + // anonymous caller — i.e. through the hole #7653 closed. Split in two so + // neither half is lost: the delegate call carries a seeded principal (the + // same bypass the `/keys` and `/automation` cases above use, and for the + // same reason), and the registry path keeps its own assertion below. it('/ai/agents returns an empty list (not 404) when no AI service is configured', async () => { - const result = await makeDispatcher().dispatch('GET', '/ai/agents', undefined, {}, {} as any); + const context: any = { request: {}, executionContext: { userId: 'usr_1' } }; + const result = await makeDispatcher().handleAI('/ai/agents', 'GET', undefined, {}, context); expect(result.response?.status).toBe(200); // #4053: in the declared envelope now, with `AiAgentsResponseSchema`'s // `{ agents }` RELOCATED under `data` rather than flattened to the bare @@ -735,11 +746,34 @@ describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => { // request with no AI service reached a handler that had nothing to // delegate to — 501. (`GET /ai/agents` keeps its deliberate empty-list // 200, asserted separately: the console polls it on every navigation.) + // [#7653] Authenticated, for the same reason as the case above: the 501 is + // what a legitimate caller is told, while an anonymous one is denied 401 + // before ever reaching it. it('/ai routes 501 (service missing) for non-agents paths', async () => { - const result = await makeDispatcher().dispatch('POST', '/ai/chat', { q: 'hi' }, {}, {} as any); + const context: any = { request: {}, executionContext: { userId: 'usr_1' } }; + const result = await makeDispatcher().handleAI('/ai/chat', 'POST', { q: 'hi' }, {}, context); expect(result.response?.status).toBe(501); }); + /** + * [#7653] The registry path itself, which the two cases above used to cover + * incidentally. `dispatch()` re-resolves identity off the auth-less mock + * kernel, so the caller it produces is ANONYMOUS — and an anonymous caller + * is exactly what must not receive either capability answer. So this keeps + * the end-to-end registry coverage and pins the fix at the same time: the + * `/ai` prefix is still claimed and routed (`handled: true`), and what comes + * back is the ADR-0112 refusal envelope rather than the 200 courtesy or the + * 501 remedy sentence. + */ + it('/ai/** denies an anonymous caller through the full registry path', async () => { + for (const [method, path] of [['GET', '/ai/agents'], ['POST', '/ai/chat']] as const) { + const result = await makeDispatcher().dispatch(method, path, undefined, {}, {} as any); + expect(result.handled, path).toBe(true); + expect(result.response?.status, path).toBe(401); + expect(result.response?.body?.error?.code, path).toBe('UNAUTHENTICATED'); + } + }); + it('/ai dispatches to a matching cached kernel route with params + user threading', async () => { const routeHandler = vi.fn().mockResolvedValue({ status: 200, body: { answer: 42 } }); const kernelExtras = { __aiRoutes: [{ method: 'GET', path: '/api/v1/ai/conversations/:id', handler: routeHandler, auth: false }] }; diff --git a/packages/runtime/src/domains/ai-anonymous-deny-ordering.test.ts b/packages/runtime/src/domains/ai-anonymous-deny-ordering.test.ts new file mode 100644 index 0000000000..382a1b0dfe --- /dev/null +++ b/packages/runtime/src/domains/ai-anonymous-deny-ordering.test.ts @@ -0,0 +1,286 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7653 — the anonymous-deny gate is consulted BEFORE `/ai/**`'s capability + * answers, not after them. + * + * ## The defect + * + * `handleAIRequest` held the gate INSIDE its per-route loop, which is reachable + * only once the AI service is serveable. On an open-edition boot — where + * `@objectstack/service-ai` is absent by construction, it being a + * Cloud/Enterprise package — the `!isServiceServeable` branch answered first, + * so the whole family replied to unauthenticated callers: `GET /ai/agents` + * → 200 with the console's empty-list courtesy, every other route → 501 + * carrying the Cloud/EE remedy sentence. Measured twice on the QA boot in + * objectstack-ai/objectstack#7627, against a control on the same boot + * (anonymous `GET /api/v1/data/showcase_task` → 401) that proves the platform + * gate was live and this is ORDERING, not absence. + * + * The disclosure is two static strings, so the value here is the contract, not + * the secret: serveability must not decide whether the gate runs. `/ai` stands + * on the same anonymous-deny floor as `/data`, `/meta`, `/security`, + * `/actions` and `/automation` (ADR-0056 D2 → #3963), and `domains/automation.ts` + * already gates ahead of its own `capabilityUnavailable` for the same reason. + * + * ## What must NOT change, and why half these cases exist + * + * The honest degradation is CORRECT and was never the bug. A fix that simply + * 401s everyone would satisfy every positive case above and still be a + * regression, so the authenticated half is pinned just as hard: + * + * - `/ai/models`, `/ai/conversations`, `/ai/usage`, `/ai/chat` → **501** + * (mounted-but-unimplemented — never 404, never 503) carrying + * `serviceUnavailableMessage('ai')` verbatim; + * - `/ai/agents` → the declared envelope with the payload RELOCATED under + * `data` (`data.agents`), not flattened to a bare array — `useAiSurfaceEnabled` + * reads `.agents` off it and a flatten hides the whole AI surface (#4053); + * - the 501 body is string-identical to what `/discovery` reports for the + * `ai` slot, because both sides call the one producer + * (`http-dispatcher.ts`'s `services..message`, and + * `domains/unavailable.ts` — see #4093). + * + * ## The control, at the level this file can hold it + * + * `Group C` is the unit-level form of the QA boot's `/data` control: with a + * SERVEABLE service, an `auth: true` route still denies anonymous (the gate + * that always worked, unchanged) while an `auth: false` route still SERVES + * anonymous. The second case is what distinguishes "the gate was hoisted" from + * "the gate became blanket": a registered route may legitimately open itself, + * and only a registered route can — which is why the unserveable and + * no-route-table exits apply the family default instead. + */ + +import { describe, it, expect } from 'vitest'; +import { serviceUnavailableMessage } from '@objectstack/spec/system'; +import { + ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; + +import { handleAIRequest } from './ai.js'; +import { apiErrorResponse } from '../error-envelope.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +/** The sentence discovery reports for the `ai` slot — the ONE producer. */ +const AI_REMEDY = serviceUnavailableMessage('ai'); + +// ── contexts ──────────────────────────────────────────────────────────────── +// Two anonymous shapes, both of which occur in the wild: `resolveExecutionContext` +// leaves `executionContext` UNDEFINED when identity resolution throws, and +// writes a `userId`-less record for a resolved-but-sessionless caller. +const anonUnresolved = () => ({ request: { headers: {} } }) as unknown as HttpProtocolContext; +const anonResolved = () => ({ + request: { headers: {} }, + executionContext: { isSystem: false, positions: [], permissions: [], systemPermissions: [] }, +}) as unknown as HttpProtocolContext; +const authed = () => ({ + request: { headers: {} }, + executionContext: { userId: 'usr_1', isSystem: false, positions: [], permissions: [], systemPermissions: [] }, +}) as unknown as HttpProtocolContext; +const system = () => ({ + request: { headers: {} }, + executionContext: { isSystem: true }, +}) as unknown as HttpProtocolContext; + +// ── deps ──────────────────────────────────────────────────────────────────── + +/** + * `error` is the REAL envelope builder the dispatcher wires in + * (`http-dispatcher.ts` → `apiErrorResponse`), not a stub that drops the third + * argument. Without it `details.code` would never be promoted into + * `error.code` and every `code` assertion below would be vacuous — the exact + * way an ADR-0112 envelope test can pass while asserting nothing. + */ +function makeDeps(opts: { + /** `undefined` → the open-edition boot: no AI service in the slot. */ + aiService?: any; + /** `undefined` → the route table has not been published yet. */ + routes?: Array<{ method: string; path: string; auth?: boolean; handler: (req: any) => Promise }>; +} = {}): DomainHandlerDeps { + return { + resolveService: (async (_ctx: HttpProtocolContext, name: string) => + (name === 'ai' ? opts.aiService : undefined)) as any, + getRegisteredAiRoutes: (_ctx: HttpProtocolContext) => opts.routes, + success: (data: any) => ({ status: 200, body: { success: true, data } }), + error: (message: string, httpStatus = 500, details?: any) => + apiErrorResponse({ message, httpStatus, details }), + routeNotFound: (route: string) => ({ status: 404, body: { success: false, error: { code: 'ROUTE_NOT_FOUND', route } } }), + getObjectQL: () => undefined, + } as unknown as DomainHandlerDeps; +} + +function dispatch(deps: DomainHandlerDeps, context: HttpProtocolContext, subPath: string, method = 'GET') { + return handleAIRequest(deps, subPath, method, {}, {}, context); +} + +/** Assert the ADR-0112 refusal envelope: status AND code, never one alone. */ +function expectAnonymousDenied(result: any) { + expect(result.handled).toBe(true); + expect(result.response.status).toBe(ANONYMOUS_DENY_STATUS); + expect(result.response.status).toBe(401); + expect(result.response.body.success).toBe(false); + expect(result.response.body.error.code).toBe(ANONYMOUS_DENY_CODE); + expect(result.response.body.error.code).toBe('UNAUTHENTICATED'); + expect(result.response.body.error.httpStatus).toBe(401); + expect(result.response.body.error.message).toBe(ANONYMOUS_DENY_MESSAGE); +} + +// ── Group A: the defect ───────────────────────────────────────────────────── + +describe('#7653 A — an open-edition boot denies anonymous callers on the whole /ai/** family', () => { + // The two the issue measured, by name and by number. + it('GET /ai/models → 401, not the 501 remedy sentence', async () => { + const result: any = await dispatch(makeDeps(), anonUnresolved(), '/ai/models'); + expectAnonymousDenied(result); + // The precise leak: the Cloud/EE sentence must not reach an + // unauthenticated caller, and 501 must not be the answer they get. + expect(result.response.status).not.toBe(501); + expect(JSON.stringify(result.response.body)).not.toContain(AI_REMEDY); + }); + + it('GET /ai/agents → 401, not the 200 empty-list courtesy', async () => { + const result: any = await dispatch(makeDeps(), anonUnresolved(), '/ai/agents'); + expectAnonymousDenied(result); + expect(result.response.status).not.toBe(200); + // The courtesy body must not be served either — the console is an + // authenticated surface, so nothing is owed here. + expect(result.response.body.success).toBe(false); + expect(result.response.body.data).toBeUndefined(); + }); + + it('denies the resolved-but-sessionless anonymous shape identically', async () => { + for (const path of ['/ai/models', '/ai/agents']) { + expectAnonymousDenied(await dispatch(makeDeps(), anonResolved(), path)); + } + }); + + it('covers the rest of the family, not just the two that were measured', async () => { + const cases: Array<[string, string]> = [ + ['/ai/conversations', 'GET'], + ['/ai/usage', 'GET'], + ['/ai/chat', 'POST'], + ['/ai/status', 'GET'], + ['/ai/tools/create_object/execute', 'POST'], + ]; + for (const [path, method] of cases) { + expectAnonymousDenied(await dispatch(makeDeps(), anonUnresolved(), path, method)); + } + }); +}); + +// ── Group B: the honest degradation — LOAD-BEARING, must stay green ───────── + +describe('#7653 B — the honest degradation is untouched for an authenticated caller', () => { + it('501s /ai/models, /conversations, /usage and /chat with the remedy sentence', async () => { + const cases: Array<[string, string]> = [ + ['/ai/models', 'GET'], + ['/ai/conversations', 'GET'], + ['/ai/usage', 'GET'], + ['/ai/chat', 'POST'], + ]; + for (const [path, method] of cases) { + const result: any = await dispatch(makeDeps(), authed(), path, method); + expect(result.handled).toBe(true); + // Mounted-but-unimplemented. NOT 404 (the route IS mounted) and + // NOT 503 (retrying does not install a Cloud package). + expect(result.response.status).toBe(501); + expect(result.response.status).not.toBe(404); + expect(result.response.status).not.toBe(503); + expect(result.response.body.error.message).toBe(AI_REMEDY); + expect(result.response.body.error.httpStatus).toBe(501); + } + }); + + it('serves /ai/agents as the declared envelope with the payload relocated under data', async () => { + const result: any = await dispatch(makeDeps(), authed(), '/ai/agents'); + expect(result.handled).toBe(true); + expect(result.response.status).toBe(200); + expect(result.response.body).toEqual({ success: true, data: { agents: [] } }); + // A RELOCATION, not a flatten: `unwrapResponse` returns `data`, and + // `client.ai.agents.list()` reads `.agents` off it. `data: []` would + // make `.agents` undefined, which `useAiSurfaceEnabled` reads as "hide + // the entire AI surface" (#4053). + expect(Array.isArray(result.response.body.data)).toBe(false); + expect(result.response.body.data.agents).toEqual([]); + }); + + it("keeps the 501 body string-identical to /discovery's services.ai message", async () => { + // Both sides call the one producer — `serviceUnavailableMessage` + // (`domains/unavailable.ts` here, `services..message` in + // `http-dispatcher.ts`) — so they cannot drift into naming different + // remedies. Pinned as an exact string, not a substring. + const result: any = await dispatch(makeDeps(), authed(), '/ai/models'); + expect(result.response.body.error.message).toBe(serviceUnavailableMessage('ai')); + expect(result.response.body.error.message).toBe( + 'Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise' + + ' — no implementation ships in the open framework', + ); + }); + + it('lets an internal SYSTEM context through to the same degradation answers', async () => { + // `isSystem` is never settable from the wire; a host dispatching + // internally must not be caught by a caller-facing gate. + const models: any = await dispatch(makeDeps(), system(), '/ai/models'); + expect(models.response.status).toBe(501); + expect(models.response.body.error.message).toBe(AI_REMEDY); + const agents: any = await dispatch(makeDeps(), system(), '/ai/agents'); + expect(agents.response.body).toEqual({ success: true, data: { agents: [] } }); + }); +}); + +// ── Group C: the control — hoisted, not made blanket ──────────────────────── + +describe('#7653 C — a serveable service keeps the per-route auth contract', () => { + const served = { chat: async () => ({ text: 'ok' }) }; + const okRoute = (path: string, auth?: boolean) => ({ + method: 'GET', + path, + ...(auth === undefined ? {} : { auth }), + handler: async () => ({ status: 200, body: { success: true, data: { served: true } } }), + }); + + it('still denies anonymous on an auth: true route (the gate that always worked)', async () => { + const deps = makeDeps({ aiService: served, routes: [okRoute('/api/v1/ai/status', true)] }); + expectAnonymousDenied(await dispatch(deps, anonUnresolved(), '/ai/status')); + }); + + it('still SERVES anonymous on a route that declares auth: false', async () => { + // The opt-out is the reason the family-wide decision is consulted here + // rather than short-circuiting at the top of the handler. A fix that + // 401s every anonymous caller passes all of Group A and fails this. + const deps = makeDeps({ aiService: served, routes: [okRoute('/api/v1/ai/public', false)] }); + const result: any = await dispatch(deps, anonUnresolved(), '/ai/public'); + expect(result.handled).toBe(true); + expect(result.response.status).toBe(200); + expect(result.response.body).toEqual({ success: true, data: { served: true } }); + }); + + it('serves an authenticated caller on an auth: true route', async () => { + const deps = makeDeps({ aiService: served, routes: [okRoute('/api/v1/ai/status', true)] }); + const result: any = await dispatch(deps, authed(), '/ai/status'); + expect(result.response.status).toBe(200); + expect(result.response.body).toEqual({ success: true, data: { served: true } }); + }); +}); + +// ── Group D: the boot-race exit ───────────────────────────────────────────── + +describe('#7653 D — the unpublished-route-table exit takes the gate first too', () => { + const served = { chat: async () => ({ text: 'ok' }) }; + + it('denies anonymous rather than answering "AI routes not yet initialized"', async () => { + // Same shape as the unserveable exit: with no route table there is no + // route to declare `auth: false`, so the family default applies. The + // 503 is itself a disclosure that AI is mounted here. + const result: any = await dispatch(makeDeps({ aiService: served }), anonUnresolved(), '/ai/models'); + expectAnonymousDenied(result); + expect(result.response.status).not.toBe(503); + }); + + it('still tells an authenticated caller the routes are not initialized', async () => { + const result: any = await dispatch(makeDeps({ aiService: served }), authed(), '/ai/models'); + expect(result.response.status).toBe(503); + expect(result.response.body.error.message).toBe('AI service routes not yet initialized'); + }); +}); diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts index f2ef25f559..ced7e862e1 100644 --- a/packages/runtime/src/domains/ai.ts +++ b/packages/runtime/src/domains/ai.ts @@ -33,6 +33,36 @@ export function createAiDomain(deps: DomainHandlerDeps): DomainRoute { * Resolves the AI service and its built-in route handlers, then dispatches. */ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise { + // [#7653] ANONYMOUS BASELINE — decided here, ahead of every capability + // answer below. + // + // The gate itself was already in this handler, but only INSIDE the + // per-route loop, which is reachable only once the AI service is + // serveable. On an open-edition boot (no `service-ai` — a Cloud/Enterprise + // package) the `!isServiceServeable` branch below answered first, so the + // whole `/ai/**` family replied to unauthenticated callers: `GET + // /ai/agents` → 200 with the 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: `/ai` stands on the same + // anonymous-deny floor as `/data`, `/meta`, `/security`, `/actions` and + // `/automation` (ADR-0056 D2 → #3963), and `domains/automation.ts` + // already gates ahead of its own `capabilityUnavailable` for exactly this + // reason — an anonymous caller must not learn from a 501-vs-401 whether + // this deployment mounts AI at all. + // + // The decision is taken ONCE and consulted twice; there is no second copy + // of the rule to drift. The route-level `auth: false` opt-out stays down + // in the loop because that is the only place it can mean anything: 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, + // and the family default — auth required — stands. + const gec: any = context.executionContext; + const denyAnonymous = shouldDenyAnonymous({ userId: gec?.userId, isSystem: gec?.isSystem }); + const anonymousRefusal = (): HttpDispatcherResult => ({ + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }); + let aiService: IAIService | undefined; try { aiService = await deps.resolveService(context, 'ai'); @@ -47,6 +77,10 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, // which both reads as a fault and loses the `/ai/agents` empty-list // courtesy the console depends on. if (!isServiceServeable(aiService)) { + // [#7653] The gate before the courtesy and the 501: both of them are + // capability disclosures, and neither is owed to a caller who has not + // authenticated. This is the exit the defect lived behind. + if (denyAnonymous) return anonymousRefusal(); // The console polls `GET /ai/agents` on every navigation to decide // whether to show AI affordances. Reporting that as a 404 turns the // normal "no AI service configured" state (the open-source default — @@ -105,6 +139,12 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, }> | undefined; if (!routes) { + // [#7653] Same shape as the unserveable exit above: a route table that + // has not been published yet offers no route to declare `auth: false`, + // so the family default applies and the gate goes first. Otherwise a + // boot-race window would answer anonymous callers "AI is mounted, come + // back in a moment" — the very disclosure this ordering exists to stop. + if (denyAnonymous) return anonymousRefusal(); return { handled: true, response: deps.error('AI service routes not yet initialized', 503) }; } @@ -121,17 +161,14 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, // adapter/model config back. Gate when the deployment requires // auth; an authenticated user (or an internal system context) // passes, matching the REST `enforceAuth` seam. Off → unchanged. - if (route.auth !== false) { - const gec: any = context.executionContext; - // `route.auth !== false` is the AI-route contract; #3963 dropped the - // deployment-wide opt-out, so the shared function owns the decision. - if (shouldDenyAnonymous({ userId: gec?.userId, isSystem: gec?.isSystem })) { - return { - handled: true, - response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), - }; - } - } + // + // [#7653] `route.auth !== false` is the AI-route contract and the one + // thing this site adds over the family-wide decision taken at the top + // of the handler: a registered route may legitimately open itself to + // anonymous callers, and only a registered route can. The decision + // itself is the same one — `denyAnonymous`, computed once above — + // so the two exits cannot answer differently. + if (route.auth !== false && denyAnonymous) return anonymousRefusal(); // Resolve `req.user` from the already-resolved ExecutionContext so // AI route handlers can attribute the call to the authenticated diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 57570a2606..2547d1a065 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -3118,12 +3118,19 @@ describe('HttpDispatcher', () => { const stub = stubbed({ chat: vi.fn(), listModels: vi.fn() }); serveOnly('ai', stub); - const chat = await dispatcher.handleAI('/ai/chat', 'POST', { messages: [] }, {}, { request: {} }); + // [#7653] Authenticated — the same principal the `/notifications` + // stub-slot case above passes, and for the same reason. What this + // test pins is that a STUB slot degrades like an empty one; the + // anonymous gate is a separate axis, and since #7653 it is consulted + // before these capability answers, so an empty context would now + // measure the 401 instead of the degradation. + const authed = { request: {}, executionContext: { userId: 'usr_1' } }; + const chat = await dispatcher.handleAI('/ai/chat', 'POST', { messages: [] }, {}, authed); expect(chat.handled).toBe(true); expect(chat.response?.status).toBe(501); expect(stub.chat).not.toHaveBeenCalled(); - const agents = await dispatcher.handleAI('/ai/agents', 'GET', undefined, {}, { request: {} }); + const agents = await dispatcher.handleAI('/ai/agents', 'GET', undefined, {}, authed); expect(agents.handled).toBe(true); expect(agents.response?.status).toBe(200); // #4053 enveloped this body while #4058 was in flight. The courtesy