# Architektur und Entscheidungen Ergänzt [`CLAUDE.md`](../CLAUDE.md), das die verbindlichen Konventionen enthält. Hier steht das Warum. ## 1. Zwei Betriebsmodi, ein Codepfad `Einstellung.modus` ist `INTERN` oder `DIENSTLEISTUNG` und liegt in der Datenbank, nicht in der Umgebung — der Admin schaltet ohne Neustart um. Der Kern der Entscheidung: **jede kundenbezogene Zeile trägt immer eine `kundeId`**, auch im Modus `INTERN`. Dort ist es die eine Organisation mit `isteigeneOrganisation = true`, die in keiner Liste, keinem Filter, keinem Formular und auf keinem Etikett erscheint. Dadurch ist der Wechsel nach `DIENSTLEISTUNG` eine Datenänderung (die eigene Organisation wird zum ersten Kunden) und keine Migration. Es gibt daher **nicht zwei Codepfade** für Geräte oder Läufe, sondern ein- und ausgeblendete Felder und Filter. Zwei Funktionen tragen das: - `betriebsmodus()` — gecachter Zugriff, den alle Listen und Formulare fragen - `kundeErmitteln(eingabe)` — liefert im Modus `INTERN` immer die eigene Organisation und **verwirft** eine mitgeschickte `kundeId`. Das ist eine Sicherheitsgrenze, kein Komfort. ## 2. Schichtung ``` Server Component ──► queries/* ──► prisma Server Action ──► services/* ──► prisma └─ rechtErforderlich(), Zod, Regeln, Protokoll ``` `services/*` hält die Regeln und wirft eigene `…Fehler`-Klassen. `queries/*` hält Lesemodelle und wendet Rollen- und Mandantensicht selbst an, damit kein Aufrufer sie vergessen kann. `actions/*` sind Dreizeiler: Recht prüfen, Schema anwenden, Service rufen. Der Grund für die Trennung von Action und Service: die Regeln sollen **ohne Next.js testbar** sein. Vitest ruft den Service direkt. ## 3. Anmeldung: kein Auth.js Der Auftrag nannte Auth.js. Beim Prüfen der Lage am 2026-09-24: - `next-auth` v5 steht auf `5.0.0-beta.32`, seit Jahren ohne GA - authjs.dev führt das Projekt im Wartungsmodus („The Auth.js project is now part of Better Auth"), die Maintainer schreiben „no immediate plans for v5“ - `@auth/prisma-adapter` deklariert `@prisma/client` nur bis `>=6` — wir fahren Prisma 7 Entschieden wurde deshalb der Eigenbau mit stabilen Bausteinen, wie in ProxLedger: - **`openid-client` 6.x** für generisches OIDC mit Discovery (Ziel: authentik) - **`jose`** für ein signiertes Sitzungs-Cookie (HS256 aus `APP_SECRET`) - **`@node-rs/argon2`** für den lokalen Notfallzugang Rolle und Aktiv-Status werden **pro Anfrage aus der Datenbank** gelesen, nicht aus dem Token. Eine Rollenänderung greift damit sofort, und ein deaktivierter Benutzer wird auch mit gültigem Cookie abgewiesen. ## 4. Versionierung und Archiv Zwei Regeln tragen die Nachvollziehbarkeit: 1. **Eine veröffentlichte `VorlagenVersion` wird nie verändert.** „Bearbeiten“ erzeugt eine tiefe Kopie als Entwurf. Ein Lauf auf Version 1 zeigt für immer die Schritte aus Version 1, auch wenn längst Version 5 veröffentlicht ist. 2. **Ein archivierter Lauf ist schreibgeschützt.** Die Sperre sitzt in genau einer Funktion, `laufMussVeraenderbarSein(lauf)`, die vor jeder Änderung an Kopfdaten, `Schrittergebnis` und `Kommentar` gerufen wird. Nur `ADMIN` holt einen Lauf zurück, mit `Protokoll`-Eintrag. Beides ist durch Tests abgedeckt, nicht durch Disziplin. Der Aufbau einer Version wird **als Ganzes** gespeichert, nicht Schritt für Schritt: der Editor hält den Baum im Speicher und sortiert per Drag & Drop, ein Teil-Update wäre dort ständig mit dem Sortieren im Streit. Dasselbe Zod-Schema (`src/lib/vorlagen-format.ts`) beschreibt den Entwurf im Editor, den JSON-Export und den Import — drei getrennte Schemata würden auseinanderlaufen, und der Import wäre der Ort, an dem das erst auffiele. Das Format ist in [`docs/vorlagen-format.md`](vorlagen-format.md) beschrieben. Die **eingebauten Platzhalter** stehen als Liste in [`src/lib/platzhalter.ts`](../src/lib/platzhalter.ts) — Daten, kein Code. Der Editor zeigt sie als Hilfe, die Warnung vor unbekannten Platzhaltern vergleicht dagegen, und die Auflösung in #012 baut darauf auf. Eine zweite Aufzählung an anderer Stelle würde unweigerlich auseinanderlaufen: ein Platzhalter, der in der Hilfe steht, aber nicht aufgelöst wird, bliebe im fertigen PowerShell-Befehl als `{{…}}` stehen. Die Warnung ist ausdrücklich **keine Ablehnung**. Ein Tippfehler soll im Editor auffallen und nicht erst dem Techniker im Lauf; es kann aber gute Gründe geben, einen Platzhalter stehen zu lassen — etwa weil die Variable gleich noch kommt. Speichern und Veröffentlichen bleiben deshalb erlaubt. ## 5. Der Weg zu Phase 4 (Aktionen) Die in den Vorlagen vorbereiteten PowerShell-Befehle sind bewusst die **Vorstufe ausführbarer Schritte**. Damit der Schritt dorthin keine Migration braucht: - `VorlagenSchritt.typ` ist ein Enum, dem `AKTION` hinzugefügt wird; `konfig` (Json) trägt dann Connector und Parameter - `Schrittergebnis.wert` ist ein Json, das ein Aktionsergebnis mit Log, IDs und Zeitstempeln aufnimmt - kein Codepfad nimmt an, dass ein Schritt nur von einem Menschen erledigt wird - das Krypto-Modul für das LDAP-Bind-Passwort ist schon der Platz für Connector-Geheimnisse ## 5a. Was in der Datenbank steht und warum Die Service-Schicht ist der richtige Ort für Regeln mit verständlichen Meldungen. Sie ist aber nicht der einzige Weg, auf dem Daten entstehen: Migrationen, `psql`, Skripte und ab Phase 4 die Connectoren schreiben an ihr vorbei. Was stillschweigend falsch werden könnte, steht deshalb zusätzlich in der Datenbank: | Regel | Form | |---|---| | Höchstens eine eigene Organisation | partieller Eindeutigkeitsindex | | Höchstens ein Entwurf je Vorlage | partieller Eindeutigkeitsindex | | Kundenkürzel, `tagCode`-Form, verschlüsseltes Bind-Passwort | CHECK auf die Form | | Registriertes Gerät braucht Hostname oder Bezeichnung | CHECK, greift erst ab `registriertAm` | | Mitarbeiter aus dem Verzeichnis **oder** Freitextname | CHECK | | Status und Zeitstempel laufen nicht auseinander | vier CHECKs | | `NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` brauchen eine Notiz | CHECK | | Einstellung ist eine einzelne Zeile | CHECK auf die feste Kennung | | **Mandantengrenze** | vier zusammengesetzte Fremdschlüssel | Die Mandantengrenze verdient die Erklärung: `Lauf` verweist mit `("geraetId", "kundeId")` auf `Geraet("id", "kundeId")`, analog für Person und Abteilung, und `Geraet` ebenso auf den Besitzer. Damit ist ein Lauf, der ein Gerät eines fremden Kunden zieht, nicht mehr bloß ein Fehler im Service, sondern strukturell unmöglich. `MATCH SIMPLE` sorgt dafür, dass ein Lauf ohne Gerät weiterhin erlaubt ist. Zwei Dinge sind bewusst **nicht** kaskadierend: ein `Lauf` nimmt seine Schrittergebnisse und Kommentare nicht mit, und ein `Protokoll`-Eintrag verliert seinen Urheber nicht. Beides stünde gegen den Zweck eines Archivs. Der `tagCode` ist zusätzlich in `TagCodeVergabe` festgehalten, aus dem nie gelöscht wird — sonst würde ein Code nach dem Löschen eines Geräts wieder frei und ein alter Aufkleber zeigte auf ein fremdes Objekt. ## 5b. Warum ein Screenshot nichts über Bedienbarkeit sagt In dieser Umgebung hydriert `next dev` nicht: die Seiten werden serverseitig gerendert und sehen vollständig richtig aus, aber React hängt sich nie an — kein Knopf, kein Formular, kein Sortieren tut etwas. Nachgewiesen in #010, und zwar auch ohne `src/proxy.ts` und ohne `output: "standalone"`; der HMR-WebSocket scheitert mit `ERR_INVALID_HTTP_RESPONSE`. Es liegt am Werkzeug, nicht an diesem Code — im gebauten Stand tragen 1281 von 1296 Elementen React-Knoten, im Entwicklungsmodus zwei. Zwei Folgen, beide verbindlich: 1. **Oberflächen werden gegen `pnpm build && pnpm start` abgenommen**, nicht gegen `pnpm dev` — ebenso die Screenshots. Steht in `CLAUDE.md`. 2. **Was nur mit angehängtem React gelingt, gehört in einen Playwright-Test.** `pnpm test:e2e` klickt den Editor durch: anlegen, tippen, sortieren mit dem Finger bei 390 px, speichern, neu laden und nachsehen, ob es wirklich in der Datenbank steht. Die Content-Security-Policy in `src/proxy.ts` ist deshalb zweigeteilt: im Entwicklungsmodus mit `unsafe-eval` und WebSocket, im ausgelieferten Stand ohne beides. Die Entscheidung fällt an `process.env.NODE_ENV`, und `tests/sicherheitsrichtlinie.test.ts` prüft sie am Proxy selbst — eine Prüfung der Funktion mit selbst gesetztem Argument hätte genau den Fehler durchgelassen, vor dem der Kommentar darüber warnt. ## 6. Betrieb: zwei Wege, eine Regel Die Datenbank ist auf beiden Wegen von außen nicht erreichbar: | Weg | Wie | |---|---| | Docker Compose | `postgres` ohne `ports:`, nur im Netz `intern` mit `internal: true` (kein Gateway) | | Nativ im LXC | PostgreSQL mit `listen_addresses = ''` — nur Unix-Socket | Festgehalten in `tests/betriebsstand.test.ts`, damit ein `ports:`-Mapping nicht versehentlich zurückkommt. ## 7. Werkzeug-Obergrenzen Zwei Abhängigkeiten dürfen **nicht** angehoben werden, beides empirisch festgestellt: - **`eslint` bleibt auf 9.x.** `eslint-config-next` bringt `eslint-plugin-react@7.37.5` mit, das `eslint` nur bis `^9.7` unterstützt. Mit eslint 10 bricht das Regelwerk beim Laden ab (`contextOrFilename.getFilename is not a function`). Eine neuere Fassung des Plugins gibt es nicht — die letzte stammt von April 2025. - **`typescript` bleibt auf `~6.0.3`.** `typescript-eslint@8` erlaubt nur `>=4.8.4 <6.1.0`. TypeScript 7.0.2 ist zwar `latest`, aber noch nicht unterstützt. Dazu ein Pinning: **`prisma` und `@prisma/client` exakt auf `7.10.0`**. Der `latest`-Tag von `prisma` zeigt auf einen 8.0-RC, der von `@prisma/client` nicht — ein unbedachtes `pnpm add prisma` würde beide auseinanderlaufen lassen.