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; } 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(), };