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

156 lines
5.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: isProd
? { level: 'info' }
: { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } },
trustProxy: true,
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.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);
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,
decorateReply: false,
});
// SPA fallback: any non-/api path returns index.html
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/api/')) {
return reply.code(404).send({ error: 'not_found' });
}
return reply.type('text/html').sendFile('index.html', webDist);
});
} 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);
}
}