import { z } from 'zod'; /** * Environment schemas live here so api, collab, and tooling validate their * configuration the same way. Every service calls `parseEnv` once at startup * and crashes with a readable list of problems instead of failing later at * first use. */ /** Fields every service configures the same way; keep these in sync. */ const nodeEnv = z.enum(['development', 'test', 'production']).default('development'); const logLevel = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'); /** Version shown in health responses; injected at image build time. */ const appVersion = z.string().default('0.0.0-dev'); /** PostgreSQL connection string — required, there is no sensible default. */ const databaseUrl = z .string() .min(1) .refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), { message: 'must be a postgresql:// connection string', }); /** * Symmetric secret shared by the api (which signs) and the collab server * (which verifies) for the short-lived collaboration tokens (issue #34, * ADR 0007). The dev default only keeps native dev/test/CI running without * extra setup; every real instance MUST set its own identical value in the * `.env` of both services (the stage setup and the M8 wizard do this). */ const collabTokenSecret = z.string().min(16).default('dev-insecure-collab-token-secret-change-me'); /** * SMTP delivery fields, shared by the api (transactional mail) and the * backup sidecar (failure alert mail, issue #83). Defaults match the * Mailpit container from the dev overlay; production instances configure * their real relay in the stage `.env` or through the M8 setup wizard, * whose secret store both services overlay the same way. */ const smtpFields = { SMTP_HOST: z.string().default('localhost'), SMTP_PORT: z.coerce.number().int().default(1025), SMTP_SECURE: z .enum(['true', 'false']) .default('false') .transform((value) => value === 'true'), SMTP_USER: z.string().optional(), SMTP_PASS: z.string().optional(), SMTP_FROM: z.string().default('Dorfteich '), }; export const apiEnvSchema = z.object({ NODE_ENV: nodeEnv, PORT: z.coerce.number().int().min(1).max(65535).default(3000), LOG_LEVEL: logLevel, APP_VERSION: appVersion, DATABASE_URL: databaseUrl, COLLAB_TOKEN_SECRET: collabTokenSecret, /** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */ MIGRATE_ON_START: z .enum(['true', 'false']) .default('true') .transform((value) => value === 'true'), /** Public base URL of this instance — used in e-mail links. */ APP_BASE_URL: z.string().url().default('http://localhost:5173'), ...smtpFields, /** * Filesystem root for uploaded files (ADR 0011). The compose stack * mounts the `uploads` volume at `/data/uploads` and sets this * explicitly; the relative default only serves native (non-Docker) * dev/test runs. */ UPLOADS_DIR: z.string().min(1).default('./data/uploads'), /** * Base URL of the internal pandoc-server sidecar (ADR 0009, issue #62). * The default matches the compose service name; native dev/test runs point * it at a locally running container or leave it unreachable (the converter * readiness check is warning-level, so an unset sidecar never fails readyz). */ PANDOC_URL: z.string().url().default('http://pandoc:3030'), /** * Base URL of the internal Gotenberg sidecar for PDF export (ADR 0009, issue * #67). Like the converter, its readiness check is warning-level, so an * unreachable renderer degrades PDF export without failing readyz. The default * matches the compose service name. */ GOTENBERG_URL: z.string().url().default('http://gotenberg:3000'), /** * Directory of the self-hosted font catalog (WOFF2, ADR 0016), baked into the * api image so the PDF exporter can inline a pond's fonts as base64. Native * dev/test runs point this at the web app's built `public/fonts`. */ FONTS_DIR: z.string().min(1).default('./fonts'), /** * Directory holding installed plugin packages (ADR 0008, issue #71). Layout * `///…` for unpacked bundles the sandbox iframe * loads, plus a `_dropzone/` a Site Admin drops ZIPs into and a * `_quarantine/` for rejected drops. In Docker a persistent volume mounts * here; the relative default serves native dev/test runs. */ PLUGINS_DIR: z.string().min(1).default('./data/plugins'), /** * The backup sidecar's volume with the restore sets and `status.json` * (ADR 0015). The api mounts it read-only and only consumes the status * file (readyz freshness, issue #85; admin backup card, issue #86). */ BACKUPS_DIR: z.string().min(1).default('./data/backups'), /** * Env-backed secret store (security.md §Secrets, issue #80): a mode-600 * dotenv-style file on a persistent volume where the setup wizard writes * secrets entered in the browser (currently the SMTP configuration). * Values from this file fill environment variables that are NOT set on * the process — explicit container env always wins, so operators can * override a broken wizard entry from the stage `.env`. */ SECRETS_FILE: z.string().min(1).default('./data/secrets.env'), /** * First-run pre-seeding (issue #80): when the api boots against a database * that still requires setup and all three SETUP_ADMIN_* values are set, it * creates the Site Admin, applies the optional instance values below, and * completes (locks) the wizard — automated deploys never see it. */ SETUP_ADMIN_USERNAME: z.string().optional(), SETUP_ADMIN_EMAIL: z.string().optional(), SETUP_ADMIN_PASSWORD: z.string().optional(), SETUP_ADMIN_DISPLAY_NAME: z.string().optional(), SETUP_INSTANCE_NAME: z.string().optional(), SETUP_DEFAULT_LOCALE: z.enum(['de', 'en']).optional(), SETUP_REGISTRATION_MODE: z.enum(['open', 'closed']).optional(), }); export type ApiEnv = z.infer; /** * Configuration for the collaboration server (Hocuspocus, ADR 0003). It is a * thin real-time front-end to the same PostgreSQL database as the api; it does * not run migrations (the api owns the schema) and needs no SMTP or uploads. */ export const collabEnvSchema = z.object({ NODE_ENV: nodeEnv, PORT: z.coerce.number().int().min(1).max(65535).default(3000), LOG_LEVEL: logLevel, APP_VERSION: appVersion, DATABASE_URL: databaseUrl, COLLAB_TOKEN_SECRET: collabTokenSecret, }); export type CollabEnv = z.infer; /** * Configuration for the backup sidecar (ADR 0015, issue #83). It talks to * the same database and mounts the same data volumes as the api, plus its * own `backups` volume for the nightly restore sets and `status.json`. */ export const backupEnvSchema = z.object({ NODE_ENV: nodeEnv, LOG_LEVEL: logLevel, APP_VERSION: appVersion, DATABASE_URL: databaseUrl, /** Where restore sets and `status.json` are written (the `backups` volume). */ BACKUPS_DIR: z.string().min(1).default('./data/backups'), /** Same mounts as the api — archived together as one restore set. */ UPLOADS_DIR: z.string().min(1).default('./data/uploads'), PLUGINS_DIR: z.string().min(1).default('./data/plugins'), /** Daily run time as HH:MM, interpreted in the container's TZ. */ BACKUP_TIME: z .string() .regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'must be HH:MM (24h)') .default('03:00'), /** Local retention in days: 30 for Prod, 7 for Test/Int (ADR 0015). */ BACKUP_RETENTION_DAYS: z.coerce.number().int().min(1).default(30), /** Failure-alert recipient; unset disables the mail (logged instead). */ BACKUP_MAIL_TO: z.string().optional(), /** Language of the failure mail (ADR 0012 — both exist, operator picks). */ BACKUP_MAIL_LOCALE: z.enum(['de', 'en']).default('en'), /** Instance label in the mail subject, e.g. "dorfteich-test". */ BACKUP_INSTANCE_LABEL: z.string().optional(), /** * Optional rsync mirror to a private host (issue #84, ADR 0015), e.g. * `dorfteich-backup@172.30.1.10:/home/RAID/BACKUPS/dorfteich-prod/`. * Unset disables the mirror entirely. Deliberately env-only (operator * territory), unlike the admin-configured Nextcloud target (#103). */ BACKUP_MIRROR_TARGET: z.string().optional(), /** Private SSH key file for the mirror; mount it via the secrets volume * (e.g. `/data/secrets/basel_ed25519`), mode 600, never in the image. */ BACKUP_MIRROR_SSH_KEY: z.string().optional(), BACKUP_MIRROR_SSH_PORT: z.coerce.number().int().min(1).max(65535).default(22), ...smtpFields, /** Read-only view of the wizard-written secret store (issue #80). */ SECRETS_FILE: z.string().min(1).default('./data/secrets.env'), }); export type BackupEnv = z.infer; export function parseEnv( schema: Schema, env: Record, ): z.infer { const result = schema.safeParse(env); if (!result.success) { const problems = result.error.issues .map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`) .join('\n'); throw new Error(`Invalid environment configuration:\n${problems}`); } return result.data; }