Phase 0: Fundament der Feuerwehr-Verwaltungsplattform
Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen. Betrieb: - docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS, Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer mit Mailpit und Adminer - Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck - .env.example mit einem Kommentar zu jeder Variable Mandantentrennung: - Row Level Security auf allen mandantenpflichtigen Tabellen - Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung - fuerOrganisation() als einziger Weg zu Fachdaten Nachvollziehbarkeit: - AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE nur im gekennzeichneten Aufbewahrungslauf - Geheimnisse werden vor dem Schreiben entfernt Anmeldung: - OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als Vorbelegung beim ersten Login - Lokaler E-Mail/Passwort-Login als Rueckfallweg - Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person) Weiteres: - lib/authz.ts mit 9 Rollen und 38 Einzelrechten - lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ) - Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces - tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung - Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete - 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests - README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog, Versionierungsregeln und Changelog Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
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(),
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user