Spreadsheet-UX op een echte relationele engine

main

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

9.6 kB Ruw bestand
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import { readLicence, type LicenceState } from './licence/index.js';

/**
 * Locate the repo root by walking up for the workspace manifest. Resolving by
 * a fixed number of `..` hops breaks between `src/` (tsx) and `dist/` (build).
 */
function findRepoRoot(start: string): string {
  let dir = start;
  for (let i = 0; i < 8; i++) {
    const manifest = path.join(dir, 'package.json');
    if (existsSync(manifest)) {
      try {
        const pkg = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string };
        if (pkg.name === 'wellformed') return dir;
      } catch {
        // Unreadable manifest on the way up is not fatal; keep climbing.
      }
    }
    const parent = path.dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  return start;
}

export const REPO_ROOT = findRepoRoot(path.dirname(fileURLToPath(import.meta.url)));

const envFile = path.join(REPO_ROOT, '.env');
if (existsSync(envFile)) dotenv.config({ path: envFile, quiet: true });

const isProd = process.env.NODE_ENV === 'production';
const isTest = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';

function resolveFromRoot(p: string): string {
  return path.isAbsolute(p) ? p : path.resolve(REPO_ROOT, p);
}

function requiredSecret(): string {
  const fromEnv = process.env.WELLFORMED_SECRET?.trim();
  if (fromEnv && fromEnv.length >= 32) return fromEnv;
  if (isProd) {
    throw new Error(
      'WELLFORMED_SECRET must be set to at least 32 characters in production. Generate one with:\n' +
        '  node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"',
    );
  }
  if (fromEnv) {
    console.warn('[wellformed] WELLFORMED_SECRET is shorter than 32 characters; using it anyway (development).');
    return fromEnv;
  }
  return randomBytes(32).toString('hex');
}

const dataDir = resolveFromRoot(process.env.WELLFORMED_DATA_DIR ?? './data');

export interface Config {
  /** Accounts, workspaces, memberships, sessions and invites. One per install. */
  controlDbPath: string;
  /** Directory holding one SQLite file per workspace. */
  tenantsDir: string;
  /**
   * A pre-tenancy single-database install, adopted as the first workspace on
   * the next boot. Nothing writes here afterwards; the workspace owns it.
   */
  legacyDbPath: string;
  legacySlug: string;
  legacyName: string;
  /**
   * Whether strangers may create an account and a workspace.
   *
   * Open by default, which is what a hosted Wellformed wants. A self-hoster running
   * this for one team closes it and adds people by invitation instead.
   */
  signupsOpen: boolean;
  /**
   * Whose licence this install runs under, if anyone's.
   *
   * Read once, on start, from `WELLFORMED_LICENSE`. See `licence/index.ts` for
   * what the states mean and why this is not a lock.
   */
  licence: LicenceState;
  /**
   * Whether the plans are a ceiling on this install.
   *
   * A hosted Wellformed meters, because the plan is the thing a customer buys.
   * An install running under its own licence has settled that on paper: there
   * is nobody to bill and nothing to sell them, and metering it means a wall
   * arriving on a machine the operator does not watch, with no way to lift it
   * but SSH.
   *
   * So it follows from the licence, and from nothing else. Not from a mode
   * word anyone can type — that was the old arrangement, and the trouble with
   * it was that the setting asserted nothing checkable. A signed licence names
   * the party it was granted to, and this is the one place that fact is
   * allowed to change what the software does.
   */
  metered: boolean;
  /**
   * Workspaces published read-only to people with no account, named by slug —
   * usually one per language, so the marketing page can point at a demo that
   * speaks the visitor's language. The first slug is the fallback for
   * visitors whose language has no demo.
   *
   * The operator names them here rather than a flag on the workspace itself,
   * so that no workspace admin can publish a workspace by clicking something —
   * the one irreversible mistake in a product like this is exposing a customer
   * database, and it should take shell access.
   */
  demoSlugs: string[];
  /** Workspace database handles kept open at once. */
  maxOpenWorkspaces: number;
  /** Close a workspace handle nobody has used for this long. */
  workspaceIdleMs: number;
  port: number;
  host: string;
  secret: string;
  isProd: boolean;
  isTest: boolean;
  corsOrigin: string | false;
  /** Absolute path to the built web app, when it exists. */
  webDist: string;
  /** The SSR bundle that renders the marketing page server-side, when built. */
  webSsrEntry: string;
  sessionTtlMs: number;
  /**
   * Where this install answers from, used only to make links in outbound mail
   * absolute. Everything in the app builds URLs against the browser's own
   * origin; a message has no browser to ask.
   */
  publicUrl: string;
  email: EmailConfig;
  billing: BillingConfig;
}

/**
 * Taking money, if this install does.
 *
 * `none` is a first-class state, not a broken one: a self-hosted Wellformed has
 * nobody to bill, and every workspace on it simply runs on the free plan with
 * the operator moving one by hand when they want to.
 */
export interface BillingConfig {
  provider: 'none' | 'paddle';
  /** Hosted checkout page; the plan's price id is appended as a query param. */
  checkoutBase: string;
  /** Where someone manages or cancels a subscription — the provider's page. */
  portalUrl: string;
  webhookSecret: string;
  /** Plan id → the provider's price id. Only paid plans need one. */
  priceIds: Record<string, string>;
}

