Files
elegalsoftware/apps/api/src/server.ts
T

170 lines
6.3 KiB
TypeScript
Raw Normal View History

2026-04-26 02:42:42 -04:00
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';
2026-04-26 02:42:42 -04:00
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';
2026-04-26 02:42:42 -04:00
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,
2026-04-26 02:42:42 -04:00
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') {
2026-04-26 02:42:42 -04:00
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);
2026-04-26 02:42:42 -04:00
// 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);
2026-04-26 02:42:42 -04:00
// 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.
2026-04-26 02:42:42 -04:00
});
// 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.
2026-04-26 02:42:42 -04:00
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 });
2026-04-26 02:42:42 -04:00
});
} 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);
}
}