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 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 { refreshAllLists } from './services/lists/refresh.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({ logger: { level: config.logLevel }, }).withTypeProvider() 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(import('@fastify/rate-limit'), { max: 300, timeWindow: '1 minute', }) await app.register(corsPlugin, { config }) await app.register(errorHandlerPlugin) await app.register(dbPlugin, { config, memory: opts.memory }) await app.register(authPlugin, { config }) // 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( async (protectedApi) => { protectedApi.addHook('onRequest', app.requireAuth) await protectedApi.register(controlRoutes, { config }) await protectedApi.register(auditRoutes) }, { 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] ?? '' 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.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, }), ) } return app }