Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

14.7 kB Ruw bestand
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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
}

/**
 * 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>`,
    );
}