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