import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify'; import cookie from '@fastify/cookie'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import { ZodError } from 'zod'; import { FormulaError, ValidationError, type ApiError, type Locale, type PublicConfig, type VoiceAssignment, } from '@wellformed/shared'; import { config } from './config.js'; import { HttpError } from './errors.js'; import type { ControlDb } from './control/db.js'; import type { TenantRegistry } from './tenancy/registry.js'; import { SESSION_COOKIE } from './control/sessions.js'; import { localeFromHeader } from './locale.js'; import { contextPlugin } from './plugins/context.js'; import { humanOnly, tenantScope, WORKSPACE_PREFIX } from './plugins/tenant.js'; import { authRoutes } from './routes/auth.js'; import { assignVoice, composePublicConfig, publicRoutes } from './routes/public.js'; import { catalogRoutes } from './routes/catalog.js'; import { rowRoutes } from './routes/rows.js'; import { auditRoutes } from './routes/audit.js'; import { memberRoutes } from './routes/members.js'; import { inviteAcceptRoutes, inviteRoutes } from './routes/invites.js'; import { onboardingRoutes } from './routes/onboarding.js'; import { billingRoutes, billingWebhookRoutes } from './routes/billing.js'; import { workspaceAdminRoutes, workspaceRoutes } from './routes/workspaces.js'; import { aiRoutes } from './routes/ai.js'; import { keyRoutes } from './routes/keys.js'; export interface BuildOptions { control: ControlDb; registry: TenantRegistry; logger?: boolean; } const MUTATING_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']); export async function buildApp({ control, registry, logger = !config.isTest, }: BuildOptions): Promise { const app = Fastify({ logger: logger ? { level: config.isProd ? 'info' : 'warn' } : false, bodyLimit: 1024 * 1024, trustProxy: config.isProd, }); await app.register(cookie, { secret: config.secret }); if (config.corsOrigin) { await app.register(cors, { origin: config.corsOrigin, credentials: true }); } await app.register(contextPlugin, { control, registry }); /** * Defence in depth against cross-site writes. * * The session cookie is already SameSite=Lax, which stops a cross-site form * POST from carrying it. This adds an explicit Origin check so a * misconfigured proxy or a future SameSite=None cannot silently open a hole. */ app.addHook('onRequest', async (request) => { if (!MUTATING_METHODS.has(request.method)) return; // An agent key is presented deliberately in a header rather than carried // ambiently by the browser, so no other site can make a request with one. // Cross-site forgery is a cookie problem; this caller has no cookie. if (request.agent) return; const origin = request.headers.origin; if (!origin) return; // Same-origin fetches and curl send no Origin. const allowed = config.corsOrigin ? [config.corsOrigin] : []; const host = request.headers.host; if (host) { allowed.push(`http://${host}`, `https://${host}`); } if (!allowed.includes(origin)) { throw new HttpError(403, 'forbidden', `Cross-origin writes from ${origin} are not allowed.`); } }); /** * Must be installed before the route plugins below. Each `register` call * creates an encapsulated child context that captures the error handler in * force at that moment — setting it afterwards would leave every route on * Fastify's default handler, turning validation failures into 500s. */ app.setErrorHandler((error, request, reply) => { const payload = toApiError(error); const status = statusFor(error); if (status >= 500) { request.log.error({ err: error }, 'unhandled error'); } else { request.log.debug({ err: error }, 'request rejected'); } reply.code(status).send(payload); }); app.get('/api/health', async () => ({ ok: true, name: 'wellformed', version: 1, })); // Installation-wide: what this install is, who you are, and where you can go. await app.register(publicRoutes); await app.register(authRoutes); await app.register(workspaceRoutes); await app.register(inviteAcceptRoutes); await app.register(billingWebhookRoutes); /** * Everything that touches customer data lives behind one prefix and one * plugin. A route registered here cannot reach a database without a * membership check having run first — that is the property worth protecting, * and it is enforced by where the route sits rather than by remembering to * call something. */ await app.register( tenantScope([ catalogRoutes, rowRoutes, auditRoutes, onboardingRoutes, workspaceAdminRoutes, aiRoutes, /** * The roster, the invites, the money, and the keys themselves. * * Everything an assistant is actually for — reading, editing, and * building the schema — sits above this line. What is below it changes * *who can reach the workspace* rather than what is in it, and that * stays a decision a person makes while signed in. */ humanOnly('change who can reach this workspace', [ memberRoutes, inviteRoutes, billingRoutes, keyRoutes, ]), ]), { prefix: WORKSPACE_PREFIX }, ); /** * The SPA shell — or, on the landing paths, the page already rendered. * * Server-side rendering happens only where every condition holds: a hosted * install (a self-hosted front door is a login page, and flashing * Wellformed's own pitch across somebody else's business is the one thing * this must not do), a visitor with no session cookie (people who belong * here are on their way to their workspaces, not to the pitch), and an SSR * bundle that built. In every other case the plain shell answers, exactly * as it always has. */ async function serveShell(request: FastifyRequest, reply: FastifyReply): Promise { const indexHtml = path.join(config.webDist, 'index.html'); const pathOnly = request.url.split('?')[0]!; const forced = request.method === 'GET' ? MARKETING_PATHS[pathOnly] : undefined; if (forced !== undefined && config.metered && !request.cookies[SESSION_COOKIE] && existsSync(indexHtml)) { const ssr = await loadMarketingSsr(app); if (ssr) { const locale = forced ?? localeFromHeader(request.headers['accept-language']); const voice = assignVoice(app, request); const body = ssr.renderMarketing({ locale, config: composePublicConfig(app), voice, }); await reply .type('text/html') .send(marketingHtml(readIndexHtml(indexHtml), { locale, meta: ssr.marketingMeta(locale), path: pathOnly, body, voice })); return; } } if (existsSync(indexHtml)) { await reply.type('text/html').send(readIndexHtml(indexHtml)); return; } await reply.code(404).send({ error: 'not_found', message: 'Not found.' }); } app.setNotFoundHandler(async (request, reply) => { if (request.url.startsWith('/api/')) { reply.code(404).send({ error: 'not_found', message: `No route for ${request.method} ${request.url}.` }); return; } // Anything else is a client-side route. await serveShell(request, reply); }); // The static plugin's wildcard would answer `/` itself (as a directory, // with a 403); an explicit route wins, so the front door goes through // serveShell like every other client-side path. /en and /nl need no route // of their own — they miss as files and fall through to the handler above. app.get('/', serveShell); // Serve the built client in production; in development Vite does it. if (existsSync(config.webDist)) { await app.register(fastifyStatic, { root: config.webDist, prefix: '/', index: false }); } return app; } function statusFor(error: unknown): number { if (error instanceof HttpError) return error.status; if (error instanceof ZodError) return 400; if (error instanceof ValidationError) return 400; if (error instanceof FormulaError) return 400; const withStatus = error as { statusCode?: number }; if (typeof withStatus?.statusCode === 'number' && withStatus.statusCode >= 400) { return withStatus.statusCode; } // A constraint the operator asked for, refusing a write: 409, not a fault. if (isConstraintFailure(error)) return 409; return 500; } /** * A SQLite constraint refusing a write. * * Recognised by the error's `code` rather than its text: better-sqlite3 puts * `SQLITE_CONSTRAINT_UNIQUE` there and leaves the message as the bare * `UNIQUE constraint failed: orders.Ref`, so matching on the message alone * never fired. */ function isConstraintFailure(error: unknown): boolean { if (!(error instanceof Error)) return false; const code = (error as { code?: unknown }).code; return ( (typeof code === 'string' && code.startsWith('SQLITE_CONSTRAINT')) || /SQLITE_CONSTRAINT|constraint failed/i.test(error.message) ); } function toApiError(error: unknown): ApiError { if (error instanceof HttpError) { return { error: error.code, message: error.message, details: error.details }; } if (error instanceof ZodError) { return { error: 'invalid_request', message: describeZodError(error), details: error.issues, }; } if (error instanceof ValidationError || error instanceof FormulaError) { return { error: 'bad_request', message: error.message }; } const message = error instanceof Error ? error.message : String(error); // SQLite constraint failures are the user's problem, not a server fault. if (isConstraintFailure(error)) { // `UNIQUE constraint failed: customers.Ref` — the column somebody asked to // be unique, refusing the second one. Said in those words rather than in // SQLite's, because the constraint was a deliberate choice and its refusal // is the constraint working. const unique = /UNIQUE constraint failed: [^.]+\.(.+)$/m.exec(message); return { error: 'conflict', message: unique ? `"${unique[1]}" must be unique, and another row already has that value.` : 'That change violates a database constraint.', }; } return { error: 'internal_error', message: config.isProd ? 'Something went wrong.' : message, }; } function describeZodError(error: ZodError): string { const first = error.issues[0]; if (!first) return 'The request was not valid.'; const where = first.path.length ? `"${first.path.join('.')}"` : 'the request'; return `${where}: ${first.message}`; } /** The SPA shell is read once and held; it only changes on redeploy. */ let cachedIndex: { file: string; content: string } | null = null; function readIndexHtml(file: string): string { if (cachedIndex?.file === file) return cachedIndex.content; const content = readFileSync(file, 'utf8'); cachedIndex = { file, content }; return content; } /** The paths that get a server-rendered landing page, and the language each pins. */ const MARKETING_PATHS: Record = { '/': null, '/en': 'en', '/nl': 'nl' }; interface MarketingSsr { renderMarketing(input: { locale: Locale; config: PublicConfig; voice: VoiceAssignment | null }): string; marketingMeta(locale: Locale): { title: string; description: string }; } /** * The SSR bundle, imported once and held. Absent or broken it resolves null, * and every page it would have rendered still works client-side — a marketing * page that cannot be pre-rendered is slower for crawlers, not down. */ let ssrLoad: Promise | null = null; function loadMarketingSsr(app: FastifyInstance): Promise { ssrLoad ??= (async () => { if (!existsSync(config.webSsrEntry)) return null; try { return (await import(pathToFileURL(config.webSsrEntry).href)) as MarketingSsr; } catch (error) { app.log.warn({ err: error }, 'The marketing SSR bundle exists but would not load.'); return null; } })(); return ssrLoad; } function escapeAttr(value: string): string { return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); } /** * The built shell with one landing page rendered into it. * * String surgery on dist/index.html rather than a template of our own, so the * hashed script and stylesheet links Vite emitted stay exactly as built. The * rendered page goes into `#ssr` beside the empty `#root`, with a style rule * hiding `#root` until a mounted screen removes the overlay — see * web/src/state/ssr.ts for the other half of that handshake. The voice * assignment rides along as JSON so the client adopts this impression instead * of drawing a second one. */ function marketingHtml( shell: string, input: { locale: Locale; meta: { title: string; description: string }; path: string; body: string; voice: VoiceAssignment | null; }, ): string { const base = config.publicUrl; const canonical = `${base}${input.path === '/' ? '/' : input.path}`; const head = [ ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ].join('\n '); // `', ``) .replace(/.*?<\/title>/, `<title>${escapeAttr(input.meta.title)}`) .replace(/]*\/>\s*/, '') .replace('', ` ${head}\n `) .replace( '
', `
\n
${input.body}
\n `, ); }