All checks were successful
CI / Build container images (pull_request) Successful in 3m53s
CI / Auth e2e pack (pull_request) Successful in 8m42s
CI / Auth e2e pack (push) Successful in 8m41s
CI / Lint, typecheck, test (pull_request) Successful in 6m30s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Deploy to Test (push) Successful in 16s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m41s
CI / Build container images (push) Has been skipped
CI / Import/export fidelity gate (push) Successful in 52s
An operator holding a font licence could only use it by baking the file into a custom image, which tied every change to a rebuild and left the file out of the backup. ADR 0016 said there is no runtime font management. It also listed this exact case under "Alternatives considered" — *may become a Site-Admin- level feature later*. The amendment takes that option and answers the two objections it raised: licensing risk (Site Admins only, licence recorded with the family) and file-format attack surface (magic-byte check and a size cap, never a parse). - `CUSTOM_FONTS_DIR` (default `./data/fonts`) — a sibling of uploads and plugins, NOT inside the image-baked `FONTS_DIR`, where a deploy would overwrite it and no backup would ever see it. - One list of data directories (`apps/backup/src/data-dirs.ts`) now feeds both the nightly archive and the restore, so they cannot drift. #306 and #307 add one line each instead of a second mechanism. - Both Dockerfiles bake the path. The backup image sets its volume paths itself ("self-sufficient without compose env" — #71's lesson) and reads no *_DIR from compose; without the ENV entry the archive would have skipped the directory silently. - The PDF path already read WOFF2 from disk at request time, so it only had to pick the other base directory for a custom family. - `fontStack`/`fontEntry` take the instance's uploaded families as an argument — they are runtime data. The catalog is searched first, and a colliding family name is rejected at upload, so a custom font can never shadow a catalog one. - Deletion is never blocked by usage: an unknown family already falls back to the system stack, so affected ponds degrade instead of breaking. The count of affected ponds travels into the audit entry. - Audit catalogue v1.6 (`font.uploaded`, `font.deleted`). Verified: api full suite against a fresh database, 102 files / 571 tests. The upload suite writes into a real temp directory and reads the bytes back off disk, so the storage layer is exercised rather than mocked.
311 lines
14 KiB
TypeScript
311 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 of operator-uploaded fonts (issue #303, ADR 0016 §#303).
|
|
* Deliberately NOT under {@link FONTS_DIR}: that one is baked into the
|
|
* image, so anything written there is lost on the next deploy and never
|
|
* reaches a backup. This is a sibling of the uploads and plugins
|
|
* directories so it travels in the same restore set (ADR 0015).
|
|
* Layout mirrors the catalog: `<dir>/<slug>/<slug>-<weight>.woff2`.
|
|
*/
|
|
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/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.
|
|
* The authoritative list lives in `apps/backup/src/data-dirs.ts`. */
|
|
UPLOADS_DIR: z.string().min(1).default('./data/uploads'),
|
|
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
|
|
CUSTOM_FONTS_DIR: z.string().min(1).default('./data/fonts'),
|
|
/** 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;
|
|
}
|