CI / build-and-test (push) Has been cancelled
- E2E suite (23 tests, `npm run test:e2e -w @lawdesk/api`): boots the real Fastify app against a disposable Dockerized Postgres (never a real DB) and covers signup/login/lockout/rate limits, CSRF (incl. forged-token rejection), logout, password reset, email verification, the superadmin verified-email promotion gate, and cross-firm tenancy isolation - packages/db: DATABASE_SSL=disable opt-out for local/test databases that don't speak TLS; refused in production - retention-sweep.ts cron enforcing Privacy Policy windows (sessions, tokens, login attempts, tool usage, contact messages, audit log) + sweep-orphaned-storage.ts Spaces reconciliation + scripts/README - Expose emailVerified on the session user; in-app verify-email banner with resend, and verified=1|0 toasts on the login page - Silence Fastify logger under NODE_ENV=test; fix footer resource link; document login-attempt/tool-usage retention in the Privacy Policy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
170 lines
6.3 KiB
TypeScript
170 lines
6.3 KiB
TypeScript
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);
|
|
}
|
|
}
|