Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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 { 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 { 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,
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user