import { z } from 'zod'; import { parseBackupTargetAllowlist } from './backup-target-policy'; import { vsNfdModeSchema } from './vs-nfd-profile'; /** * 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'); /** * Deploy-level allowlist of permissible backup destination HOSTS (issue * #192, ADR 0026), comma-separated — e.g. "cloud.example.org,172.30.1.10". * Empty (the default) disables EVERY remote target, WebDAV and rsync * mirror alike; "local only" is the VS-NfD reference configuration. * Deliberately env-only: a compromised Site-Admin account cannot widen it. * Shared by the api (settings writes, admin view) and the backup sidecar * (the actual egress). */ const backupAllowedTargets = z.string().default('').transform(parseBackupTargetAllowlist); /** * 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'), /** * Session bounds (issue #190). ABSOLUTE caps a session's total lifetime * from login — active use never extends it. IDLE ends a session that has * not been used for that long; activity renews it (server-side against * `lastSeenAt`, not just the cookie). Defaults are deliberately well * below the old sliding 30 days; hardened deployments set them lower * (see deploy/compose/.env.example). */ SESSION_ABSOLUTE_HOURS: z.coerce .number() .positive() .default(7 * 24), SESSION_IDLE_HOURS: z.coerce .number() .positive() .default(3 * 24), ...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'), BACKUP_ALLOWED_TARGETS: backupAllowedTargets, /** * 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(), /** * External authentication via OIDC (ADR 0021, issue #214). Deploy-level * on purpose: who authenticates users is a platform decision, not a * runtime setting a Site Admin can flip. OIDC is enabled when ISSUER and * CLIENT_ID are both set; configuration is discovery-based * (`/.well-known/openid-configuration`). The client secret is * optional — a public client uses PKCE alone (always sent regardless). */ OIDC_ISSUER: z.string().url().optional(), OIDC_CLIENT_ID: z.string().min(1).optional(), OIDC_CLIENT_SECRET: z.string().min(1).optional(), OIDC_SCOPES: z.string().min(1).default('openid profile email'), /** Button label the login page shows, e.g. the agency SSO's name. */ OIDC_PROVIDER_LABEL: z.string().min(1).default('Single Sign-On'), /** * Trusted reverse-proxy authentication (ADR 0021, issue #215) — for * environments that terminate authentication (or mTLS) at the perimeter. * OFF unless BOTH the header name and the peer allowlist are set: a * trusted header is a loaded gun, so nothing about it is guessed. The * peer check runs against the TCP peer address (never a forwarded * header); a request carrying the header from any other peer is * rejected and audited. */ /** * The hard local-authentication switch (ADR 0021, issue #216) — the * deploy-level realization of the planned `auth.local.enabled`. FALSE * closes EVERY local credential flow with 404 (login, signup, e-mail * verification, resend, password forgot/reset/change); authentication * then comes exclusively from OIDC (#214) or the trusted proxy (#215). * Deploy-level on purpose: a compromised Site Admin must not be able to * reopen the local path at runtime. Bootstrap order: complete the * first-run setup (or SETUP_ADMIN_* pre-seed) BEFORE flipping to false. */ AUTH_LOCAL_ENABLED: z .enum(['true', 'false']) .default('true') .transform((value) => value === 'true'), AUTH_PROXY_HEADER: z.string().min(1).optional(), AUTH_PROXY_TRUSTED_PEERS: z .string() .optional() .transform((value) => (value ?? '') .split(',') .map((peer) => peer.trim()) .filter(Boolean), ), /** How the header value maps to a local account: as its username or its * e-mail address. No just-in-time creation — the account must exist. */ AUTH_PROXY_MAP: z.enum(['username', 'email']).default('username'), /** `mtls-dn`: the header carries a client-certificate subject DN (as the * proxy forwards it) and the identity is the configured attribute. */ AUTH_PROXY_MODE: z.enum(['plain', 'mtls-dn']).default('plain'), AUTH_PROXY_DN_ATTRIBUTE: z.string().min(1).default('CN'), /** * How the UI and API treat configuration options violating the VS-NfD * reference profile (issue #243, ADR 0027): off | marked | hidden | * enforced. Default `off` — outside a VS context the profile is not a * topic and nothing is marked. Deploy-level like BACKUP_ALLOWED_TARGETS: * a compromised Site Admin must not be able to widen the mode. */ VS_NFD_MODE: vsNfdModeSchema.default('off'), }); 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), BACKUP_ALLOWED_TARGETS: backupAllowedTargets, /** 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; }