Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper, Berichtsassistent und Belegerfassung — alles gegen selbst betriebene Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was Menschen getan haben. Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal. Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion. Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen. Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und faster-whisper stehen im Compose-Profil "ki". 73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
331 lines
12 KiB
TypeScript
331 lines
12 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(),
|
|
|
|
/**
|
|
* 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<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;
|
|
}
|