Integrating & operating
The system's edges: providers and tools, the API surface, the remote bridge, datasets, and the two live migration roadmaps.
src/api/server.ts825 lines · startApiServer L70–824
Outline 2 symbols
- logger const
- startApiServer function export
1/**
2 * The Shem API Server — HTTP + WebSocket server for both
3 * agentic clients and the visualization frontend.
4 *
5 * Built with Fastify for speed and TypeScript-native plugin system.
6 * WebSocket provides real-time event streaming (ShemEvents).
7 *
8 * Endpoints:
9 * POST /api/sessions — Create a new analysis session
10 * GET /api/sessions — List active sessions
11 * GET /api/sessions/:id — Get session status
12 * GET /api/sessions/:id/events — WebSocket event stream
13 * POST /api/sessions/:id/gate — Submit gate decision
14 * DELETE /api/sessions/:id — Cancel session
15 * GET /api/audit-logs — List audit log files
16 * GET /api/audit-logs/:sessionId — Get parsed audit entries
17 * GET /api/replay/:sessionId — WebSocket replay from JSONL
18 * GET /health — Health check
19 */
20
21import * as path from 'node:path';
22import * as fs from 'node:fs';
23import { fileURLToPath } from 'node:url';
24import { timingSafeEqual } from 'node:crypto';
25import Fastify from 'fastify';
26import fastifyWebsocket from '@fastify/websocket';
27import fastifyCors from '@fastify/cors';
28import fastifyStatic from '@fastify/static';
29import fastifyMultipart from '@fastify/multipart';
30import fastifyRateLimit from '@fastify/rate-limit';
31import fastifyHelmet from '@fastify/helmet';
32import { SessionManager } from '../session/session-manager.js';
33import { getDailySpendStats } from '../utils/spend-tracker.js';
34import { registerSessionRoutes } from './routes/sessions.js';
35import { getWsConnectionCount } from './ws-handler.js';
36import { registerReplayRoutes } from './routes/replay.js';
37import { registerMatterRoutes } from './routes/matters.js';
38import { registerAgentRoutes } from './routes/agents.js';
39import { registerWorkflowRoutes } from './routes/workflows.js';
40import { registerBriefingRoutes } from './routes/briefing.js';
41import { registerAgentBuilderRoutes } from './routes/agent-builder.js';
42import { registerPartnerRoutes } from './routes/partner.js';
43import { registerVoiceRoutes } from './routes/voice.js';
44import { registerEngageRoutes } from './routes/engage.js';
45import { registerCapabilitiesRoutes } from './routes/capabilities.js';
46import { registerWellKnownRoutes } from './routes/well-known.js';
47import { registerPricingRoutes } from './routes/pricing.js';
48import { registerReputationRoutes } from './routes/reputation.js';
49import { registerDocumentRoutes } from './routes/documents.js';
50import { registerKnowledgeBaseRoutes } from './routes/knowledge-base.js';
51import { registerVerifyRoutes } from './routes/verify.js';
52import { registerClawRoutes } from './routes/claw.js';
53import { registerChallengeRoutes } from './routes/challenge.js';
54import { registerWaitlistRoutes } from './routes/waitlist.js';
55import { registerAdminRoutes } from './routes/admin.js';
56import { maybeRegisterRemoteBridge } from '../mcp/remote-bridge/index.js';
57import { registerReferralRoutes } from './routes/referral.js';
58import { registerTemplateRoutes } from './routes/templates.js';
59import { ClientRegistry, createAuthMiddleware, registerAuthRoutes } from './middleware/auth.js';
60import { createPerUserRateLimitHook } from './middleware/rate-limit.js';
61import { registerUserAuthRoutes } from './routes/auth-routes.js';
62import { registerGoogleAuthRoutes } from './routes/google-auth.js';
63import { initDatabase, cleanExpiredTokens, cleanExpiredUserTokens, rotateAuditLog, logAuditEvent, cleanOldArchives, sweepStaleHolds } from '../db/database.js';
64import { config } from '../config.js';
65import { captureError, isSentryEnabled } from '../utils/sentry.js';
66import { createLogger } from '../utils/logger.js';
67
68const logger = createLogger('SERVER');
69
70export async function startApiServer(port: number): Promise<void> {
71 // SECURITY (fail-safe): multi-user auth ENFORCEMENT is not implemented in
72 // this build — createAuthMiddleware/createRequireVerifiedHook inject a shared
73 // synthetic `local-user` for every request. Setting LAVERN_AUTH_ENABLED=true
74 // would still register the login/OAuth routes and advertise auth as "on" to
75 // the dashboard, while every request silently runs as that shared user — a
76 // total authN/authZ bypass. Refuse to boot rather than give a false sense of
77 // security. (Run in LOCAL MODE, or reimplement enforcement before enabling.)
78 if (config.authEnabled) {
79 throw new Error(
80 'LAVERN_AUTH_ENABLED=true is not supported in this build: multi-user ' +
81 'authentication enforcement is not implemented, so the server would treat ' +
82 'every request as a single shared local-user (total auth bypass). Unset ' +
83 'LAVERN_AUTH_ENABLED to run in LOCAL MODE (single-user).',
84 );
85 }
86
87 const isProd = config.isProduction;
88 const fastify = Fastify({
89 trustProxy: config.trustProxy,
90 disableRequestLogging: true,
91 logger: {
92 level: config.logLevel === 'debug' ? 'debug' : 'info',
93 ...(isProd ? {} : {
94 transport: {
95 target: 'pino-pretty',
96 options: {
97 translateTime: 'HH:MM:ss Z',
98 ignore: 'pid,hostname',
99 },
100 },
101 }),
102 },
103 });
104
105 fastify.addHook('onResponse', (request, reply, done) => {
106 if (reply.statusCode >= 400) {
107 request.log.warn({
108 method: request.method,
109 url: request.url,
110 statusCode: reply.statusCode,
111 responseTime: reply.elapsedTime,
112 }, 'request failed');
113 }
114 done();
115 });
116
117 // ── Plugins ──────────────────────────────────────────────────────────
118
119 // ── Security headers ─────────────────────────────────────────────────
120 // Helmet sets a baseline of defensive HTTP headers — frame protection,
121 // content sniffing block, referrer policy, HSTS in production.
122 // CSP is intentionally NOT enforced here yet: the dashboard inlines styles
123 // in many components and a strict CSP would blank-screen them. CSP is
124 // tracked as a follow-up in SECURITY.md.
125 await fastify.register(fastifyHelmet, {
126 contentSecurityPolicy: false, // see comment above; enable after CSP audit
127 crossOriginEmbedderPolicy: false, // we serve user-fetched DiceBear avatars
128 crossOriginResourcePolicy: { policy: 'cross-origin' }, // dashboard is on a different port in dev
129 hsts: config.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
130 frameguard: { action: 'deny' },
131 referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
132 });
133
134 await fastify.register(fastifyWebsocket);
135
136 // CORS — locked to specific origins by default.
137 // Set SHEM_CORS_ORIGINS='*' to allow all (NOT recommended for production).
138 if (config.corsOrigins === '*') {
139 console.warn('[SECURITY] CORS is set to wildcard (*) — all origins can access the API. Set SHEM_CORS_ORIGINS to restrict.');
140 }
141 await fastify.register(fastifyCors, {
142 origin: config.corsOrigins === '*' ? true : config.corsOrigins.split(',').map(o => o.trim()),
143 methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
144 credentials: true,
145 });
146
147 await fastify.register(fastifyMultipart, {
148 limits: { fileSize: config.maxUploadBytes },
149 });
150
151 // Rate limiting — global default + stricter limit on session creation.
152 //
153 // `allowList` honors a shared-secret bypass for load testing. When
154 // LAVERN_LOAD_TEST_BYPASS_KEY is set AND the request carries a matching
155 // `X-Load-Test-Bypass` header, the limit is skipped — this is required to
156 // drive 1500 simulated users from a single IP without signup (3/min/IP) or
157 // the global (100/min/IP) throttle blocking the run. The per-route auth
158 // limits configured in auth-routes.ts also inherit this allow list, so one
159 // toggle unblocks signup/login at scale.
160 //
161 // Compared in constant time to avoid exposing the secret via timing. Empty
162 // key disables the bypass entirely — production never presents the header
163 // to a server missing the env.
164 // Production safety: refuse to honour the bypass when NODE_ENV=production
165 // unless the operator has ALSO set LAVERN_ALLOW_LOAD_TEST_BYPASS=1. Without
166 // this, an OSS user shipping with a stray bypass key in their .env could
167 // accept arbitrary auth/rate-limit-bypass requests on the open internet.
168 // (`isProd` is already declared at the top of startApiServer for HSTS.)
169 const allowBypassInProd = config.allowLoadTestBypassInProd;
170 const loadTestKey =
171 isProd && !allowBypassInProd
172 ? '' // disable in production unless explicitly opted in
173 : config.loadTestBypassKey;
174 if (config.loadTestBypassKey && isProd && !allowBypassInProd) {
175 console.warn(
176 '[SECURITY] LAVERN_LOAD_TEST_BYPASS_KEY is set in production but ' +
177 'LAVERN_ALLOW_LOAD_TEST_BYPASS is not "1". The bypass is DISABLED. ' +
178 'To allow it, also set LAVERN_ALLOW_LOAD_TEST_BYPASS=1 (and understand ' +
179 'that this disables auth + rate limiting for any caller with the key).',
180 );
181 }
182 if (loadTestKey && isProd && allowBypassInProd) {
183 console.warn(
184 '[SECURITY] Load-test bypass is ACTIVE in production. Auth and rate ' +
185 'limits can be skipped by any caller presenting X-Load-Test-Bypass with ' +
186 'the configured key. Disable with LAVERN_ALLOW_LOAD_TEST_BYPASS unset.',
187 );
188 }
189 const loadTestKeyBuf = loadTestKey ? Buffer.from(loadTestKey, 'utf8') : null;
190 await fastify.register(fastifyRateLimit, {
191 max: config.rateLimitMax,
192 timeWindow: config.rateLimitWindowMs,
193 allowList: (req) => {
194 if (!loadTestKeyBuf) return false;
195 const presented = req.headers['x-load-test-bypass'];
196 if (typeof presented !== 'string' || presented.length === 0) return false;
197 const presentedBuf = Buffer.from(presented, 'utf8');
198 if (presentedBuf.length !== loadTestKeyBuf.length) return false;
199 try {
200 return timingSafeEqual(presentedBuf, loadTestKeyBuf);
201 } catch {
202 return false;
203 }
204 },
205 });
206
207 // ── Raw body capture for Stripe webhook ──────────────────────────────
208 // Stripe webhook signature verification requires the raw request body.
209 // We capture it via preParsing hook (only for the webhook route) and
210 // store it on the request object via Fastify request decorator.
211
212 // ── Database ────────────────────────────────────────────────────────
213
214 initDatabase();
215
216 // Clean expired auth tokens at startup and every hour
217 const expired = cleanExpiredTokens();
218 if (expired > 0) console.log(`[AUTH] Cleaned ${expired} expired auth token${expired === 1 ? '' : 's'}`);
219 const expiredUserTokens = cleanExpiredUserTokens();
220 if (expiredUserTokens > 0) console.log(`[AUTH] Cleaned ${expiredUserTokens} expired user token${expiredUserTokens === 1 ? '' : 's'}`);
221
222 // Rotate audit log at startup (retain 90 days)
223 const rotated = rotateAuditLog(90);
224 if (rotated > 0) console.log(`[AUDIT] Rotated ${rotated} audit log entr${rotated === 1 ? 'y' : 'ies'} older than 90 days`);
225
226 // Clean old session archives (retain configurable days, default 180)
227 const archivedCleaned = cleanOldArchives(config.archiveRetentionDays);
228 if (archivedCleaned > 0) console.log(`[ARCHIVE] Cleaned ${archivedCleaned} archived session${archivedCleaned === 1 ? '' : 's'} older than ${config.archiveRetentionDays} days`);
229
230 // Audit fix H10: sweep holds left behind by sessions that crashed before
231 // archive (or by hard restarts). Without this, the hold permanently locks
232 // billable hours from the user's available balance.
233 const sessionTtlMs = config.sessionTtlMs ?? 4 * 60 * 60 * 1000;
234 const staleHoldsReleased = sweepStaleHolds(sessionTtlMs);
235 if (staleHoldsReleased > 0) console.log(`[BILLING] Released ${staleHoldsReleased} stale hold${staleHoldsReleased === 1 ? '' : 's'} older than ${(sessionTtlMs / 3600000).toFixed(1)}h`);
236
237 const tokenCleanupInterval = setInterval(() => {
238 const cleaned = cleanExpiredTokens();
239 if (cleaned > 0) console.log(`[AUTH] Cleaned ${cleaned} expired auth token${cleaned === 1 ? '' : 's'}`);
240 const cleanedUser = cleanExpiredUserTokens();
241 if (cleanedUser > 0) console.log(`[AUTH] Cleaned ${cleanedUser} expired user token${cleanedUser === 1 ? '' : 's'}`);
242 // Also rotate audit log hourly
243 const auditRotated = rotateAuditLog(90);
244 if (auditRotated > 0) console.log(`[AUDIT] Rotated ${auditRotated} audit log entries`);
245 // Clean old session archives hourly
246 const archiveCleaned = cleanOldArchives(config.archiveRetentionDays);
247 if (archiveCleaned > 0) console.log(`[ARCHIVE] Cleaned ${archiveCleaned} old archived sessions`);
248 // Hourly stale-hold sweep — covers in-flight crashes between boots.
249 const heldReleased = sweepStaleHolds(sessionTtlMs);
250 if (heldReleased > 0) console.log(`[BILLING] Released ${heldReleased} stale holds`);
251 }, 60 * 60 * 1000); // 1 hour
252 tokenCleanupInterval.unref(); // Don't keep the process alive for cleanup
253
254 // ── Shared State ─────────────────────────────────────────────────────
255
256 const sessionManager = new SessionManager();
257 const clientRegistry = new ClientRegistry();
258 clientRegistry.loadFromDb(); // Restore API clients from SQLite
259
260 // ── Authentication ──────────────────────────────────────────────────
261
262 // ── Public paths ──────────────────────────────────────────────────
263 // Paths listed here bypass auth (no Bearer token or cookie required).
264 // Most POST mutations require a lavern_token cookie (set by
265 // /api/auth/login). Exceptions: session creation is public so the
266 // QuickStart express lane works without login.
267 const publicPaths: string[] = [
268 '/health',
269 '/health/capacity',
270 '/api/health',
271 '/',
272 // Session access — individual session detail/WS is public (session ID is a capability token).
273 // Archive endpoints (GET /api/sessions/archive[/...]) explicitly enforce
274 // auth in-route (see sessions.ts) — fix C1 closed the leak there.
275 // Future hardening (M16): replace this wildcard with explicit per-route
276 // entries once the auth matcher supports param syntax.
277 'GET /api/sessions/*',
278 // NOTE: /api/clients, /api/audit-logs, /api/replay are NOT public.
279 // They contain sensitive data and require authentication.
280 'GET /api/agents/*', // Agent profiles, presets, recommendations
281 'GET /api/workflows', // Workflow templates
282 // Agent API — public discovery endpoints (read-only)
283 'GET /api/capabilities', // Machine-readable service manifest
284 'GET /.well-known/*', // A2A agent card + OpenAI plugin manifest
285 'GET /openapi.json', // OpenAPI 3.0 spec
286 'GET /llms.txt', // AI crawler guidance
287 'GET /api/pricing', // Deterministic cost estimates
288 'GET /api/reputation', // Machine-readable trust signal
289 // Session creation requires auth — all users must log in before starting sessions.
290 // Briefing — intake flow before login
291 'POST /api/briefing/interview',
292 'POST /api/briefing/analyze',
293 // Partner consultation — conversational intake
294 'POST /api/partner/consult',
295 // Voice — STT/TTS proxy (API keys stay server-side)
296 'GET /api/voice/stt',
297 'POST /api/voice/tts',
298 // User auth routes (public by definition)
299 'POST /api/auth/signup',
300 'POST /api/auth/login',
301 'POST /api/auth/logout',
302 'GET /api/auth/me',
303 // v23: Password reset + email verification (public by definition)
304 'POST /api/auth/forgot-password',
305 'POST /api/auth/reset-password',
306 'POST /api/auth/verify-email',
307 // Google OAuth flow
308 'GET /api/auth/google',
309 'GET /api/auth/google/callback',
310 // Public agent-share — anyone can view a shared agent + its OG image.
311 // The token is the capability (32-char base64url, unguessable). Owner-only
312 // mutations (POST/DELETE share) require auth and are NOT in this list.
313 'GET /api/agents/share/*',
314 // Public team-share — same capability-token pattern as agent share.
315 'GET /api/teams/share/*',
316 // Session-scoped POST mutations — scoped by session ID, work without login
317 // so the QuickStart → Working → Delivery flow doesn't require auth.
318 // Session ID acts as the auth token (only the user who created it has it).
319 // SECURITY NOTE: This makes ALL session POST mutations public (gate decisions,
320 // derivatives, conversations, reassembly). This is by design — session IDs
321 // are unguessable UUIDs that serve as capability tokens. Each route also
322 // enforces checkSessionOwnership() as a secondary guard.
323 'POST /api/sessions/*',
324 // Session cancellation — allows QuickStart users to cancel without login.
325 // Same session-ID-as-capability-token model as POST mutations above.
326 'DELETE /api/sessions/*',
327 // Document parsing — needed by Challenge and Briefing before login
328 'POST /api/documents/parse',
329 // The Lavern Challenge — zero-friction, no auth required
330 'POST /api/challenge',
331 // v22: Waitlist — public join + status, admin endpoints verify X-Admin-Key internally
332 'POST /api/waitlist',
333 'GET /api/waitlist/status',
334 'POST /api/waitlist/invite',
335 'GET /api/waitlist/list',
336 // Admin endpoints verify X-Admin-Key internally; bypass user auth.
337 'GET /api/admin/spend-status',
338 'GET /api/admin/user-spend',
339 // Remote MCP bridge authenticates via its own shared-secret Bearer header
340 // + X-Lavern-Session-Id; it must bypass the global cookie/Bearer middleware.
341 'POST /api/mcp/bridge',
342 // Frontend static files (prefix match — trailing /)
343 '/dashboard/',
344 ];
345
346 const authMiddleware = createAuthMiddleware(clientRegistry, publicPaths);
347 fastify.addHook('onRequest', authMiddleware);
348
349 // ── Email Verification Enforcement ────────────────────────────────────
350 // Runs AFTER auth (needs userId). Blocks unverified browser users from
351 // paid mutations. Anonymous QuickStart, GET requests, API clients, and
352 // exempt paths (auth, waitlist, etc.) pass through.
353 const { createRequireVerifiedHook } = await import('./middleware/require-verified.js');
354 const requireVerified = createRequireVerifiedHook([
355 '/api/auth/', // All auth routes (login, signup, verify, reset, etc.)
356 '/api/waitlist', // Public waitlist operations
357 '/api/documents/', // Document parsing (needed by Challenge + Briefing)
358 '/api/challenge', // Zero-friction challenge
359 '/api/partner/', // Conversational intake
360 '/api/briefing/', // Intake flow
361 '/api/voice/', // STT/TTS proxy
362 ]);
363 fastify.addHook('onRequest', requireVerified);
364
365 // ── Per-User Rate Limiting ────────────────────────────────────────────
366 // Runs AFTER auth so we have userId. 30 req/min per user, 5 concurrent sessions.
367 const perUserRateLimit = createPerUserRateLimitHook(sessionManager);
368 fastify.addHook('onRequest', perUserRateLimit);
369
370 // ── CSRF Protection ──────────────────────────────────────────────────
371 // For cookie-authenticated state-changing requests, verify Origin header
372 // matches allowed CORS origins. Bearer token requests are exempt (immune
373 // to CSRF since the attacker can't inject the Authorization header).
374 const allowedOrigins = new Set(
375 config.corsOrigins === '*'
376 ? [] // Can't validate if all origins allowed
377 : config.corsOrigins.split(',').map(o => o.trim())
378 );
379
380 fastify.addHook('onRequest', async (request, reply) => {
381 const isWebSocketUpgrade =
382 request.method === 'GET' &&
383 request.headers.upgrade?.toLowerCase() === 'websocket';
384
385 // Audit follow-up: WebSocket handshakes are GETs but they ARE state-changing
386 // (they open a long-lived event stream). SameSite=Lax already prevents
387 // cross-origin cookies on WS handshakes in modern browsers, but explicit
388 // origin validation closes the door for older clients + edge cases.
389 if (!isWebSocketUpgrade) {
390 // Only check state-changing methods for non-WS requests.
391 if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') return;
392 }
393
394 // Bearer token auth is immune to CSRF — skip
395 if (request.headers.authorization?.startsWith('Bearer ')) return;
396
397 // If CORS is wildcard, we can't validate (logged warning above)
398 if (config.corsOrigins === '*') return;
399
400 // Cookie-authenticated request: verify Origin
401 const origin = request.headers.origin;
402 if (origin && !allowedOrigins.has(origin)) {
403 return reply.status(403).send({ error: 'Origin not allowed' });
404 }
405
406 // If no Origin header, check Referer (some browsers omit Origin on same-origin)
407 if (!origin) {
408 const referer = request.headers.referer;
409 if (referer) {
410 try {
411 const refOrigin = new URL(referer).origin;
412 if (!allowedOrigins.has(refOrigin)) {
413 return reply.status(403).send({ error: 'Origin not allowed' });
414 }
415 } catch {
416 // Malformed referer — reject for safety (legitimate clients send well-formed referers)
417 return reply.status(403).send({ error: 'Malformed referer' });
418 }
419 } else if (isWebSocketUpgrade) {
420 // WS handshake with no Origin AND no Referer is suspicious — legitimate
421 // browsers always send Origin on WebSocket upgrades. Reject.
422 return reply.status(403).send({ error: 'Origin required for WebSocket' });
423 }
424 // No Origin or Referer on a non-WS request: could be curl, Postman, or
425 // API client. SameSite=Lax cookie already prevents cross-site form submissions.
426 }
427 });
428
429 // ── Routes ───────────────────────────────────────────────────────────
430
431 // HEAD /health + /api/health — for liveness probes (curl -I, uptime checks, menubar app) that only care about the status code.
432 const headHealth = async (_request: unknown, reply: { code: (n: number) => { send: () => void } }) => {
433 reply.code(200).send();
434 };
435 fastify.head('/health', headHealth);
436 fastify.head('/api/health', headHealth);
437
438 // Health check — shallow (fast, for load balancers) + deep (checks dependencies)
439 fastify.get('/health', async (request) => {
440 const deep = (request.query as { deep?: string }).deep === 'true';
441
442 const base = {
443 status: 'ok' as string,
444 service: 'the-shem',
445 version: config.version,
446 sessions: sessionManager.size,
447 wsConnections: getWsConnectionCount(),
448 timestamp: new Date().toISOString(),
449 };
450
451 if (!deep) return base;
452
453 // Deep health check — verify dependencies
454 const checks: Record<string, { ok: boolean; detail?: string }> = {};
455
456 // SQLite writable + size
457 try {
458 const db = (await import('../db/database.js')).getDb();
459 db.prepare('SELECT 1').get();
460 let dbSizeDetail = 'writable';
461 try {
462 const dbStat = fs.statSync(config.dbPath);
463 const sizeMb = (dbStat.size / (1024 * 1024)).toFixed(1);
464 dbSizeDetail = `writable, ${sizeMb} MB`;
465 } catch { /* size check optional */ }
466 checks.database = { ok: true, detail: dbSizeDetail };
467 } catch (err) {
468 checks.database = { ok: false, detail: err instanceof Error ? err.message : 'unavailable' };
469 }
470
471 // LLM API key present
472 checks.llm = {
473 ok: !!config.anthropic.apiKey,
474 detail: config.anthropic.apiKey ? 'configured' : 'ANTHROPIC_API_KEY not set',
475 };
476
477 // Email service
478 checks.email = {
479 ok: !!config.email.resendApiKey,
480 detail: config.email.resendApiKey ? 'configured' : 'RESEND_API_KEY not set',
481 };
482
483 // Disk space (data directory)
484 try {
485 const dataDir = path.dirname(config.dbPath);
486 if (fs.existsSync(dataDir)) {
487 const stats = fs.statfsSync(dataDir);
488 const freeGb = (stats.bavail * stats.bsize) / (1024 ** 3);
489 checks.disk = {
490 ok: freeGb > 1,
491 detail: `${freeGb.toFixed(1)} GB free`,
492 };
493 } else {
494 checks.disk = { ok: false, detail: 'data directory does not exist' };
495 }
496 } catch {
497 checks.disk = { ok: true, detail: 'statfs not available (skipped)' };
498 }
499
500 const allOk = Object.values(checks).every(c => c.ok);
501
502 return {
503 ...base,
504 status: allOk ? 'ok' : 'degraded',
505 checks,
506 };
507 });
508
509 // v27: Capacity endpoint — for monitoring and frontend queue display
510 fastify.get('/health/capacity', async () => {
511 const capacity = sessionManager.getCapacity();
512 const spend = getDailySpendStats();
513 return {
514 current: capacity.current,
515 max: capacity.max,
516 available: capacity.available,
517 estimatedWaitMs: capacity.estimatedWaitMs,
518 utilization: capacity.max > 0 ? Math.round((capacity.current / capacity.max) * 100) : 0,
519 dailySpend: {
520 date: spend.date,
521 totalUsd: Math.round(spend.totalUsd * 100) / 100,
522 capUsd: spend.capUsd,
523 pct: Math.round(spend.pct),
524 capReached: spend.capReached,
525 },
526 };
527 });
528
529 // API info
530 fastify.get('/', async () => ({
531 name: 'The Shem API',
532 version: config.version,
533 description: 'Multi-agent legal design system — API & WebSocket server',
534 endpoints: {
535 sessions: {
536 create: 'POST /api/sessions',
537 list: 'GET /api/sessions',
538 get: 'GET /api/sessions/:id',
539 events: 'GET /api/sessions/:id/events (WebSocket)',
540 gate: 'POST /api/sessions/:id/gate',
541 cancel: 'DELETE /api/sessions/:id',
542 },
543 audit: {
544 list: 'GET /api/audit-logs',
545 get: 'GET /api/audit-logs/:sessionId',
546 replay: 'GET /api/replay/:sessionId (WebSocket)',
547 },
548 matters: {
549 create: 'POST /api/matters',
550 list: 'GET /api/matters',
551 get: 'GET /api/matters/:id',
552 accept: 'POST /api/matters/:id/accept',
553 team: 'POST /api/matters/:id/team',
554 },
555 agents: {
556 profiles: 'GET /api/agents/profiles',
557 profile: 'GET /api/agents/profiles/:role',
558 presets: 'GET /api/agents/presets',
559 recommend: 'GET /api/agents/recommend',
560 },
561 workflows: {
562 list: 'GET /api/workflows',
563 },
564 clients: {
565 register: 'POST /api/clients',
566 get: 'GET /api/clients/:id',
567 list: 'GET /api/clients',
568 },
569 agentApi: {
570 capabilities: 'GET /api/capabilities',
571 engage: 'POST /api/engage',
572 pricing: 'GET /api/pricing',
573 reputation: 'GET /api/reputation',
574 },
575 discovery: {
576 agentCard: 'GET /.well-known/agent.json',
577 pluginManifest: 'GET /.well-known/ai-plugin.json',
578 openapi: 'GET /openapi.json',
579 llmsTxt: 'GET /llms.txt',
580 },
581 auth: {
582 signup: 'POST /api/auth/signup',
583 login: 'POST /api/auth/login',
584 logout: 'POST /api/auth/logout',
585 me: 'GET /api/auth/me',
586 profile: 'PUT /api/auth/profile',
587 },
588 documents: {
589 parse: 'POST /api/documents/parse (multipart)',
590 },
591 claw: {
592 status: 'GET /api/claw/status',
593 documents: 'GET /api/claw/documents',
594 deliveries: 'GET /api/claw/deliveries',
595 scan: 'POST /api/claw/scan',
596 },
597 knowledgeBase: {
598 createCollection: 'POST /api/knowledge-base/collections',
599 listCollections: 'GET /api/knowledge-base/collections',
600 upload: 'POST /api/knowledge-base/collections/:id/upload (multipart)',
601 search: 'GET /api/knowledge-base/search?q=...',
602 deleteCollection: 'DELETE /api/knowledge-base/collections/:id',
603 deleteDocument: 'DELETE /api/knowledge-base/documents/:id',
604 },
605 challenge: {
606 compare: 'POST /api/challenge',
607 },
608 health: 'GET /health',
609 },
610 }));
611
612 // /api/capabilities is registered by registerCapabilitiesRoutes() below —
613 // it serves both the dashboard runtime flags and the agent-facing rich
614 // manifest from a single endpoint. Do not duplicate it here.
615
616 // Register route groups
617 registerSessionRoutes(fastify, sessionManager);
618 registerReplayRoutes(fastify);
619 // Auth surface is gated off in LOCAL MODE (v0.15.0 default). When
620 // LAVERN_AUTH_ENABLED=true is set, the API-key client registry,
621 // user-account signup/login/email-verify routes, and Google OAuth
622 // come online together. When off, every request runs as the
623 // synthetic `local-user` injected by createAuthMiddleware.
624 if (config.authEnabled) {
625 registerAuthRoutes(fastify, clientRegistry);
626 registerUserAuthRoutes(fastify);
627 registerGoogleAuthRoutes(fastify);
628 // Referral stats are per-user, so they only make sense with multi-user
629 // auth. Keep them gated (404 in LOCAL MODE) alongside the other auth-shaped
630 // routes — previously this was registered unconditionally further down.
631 registerReferralRoutes(fastify);
632 }
633 // v8: Pre-engagement & team staffing routes
634 registerMatterRoutes(fastify);
635 registerAgentRoutes(fastify);
636 // v9: Engagement configurator
637 registerWorkflowRoutes(fastify);
638 // v10: LLM-powered briefing analysis
639 registerBriefingRoutes(fastify);
640 registerAgentBuilderRoutes(fastify);
641 // v11: Partner consultation (conversational intake)
642 registerPartnerRoutes(fastify);
643 registerVoiceRoutes(fastify);
644 // v10: Agent API — engage endpoint + capabilities manifest
645 registerEngageRoutes(fastify, sessionManager);
646 registerCapabilitiesRoutes(fastify);
647 // v16: Agent-first discovery + intelligence layer
648 registerWellKnownRoutes(fastify);
649 registerPricingRoutes(fastify);
650 registerReputationRoutes(fastify);
651 // v12: Document parsing
652 registerDocumentRoutes(fastify);
653 // v15: Knowledge Base — reference document collections
654 registerKnowledgeBaseRoutes(fastify);
655 // v16: Standalone document verification
656 registerVerifyRoutes(fastify, sessionManager);
657 // Claw Mode — remote monitoring & control
658 registerClawRoutes(fastify);
659 // v19: The Lavern Challenge — blind document comparison
660 registerChallengeRoutes(fastify);
661 // v22: Waitlist — join, status, admin invite & listing
662 registerWaitlistRoutes(fastify);
663 // Admin observability endpoints (X-Admin-Key gated)
664 registerAdminRoutes(fastify);
665 // Remote MCP bridge — Managed Agents integration (Stage 1: scaffolded, off
666 // unless both LAVERN_MANAGED_AGENTS_BRIDGE=1 and the shared secret are set).
667 maybeRegisterRemoteBridge(fastify, sessionManager);
668 registerTemplateRoutes(fastify);
669
670 // ── Frontend Static Files ──────────────────────────────────────────
671
672 // Serve viz/dist/ if it exists (production build of the dashboard)
673 const __dirname = path.dirname(fileURLToPath(import.meta.url));
674 const frontendDir = path.resolve(__dirname, '../../viz/dist');
675 if (fs.existsSync(frontendDir)) {
676 await fastify.register(fastifyStatic, {
677 root: frontendDir,
678 prefix: '/dashboard/',
679 decorateReply: false,
680 });
681
682 // Redirect /dashboard to /dashboard/
683 fastify.get('/dashboard', async (_request, reply) => {
684 return reply.redirect('/dashboard/');
685 });
686 }
687
688 // ── Error Monitoring (Sentry-compatible) ────────────────────────────
689 // Centralized in src/utils/sentry.ts so other modules can import captureError
690 // directly (e.g. assembly, dispatch, session archive) rather than threading
691 // it through closure scope.
692 if (isSentryEnabled()) {
693 console.log('[SENTRY] Error monitoring enabled');
694 }
695
696 // Fastify error handler — captures 5xx errors to Sentry
697 fastify.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => {
698 const statusCode = error.statusCode ?? 500;
699 if (statusCode >= 500) {
700 captureError(error, { url: request.url, method: request.method });
701 }
702 reply.status(statusCode).send({
703 statusCode,
704 error: error.name,
705 message: statusCode < 500 ? error.message : 'Internal server error',
706 });
707 });
708
709 // ── Process-level crash protection ────────────────────────────────
710 // Prevent the server from crashing on unhandled errors in background
711 // tasks (dispatch, assembly, WebSocket handlers, etc.)
712
713 let uncaughtCount = 0;
714 const UNCAUGHT_RESET_MS = 60_000; // 1 minute window
715 const MAX_UNCAUGHT = 5; // exit after 5 uncaught exceptions within the window
716
717 process.on('uncaughtException', (err) => {
718 console.error('[FATAL] Uncaught exception:', err);
719 captureError(err, { type: 'uncaughtException' });
720
721 // Exit on truly unrecoverable errors
722 if (err.message?.includes('EADDRINUSE') || err.message?.includes('ENOMEM')) {
723 console.error('[FATAL] Unrecoverable error — shutting down');
724 process.exit(1);
725 }
726
727 // Track frequency — exit if too many uncaught exceptions in rapid succession
728 uncaughtCount++;
729 setTimeout(() => { uncaughtCount = Math.max(0, uncaughtCount - 1); }, UNCAUGHT_RESET_MS);
730 if (uncaughtCount >= MAX_UNCAUGHT) {
731 console.error(`[FATAL] ${MAX_UNCAUGHT} uncaught exceptions within ${UNCAUGHT_RESET_MS / 1000}s — shutting down`);
732 process.exit(1);
733 }
734 });
735
736 process.on('unhandledRejection', (reason, _promise) => {
737 console.error('[WARN] Unhandled promise rejection:', reason);
738 if (reason instanceof Error) captureError(reason, { type: 'unhandledRejection' });
739 // Don't crash — log and continue. Most unhandled rejections come from
740 // fire-and-forget dispatch() or assembly calls that already have their
741 // own error handling. This is a safety net.
742 });
743
744 // ── Graceful shutdown ────────────────────────────────────────────────
745
746 let shuttingDown = false;
747 const shutdown = async (signal: string) => {
748 if (shuttingDown) return;
749 shuttingDown = true;
750 console.log(`\n[SERVER] ${signal} received — shutting down gracefully...`);
751
752 // Stop accepting new connections
753 try {
754 await fastify.close();
755 console.log('[SERVER] Server closed');
756 } catch (err) {
757 console.error('[SERVER] Error during shutdown:', err);
758 }
759
760 // Clean up timers
761 clearInterval(tokenCleanupInterval);
762 sessionManager.stopCleanup();
763
764 // Destroy all active sessions (archives them)
765 for (const session of sessionManager.getAllSessions()) {
766 try {
767 sessionManager.destroySession(session.id, `Server shutdown (${signal})`);
768 } catch { /* best-effort cleanup */ }
769 }
770
771 process.exit(0);
772 };
773
774 process.on('SIGTERM', () => shutdown('SIGTERM'));
775 process.on('SIGINT', () => shutdown('SIGINT'));
776
777 // ── Start ────────────────────────────────────────────────────────────
778
779 try {
780 await fastify.listen({ port, host: config.host });
781
782 const dashboardAvailable = fs.existsSync(frontendDir);
783 const hasAnthropicKey = !!config.anthropic.apiKey && config.anthropic.apiKey.length > 10;
784 const hasMistralKey = !!config.mistral.apiKey && config.mistral.apiKey.length > 10;
785 const provider = config.provider;
786 // Always false here: the startup guard above refuses to boot when
787 // LAVERN_AUTH_ENABLED=true, so the server only ever runs in LOCAL MODE.
788 const authEnabled = config.authEnabled;
789
790 console.log(`
791╔══════════════════════════════════════════════════════════════╗
792║ LAVERN API SERVER ║
793║ The agentic legal architecture ║
794╚══════════════════════════════════════════════════════════════╝
795
796 HTTP: http://localhost:${port}
797 WebSocket: ws://localhost:${port}/api/sessions/:id/events
798 Health: http://localhost:${port}/health
799${dashboardAvailable ? ` Dashboard: http://localhost:${port}/dashboard/` : ' Dashboard: Not built (run "cd viz && npm run build")'}
800
801 ┌─ First 90 seconds ─────────────────────────────────────────┐
802 │ │
803 │ 1. In another terminal: │
804 │ cd viz && npm run dev │
805 │ │
806 │ 2. Open http://localhost:5173 │
807 │ │
808 │ 3. Click "Step In" to start an engagement, or try the │
809 │ cinematic guided tour at http://localhost:5173/#/demo │
810 │ │
811 └────────────────────────────────────────────────────────────┘
812
813 Mode: ${authEnabled ? 'multi-user (LAVERN_AUTH_ENABLED=true)' : 'LOCAL MODE (single-user, auth disabled)'}
814 Provider: ${provider}${provider === 'anthropic' && !hasAnthropicKey ? ` (no ANTHROPIC_API_KEY — demo only)` : ''}${provider === 'mistral' && !hasMistralKey ? ` (no MISTRAL_API_KEY — set it in .env)` : ''}
815 Bundled try: lavern samples/sample-terms-of-service.txt --workflow review
816
817 ${provider === 'anthropic' && !hasAnthropicKey ? `Tip: set ANTHROPIC_API_KEY in .env to enable real engagements.
818 (.env was auto-created from .env.example on first run.)
819` : ''}`);
820 } catch (err) {
821 fastify.log.error(err);
822 process.exit(1);
823 }
824}
825