Files
Laufzettel/docs/architektur.md
T
florian.wilde eec5de404a
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
2026-09-25 13:30:31 +00:00

205 lines
11 KiB
Markdown

# 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.
## 5c. Warum die Maskierung dort sitzt, wo sie sitzt
Aus einer Vorlage entsteht ein PowerShell-Befehl, den ein Techniker kopiert und auf einem
Domänencontroller ausführt. Drei Entscheidungen tragen das (#012):
1. **Maskiert wird der eingesetzte Wert, nicht der fertige Befehl.** Sonst maskierte man die
Anführungszeichen des Befehls gleich mit. `platzhalterErsetzen` bekommt die Maskierung deshalb
als Funktion und wendet sie auf jeden Wert einzeln an.
2. **Die Maskierung setzt keine Anführungszeichen dazu** — die stehen in der Vorlage. Das ist eine
Annahme, und sie wird geprüft: steht ein `{{platzhalter}}` in einem `BEFEHL`-Schritt nicht
zwischen den erwarteten Anführungszeichen, wird der Schritt abgewiesen statt gefüllt. Ein halb
maskierter Befehl zum Kopieren wäre die gefährlichste aller Antworten.
3. **Was sich nicht sicher maskieren lässt, wird abgelehnt.** Für Cmd gibt es keine Regel, die ein
`"` in jedem Fall richtig behandelt — ein solcher Wert bekommt eine Fehlermeldung, keinen
Befehl. PowerShell liest auch `“`, `”` und `„` als Begrenzer; die werden auf ihre ASCII-Form
gebracht und dann maskiert, weil sie sich als Text ohnehin nicht durchreichen lassen.
Geprüft wird das **ausgeführt**, nicht gelesen: `tests/shell.test.ts` schickt bösartige Werte durch
ein echtes `bash -c` und durch ein echtes PowerShell — und wenn auf der Maschine kein `pwsh` liegt,
nimmt der Test das Container-Abbild. In CI bricht er ab, statt sich zu überspringen; ein stilles
Überspringen sieht genauso grün aus wie ein bestandener Lauf.
Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurückgegeben — es gibt
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
## 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.