import { resolve } from 'node:path' import Fastify from 'fastify' import { serializerCompiler, validatorCompiler, type ZodTypeProvider, } from '@fastify/type-provider-zod' import { AsyncTask, CronJob } from 'toad-scheduler' import type { AppConfig } from './config.js' import { loadConfig } from './config.js' import authPlugin from './plugins/auth.js' import operationalPlugin from './plugins/operational.js' import corsPlugin from './plugins/cors.js' import dbPlugin from './plugins/db.js' import errorHandlerPlugin from './plugins/error-handler.js' import { healthRoutes } from './routes/health.js' import { controlRoutes } from './routes/control.js' import { auditRoutes } from './routes/audit.js' import { agentRoutes } from './routes/agent.js' import { enrollmentRoutes } from './routes/enrollment.js' import { policyRolloutsRoutes } from './routes/policy-rollouts.js' import { webhooksRoutes } from './routes/webhooks.js' import { refreshAllLists } from './services/lists/refresh.js' import { runStatsRetention } from './services/analytics.js' import { repos } from '@evofw/db' import { isValidInstallSlug, resolveAndRenderInstall, } from './services/install-links.js' export interface BuildAppOptions { config?: AppConfig memory?: boolean } export async function buildApp(opts: BuildAppOptions = {}) { const config = opts.config ?? loadConfig() const app = Fastify({ trustProxy: config.trustProxy ?? false, logger: { level: config.logLevel }, genReqId: () => crypto.randomUUID(), }).withTypeProvider() // Correlation id in every response and in the error envelope. app.addHook('onSend', async (req, reply) => { reply.header('x-request-id', req.id) }) app.setValidatorCompiler(validatorCompiler) app.setSerializerCompiler(serializerCompiler) await app.register(import('@fastify/sensible')) // CSP only guards the served SPA; in dev the Vite server proxies API // requests same-origin and injects its own HMR scripts. await app.register(import('@fastify/helmet'), { contentSecurityPolicy: config.staticDir !== null ? { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:'], fontSrc: ["'self'", 'data:'], // app-switcher talks to the auth portal directly from the browser connectSrc: ["'self'", config.authPortalUrl], objectSrc: ["'none'"], baseUri: ["'self'"], frameAncestors: ["'none'"], }, } : false, }) await app.register(corsPlugin, { config }) await app.register(errorHandlerPlugin) await app.register(dbPlugin, { config, memory: opts.memory }) await app.register(authPlugin, { config }) await app.register(operationalPlugin, { config, memory: opts.memory }) // Seed enroll_seed into settings if empty if (!repos.getSetting(app.db, 'enroll_seed')) { repos.setSetting(app.db, 'enroll_seed', config.enrollSeed) } await app.register(healthRoutes) await app.register(agentRoutes, { config }) await app.register(enrollmentRoutes, { config }) await app.register( async (protectedApi) => { protectedApi.addHook('onRequest', app.requireAuth) await protectedApi.register(controlRoutes, { config }) await protectedApi.register(auditRoutes) await protectedApi.register(policyRolloutsRoutes, { config }) await protectedApi.register(webhooksRoutes, { config, memory: opts.memory }) }, { prefix: '/api/v1' }, ) const staticDir = config.staticDir ?? resolve(process.cwd(), 'static') if (config.staticDir !== null) { await app.register(import('@fastify/static'), { root: staticDir, wildcard: false, }) } app.setNotFoundHandler(async (request, reply) => { const path = request.url.split('?')[0] ?? '' if (path === '/api' || path.startsWith('/api/') || path === '/v1' || path.startsWith('/v1/')) { return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not Found', request_id: request.id } }) } const segment = path.startsWith('/') ? path.slice(1) : path if ( request.method === 'GET' && segment && !segment.includes('/') && isValidInstallSlug(segment) ) { const link = repos.getInstallLinkBySlug(app.db, segment) if (link) { const { body, contentType } = resolveAndRenderInstall( app.db, link, config.publicBaseUrl, config.enrollSeed, ) return reply.header('Cache-Control', 'no-store').type(contentType).send(body) } } if (config.staticDir !== null) { return reply.sendFile('index.html') } return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not Found' }, }) }) if (!opts.memory) { await app.register(import('@fastify/schedule')) const task = new AsyncTask( 'list-refresh', async () => { await refreshAllLists(app.db) app.log.info('list refresh completed') }, (err) => { app.log.warn({ err }, 'list refresh failed') }, ) app.scheduler.addCronJob( new CronJob({ cronExpression: '0 */5 * * * *' }, task, { preventOverrun: true, }), ) // Raw stats samples grow ~1 row per agent per sync interval; drop old // ones daily. Lifetime totals live on agents; aggregates are kept. const retentionAbort = new AbortController() app.addHook('onClose', async () => { retentionAbort.abort() }) const runRetention = async () => { const deleted = await runStatsRetention(app.db, new Date().toISOString(), 1000, retentionAbort.signal) if (deleted > 0) app.log.info({ deleted }, 'stats retention completed') } app.scheduler.addCronJob( new CronJob( { cronExpression: '17 * * * *' }, new AsyncTask('stats-retention', runRetention, (err) => { app.log.warn({ err }, 'stats retention failed') }), { preventOverrun: true }, ), ) // First cleanup right at startup, not only after the next 03:17. runRetention().catch((err) => app.log.warn({ err }, 'stats retention failed'), ) } return app }