Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/ai-anonymous-deny-ordering.md
Original file line numberDiff line numberDiff line change
@@ -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.
38 changes: 36 additions & 2 deletions packages/runtime/src/domain-handler-registry.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 }] };
Expand Down
Loading
Loading