Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<FastifyInstance> {
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<void> {
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<string, Locale | null> = { '/': 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<MarketingSsr | null> | null = null;
function loadMarketingSsr(app: FastifyInstance): Promise<MarketingSsr | null> {
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 = [
`<meta name="description" content="${escapeAttr(input.meta.description)}" />`,
`<link rel="canonical" href="${canonical}" />`,
`<link rel="alternate" hreflang="en" href="${base}/en" />`,
`<link rel="alternate" hreflang="nl" href="${base}/nl" />`,
`<link rel="alternate" hreflang="x-default" href="${base}/" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:site_name" content="Wellformed" />`,
`<meta property="og:url" content="${canonical}" />`,
`<meta property="og:title" content="${escapeAttr(input.meta.title)}" />`,
`<meta property="og:description" content="${escapeAttr(input.meta.description)}" />`,
`<meta property="og:image" content="${base}/grid-orders.${input.locale}.webp" />`,
`<meta property="og:locale" content="${input.locale === 'nl' ? 'nl_NL' : 'en_US'}" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<style>body:has(#ssr) #root{display:none}</style>`,
].join('\n ');
// `</script` inside a JSON script tag would end it early; < cannot.
const voiceJson = JSON.stringify(input.voice).replaceAll('<', '\\u003c');
return shell
.replace('<html lang="en">', `<html lang="${input.locale}">`)
.replace(/<title>.*?<\/title>/, `<title>${escapeAttr(input.meta.title)}</title>`)
.replace(/<meta name="description"[^>]*\/>\s*/, '')
.replace('</head>', ` ${head}\n </head>`)
.replace(
'<div id="root"></div>',
`<div id="root"></div>\n <div id="ssr">${input.body}</div>\n <script id="wf-voice" type="application/json">${voiceJson}</script>`,
);
}