-
Notifications
You must be signed in to change notification settings - Fork 6
feat(runtime): /auth /ai 两域 handler 体抽出 — ADR-0076 D11 步骤③ PR-7 (#2462) #3551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HttpDispatcherResult> { | ||
| 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<string, string> | null => { | ||
| const patternParts = pattern.split('/'); | ||
| const pathParts = path.split('/'); | ||
| if (patternParts.length !== pathParts.length) return null; | ||
| const params: Record<string, string> = {}; | ||
| 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<any>; 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), | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HttpDispatcherResult> { | ||
| // 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() }, | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| // 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 }; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.