SPB Git

Repository Public

app.ts 9.4 KB 205 lines typescript

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
Files main
Menu

Theme