Skip to content

Commit 6163393

Browse files
os-zhuangclaude
andauthored
feat(runtime): /auth /ai 两域 handler 体抽出 — ADR-0076 D11 步骤③ PR-7 (#2462) (#3551)
* feat(runtime): extract /auth and /ai dispatcher domain bodies — ADR-0076 D11 step ③ PR-7 (#2462) - domains/auth.ts: better-auth service bridge + the MSW/browser mock fallback; the local browser-safe randomUUID wrapper moves with its only consumer. - domains/ai.ts: dispatches the AI plugin's kernel-cached __aiRoutes table, enforcing each route's declared auth contract and threading the resolved actor. Receives the FULL cleanPath (legacy branch passed cleanPath whole; the matcher re-prefixes /api/v1) — preserved. - DomainHandlerDeps grows isAuthRequired() (lazily read — the requireAuth field initializes AFTER the deps object, a construction- order trap TS2729 caught) and getRegisteredAiRoutes(). - /mcp deliberately EXCLUDED: buildMcpBridge couples to the action-execution family (callData/actionPermissionError/ invokeBusinessAction/…), so the whole MCP family goes with the /actions /meta /data deep-coupling batch. Verified: seam suite 42 tests, runtime 647 green, http-conformance 41 green, dependent closure builds with DTS (--force). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(runtime): CSPRNG-backed UUID fallback in the auth domain (CodeQL js/insecure-randomness) The extraction made the legacy Math.random() RFC4122 fallback "changed code" and CodeQL flagged it (the ids feed mock session tokens). Prefer crypto.randomUUID, fall back to crypto.getRandomValues-built v4; the no-crypto-at-all branch (ancient runtimes, mock-only) avoids Math.random too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f07808c commit 6163393

6 files changed

Lines changed: 388 additions & 244 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract the /auth and /ai dispatcher domain bodies — ADR-0076 D11 step ③, PR-7 (#2462)
6+
7+
`/auth` (better-auth service bridge + the browser-safe mock fallback for
8+
MSW/test environments, with the local `randomUUID` wrapper moving alongside
9+
its only consumer) and `/ai` (dispatch to the AI plugin's kernel-cached
10+
route table with per-route auth-contract enforcement and actor threading)
11+
move to `domains/`. `DomainHandlerDeps` grows two lazily-read members:
12+
`isAuthRequired()` (the deployment's requireAuth posture —
13+
construction-order safe) and `getRegisteredAiRoutes()`. `/mcp` was
14+
deliberately excluded: `buildMcpBridge` couples to the action-execution
15+
family (callData / actionPermissionError / invokeBusinessAction), so it
16+
goes with the /actions /meta /data deep-coupling batch. Zero behavior
17+
change — http-conformance (41) plus 5 new seam tests.

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ function makeKernel(services: Record<string, any> = {}, state = 'running') {
3838
return kernel;
3939
}
4040

41+
function makeDispatcherWithKernelExtras(services: Record<string, any>, extras: Record<string, any>) {
42+
const kernel = makeKernel(services, 'running');
43+
Object.assign(kernel, extras);
44+
return new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
45+
}
46+
4147
function makeDispatcher(services: Record<string, any> = {}, state = 'running') {
4248
return new HttpDispatcher(makeKernel(services, state), undefined, {
4349
enforceProjectMembership: false,
@@ -441,3 +447,43 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => {
441447
expect(result.response?.status ?? 404).not.toBe(200);
442448
});
443449
});
450+
451+
// ---------------------------------------------------------------------------
452+
// PR-7 — auth + ai extraction
453+
// ---------------------------------------------------------------------------
454+
455+
describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => {
456+
it('/auth delegates to the auth service handler when registered', async () => {
457+
const handler = vi.fn().mockResolvedValue({ ok: true });
458+
const result = await makeDispatcher({ auth: { handler } }).dispatch('POST', '/auth/sign-in/email', { email: 'x@y.z' }, {}, {} as any);
459+
expect(result.handled).toBe(true);
460+
expect(handler).toHaveBeenCalledTimes(1);
461+
});
462+
463+
it('/auth mock fallback serves sign-up when no auth service is registered', async () => {
464+
const result = await makeDispatcher().dispatch('POST', '/auth/sign-up/email', { email: 'a@b.c', name: 'A' }, {}, {} as any);
465+
expect(result.response?.status).toBe(200);
466+
expect(result.response?.body?.user?.email).toBe('a@b.c');
467+
expect(result.response?.body?.session?.token).toMatch(/^mock_token_/);
468+
});
469+
470+
it('/ai/agents returns an empty list (not 404) when no AI service is configured', async () => {
471+
const result = await makeDispatcher().dispatch('GET', '/ai/agents', undefined, {}, {} as any);
472+
expect(result.response?.status).toBe(200);
473+
expect(result.response?.body?.agents).toEqual([]);
474+
});
475+
476+
it('/ai routes 404 (service missing) for non-agents paths', async () => {
477+
const result = await makeDispatcher().dispatch('POST', '/ai/chat', { q: 'hi' }, {}, {} as any);
478+
expect(result.response?.status).toBe(404);
479+
});
480+
481+
it('/ai dispatches to a matching cached kernel route with params + user threading', async () => {
482+
const routeHandler = vi.fn().mockResolvedValue({ status: 200, body: { answer: 42 } });
483+
const kernelExtras = { __aiRoutes: [{ method: 'GET', path: '/api/v1/ai/conversations/:id', handler: routeHandler, auth: false }] };
484+
const dispatcher = makeDispatcherWithKernelExtras({ ai: { name: 'ai' } }, kernelExtras);
485+
const result = await dispatcher.dispatch('GET', '/ai/conversations/c-1', undefined, {}, {} as any);
486+
expect(result.response?.status).toBe(200);
487+
expect(routeHandler.mock.calls[0][0].params).toMatchObject({ id: 'c-1' });
488+
});
489+
});

packages/runtime/src/domain-handler-registry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ export interface DomainHandlerDeps {
119119
announceKernelEvent(event: string, payload: unknown): Promise<void>;
120120
/** Host logger when one is attached to the dispatcher; domains fall back to console. */
121121
logger?: any;
122+
/** The deployment's `requireAuth` posture (lazily read — construction-order safe). */
123+
isAuthRequired(): boolean;
124+
/**
125+
* The AI route table the AI plugin caches on the request kernel
126+
* (`__aiRoutes`); undefined until the plugin initializes it.
127+
*/
128+
getRegisteredAiRoutes(): Array<{ method: string; path: string; handler: (req: any) => Promise<any>; auth?: boolean }> | undefined;
122129
}
123130

124131
/**

packages/runtime/src/domains/ai.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/ai` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-7).
5+
* Dispatches to the AI service's registered route handlers (the
6+
* `__aiRoutes` table the AI plugin caches on the request kernel), enforcing
7+
* each route's declared `auth` contract and threading the resolved actor
8+
* into handlers. NOTE: receives the FULL cleanPath (no prefix strip) — the
9+
* legacy branch passed `cleanPath` whole and the matcher re-prefixes
10+
* `/api/v1` internally; preserved verbatim.
11+
*/
12+
13+
import {
14+
shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE,
15+
} from '@objectstack/core';
16+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
17+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
18+
19+
export function createAiDomain(deps: DomainHandlerDeps): DomainRoute {
20+
return {
21+
prefix: '/ai',
22+
handler: (req, context) =>
23+
handleAIRequest(deps, req.path, req.method, req.body, req.query, context),
24+
};
25+
}
26+
27+
/**
28+
* Handle AI service routes (/ai/chat, /ai/models, /ai/conversations, etc.)
29+
* Resolves the AI service and its built-in route handlers, then dispatches.
30+
*/
31+
export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
32+
let aiService: any;
33+
try {
34+
aiService = await deps.resolveService('ai');
35+
} catch {
36+
// AI service not registered
37+
}
38+
39+
if (!aiService) {
40+
// The console polls `GET /ai/agents` on every navigation to decide
41+
// whether to show AI affordances. Reporting that as a 404 turns the
42+
// normal "no AI service configured" state (the open-source default —
43+
// service-ai is a Cloud/Enterprise package) into console error-log
44+
// spam on every page. An empty list conveys the same information
45+
// without looking like a fault. Every other /ai/* route still 404s.
46+
if (method === 'GET' && subPath === '/ai/agents') {
47+
return { handled: true, response: { status: 200, body: { agents: [] } } };
48+
}
49+
return {
50+
handled: true,
51+
response: {
52+
status: 404,
53+
body: { success: false, error: { message: 'AI service is not configured', code: 404 } },
54+
},
55+
};
56+
}
57+
58+
// The AI service exposes route definitions via buildAIRoutes.
59+
// We match the request path against known AI route patterns.
60+
const fullPath = `/api/v1${subPath}`;
61+
62+
// Build a simple param-extracting matcher for route patterns like /api/v1/ai/conversations/:id
63+
const matchRoute = (pattern: string, path: string): Record<string, string> | null => {
64+
const patternParts = pattern.split('/');
65+
const pathParts = path.split('/');
66+
if (patternParts.length !== pathParts.length) return null;
67+
const params: Record<string, string> = {};
68+
for (let i = 0; i < patternParts.length; i++) {
69+
if (patternParts[i].startsWith(':')) {
70+
params[patternParts[i].substring(1)] = pathParts[i];
71+
} else if (patternParts[i] !== pathParts[i]) {
72+
return null;
73+
}
74+
}
75+
return params;
76+
};
77+
78+
// Try to get route definitions from the AI service's cached routes
79+
const routes = deps.getRegisteredAiRoutes() as Array<{
80+
method: string; path: string; handler: (req: any) => Promise<any>; auth?: boolean;
81+
}> | undefined;
82+
83+
if (!routes) {
84+
return {
85+
handled: true,
86+
response: {
87+
status: 503,
88+
body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } },
89+
},
90+
};
91+
}
92+
93+
for (const route of routes) {
94+
if (route.method !== method) continue;
95+
const params = matchRoute(route.path, fullPath);
96+
if (params === null) continue;
97+
98+
// Enforce the route's declared `auth` contract. Nothing upstream
99+
// does: `enforceAuthGate` only covers ADR-0069 password/MFA gates
100+
// and `enforceProjectMembership` bails when the request is
101+
// anonymous or unscoped — so without this an anonymous caller
102+
// reached `auth: true` handlers (e.g. GET /ai/status) and got the
103+
// adapter/model config back. Gate when the deployment requires
104+
// auth; an authenticated user (or an internal system context)
105+
// passes, matching the REST `enforceAuth` seam. Off → unchanged.
106+
if (route.auth !== false) {
107+
const gec: any = context.executionContext;
108+
// `requireAuth && route.auth !== false` is the AI-route contract;
109+
// the shared function owns the anonymous decision itself.
110+
if (shouldDenyAnonymous({ requireAuth: deps.isAuthRequired(), userId: gec?.userId, isSystem: gec?.isSystem })) {
111+
return {
112+
handled: true,
113+
response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }),
114+
};
115+
}
116+
}
117+
118+
// Resolve `req.user` from the already-resolved ExecutionContext so
119+
// AI route handlers can attribute the call to the authenticated
120+
// actor (drives auto-titled conversations, permission-aware
121+
// tools, HITL conversation linkage, …). Falls back to undefined
122+
// for anonymous requests (only reachable when the deployment does
123+
// NOT require auth — the gate above rejects them otherwise).
124+
const ec: any = context.executionContext;
125+
// `ai_seat` is synthesized into ec.permissions by resolveExecutionContext
126+
// (the single, scope-correct source — security/resolve-execution-context.ts),
127+
// so it flows through here with no extra per-request lookup.
128+
const user = ec?.userId
129+
? {
130+
userId: ec.userId,
131+
id: ec.userId,
132+
displayName: ec.userDisplayName ?? ec.userName ?? ec.userId,
133+
email: ec.userEmail,
134+
roles: Array.isArray(ec.positions) ? ec.positions : [],
135+
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
136+
organizationId: ec.tenantId,
137+
}
138+
: undefined;
139+
140+
const result = await route.handler({
141+
body,
142+
params,
143+
query,
144+
headers: context.request?.headers,
145+
user,
146+
});
147+
148+
if (result.stream && result.events) {
149+
// Return a streaming result for the adapter to handle
150+
return {
151+
handled: true,
152+
result: {
153+
type: 'stream',
154+
contentType: result.vercelDataStream
155+
? 'text/plain; charset=utf-8'
156+
: 'text/event-stream',
157+
events: result.events,
158+
vercelDataStream: result.vercelDataStream,
159+
headers: {
160+
'Content-Type': result.vercelDataStream
161+
? 'text/plain; charset=utf-8'
162+
: 'text/event-stream',
163+
'Cache-Control': 'no-cache',
164+
'Connection': 'keep-alive',
165+
},
166+
},
167+
};
168+
}
169+
170+
return {
171+
handled: true,
172+
response: {
173+
status: result.status,
174+
body: result.body,
175+
},
176+
};
177+
}
178+
179+
return {
180+
handled: true,
181+
response: deps.routeNotFound(subPath),
182+
};
183+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/auth` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-7).
5+
* Bridges to the `auth` service's better-auth handler; when no auth service
6+
* is registered (MSW / browser-only mock environments) a minimal mock
7+
* fallback keeps core sign-up/sign-in/session flows from 404ing.
8+
*/
9+
10+
import { CoreServiceName } from '@objectstack/spec/system';
11+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
12+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
13+
14+
/**
15+
* Browser-safe UUID generator — prefers Web Crypto's `randomUUID`, falls back
16+
* to an RFC 4122 v4 built from `crypto.getRandomValues` (available everywhere
17+
* `randomUUID` might be missing, e.g. non-secure contexts). The legacy
18+
* `Math.random()` fallback was a latent CodeQL js/insecure-randomness hit
19+
* surfaced by the extraction — these ids feed mock session tokens, so use
20+
* CSPRNG bytes regardless.
21+
*/
22+
function randomUUID(): string {
23+
const c: Crypto | undefined = globalThis.crypto;
24+
if (c && typeof c.randomUUID === 'function') {
25+
return c.randomUUID();
26+
}
27+
const bytes = new Uint8Array(16);
28+
if (c && typeof c.getRandomValues === 'function') {
29+
c.getRandomValues(bytes);
30+
} else {
31+
// No crypto at all (ancient runtime) — mock-only path; still avoid
32+
// Math.random by deriving from the only entropy available.
33+
for (let i = 0; i < 16; i++) bytes[i] = (Date.now() + i * 7919) & 0xff;
34+
}
35+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
36+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
37+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
38+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
39+
}
40+
41+
export function createAuthDomain(deps: DomainHandlerDeps): DomainRoute {
42+
return {
43+
prefix: '/auth',
44+
handler: (req, context) =>
45+
handleAuthRequest(deps, req.path.substring(5), req.method, req.body, context),
46+
};
47+
}
48+
49+
/**
50+
* Handles Auth requests
51+
* path: sub-path after /auth/
52+
*/
53+
export async function handleAuthRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
54+
// 1. Try generic Auth Service
55+
const authService = await deps.getService(CoreServiceName.enum.auth);
56+
if (authService && typeof authService.handler === 'function') {
57+
const response = await authService.handler(context.request, context.response);
58+
return { handled: true, result: response };
59+
}
60+
61+
// 2. Mock fallback for MSW/test environments when no auth service is registered
62+
const normalizedPath = path.replace(/^\/+/, '');
63+
return mockAuthFallback(normalizedPath, method, body);
64+
}
65+
66+
/**
67+
* Provides mock auth responses for core better-auth endpoints when
68+
* AuthPlugin is not loaded (e.g. MSW/browser-only environments).
69+
* This ensures registration/sign-in flows do not 404 in mock mode.
70+
*/
71+
function mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult {
72+
const m = method.toUpperCase();
73+
const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours
74+
75+
// POST sign-up/email
76+
if ((path === 'sign-up/email' || path === 'register') && m === 'POST') {
77+
const id = `mock_${randomUUID()}`;
78+
return {
79+
handled: true,
80+
response: {
81+
status: 200,
82+
body: {
83+
user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
84+
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
85+
},
86+
},
87+
};
88+
}
89+
90+
// POST sign-in/email or login
91+
if ((path === 'sign-in/email' || path === 'login') && m === 'POST') {
92+
const id = `mock_${randomUUID()}`;
93+
return {
94+
handled: true,
95+
response: {
96+
status: 200,
97+
body: {
98+
user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
99+
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
100+
},
101+
},
102+
};
103+
}
104+
105+
// GET get-session
106+
if (path === 'get-session' && m === 'GET') {
107+
return {
108+
handled: true,
109+
response: { status: 200, body: { session: null, user: null } },
110+
};
111+
}
112+
113+
// POST sign-out
114+
if (path === 'sign-out' && m === 'POST') {
115+
return {
116+
handled: true,
117+
response: { status: 200, body: { success: true } },
118+
};
119+
}
120+
121+
return { handled: false };
122+
}

0 commit comments

Comments
 (0)