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(), /** * Push über einen selbst betriebenen ntfy (Phase 7). * * Steht der ntfy im eigenen Netz, verlässt keine Nachricht das Gerätehaus — * deshalb gilt er als interner Kanal und bekommt die ausführlichen Texte. * Wer stattdessen das öffentliche ntfy.sh einträgt, bekommt eine Warnung in * der Oberfläche: dann ist es ein externer Dienst wie jeder andere. */ NTFY_ENABLED: boolAusText(false), NTFY_URL: z.string().url().optional(), /// Nur nötig, wenn der eigene ntfy Zugriffsschutz eingeschaltet hat. NTFY_TOKEN: z.string().optional(), NTFY_TIMEOUT: zahl(10), /** * Web-Push in den Browser (Phase 7). * * Die VAPID-Schlüssel einmal mit `npm run vapid` erzeugen und hier eintragen. * Sie gehören ins Backup: ein neuer öffentlicher Schlüssel macht alle * bestehenden Push-Abos wertlos, jeder müsste Push neu erlauben. */ WEBPUSH_ENABLED: boolAusText(false), WEBPUSH_PUBLIC_KEY: z.string().optional(), WEBPUSH_PRIVATE_KEY: z.string().optional(), /// Kontaktweg für den Push-Dienst, "mailto:..." oder eine Adresse. WEBPUSH_KONTAKT: z.string().optional(), /** * Benachrichtigungen und Termine (Phase 7). * * Wann der Worker die Erinnerungen versendet. Bewusst am frühen Morgen und * nicht nachts: eine Push-Meldung um drei Uhr weckt Leute, die am nächsten * Tag arbeiten müssen. */ BENACHRICHTIGUNG_LAUF: z.string().default("0 7 * * *"), /// Wie viele Tage vor einem Termin erinnert wird (kommagetrennt, absteigend). TERMIN_ERINNERUNG_TAGE: z.string().default("7,1"), /// Wie oft eine fehlgeschlagene Zustellung wiederholt wird. BENACHRICHTIGUNG_VERSUCHE: zahl(3), /// Wie lange zugestellte Benachrichtigungen aufgehoben werden. BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: zahl(90), /** * Sprachmodelle, ausschließlich gegen einen selbst betriebenen Endpunkt * (Ollama, vLLM, llama.cpp). Ab Phase 8 tatsächlich benutzt. * * Es gibt keinen Rückfall auf einen Anbieter. Fehlt der Endpunkt, fehlt die * Funktion — sie wird nicht durch einen fremden Dienst ersetzt. */ LLM_ENABLED: boolAusText(false), LLM_BASE_URL: z.string().optional(), LLM_API_KEY: z.string().optional(), LLM_MODELL: z.string().optional(), /// Zeitlimit für eine Antwort. Ein Bericht darf dauern, aber nicht ewig. LLM_TIMEOUT: zahl(120), /** * Modell für Einbettungen (semantische Suche im Wiki). * * ACHTUNG: Die Spalte wikiabschnitt.einbettung ist auf 768 Dimensionen * festgelegt — das liefert nomic-embed-text. Ein Modell mit anderer Länge * braucht eine Migration, siehe README. */ LLM_EINBETTUNG_MODELL: z.string().default("nomic-embed-text"), LLM_EINBETTUNG_DIMENSIONEN: zahl(768), /// Modell, das Bilder lesen kann — für die Belegerfassung. LLM_VISION_MODELL: z.string().optional(), /** * Spracherkennung über einen selbst betriebenen faster-whisper * (Compose-Profil "ki"). Ohne ihn ist das Diktat abgeschaltet. */ WHISPER_ENABLED: boolAusText(false), WHISPER_URL: z.string().url().optional(), WHISPER_MODELL: z.string().default("base"), WHISPER_SPRACHE: z.string().default("de"), /// Zeitlimit für eine Aufnahme. Zehn Minuten Sprache brauchen Zeit. WHISPER_TIMEOUT: zahl(300), /// Höchstlänge einer Aufnahme in Sekunden. DIKTAT_MAX_SEKUNDEN: zahl(600), // Terminal im Gerätehaus (Phase 4) /// Wetterwarnungen des DWD auf dem Hallenmonitor. Die einzige Verbindung, /// die FLORIAN von sich aus nach außen aufbaut — standardmäßig aus. WETTER_ENABLED: boolAusText(false), /// Warnzellen-Nummer der eigenen Gemeinde aus der DWD-Warnzellenliste. DWD_WARNCELL: z.string().optional(), DWD_WARNUNGEN_URL: z .string() .url() .default("https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json"), WETTER_TIMEOUT: zahl(5), /// Wie lange eine Antwort des DWD gilt, bevor neu gefragt wird. WETTER_ZWISCHENSPEICHER: zahl(300), // Atemschutz nach FwDV 7 (Phase 5) /** * Der Sicherheitshinweis über jeder Ansicht der Atemschutzüberwachung. * * Er ist standardmäßig an und **gehört an**: FLORIAN ist ein ergänzendes * System, die analoge Überwachung bleibt führend. Wer ihn abschaltet, * erklärt damit, dass die Wehr die digitale Überwachung freigegeben hat — * das ist eine Entscheidung des Kommandanten, keine des Administrators. * Deshalb verlangt das Abschalten zusätzlich einen Verweis auf die * schriftliche Freigabe (ATEMSCHUTZ_FREIGABE), der in der Oberfläche und im * Protokoll steht. */ ATEMSCHUTZ_HINWEIS: boolAusText(true), /// Aktenzeichen oder Fundstelle der Freigabe durch den Kommandanten. ATEMSCHUTZ_FREIGABE: z.string().optional(), /// Vorgabe für den Abstand der Druckabfrage, in Sekunden. ATEMSCHUTZ_ABFRAGE_INTERVALL: zahl(600), /// Ansprechdruck der Warneinrichtung in bar, Vorgabe für neue Überwachungen. ATEMSCHUTZ_WARNDRUCK: zahl(55), /// Sicherheitszuschlag auf den Rückzugsdruck in bar. ATEMSCHUTZ_ZUSCHLAG: zahl(10), // Kartenmodule (Phase 6) /** * Adresse des eigenen Kartenservers. * * FLORIAN lädt keine Kacheln aus dem Netz — weder von OpenStreetMap noch von * einem Anbieter. Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche * Objekte sie im Blick hat, ist niemandes Geschäft. Der Kartenserver läuft * deshalb selbst (tileserver-gl im Compose-Profil "karte", siehe README). * * Ohne Adresse zeigt die Karte einen leeren Hintergrund mit Gitternetz — * Hydranten, Objekte und Lagekarte funktionieren trotzdem, nur ohne Kacheln * darunter. */ KARTE_STIL_URL: z.string().optional(), /** Kartenmittelpunkt beim Öffnen, falls kein Standort hinterlegt ist. */ KARTE_BREITENGRAD: z.coerce.number().min(-90).max(90).default(48.5), KARTE_LAENGENGRAD: z.coerce.number().min(-180).max(180).default(12.15), KARTE_ZOOM: zahl(13), // Einsätze und Übungen (Phase 3) /// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus. GEOCODING_ENABLED: boolAusText(false), /// Adresse des SELBST betriebenen Nominatim. Einsatzadressen gehören nicht /// an einen öffentlichen Dienst. NOMINATIM_URL: z.string().url().optional(), GEOCODING_TIMEOUT: zahl(5), /// Auf welche Länder die Suche eingegrenzt wird (ISO-Codes, kommagetrennt). GEOCODING_LAENDER: z.string().default("de,at,ch"), /// Einsatzportal für Fremdwehren: Link ohne Anmeldung, zeitlich begrenzt. PORTAL_ENABLED: boolAusText(true), /// Wie lange ein neu erzeugter Portal-Link gilt. PORTAL_GUELTIGKEIT_STUNDEN: zahl(24), /// Höchstzahl Zugriffe je Portal-Link und Stunde. PORTAL_MAX_ZUGRIFFE: zahl(120), // 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; }