import { z } from "zod"; /** * Zentrale Umgebungsvalidierung. * * Fehlt oder taugt eine Variable nicht, bricht der Start hier ab — mit einer * Meldung, die sagt welche. Das ist deutlich angenehmer als ein Dienst, der * anläuft und beim ersten Datei-Upload merkt, dass MinIO nie konfiguriert war. * * Beim Container-Build ist keine echte Umgebung vorhanden; dort setzt das * Dockerfile SKIP_ENV_VALIDATION=1. */ /** * Akzeptiert "true"/"1"/"ja" als wahr — Compose liefert alles als Text. * Der Standardwert wird innerhalb der Umwandlung gesetzt, nicht per .default(): * .default() erwartet den Eingabetyp (string), nicht das Ergebnis (boolean). */ const boolAusText = (standard: boolean) => z .string() .optional() .transform((wert) => { if (wert === undefined || wert.trim() === "") return standard; return ["true", "1", "ja", "yes", "on"].includes(wert.trim().toLowerCase()); }); const zahl = (standard: number) => z .string() .optional() .transform((wert) => (wert === undefined || wert === "" ? standard : Number(wert))) .pipe(z.number().int().positive()); const schema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]).default("development"), TZ: z.string().default("Europe/Berlin"), APP_URL: z.string().url().default("http://localhost:3000"), // Datenbank DATABASE_URL: z.string().url(), DIRECT_DATABASE_URL: z.string().url().optional(), // Redis / Warteschlangen REDIS_URL: z.string().url(), WORKER_CONCURRENCY: zahl(5), // Objektspeicher S3_ENDPOINT: z.string().min(1), S3_PORT: zahl(9000), S3_USE_SSL: boolAusText(false), S3_ACCESS_KEY: z.string().min(1), S3_SECRET_KEY: z.string().min(1), S3_REGION: z.string().default("eu-central-1"), S3_BUCKET: z.string().min(1).default("florian"), S3_PUBLIC_URL: z.string().url().optional(), S3_URL_TTL: zahl(900), // PDF-Erzeugung GOTENBERG_URL: z.string().url().optional(), GOTENBERG_TIMEOUT: zahl(30), // Sitzungen AUTH_SECRET: z.string().min(32, "AUTH_SECRET muss mindestens 32 Zeichen haben"), AUTH_TRUST_HOST: boolAusText(true), AUTH_SESSION_MAX_AGE: zahl(28_800), // OIDC gegen authentik AUTH_OIDC_ENABLED: boolAusText(false), AUTH_OIDC_ISSUER: z.string().optional(), AUTH_OIDC_CLIENT_ID: z.string().optional(), AUTH_OIDC_CLIENT_SECRET: z.string().optional(), AUTH_OIDC_NAME: z.string().default("authentik"), AUTH_OIDC_GROUP_CLAIM: z.string().default("groups"), AUTH_OIDC_GROUP_MAPPING: z.string().default(""), AUTH_OIDC_AUTO_PROVISION: boolAusText(true), AUTH_OIDC_STANDARD_ORGANISATION: z.string().optional(), // Lokaler Login AUTH_LOCAL_ENABLED: boolAusText(true), AUTH_BCRYPT_ROUNDS: zahl(12), // Kiosk KIOSK_ENABLED: boolAusText(true), KIOSK_DEVICE_SECRET: z.string().min(8), KIOSK_PIN_LAENGE: zahl(6), KIOSK_MAX_FEHLVERSUCHE: zahl(5), KIOSK_SPERRDAUER: zahl(300), KIOSK_IDLE_TIMEOUT: zahl(120), // Integrationen (alle optional und abschaltbar) MAIL_ENABLED: boolAusText(false), MAIL_HOST: z.string().optional(), MAIL_PORT: zahl(587), MAIL_SECURE: boolAusText(false), MAIL_USER: z.string().optional(), MAIL_PASSWORD: z.string().optional(), MAIL_FROM: z.string().optional(), TELEGRAM_ENABLED: boolAusText(false), TELEGRAM_BOT_TOKEN: z.string().optional(), TELEGRAM_CHAT_ID: z.string().optional(), LLM_ENABLED: boolAusText(false), LLM_BASE_URL: z.string().optional(), LLM_API_KEY: z.string().optional(), LLM_MODELL: z.string().optional(), // Wartung und Prüfung (Phase 2) /** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */ WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"), /** Wann Erinnerungen verschickt werden — nach der Planung, nicht davor. */ WARTUNG_ERINNERUNG_LAUF: z.string().default("45 3 * * *"), /** Wie weit im Voraus Termine angelegt werden. */ WARTUNG_PLANUNGSHORIZONT_TAGE: zahl(180), // Aufbewahrung AUFBEWAHRUNG_JAHRE: zahl(10), AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650), PERSON_ANONYMISIERUNG_TAGE: zahl(365), }); export type Umgebung = z.infer; /** Ersatzwerte für Build und Tests — nie für den echten Betrieb. */ const platzhalter: Record = { DATABASE_URL: "postgresql://build:build@localhost:5432/build?schema=public", REDIS_URL: "redis://localhost:6379", S3_ENDPOINT: "localhost", S3_ACCESS_KEY: "build", S3_SECRET_KEY: "build", AUTH_SECRET: "build_time_platzhalter_mindestens_32_zeichen", KIOSK_DEVICE_SECRET: "build_time_platzhalter", }; function lesen(): Umgebung { const roh = process.env.SKIP_ENV_VALIDATION === "1" || process.env.NODE_ENV === "test" ? { ...platzhalter, ...process.env } : process.env; const ergebnis = schema.safeParse(roh); if (!ergebnis.success) { const zeilen = ergebnis.error.issues.map( (problem) => ` - ${problem.path.join(".")}: ${problem.message}`, ); throw new Error( [ "Die Umgebungskonfiguration ist unvollständig oder fehlerhaft:", ...zeilen, "", "Vorlage mit Erklärungen zu jeder Variable: .env.example", ].join("\n"), ); } return ergebnis.data; } export const env: Umgebung = lesen(); /** true, wenn die Anmeldung über authentik vollständig konfiguriert ist. */ export const oidcVerfuegbar = env.AUTH_OIDC_ENABLED && Boolean(env.AUTH_OIDC_ISSUER) && Boolean(env.AUTH_OIDC_CLIENT_ID) && Boolean(env.AUTH_OIDC_CLIENT_SECRET); /** * Zuordnung authentik-Gruppe zu FLORIAN-Rolle aus AUTH_OIDC_GROUP_MAPPING. * Format: "gruppe:ROLLE,gruppe2:ROLLE2". */ export function oidcGruppenZuordnung(): Map { const karte = new Map(); for (const eintrag of env.AUTH_OIDC_GROUP_MAPPING.split(",")) { const [gruppe, rolle] = eintrag.split(":").map((teil) => teil?.trim()); if (gruppe && rolle) karte.set(gruppe.toLowerCase(), rolle.toUpperCase()); } return karte; }