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
17 changes: 17 additions & 0 deletions .changeset/runtime-auth-ai-extraction.md
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.
46 changes: 46 additions & 0 deletions packages/runtime/src/domain-handler-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ function makeKernel(services: Record<string, any> = {}, state = 'running') {
return kernel;
}

function makeDispatcherWithKernelExtras(services: Record<string, any>, extras: Record<string, any>) {
const kernel = makeKernel(services, 'running');
Object.assign(kernel, extras);
return new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });
}

function makeDispatcher(services: Record<string, any> = {}, state = 'running') {
return new HttpDispatcher(makeKernel(services, state), undefined, {
enforceProjectMembership: false,
Expand Down Expand Up @@ -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' });
});
});
7 changes: 7 additions & 0 deletions packages/runtime/src/domain-handler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ export interface DomainHandlerDeps {
announceKernelEvent(event: string, payload: unknown): Promise<void>;
/** 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<any>; auth?: boolean }> | undefined;
}

/**
Expand Down
183 changes: 183 additions & 0 deletions packages/runtime/src/domains/ai.ts
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),
};
}
122 changes: 122 additions & 0 deletions packages/runtime/src/domains/auth.ts
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() },
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
},
},
};
}

// 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() },
Comment thread
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 };
}
Loading