function billingConfig(): BillingConfig {
  const provider = (process.env.WELLFORMED_BILLING_PROVIDER ?? 'none').trim().toLowerCase();
  const checkoutBase = process.env.WELLFORMED_BILLING_CHECKOUT_URL?.trim() ?? '';
  const webhookSecret = process.env.WELLFORMED_BILLING_WEBHOOK_SECRET?.trim() ?? '';
  const priceIds = { team: process.env.WELLFORMED_BILLING_PRICE_TEAM?.trim() ?? '' };
  const portalUrl = process.env.WELLFORMED_BILLING_PORTAL_URL?.trim() ?? '';

  if (provider !== 'paddle') {
    return { provider: 'none', checkoutBase, portalUrl, webhookSecret, priceIds };
  }
  if (!checkoutBase || !webhookSecret || !priceIds.team) {
    console.warn(
      '[wellformed] WELLFORMED_BILLING_PROVIDER=paddle needs WELLFORMED_BILLING_CHECKOUT_URL, ' +
        'WELLFORMED_BILLING_WEBHOOK_SECRET and WELLFORMED_BILLING_PRICE_TEAM; billing stays off.',
    );
    return { provider: 'none', checkoutBase, portalUrl, webhookSecret, priceIds };
  }
  return { provider: 'paddle', checkoutBase, portalUrl, webhookSecret, priceIds };
}

/**
 * Outbound email.
 *
 * Off unless configured, and the app is expected to work with it off — that is
 * why an invite is a link you pass on yourself. What email buys is the one flow
 * a link cannot: reaching someone who is locked out and therefore cannot be
 * handed anything through the product.
 *
 * Both providers are one HTTPS POST, so this costs no dependency.
 */
export interface EmailConfig {
  provider: 'none' | 'resend' | 'postmark';
  apiKey: string;
  from: string;
}

function emailConfig(): EmailConfig {
  const provider = (process.env.WELLFORMED_EMAIL_PROVIDER ?? 'none').trim().toLowerCase();
  const apiKey = process.env.WELLFORMED_EMAIL_KEY?.trim() ?? '';
  const from = process.env.WELLFORMED_EMAIL_FROM?.trim() ?? '';

  if (provider !== 'resend' && provider !== 'postmark') return { provider: 'none', apiKey, from };
  if (!apiKey || !from) {
    console.warn(
      `[wellformed] WELLFORMED_EMAIL_PROVIDER=${provider} needs WELLFORMED_EMAIL_KEY and WELLFORMED_EMAIL_FROM; email stays off.`,
    );
    return { provider: 'none', apiKey, from };
  }
  return { provider, apiKey, from };
}

/**
 * Read once, here, rather than wherever it is wanted.
 *
 * Verifying a signature is cheap but not free, and more to the point a licence
 * that changed under a running process would mean two requests disagreeing
 * about what this install is.
 */
const licence = readLicence();

export const config: Config = {
  controlDbPath: resolveFromRoot(process.env.WELLFORMED_CONTROL_DB ?? path.join(dataDir, 'control.db')),
  tenantsDir: resolveFromRoot(process.env.WELLFORMED_TENANTS_DIR ?? path.join(dataDir, 'workspaces')),
  legacyDbPath: resolveFromRoot(process.env.WELLFORMED_DB ?? path.join(dataDir, 'wellformed.db')),
  legacySlug: process.env.WELLFORMED_LEGACY_SLUG ?? 'main',
  legacyName: process.env.WELLFORMED_LEGACY_NAME ?? 'Workspace',
  signupsOpen: process.env.WELLFORMED_SIGNUPS !== 'closed',
  licence,
  metered: licence.status !== 'licensed',
  demoSlugs: (process.env.WELLFORMED_DEMO_SLUG ?? '')
    .toLowerCase()
    .split(',')
    .map((slug) => slug.trim())
    .filter(Boolean),
  maxOpenWorkspaces: Number(process.env.WELLFORMED_MAX_OPEN_WORKSPACES ?? 64),
  workspaceIdleMs: Number(process.env.WELLFORMED_WORKSPACE_IDLE_MS ?? 10 * 60_000),
  port: Number(process.env.PORT ?? 4000),
  host: process.env.HOST ?? '127.0.0.1',
  secret: requiredSecret(),
  isProd,
  isTest,
  corsOrigin: isProd ? false : (process.env.WELLFORMED_CORS_ORIGIN ?? 'http://localhost:5173'),
  webDist: path.join(REPO_ROOT, 'packages', 'web', 'dist'),
  webSsrEntry: path.join(REPO_ROOT, 'packages', 'web', 'dist-ssr', 'entry-ssr.js'),
  sessionTtlMs: 1000 * 60 * 60 * 24 * 14,
  publicUrl: (process.env.WELLFORMED_PUBLIC_URL ?? `http://localhost:${process.env.PORT ?? 4000}`).replace(/\/+$/, ''),
  email: emailConfig(),
  billing: billingConfig(),
};