Repository Public
1/** `buildServer(config, deps)`: the whole server as a testable factory (no listen, no process state). */2import fs from 'node:fs';3import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify';4import cookie from '@fastify/cookie';5import helmet from '@fastify/helmet';6import rateLimit from '@fastify/rate-limit';7import websocket from '@fastify/websocket';8import type { DestinationStream } from 'pino';9import type { ServerConfig } from './config.js';10import { createDb, type Db } from './db.js';11import { Audit } from './audit.js';12import { EventStream } from './events-hub.js';13import { HttpError, type Ctx, type Plugin, type RateClass, type RouteEntry } from './http.js';14import { startJanitor } from './janitor.js';15import type { KernelPort } from './kernel-port.js';16import { LiveRegistry } from './live.js';17import type { ProvidersPort } from './providers-port.js';18import { sha256Hex } from './security/crypto.js';19import { registerGuard } from './security/guard.js';20import { findSessionForRequest } from './security/sessions.js';21import { ensureSetupToken } from './security/setup-token.js';22import { registerSecurityHeaders, registerStatic } from './static.js';23import authPlugin from './modules/auth/index.js';24import eventsWsPlugin from './modules/events-ws.js';25import missionsPlugin from './modules/missions/index.js';26import modelsPlugin from './modules/models/index.js';27import notificationsPlugin from './modules/notifications/index.js';28import { Notifier } from './modules/notifications/service.js';29import openapiPlugin from './modules/openapi.js';30import policyPlugin from './modules/policy/index.js';31import projectsPlugin from './modules/projects/index.js';32import settingsPlugin from './modules/settings/index.js';33import spendPlugin from './modules/spend/index.js';34import systemPlugin from './modules/system/index.js';35import waitsPlugin from './modules/waits/index.js';3637export const JSON_BODY_LIMIT = 1024 * 1024;38export const WS_MAX_FRAME = 64 * 1024;3940/** Never logged: credentials, tokens, and any body field that could carry one. */41export const REDACT_PATHS = [42 'req.headers.cookie', 'req.headers.authorization', 'req.headers["x-csrf-token"]', 'headers.cookie', 'headers.authorization', 'headers["x-csrf-token"]',43 ...['password', 'currentPassword', 'newPassword', 'token', 'setupToken', 'csrfToken', 'secret', 'totp', 'code', 'command', 'content', 'env', 'apiKey', 'gitUrl', 'headers.*'].flatMap((k) => [k, `*.${k}`, `body.${k}`, `*.body.${k}`]),44];4546export interface BuildDeps {47 /** Durable-execution port: `loadKernel()` in production, `FakeKernel` in tests. */48 kernel: KernelPort;49 /** Model catalogue port; null/undefined ⇒ `GET /models` is empty. */50 providers?: ProvidersPort | null;51 db?: Db;52 now?: () => number;53 logStream?: DestinationStream;54}55export interface Server {56 app: FastifyInstance;57 ctx: Ctx;58 /** Subscribes to the kernel event feed (+ janitor when `timers`). */59 startBackground(opts?: { timers?: boolean }): Promise<void>;60 close(): Promise<void>;61}6263export async function buildServer(config: ServerConfig, deps: BuildDeps): Promise<Server> {64 // Log lines pass through the secret scrubber as well (a key could sit in a requested URL or an error message).65 const logSink: DestinationStream = deps.logStream ?? process.stdout;66 const logStream: DestinationStream = config.secrets.active ? { write: (line: string) => logSink.write(config.secrets.scrub(line)) } : logSink;67 const app = Fastify({68 logger: { level: config.logLevel, redact: { paths: REDACT_PATHS, censor: '[redacted]' }, stream: logStream },69 trustProxy: config.trustedProxies, // X-Forwarded-For is honoured ONLY from these peers70 bodyLimit: JSON_BODY_LIMIT,71 routerOptions: { maxParamLength: 200 },72 });73 const db = deps.db ?? createDb(config.databaseUrl);74 const now = deps.now ?? Date.now;75 const notifier = new Notifier(db);76 const log = app.log;77 const ctx: Ctx = {78 config, db, log, notifier, now,79 kernel: deps.kernel,80 providers: deps.providers ?? null,81 events: new EventStream({ db, kernel: deps.kernel, log, notifier, config }),82 live: new LiveRegistry(),83 audit: new Audit(db, log),84 routes: [],85 };8687 app.addHook('onRoute', (r) => {88 const methods = Array.isArray(r.method) ? r.method : [r.method];89 const cfg = (r.config ?? {}) as { auth?: RouteEntry['auth']; doc?: RouteEntry['doc'] };90 for (const method of methods) {91 ctx.routes.push({ method, url: r.url, auth: cfg.auth ?? 'full', doc: cfg.doc ?? null, websocket: (r as { websocket?: boolean }).websocket === true });92 }93 });9495 app.setErrorHandler((error: FastifyError | HttpError, req, reply) => {96 let status = 500;97 let code = 'internal';98 let message = 'internal error';99 let details: unknown;100 if (error instanceof HttpError) {101 ({ status, code, message, details } = error);102 } else if (typeof error.statusCode === 'number' && error.statusCode >= 400 && error.statusCode < 500) {103 status = error.statusCode;104 code = status === 429 ? 'rate_limited' : status === 413 ? 'payload_too_large' : status === 415 ? 'unsupported_media_type' : 'bad_request';105 message = status === 429 ? 'too many requests' : error.message;106 }107 if (status >= 500) req.log.error({ err: error }, 'request failed');108 const retryAfterSec = (details as { retryAfterSec?: unknown } | undefined)?.retryAfterSec;109 return reply.code(status).send({110 error: { code, message, ...(details !== undefined ? { details } : {}) },111 // Login lockouts also expose the wait in the `LoginStep` shape the web client reads.112 ...(typeof retryAfterSec === 'number' ? { ok: false, next: 'done', retryAfterSec } : {}),113 });114 });115116 await app.register(cookie);117 await app.register(helmet, {118 contentSecurityPolicy: false, // set by static.ts (per-document script hashes)119 hsts: { maxAge: 63072000, includeSubDomains: true, preload: false },120 referrerPolicy: { policy: 'no-referrer' },121 frameguard: { action: 'deny' },122 crossOriginEmbedderPolicy: false,123 crossOriginResourcePolicy: { policy: 'same-origin' },124 });125 registerSecurityHeaders(app, config);126 // Defence in depth: whatever a journal row, an event or an error message contains, the model127 // API key values never leave through an API response (WebSocket frames: see events-hub.ts).128 app.addHook('onSend', async (req, reply, payload) => {129 if (!config.secrets.active || !req.url.startsWith('/api/')) return payload;130 for (const [name, value] of Object.entries(reply.getHeaders())) {131 if (typeof value !== 'string') continue;132 const clean = config.secrets.scrub(value);133 if (clean !== value) void reply.header(name, clean);134 }135 return typeof payload === 'string' ? config.secrets.scrub(payload) : payload;136 });137 // Buckets (see `RateClass`): credential checks are strict per client IP; the read-only138 // `GET /auth/status` and `GET /health` have their own generous per-IP buckets, so page loads139 // and health checks can neither exhaust nor be starved by the login budget; everything else140 // is limited per session cookie.141 const rateClassOf = (req: FastifyRequest): RateClass => {142 const cfg = req.routeOptions.config;143 return cfg.rate ?? (cfg.auth === 'preauth' ? 'credentials' : cfg.auth === 'public' ? 'health' : 'api');144 };145 await app.register(rateLimit, {146 global: true,147 timeWindow: 60_000,148 allowList: (req) => !req.url.startsWith('/api/'),149 keyGenerator: async (req) => {150 const cls = rateClassOf(req);151 if (cls === 'credentials') return `ip:pre:${req.ip}`;152 if (cls === 'health') return `ip:health:${req.ip}`;153 const sid = req.cookies[config.cookieName];154 if (cls === 'status') {155 // Only a real session moves the caller to its own bucket: a made-up cookie stays per IP.156 const row = sid ? await findSessionForRequest(db, req, sid).catch(() => null) : null;157 return row && sid ? `sid:${sha256Hex(sid)}` : `ip:status:${req.ip}`;158 }159 return sid ? `sid:${sha256Hex(sid)}` : `ip:api:${req.ip}`;160 },161 max: (req, key) => {162 const cls = rateClassOf(req);163 if (cls === 'credentials') return config.rateLimit.preAuth;164 if (cls === 'health') return config.rateLimit.health;165 if (cls === 'status' && !key.startsWith('sid:')) return config.rateLimit.status;166 return config.rateLimit.api;167 },168 });169 await app.register(websocket, { options: { maxPayload: WS_MAX_FRAME, perMessageDeflate: false } });170 registerGuard(app, ctx);171 await registerStatic(app, config);172173 const plugins: Plugin[] = [174 systemPlugin, authPlugin, projectsPlugin, modelsPlugin, missionsPlugin, waitsPlugin, policyPlugin, spendPlugin,175 eventsWsPlugin, notificationsPlugin, settingsPlugin, openapiPlugin,176 ];177 await app.register(async (api) => {178 for (const plugin of plugins) await plugin(api, ctx);179 }, { prefix: '/api/v1' });180181 fs.mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });182 await ensureSetupToken(db, config.dataDir);183 await app.ready();184185 const stops: (() => void)[] = [];186 let started = false;187 return {188 app,189 ctx,190 async startBackground(opts = {}) {191 if (started) return;192 started = true;193 await ctx.events.start();194 if (opts.timers !== false) stops.push(startJanitor(db, log));195 },196 async close() {197 for (const stop of stops) stop();198 await ctx.events.stop();199 ctx.live.closeAll(1001, 'server shutting down');200 await app.close();201 if (!deps.db) await db.end();202 },203 };204}205