dorfteich/packages/shared/src/env.ts
Claude Fable 5 da5fd7c770
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m12s
CI / Build container images (pull_request) Successful in 4m2s
CI / Auth e2e pack (pull_request) Successful in 8m29s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 31s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m29s
CD / Promote to Int (push) Successful in 14s
CI / Build container images (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 6m30s
CI / Auth e2e pack (push) Successful in 8m6s
CI / Import/export fidelity gate (push) Successful in 57s
#243: VS_NFD_MODE and the machine-readable hardening-profile catalog
The deployment declares through VS_NFD_MODE (off | marked | hidden |
enforced, default off) how the application treats configuration that
violates the VS-NfD reference profile — deploy-level like
BACKUP_ALLOWED_TARGETS, so a compromised Site Admin cannot widen it.
The catalog in shared (vs-nfd-profile.ts) is the single source of
truth: every profile-relevant setting with a decidable compliant value,
judgement calls in an explicit advisory list, and a fence test parsing
the hardening guide's reference tables so neither can drift (pattern
#201). The api evaluates the catalog against the typed settings
registry and validated env and exposes mode + verdict on
GET /admin/system/vs-nfd-profile; the admin settings view shows the
card whenever the mode is not off. Display only — the treatments land
with #244–#246 (ADR 0027, proposed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 18:24:04 +02:00

300 lines
14 KiB
TypeScript

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 <no-reply@localhost>'),
};
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
* `<PLUGINS_DIR>/<id>/<version>/…` 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
* (`<issuer>/.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<typeof apiEnvSchema>;
/**
* 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<typeof collabEnvSchema>;
/**
* 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<typeof backupEnvSchema>;
export function parseEnv<Schema extends z.ZodTypeAny>(
schema: Schema,
env: Record<string, string | undefined>,
): z.infer<Schema> {
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;
}