import path from 'node:path'; import { fileURLToPath } from 'node:url'; import fs from 'node:fs'; import Fastify from 'fastify'; import { ZodError } from 'zod'; import cookie from '@fastify/cookie'; import helmet from '@fastify/helmet'; import rateLimit from '@fastify/rate-limit'; import staticPlugin from '@fastify/static'; import multipart from '@fastify/multipart'; import { env, isProd } from './env'; import { initSentry, captureError } from './lib/sentry'; import { authPlugin } from './auth/plugin'; import { csrfPlugin } from './auth/csrf'; import { authRoutes } from './routes/auth'; import { healthRoutes } from './routes/health'; import { contactRoutes } from './routes/contact'; import { clientsRoutes } from './routes/clients'; import { casesRoutes } from './routes/cases'; import { timeEntriesRoutes } from './routes/time-entries'; import { invoicesRoutes } from './routes/invoices'; import { adminRoutes } from './routes/admin'; import { accountRoutes } from './routes/account'; import { toolUsageRoutes } from './routes/tool-usage'; import { billingRoutes } from './routes/billing'; import { documentsRoutes } from './routes/documents'; import { stripeWebhookRoute } from './routes/webhooks-stripe'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); initSentry(); export async function buildServer() { const app = Fastify({ logger: env.NODE_ENV === 'test' ? false : isProd ? { level: 'info' } : { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } }, // Trust exactly ONE proxy hop (the Plesk/nginx reverse proxy in front of Passenger). // `true` would trust the entire X-Forwarded-For chain, letting any client spoof req.ip and // evade the IP-keyed rate limits (including auth brute-force protection). trustProxy: 1, bodyLimit: 5 * 1024 * 1024, }); // Register the global error handler EARLY so it wins over plugin-default handlers and // catches ZodErrors thrown by .parse() inside route handlers. app.setErrorHandler((err, req, reply) => { if (err instanceof ZodError || (err as { validation?: unknown }).validation || (err as Error).name === 'ZodError') { req.log.info({ err }, 'validation error'); return reply .code(400) .send({ error: 'validation', details: err instanceof ZodError ? err.errors : (err as Error).message }); } if ((err as { statusCode?: number }).statusCode === 429) { // Let @fastify/rate-limit handle its own response shape. return reply.send(err); } req.log.error({ err }, 'unhandled error'); captureError(err, { url: req.url, method: req.method, userId: req.user?.id }); return reply.code(500).send({ error: 'internal_error' }); }); // CSP: tight in production, off in dev (Vite HMR injects inline scripts/styles + uses eval) await app.register(helmet, { contentSecurityPolicy: isProd ? { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'], imgSrc: ["'self'", 'data:', 'blob:'], connectSrc: ["'self'"], frameAncestors: ["'none'"], formAction: ["'self'"], baseUri: ["'self'"], objectSrc: ["'none'"], upgradeInsecureRequests: [], }, } : false, crossOriginEmbedderPolicy: false, }); await app.register(cookie, { secret: env.SESSION_SECRET, }); await app.register(multipart); // Global rate limit floor — per-route limits override below. await app.register(rateLimit, { global: true, max: 600, timeWindow: '1 minute', keyGenerator: (req) => `${req.ip}`, }); // Stripe webhook BEFORE auth/CSRF — registered as its own subapp with a buffer-only parser // so signature verification works against the raw body. await app.register(stripeWebhookRoute); await app.register(authPlugin); await app.register(csrfPlugin); await app.register(authRoutes); await app.register(healthRoutes); await app.register(contactRoutes); await app.register(clientsRoutes); await app.register(casesRoutes); await app.register(timeEntriesRoutes); await app.register(invoicesRoutes); await app.register(adminRoutes); await app.register(accountRoutes); await app.register(toolUsageRoutes); await app.register(billingRoutes); await app.register(documentsRoutes); // Serve the built SPA in production. In dev, the Vite dev server runs separately. const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist'); if (fs.existsSync(webDist)) { await app.register(staticPlugin, { root: webDist, prefix: '/', cacheControl: true, maxAge: '1y', immutable: true, // Must NOT be `false`: the SPA fallback below calls reply.sendFile, which only exists when // @fastify/static decorates the reply. With it disabled, every deep-link/refresh to a // non-/api route (e.g. /dashboard, emailed /reset-password links) 500s in production. }); // SPA fallback: any non-/api path returns index.html. index.html itself must not be cached // for a year (unlike the hashed assets) or clients keep a stale app shell after each deploy. app.setNotFoundHandler((req, reply) => { if (req.raw.url?.startsWith('/api/')) { return reply.code(404).send({ error: 'not_found' }); } // cacheControl:false stops @fastify/static from stamping its own 1y immutable header // (which would otherwise override the no-cache below and pin a stale app shell). return reply .header('Cache-Control', 'no-cache') .type('text/html') .sendFile('index.html', webDist, { cacheControl: false }); }); } else { app.log.warn({ webDist }, 'web/dist not found — SPA assets will not be served'); } return app; } const isEntrypoint = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isEntrypoint) { const app = await buildServer(); try { await app.listen({ host: '0.0.0.0', port: env.PORT }); app.log.info(`eLegal Software API listening on :${env.PORT}`); } catch (err) { app.log.error(err); captureError(err); process.exit(1); } }