Files
FireAdministration/src/lib/env.ts
T
Florian WildeandClaude Opus 5 e503aafc18 Phase 2: Wartungs- und Pruefmanagement
Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:07:11 +02:00

182 lines
5.8 KiB
TypeScript

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<typeof schema>;
/** Ersatzwerte für Build und Tests — nie für den echten Betrieb. */
const platzhalter: Record<string, string> = {
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<string, string> {
const karte = new Map<string, string>();
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;
}