diff --git a/.changeset/runtime-auth-ai-extraction.md b/.changeset/runtime-auth-ai-extraction.md new file mode 100644 index 0000000000..48eca394b9 --- /dev/null +++ b/.changeset/runtime-auth-ai-extraction.md @@ -0,0 +1,17 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): extract the /auth and /ai dispatcher domain bodies — ADR-0076 D11 step ③, PR-7 (#2462) + +`/auth` (better-auth service bridge + the browser-safe mock fallback for +MSW/test environments, with the local `randomUUID` wrapper moving alongside +its only consumer) and `/ai` (dispatch to the AI plugin's kernel-cached +route table with per-route auth-contract enforcement and actor threading) +move to `domains/`. `DomainHandlerDeps` grows two lazily-read members: +`isAuthRequired()` (the deployment's requireAuth posture — +construction-order safe) and `getRegisteredAiRoutes()`. `/mcp` was +deliberately excluded: `buildMcpBridge` couples to the action-execution +family (callData / actionPermissionError / invokeBusinessAction), so it +goes with the /actions /meta /data deep-coupling batch. Zero behavior +change — http-conformance (41) plus 5 new seam tests. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 282c59c9c6..6c6b01bb73 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -38,6 +38,12 @@ function makeKernel(services: Record = {}, state = 'running') { return kernel; } +function makeDispatcherWithKernelExtras(services: Record, extras: Record) { + const kernel = makeKernel(services, 'running'); + Object.assign(kernel, extras); + return new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); +} + function makeDispatcher(services: Record = {}, state = 'running') { return new HttpDispatcher(makeKernel(services, state), undefined, { enforceProjectMembership: false, @@ -441,3 +447,43 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { expect(result.response?.status ?? 404).not.toBe(200); }); }); + +// --------------------------------------------------------------------------- +// PR-7 — auth + ai extraction +// --------------------------------------------------------------------------- + +describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => { + it('/auth delegates to the auth service handler when registered', async () => { + const handler = vi.fn().mockResolvedValue({ ok: true }); + const result = await makeDispatcher({ auth: { handler } }).dispatch('POST', '/auth/sign-in/email', { email: 'x@y.z' }, {}, {} as any); + expect(result.handled).toBe(true); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('/auth mock fallback serves sign-up when no auth service is registered', async () => { + const result = await makeDispatcher().dispatch('POST', '/auth/sign-up/email', { email: 'a@b.c', name: 'A' }, {}, {} as any); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.user?.email).toBe('a@b.c'); + expect(result.response?.body?.session?.token).toMatch(/^mock_token_/); + }); + + 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); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.agents).toEqual([]); + }); + + it('/ai routes 404 (service missing) for non-agents paths', async () => { + const result = await makeDispatcher().dispatch('POST', '/ai/chat', { q: 'hi' }, {}, {} as any); + expect(result.response?.status).toBe(404); + }); + + 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 }] }; + const dispatcher = makeDispatcherWithKernelExtras({ ai: { name: 'ai' } }, kernelExtras); + const result = await dispatcher.dispatch('GET', '/ai/conversations/c-1', undefined, {}, {} as any); + expect(result.response?.status).toBe(200); + expect(routeHandler.mock.calls[0][0].params).toMatchObject({ id: 'c-1' }); + }); +}); diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index ca89fcbd5f..7d324b3108 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -119,6 +119,13 @@ export interface DomainHandlerDeps { announceKernelEvent(event: string, payload: unknown): Promise; /** Host logger when one is attached to the dispatcher; domains fall back to console. */ logger?: any; + /** The deployment's `requireAuth` posture (lazily read — construction-order safe). */ + isAuthRequired(): boolean; + /** + * The AI route table the AI plugin caches on the request kernel + * (`__aiRoutes`); undefined until the plugin initializes it. + */ + getRegisteredAiRoutes(): Array<{ method: string; path: string; handler: (req: any) => Promise; auth?: boolean }> | undefined; } /** diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts new file mode 100644 index 0000000000..79a606f201 --- /dev/null +++ b/packages/runtime/src/domains/ai.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/ai` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-7). + * Dispatches to the AI service's registered route handlers (the + * `__aiRoutes` table the AI plugin caches on the request kernel), enforcing + * each route's declared `auth` contract and threading the resolved actor + * into handlers. NOTE: receives the FULL cleanPath (no prefix strip) — the + * legacy branch passed `cleanPath` whole and the matcher re-prefixes + * `/api/v1` internally; preserved verbatim. + */ + +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +export function createAiDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/ai', + handler: (req, context) => + handleAIRequest(deps, req.path, req.method, req.body, req.query, context), + }; +} + +/** + * Handle AI service routes (/ai/chat, /ai/models, /ai/conversations, etc.) + * 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 { + let aiService: any; + try { + aiService = await deps.resolveService('ai'); + } catch { + // AI service not registered + } + + if (!aiService) { + // 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 — + // service-ai is a Cloud/Enterprise package) into console error-log + // spam on every page. An empty list conveys the same information + // without looking like a fault. Every other /ai/* route still 404s. + if (method === 'GET' && subPath === '/ai/agents') { + return { handled: true, response: { status: 200, body: { agents: [] } } }; + } + return { + handled: true, + response: { + status: 404, + body: { success: false, error: { message: 'AI service is not configured', code: 404 } }, + }, + }; + } + + // The AI service exposes route definitions via buildAIRoutes. + // We match the request path against known AI route patterns. + const fullPath = `/api/v1${subPath}`; + + // Build a simple param-extracting matcher for route patterns like /api/v1/ai/conversations/:id + const matchRoute = (pattern: string, path: string): Record | null => { + const patternParts = pattern.split('/'); + const pathParts = path.split('/'); + if (patternParts.length !== pathParts.length) return null; + const params: Record = {}; + for (let i = 0; i < patternParts.length; i++) { + if (patternParts[i].startsWith(':')) { + params[patternParts[i].substring(1)] = pathParts[i]; + } else if (patternParts[i] !== pathParts[i]) { + return null; + } + } + return params; + }; + + // Try to get route definitions from the AI service's cached routes + const routes = deps.getRegisteredAiRoutes() as Array<{ + method: string; path: string; handler: (req: any) => Promise; auth?: boolean; + }> | undefined; + + if (!routes) { + return { + handled: true, + response: { + status: 503, + body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } }, + }, + }; + } + + for (const route of routes) { + if (route.method !== method) continue; + const params = matchRoute(route.path, fullPath); + if (params === null) continue; + + // Enforce the route's declared `auth` contract. Nothing upstream + // does: `enforceAuthGate` only covers ADR-0069 password/MFA gates + // and `enforceProjectMembership` bails when the request is + // anonymous or unscoped — so without this an anonymous caller + // reached `auth: true` handlers (e.g. GET /ai/status) and got the + // 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; + // `requireAuth && route.auth !== false` is the AI-route contract; + // the shared function owns the anonymous decision itself. + if (shouldDenyAnonymous({ requireAuth: deps.isAuthRequired(), userId: gec?.userId, isSystem: gec?.isSystem })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + } + + // Resolve `req.user` from the already-resolved ExecutionContext so + // AI route handlers can attribute the call to the authenticated + // actor (drives auto-titled conversations, permission-aware + // tools, HITL conversation linkage, …). Falls back to undefined + // for anonymous requests (only reachable when the deployment does + // NOT require auth — the gate above rejects them otherwise). + const ec: any = context.executionContext; + // `ai_seat` is synthesized into ec.permissions by resolveExecutionContext + // (the single, scope-correct source — security/resolve-execution-context.ts), + // so it flows through here with no extra per-request lookup. + const user = ec?.userId + ? { + userId: ec.userId, + id: ec.userId, + displayName: ec.userDisplayName ?? ec.userName ?? ec.userId, + email: ec.userEmail, + roles: Array.isArray(ec.positions) ? ec.positions : [], + permissions: Array.isArray(ec.permissions) ? ec.permissions : [], + organizationId: ec.tenantId, + } + : undefined; + + const result = await route.handler({ + body, + params, + query, + headers: context.request?.headers, + user, + }); + + if (result.stream && result.events) { + // Return a streaming result for the adapter to handle + return { + handled: true, + result: { + type: 'stream', + contentType: result.vercelDataStream + ? 'text/plain; charset=utf-8' + : 'text/event-stream', + events: result.events, + vercelDataStream: result.vercelDataStream, + headers: { + 'Content-Type': result.vercelDataStream + ? 'text/plain; charset=utf-8' + : 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }, + }; + } + + return { + handled: true, + response: { + status: result.status, + body: result.body, + }, + }; + } + + return { + handled: true, + response: deps.routeNotFound(subPath), + }; +} diff --git a/packages/runtime/src/domains/auth.ts b/packages/runtime/src/domains/auth.ts new file mode 100644 index 0000000000..119d823d32 --- /dev/null +++ b/packages/runtime/src/domains/auth.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/auth` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-7). + * Bridges to the `auth` service's better-auth handler; when no auth service + * is registered (MSW / browser-only mock environments) a minimal mock + * fallback keeps core sign-up/sign-in/session flows from 404ing. + */ + +import { CoreServiceName } from '@objectstack/spec/system'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +/** + * Browser-safe UUID generator — prefers Web Crypto's `randomUUID`, falls back + * to an RFC 4122 v4 built from `crypto.getRandomValues` (available everywhere + * `randomUUID` might be missing, e.g. non-secure contexts). The legacy + * `Math.random()` fallback was a latent CodeQL js/insecure-randomness hit + * surfaced by the extraction — these ids feed mock session tokens, so use + * CSPRNG bytes regardless. + */ +function randomUUID(): string { + const c: Crypto | undefined = globalThis.crypto; + if (c && typeof c.randomUUID === 'function') { + return c.randomUUID(); + } + const bytes = new Uint8Array(16); + if (c && typeof c.getRandomValues === 'function') { + c.getRandomValues(bytes); + } else { + // No crypto at all (ancient runtime) — mock-only path; still avoid + // Math.random by deriving from the only entropy available. + for (let i = 0; i < 16; i++) bytes[i] = (Date.now() + i * 7919) & 0xff; + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function createAuthDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/auth', + handler: (req, context) => + handleAuthRequest(deps, req.path.substring(5), req.method, req.body, context), + }; +} + +/** + * Handles Auth requests + * path: sub-path after /auth/ + */ +export async function handleAuthRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, context: HttpProtocolContext): Promise { + // 1. Try generic Auth Service + const authService = await deps.getService(CoreServiceName.enum.auth); + if (authService && typeof authService.handler === 'function') { + const response = await authService.handler(context.request, context.response); + return { handled: true, result: response }; + } + + // 2. Mock fallback for MSW/test environments when no auth service is registered + const normalizedPath = path.replace(/^\/+/, ''); + return mockAuthFallback(normalizedPath, method, body); +} + +/** + * Provides mock auth responses for core better-auth endpoints when + * AuthPlugin is not loaded (e.g. MSW/browser-only environments). + * This ensures registration/sign-in flows do not 404 in mock mode. + */ +function mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult { + const m = method.toUpperCase(); + const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours + + // POST sign-up/email + if ((path === 'sign-up/email' || path === 'register') && m === 'POST') { + const id = `mock_${randomUUID()}`; + return { + handled: true, + response: { + status: 200, + body: { + user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() }, + }, + }, + }; + } + + // POST sign-in/email or login + if ((path === 'sign-in/email' || path === 'login') && m === 'POST') { + const id = `mock_${randomUUID()}`; + return { + handled: true, + response: { + status: 200, + body: { + user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() }, + }, + }, + }; + } + + // GET get-session + if (path === 'get-session' && m === 'GET') { + return { + handled: true, + response: { status: 200, body: { session: null, user: null } }, + }; + } + + // POST sign-out + if (path === 'sign-out' && m === 'POST') { + return { + handled: true, + response: { status: 200, body: { success: true } }, + }; + } + + return { handled: false }; +} diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index f215666d4d..ebd71981f3 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -24,6 +24,8 @@ import { createUiDomain, handleUiRequest } from './domains/ui.js'; import { createShareLinksDomain, handleShareLinksRequest } from './domains/share-links.js'; import { createPackagesDomain, handlePackagesRequest } from './domains/packages.js'; import { createAutomationDomain, handleAutomationRequest } from './domains/automation.js'; +import { createAuthDomain, handleAuthRequest } from './domains/auth.js'; +import { createAiDomain, handleAIRequest } from './domains/ai.js'; /** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */ interface EnvironmentScopeManager { @@ -34,17 +36,7 @@ import { isPermissionDeniedError, } from './security/resolve-execution-context.js'; -/** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */ -function randomUUID(): string { - if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { - return globalThis.crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { - const r = (Math.random() * 16) | 0; - const v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} +// randomUUID moved to ./domains/auth.ts with its only consumer (D11③ PR-7). /** A `sys_`-prefixed object is a system table — off-limits to external MCP agents. */ function isSystemObjectName(name: string): boolean { @@ -264,6 +256,8 @@ export class HttpDispatcher { if (k?.context?.trigger) await k.context.trigger(event, payload); }, logger: (this as any).logger, + isAuthRequired: () => this.requireAuth, + getRegisteredAiRoutes: () => (this.kernel as any)?.__aiRoutes, }; /** @@ -314,6 +308,8 @@ export class HttpDispatcher { this.domainRegistry.register(createShareLinksDomain(this.domainDeps)); this.domainRegistry.register(createPackagesDomain(this.domainDeps)); this.domainRegistry.register(createAutomationDomain(this.domainDeps)); + this.domainRegistry.register(createAuthDomain(this.domainDeps)); + this.domainRegistry.register(createAiDomain(this.domainDeps)); } /** @@ -1962,80 +1958,11 @@ export class HttpDispatcher { } } - /** - * Handles Auth requests - * path: sub-path after /auth/ - */ + /** Thin delegate — body extracted to `./domains/auth.ts` (D11③ PR-7). */ async handleAuth(path: string, method: string, body: any, context: HttpProtocolContext): Promise { - // 1. Try generic Auth Service - const authService = await this.getService(CoreServiceName.enum.auth); - if (authService && typeof authService.handler === 'function') { - const response = await authService.handler(context.request, context.response); - return { handled: true, result: response }; - } - - // 2. Mock fallback for MSW/test environments when no auth service is registered - const normalizedPath = path.replace(/^\/+/, ''); - return this.mockAuthFallback(normalizedPath, method, body); + return handleAuthRequest(this.domainDeps, path, method, body, context); } - /** - * Provides mock auth responses for core better-auth endpoints when - * AuthPlugin is not loaded (e.g. MSW/browser-only environments). - * This ensures registration/sign-in flows do not 404 in mock mode. - */ - private mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult { - const m = method.toUpperCase(); - const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours - - // POST sign-up/email - if ((path === 'sign-up/email' || path === 'register') && m === 'POST') { - const id = `mock_${randomUUID()}`; - return { - handled: true, - response: { - status: 200, - body: { - user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, - session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() }, - }, - }, - }; - } - - // POST sign-in/email or login - if ((path === 'sign-in/email' || path === 'login') && m === 'POST') { - const id = `mock_${randomUUID()}`; - return { - handled: true, - response: { - status: 200, - body: { - user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, - session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() }, - }, - }, - }; - } - - // GET get-session - if (path === 'get-session' && m === 'GET') { - return { - handled: true, - response: { status: 200, body: { session: null, user: null } }, - }; - } - - // POST sign-out - if (path === 'sign-out' && m === 'POST') { - return { - handled: true, - response: { status: 200, body: { success: true } }, - }; - } - - return { handled: false }; - } /** * Handles Metadata requests @@ -2935,162 +2862,9 @@ export class HttpDispatcher { } } - /** - * Handle AI service routes (/ai/chat, /ai/models, /ai/conversations, etc.) - * Resolves the AI service and its built-in route handlers, then dispatches. - */ + /** Thin delegate — body extracted to `./domains/ai.ts` (D11③ PR-7). */ async handleAI(subPath: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise { - let aiService: any; - try { - aiService = await this.resolveService('ai'); - } catch { - // AI service not registered - } - - if (!aiService) { - // 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 — - // service-ai is a Cloud/Enterprise package) into console error-log - // spam on every page. An empty list conveys the same information - // without looking like a fault. Every other /ai/* route still 404s. - if (method === 'GET' && subPath === '/ai/agents') { - return { handled: true, response: { status: 200, body: { agents: [] } } }; - } - return { - handled: true, - response: { - status: 404, - body: { success: false, error: { message: 'AI service is not configured', code: 404 } }, - }, - }; - } - - // The AI service exposes route definitions via buildAIRoutes. - // We match the request path against known AI route patterns. - const fullPath = `/api/v1${subPath}`; - - // Build a simple param-extracting matcher for route patterns like /api/v1/ai/conversations/:id - const matchRoute = (pattern: string, path: string): Record | null => { - const patternParts = pattern.split('/'); - const pathParts = path.split('/'); - if (patternParts.length !== pathParts.length) return null; - const params: Record = {}; - for (let i = 0; i < patternParts.length; i++) { - if (patternParts[i].startsWith(':')) { - params[patternParts[i].substring(1)] = pathParts[i]; - } else if (patternParts[i] !== pathParts[i]) { - return null; - } - } - return params; - }; - - // Try to get route definitions from the AI service's cached routes - const routes = (this.kernel as any).__aiRoutes as Array<{ - method: string; path: string; handler: (req: any) => Promise; auth?: boolean; - }> | undefined; - - if (!routes) { - return { - handled: true, - response: { - status: 503, - body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } }, - }, - }; - } - - for (const route of routes) { - if (route.method !== method) continue; - const params = matchRoute(route.path, fullPath); - if (params === null) continue; - - // Enforce the route's declared `auth` contract. Nothing upstream - // does: `enforceAuthGate` only covers ADR-0069 password/MFA gates - // and `enforceProjectMembership` bails when the request is - // anonymous or unscoped — so without this an anonymous caller - // reached `auth: true` handlers (e.g. GET /ai/status) and got the - // 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; - // `requireAuth && route.auth !== false` is the AI-route contract; - // the shared function owns the anonymous decision itself. - if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: gec?.userId, isSystem: gec?.isSystem })) { - return { - handled: true, - response: this.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), - }; - } - } - - // Resolve `req.user` from the already-resolved ExecutionContext so - // AI route handlers can attribute the call to the authenticated - // actor (drives auto-titled conversations, permission-aware - // tools, HITL conversation linkage, …). Falls back to undefined - // for anonymous requests (only reachable when the deployment does - // NOT require auth — the gate above rejects them otherwise). - const ec: any = context.executionContext; - // `ai_seat` is synthesized into ec.permissions by resolveExecutionContext - // (the single, scope-correct source — security/resolve-execution-context.ts), - // so it flows through here with no extra per-request lookup. - const user = ec?.userId - ? { - userId: ec.userId, - id: ec.userId, - displayName: ec.userDisplayName ?? ec.userName ?? ec.userId, - email: ec.userEmail, - roles: Array.isArray(ec.positions) ? ec.positions : [], - permissions: Array.isArray(ec.permissions) ? ec.permissions : [], - organizationId: ec.tenantId, - } - : undefined; - - const result = await route.handler({ - body, - params, - query, - headers: context.request?.headers, - user, - }); - - if (result.stream && result.events) { - // Return a streaming result for the adapter to handle - return { - handled: true, - result: { - type: 'stream', - contentType: result.vercelDataStream - ? 'text/plain; charset=utf-8' - : 'text/event-stream', - events: result.events, - vercelDataStream: result.vercelDataStream, - headers: { - 'Content-Type': result.vercelDataStream - ? 'text/plain; charset=utf-8' - : 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }, - }, - }; - } - - return { - handled: true, - response: { - status: result.status, - body: result.body, - }, - }; - } - - return { - handled: true, - response: this.routeNotFound(subPath), - }; + return handleAIRequest(this.domainDeps, subPath, method, body, query, context); } /** Thin delegate — body extracted to `./domains/share-links.ts` (D11③ PR-4). */ @@ -3220,9 +2994,7 @@ export class HttpDispatcher { // probes were temporary debugging tools used during the SSO rollout. // 1. System Protocols (Prefix-based) - if (cleanPath.startsWith('/auth')) { - return this.handleAuth(cleanPath.substring(5), method, body, context); - } + // /auth moved to the domain registry (D11 step ③). if (cleanPath.startsWith('/meta')) { return this.handleMetadata(cleanPath.substring(5), context, method, body, query); @@ -3265,10 +3037,7 @@ export class HttpDispatcher { // /packages and /i18n moved to the domain registry (D11 step ③). - // AI Service — delegate to the registered AI route handlers - if (cleanPath.startsWith('/ai')) { - return this.handleAI(cleanPath, method, body, query, context); - } + // /ai moved to the domain registry (D11 step ③). // /share-links moved to the domain registry (D11 step ③).