# 📋 OpenCRM – Todo-Liste

---

## 🔜 Offen

### Manuelle Tests (vor Release durchklicken)
Checklisten für Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**.
Einmal komplett durchlaufen vor v1.0.0-Release.

### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless

**Vision:** OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene
isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
über ein zentrales Admin-Portal.

**Architektur-Entscheidung:** Weg C (Instance-per-Customer)
- Pro Kunde eine eigene Docker-Instanz mit eigener DB
- Keine `tenantId` im CRM-Code → keine Security-Risiken durch vergessene Filter
- Komplette Datenisolation (DSGVO-freundlich)
- Updates können gestaffelt ausgerollt werden (erst 10% testen)
- Bei Kündigung: Docker-Image + DB-Export als "Mitnehm-Paket"

**Bewusst NICHT dabei:** eigener Mailserver. Stattdessen Plesk-Integration
(die wir schon haben) – Kunde bekommt Mail-Zugang über unseren Plesk bei Bedarf.

---

**Admin-Portal (separate App, neben den CRM-Instanzen):**
- Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled)
- "Neuen Kunden anlegen" → Provisioning-Script
  - DB anlegen (Master-DB kennt die Mapping)
  - Docker-Container starten
  - Subdomain konfigurieren (`kundenname.deincrm.de` via Caddy/Traefik)
  - Initial-Admin-Account erstellen + Einladungs-Email senden
  - Optional: Factory-Defaults für Stammdaten einspielen
- GoCardless-Integration (Webhook + Dashboard)
- Instanz-Management: Pause/Resume bei Zahlungsproblemen
- Logs & Metriken pro Instanz (optional)
- Support-Bereich (Tickets? oder einfach E-Mail)

---

**Abrechnung mit GoCardless (gocardless.com):**
- Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (über GoCardless Embedded/Success)
- 30 Tage kostenlose Testphase ohne Zahlungsmittel
- Nach Trial: Mandats-Erfassung → regelmäßige Abbuchung
- Mehrere Pläne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features
- Webhook-Endpoint im Admin-Portal:
  - `payment_confirmed` → Instanz aktiv lassen
  - `payment_failed` → Banner im CRM, nach X Tagen pausieren
  - `mandate_cancelled` → Kündigungs-Flow
- Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber **echte Rechnungen**
  (mit USt-ID, Rechnungsnummer etc.) müssen wir selbst generieren
  (evtl. über das existierende PDF-Template-System aus dem CRM nutzen)

---

**Provisioning-Flow (grober Entwurf):**
1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain)
2. Admin-Portal: Trial-Instanz starten
   - DB erstellen, Docker-Container hochfahren, Caddy-Config für Subdomain
   - Einladungs-Email mit Admin-Login + Passwort-Reset-Link
3. Tag 25: Erinnerungs-Email "Deine Trial läuft bald ab"
4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren"
5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login
6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv
7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis)

---

**Technische Bausteine für später:**
- Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs)
- Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt)
- Docker-Orchestrierung: einzelne `docker-compose.yml` pro Kunde oder Docker-Swarm/K8s
- Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups
- Monitoring: ein Fail macht nicht alle down, aber wir müssen es mitbekommen
- Logs zentral: z.B. Loki + Grafana für aggregierte Logs aller Instanzen

---

**Grobe Zeitschätzung:**
- Admin-Portal (MVP): ~1 Woche
- GoCardless-Integration + Webhooks: ~3-5 Tage
- Provisioning-Automatisierung (Docker + Caddy): ~1 Woche
- Landing Page + Checkout: ~3-5 Tage
- Tests + Polishing: ~1 Woche
- **Gesamt: ~3-4 Wochen**

**Vorbereitung JETZT (einfach, macht später Arbeit leichter):**
- ✅ Factory-Defaults System (schon erledigt, hilft beim Provisioning)
- ✅ Domain/Label dynamisch per Provider (schon erledigt)
- Docker-Compose aufräumen, Env-Variablen dokumentieren (klein, ein Tag)
- Backup-Script robust + wiederherstellbar (haben wir schon weitgehend)

---

## ✅ Erledigt

- [x] **🕳️ Entfernter Protokoll-ANFANG wurde nicht erkannt** (2026-08-26)
  - Gefunden bei der Vorbereitung des Prod-Siegels: Die Verkettung wird
    zeilenweise gegen die Vorgängerin geprüft – die **erste** Zeile hat keine,
    also fiel bisher **nichts** auf, wenn ein zusammenhängender Anfang des
    Protokolls entfernt wurde. Kein Kettenbruch, kein Befund, `valid` blieb
    grün. Die stillste Löschung von allen: Wer die ältesten Einträge loswerden
    will, muss nur vorne anfangen.
  - Erkennbar ist es trotzdem: Die allererste Zeile wird ohne Vorgänger
    geschrieben und trägt einen leeren `previousHash`. Trägt die erste
    **vorhandene** Zeile einen Wert, hat es eine Vorgängerin gegeben – und die
    ist weg. Wird jetzt als Kettenlücke an dieser Zeile geführt, mit derselben
    Manifest- und Beglaubigungslogik wie jede andere Lücke.
  - Nur bei Prüfung des Gesamtbereichs; mit `fromId` ist ein gefüllter
    `previousHash` selbstverständlich (gegengeprüft, kein Fehlalarm).
  - Getestet über HTTP: vollständiges Protokoll → `valid:true`; erste drei
    Zeilen entfernt → **vorher** unverändert `valid:true`, **jetzt**
    `valid:false`, `chainGaps:[4]`, wegen Hash-Version 3 zusätzlich als
    Manipulation eskaliert.
  - Auf Prod ausgeschlossen: Das Protokoll beginnt bei ID 1 (07.05.2026,
    Inbetriebnahme), kein Cleanup, keine Neuberechnung.
  - Datei: `backend/src/services/audit.service.ts`

- [x] **⏱️ Neuberechnung der Kette ist jetzt eine eigene Gegenbuch-Alarmbedingung (Pentest R186, Frage b)** (2026-08-26)
  - Der Tester fragte präzise: Löst `cleanup` → `rehash` **ohne** erneutes
    Siegeln, über **nicht versiegelten** Inhalt, etwas **Automatisches** aus –
    oder nur die Prosa in `verify`, die ein Mensch lesen muss?
  - **Gemessen statt behauptet:** Es löste bereits aus, aber als *Nebenwirkung*.
    Ein Rehash ändert jeden Hash, also stimmt der beglaubigte Kettenkopf nicht
    mehr und der bestehende Vergleich schlug an. Funktioniert – ist aber ein
    Zufallstreffer: Verschöbe sich der Anker, wäre der Melder lautlos weg. Und
    die Meldung hieß „Eintrag wurde verändert" statt „die Kette wurde neu
    berechnet", also Wirkung statt Ursache.
  - Jetzt hängt der Alarm an der Sache selbst: Das Gegenbuch führt
    `rehashCount`/`rehashLast` im Buch mit und meldet jede neue Neuberechnung
    seit der letzten Beglaubigung mit **exit 2** – samt Zeitpunkt, Zeilenzahl
    und dem Befund, der unmittelbar davor galt. Dieselbe Lehre wie R184-01
    (Gate am Auslöser, nicht an der Wirkung) und R185-01 (Wurzelwechsel statt
    `valid`).
  - **Reihenfolge geändert, und das war nötig:** Der neue Melder steht **vor**
    dem Kopf-Hash-Vergleich, sonst hätte immer die unpräzisere Meldung
    gewonnen. Und der Kopf-Vergleich wird nach einer *bestätigten*
    Neuberechnung übersprungen – sonst wäre die Bestätigung wertlos gewesen,
    weil ein Rehash den Kopf zwangsläufig ändert. Beim Bauen aufgefallen, nicht
    im Entwurf.
  - `NOTARY_REHASH_ACK` wird mit der **ID des Rehash-Eintrags** bestätigt, nicht
    mit `true`. IDs steigen streng – ein stehen gelassener Wert passt beim
    nächsten Vorgang nicht mehr. Analog zu `NOTARY_SEAL_ACK`.
  - Ein **fehlender** beglaubigter Eintrag alarmiert weiterhin immer: Bestätigt
    wird die Neuberechnung, nicht das Verschwinden von Zeilen.
  - Bestandsbücher ohne das neue Feld alarmieren nicht rückwirkend, sondern
    setzen die Grundlage – mit Ausgabe, statt es stillschweigend zu tun.
  - **Getestet mit echtem Gegenbuch gegen ein echtes CRM** (SSH-signiertes
    lokales Buch): Wäsche ohne Datenbankzugriff (3 Zeilen gelöscht → Rehash)
    → CRM meldet `valid:true`, `chainGaps:[]`; Gegenbuch **exit 2** und nennt
    die Neuberechnung als Ursache. Falsche Ack-ID → weiter exit 2, richtige →
    exit 0 mit neuer Grundlage, Folgelauf ruhig.
  - Dateien: `tools/audit-notary/notary.mjs`,
    `tools/audit-notary/{docker-compose.yml,.env.example,README.md}`

- [x] **🔍 `verify` meldet jetzt, dass die Kette neu berechnet wurde** (2026-08-26)
  - **Im Betrieb entdeckt, nicht im Test.** Das Staging-Gegenbuch meldete
    „Der beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem
    Protokoll: am 22.08. wurden die Aufbewahrungsfristen auf **0** gesetzt,
    zwei Cleanups löschten **3.155** Einträge (id 1–5356), danach lief ein
    **Rehash**. Seither meldet die Prüfung „Alle Einträge sind unverändert und
    **lückenlos** verkettet".
  - Wahr – und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der
    Rehash verknüpft alles neu; die rund **700** Kettenlücken, die davor
    bestanden, sind seitdem unsichtbar. Nachweisbar im Vorbefund, den der
    Rehash selbst mitschreibt (R170-01) – nur schaute den nie jemand an.
  - Das Gegenbuch war der einzige Zeuge. Innerhalb des CRM war die Löschung
    nicht mehr feststellbar.
  - `verifyIntegrity` sammelt jetzt die Rehash-Marker; die Antwort enthält
    `rehashes[]` (Zeitpunkt, Anzahl, Signatur, Vorbefund). Die Meldung nennt
    sie **immer**, auch im grünen Fall, und der Einstiegssatz lautet dann
    „…lückenlos verkettet – allerdings erst seit der letzten Neuberechnung".
  - **`valid` bleibt unberührt.** Ein Rehash ist eine legitime Maßnahme; ihn
    dauerhaft als Befund zu führen wäre der Dauer-Alarm, den wir mit den
    beglaubigten Lücken gerade beseitigt haben. Melden, nicht alarmieren.
  - **Umgekehrte Beweislast als bei Manifest und Siegel:** Dort zählen nur
    signierte Träger, weil ein gefälschter Marker Lücken *wegerklären* könnte.
    Hier erzeugt ein Marker eine *Warnung* – würden nur signierte zählen,
    könnte man einen Rehash unsichtbar machen, indem man seine Signatur
    zerstört. Deshalb zählt jeder auswertbare Marker; fehlende Signatur wird
    zusätzlich gemeldet.
  - Getestet über HTTP gegen eine Wegwerf-DB, mit echtem Rehash über den
    regulären Endpunkt: sauberer Vorzustand → „es wurde nichts überdeckt";
    Löschung + Rehash (der Staging-Ablauf im Kleinen) → `chainGaps` fällt von
    1 auf 0, die Meldung nennt Zeitpunkt und Vorbefund; zerstörte Signatur →
    Marker wird weiterhin gemeldet, mit Hinweis.
  - Dateien: `backend/src/services/audit.service.ts`,
    `backend/src/controllers/auditLog.controller.ts`,
    `frontend/src/services/api.ts`,
    `frontend/src/pages/settings/AuditIntegrityCard.tsx`

- [x] **📄 `status.txt` nennt jetzt den Grund, nicht nur das Etikett** (2026-08-26)
  - In der Statusdatei des Gegenbuchs stand für **jeden** Exit-2 derselbe Satz:
    „BEFUND – Widerspruch zwischen CRM und Gegenbuch". Ein echter Widerspruch
    sah damit genauso aus wie eine quittierpflichtige Erstsiegelung.
  - Aufgefallen im Betrieb: Der Betreiber las „BEFUND" auf Staging und konnte
    nicht entscheiden, ob er handeln muss – obwohl die Kette dort `valid:true`
    meldet und der Alarm nur den erwarteten Siegelwechsel betraf. Wer nur die
    Statusdatei liest (und dafür ist sie da – „damit eine Überwachung sie
    abgreifen kann, ohne Logs zu durchsuchen"), bekam ein Etikett ohne Inhalt.
  - Der Lauf wird jetzt mitgeschnitten; die erste `ALARM:`-Zeile landet als
    `Grund:` in `status.txt`. Ohne Alarm die erste Ausgabezeile.
  - Datei: `tools/audit-notary/entrypoint.sh`

- [x] **⚖️ Aufsicht und Eingriff getrennt: neuer Haken „Audit-Betrieb"** (2026-08-26)
  - Die DSGVO-Rolle trug `audit:*` **komplett**, also auch `audit:admin`. Ein
    DSGVO-Beauftragter konnte damit `seal-backlog`, `rehash` und `cleanup` –
    seine eigene Beweisgrundlage ersetzen. Wer das Protokoll beaufsichtigt,
    darf es nicht umschreiben können. Dieselbe Klasse wie R184-02: falsche
    Domäne, zu breit gebündelt.
  - **Der naheliegende Fix wäre falsch gewesen.** `audit:admin` einfach aus
    der DSGVO-Rolle zu streichen hätte es heimatlos gemacht: Die Admin-Rolle
    ist ausdrücklich **ohne** `audit`/`gdpr` gebaut, einzige verbleibende
    Quelle wäre der Entwicklerzugriff – der *alles* gibt. Prod versiegeln
    hätte dann Vollzugriff vorausgesetzt.
  - Deshalb eine eigene versteckte Rolle **`Audit-Betrieb`**
    (`audit:read` + `audit:admin`), zugewiesen über eine Checkbox wie
    DSGVO/Entwickler. DSGVO behält `audit:read` + `audit:export` + `gdpr:*`.
    **Für kein bestehendes Konto weitet sich etwas aus** – im Gegenteil, es
    wird enger, und wer eingreifen können soll, bekommt es ausdrücklich.
  - **Keine zusätzliche Rechte-Hürde davor**, weil das am Henne-Ei-Problem
    scheitert: Nach der Aufteilung hält zunächst niemand `audit:admin`, könnte
    ihn also auch niemand vergeben. Stattdessen wird die Vergabe **laut** –
    CRITICAL im Protokoll **und** `PERMISSION_CHANGED`/CRITICAL im Alarmkanal,
    inklusive Kennzeichen, ob sich jemand den Haken selbst gesetzt hat.
  - Nebenbei geschlossen: `setUserGdprAccess()` legte die DSGVO-Rolle im
    Notfallpfad mit `audit:*` komplett an – eine zweite Liste, die dasselbe
    bedeuten sollte und die Bündelung stillschweigend zurückgebracht hätte.
  - **⚠️ Beim Deploy:** Bestehende DSGVO-Konten verlieren `audit:admin`. Wer
    Prod versiegeln will, muss sich vorher „Audit-Betrieb" ankreuzen.
  - **Gemeldet, nicht geändert:** Jeder mit `users:update` (also Admin) kann
    sich DSGVO oder Entwicklerzugriff selbst vergeben – Entwickler heißt
    *alle* Rechte. Das ist vorbestehend und gehört ins Rollenmodell-Thema des
    Pentesters.
  - Dateien: `backend/prisma/sync-roles.ts`,
    `backend/src/services/user.service.ts`,
    `backend/src/controllers/user.controller.ts`,
    `backend/src/utils/sanitize.ts`,
    `frontend/src/pages/users/UserList.tsx`,
    `frontend/src/services/api.ts`, `tools/audit-notary/README.md`

- [x] **🏷️ Siegel über null Blättern meldet nicht mehr „intakt"** (2026-08-26)
  - Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand
    vorfand, ist rechnerisch tadellos und schützt **nichts**. Gemeldet wurde
    trotzdem `intakt` – formal richtig, aber es liest sich als Schutzzusage.
  - Der Pentester hat Stagings Leersiegel genau so missverstanden und hielt es
    für zahnhaltig. Das ist der rote Faden im Kleinen: ein Signal, das
    beruhigt, wo nichts abgesichert ist.
  - Eigener Zustand `leer` mit eigenem Text („besteht ein Bestandssiegel, das
    aber NICHTS umschließt … kein Fehler, aber auch keine Zusage").
    Wertung wie `nicht_noetig`: kippt `valid` nicht.
  - Dateien: `backend/src/services/audit.service.ts`,
    `backend/src/controllers/auditLog.controller.ts`,
    `frontend/src/services/api.ts`,
    `frontend/src/pages/settings/AuditIntegrityCard.tsx`

- [x] **🔒 `audit:export` gatete nichts – Export hing an `audit:read`** (2026-08-26)
  - **Bei der Gegenprobe zur neuen Rolle `Gegenbuch` gefunden:** Ein Konto mit
    ausschließlich `audit:read` bekam auf `GET /audit-logs/export` **200**.
    Die Berechtigung `audit:export` stand im Katalog und in der
    Rollenverwaltung – und wurde **nirgends** geprüft.
  - **Der Unterschied ist nicht kosmetisch.** Blättern zeigt 50 Zeilen; der
    Export liefert in einem Zug das gesamte Protokoll inklusive
    `changesBefore`/`changesAfter` – also der vollständigen Vorher/Nachher-
    Datensätze – dazu `resourceLabel` mit Klartextnamen, IP-Adressen und
    User-Agents. Live nachgewiesen: 43 Einträge mit gefüllter
    `resourceLabel` allein für `resourceType=Customer`.
  - Damit konnte ausgerechnet das Dienstkonto des Gegenbuchs, dessen Passwort
    im Klartext in der `.env` auf der Notar-Maschine liegt, Personendaten
    exportieren – während README und Rollenname „nur Prüfwerte lesen"
    versprachen.
  - `/audit-logs/export` verlangt jetzt `audit:export`. Betroffen ist genau
    eine Rolle: `Gegenbuch` (gewollt). Die DSGVO-Rolle hat `audit:*`
    vollständig und behält den Export.
  - Oberfläche: JSON- und CSV-Knopf werden nur noch mit `audit:export`
    angezeigt – sonst stünden dort Knöpfe, die zuverlässig 403 liefern.
  - Dateien: `backend/src/routes/auditLog.routes.ts`,
    `frontend/src/pages/settings/AuditLogs.tsx`,
    `tools/audit-notary/README.md`

- [x] **🔑 Rolle „Gegenbuch": Leserecht aufs Audit-Protokoll ohne `audit:admin`** (2026-08-26)
  - **Beim Selbst-Nachprüfen eines Deploys aufgefallen:** Das Gegenbuch-
    Dienstkonto auf Staging meldete beim Login
    `["audit:read","audit:export","audit:admin","gdpr:export","gdpr:delete","gdpr:admin"]`.
    Es braucht **genau eines** davon – `audit:read` – denn es ruft nur
    `/audit-logs/checkpoint` und `/audit-logs/verify` auf.
  - **Kein Bedienfehler, ein Konstruktionsfehler:** Es gab keine Rolle, die
    nur Leserecht aufs Protokoll gibt. Wer das wollte, musste den Haken
    „DSGVO-Zugriff" setzen – und der vergibt `audit:*` komplett, also auch
    `audit:admin` mit `seal-backlog`, `rehash`, `cleanup`. Das Label
    („Audit-Logs, Datenschutz") legt Lesen nahe und liefert Vollzugriff.
  - **Warum das ernst ist:** Das Passwort des Dienstkontos liegt im Klartext
    in `tools/audit-notary/.env` auf der Gegenbuch-Maschine. Mit `audit:admin`
    hätte ein Einbruch dort nicht nur den Wächter gehabt, sondern gleich die
    Mittel zur Wäsche aus R185-01 – und damit genau die Trennung aufgehoben,
    wegen der das Gegenbuch überhaupt auf einer eigenen Maschine läuft.
  - Neue Rolle **`Gegenbuch`** in `sync-roles.ts` mit ausschließlich
    `audit:read`. Läuft beim Containerstart mit, erscheint danach in der
    Benutzerverwaltung. README des Gegenbuchs umgeschrieben: Rolle statt
    „selbst anlegen", ausdrückliche Warnung vor dem DSGVO-Haken, plus eine
    Gegenprobe (`checkpoint` → 200, `seal-backlog` → 403).
  - **Offen, bewusst nicht angefasst:** Dass die DSGVO-Rolle selbst
    `audit:admin` trägt, ist ein Gewaltenteilungs-Problem – wer das Protokoll
    beaufsichtigt, kann seine Beweisgrundlage ersetzen. Ändern hieße
    bestehenden DSGVO-Konten Rechte entziehen; gehört entschieden, nicht
    nebenbei gemacht. An den Pentester gemeldet, dessen nächstes Thema das
    Rechtemodell ist.
  - Dateien: `backend/prisma/sync-roles.ts`, `tools/audit-notary/README.md`

- [x] **📤 Eingegrenzter Export lieferte alles (Pentest R186-01, MEDIUM)** (2026-08-26)
  - Der Tester fand: `GET /audit-logs/export?userId=…` filterte **nicht** –
    `userId=999999` gab alle 2761 Datensätze zurück, byte-identisch zum
    ungefilterten Export. HTTP 200, sah korrekt aus.
  - **Ursache war schlimmer als der Befund.** Nicht `userId` allein fehlte:
    der Export-Controller pflegte eine **eigene, kürzere** Filterliste und
    verwarf still `userId`, `customerId`, `dataSubjectId`, `resourceId`,
    `success` **und** `search`. Der Service konnte alle sechs – sie kamen
    nie bei ihm an.
  - **Dieselbe Lücke ein drittes Mal in der Oberfläche:** Der CSV-Knopf baute
    seine Parameter nochmal von Hand, mit wieder anderen fünf Feldern. Wer im
    Suchfeld eingrenzte und dann CSV klickte, bekam das gesamte Protokoll
    statt seiner Auswahl.
  - **Warum das mehr ist als ein fehlender Filter:** Ein bewusst eingegrenzter
    Export – „nur die Spur von Benutzer X" für eine DSGVO-Auskunft oder eine
    Innentäter-Prüfung – gab das vollständige Protokoll **aller** Nutzer
    heraus, mit einem beruhigenden 200. Auf einem datenminimierungspflichtigen
    Pfad ist das eine Weitergabe, kein Schönheitsfehler.
  - **Fix ist strukturell, nicht punktuell:** ein gemeinsamer `leseFilter(req)`
    für Liste und Export; die Oberfläche schickt alle aktiven Filter statt
    einer handgepflegten Auswahl. Drei Listen, die dasselbe bedeuten sollen,
    laufen früher oder später auseinander – jetzt gibt es nur noch eine.
  - Geprüft über HTTP: Export und Liste liefern für `userId`, `action`,
    `search`, `success`, `resourceType` **identische** Treffermengen; auf dem
    Export-Pfad gilt jetzt dieselbe Validierung (`userId=abc` → 400 statt
    200). CSV-Pfad gegengeprüft.
  - Dateien: `backend/src/controllers/auditLog.controller.ts`,
    `frontend/src/pages/settings/AuditLogs.tsx`

- [x] **🚨 Siegelwechsel ist ein Alarm, kein Hinweis + Filter-Validierung (Pentest R185)** (2026-08-26)
  - **R185-01 (MEDIUM)** – Die Flanke, die wir dem Tester selbst gemeldet
    hatten, hat er live bestätigt: `seal-backlog` war beim **zweiten** Aufruf
    genauso gegatet wie beim ersten (`{"confirm":"SEAL"}` → 200), und das
    Ereignis landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
    automatische Rückhalt des Gegenbuchs hängt an `valid` – und `valid`
    überlebt ein ersetzendes Siegel **per Konstruktion**. Angriff: Altzeile
    per DB-Zugriff löschen → neu siegeln → Lücke ist beglaubigt, `valid`
    wieder `true`. **Live reproduziert.**
  - Zwei Schichten, in seiner Reihenfolge:
    1. **Alarmkanal.** Neuer `SecurityEventType` `AUDIT_SEAL_CHANGED` (Migration
       `20260826120000`). Erstes Siegeln → HIGH, Ersetzen → **CRITICAL** (geht
       damit über `sendPendingCriticalAlerts` sofort per Mail raus). Die
       Ereignis-Details halten Wurzel vorher/nachher und den vollständigen
       Vorbefund fest.
    2. **Gate.** Steht bereits ein Siegel, verlangt der Endpunkt
       `{"confirm":"RESEAL"}` statt `SEAL` – mit einem Text, der sagt, was
       dabei verloren geht. Ein Austausch der Beweisgrundlage soll nicht
       dasselbe Wort haben wie das Einrichten.
  - **Und im Gegenbuch selbst:** Dort stand für den Wurzelwechsel ein
    `console.warn`, während der Rückgabecode auf **0** blieb – also exakt das
    Muster, das wir dem CRM zweimal angekreidet haben (R179/R183-02), im
    Werkzeug, das dagegen gebaut wurde. Jetzt **exit 2**, mit alter und neuer
    Wurzel samt Blattzahl (`10 Blätter → 9 Blätter` zeigt die Löschung
    sofort). Auch die Erstsiegelung meldet sich, statt stillschweigend
    übernommen zu werden.
  - **Auflösbar gemacht:** Der Alarm bricht ab, *bevor* angehängt wird – ohne
    Bestätigungsweg hätte auch ein legitimes Siegeln für immer alarmiert
    (R183-03-Falle). Neu: `NOTARY_SEAL_ACK`. Bewusst **nicht** `true`, sondern
    die **Wurzel selbst** (mind. 16 Zeichen): ein stehen gelassener Wert passt
    beim nächsten Wechsel nicht mehr und kann keinen weiteren Austausch
    durchwinken – der Unterschied zu `NOTARY_GENESIS_ACK`, wo genau diese
    Falle dokumentiert werden musste.
  - **R185-02 (LOW)** – `GET /api/audit-logs?action=<x>`: ungültige Enum-Werte
    gingen roh an die Spalte → **500**. Zweifach schlecht: fehlende
    Validierung *und* Fehler-Orakel (200 vs. 500 verrät die Enum-Mitglieder).
    Jetzt 400 mit der erlaubten Menge im Klartext – die steht ohnehin in der
    Oberfläche. Gleich mitgenommen: `sensitivity`, Datumsfelder
    (`new Date('foo')` → Invalid Date → 500), Zahlenfelder (`parseInt` → NaN),
    Textlängen, und ein Deckel auf `limit` (200), über den sich sonst die
    ganze Tabelle an der Seitenlogik vorbei ziehen ließ. Beide Endpunkte
    (`/audit-logs` und `/audit-logs/export`).
  - **Getestet über HTTP gegen eine Wegwerf-DB**, inkl. echtem Gegenbuch-Lauf
    mit SSH-signiertem lokalem Repo: Erstsiegeln mit `SEAL` → 200; zweites mit
    `SEAL` → **400**; mit `RESEAL` → 200; beide Alarmkanal-Ereignisse mit
    korrekter Severity vorhanden. Wäsche (Zeile 7 gelöscht → RESEAL) → CRM
    meldet `valid:true`, **Gegenbuch exit=2**. Bestätigung: falsche Wurzel →
    weiter exit 2, zu kurzer Wert → weiter exit 2, richtige Wurzel → exit 0
    und beglaubigt, Folgelauf ruhig.
  - **Nachgeholt, was der Tester nicht herstellen konnte:** vollständig
    unsigniertes Protokoll (Platzhalter-`AUDIT_HMAC_KEY`) → `kein_siegel`
    statt der früheren falschen Entwarnung `nicht_noetig`, und
    `seal-backlog` nennt den fehlenden Schlüssel als nächsten Schritt – die
    Warnung ist also auflösbar. Gegenrichtung (Log beginnt signiert) →
    weiterhin `nicht_noetig`, R183-03 bleibt behoben.
  - Dateien: `backend/src/controllers/auditLog.controller.ts`,
    `backend/prisma/schema.prisma` + Migration, `tools/audit-notary/notary.mjs`,
    `tools/audit-notary/{docker-compose.yml,.env.example,README.md}`,
    `frontend/src/services/api.ts`, `frontend/src/pages/settings/Monitoring.tsx`

- [x] **👁️ Integritätsstatus in der Oberfläche (Einstellungen → Audit-Protokoll)** (2026-08-26)
  - Bisher war der Zustand der Hash-Kette nur per `POST /api/audit-logs/verify`
    einsehbar – also praktisch nur für das Gegenbuch und für jemanden mit
    `curl`. Jetzt steht er oben auf der Audit-Seite.
  - Vier Zustände statt „grün/rot": **unversehrt**, **Befund**, **unversehrt
    aber ungeschützter Altbestand** (`kein_siegel`), **nicht vollständig
    prüfbar** (signierte Zeilen ohne `AUDIT_HMAC_KEY`).
  - Die beiden mittleren Zustände sind bewusst nicht grün. Ein Protokoll mit
    unversiegeltem Altbestand ist rechnerisch stimmig, aber am Altbestand
    unbemerkt änderbar; ein Protokoll, das mangels Schlüssel nicht prüfbar ist,
    ist schlicht ungeprüft. Beides als „alles in Ordnung" zu zeigen wäre
    genau die Klasse Fehler, die diese ganze Runde behandelt hat.
  - Schlägt die Prüfung selbst fehl, steht dort ausdrücklich: *„Das ist keine
    Entwarnung – der Zustand der Kette ist damit schlicht unbekannt."*
  - Aufklappbare Einzelheiten trennen die Kategorien, die nicht gleich schwer
    wiegen: nachträglich verändert (ernst) / Verkettung unterbrochen /
    davon ohne dokumentierte Löschung / davon vom Siegel beglaubigt /
    ohne Schlüssel nicht prüfbar – mit einer Erklärung im Klartext darunter.
  - Die Prüfung liest die **gesamte** Kette. Sie läuft deshalb einmal beim
    Öffnen der Seite und wird 5 Minuten wiederverwendet; „Neu prüfen" erzwingt
    einen frischen Lauf.
  - Bewusst **read-only**: kein Siegel- oder Rehash-Knopf. Diese Eingriffe
    verlangen `audit:admin` und eine ausdrückliche Bestätigung; sie gehören
    nicht neben eine Statusanzeige, die man im Vorbeigehen anklickt.
  - Dateien: `frontend/src/pages/settings/AuditIntegrityCard.tsx` (neu),
    `frontend/src/pages/settings/AuditLogs.tsx`, `frontend/src/services/api.ts`
    (Typ `IntegrityResult` ausgelagert)

- [x] **🧾 Beglaubigte Alt-Lücken: Dauer-Alarm im Gegenbuch beendet** (2026-08-26)
  - **Ausgangslage.** Das Gegenbuch auf Prod meldete stündlich `exit=2`. Die
    CRM-Prüfung lieferte `valid: false` wegen **6 struktureller Lücken**
    (IDs 33, 44, 45, 922, 1434, 2583).
  - **Diagnose: harmlos.** Jede Lücke liegt innerhalb eines Schwungs von
    Einträgen mit **identischer Sekunde**, betrifft nur `/login` und
    `/refresh`, kein Eintrag fehlt (kein 404), `tamperedEntries` leer. Das ist
    die Signatur der Race-Condition, die am 19.08. mit `AuditChainLock`
    geschlossen wurde (R166-01). Bestätigt durch die Zeilen selbst: Eintrag
    2583 stammt vom 21.08., ist aber noch `hashVersion=1` – auf Prod lief zu
    dem Zeitpunkt also der alte Stand.
  - **Das eigentliche Problem war nicht die Lücke, sondern der Dauer-Alarm.**
    Diese Lücken sind nicht heilbar: die Verkettung ist gebrochen, die Inhalte
    sind unversehrt. Ohne Änderung hätte das Gegenbuch für immer Alarm gemeldet
    – und ein Signal, das immer schreit, warnt nicht mehr. Dieselbe Klasse wie
    R162, R174, R179, R182, R183-02.
  - **Lösung: Beglaubigung statt Unterdrückung.** Eine Lücke zählt nicht mehr
    als offener Befund, wenn beides gilt: sie liegt im **versiegelten Bereich**
    UND steht im **Vorbefund des Siegel-Markers**, also im Zustand, den der
    Betreiber beim Siegeln ausdrücklich festgeschrieben hat. Der Vorbefund
    liegt in `changesBefore` und ist ab Version 3 mitgehasht – die Liste lässt
    sich ohne `AUDIT_HMAC_KEY` nicht nachträglich erweitern (gleiche
    Absicherung wie beim Löschungs-Manifest, R171-01).
  - Beglaubigt heißt **nicht verschwunden**: die Lücken bleiben in `chainGaps`,
    stehen zusätzlich in neuem Feld `attestedGaps` und werden im Bericht
    ausdrücklich benannt.
  - **Nebenbefund derselben Klasse mitbehoben:** `backlogSealStatus` meldete
    `nicht_noetig` ("Es gibt keine unsignierten Alteinträge"), wenn
    `v3FromId === null` – das bedeutet aber das **Gegenteil**: dass überhaupt
    nichts signiert ist, etwa weil `AUDIT_HMAC_KEY` fehlt. Ein vollständig
    unsigniertes Log bekam damit Entwarnung für genau den Zustand mit der
    geringsten Beweiskraft. Die Bedingung hängt jetzt an der Zahl der
    unsignierten Zeilen. R183-03 (unauflösbare Warnung bei frisch signiertem
    Log) bleibt behoben – per Regressionstest geprüft.
  - **Getestet über HTTP gegen eine eigene Wegwerf-Datenbank**, nicht am
    Service vorbei: Prod-Zustand nachgebaut (10 v1-Zeilen, Bruch bei id 5) →
    `valid:false`; nach `seal-backlog` → `valid:true`, `attestedGaps:[5]`.
    Drei Gegenproben: **neue Lücke** nach dem Siegeln → `valid:false`;
    **gesiegelte Altzeile verändert** → Siegel `gebrochen`, nichts mehr
    beglaubigt; **Beglaubigungsliste im Marker gefälscht** (DB-Schreibrecht,
    kein Schlüssel) → Marker ungültig, Status `entfernt`, `valid:false`.
  - Doku: Abschnitt „Wenn der erste Lauf `exit=2` meldet" in
    `tools/audit-notary/README.md` – inklusive der Warnung, **vor** dem
    Siegeln zu prüfen, was man da festschreibt.
  - Dateien: `backend/src/services/audit.service.ts`,
    `backend/src/controllers/auditLog.controller.ts`,
    `frontend/src/services/api.ts`, `tools/audit-notary/README.md`

- [x] **🔓 Dienstkonto-Flag: Gate in beide Richtungen, richtige Rechte-Domaene (Pentest R184-01/-02)** (2026-08-24)
  - **R184-01** – Setzen war gegatet, **Entfernen nicht**. Und das Entfernen ist
    der gefaehrlichere Weg: Der Heartbeat-Wachhund fragt `isServiceAccount:
    true` ab, haengt also am **Live-Kennzeichen**. Seine Hypothese war damit
    richtig – Un-Flaggen kappt die Wache und macht genau das moeglich, wogegen
    der Tripwire gebaut wurde: stilllegen und auf Stille setzen.
    Fix: Gate in beide Richtungen, mit unterschiedlichem Wortlaut (beim
    Entfernen: „faellt aus der Ueberwachung heraus“).
  - **Wichtiger noch:** Die Aenderung geht jetzt zusaetzlich in den
    **Alarmkanal** (`PERMISSION_CHANGED`/CRITICAL), nicht nur ins Audit-Log.
    Eine CRITICAL-Zeile muss jemand LESEN – das war die R183-02-Klasse. Jetzt
    reagiert die Ueberwachung automatisch.
  - **R184-02** – Das Kennzeichen hing an `users:update`, einer
    Anwendungs-Berechtigung, obwohl es ein Audit-Governance-Eingriff ist –
    Geschwister von retention-shorten, seal-backlog, rehash und cleanup, die
    alle `audit:admin` verlangen. Heute deckungsgleich, aber jede kuenftige
    Rolle mit „Benutzer bearbeiten“ haette still „Login-Alarme herunterstufen“
    geerbt. Fix: zusaetzliche Pruefung auf `audit:admin` im Controller.
  - Verifiziert: Setzen mit `audit:admin` 200 · Entfernen ohne Bestaetigung
    **400** (vorher 200) · mit Bestaetigung 200 · ohne `audit:admin` **403** ·
    zwei CRITICAL-Meldungen im Alarmkanal mit sprechendem Text. Frontend sendet
    die Bestaetigung in beide Richtungen. `npm run build` und Backend-`tsc`
    gruen.
  - Seine Non-Findings uebernommen: Selbst-Block nicht ueber Methode, Pfad oder
    ID-Aliasing umgehbar; Create streift das Feld ab; Confirm-Gate strikt.

- [x] **🔒 Dienstkonto-Kennzeichen gegatet, laut protokolliert, kein Selbstbedienen (Pentest R184)** (2026-08-24)
  - Der Pentester hat sofort erkannt, was das Scharfschalten des Feldes
    bedeutet: Ein Attribut, das die **Alarmstufe senkt**, war ueber den
    normalen Benutzer-Update-Pfad setzbar – dieselbe Klasse wie R183-01,
    nur neu gebaut. Wer sein eigenes Konto so markiert, laesst die eigenen
    auffaelligen Anmeldungen als Routine erscheinen. Das ist die Waesche.
  - Ist-Zustand vor dem Fix, alle drei seiner Sorgen bestaetigt: kein Gate,
    Protokollierung nur als MEDIUM (Standardstufe fuer `User`), und jeder mit
    `users:update` konnte es auf jedes Konto setzen – auch auf das eigene.
  - Fix, analog zur Retention-Absenkung:
    * **Bestaetigung** `{"confirm":"SERVICE_ACCOUNT"}` beim Aktivieren, mit
      Klartext, was das bedeutet.
    * **CRITICAL** statt MEDIUM, mit eigenem Label („Dienstkonto-Kennzeichen
      GESETZT fuer … – Anmeldungen werden kuenftig als Routine gefuehrt“) sowie
      Wer/Vorher/Nachher.
    * **Kein Selbstbedienen**: am eigenen Konto ist das Kennzeichen weder
      setzbar noch entfernbar (403 mit Begruendung) – muss ein anderer
      Administrator vornehmen.
  - Frontend sendet die Bestaetigung mit; der Haken im Formular IST die
    Bestaetigung, der Betreiber merkt nichts davon.
  - Verifiziert ueber den echten Controller: ohne Bestaetigung 400, mit 200,
    am eigenen Konto 403, Protokolleintrag CRITICAL mit sprechendem Label.
    `npm run build` (inkl. `tsc`) und Backend-`tsc` gruen.

- [x] **🖱️ Dienstkonto-Kennzeichen in der Benutzerverwaltung** (2026-08-24)
  - Nachgezogen: Das Feld `isServiceAccount` lag zwar in der Datenbank, war
    aber **nirgends setzbar** – weder im Formular noch ueber die API. Der
    Betreiber haette es nicht aktivieren koennen, damit waere die
    R182-Einstufung wirkungslos geblieben.
  - Jetzt: Ankreuzfeld „Dienstkonto“ im Benutzerformular unter den
    zusaetzlichen Berechtigungen, mit Erklaerung im Klartext (planmaessige
    Anmeldungen als Routine, dafuer Meldung bei AUSBLEIBEN). Feld in der
    Whitelist (`USER_UPDATABLE_FIELDS`, damit auch beim Anlegen), im
    Service-Typ und in allen drei `select`-Bloecken, damit es beim Bearbeiten
    vorbelegt wird.
  - Verifiziert: Whitelist laesst das Feld durch und blockt Fremdfelder
    weiterhin; Wert ueber Prisma lesbar; `tsc` und `vite build` gruen.

- [x] **🚨 Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)** (2026-08-24)
  - **R183-01 (HIGH)** – Die geladene Waffe war ungegatet, der Abzug gegatet:
    `PUT /retention-policies/{id}` nahm `retentionDays: 0` **ohne
    Bestaetigung** an und protokollierte es als MEDIUM – waehrend cleanup,
    seal und rehash alle ein Confirm-Gate haben und die FOLGE CRITICAL ist.
    Die ausloesende Tat war leiser als ihre Wirkung.
    Fix: Absenken verlangt `{"confirm":"SHORTEN"}`, wird als **CRITICAL** mit
    Vorher/Nachher protokolliert („Aufbewahrung VERKUERZT … 730 → 0 Tage“), und
    fuer `Authentication`/`AuditLog` gilt eine **Untergrenze von 30 Tagen**.
    `logChange` nimmt dafuer jetzt Sensitivitaet und Vorzustand entgegen.
  - **R183-02 (HIGH)** – Nach dem Cleanup meldete verify
    „**Keine Manipulation.** 656 strukturelle Luecken (alle durch protokollierte
    Loeschungen erklaert)“ – bei `valid: false` und 3010 endgueltig geloeschten
    Anmeldeprotokollen. Der Befund stand nur im Feld, die Prosa beruhigte.
    Dieselbe Alarm-Muedigkeit wie R162/R182, diesmal im Verifizierer selbst.
    Fix: Bei Luecken heisst es jetzt „Die Kette ist nicht mehr lueckenlos …
    geloeschte Eintraege lassen sich naturgemaess nicht mehr pruefen“.
    Zusaetzlich wertet das **Gegenbuch `valid:false` hart** – es ruft
    `/verify` mit und schlaegt Alarm, egal wie der Text klingt.
  - **R183-03 (MEDIUM)** – verify warnte dauerhaft „Altbestand nicht
    versiegelt“, waehrend seal-backlog zu Recht ablehnte („kein Altbestand“).
    Eine Warnung, die niemand aufloesen kann, lernt man zu ignorieren.
    Fix: eigener Zustand `nicht_noetig` mit Klartext; die echte Warnung nennt
    jetzt den Befehl zum Beheben.
  - **Heartbeat-Wachhund gebaut** (seine wichtigere Haelfte): Bleibt ein
    Dienstkonto laenger still als `SERVICE_ACCOUNT_MAX_SILENCE_MINUTES`
    (Standard 180), gibt es `SUSPICIOUS/CRITICAL`. Ohne je gesehene Anmeldung
    wird geschwiegen statt geraten; pro Ausfall wird genau einmal gemeldet.
    Verifiziert: keine Grundlinie → still; 500 Minuten Stille → Meldung;
    Wiederholung → keine Dublette.
  - Seine Non-Findings bestaetigt uebernommen: Confirm-Gates gegen neun
    Umgehungsvarianten dicht, rehash-Protokoll ehrlich, Tombstone-Mechanik und
    Anker-Backstop intakt.

- [x] **🔧 Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken** (2026-08-22)
  - Fehlerbild aus dem echten Betrieb: `mkdir: cannot create directory
    '/gegenbuch/schluessel': Permission denied`, Container in der
    Neustart-Schleife. Ursache: Das Datenverzeichnis kommt als Bind-Mount vom
    Host; der Betreiber hatte das Projekt als **root** geklont, der Container
    lief aber direkt als UID 1000 und durfte dort nichts anlegen.
    Mein `.gitkeep`-Ansatz hatte stillschweigend angenommen, dass als normaler
    Benutzer geklont wird – auf einem Server ist root der Normalfall.
  - Fix: Der Container startet als root, setzt `/gegenbuch` per `chown` auf den
    Arbeitsbenutzer (`PUID`/`PGID`, Standard 1000) und startet sich per
    `setpriv` als dieser neu. Die eigentliche Arbeit laeuft damit weiterhin
    unprivilegiert. Schlaegt das `chown` fehl (z. B. rootless Docker), gibt es
    einen Hinweis mit dem passenden Host-Befehl statt eines stummen Abbruchs.
  - Verifiziert mit exakt der Ausgangslage: Datenverzeichnis auf `root:root`
    gesetzt, Container gestartet → laeuft durch, Schluessel und Buch werden
    angelegt, erzeugte Dateien gehoeren 1000:1000, der private Schluessel liegt
    mit `0600`.

- [x] **🔑 Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten** (2026-08-22)
  - **Blocker gefunden und behoben:** Ich hatte ein dauerhaftes API-Token
    vorausgesetzt – das gibt es in OpenCRM gar nicht. Zugangstoken leben
    **15 Minuten**; der Gegenbuch-Container waere nach dem ersten Durchlauf
    gestorben. Aufgefallen erst, als der Betreiber fragte, woher er den Token
    nimmt.
    Loesung: Das Gegenbuch meldet sich bei **jedem Lauf selbst an** – mit einem
    eigenen Benutzerkonto, dessen Rolle ausschliesslich `audit:read` traegt.
    Damit kann es nur Pruefwerte lesen: keine Kundendaten, keine Aenderungen.
    `CRM_TOKEN` bleibt fuer Tests moeglich, ist aber nicht mehr der Normalweg.
  - Fehlerfaelle sauber gemeldet: falsches Passwort → Klartext statt HTTP-Code,
    Anmelde-Bremse (429) benannt, CRM nicht erreichbar unterschieden.
  - **`.env.example` nennt jetzt die gueltigen Werte** – bisher liess sich nur
    raten, ob es `prod` oder `production` heisst. Fuer jeden Schalter steht
    dabei, was erlaubt ist, inkl. des Falls „erst nur Staging testen, Prod
    spaeter dazunehmen“ (`COMPOSE_PROFILES=staging` → `prod,staging`).
  - Beide READMEs um „Zugang einrichten“ ergaenzt: Rolle mit nur `audit:read`,
    Benutzer damit, Zugangsdaten in die `.env`. Mit dem Hinweis, dass jede
    Anmeldung im Audit-Log erscheint – gewollt, denn so sieht man auch, wenn
    das Gegenbuch aufhoert zu arbeiten.
  - Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung
    verlangt: Anmeldung + Lauf erfolgreich, falsches Passwort → verstaendliche
    Meldung, exit 1.

- [x] **🐳 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine** (2026-08-22)
  - Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer **eigenen Maschine**
    fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch
    liegt lokal auf dieser Maschine. Die Trennung, auf die es ankommt, ist
    damit gegeben – wer OpenCRM uebernimmt, kommt nicht ans Buch.
  - Richtung bewusst so herum: Das Gegenbuch **holt** ueber HTTPS mit einem
    Token, das nur `audit:read` kann. OpenCRM kennt weder Adresse noch
    Schluessel des Gegenbuchs. Kein SSH-Zugang zum CRM noetig – das Token kann
    ausschliesslich Pruefwerte lesen.
  - Unterverzeichnis `tools/audit-notary/`: `Dockerfile`, `entrypoint.sh`,
    `docker-compose.yml`, `.env.example`. Zwei Dienste (`prod`, `staging`) mit
    getrennten Verzeichnissen und Schluesseln, gesteuert ueber
    `COMPOSE_PROFILES` – dasselbe Muster wie beim Caddy-Profil im Hauptstack.
    Der Signaturschluessel wird beim ersten Start auf der Gegenbuch-Maschine
    erzeugt und verlaesst sie nie.
  - **Lokaler Betrieb ist jetzt ein vollwertiger Modus**, kein Testschalter
    mehr. Die Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist
    („jemand veraendert Daten im CRM“) und was nicht („jemand uebernimmt diesen
    Rechner hier“) – statt der frueheren pauschalen Formulierung „kein
    Manipulationsschutz“, die im Einsatz auf eigener Maschine schlicht falsch
    war.
  - Jeder Durchlauf schreibt `status.txt` fuer die Ueberwachung; alles ausser
    `exit=0` gehoert angesehen.
  - Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne
    Bestaetigung → Code 4; mit Bestaetigung → Buch angelegt, Normalbetrieb
    exit 0; **Eintrag nachtraeglich veraendert → Alarm exit 2**; **Eintraege
    geloescht (maxId geschrumpft) → Alarm exit 2**; Siegel verschwunden →
    Alarm. Testartefakte und Image geloescht.
  - **Daten im Projektverzeichnis, keine Docker-Volumes** (Projektkonvention):
    Bind-Mount auf `tools/audit-notary/data/<instanz>/`. Der Inhalt ist per
    `.gitignore` ausgenommen – dort liegt der Signaturschluessel –, die
    Verzeichnisse selbst bestehen ueber `.gitkeep`. Ohne das haette Docker sie
    beim ersten Start als `root` angelegt und der Container (UID 1000) haette
    nicht schreiben koennen.
  - Behoben beim Bauen: `useradd -u 1000 || true` verschluckte, dass UID 1000
    im Node-Image bereits vergeben ist – der Container startete gar nicht.
    Jetzt laeuft er als der vorhandene Benutzer `node`.

- [x] **🧮 Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (Pentest R181)** (2026-08-18)
  - **R181-01 (LOW→MED)** – Der Pruefmodus sagte zu, nichts zu veraendern und
    kein Schreibrecht zu brauchen – und pushte trotzdem: `ankerNachziehen()`
    lief in JEDEM Modus. Ein „read-only“ Audit mutierte damit still das
    geteilte Substrat. Fix: Nachziehen nur im Schreiblauf.
  - **R181-02 (MEDIUM) – ein Widerspruch zwischen meinen EIGENEN Fixes.**
    R180-01 erhebt die Serversperre auf `refs/notary/*` zur tragenden Pflicht;
    R180-02 verlangt genau dort Schreibrecht zur Selbstheilung. Ergebnis:
    Sobald je ein Anker fehlte, bekam jeder read-only pruefende Auditor
    dauerhaft einen Fehler auf einer **voellig gueltigen** Kette – und konnte
    ihn nicht beheben.
    Fix: Reparieren ist Sache des Notar-Laufs; im Pruefmodus wird der fehlende
    Anker gemeldet, nicht repariert.
  - **Eigener Rueckgabecode 5** (seine Frage b): „Anker unvollstaendig“ ist
    NICHT „nicht feststellbar“. Die Kette ist geprueft und gueltig, nur das
    Substrat-Gedaechtnis ist unvollstaendig – ein benannter, reparierbarer
    Defekt mit klarer Handlungsanweisung. Ihn in Code 3 zu werfen hiesse, eine
    Anweisung als Ungewissheit zu melden; dieselbe Trennung wie bei
    Genesis/Adoption.
  - **Seine Frage (a) empirisch beantwortet:** Die Notar-Identitaet laesst sich
    eng auf das ANLEGEN von `refs/notary/*` beschraenken, ohne ihr Loeschen
    oder Ueberschreiben zu geben – serverseitig unterscheidbar an der Null-OID
    beim Anlegen. Mit `pre-receive`-Hook verifiziert: Backfill greift (exit 0),
    Loeschen und Force-Overwrite werden weiterhin abgewiesen. Der Hook steht
    als Beispiel in der README.
  - Verifiziert: read-only `--check` mit fehlendem Anker → **Code 5, nichts
    gepusht** (Anker-Anzahl vorher/nachher identisch); Notar-Schreiblauf unter
    derselben ACL → „Fehlende Anker nachgetragen: 1“, exit 0.
  - Seine Non-Findings uebernommen: Die Backfill-Zuordnung ist gegen das
    PIN-Modell robust – jeder Commit mit echter Ledger-Aenderung ist non-merge
    und muss keyA-signiert sein, sig-exempte Merges tragen keinen Inhalt in die
    Zuordnungsliste.

- [x] **🛑 Gegenbuch: Anker belegen nichts mehr, Anker-Verlust wird laut (Pentest R180-01/-02)** (2026-08-18)
  - **R180-01 (MEDIUM)** – Genau die Unsicherheit, die ich selbst benannt
    hatte, bestaetigt: Der Code nahm den **hoechsten noch vorhandenen**
    `refs/notary/seq-*` als „hoechsten je“. Loescht ein Angreifer nur die
    OBEREN Anker und laesst einen niedrigeren stehen, senkt er den
    Vergleichswert selbst – ein frischer Auditoren-Klon meldete `OK`, exit 0
    auf gewaschenem Stand.
    Perverser Gradient: Wer ALLE Refs loeschte, flog auf (Code 4); wer weniger
    loeschte, kam durch – weil `ankerBelegt` sowohl die Gewissheit begruendete
    als auch den Code-4-Diskriminator kurzschloss.
    Fix: **Anker begruenden keine Gewissheit mehr.** Sie koennen ein
    Zurueckspulen *widerlegen* (hoher Anker nicht mehr im Kopf → Alarm), aber
    nie Unversehrtheit *belegen*. Der Code-4-Diskriminator haengt nicht mehr an
    ihnen und greift nur noch im Schreiblauf (ein Auditor hatte nie ein
    Gedaechtnis, ihm Code 4 vorzuwerfen waere Laerm).
  - **R180-02 (LOW→MED)** – Der Anker-Push-Fehlschlag war nur eine Warnung mit
    exit 0, ausgerechnet bei der tragenden Eigenschaft: `main` stand auf dem
    neuen Checkpoint, der Anker fehlte, niemand holte es nach. Der
    **Normalbetrieb** senkte damit den Hoechststand still um eins und riss das
    R180-01-Fenster ohne Angriff auf.
    Fix: Anker-Push-Fehlschlag → **exit 3**; und jeder Lauf **zieht fehlende
    Anker nach**, bevor er irgendetwas als OK meldet (Zuordnung ueber die
    n-te Aenderung am Gegenbuch).
  - Verifiziert: Teil-Loeschung (main auf seq-2, Anker 3–5 weg, DB gekuerzt),
    frischer Auditoren-Klon → **exit 3 mit Vorbehalt statt exit 0** (das falsche
    Gruen ist weg; erkannt wird es in-repo weiterhin nicht, das geht ohne
    Ref-Schutz nicht); Anker-Push per Hook abgelehnt → **exit 3** statt
    Hinweis; Folgelauf → „Fehlende Anker nachgetragen: 1“, Anker wieder
    vollstaendig.
  - **README:** Der Schutz von `refs/notary/*` gegen Loeschen und Ueberschreiben
    ist von der Fussnote zur **tragenden Voraussetzung** erhoben – mit der
    Begruendung, warum Anker nichts belegen koennen, und dem ausdruecklichen
    Hinweis, dass ein `--check` aus frischem Klon eine Anker-Loeschung nicht
    erkennt.

- [x] **🧠 Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (Pentest R180)** (2026-08-18)
  - Sein Durchrechnen war zwingend: „erste Beobachtung → exit 3“ galt **genau
    einen Lauf**. Danach baselinete der Waechter auf den AKTUELLEN Kopf – nach
    einem Rewind also auf den gewaschenen Stand – und meldete dauerhaft gruen.
    Der Angreifer musste nur **ein einziges exit 3** ueberstehen, ausgerechnet
    den Code, den der Betreiber bei Remote-Ausfaellen ohnehin staendig sieht.
    Damit war der Waechter, der Rewind OHNE Notar-Host-Integritaet fangen
    sollte, wieder an genau diese Integritaet gekoppelt.
  - **Eigentlicher Fix (sein Vorschlag): Gedaechtnis ins Append-only-Substrat.**
    Jeder verankerte Checkpoint bekommt einen eigenen Ref
    `refs/notary/seq-N`. Der ueberlebt einen Force-Push auf den Zweig, weil er
    ein anderer Ref ist – die hoechste je existierende Nummer ist damit aus dem
    Server rekonstruierbar. Geprueft wird: ist der hoechste verankerte
    Checkpoint noch im aktuellen Kopf enthalten, und ist die Reihe mindestens
    so lang wie verankert.
  - **Laute Verlustbehandlung mit seinem Diskriminator** („traegt der Remote
    schon Checkpoints?“): keine Historie → echte Genesis, einmalig
    `NOTARY_GENESIS_ACK=true`; Historie vorhanden, aber kein Gedaechtnis →
    **Anomalie**, eigener **Code 4**, **keine stille Adoption**, erst nach
    `NOTARY_ADOPT_ACK=true`. Eine Baseline zu setzen heisst „ich verbuerge
    mich, DAS ist der wahre Kopf“ – dieser Akt wird nicht automatisch
    wiederholt.
  - Verifiziert: Genesis ohne Bestaetigung → **Code 4**; mit Bestaetigung →
    Kette aufgebaut, `refs/notary/seq-1..5` auf dem Server; Rewind auf
    Checkpoint 2 → Alarm **exit 2**, und zwar **auch nach Loeschen des lokalen
    Speichers** und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft
    gruen); zusaetzlich die Anker-Refs geloescht → **Code 4** statt stiller
    Uebernahme, erst nach ausdruecklicher Bestaetigung weiter (dann exit 3 mit
    Vorbehalt, nicht gruen). Labor und Port geraeumt.
  - **Ehrlich dokumentiert:** Die Anker-Refs muessen serverseitig ebenfalls vor
    Loeschen/Ueberschreiben geschuetzt sein – sonst verschiebt sich das Problem
    nur eine Ebene weiter. Server-API-Abfrage der Protection bleibt bewusst
    weg (er hat sie selbst nur als Plausibilisierung eingeordnet).

- [x] **👀 Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (Pentest R179 a/b)** (2026-08-18)
  - **(a) Probe-Push verworfen – seine Analyse war zwingend.** Ein Testpush
    haette (1) nur den geprobten Ref beurteilt, nicht `main`; (2) die EIGENE
    Push-Identitaet gemessen statt die des Angreifers – Bypass-Rechte fuer
    Admins gehen genau dann auseinander, wenn es zaehlt; (3) nur einen
    Zeitpunkt abgedeckt und damit die Praesenz-erzeugt-Zuversicht-Falle aus
    R179-01 neu gebaut. Und: einen zerstoerungsfreien Force-Push gibt es nicht –
    die bestaetigende Beobachtung waere derselbe Vorgang wie der Schaden.
  - Stattdessen **Fast-Forward-Waechter**: Bei jedem Lauf wird der beobachtete
    Remote-Kopf **ausserhalb des Klons** festgehalten
    (`~/.opencrm-notary/beobachtungen.jsonl`, per `NOTARY_STATE_FILE`
    aenderbar). Beim naechsten Lauf muss der neue Kopf ein Nachfahre des alten
    sein – sonst Alarm. Das erkennt das **Ereignis** statt die Regel
    abzufragen und wirkt unabhaengig davon, ob serverseitiger Schutz existiert.
    Ein belegter Fast-Forward gilt als Nachweis und blendet den Rewind-Vorbehalt
    aus (im Gegensatz zur blossen Praesenz eines MIN_SEQ-Werts).
  - **(b) Code 3 als Tor VOR dem Anhaengen, nicht als Status danach.** Sein
    Argument: Der Schreiblauf signiert mit dem neuen Checkpoint zugleich ueber
    den Bestand darunter – ist die Basis ungeklaert, waere das Anhaengen selbst
    das Waschmittel. Jetzt: Grundlage nicht feststellbar → **nichts anhaengen,
    exit 3**. Erster Lauf ueberhaupt → Basislinie setzen, aber ehrlich als
    solche melden (exit 3, kein gruenes Licht). Anhaengen geklappt, Push
    gescheitert → **exit 3 mit „erstellt, aber NICHT verankert“** statt 0 oder
    1. Nur Anhaengen + Push + belegte Verankerung → exit 0.
  - Verifiziert: Basislinie → exit 3; Folgelauf → exit 0 ohne Vorbehalt;
    **Rewind aus einem FRISCHEN Auditoren-Klon ohne MIN_SEQ und ohne
    Zusicherung → Alarm exit 2** (das war bisher stilles Gruen); Push-Ziel
    kaputt → „erstellt, aber NICHT verankert“ exit 3, Folgelauf haelt den
    ungepushten Commit fail-closed an (exit 2). Labor und Port geraeumt.

- [x] **🚦 Gegenbuch: Unsicherheit erreicht jetzt den Rueckgabecode (Pentest R179)** (2026-08-18)
  - **R179-b – der wichtigere Punkt:** Mein Rewind-Vorbehalt stand in der
    Ausgabe, der Exit blieb **0**. Meine eigene README sagt „fuer Cron gilt:
    jeder Code ausser 0 gehoert gemeldet“ – der Zustand „ich bin an dieser
    Stelle blind“ erreichte die Ueberwachung also **nie**. Dieselbe Klasse wie
    R174 (vergrabener Vorbehalt), eine Ebene hoeher.
    Fix: Code **0 nur bei belegter Gewissheit** – entweder ist serverseitiger
    Rewind-Schutz zugesichert oder eine mitgegebene Mindesthoehe wurde
    erfuellt. Sonst **Code 3**, dieselbe Bedeutung wie beim unerreichbaren
    Remote und bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen.
  - **R179-01 (MEDIUM)** – `NOTARY_MIN_SEQ` ist eine **Untergrenze**, kein
    Ist-Stand. Ein veralteter Wert liess einen Teil-Rewind darueber lautlos
    durch; schlimmer: die blosse **Praesenz** einer Zahl blendete den Vorbehalt
    aus, ein `MIN_SEQ=0` war damit ein Freibrief und ein Tippfehler (`NaN`)
    wurde still verschluckt. Ein veralteter Anker erzeugte so ein
    **selbstbewussteres Ergebnis als gar keiner**.
    Fix: Der Vorbehalt haengt jetzt allein an der ausdruecklichen Zusicherung
    `NOTARY_REWIND_PROTECTED=true`; bei gesetztem MIN_SEQ benennt er zusaetzlich
    die Grenze („deckt nur ab, was UNTERHALB dieser Nummer fehlt“).
    `MIN_SEQ` ≤ 0 oder unparsbar → **exit 1** mit Erklaerung statt stiller
    Annahme.
  - Verifiziert gegen seinen Aufbau (10 Checkpoints, Rewind auf 7, DB passend
    gekuerzt, frischer Auditoren-Klon):
    nicht gesetzt → **exit 3** (vorher 0) · 10 → Alarm 2 · 8 → Alarm 2 ·
    7 (veraltet) → 0, aber **mit Vorbehalt** (vorher ohne) · 0 / `xyz` →
    **exit 1** (vorher stilles 0) · Rewind-Schutz zugesichert → 0 ohne
    Vorbehalt. Labor und Port geraeumt.
  - Seine Positivkontrolle bestaetigt und uebernommen: „in der Mitte gewaschen“
    ist dicht, weil der Kopf-Hash bei `atId=letzter.maxId` alles darunter
    abdeckt – die Reconciliation nur des letzten Checkpoints genuegt.

- [x] **🔄 Gegenbuch: Rewind auf signierten Praefix benennbar gemacht (Pentest R178-01)** (2026-08-18)
  - Fund: Mein Signatur-Gate faengt Force-Push mit **fremder oder unsignierter**
    Historie – aber ein **Rewind auf einen aelteren, echt signierten Stand**
    ist signaturseitig einwandfrei. Angreifer spult `origin/main` auf einen
    frueheren Checkpoint zurueck und kuerzt die Datenbank passend: alle
    Signaturen `G`, Pin korrekt, Reihe lueckenlos. Ein Notar-Klon mit lokalem
    Vorlauf merkt es; ein **frischer Auditoren-Klon** meldete `OK`, exit 0 –
    also ausgerechnet im dokumentierten Pruef-Fall.
  - **Ehrliche Einordnung:** Das laesst sich im Skript nicht kryptographisch
    erkennen, die Historie ist ja echt. Also zwei Dinge statt eines
    Scheinfixes:
    1. `NOTARY_MIN_SEQ` – die zuletzt bekannte Nummer als Bezugspunkt. Ist die
       Reihe kuerzer, ist das das Bild eines Rewinds → Alarm. Jeder Lauf nennt
       die Nummer am Ende, damit sie ueberhaupt bekannt sein kann.
    2. Ohne diesen Bezugspunkt sagt die Erfolgsmeldung jetzt ausdruecklich,
       dass ein Zurueckspulen **nicht** erkennbar war – „OK“ soll nicht mehr
       Gewissheit behaupten als vorhanden ist.
  - README: serverseitiger **Rewind-Schutz (non-fast-forward verbieten)** ist
    jetzt als Pflicht formuliert, mit der Begruendung warum Signaturpruefung
    allein dagegen nichts ausrichtet – und mit dem ausdruecklichen Hinweis,
    dass ein frischer Klon den Rewind nicht sieht.
  - Kleinkram aus seinem Bericht: `NOTARY_SIGNER_FINGERPRINT` wird beim
    **Einlesen** getrimmt (ein Zeilenumbruch loeste sonst 4/4 Fehlalarme aus,
    die auf den *korrekten* Fingerabdruck zeigten); die „nie gepusht“-Meldung
    priorisiert jetzt **Untersuchen** statt Pushen und nennt den passenden
    `git log`-Befehl – „pushen“ haette einen falsch signierten Commit dauerhaft
    in die Kette gebracht.
  - Beim Testen selbst gefunden: der allererste git-Aufruf war ungeschuetzt und
    warf bei kaputtem Klon einen Node-Stacktrace – jetzt erklaerende Meldung.
  - Verifiziert: Rewind 3→1 bei passend gekuerzter DB → frischer Klon ohne
    Bezugspunkt `OK` **mit Vorbehalt**, mit `NOTARY_MIN_SEQ=3` → **Alarm
    exit 2**; Pin mit Zeilenumbruch → kein Fehlalarm mehr; regulaerer Lauf
    unveraendert. Testlabor und Port geraeumt.
  - Seine Bestaetigungen: Pin-Ableitung faellt bei Key-Literal/GPG-Key-ID
    korrekt fail-closed aus; der Rollback bei Pin-Mismatch ist vollstaendig,
    und selbst ein Absturz zwischen Commit und Reset wird vom
    „nie gepusht“-Check aufgefangen.

- [x] **📌 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (Pentest R177)** (2026-08-18)
  - **R177-01 (MEDIUM)** – Der Pin war optional. Ohne ihn war der
    Vertrauensanker die gesamte `allowed_signers`-**Menge**, nicht der eine
    Notar-Schluessel: Ein zweiter dort gelisteter Schluessel (Kollege,
    Alt-Key, geteilte Datei) konnte das Gegenbuch waschen, force-pushen – und
    `%G?` war `G`. Live gezeigt, exit 0. R176-01 hatte „jeder selbst erzeugte
    Schluessel“ geschlossen, „jeder **erlaubte** Schluessel“ blieb offen.
    Fix: Pin wird aus `user.signingkey` automatisch abgeleitet
    (`ssh-keygen -lf` → `SHA256:…`); laesst er sich nicht bestimmen, wird
    **abgebrochen** statt die ganze Liste zu akzeptieren.
  - **R177-02 (MEDIUM)** – Der Schreib-Lauf prueft den frischen Commit nur auf
    `%G?`, nicht auf den Pin. Ein Notar-Host mit falsch konfiguriertem
    Schluessel meldete „beglaubigt“ und **pushte** – und ab da war die Kette
    bei jeder Folgepruefung dauerhaft rot, behebbar nur per Force-Push, den
    die Branch-Protection gerade verhindern soll. Selbst-DoS ohne Signal.
    Fix: Pin-Abgleich am frischen Commit **vor** dem Push, bei Abweichung
    Ruecknahme und Abbruch.
  - Kleinkram aus seinem Bericht: `NOTARY_ALLOW_LOCAL=true` faerbt jede
    Erfolgsmeldung jetzt ein („nur lokal, kein externer Anker“) und pusht
    nicht mehr ins Leere; CRM-Fehler (nicht erreichbar, 401/403, kein JSON)
    liefern eine erklaerende Zeile statt eines Node-Stacktrace.
  - Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff
    (in `allowed_signers`, aber nicht der Notar-Key) → **Alarm exit 2 auch
    ohne gesetzten Pin**; Schreiblauf mit falschem Schluessel → zurueckgerollt,
    **nichts gepusht**, keine Geisterzeile; CRM nicht erreichbar / 401 →
    saubere Meldung, exit 1; Lokalmodus → Erfolgsmeldung eingefaerbt;
    **saubere Historie: vier Laeufe plus Pruefmodus ohne Fehlalarm**.
    Alle Testartefakte geloescht, Ports frei.
  - Seine Antworten uebernommen: (a) `%GF` ist kryptographisch an die Signatur
    gebunden und nicht faelschbar – Non-Finding; (b) Code 3 bleibt getrennt von
    Code 2, „ich weiss es nicht“ darf nicht als „Manipulation“ gemeldet werden;
    (c) „Notar-Host **und** Key kompromittiert“ bleibt bewusst offen – dagegen
    hilft nur Branch-Protection am Remote, im Skript nicht erzwingbar.

- [x] **🔑 Gegenbuch: nur wirklich vertrauenswuerdige Signaturen, fail-closed ohne Remote (Pentest R176)** (2026-08-18)
  - **R176-01 (HIGH)** – Mein Signatur-Gate akzeptierte `%G?` = `G` **oder**
    `U`. Bei SSH-Signaturen bedeutet `U` woertlich „gute Signatur, aber kein
    passender Principal“ – also: der Schluessel steht **nicht** in
    `allowed_signers`. Damit passierte **jeder selbst erzeugte Schluessel** das
    Gate, und der einzige In-System-Vertrauensanker war wirkungslos. Der
    Pentester hat es end-to-end gefahren: Gegenbuch mit fremdem Schluessel
    re-signiert und force-gepusht → „OK, Signaturen gültig“, exit 0.
    Vorbedingung war nur Remote-Schreibrecht, kein Host-Zugriff.
    Fix: **nur `G`** an beiden Stellen. Zusaetzlich optionales Pinnen des
    erwarteten Signierschluessels ueber `NOTARY_SIGNER_FINGERPRINT` (`%GF`).
  - **R176-02 (MEDIUM)** – War der Remote unerreichbar, fiel der Ablauf still
    auf `HEAD` zurueck und die „nie gepusht“-Pruefung wurde uebersprungen –
    ausgerechnet unter der Bedingung, die einen Push-Fehlschlag verursacht.
    `--check` meldete waehrend eines Remote-Ausfalls gruenes Licht auf nicht
    notarisiertem Zustand. Fix: **fail-closed**, Abbruch mit Code 3 statt dem
    lokalen Stand zu glauben; ebenso, wenn gar kein Remote konfiguriert ist
    (bewusster Testlauf nur mit `NOTARY_ALLOW_LOCAL=true`).
  - Nebenbei: git-eigene Fehlermeldungen standen VOR der eigenen Erklaerung –
    stderr wird jetzt abgefangen und gezielt weitergereicht.
  - Verifiziert mit zwei SSH-Schluesseln (keyA vertrauenswuerdig, keyB nicht)
    gegen echten Remote: Angriff mit keyB + Force-Push → **Alarm, exit 2**
    (vorher „OK“); Remote unerreichbar → **exit 3**; kein Remote → exit 3;
    falscher Fingerabdruck-Pin → Alarm; **saubere Historie: drei Laeufe und
    Pruefmodus ohne Fehlalarm**. Alle Testartefakte geloescht, Port frei.
  - Seine Antwort auf Frage (a) uebernommen: der Vergleich Arbeitsdatei ↔
    signierter Baum ist nicht umgehbar; `.trim()` auf beiden Seiten schluckt
    nur Kosmetik. Notiert als Restrisiko: internes CRLF koennte bei
    `core.autocrlf` auf dem Notar-Host einen Fehlalarm ausloesen –
    Verfuegbarkeit, kein Integritaetsloch.

- [x] **🔏 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)** (2026-08-18)
  - **R175-01 (HIGH)** – Der Kernsatz des Pentesters: „Append-only allein
    reicht nicht, es braucht einen verifizierenden Leser.“ Meine erste Fassung
    signierte zwar, **prüfte aber nie**: Sie las ihre Wahrheit per
    `readFileSync` aus der lokalen **Arbeitsdatei**, nirgends im Repo gab es
    ein `git verify-commit`. Das `-S` war write-only ohne Konsument. Live
    reproduziert: DB-Tail abgeschnitten UND die lokale Ledger-Zeile angepasst →
    „OK, Checkpoint beglaubigt“, exit 0, kein Alarm. Der gekuerzte Zustand
    wurde zur neuen Wahrheit.
    Fix: Wahrheitsquelle ist der **signierte Commit-Baum** (bevorzugt der
    Remote-Kopf); jeder Commit mit Gegenbuch-Aenderung muss eine gueltige
    Signatur tragen; weicht die Arbeitsdatei vom signierten Stand ab, wird
    abgebrochen; lokale, nie gepushte Commits gelten nicht als beglaubigt;
    die Signatur des frisch erzeugten Commits wird gegengeprueft.
    Dazu ein **Pruefmodus** `--check` fuer Auditoren (alle Kontrollen, kein
    Schreibrecht noetig).
  - **R175-02 (MEDIUM)** – `/checkpoint` fuhr je Aufruf ein volles
    `verifyIntegrity()` (O(n), gemessen 0,85 s bei 16k Zeilen) – authentifizierte
    DoS-Verstaerkung. Gebraucht wurde davon nur die Siegel-Wurzel. Jetzt:
    Kopf-Hash direkt aus der Kopfzeile, Wurzel aus dem juengsten gueltigen
    Marker (eine Abfrage + Signaturpruefung), plus `sealLeafCount` statt des
    teuren Status.
  - **R175-03 (LOW→MED)** – `writeFileSync` lief vor dem Commit: schlug der
    signierte Commit fehl, blieb eine verwaiste Zeile liegen, die der
    Folgelauf absorbierte und zementierte. Jetzt Ruecknahme bei Fehlschlag
    (`git checkout`), und `NOTARY_SIGN=false` verlangt zusaetzlich
    `NOTARY_INSECURE_ACK=…` – der Reflex „Cron hakt, Signatur abschalten“
    soll nicht ausreichen.
  - Verifiziert mit **echtem SSH-Signaturschluessel** gegen eine CRM-Attrappe:
    stilles Waschen (DB gekuerzt + lokale Datei gebogen) → Alarm statt „OK“;
    frei erfundene, nie committete Zeile → Alarm; unsignierter Commit in der
    Historie → Alarm; Commit-Fehlschlag → Datei zurueckgerollt, keine
    Geisterzeile; `NOTARY_SIGN=false` ohne Bestaetigung → verweigert;
    Pruefmodus meldet sauber ohne anzuhaengen. Alle Testartefakte geloescht.
  - Seine Bestaetigungen: Key-Trennung im Deploy haelt (`printenv` im
    Container zeigt keinen Notar-Schluessel); `audit:read` kann am
    Checkpoint keine falschen Werte injizieren.

- [x] **⚖️ Gegenbuch: externe Notarisierung der Audit-Kette** (2026-08-18)
  - Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in
    derselben Datenbank, die sie absichern sollen – der Pentest hat das ueber
    mehrere Runden Schicht fuer Schicht gezeigt, zuletzt in R174-01 am Siegel-
    Marker selbst. Ein Gegenbuch an einem fremden Ort durchbricht das.
  - Aufteilung nach der Analyse des Pentesters („der Schutz kommt vom ORT,
    nicht von der Signatur“): Das CRM liefert nur einen **lesbaren
    Kontrollwert ohne Geheimnisse** (`GET /api/audit-logs/checkpoint`,
    `audit:read`). Signiert, zeitstempelt und angehaengt wird auf einem
    **anderen Rechner** – Schluessel und Push-Recht liegen NICHT in den
    Deploy-Secrets des CRM. Ohne diese Trennung waere es „D1 nochmal, nur
    schlimmer“: sieht nach doppeltem Boden aus, tut still nichts.
  - Der Kontrollwert enthaelt bewusst `maxId`. Ein blosser Kopf-Hash erkennt
    Umschreiben, aber kein **Abschneiden am Ende** – genau die R174-01-Klasse,
    eine Ebene hoeher. `atId` erlaubt der Gegenstelle, einen frueher
    beglaubigten Kopf erneut abzufragen und nachzurechnen.
  - Gegenstelle: `tools/audit-notary/notary.mjs` (Cron auf zweitem Rechner,
    privates Git-Repo als Append-only-Ablage, signierte Commits). Prueft VOR
    dem Anhaengen und bricht bei Widerspruch mit Exit-Code 2 ab, **ohne zu
    schreiben** – der manipulierte Zustand wird nicht als neue Wahrheit
    festgeschrieben.
  - Verifiziert gegen eine CRM-Attrappe mit echter DB: beglaubigte Zeile
    veraendert → Alarm; am Ende abgeschnitten (maxId 5→4) → Alarm; Gegenbuch
    selbst gekuerzt (seq-Luecke) → Alarm; in allen Faellen nichts angehaengt.
    Wegwerf-DB und Test-Gegenbuch danach geloescht.
  - **Ehrlich dokumentiert** (README): Restfenster zwischen zwei Laeufen bleibt
    und ist inhaerent; ein stiller Cron-Ausfall erzeugt im CRM KEINE Warnung
    und muss auf dem Gegenbuch-Rechner ueberwacht werden; Force-Push muss
    serverseitig gesperrt sein, sonst ist Append-only nur geliehen.
  - **Betrieb:** Einrichtung liegt beim Betreiber (privates Repo, Cron,
    Signaturschluessel auf dem zweiten Rechner).

- [x] **🚨 Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)** (2026-08-18)
  - Fund: Der Siegelzustand hing **ausschliesslich** am Marker im Audit-Log –
    und den kann ein DB-Schreiber **ohne Schluessel** loeschen. Danach meldete
    die Pruefung `kein_siegel`, also **Entwarnung**, ununterscheidbar von „nie
    versiegelt“. Die 11 Blattwerte blieben verwaist liegen und wurden nie
    konsultiert; die zuvor erkannte V1-Faelschung war wieder unsichtbar.
  - Besonders bitter im **Tail-Fall**: Steht der Marker am Ketten-Ende – genau
    der Zustand direkt nach dem einmaligen `seal-backlog` beim Deploy – reisst
    beim Loeschen nicht einmal eine Luecke. Ergebnis war `valid: true`,
    „Alle Einträge unverändert und lückenlos verkettet“, **null Spur**. Meine
    Antwort auf seine Frage (b) war damit falsch: die Luecke reisst nur,
    solange der Marker NICHT am Ende steht.
  - Fix (sein Vorschlag): Gegen-Check „Blaetter vorhanden, aber kein gueltiger
    Marker“ → neuer Status **`entfernt`** statt `kein_siegel`, mit
    ausdruecklicher Meldung („Das ist KEIN Normalzustand“). Zusaetzlich kippt
    ein gebrochenes ODER entferntes Siegel jetzt `valid` auf `false`, auch wenn
    keine einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust
    ohne Wirkung. Und der beruhigende Einstiegssatz entfaellt bei
    Siegelproblemen, damit er die Warnung nicht entwertet.
  - Verifiziert ueber den echten HTTP-Pfad, exakt sein Szenario 4: Marker war
    nachweislich das Ketten-Ende, Marker + Middleware-Decoy geloescht,
    **0 Ketten-Luecken** – Ergebnis jetzt `valid: false`, Status `entfernt`,
    Klartext-Warnung. Zusaetzlich der Nicht-Tail-Fall geprueft. Zwei
    Wegwerf-Datenbanken, beide danach geloescht; Dev unberuehrt.
    `tsc` + `vite build` gruen.
  - Seine Gegenchecks bestaetigt: Frage (a) Middleware kann keine Marker-/
    Manifest-Signatur mit auswertbarem Inhalt nachbilden; Frage (c)
    Re-Insert mit derselben id umgeht nichts, weil das Blatt alle Spalten
    deckt.

- [x] **🔧 Bestandssiegel betriebstauglich gemacht (Pentest R173-01 HIGH, R173-02/-03 MEDIUM)** (2026-08-18)
  - **R173-01 (HIGH) – Siegel war ueber HTTP tot.** Die generische
    `auditMiddleware` protokolliert den `POST /seal-backlog`-Request SELBST als
    `AuditLog / CREATE / endpoint=/api/audit-logs/seal-backlog` – exakt die
    Signatur, mit der ich den Marker suchte, nur mit hoeherer id und ohne
    `changesAfter`. Der Selektor griff diese Zeile, `root` war `undefined`,
    Ergebnis: **dauerhaft „gebrochen“ bei null manipulierten Zeilen.** Das
    komplette R171-02-Deliverable war damit im Betrieb wertlos.
    **Mein Testfehler:** Ich hatte `sealBacklog()`/`verifyIntegrity()` direkt
    aufgerufen, nie ueber HTTP – die Middleware lief nie mit. Dieselbe
    Fehlerklasse wie R165 (Code korrekt, im Betrieb nicht erreichbar).
    Fix: eigener Ressourcentyp `AuditBacklogSeal`, den die Middleware nie
    vergibt (sie leitet ihn aus dem Pfad ab → `AuditLog`), zusaetzlich muss der
    Marker auswertbares `{toId, root}` tragen.
  - **R173-02 (MEDIUM) – Siegel war blind fuers Loeschen.** `if (!zeile)
    continue` uebersprang fehlende Zeilen; das Loeschen eines gesiegelten
    Einbruchsbelegs erschien nur als „unerklaerte Luecke“, waehrend der
    dedizierte Indikator „intakt“ meldete – Entwarnung, waehrend Beweismaterial
    verschwindet. Fix: fehlende gesiegelte Zeilen sind ein Siegelbruch mit
    eigener Liste (`backlogMissing`) und werden namentlich gemeldet. Deckt
    zugleich seine Frage (a) ab: geprueft wird jetzt die gesiegelte Menge
    gegen die Live-Population, nicht nur der Inhalt vorhandener Zeilen.
  - **R173-03 (MEDIUM) – Re-Seal wusch mit weniger Spur als ein Rehash.** Die
    R170-01-Haertung (Vorbefund im Marker) war auf `seal-backlog` nie
    angewandt. Fix: Der Siegel-Marker haelt jetzt den Befund VOR dem Siegeln
    fest (manipulierte/fehlende Zeilen, Ketten-Luecken) samt Status und Wurzel
    des vorherigen Siegels; `/verify` weist die Anzahl gueltiger Siegel aus und
    weist bei >1 ausdruecklich darauf hin.
  - Verifiziert **ueber den echten HTTP-Pfad inkl. Middleware** (separate
    Wegwerf-DB, Mischbestand 6×V1/4×V2/4×V3): Middleware-Protokollzeile ist
    vorhanden, Status trotzdem `intakt`; Loeschen einer gesiegelten Zeile →
    `gebrochen`, `backlogMissing:[2]`, namentlich in der Meldung; erneutes
    Siegeln → Anzahl 2 gemeldet, Marker enthaelt „vorheriges Siegel: gebrochen,
    Befund fehlend [2]“. Wegwerf-DB geloescht, Dev unberuehrt.
    `tsc` + `vite build` gruen.

- [x] **🧿 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02, HIGH)** (2026-08-18)
  - Der Pentester hat den Fund live reproduziert: Bei Hash-Version 1 sind nur
    7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine
    `LOGIN_FAILED`-Zeile auf `success=1` setzen, das Label auf
    „…hat sich angemeldet“ aendern und `errorMessage` leeren – **alles
    Nicht-Hash-Felder, Hash unveraendert** → `/verify` meldete `valid=true`.
    Ein Einbruchsversuch war unsichtbar in einen Erfolg umschreibbar.
  - Rueckwirkend signieren geht nicht; ein Rehash waere die falsche Medizin.
    Stattdessen **Bestandssiegel**: einmalig, nicht destruktiv, schreibt nichts
    um. Je Altzeile ein Blattwert, die Wurzel darueber in einem HMAC-signierten
    Marker.
  - Umgesetzt exakt nach seinen vier Bedingungen:
    1. **Blaetter ueber den vollen Zeileninhalt** (alle Spalten inkl. `id` und
       `hashVersion`), NICHT ueber den 7-Feld-V1-Hash – sonst lebte die Luecke
       im Siegel weiter.
    2. **Wurzel signiert** – sie steht im Marker, der selbst Version 3 und
       HMAC-signiert ist. Ohne `AUDIT_HMAC_KEY` wird das Siegeln abgelehnt.
    3. **Bereich fix auf `[1 … v3FromId-1]`** gebunden statt per Live-Abfrage
       `hashVersion < 3`. Sein Umgehungsweg: ein Up-Flip der Grenzzeile
       (V1→V2, schluessellos nachrechenbar) haette sie sonst aus der geprueften
       Menge gedraengt – sie waere nur als harmlose Luecke erschienen.
    4. **Pruefung je id**: vorhanden, weiterhin Altbestand, Inhalt == Blatt.
       Zusaetzlich Wurzelabgleich, damit auch ein veraenderter Blattbestand
       auffliegt.
    - Naht geschlossen: Zeilen ab `v3FromId` MUESSEN laut Versionsgrenze V3
      sein, es bleibt also keine ungesiegelte Zone dazwischen.
  - Neuer Endpunkt `POST /audit-logs/seal-backlog` (`audit:admin`,
    `{"confirm":"SEAL"}`). `/verify` meldet den Siegelzustand im Klartext –
    auch wenn **kein** Siegel existiert („Aenderungen daran waeren nicht
    erkennbar“).
  - Verifiziert in einer separaten Wegwerf-Datenbank mit realistischem
    Mischbestand (6×V1, 5×V2, 5×V3): ohne Siegel ist der Angriff unsichtbar
    (`valid=true`) – mit Siegel wird er erkannt und die Zeile benannt (`[3]`);
    sein Up-Flip der Grenzzeile wird ebenfalls erkannt (`[6]`). Wegwerf-DB
    danach geloescht, Dev-Daten unberuehrt. `tsc` + `vite build` gruen.

- [x] **🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW)** (2026-08-18)
  - **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten
    bedingungslos – ohne zu pruefen, ob die Traegerzeile ueberhaupt signiert
    und gueltig ist. Das Manifest steht in `changesAfter`, und dieses Feld ist
    erst ab Hash-Version 2 mitgehasht, auf **V1-Altzeilen also voellig
    unauthentifiziert**. Ein Angreifer konnte in eine BELIEBIGE V1-Zeile ein
    erfundenes Manifest schreiben, ohne deren Hash zu veraendern, und damit
    eigene Loeschungen als „erklaert“ ausweisen – womit zugleich die
    Eskalation an signierten Zeilen ausfiel. Damit waren **Anker UND
    Versionsgrenze** umgangen. Der Pentester hat es live gezeigt; es war genau
    die Stelle, die ich beim Uebergeben als meine schwaechste benannt hatte.
  - Fix: Ein Manifest zaehlt nur noch, wenn seine Traegerzeile (1) laut
    Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert und (2) ihre
    HMAC-Signatur mit einem konfigurierten Schluessel aufgeht. Ohne Schluessel
    gibt es keine gueltigen Traeger – dann bleiben Luecken unerklaert, was die
    sichere Richtung ist.
  - **R171-03:** Eskalierte Luecken standen in `tamperedEntries` UND
    `chainGaps`; `invalidEntries` zaehlte sie doppelt. Jetzt entdoppelt.
  - Verifiziert: boeswillige Loeschung → Befund; erfundenes Manifest in
    herabgestufter Traegerzeile → **ignoriert**, Luecke bleibt Befund und die
    Traegerzeile wird selbst beanstandet; **legitimes signiertes Manifest
    erklaert die Luecke weiterhin** (Funktion nicht kaputt). `tsc` gruen.
  - Hinweis: Auf der Dev-DB existiert kein V1-Bestand mehr (der Rehash-Test aus
    R170-01 hat alles auf V3 gehoben), der Angriff wurde daher mit einer gezielt
    herabgestuften Traegerzeile nachgestellt. Auf Staging/Prod ist der
    V1-Altbestand real vorhanden.

- [x] **🧾 rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)** (2026-08-18)
  - Fund: `POST /audit-logs/rehash` (audit:admin) rechnet die Kette MIT dem
    HMAC-Schluessel neu und macht sie damit wieder stimmig – auch wenn sie
    vorher berechtigte Beanstandungen enthielt. Der Anker schuetzt also gegen
    einen DB-Schreiber ohne Schluessel, **nicht** gegen einen Admin mit
    `audit:admin`. Der bisherige Marker hielt nur fest, DASS rehasht wurde,
    nicht WAS dabei verschwand.
  - Fix 1 – **Vorzustand im Marker**: Vor dem Rehash wird `verifyIntegrity()`
    erhoben und samt Kettenkopf (id, hash, hashVersion) im Marker gesichert:
    Anzahl geprueft, Listen der manipulierten Zeilen, der Ketten-Luecken, der
    Luecken ohne dokumentierte Loeschung und der nicht pruefbaren. Dazu
    ausloesender Benutzer und IP (bisher stand dort pauschal „system“).
    Der Marker entsteht nach dem Rehash, ist Teil der neuen Kette und
    signiert – entfernen ginge nur unter Hinterlassung einer Luecke.
  - Fix 2 – **Bestaetigung verlangen**: `rehash` erfordert
    `{"confirm":"REHASH"}`, `cleanup` erfordert `{"confirm":"CLEANUP"}`.
    Der Pentester hatte beide bei blinder Methoden-Erkundung per POST
    unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400.
  - Verifiziert: blinder POST auf beide Endpunkte → 400 ohne Wirkung; mit
    Bestaetigung → Rehash laeuft, Marker enthaelt Ausloeser, Vorbefund
    (1 manipuliert, 7 Luecken mit exakten IDs) und Kettenkopf.
  - **Offengelegt:** Dieser Test hat auf der DEV-Datenbank real rehasht – die
    dortigen historischen Beanstandungen sind rechnerisch geglaettet
    (`valid = true`). Genau der beschriebene Effekt; der Vorzustand steht
    jetzt aber im Marker. Staging/Prod unberuehrt.
  - Offen (Betreiber-Entscheidung): Off-Site-Notarisierung des Kettenkopfes.
    Erst sie deckt den Fall „Admin mit audit:admin“ vollstaendig ab.

- [x] **🔒 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01, HIGH)** (2026-08-18)
  - Der Pentester hat genau die Frage beantwortet, die ich beim Uebergeben
    gestellt hatte („laesst sich das Kulanzfenster ausnutzen?“) – und zwar
    nicht per Timing, sondern per **Linien-Fork**: Jede Kulanz-Wiedervorlage
    rotierte auf einen FRISCHEN Token mit eigenem, zurueckgesetztem Zaehler.
    Ein Angreifer mit gestohlenem Token konnte damit aus dem erkennbaren
    Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung entkommen,
    die nie wieder mit der des Opfers kollidiert – **dauerhaft unsichtbar,
    kein einziges CRITICAL**. Damit war die Kern-Garantie von R164-02
    (Diebstahl faellt bei der naechsten Nutzung auf) ausgehebelt.
  - Fix (sein Vorschlag): **Kulanz idempotent**. Die jti des Nachfolgers wird
    schon beim Einloesen im selben bedingten UPDATE reserviert
    (`replacedByJti`). Eine Wiedervorlage im Fenster gibt **denselben** bereits
    ausgestellten Nachfolger zurueck, statt neu zu rotieren – ohne neuen
    Datensatz. Parallele Tabs laufen dadurch auf EINE Linie zusammen; wer den
    Token spaeter (ausserhalb des Fensters) vorlegt, kollidiert zwangslaeufig
    und loest den Familien-Widerruf aus.
    Ist kein Nachfolger hinterlegt, wird bewusst NICHT ersatzweise rotiert
    (das waere wieder der Fork), sondern fail-closed als Replay gewertet.
  - Verifiziert – sein PoC nachgebaut: T0 legit → TA, T0 replayt → TC;
    **TA.jti == TC.jti**, also kein Fork mehr. Ueber HTTP: zwei parallele Tabs
    beide erfolgreich und auf derselben Linie; gestohlener Token nach Ablauf
    des Fensters → 401, Familie widerrufen, Angreifer-Linie tot,
    `SUSPICIOUS/CRITICAL` gemeldet.
  - Regression der in R168 bestaetigten Faelle: 40 parallel → 4 erfolgreich
    (1 + Kulanz 3) auf **einer** Linie, Rest abgewiesen; serielles Replay 1–4
    ok, 5. → Replay; Token ohne jti → fail-closed; Logout widerruft. `tsc` gruen.

- [x] **🔁 Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)** (2026-08-18)
  - Die Rotation war bisher wirkungslos: Der alte Refresh-Token blieb bis `exp`
    gueltig, ein gestohlener Token also **bis zu 7 Tage parallel** zum legitimen
    nutzbar (der Pentester trug mit EINEM Token 90 Parallel-Requests).
  - Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine `jti`
    und gehoert zu einer Sitzungs-`familyId` (neue Tabelle
    `RefreshTokenRecord`, Migration `20260818160000`). Beim Einloesen wird die
    `jti` verbraucht; taucht sie erneut auf, wird die **gesamte Familie**
    widerrufen – Angreifer und legitimer Nutzer fliegen raus, der Nutzer merkt
    es und der Vorfall wird als `SUSPICIOUS / CRITICAL` gemeldet.
  - Der Token selbst wird NICHT gespeichert (die Signatur authentifiziert ihn
    bereits); ein DB-Leck gibt damit keine nutzbaren Sitzungen preis.
  - **Kulanzfenster** fuer parallele Tabs: 15 s UND hoechstens 3
    Wiederverwendungen. Ohne Toleranz wuerde der zweite legitime Tab die
    Sitzung sprengen; die enge Grenze laesst einen Missbrauchs-Burst trotzdem
    auflaufen.
  - **Einloesen ist atomar** (bedingtes UPDATE statt Lesen-dann-Schreiben) –
    derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90
    gleichzeitige Requests ausnahmslos durch, weil alle den Token als „noch
    unbenutzt“ lasen. Jetzt entscheidet die Datenbank, wer zuerst war.
  - Verifiziert: Rotation vergibt neue `jti` in derselben Familie; 90 parallele
    Requests → nur 4 erfolgreich (1 + Kulanz 3), 27 als Replay erkannt, Rest
    widerrufen, alle daraus entstandenen Tokens tot; 2 parallele Tabs weiterhin
    erfolgreich; gestohlener Token spaeter erneut → abgewiesen; Logout
    widerruft die Familie; ueber HTTP kommt `SUSPICIOUS/CRITICAL` an.
    Audit-Regression unveraendert (25/25, 0 Forks, alle V3). `tsc` +
    `vite build` gruen.
  - **Deploy-Hinweis:** Refresh-Tokens ohne `jti` (Bestand vor dem Deploy)
    werden bewusst **fail-closed** abgewiesen (`REFRESH_LEGACY`, als LOW
    gemeldet, kein Angriffsindiz). Alle angemeldeten Nutzer muessen sich nach
    dem Deploy **einmalig neu anmelden**.

- [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18)
  - Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die
    Kette selbsttragend – wer die DB schreiben kann, konnte jede Zeile aendern
    und alle Folgehashes konsistent nachziehen, die Pruefung meldete „gueltig“.
  - Version 3 signiert denselben Inhalt per **HMAC-SHA256** mit `AUDIT_HMAC_KEY`
    – einem Schluessel, der NICHT in der Datenbank liegt. Ohne ihn laesst sich
    keine gueltige Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr.
  - **Fail-safe:** Ohne Schluessel schreibt das Audit-Log weiter Version 2, es
    faellt nichts aus. Signierte Zeilen gelten dann als **nicht pruefbar**
    (eigener Topf `unverifiableEntries`) und ausdruecklich NICHT als
    manipuliert – eine fehlende Konfiguration darf kein Fehlalarm ueber das
    gesamte Log sein.
  - **Schluesselwechsel ohne Rehash:** `AUDIT_HMAC_KEY_OLD` wird bei der
    Pruefung zusaetzlich akzeptiert. Verifiziert: mit KEY_OLD 0 Befunde, ohne
    KEY_OLD werden die alt signierten Zeilen erwartungsgemaess auffaellig.
  - **Restluecke der Versionsgrenze geschlossen (selbst gefunden):** Wird die
    FRUEHESTE Zeile einer Stufe herabgestuft, wandert `MIN(id)` mit – die
    Grenze allein haette den Downgrade durchgewunken (mein erster Testlauf fiel
    genau darauf durch). Der Nachfolger ist jedoch HMAC-signiert, sein
    `previousHash` ist ohne Schluessel nicht faelschbar. Eine unerklaerte Luecke
    vor einer signierten Zeile gilt deshalb jetzt als Befund, nicht als
    struktureller Zufall.
  - Verifiziert: Inhalt geaendert + Hash beliebig → erkannt; Downgrade 3→2 mit
    gueltigem V2-Hash auf der **fruehesten** V3-Zeile → erkannt; dasselbe auf
    der **letzten** V3-Zeile (kein Nachfolger) → erkannt; ohne Schluessel
    0 manipuliert / 2 nicht pruefbar; 40 parallele Schreiber → 40/40, 0 Forks,
    alle V3. `tsc` + `vite build` gruen.
  - **Betrieb:** `AUDIT_HMAC_KEY` in `.env.example` dokumentiert
    (`openssl rand -hex 32`). Schluessel sichern – geht er verloren, sind die
    damit signierten Eintraege dauerhaft nicht mehr pruefbar (aber nicht als
    manipuliert gemeldet). Der Dev-Schluessel liegt nur lokal in `.env`
    (gitignored) und ist NICHT der Prod-Schluessel.

- [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18)
  - Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02
    selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen
    Live-Code + Live-Daten): `verifyIntegrity` waehlte die Pruefstaerke nach der
    von der Zeile SELBST deklarierten `hashVersion` – und die ist nicht gehasht.
    Angriff: `hashVersion` 2→1 setzen, die nur von V2 abgedeckten Felder
    aendern (`success` false→true, `errorMessage` leeren, `resourceLabel`
    umschreiben) und den schwachen V1-Hash ueber die 7 unveraenderten Felder
    nachziehen → Fehl-Login als Erfolg getarnt, Pruefung meldet „gueltig“.
    Besonders kritisch an der **letzten Zeile** der Kette: dort entsteht nicht
    einmal ein Gap → dauerhaft unsichtbar.
  - Fix: **Version-Floor.** Die erwartete Pruefstaerke leitet sich aus der Kette
    ab (`MIN(id) WHERE hashVersion >= 2`), nicht aus der Selbstauskunft. Ab
    dieser Grenze muss jede Zeile V2 sein; weicht die deklarierte Version von
    der erwarteten ab, gilt die Zeile selbst als manipuliert. Geprueft wird
    immer mit dem ERWARTETEN Verfahren. Die Grenze laesst sich durch das
    Herabstufen einzelner Zeilen nicht verschieben (Minimum bleibt) – ein
    Angreifer muesste alle V2-Zeilen ab der Grenze herabstufen und die gesamte
    Kette neu rechnen (= vollstaendiger Rehash, bekannter Grenzfall).
  - Zusaetzlich: Die Pruefung konsultiert jetzt das **Loeschungs-Manifest**.
    Neu `unexplainedGaps` – nur Luecken ohne protokollierte Loeschung sind
    erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich
    eine boeswillige Loeschung als „harmloser Gap“ tarnen konnte.
  - Verifiziert (PoC nachgebaut): Downgrade-Angriff auf Zeile mit Nachfolger →
    **erkannt**; auf die Tail-Zeile (erzeugt keinen Gap) → **erkannt**;
    Gegenrichtung (V1-Altzeile faelschlich als V2 deklariert) → **erkannt**;
    Ausgangslage und Zustand nach Wiederherstellung jeweils 0 manipuliert
    (keine Falschmeldungen auf Bestandsdaten). `tsc` + `vite build` gruen.
  - Betriebshinweis dokumentiert: Bei einem rollierenden Deploy mit kurzzeitig
    parallel schreibender Alt-Instanz koennen echte V1-Zeilen nach der Grenze
    entstehen und wuerden angezeigt. Beim hier ueblichen Deploy
    (pull + rebuild + restart) tritt das nicht auf.

- [x] **🛡️ Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18)
  - **R166-01 (HIGH) – Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die
    Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem
    COMMIT. Im Fenster Release↔Commit las der naechste Schreiber ein noch nicht
    sichtbares Kettenende → zwei Zeilen am selben Vorgaenger. Meine
    „100 parallel → 0 Brueche“-Messung war zu schwach: sie suchte Luecken
    zwischen Nachbarn, nicht Forks, und das Fenster ist lokal sehr schmal.
    Fix: einzeiliger Mutex `AuditChainLock` + `FOR UPDATE`; InnoDB-Zeilensperren
    fallen erst beim COMMIT. Dazu `isolationLevel: ReadCommitted`, damit der
    Lesevorgang den frisch festgeschriebenen Stand sieht.
    **Belegt** im Direktvergleich mit kuenstlich geweitetem Fenster:
    Release-vor-Commit → Fork, Zeilensperre → kein Fork.
  - **R166-02 (MEDIUM) – Hash deckte nur 7 Felder.** `changesBefore/After`
    (die eigentliche Nutzlast), `success`, `ipAddress`, `resourceLabel`,
    `dataSubjectId`, `userId`/`customerId` waren NICHT gehasht – ein Einzeledit
    dort blieb unsichtbar. Fix: `hashVersion` (Migration `20260818150000`) +
    `generateHashV2` ueber alle Inhaltsspalten. Bestandszeilen behalten
    Version 1 und bleiben ohne Rehash gueltig. `rehashAll` schreibt V2.
    Verifiziert: Manipulation an success/changesAfter/ipAddress/resourceLabel/
    dataSubjectId wird jetzt **5/5 erkannt**, Altbestand weiter gueltig.
  - **R166-03 (LOW)** – „kein Cookie“ (normaler Erstbesuch) wurde als
    `HIGH / abgelehnt` gefuehrt. Jetzt eigener Ausgang: LOW + Label
    „ohne vorliegenden Token“. Nur echte Ablehnung bleibt HIGH.
  - **R166-04 (LOW, pre-existing)** – `GET /retention-policies` wurde von
    `GET /:id` verschluckt. Konkrete Routen jetzt konsequent vor der
    Parameter-Route, mit Warnhinweis im Code.
  - **Design-Empfehlungen umgesetzt:** `runRetentionCleanup` schreibt ein
    Loeschungs-Manifest (Bereich `fromId`–`toId`, Anzahl, Policy, Cutoff) als
    eigenen verketteten Eintrag – Luecken ausserhalb bleiben damit
    erklaerungsbeduerftig; `rehashAll` schreibt einen Marker, dass die
    Beweiskraft der Vergangenheit zurueckgesetzt wurde.
  - Verifiziert: 50 parallele Schreiber → 50/50, 0 Forks, alle V2;
    manipuliert 0, Luecken unveraendert 7. `tsc` + `vite build` gruen.
  - **Offen (bewusst nicht umgesetzt):** externer Anker (HMAC mit Schluessel
    ausserhalb der DB). Adressiert Full-DB-Compromise, erfordert aber
    Schluesselverwaltung/Rotation im Deployment → Entscheidung des Betreibers.

- [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18)
  - **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig
    unterschiedliche Befunde in einen Topf und meldete beides als
    „N manipulierte Eintraege“. Eine harmlose Verkettungsluecke sah damit aus
    wie ein Angriff – die Meldung war im Alltag nicht deutbar und wurde dadurch
    wertlos (dasselbe Muster wie beim Refresh-Rauschen).
  - Fix: Rueckgabe um `tamperedEntries` (Inhalt einer Zeile nachtraeglich
    veraendert – **ernst**) und `chainGaps` (Verkettung unterbrochen durch
    parallele Schreibvorgaenge oder geloeschte Zeilen – **meist harmlos**)
    erweitert. `invalidEntries` bleibt als Summe erhalten
    (Abwaertskompatibilitaet). Controller formuliert die Meldung entsprechend
    eindeutig; Frontend-API-Typ nachgezogen.
  - **Problem 2 (Aufbewahrung):** Seit der Entrauschung landen Token-Refreshes
    als `Authentication / LOW`. Diese Kombination traf auf keine spezifische
    Regel und fiel in die Auffangregel `*` mit 3650 Tagen – das **Rauschen
    waere 10 Jahre** aufbewahrt worden, echte Logins nur 2 (730 Tage).
  - Fix: Regel `Authentication / LOW` → 90 Tage. Als Migration
    (`20260818130000`, idempotent per `ON DUPLICATE KEY`) **und** im Seed, damit
    sie sowohl bestehende Installationen als auch Neuinstallationen erreicht.
  - Hinweis zur Sensitivitaet: Sie steuert die Aufbewahrung, ist also **keine**
    Alarmstufe. Normale Logins/Logouts sowie Zugriffe auf Bankdaten/Ausweise
    bleiben bewusst CRITICAL. Ein Herabstufen „fuer eine ruhigere Liste“ wuerde
    still die Aufbewahrungsfrist verlaengern – daher unterlassen.
  - Verifiziert: Live-Test gegen Dev-DB – echte Manipulation einer Zeile
    (`UPDATE … SET userEmail`) wird als **manipuliert** erkannt und nicht mit
    Luecken verwechselt; Ketten-Luecken bleiben bei 7; Originalzustand exakt
    wiederhergestellt (0 manipuliert danach). `tsc` + `vite build` gruen.

- [x] **🔗 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)** (2026-08-18)
  - `createAuditLog` las den Vorgaenger-Hash und schrieb den neuen Eintrag als
    zwei getrennte Schritte. Zwei parallele Requests lasen denselben letzten
    Hash und haengten sich beide daran → Kette zerrissen (echte Bruchstellen im
    Bestand: 05.05./07.05.2026).
  - Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen
    benannten MySQL-Lock (`GET_LOCK('opencrm_audit_chain')`). Liegt in der DB,
    wirkt daher auch ueber mehrere App-Instanzen hinweg. Release im `finally`,
    da benannte Locks nicht transaktional sind (sonst wandert die Sperre mit der
    Verbindung zurueck in den Pool und blockiert alle Schreiber).
  - **Verworfener erster Ansatz – wichtig:** `SELECT … FOR UPDATE` auf das
    Kettenende nimmt Gap-/Next-Key-Locks, die mit den gleichzeitigen INSERTs
    kollidieren. Gemessen: **38 von 40** parallelen Eintraegen gingen durch
    Deadlocks verloren (vom `catch` still verschluckt). Ein FEHLENDER
    Audit-Eintrag ist unsichtbar und damit gefaehrlicher als ein sichtbarer
    Kettenbruch – deshalb der Umbau auf den benannten Lock.
  - Verifiziert: 100 parallele Schreiber → **100/100 geschrieben, 0 neue
    Brueche** (444 ms); Folge-Schreiber danach in 6 ms, `IS_FREE_LOCK` = frei
    (kein Lock-Leak). Gesamtzahl ungueltiger Zeilen bleibt bei den 7
    historischen. Rechenintensives (Serialisieren/Verschluesseln) liegt bewusst
    VOR der Transaktion, damit die Sperre kurz bleibt. `tsc` gruen.

- [x] **🛡️ Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben** (2026-08-18)
  - Beim Nachpruefen aufgefallen: `verifyIntegrity` meldete **3107 von 4630**
    Zeilen als „manipuliert“. Davon waren **3100 Fehlalarme** – eingegrenzt auf
    exakt die Zeilen mit `resourceId = NULL` aus dem Zeitraum 08.02.–01.05.2026.
  - Ursache: Der R121-Fix nahm an, `resourceId` sei beim Schreiben immer
    `undefined` gewesen (Key faellt bei `JSON.stringify` weg) und daher wuerden
    **alle** Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 –
    aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war DRIN.
  - Warum das sicherheitsrelevant ist: Ein Alarm, der staendig grundlos
    ausloest, wird ignoriert – **echte** Manipulation ginge im Lärm unter
    (gleiches Muster wie beim Refresh-Rauschen, [R165]).
  - Fix: `generateHashLegacy()` reproduziert das alte Schreibverhalten;
    `verifyIntegrity` akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
    wenn die aktuelle Variante nicht passt). **Kein Rehash** – der waere der
    naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der
    Vergangenheit aber unwiederbringlich zerstoeren. Gespeicherte Hashes
    bleiben unangetastet.
  - Verifiziert: ungueltige Zeilen **3107 → 7** (die 7 sind echte Ketten-Brueche,
    siehe naechster Punkt). Adversarial gegengetestet: Manipulation an
    `userEmail`/`action`/`endpoint`/`createdAt`/`resourceId` wird bei ALTEN wie
    NEUEN Zeilen weiterhin zu 100 % erkannt (10/10), unveraenderte Zeilen
    akzeptiert. `tsc` gruen.

- [x] **🐛 Audit-Log: Pfad-Matching kaputt – Auth-Actions generisch (Pentest R165-01)** (2026-08-18)
  - Pentester meldete: Entrauschung (`de0d6bd`) live **nicht wirksam** – jeder
    `/refresh` weiter `CREATE / CRITICAL / „Anmeldung erstellt“`. Zusatzbefund:
    auch `/login` und `/logout` liefen als generisches `CREATE`.
  - **Kein Deploy-Miss** (Alerting aus `d599eb3` lief ja live), sondern **toter
    Code**: `auditMiddleware` liest `req.path` erst im `res.on('finish')`-Handler.
    Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url` und stellt
    ihn nur beim `next()`-Durchlauf wieder her – ein terminaler Handler
    (`res.json()`) ruft nie `next()`, also bleibt `req.path` router-relativ
    (`/refresh` statt `/api/auth/refresh`). Alle `path.includes('/auth/...')`-Checks
    liefen ins Leere → Fallback POST→CREATE + Default-Sensitivität CRITICAL.
    Empirisch nachgestellt (Mini-Express: ENTRY `/api/auth/refresh` → FINISH `/refresh`).
  - Betraf **nicht nur** den neuen `TOKEN_REFRESH`: `LOGIN`/`LOGOUT`/`LOGIN_FAILED`
    waren im Audit-Stream **seit jeher** kaputt (pre-existing), ebenso das
    `endpoint`-Feld (router-relativ statt voll). Der SecurityEvent-Stream war nie
    betroffen (eigene `emit()`-Calls) – daher funktionierte das Alerting korrekt.
  - Fix: vollen Pfad **einmal synchron beim Eintritt** festhalten
    (`req.originalUrl.split('?')[0]`, wird von Express nie mutiert) und downstream
    ausschließlich diesen nutzen – in `determineAction`, `generateHumanLabel`,
    `extractDataSubjectId`, `manuallyLoggedPaths` und `endpoint`. `TOKEN_REFRESH`
    zusätzlich in die „immer loggen“-Ausnahme aufgenommen.
  - Verifiziert (E2E mit echter Middleware gegen Dev-DB, 5 Requests):
    `TOKEN_REFRESH/LOW` (Erfolg), `TOKEN_REFRESH/HIGH` (Fehlschlag),
    `LOGIN/CRITICAL`, `LOGIN_FAILED/CRITICAL`, `LOGOUT/CRITICAL`, alle mit vollem
    `endpoint`-Pfad und korrekten Labels. `tsc` grün.

- [x] **🛡️ Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18)
  - Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos
    `TOKEN_REFRESH`/LOW → ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf
    geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der
    Alarmierung (Angreifer weicht von `/login` auf `/refresh` aus, um unter
    CRITICAL zu bleiben).
  - **Wichtig:** Audit-Actions speisen die Alert-Engine NICHT (die zählt
    `SecurityEvent`-Zeilen via `emit()`). Der Tester-Minimalvorschlag (Action →
    `LOGIN_FAILED`) hätte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen:
    - **Detection:** `refresh()`-Catch emittiert jetzt `TOKEN_REJECTED` →
      greift die bestehende Schwelle `≥3 TOKEN_REJECTED/5min/IP → CRITICAL`
      (securityAlert.service, kein Severity-Filter). Severity wie Access-Token:
      abgelaufen/revoked → LOW (benigne, kein Sofort-Alert), ungültige
      Signatur/Manipulation → HIGH (Sofort-Alert). `auth.service` reicht dafür
      `err.code` REFRESH_EXPIRED/REFRESH_INVALID durch. „Kein Cookie" emittiert
      NICHT (normaler Erstbesuch).
    - **Audit-Triage:** fehlgeschlagener Refresh → Sensitivität HIGH statt LOW +
      Label „Token-Refresh abgelehnt (ungültig/abgelaufen)". Action bleibt
      bewusst `TOKEN_REFRESH` (semantisch ein Refresh, kein Login).
  - Verifiziert: tsx-Test — abgelaufen→LOW, manipuliert/garbage→HIGH; `tsc` grün.
  - Nebenbefund R164-02 (pre-existing, kein Commit von uns): Refresh-Rotation
    bietet keinen Replay-Schutz (alter Token bis exp gültig), aber fail-closed
    nach Logout. Ggf. später: Refresh-Token-Jti-Blacklist / One-Time-Use.

- [x] **🔇 Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18)
  - Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach
    Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` /
    „Anmeldung erstellt" / **CRITICAL** / User `anonymous` geloggt → sah aus wie
    anonyme Login-Flut, war aber die eigene Session.
  - Neuer `AuditAction`-Wert **`TOKEN_REFRESH`** (Enum-Migration
    `20260818120000_audit_token_refresh_action`, idempotentes `MODIFY COLUMN`).
    `determineAction()` erkennt `/auth/refresh` → eigene Action; Label
    „Sitzung verlängert (Token erneuert)"; Sensitivität in der Middleware
    explizit auf **LOW** (statt Default `Authentication → CRITICAL`).
  - Frontend `AuditLogs.tsx`: Filter-Option „Sitzung verlängert" + dezente
    Badge-Farbe (slate); Typ-Union ergänzt. `anonymous` bleibt (Endpoint läuft
    ohne `authenticate`-Middleware, authentifiziert per Cookie im Service) –
    bewusst nicht geändert (Option 1).
  - Verifiziert: Migration auf Dev-DB aktiv, `tsc` + `vite build` grün.

- [x] **🔒 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)** (2026-08-18)
  - `createContract`/`updateContract` spreadeten `energyDetails`/`tvDetails`/
    `carInsuranceDetails`/`mobileDetails` (via `...mobileData`) roh an Prisma →
    injizierte `id`/`contractId` konnten ein Detail-Objekt **reparenten** (auf
    Fremdvertrag umhängen) oder den **PK frei setzen** (stilles 200 statt 400).
    MEDIUM (Integrität; kein Cross-Tenant, staff-only, Portal 403).
  - **Fix:** Feld-Whitelists (`pickEnergyScalars`/`pickMobileScalars`/`pickTvScalars`/
    `pickCarInsuranceScalars`, analog R158) an allen Spread-Stellen. `internet`
    war bereits explizit (safe). Whitelists programmatisch gegen die DB-Spalten
    abgeglichen (minus id/contractId/verschlüsselt) – alle Diffs leer.
  - Verifiziert: `energyDetails:{basePrice:99.99, id:999999, contractId:fremd}` →
    basePrice aktualisiert, ecd.id + contractId **unverändert** (kein Reparenting).

- [x] **📋 Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18)
  - `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint
    `POST /tasks` (staff-only, `contracts:update`) für allgemeine Aufgaben ohne
    Vertrag/Kunde. Ohne Vertrag → **kein Kunde → nie im Portal sichtbar**
    (`visibleInPortal` serverseitig erzwungen false; Portal-Reply-Endpoint 403 bei
    contractloser Aufgabe; getAllTasks-Portal-Filter schließt sie automatisch aus).
  - Task-Modal (Mitarbeiter): Checkbox „Ohne Kunde (allgemeine Aufgabe)" – blendet
    Kunden-/Vertragsauswahl **und** „Im Kundenportal sichtbar" aus. Task-Liste zeigt
    solche Aufgaben als „Allgemeine Aufgabe (ohne Vertrag)" (kein Vertrags-Link).

- [x] **📧 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain** (2026-08-18)
  - Private Kunden-E-Mail (`Customer.email`) darf nicht auf einer bei den E-Mail-
    Providern konfigurierten Domain (oder Subdomain) liegen → man trägt so keine
    verwaltete Weiterleitungs-/Mailbox-Adresse als private Adresse ein. E-Mail ist
    **nur beim Anlegen** Pflicht (Bestandskunden ohne E-Mail bleiben editierbar);
    die Domain-Prüfung greift aber bei create UND update, falls eine gesetzt wird.
  - Helper `getConfiguredEmailDomains`/`emailUsesDomain` im emailProvider-Service.

- [x] **⚡ Energievertrag: Ankreuzfeld „Keine Bonis erwünscht"** (2026-08-18)
  - `EnergyContractDetails.noBonusDesired` (Boolean, Migration `20260818100000`).
    Checkbox im Vertragsformular (Strom/Gas), Anzeige im Vertragsdetail.

- [x] **🔌 MaLo-ID (Marktlokation) an die Lieferadresse verschoben (Strom/Gas)** (2026-08-14)
  - MaLo-ID gehört zur **(Liefer-)Adresse**, nicht zum Vertrag. Adresse bekommt
    **zwei Felder**: `maloIdElectricity` (Strom) + `maloIdGas` (Gas) – im AddressModal
    (nur Lieferadresse) pflegbar.
  - **Im Vertrag** ist die MaLo-ID jetzt ein **Lesefeld**, das je nach Sparte
    (ELECTRICITY→Strom, GAS→Gas) die MaLo der gewählten Lieferadresse zeigt (mit
    Copy + „dort pflegen"-Link). ContractDetail/-Modal zeigen sie ebenso aus der
    Adresse.
  - **Schema + Migration** `20260814100000_address_malo_ids`: 2 Spalten (idempotent)
    **+ Daten-Migration** (bestehende `EnergyContractDetails.maloId` → jeweilige
    Lieferadresse, Strom→maloIdElectricity / Gas→maloIdGas). Verifiziert.
  - **Nebenbei einen selbst verursachten Regressions-Bug gefixt:** Beim R156-
    Mass-Assignment-Umbau waren die **10 `owner*`-Adressfelder** aus der Address-
    Whitelist gefallen → die Eigentümer-Sektion speicherte seit `cb21a2c` nicht
    mehr. Address-Whitelist jetzt per Pick-Helper **programmatisch gegen alle
    DB-Spalten** abgeglichen (owner* + MaLo drin, id/customerId/Timestamps raus).

- [x] **🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13)
  - Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen
    `...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent
    zur R156-Härtung (BankCard/Address/Document). `id`/`contractNumber`/`createdAt`/
    `updatedAt`/`portalPasswordEncrypted` und alle `cancellation*Path`-Felder sind
    damit **nicht** mehr per Form-Update setzbar (Pfade nur noch über die
    Upload-Endpunkte).
  - **Whitelist autoritativ aus den DB-Spalten** abgeleitet (nicht aus dem
    unvollständigen `ContractCreateData`-Typ!) – dabei fielen 4 echte, vom Formular
    gesendete Felder auf, die NICHT im Typ standen und sonst still gebrochen wären:
    `previousProviderId`, `previousContractNumber`, `previousCustomerNumber`,
    `nextReviewDate`.
  - Verifiziert: legit Felder (inkl. der 4) persistieren; injizierte
    `id`/`contractNumber`/`cancellationLetterPath` werden ignoriert.

- [x] **🗂️ Neuer Vertragsstatus „Gekündigt / bestätigt" + Kündigungs-Workflow** (2026-08-13)
  - Bisheriges **„Gekündigt"** umbenannt in **„Gekündigt / Bestätigung abwarten"**
    (Status `CANCELLED`) – wird jetzt automatisch gesetzt, sobald ein
    **Kündigungsschreiben** (`cancellationLetterPath`) hochgeladen wird
    (aus ACTIVE/PENDING/ONGOING/EXPIRED; nie DRAFT/DEACTIVATED/bereits bestätigt).
  - **Neuer Status „Gekündigt / bestätigt"** (`CANCELLED_CONFIRMED`) – automatisch,
    sobald ein **Kündigungsbestätigungsdatum** vorliegt (per Bestätigungsdokument,
    das das Datum füllt, ODER manuell) + Vertragsende = Kündigungsdatum. Hebt auch
    aus „Bestätigung abwarten" hoch.
  - **Schema:** Enum-Wert `CANCELLED_CONFIRMED`, Migration
    `20260813200000_contract_status_cancelled_confirmed` (idempotentes
    `MODIFY COLUMN`). **Daten-Migration:** bestehende `CANCELLED` (unter alter
    Logik nur bei vorhandener Bestätigung gesetzt) → `CANCELLED_CONFIRMED`.
  - **Cockpit-Semantik mitgewandert:** Fristen-Skip + „beendet" (Schlussrechnung)
    gelten jetzt für `CANCELLED_CONFIRMED` (nicht mehr das reine „abwarten");
    „Kündigungsbestätigung fehlt"-Warnung greift dadurch weiter für CANCELLED.
    `CANCELLED_CONFIRMED` in Ladeliste + Kündigungsbestätigungs-Filter aufgenommen.
  - **Frontend:** Labels/Farben/Status-Erklärungen + Status-Dropdown in
    ContractList, ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
    (CANCELLED = orange „abwarten", CANCELLED_CONFIRMED = rot). Verifiziert:
    Schreiben→CANCELLED, Bestätigung→CANCELLED_CONFIRMED+Enddatum.

- [x] **🔒 Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)** (2026-08-13)
  - Controller reichten rohen `req.body` an Prisma durch → `customerId` (Owner)
    und `id` (PK) waren per Update mutierbar (staff-only, kein Cross-Tenant, aber
    Integritätsschwäche – und mit `cardNumber` liegt Finanz-PII drauf).
  - **Fix:** explizite Feld-Whitelist im Service (create+update) für **BankCard,
    Address, IdentityDocument** – nur benannte Felder gehen an Prisma, kein
    `...data`/`req.body`-Spread mehr. Zusätzlich Controller-`pickBankCardFields`
    für saubere Audit-Logs (keine Phantom-Einträge injizierter Keys).
  - Verifiziert: Update mit `{customerId:99999, id:88888, bogusField, cardNumber}`
    → id+customerId **unverändert**, nur cardNumber gesetzt, Fremdfelder ignoriert.

- [x] **🪪 Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer** (2026-08-13)
  - **Schema:** neues Feld `BankCard.cardNumber` (String?, optional). Migration
    `20260813100000_bank_card_number` (`ADD COLUMN IF NOT EXISTS`), auf Dev
    angewandt + `prisma generate`. Prod zieht via `migrate deploy` im Entrypoint.
    Eingabefeld „Kartennummer" im Bankkarten-Modal (Kundenakte) ergänzt.
  - **Vertragsansicht (ContractDetail)** – Karten „Bankkarte"/„Ausweis" zeigen
    zusätzlich (jeweils mit Copy-Button, nur wenn gesetzt):
    - Bankkarte: BIC, Bank, Kartennummer, Ablaufdatum
    - Ausweis: Behörde, Ausstellung, Ablaufdatum + **Geburtsort/Geburtsdatum
      vom Kunden**
  - **Vertrag bearbeiten (ContractForm)** – unter den Bankkarte-/Ausweis-
    Dropdowns dieselben Detailfelder der aktuell gewählten Karte/Ausweis
    (Copy-Buttons). Selects dafür je in eigenem `<div>` gewrappt (Grid-Alignment).
  - Datenquelle war bereits vorhanden: `getContractById` (bankCard/identityDocument/
    customer) + `getCustomerById` (bankCards/identityDocuments) liefern alle Felder.

- [x] **📄 PDF-Viewer-Modal für Bankkarte-/Ausweis-Dokument** (2026-08-13)
  - Neue wiederverwendbare Komponente `PdfViewerModal` (Modal + `<iframe>` auf
    `viewUrl(documentPath)`, inkl. „In neuem Tab" + „Herunterladen"). Nutzt den
    bestehenden `/api/files/download`-Endpoint (Per-File-Ownership-Check) – kein
    neuer Zugriffspfad. `<iframe>` erlaubt via CSP `default-src 'self'`
    (`object-src 'none'` bleibt, daher iframe statt embed/object).
  - **Vertragsansicht** (ContractDetail): Button (FileText-Icon) im Card-Header
    von „Bankkarte"/„Ausweis" – **nur wenn `documentPath` gesetzt**.
  - **Vertrag bearbeiten** (ContractForm): Button neben dem Label „Bankkarte"/
    „Ausweis" – **nur wenn die aktuell gewählte Karte/Ausweis ein `documentPath`
    hat** (`e.preventDefault/stopPropagation`, da im `<label>`).

- [x] **📇 Vertragslisten: Karteninhaber + Mobilfunknetz + Kündigung (rot)** (2026-08-13)
  - In **beiden** Vertragslisten (Kundenakte-Baum + Hauptmenü `/contracts`): bei
    Mobilfunkverträgen mit Rufnummer zusätzlich **Karteninhaber** (`SimCard.cardUser`
    der angezeigten SIM, nur wenn gesetzt) und **Netz** (`mobileNetwork` →
    Telekom/Vodafone/Telefónica (o2)) inline hinter der Rufnummer.
  - Verträge mit **Kündigungsbestätigung**: `cancellationConfirmationDate` als
    eigene Zeile in **roter, fetter Schrift** („Kündigungsbestätigung zum …").
  - Backend: `getContractTreeForCustomer` + `getAllContracts` liefern jetzt
    `mobileNetwork` + `cardUser` (Tree zusätzlich `cancellationConfirmationDate`;
    Flat-Liste hat Scalars ohnehin via include). Shared-Helper
    `getContractTypeInfo` um `cardUser`/`network` erweitert (+ `mobileNetworkLabel`).
  - **3 Render-Pfade** ergänzt (waren getrennt!): Kundenakte-Baum, die
    Staff-**Tabelle** in `/contracts` (flache `getAll`-Liste) UND der
    Portal-Baum in `/contracts`. Der erste Anlauf hatte nur die Baum-Pfade –
    die Staff-Tabelle (Haupt-Ansicht für Mitarbeiter) fehlte und wurde nachgezogen.
  - **Vertragssuche** (`?search=`) durchsucht jetzt auch den **Karteninhaber**
    (`simCards.cardUser`) – zusätzlich zu Rufnummer/SIM-Nummer/IMEI (Pentest-R150-
    Randnotiz, fachlich gewünscht). Portal-Suche bleibt durch Scoping begrenzt.

- [x] **🛡️ Globaler API-Rate-Limit-Backstop + BLZ-Guard-Härtung (Pentest R148)** (2026-08-12)
  - **Backstop:** Neuer genereller Limiter auf ALLE `/api`-Requests
    (`apiBackstopRateLimiter`, in `index.ts` vor den Routern). Vorher gab es
    KEINEN generellen Limiter – nur Login/Passwort-Reset/Staff-ReAuth/Consent;
    authentifizierte Endpoints waren gegen Enumeration/DoS ungedrosselt.
    Key = **nur IPv6-/56-normalisierte IP** (bewusst nicht IP+User: der
    User-Claim wäre hier nur unverifiziert lesbar → Bypass per Fake-Token).
    Limit per Env `API_RATE_LIMIT_PER_MIN` (Default **1200/min**, Floor 60),
    `/api/health` ausgenommen. Kein SecurityEvent pro Block (sonst Flood-
    Amplification). Verifiziert (60×200→429, health bleibt 200).
  - **Deckt den offenen IPv6-Test mit ab:** außerhalb der Auth-Pfade greift jetzt
    ebenfalls ein IPv6-normalisierter Limiter.
  - **BLZ-Poisoning-Guard:** prüft jetzt JEDEN Dataset-Eintrag (nicht nur die
    erste Zeile) – 8-stellige BLZ, Wert `[Name]` oder `[Name,BIC]`,
    `next.remove` = BLZ-Liste, `next.valid` = gültiges Datum. Banken **ohne BIC**
    (`[Name]`, z.B. BLZ 60050009) korrekt zugelassen → `lookupBlz` liefert `bic:''`.

- [x] **🔄 BLZ-/Bankdaten: Auto-Update via Volume + Einstellungen** (2026-08-12)
  - Neue Einstellungen-Seite **Einstellungen → Bankdaten (BLZ)** (`/settings/bank-data`):
    zeigt Datenstand (aktive Quelle Volume/Image, Version, Anzahl Banken,
    zuletzt geprüft/aktualisiert, neueste verfügbare Version + „Update
    verfügbar"-Badge, letzter Fehler). Button **„Jetzt prüfen & aktualisieren"**
    (+ „Neu laden erzwingen"). Auto-Update-Schalter + **Intervall in Tagen** (1–365).
  - **Echtes Laufzeit-Update** statt Rebuild: Ein Scheduler (täglich 03:30 +
    Catch-up 90s nach Start) prüft, ob laut Intervall fällig, und lädt dann den
    aktuellen Datensatz. Quelle = npm/jsDelivr (`bankdata-germany` current.json +
    next.json), gespeichert im **Bind-Mount-Volume** `BANKDATA_DIR`
    (`./data/bankdata` → `/app/bankdata`, neu in docker-compose + Dockerfile mkdir
    + .env.example).
  - **Lookup-Vorrang:** Volume-Datensatz (aktuell) vor ins Image gebackenen Daten
    (`bankdata-germany`, Fallback). Kombiniert current+next-Delta wie das Paket.
    Kein Fremdcode – nur JSON wird gelesen. Validierung (≥1000 Einträge, Format)
    verhindert, dass Müll/HTML den guten Datenstand überschreibt (atomarer
    Write via tmp+rename).
  - **Datenschutz:** Der Updater sendet KEINE Kundendaten – lädt nur eine
    öffentliche Datendatei. Beim IBAN-Lookup verlässt weiterhin nichts den Server.
    Auto-Update ist abschaltbar; ohne Egress/bei Fehler greift der Builtin-Stand.
  - Endpoints: `GET /api/settings/blz` (settings:read), `POST /api/settings/blz/update-now`
    (settings:update, `?force=1`). Settings-Keys `blzAutoUpdateEnabled` (default true),
    `blzUpdateIntervalDays` (default 30) in Whitelist + validiert.
  - **Damit entfällt die manuelle `npm update`-Wartung** aus dem vorherigen Punkt.

- [x] **🏦 Bankkarte-Modal: IBAN-Prüfung + BIC/Bank-Auto-Ausfüllen** (2026-08-12)
  - Button „BIC & Bank aus IBAN abrufen" unter dem IBAN-Feld (Bankkarte
    hinzufügen/bearbeiten). Füllt BIC + Banknamen automatisch aus und prüft
    dabei die **IBAN-Prüfziffer (mod-97)** → erkennt Tippfehler.
  - **Offline / datenschutzfreundlich:** kein Dritt-Dienst. Nachschlag im
    eigenen Backend über die **Bundesbank-Bankleitzahlendatei**
    (`bankdata-germany`) + `ibantools` für die Prüfziffer. Die IBAN verlässt
    nie den Server; zurück kommen nur öffentliche Bankverzeichnis-Daten.
  - Endpoint `POST /api/bank-cards/iban-lookup` (nur eingeloggt). Antwort:
    `{valid:false}` (Prüfziffer falsch) · `{valid:true, bic, bankName, blz}`
    (DE-Bank gefunden) · `{valid:true, bic:null,…}` (gültig, aber Nicht-DE/
    unbekannte BLZ → Info-Toast).
  - Leeres IBAN-Feld → **OK-Messagebox** „IBAN erforderlich" statt Anfrage.
  - **Wartung:** `bankdata-germany` + `ibantools` müssen ~quartalsweise per
    `npm update` aktualisiert werden (Bundesbank-BLZ ändert sich mehrmals/Jahr).

- [x] **📌 Update-Banner klebt oben fest (sticky)** (2026-08-12)
  - „Neue Version verfügbar"-Banner scrollte weg; jetzt `sticky` an der
    Viewport-Oberkante. Banner meldet seine Höhe als CSS-Var `--app-banner-h`,
    an der die Sticky-Header von ContractDetail/ContractForm einrasten
    (`top-[var(--app-banner-h,0px)]`) → keine Überdeckung.

- [x] **📋 Hauptmenü: Gutschriften/Lieferscheine-Gesamtübersicht** (2026-08-12)
  - Neuer Menüpunkt „Gutschriften" (Sidebar, `show: contracts:read`) → Seite
    `/credit-notes` mit Tabelle aller Belege (Beleg-Nr., Art, Kunde, Vertrag,
    Betrag, Datum, PDF), Suche + Pagination.
  - **Scoping:** neuer Endpoint `GET /credit-notes` (NICHT staff-only wie die
    übrigen Credit-Note-Endpoints): Staff sieht **alle** Belege aller Kunden,
    Portal-Kunden **nur eigene + vertretene** (Vollmacht via `hasAuthorization`).
    `customerIds` kommt aus dem JWT (nicht aus Query/Body → nicht manipulierbar).
    Für Portal wird `receiptPath` aus der Response entfernt (Belege bleiben
    staff-only). Verifiziert (Staff alle, Portal nur eigene).
  - **Fail-closed (Pentest R144):** Ein Portal-Token wird jetzt IMMER gescoped;
    fehlt wider Erwarten die `customerId` (null/0), ergibt das eine **leere
    Menge** statt des Staff-Zweigs. Verifiziert (`customerIds=[]` → 0 Belege).
- [x] **🚫 Gutschrift nur mit Empfängeradresse (Rechnung > Liefer)** (2026-08-12)
  - Beim Klick auf „Gutschrift anlegen" wird geprüft, ob der Vertrag eine
    Empfängeradresse hat: **Rechnungsadresse hat Vorrang, sonst Lieferadresse**.
    Ist **keine** von beiden hinterlegt → **Modal-OK-Meldung** statt Formular.
  - Backend Defense-in-Depth: `createCreditNote` wirft **400**, wenn weder
    `billingAddressId` noch `addressId` gesetzt ist. `getCreditNoteDefaults`
    liefert `hasRecipientAddress` fürs Frontend. Verifiziert.
- [x] **🧾 Gutschrift: Beleg-Upload auch für Sachwerte + kein Unterschriftsblock bei Geld** (2026-08-12)
  - **Beleg-Upload jetzt für beide Arten:** bei Geld die Überweisungsbestätigung,
    bei Sachwert das **unterschriebene Dokument**. `ReceiptControls` in der Liste
    für Geld UND Sachwert (Label „Überweisungsbeleg" bzw. „Unterschr. Dokument").
    Endpoint `/upload/credit-notes/:id/receipt` war schon typ-agnostisch.
  - **PDF-Unterschriftsblock nur noch bei Sachwert:** eine Geld-Überweisung wird
    nicht unterschrieben (Beleg = hochgeladene Überweisungsbestätigung). Bei Geld
    entfällt der Unterschrift/Ort-Block im PDF; im Formular sind „Ort" +
    „Unterschrift am" bei Geld ausgeblendet. Verifiziert (beide PDFs 1 Seite).
- [x] **📦 Lieferschein-Nummernkreis für betragslose Sachwerte (GoBD)** (2026-08-12)
  - Pentester-Hinweis R142: Beim Übergang Geld→betragsloser Sachwert wurde die
    schon vergebene Gutschriftsnummer auf null gesetzt → **Lücke** in der GS-Serie.
  - Lösung (User): betragsloser Sachwert = **Lieferschein** mit **eigener
    Lieferscheinnummer** aus **separatem Nummernkreis** (`DeliveryNoteNumberRange`,
    Default-Präfix „LS-"). `CreditNote.deliveryNoteNumber` (nullbar, unique) +
    Migration.
  - **Gap-frei:** Nummern werden lazy pro Serie vergeben und **nie wieder
    freigegeben**. Übergänge (Geld↔betragsloser Sachwert) behalten die jeweils
    schon vergebene Nummer der anderen Serie reserviert am Beleg → kein
    Doppelverbrauch, keine Lücke. Angezeigt/erzeugt wird via `effectiveNumber`
    je nach aktuellem Typ die passende (LS bzw. GS).
  - Settings-Seite verwaltet jetzt **beide** Nummernkreise (Gutschrift +
    Lieferschein); neue Endpunkte `GET/PUT /credit-notes/delivery-note-number-
    range`. PDF-Titel „Sachwert-Übergabe", Dateiname `lieferschein-…`.
  - Verifiziert: Sachwert 0 → LS-Nr, GS-Zähler unberührt; Geld → GS-Nr;
    Übergänge behalten Nummern (kein Neuverbrauch).
- [x] **🔐 Portal-Passwort: Reveal/Send prüft Konsistenz (kein „falsches" PW)** (2026-08-11)
  - Pentester-Hinweis: Bei einem Kunden waren bcrypt-Hash (Login) und
    verschlüsseltes Reveal-Feld out-of-sync → Support hätte ein Passwort
    vorgelesen, das beim Login scheitert.
  - **Analyse:** Alle aktuellen Schreibpfade sind konsistent (beide Felder
    zusammen ODER encrypted=null ODER Rehash desselben Passworts) – der aktuelle
    Code erzeugt keinen Desync. Ursache war Altlast/manueller DB-Eingriff.
  - **Fix (Belt-and-Suspenders):** `getCustomerPortalPassword` liefert jetzt
    `{status: ok|none|desync}` und prüft den entschlüsselten Klartext per
    `bcrypt.compare` gegen den Login-Hash. Bei `desync` (oder Entschlüsselungs-
    fehler) geben **weder Reveal noch Send-Credentials** das Passwort aus,
    sondern 409 „Dateninkonsistenz – bitte neu setzen". Reveal-Read wird mit
    Status auditiert.
  - **Diagnose-Script** `scripts/check-portal-password-sync.ts`: scannt alle
    Portal-Kunden auf Hash-vs-Encrypted-Desync (nur Diagnose) – für Prod, da
    der Pentester kein FS-Zugriff hat. Verifiziert (desync/ok/none-Fälle).
- [x] **🎁 Gutschrift: Sachwert ohne Betrag = keine Rechnung** (2026-08-09)
  - Eine Sachwert-Gutschrift darf jetzt **betragslos** sein (Betrag leer/0):
    dann findet **keine Rechnungsstellung** statt – der Kunde hat den
    Gegenstand einfach als Subvention erhalten. Beträge bleiben 0, keine USt.
  - Backend: `amount` leer → 0; Sachwert erlaubt 0, **Geld verlangt Betrag > 0**
    (400 sonst). `vatRelevant` wird bei 0 erzwungen-false.
  - PDF: bei betragslosem Sachwert Titel „Sachwert-Übergabe", kein
    Betrags-/USt-Block (Hinweis „keine Rechnungsstellung") und **kein ZUGFeRD-
    Embedding** (es ist keine Rechnung). Mit Betrag → unverändert ZUGFeRD.
  - Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet solange
    kein Betrag (Hinweis); Liste zeigt „Sachwert ohne Betrag (keine Rechnung)".
  - Verifiziert (Sachwert 0 → kein factur-x.xml; Geld 0 → 400; Sachwert 200 →
    ZUGFeRD).
  - **Keine Gutschriftsnummer für betragslose Sachwerte:** `number` ist jetzt
    nullbar (Migration `MODIFY … NULL`, UNIQUE bleibt); ein betragsloser
    Sachwert (Lieferschein) bekommt **keine** Nummer und verbraucht den
    Nummernkreis-Zähler **nicht**. Update-Übergänge: wird betragslos → Nummer
    entfernt; bekommt nachträglich einen Betrag → jetzt Nummer vergeben. PDF/
    Liste/Audit zeigen dann „Sachwert-Übergabe"/„Beleg #id". Verifiziert
    (Zähler bleibt bei Sachwert 0 stehen, springt nur bei echten Gutschriften).

- [x] **🔗 Bug: Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal** (2026-08-07)
  - **Folgevertrag-Bug:** Backend legt den Folgevertrag korrekt mit
    `previousContractId` an – der Verlust passierte im Frontend: das
    „Vorgänger-Vertrag"-Dropdown holte die Verträge über `getAll`, das
    **DEACTIVATED standardmäßig ausblendet**. Beim Bearbeiten des Folgevertrags
    war der (deaktivierte) Vorgänger daher nicht als Option vorhanden → beim
    Speichern ging die Verknüpfung verloren. Fix: `getAllContracts` +
    Controller + `contractApi.getAll` um `includeDeactivated` erweitert; das
    Vorgänger-Dropdown nutzt es (`includeDeactivated: true`) und markiert
    deaktivierte Verträge mit „· deaktiviert". Verifiziert.
  - **Kundendaten-Modal (Schnellansicht):** zeigt jetzt zusätzlich die
    **Lieferadresse** und (falls abweichend) **Rechnungsadresse** des geöffneten
    Vertrags, die **Stressfrei-Adresse des Vertrags** (einzeln) und darunter
    **alle weiteren Stressfrei-Adressen** des Kunden. `CustomerInfoModal` nimmt
    optionale Props (deliveryAddress/billingAddress/contractStressfreiEmailId),
    Aufrufe aus ContractDetail + ContractForm übergeben den Vertragskontext.

- [x] **🚫 Auto-Kündigung + Cockpit-Filter Kündigungsbestätigung** (2026-08-07)
  - **1) Auto-Status bei Kündigungsbestätigung:** Wird zu einem Vertrag eine
    Kündigungsbestätigung hinzugefügt – als **Datum** (`cancellationConfirmationDate`
    übers Formular) und/oder als **Dokument** (Upload) – wird der Vertrag von
    ACTIVE → **CANCELLED** gesetzt und das **Vertragsende = Kündigungsdatum**.
    Zentrale Funktion `maybeCancelOnCancellationConfirmation` (idempotent, nur
    aus ACTIVE, nur echte Änderungen); genutzt vom Upload (ersetzt die alte
    Inline-Logik, jetzt auch endDate) und vom Update-Controller (nur bei
    neu/geändertem Bestätigungsdatum, damit manuelle Status-Korrekturen nicht
    zurückgesetzt werden). Verifiziert.
  - **2) Cockpit-Filter „Kündigungsbestätigung":** listet alle Verträge mit
    hinterlegter Kündigungsbestätigung (Dokument und/oder Datum) in Status
    ACTIVE/DRAFT/CANCELLED/EXPIRED (Abgelaufen nachträglich ergänzt). Eigene Liste `cancellationConfirmations` im
    Cockpit-Result (weil bereits CANCELLED-Verträge mangels „Issue" sonst nicht
    auftauchen) + neue Filter-Option im Cockpit-Dropdown. Verifiziert.
  - **Nachbesserungen Pentest R138:**
    - `endDate` wird bei **DRAFT** nicht mehr gesetzt (Entwurf = Vorlage);
      nur ACTIVE/CANCELLED bekommen Vertragsende = Kündigungsdatum.
    - `cancellationConfirmationDate`/`-OptionsDate` im Update-Pfad über
      `validateOptionalIsoDate` normalisiert → akzeptiert Datum-only UND
      volles ISO (vorher lehnte Prisma Datum-only ab), konsistent zum Upload.
  - **Gutschrift-Dateien:** Überweisungsbelege (`credit-note-receipts`) sind
    jetzt **staff-only** (neuer FileOwner-Typ `contract-staff`, Portal 403);
    das Gutschrift-**PDF** (`credit-notes`) bleibt vertragsbasiert (eigener
    Kunde darf seine Gutschrift laden). Anlegen/Beleg-Upload für Portal war
    bereits per `blockPortal`/Portal-403 gesperrt.
    - **Datei-Cleanup (R138-Hinweis):** Beim Löschen einer Gutschrift werden
      PDF + Beleg von der Platte entfernt; beim Bearbeiten (pdfPath wird
      geleert) wird das alte PDF gelöscht → keine verwaisten Dateien mehr.
    - **Hygiene R140:** Datei-Lösch-Helfer in `utils/fileCleanup.ts`
      konsolidiert (`deleteFileAbsolute` + `deleteUploadByRelativePath`),
      ersetzt die vorher 3× kopierten `deleteFileIfExists`/`cleanupFile`
      (creditNote-, upload-, customer-Service). Reihenfolge umgestellt:
      erst DB-Delete/-Update, DANN Datei löschen (schlägt DB fehl, bleibt
      die Datei). Verifiziert.

- [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) – Phase 1: Backend** (2026-08-06)
  - Use-Case: zu einem Vertrag kann eine Subvention gewährt werden – **Geld**
    (Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro …, Textfeld).
    Betrag, Datum (änderbar), Ort/Unterschrift, bei Sachwert „Ware erhalten",
    bei Geld Überweisungsbeleg (Upload, Phase 2).
  - **USt pro Gutschrift wählbar** (`vatRelevant` + Basis NETTO/BRUTTO +
    `vatRate`) → Netto/USt/Brutto werden berechnet & getrennt gespeichert
    (ZUGFeRD-tauglich). `PRIVAT`/`FIRMA` aus Kunde vorbelegt. Verifiziert:
    nicht-USt 200→200/0/200; NETTO 200→200/38/238; BRUTTO 200→168,07/31,93/200.
  - **Nummernkreis** (`CreditNoteNumberRange`, Einzel-Zeile, in Settings
    verwaltbar): Präfix/Jahr/Separator/Padding/Startzähler/Jahres-Reset.
    Nummernvergabe transaktional mit `SELECT … FOR UPDATE` (keine Doppel-
    vergabe). Beispiel `GS-2026-0001`.
  - Neu: Model `CreditNote` + `CreditNoteNumberRange` + Enums + Migration
    (IF NOT EXISTS, auf Dev angewandt). Service/Controller/Routes:
    `GET/POST /contracts/:id/credit-notes`, `GET .../defaults`,
    `GET/PUT/DELETE /credit-notes/:id`, `GET/PUT /credit-notes/number-range`.
    Portal-Token explizit geblockt (interner Bereich), CREATE/UPDATE/DELETE
    auditiert.
  - **Phase 2a (erledigt):** Kunden-Feld `vatExempt` (Kleinunternehmer §19,
    Checkbox nur für Firmenkunden) + Migration. Gutschrift-Default `vatRelevant`
    wird abgeleitet: Firmenkunde ohne USt-Befreiung → USt-relevant an, sonst
    aus. Jede Gutschrift speichert ihren Snapshot → späterer Statuswechsel
    ändert bestehende Gutschriften nicht. USt-Default „aus Kundentyp ableiten".
  - **Phase 2b (erledigt):** `CreditNotesSection` im Vertragsdetail (eigene
    Section wie Rechnungen – die Vertragsansicht ist kartenbasiert, kein
    echter Tab-Balken; ggf. später umbaubar): Liste + Anlegen/Bearbeiten
    (Modal, Geld/Sachwert, USt-Block mit Live-Netto/USt/Brutto, Datum, Ort,
    Unterschrift, „Ware erhalten"), Überweisungsbeleg-Upload (nur GELD, über
    `/upload/credit-notes/:id/receipt`, fileDownload-Ownership ergänzt).
    Nummernkreis-Verwaltung als Settings-Seite `/settings/credit-note-number-
    range` mit Live-Vorschau. Frontend-Typen + `creditNoteApi`.
  - **Phase 3a (erledigt):** `CompanyProfile` (Firmenstammdaten/Absender) +
    Migration + Settings-Seite `/settings/company-profile`.
  - **Phase 3b Teil 1 (erledigt):** Gutschrift-**PDF** (pdfkit) mit Absender
    aus CompanyProfile, Empfänger, Netto/USt/Brutto, Sachwert/„Ware erhalten"
    bzw. Überweisungs-Bankverbindung, Unterschriftsblock, Fußzeile. Endpoint
    `POST /credit-notes/:id/pdf`, „PDF"-Button in der Section (Ansehen/Erzeugen).
    PDF-Pfad wird bei inhaltlicher Änderung geleert (Neu-Erzeugung). Lokal
    verifiziert (valides PDF).
  - **Auszahlungskonto (nur GELD):** `CreditNote.payoutBankCardId` +
    Migration (FK ON DELETE SET NULL). Dropdown im Formular mit ALLEN
    Bankkonten des Kunden (Default = Vertrags-Abbuchkonto), da die Auszahlung
    auf ein anderes Konto gehen kann. Server prüft, dass die Bankkarte dem
    Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere
    Bankverbindung" + „an Bankkonto: <Kunden-IBAN>". Section-Zeile zeigt das
    Auszahlungskonto.
  - **Phase 3b Teil 2 (erledigt):** ZUGFeRD/Factur-X. `zugferd.service.ts`
    erzeugt CII-XML (EN 16931 `urn:cen.eu:en16931:2017`, Typ **381**;
    Kategorie S bei USt, sonst E + Befreiungsgrund). `zugferdPdf.service.ts`
    bettet als hybrides **PDF/A-3B** ein: `factur-x.xml` (AF /Data), sRGB-
    OutputIntent (pdfkit-ICC ins Repo kopiert), XMP (pdfaid part=3/conf=B +
    Factur-X-Extension-Schema). PDF nutzt **eingebettete DejaVuSans-Fonts**
    (im Repo unter `backend/assets/fonts`, Pflicht für PDF/A). Dockerfile
    kopiert `backend/assets` ins Runtime-Image. Lokal strukturell verifiziert
    (1 Seite, /AF, /Metadata, /OutputIntents, EmbeddedFiles, Font eingebettet,
    XML wohlgeformt, TypeCode 381, GrandTotal korrekt).
  - **⚠️ VOR PROD:** hybrides PDF gegen einen **ZUGFeRD-/Factur-X-Validator**
    prüfen (am besten auf Staging mit echten Firmendaten). Feinheiten
    (Trailer-ID, XMP-Details, MIME `text/xml` vs `application/xml`) ggf.
    nach dem ersten Validator-Lauf nachziehen.
  - **Offen (fachlich):** USt-Einordnung (Vermittlung vs. Abschlussbonus) mit
    Steuerberater klären – Modell/XML decken beide über `vatRelevant` ab.
    ZUGFeRD-Semantik (Seller=Firma, Buyer=Kunde, Typ 381) ggf. anpassen.

- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
  - „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
    Neu-Formular unter `/contracts/new?copyFrom=<id>&customerId=<cid>` mit
    ALLEN Daten des Quellvertrags vorbefüllt. Beim Speichern entsteht ein
    frischer, unabhängiger Vertrag (kein `previousContractId`-Link, kein VVL).
  - Use-Case: neuer Mobilfunk-/o.ä. Vertrag ist meist fast identisch – nur
    Preis, Laufzeit, Kunden-/Vertragsnummer, Anbieter/Tarif ändern sich.
  - Geleert im Kopier-Modus (Integrität / Eindeutigkeit): Status→DRAFT,
    Vorgänger-Link, alle Datumsfelder (Start/Ende/Installation/Kündigungs-
    bestätigung), Kunden-/Vertragsnummer beim Anbieter + Plattform-Nummern.
    **Nicht** übernommen: SIM-Karten & Rufnummern (hardware-/vertrags-
    spezifisch, eindeutige Nummern) – inkl. der Legacy-Scalar-Felder
    `phoneNumber`/`simCardNumber` (Pentest R135/136: die wurden zunächst
    noch kopiert → zwei getrennte Verträge hätten dieselbe Rufnummer tragen
    können; jetzt im Leer-Block). **Behalten** wird der Portal-Zugang
    (Benutzername + Stressfrei-Verknüpfung/„nicht benötigt") – bei gleichem
    Anbieter oft identisch; nur das verschlüsselte Passwort wird nicht ins
    Feld geladen (wie beim Bearbeiten). Rest (Anbieter/Tarif, Preise, alle
    Detailfelder, Notizen) bleibt als Vorlage.
  - Rein Frontend (`ContractForm` copy-Mode + Button in `ContractDetail`);
    kein neuer Endpoint – nutzt bestehendes Create. Blauer Hinweis-Banner
    „Kopie … neuer eigenständiger Vertrag" im Formular.

- [~] **🖱️ PDF-Drag-and-Drop aus Bankkarte/Ausweis — VERWORFEN** (2026-07-30)
  - Idee: hinterlegten Scan per Drag direkt in Mail-Anhang/Datei-Explorer
    ziehen. Über mehrere Iterationen durchgespielt (Chromium-`DownloadURL`
    mit Access-Token → 60s-Download-Token → Blob/`items.add(File)`).
  - **Ergebnis: harte Plattformgrenze.** Ein Browser kann einer fremden
    Desktop-App keine echte lokale Datei zum Anhängen übergeben. URL-Drag →
    Thunderbird speichert nur einen Link (0 Bytes, lädt beim Senden nach →
    Fehler). Datei-Drop in den Nachrichtentext → nur Dateiname als Text.
    Auf die Anhang-Leiste gezogen (Thunderbird/Linux/Chromium) → ebenfalls
    kein echter Anhang. Webmail im Browser → gar keine Datei möglich.
  - **Entscheidung (User):** komplett wieder entfernt. Download- + Anzeigen-
    Button decken den Bedarf zuverlässig ab. Komponente `PdfDragButton`
    gelöscht, `fileUrl()`-Token-Param zurückgebaut. Pentest R131 damit
    gegenstandslos (kein Drag-Feature mehr).
  - **Bleibt erhalten:** die separaten Copy-Buttons für IBAN (Bankkarte) und
    Ausweisnummer im Vertragsformular (s.u.).

- [x] **📋 Vertragsformular: Copy-Buttons für Bankkarte (IBAN) + Ausweis** (2026-07-30)
  - In „Kundendaten verknüpfen" je ein Kopieren-Button neben dem Label,
    kopiert nur den reinen Wert der aktuell gewählten Option: bei Bankkarte
    die **IBAN ohne Namen**, bei Ausweis die **Ausweisnummer ohne (TYP)**.
  - Button erscheint nur, wenn eine Karte/ein Ausweis ausgewählt ist
    (`watch('bankCardId')`/`watch('identityDocumentId')` → Lookup).

- [x] **📧 Stressfrei-Mailbox: Passwort beim Anlegen verbindlich setzen** (2026-07-30)
  - Beim Anlegen einer Adresse mit „Echte Mailbox erstellen (IMAP/SMTP)"
    wird jetzt immer ein frisch generiertes Passwort **explizit am Provider
    gesetzt** (`updateMailboxPassword` direkt nach dem Provisioning).
  - Behebt den Fall, dass die Adresse beim Provider bereits als reine
    Weiterleitung existierte: dann kehrte `provisionEmailWithMailbox` früh
    mit success zurück, ohne je ein Postfach-Passwort zu setzen – im CRM lag
    ein verschlüsseltes Passwort, das der Provider nicht kannte → IMAP/SMTP-
    Login schlug fehl. Jetzt stimmen CRM und Provider garantiert überein.
  - **Nachtrag Pentest R130:** Erster Versuch (`updateMailboxPassword`)
    setzte bei Plesk nur `-passwd`, ließ die Adresse aber auf
    `-mailbox false` → Mailbox nie aktiviert, Login scheiterte weiter.
    Jetzt `enableMailboxForExistingEmail` (`-mailbox true -passwd ...`),
    deckt beide Fälle idempotent ab.
  - Tab-Leiste bei vielen Tabs: `flex-wrap` statt Overflow aus dem Rahmen.
  - Referrals: 4 neue Beziehungen + Bearbeiten-Stift pro Eintrag (s.u.).

- [x] **🤝 Kundenakte: Tab „Geworben / angeworben" (Kundenempfehlungen)**
  - Neuer Tab vor „Datenschutz", **nur Mitarbeiter/Admin** (nicht Portal),
    **ohne** Consent-Pflicht nutzbar. Zwei Abschnitte:
    1. „<Kunde> wurde an Board geholt durch:" – **max. 1** Werber
       (DB-Unique auf `recruitedId`).
    2. „<Kunde> hat folgende Kunden an Board geholt:" – beliebig viele.
  - Jede Zeile: Kunde (per Lupe-Modal gesucht, breite Suche über
    Name/Nr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown (Ehepartner/in,
    Kind, Freund/Kumpel, … „Bitte auswählen!"). Löschen + Externtab-Link
    zur Kundenakte pro Zeile.
  - **Bidirektional aus einem Datensatz:** trägt man in Kunde A „geworben
    durch B" ein, erscheint A automatisch bei B unter „hat geworben".
    Von beiden Akten hinzufügbar/löschbar.
  - Neues Model `CustomerReferral` (recruiter/recruited FKs, `recruitedId`
    @unique, `relationship`) + Migration. Beziehungs-Whitelist server-
    seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token
    explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE
    auditiert.
  - **Nachtrag 2026-07-30:** Beziehungs-Dropdown um Schwiegertochter/
    Schwiegersohn, Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa
    erweitert (Whitelist front- & backend synchron). Bearbeiten-Stift pro
    Zeile (vor der Mülltonne): ändert Beziehung und/oder Gegen-Kunden;
    neuer `PUT /:customerId/referrals/:referralId` (Whitelist, Doppel-
    Werber-409 mit Self-Ausschluss, Portal-Block, UPDATE auditiert).

- [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)**
  - Cache-Header waren schon optimal (index.html `no-store`, Assets
    `immutable`) – aber eine laufende SPA holt sich nach einem Deploy
    nicht von selbst neuen Code. Neuer `useVersionCheck`-Hook holt die
    (immer frische) index.html periodisch + beim Zurückkehren zum Tab,
    vergleicht die Vite-Asset-Hash-Signatur mit dem Startstand. Bei
    Änderung erscheint ein dezentes Banner „Neue Version verfügbar →
    Jetzt neu laden" (`UpdateBanner`, oben im Layout).
  - Kein Backend-/Build-Change nötig; im Dev-Modus automatisch No-op
    (keine `/assets/<hash>`-Dateien → leere Signatur).

- [x] **👁 Kundenportal: Toggle „Deaktivierte Verträge anzeigen"**
  - Der Admin-/Mitarbeiter-Blick (CustomerDetail) hatte den Toggle schon;
    jetzt auch in der Kundenportal-Vertragsübersicht (`ContractList`,
    Portal-Zweig). Button nur bei `isCustomerPortal`, reicht
    `includeDeactivated` an die `getTreeForCustomer`-Queries durch
    (`showDeactivated` im Query-Key → frischer Fetch beim Umschalten).
  - Security: nutzt denselben Endpoint hinter `canAccessCustomer`
    (R120) – Portal-Kunde sieht nur eigene/vertretene Bäume, das Flag
    weitet nur den Status-Filter innerhalb der erlaubten Daten. Keine
    neue Exposition.

- [x] **🔒 Mobilfunknetz: server-seitige Whitelist (Pentest-INFO)**
  - `mobileNetwork` akzeptierte serverseitig jeden (stripHtml-bereinigten)
    String – das Frontend-Dropdown beschränkte nur clientseitig. Jetzt
    Whitelist `TELEKOM|VODAFONE|TELEFONICA` in `contract.service.ts`
    (`normalizeMobileNetwork`), angewandt in Create- UND Update-Pfad
    (Update-Spread wurde vorher ungefiltert durchgereicht). Unbekannte/
    leere Werte → null.

- [x] **🔒 Rate-Limiting: IPv6-Bypass-Härtung (ipKeyGenerator)**
  - Die Rate-Limiter mit eigenem `keyGenerator` (`loginRateLimiter`,
    `staffPasswordReAuthLimiter`) keyten auf die **volle** `req.ip`.
    Bei IPv6 kann ein Angreifer aus seinem zugeteilten Block (/56–/64)
    pro Versuch eine neue Adresse nehmen und so das Per-IP-Limit
    (Login-Bruteforce, Passwort-Set-Reauth) umgehen.
  - Fix: `req.ip` in beiden `keyGenerator` durch `ipKeyGenerator(...)`
    (express-rate-limit v7) ersetzt → IPv6 wird auf das Subnetz
    normalisiert (Library-Default /56), IPv4 unverändert. Verifiziert:
    zwei verschiedene IPv6 im selben /56 ⇒ derselbe Key.
  - Die Limiter OHNE eigenen keyGenerator (Passwort-Reset, Consent)
    machten das schon über den Library-Default – jetzt konsistent.
    (Ersetzt den verworfenen aria-WIP; auf aktuellem Stand neu gebaut.)

- [x] **🐞 Spam-Tab: Anhänge aus Junk-Ordner (Pentest R124-Fund)**
  - Beim Spam-Feature wurden `moveEmailToTrash`/`restoreEmailFromTrash`
    auf den echten Junk-Pfad umgestellt, aber vier Attachment-Funktionen
    im Controller (`downloadAttachment`, `saveAttachmentTo`,
    `saveAttachmentAsInvoice`, `saveAttachmentAsContractDocument`)
    bestimmten den IMAP-Ordner weiter hart als
    `email.folder === 'SENT' ? 'Sent' : 'INBOX'`. Für SPAM-Mails landete
    das fälschlich auf INBOX. Da IMAP-UIDs pro Ordner vergeben sind:
    404 (Anhang nicht gefunden) oder – bei UID-Kollision – der falsche
    Anhang aus INBOX (z.B. als Rechnung/Vertragsdokument abgelegt).
    Kein Cross-Customer-Leak (bleibt im selben Postfach), aber echtes
    Datenintegritätsproblem.
  - Fix: an allen vier Stellen dieselbe Junk-Pfad-Logik wie in
    `moveEmailToTrash` + `findJunkFolderPath` importiert.
  - Randfall (Pentester-Nachtrag): findet sich der Junk-Ordner nicht
    (mehr), wird jetzt ein klarer Fehler zurückgegeben statt still auf
    INBOX/`'Junk'` zu raten – in allen 4 Controller-Funktionen (404
    „Anhang nicht abrufbar") und den 2 Service-Funktionen
    (`moveEmailToTrash`/`restoreEmailFromTrash`). Kein `undefined` mehr
    Richtung IMAP-Lib.

- [x] **📧 E-Mail-Client: Spam-Ordner als eigener Tab**
  - Neuer Tab „Spam" (zwischen Gesendet und Papierkorb) im E-Mail-Client,
    zeigt den Junk-/Spam-Ordner des gewählten Postfachs – damit
    fälschlich als Spam eingestufte Mails auffindbar sind.
  - Backend: `EmailFolder`-Enum um `SPAM` erweitert (Migration
    `20260727110000_email_folder_spam`, Enum-Wert angehängt). IMAP-Sync
    ermittelt den Junk-Ordnerpfad per Special-Use-Flag `\Junk` +
    Namensliste (`findJunkFolderPath`), `syncAllFoldersForAccount` synct
    ihn zusätzlich als `dbFolder=SPAM`. `getCachedEmails` +
    `getFolderCountsForAccount` um SPAM erweitert.
  - Papierkorb-Verschieben/Wiederherstellen für Spam-Mails nutzt den
    tatsächlichen Junk-Pfad als Quell-/Zielordner.
  - Frontend: Tab + Badge (ungelesen/gesamt), nutzt denselben
    List-/Detail-Pfad wie INBOX; Zuordnen-zu-Vertrag auch aus Spam
    möglich (Rettung legitimer Mails).

- [x] **📱 Mobilfunk: Feld „Mobilfunknetz" unter Anbieter & Tarif**
  - Neues Dropdown „Mobilfunknetz" in der Anbieter-&-Tarif-Karte, nur
    sichtbar wenn Vertragstyp = Mobilfunk. Optionen: Bitte auswählen
    (leer), Telekom, Vodafone, Telefónica.
  - Neues Feld `MobileContractDetails.mobileNetwork` (String, nullable,
    speichert TELEKOM/VODAFONE/TELEFONICA) + Migration
    `20260727100000_mobile_network` (`ADD COLUMN IF NOT EXISTS`).
    String statt Enum, damit weitere Netze ohne Migration möglich sind.
  - Anzeige in der Vertragsansicht (Mobilfunk-Details) mit lesbarem
    Netz-Namen.

- [x] **🐞 Lieferbestätigung: eingegebenes Datum ändert Vertragsbeginn nicht**
  - Beim Upload einer Lieferbestätigung mit Datum wurde `startDate` nur
    gesetzt, wenn es noch LEER war (`if (!contract.startDate)`). Hatte
    der Vertrag schon ein (geschätztes) Beginndatum, blieb es trotz
    eingetragenem Lieferdatum stehen.
  - Fix in `maybeActivateOnDeliveryConfirmation`: ein explizit
    eingegebenes Lieferdatum überschreibt den Vertragsbeginn jetzt IMMER
    (die Lieferbestätigung ist das maßgebliche tatsächliche Startdatum).
    Der Fallback „heute" (kein Datum eingegeben) füllt weiterhin nur ein
    leeres Feld, um ein echtes Datum nicht versehentlich zu überschreiben.
    No-op + Audit-Log unverändert. Frontend schickte das Datum bereits
    mit und lädt den Vertrag nach Upload neu – kein FE-Change nötig.

- [x] **🎂 Anstehende Geburtstage auch im Dashboard**
  - Die Geburtstags-Sektion gab es bisher nur im Vertrags-Cockpit.
    Jetzt 1:1 auch auf dem Dashboard (nur Mitarbeiter/Admin,
    `enabled: !isCustomer`), gleiche `birthdayApi.getUpcoming(7, 30)`-
    Query + identische Karten-Darstellung (Heute/vergangen/kommend,
    Link auf die Kundenakte). Kein Backend-Change nötig.

- [x] **🔴 Pentest R121 – Audit-Verify: Fehlalarm „manipuliert" bei leerem resourceId**
  - `POST /api/audit-logs/verify` meldete ~73% der Einträge als
    manipuliert. Kein echtes Tampering, sondern ein Bug in
    `generateHash`: `resourceId` war beim Schreiben oft `undefined`
    (middleware-Einträge ohne Route-ID). `JSON.stringify` LÄSST einen
    undefined-Wert weg → Hash ohne `resourceId`-Key. In der DB landet
    der Wert aber als `NULL`; `verifyIntegrity`/`rehashAll` lasen ihn
    als `null` zurück und `JSON.stringify({resourceId:null})` schrieb
    ihn REIN → anderer Hash → Fehlalarm für JEDE leere-resourceId-Zeile.
    Die vom Pentester gefundene `durationMs`-Korrelation war ein Proxy:
    middleware-Einträge (durationMs gesetzt) haben oft kein resourceId.
  - Fix: `generateHash` lässt nullish `resourceId` weg – reproduziert
    exakt das historische Schreibverhalten. Kein Caller übergibt je
    `null` explizit (verifiziert) → alle Bestands-Hashes matchen ohne
    Rehash. Empirisch gegen echte DB bewiesen: aktuelle Hash-Ära
    (id>4141) 482/482 valide (vorher alle 289 null-Zeilen geflaggt).
  - Separat/vorbestehend: Einträge VOR Commit `fd55742` („complete new
    audit system") nutzen ein altes Hash-Schema und wurden nie neu
    baselined → scheitern unabhängig davon. Remediation: einmalig
    `POST /api/audit-logs/rehash` (Admin). Nicht Teil dieses Fixes.

- [x] **🔧 Pentest R120 – Audit-Log `/:id` mit nicht-numerischer ID → 500 statt 400**
  - Pentester stiess beim Suchen eines `verify-integrity`-Endpoints auf
    einen 500er. Ursache: `GET /api/audit-logs/verify` matcht `GET /:id`
    (das echte Verify ist `POST /verify`), `parseInt("verify")` = NaN →
    Prisma `findUnique({ where:{ id: NaN }})` wirft → 500. Gleiche
    „400 statt 500"-Klasse wie R64.1/R104.1.
  - Fix: `Number.isNaN`-Guard in `getAuditLogById`,
    `getAuditLogsByCustomer` und `updateRetentionPolicy` → sauberer 400.

- [x] **🔴 Pentest R120 – CRITICAL IDOR: Vertragsbaum fremder Kunden lesbar**
  - `GET /api/contracts?tree=true&customerId=<fremd>` returnte für
    Portal-User den vollständigen Vertragsbaum eines beliebigen
    Fremdkunden (Name, Kundennummer, Vertragsnummern, Tarife). Der
    `tree=true`-Zweig im Controller returnte **früh**, bevor die
    Portal-User-`customerIds`-Filterung unten für die flache Liste
    griff. Vorbestehender Bug – der Toggle „Deaktivierte anzeigen"
    (siehe unten) hat ihn nur sichtbarer gemacht, weil selbst
    archivierte Fremdverträge mit auftauchten.
  - Fix: `canAccessCustomer(req, res, customerId)` vor dem frühen
    Return ([contract.controller.ts:80](../backend/src/controllers/contract.controller.ts#L80)).
    Prüft eigene Customer-ID + vertretene MIT Live-Vollmacht, sendet
    selbst die 403. Staff (Nicht-Portal) passiert unverändert. Gleiches
    Muster wie Pentest 56.3 bei update/delete.
  - Zusatzbefund (Pentester): liegen gebliebenes `<script>`-Test-
    Artefakt in `providerName`/`tariffName` eines Staging-Vertrags.
    Nicht exploitierbar – keine der 9 `dangerouslySetInnerHTML`-Stellen
    rendert Vertrags-Provider/Tarif-Namen, React escaped sie als
    Text. Neue Writes werden zusätzlich per `sanitizeContractBody`
    (stripHtml) entschärft; der Altwert stammt aus DB-Direkteingabe.
  - Nachtrag (Retest): Der abgewehrte Zugriff wird nun in ZWEI Streams
    protokolliert. Bisher nur `SecurityEvent` (`ACCESS_DENIED`, sichtbar
    unter `/api/monitoring/events`, aber löschbar + nicht hash-verkettet)
    – der Pentester suchte im AuditLog und fand nichts. `emitAccessDenied`
    schreibt jetzt zusätzlich einen tamper-evidenten AuditLog-Eintrag
    (`action: READ`, `resourceType: 'AccessDenied'`, Sensitivity HIGH)
    für alle `canAccess*`-403. Meine ursprüngliche Formulierung „landet
    im Audit" war die falsche Tabelle – jetzt stimmt sie.

- [x] **👁 Kundenansicht: Toggle „Deaktivierte Verträge anzeigen"**
  - Der Vertragsbaum beim Kunden (`CustomerDetail` → Tab Verträge)
    blendete `DEACTIVATED`-Verträge komplett aus. Da der jeweils
    aktuellste Vertrag die Baumwurzel ist und Vorgänger als Children
    hängen, verschwand eine ganze Kette aus der Ansicht, sobald die
    Wurzel deaktiviert wurde – genau das passierte, als ein aktiver
    Folgevertrag gelöscht und der Vorgänger vorher auf „deaktiviert"
    gesetzt worden war. Der Vertrag „verschwand" scheinbar, war aber
    nur unsichtbar gefiltert.
  - Fix: `getContractTreeForCustomer(customerId, includeDeactivated)`
    mit optionalem Flag (Default false = bisheriges Verhalten). Query-
    Param `includeDeactivated=true`, Toggle-Button im Kunden-Vertragstab
    (`Eye`/`EyeOff`). `showDeactivated` ist Teil des Query-Keys →
    frischer Fetch beim Umschalten. Deaktivierte Verträge tragen ihr
    graues `DEACTIVATED`-Badge, bleiben also klar erkennbar.

- [x] **🐞 Vertrag-Löschen: Kette unterbrach, Liste aktualisierte nicht**
  - Zwei zusammenspielende Bugs: (1) im Service scheiterte das Umbiegen
    des Folgevertrags an der `@unique`-Regel auf `Contract.previousContractId`
    – wenn A → B → C stand und B gelöscht wurde, hielt B im UPDATE-Moment
    noch selbst `previousContractId=A`, C sollte auch auf A → Duplicate-
    Entry, das Update brach ab. (2) Der Frontend-`deleteMutation` in
    `ContractDetail` invalidierte die Contract-Listen-Query nicht,
    navigierte nur nach `/contracts` und zeigte den alten Cache.
    Zusammen sah es aus, als wäre die ganze Historie weg – tatsächlich
    stand der Vorgänger noch im Backend.
  - Fix Service: in einer Transaktion erst B löschen (DB-Cascade räumt
    C.previousContractId auf NULL und gibt den A-Slot frei), dann C
    sauber auf A umbiegen. Kein Zwischenzustand mehr sichtbar.
  - Fix Frontend: `queryClient.invalidateQueries(['contracts'])`
    plus `['contract-tree', customerId]` und `['customer', customerId]`
    nach dem Delete. `onError`-Toast ergänzt, damit fehlgeschlagene
    Löschungen nicht mehr still verschwinden.

- [x] **📝 DSGVO-Audit: Portaldaten-Opt-out als eigenes CRITICAL-Event**
  - Auf Wunsch des Pentesters (R117-Nachtrag): das Umschalten des
    `portalCredentialsNotRequired`-Flags emittiert jetzt zusätzlich zum
    generischen Contract-Update-Diff ein dediziertes `UPDATE`-Event
    unter `resourceType: 'ContractPassword'` — landet damit in derselben
    CRITICAL-Sensitivity-Reihe wie Klartext-Password-Reads.
  - Motivation: das Setzen des Flags räumt server-seitig
    `portalUsername`/`portalPasswordEncrypted`/`stressfreiEmailId` auf
    `NULL`. Diese Löschung sensitiver Daten ist jetzt unabhängig vom
    generischen Diff nachweisbar. Rücknahme wird ebenfalls geloggt.
  - `details` enthält alte + neue Flag-Werte plus Bool-Marker, welche
    Anmeldedaten vor dem Opt-out belegt waren (keine Klartext-Leckage).

- [x] **🔕 Vertrag: „Portaldaten nicht möglich / nicht benötigt"-Opt-out**
  - Dritter Radio-Button in der `Zugangsdaten`-Card des Vertragsformulars:
    „Nicht möglich zu bekommen / nicht benötigt". Wenn gesetzt, unterdrückt
    das Cockpit die Warnung „Portal-Zugangsdaten fehlen" für diesen
    Vertrag (Anbieter hat kein Portal / Kunde bekommt keine Zugangsdaten).
  - Neues Feld `Contract.portalCredentialsNotRequired` (Boolean, default
    false) + Migration `20260717100000_contract_portal_credentials_not_required`
    (`ADD COLUMN IF NOT EXISTS`). Bestand bleibt unverändert warnend.
  - Passwort-Spalte im Formular wird bei Opt-out ausgeblendet; alte
    Username-/Passwort-Werte werden beim Umschalten geleert, damit der
    Vertrag nach dem Speichern nicht mit Leichen zurückkommt.

- [x] **🔧 Debug-Hilfe: Fehler bei „PIN/PUK anzeigen" sichtbar machen**
  - Der stumme `alert('PIN/PUK konnte nicht geladen werden')` maskierte
    die eigentliche Ursache. Ersetzt durch Toast mit Backend-Message
    (Vertrag nicht gefunden / Kein Zugriff / Decrypt-Fehler),
    `console.error` mit Stacktrace und einem Info-Toast, falls die SIM
    in der DB gar keine PIN/PUK hinterlegt hat. Kein Verhaltens-Change am
    Endpoint – nur Diagnose.

- [x] **🔗 Vertrag-Zugangsdaten: Externtab-Link zu Kunden-Adressen**
  - Kleines Externtab-Icon neben dem Label „Portal Benutzername", öffnet
    `/customers/{id}?tab=stressfrei` in neuem Tab, damit man beim Anlegen
    einer Portal-Wechseladresse nicht durch die Vertragsansicht zurück
    navigieren muss. Sichtbar in beiden Modi.
  - Als R116 vom Pentester geprüft, kein Finding (SPA-Parameter, Backend
    wertet `?tab=` nicht aus; Sub-Route-IDOR-Check auf `canAccessCustomer`
    steht).

- [x] **🔧 Pentest R111 – contract-categories: falscher Permission-String**
  - Der Pentester meldete 403 auf `POST/PUT/DELETE /api/contract-categories/*`
    für Admin trotz vorhandener `contract-categories:*`-Permissions.
    Vermutete Regression aus R110, war aber schon seit dem ersten
    Commit so – die Route verlangte `developer:access`, während der
    Permission-Seed den Admins `contract-categories:*` gibt.
  - Fix: Route auf `contract-categories:create/update/delete`
    umgestellt, konsistent mit den sechs Peer-Katalog-Endpunkten
    (platforms/tariffs/cancellation-periods/…). Kommentar aktualisiert,
    damit die historische Design-Restriktion nicht wieder eingebaut wird.

- [x] **🔒 Pentest R110 – Mass-Assignment-Whitelist auf 7 Update-Endpunkten**
  - MEDIUM-Finding: `PUT /api/stressfrei-emails/:id` und 6 weitere Update-
    Endpunkte (`platform`, `tariff`, `contractCategory`,
    `cancellationPeriod`, `contractDuration`, `email-providers`) reichten
    `req.body` ungefiltert an Prisma – gleiche Bug-Klasse wie M1
    (Settings Mass Assignment). Nachgewiesen war es via
    `provisionError`-Feld ausserhalb des TS-Types.
  - Fix: sieben Whitelists + `pickXxxUpdate()`-Helper in `sanitize.ts`,
    in den jeweiligen Controllern eingehängt. Nur die vom Service-
    Interface deklarierten Felder passieren.
  - EmailProvider: bewusst ohne `stripHtmlFromStrings`, weil das
    Passwörter/API-Keys mit Sonderzeichen mutiliert hätte.

- [x] **🐞 Kündigungsdatum: Cursor sprang beim Tippen aus dem Feld**
  - `<input type=date>` feuerte `onChange` bei jedem Tastendruck; sobald
    z.B. `18.08.0002` ein gültiges Datum ergab, feuerte die PUT-Mutation,
    Server-Antwort re-renderte den Input, Cursor sprang raus, Seite
    scrollte, weiteres Tippen landete im nächsten Feld.
  - Fix: neue `AutosaveDateInput`-Komponente hält den Wert während der
    Eingabe lokal, feuert `onSave` erst bei Blur/Enter. Escape verwirft.
    Angewendet auf beide Kündigungsbestätigungs-Datumsfelder in
    ContractDetail. Andere App-Datumsfelder laufen über react-hook-form
    (batched Submit) und sind nicht betroffen.

- [x] **🐞 IMAP-Fehler: „Command failed" ohne Kontext**
  - `imapflow` wirft bei jedem IMAP-NO/BAD-Response nur
    `new Error('Command failed')` und legt Details in `.responseText`/
    `.responseStatus` ab. Wir haben sie nirgends gelesen → User sah
    im Sync-Toast und im Anhang-Download nur "Command failed" ohne
    Ursache. Realer Fall: abgelaufenes Postfach-Passwort.
  - Fix: zentraler `humanizeImapError()`-Helper in `imapService.ts`.
    Extrahiert responseText/responseStatus, erkennt Auth-Fehler,
    UID-Stale, TLS-/Netzwerk-Fehler und gibt jeweils eine klare
    Meldung mit Handlungsanweisung zurück. Angewendet auf
    `fetchAttachmentInner`, `downloadAttachment`-Controller und
    `syncEmailsForAccount`.

- [x] **🆕 Bankkarte-Beschreibung in Vertragsansicht + Auswahl**
  - Nach dem Anlegen des `description`-Feldes (Bankkarten-Modal) wird
    die Beschreibung jetzt auch in der Vertragsansicht (Bankkarten-Card)
    und im ContractForm-Select angezeigt. Im Select mit
    Whitespace-Flatten und 60-Zeichen-Cap, damit lange Notizen den
    Select nicht sprengen.

- [x] **🔒 Pentest R104.1 – Salutation-Endpunkte: 404 statt 500**
  - INFO-Finding: `GET/PUT /api/customers/:id/salutation-preference`
    warfen bei nicht-existierendem Customer 500 statt 404.
  - Ursache: `canAccessCustomer` prüft für Staff-User nur Portal-Flag,
    kein Existenz-Check. Service warf `new Error(...)`, Controller-
    Catch mappte generisch auf 500.
  - Fix: neuer Helper `assertCustomerExists()` im Service wirft
    `ApiError(404, ...)`. Die drei Controller-Catches respektieren
    jetzt `ApiError.statusCode`. Auch `set`/`clear` prüfen Existenz
    (sonst käme ein Prisma-P2003 als 500 durch).

- [x] **🔒 Pentest R102 – Interne Vertragsnummer nachziehen**
  - INFO-Finding: `contractNumber` (interne CRM-Nummer) hatte im
    Update-Path keine Whitelist. `<script>alert(1)</script>` →
    `stripHtml` → leerer String → Vertragsnummer silent überschrieben.
  - Fix: `contractNumber` zu `CONTRACT_IDENTIFIER_FIELDS` hinzugefügt –
    bestehende R86-Whitelist (`^[A-Za-z0-9_\-/. ]{0,100}$`) greift
    automatisch, R87-Fix (Raw-Input vor stripHtml) ist auch drin.
    Legitime `GAS-MPVJ4P10FE8` bleibt OK, alle Angriffs-Payloads → 400.
  - Doku in `SECURITY-HARDENING.md § Runde 102`.

- [x] **🔧 Pentest R101.1 – Inline-Preview-Pfad refaktoriert + Diagnose-Log**
  - Pentester R101.1 (INFO/funktional) berichtet: `?disposition=inline`
    bewirkt nichts, Browser zeigt Download-Dialog. Die Logik im
    `fileDownload.controller` ist eigentlich korrekt – sauberer Magic-
    Byte-Check für PDF/PNG/JPEG/GIF/WebP – und liefert beim Direkttest
    gegen echte Vertrags-PDFs `application/pdf`. Wir können das in Prod
    aber nicht reproduzieren.
  - Refaktorierung: Magic-Byte-Check in `detectSafeContentType()`
    extrahiert, finally-Block schließt File-Descriptor garantiert,
    Short-Read-Fälle (`bytesRead < n`) jetzt sauber geguardet.
  - Sicherheits-Verhalten unverändert: bei Magic-Byte-Mismatch bleibt
    es bei `Content-Disposition: attachment` (Stored-XSS-Schutz aus
    R30.13).
  - Neu: `console.warn`, wenn `inline` angefragt wurde, aber der
    Magic-Byte-Check fehlschlägt oder der Read crasht. Damit fällt
    der Fall im Prod-Log auf, falls er nochmal auftritt – bisher
    war's silent.

- [x] **🔒 Pentest R97 – Attachment-Validierung im Send-Handler**
  - R97.1 (LOW): malformed `content` (`null`, fehlend, `true`, `""`)
    erzeugte 200/500 mit rohem `Buffer.from()`-Fehlertext in der
    Response. `content: ""` ließ sogar eine Mail mit 0-Byte-Anhang
    durchgehen.
  - R97.2 (INFO): keine App-Level-Caps (Größe + Anzahl) – die im
    Frontend dokumentierten 10 MB/25 MB/Datei-Limits hingen am
    bodyParser; falls der je hochgedreht wird, fällt die Sicherung.
  - Fix: `validateAttachments()` im Controller `sendEmailFromAccount`
    läuft **vor** dem `sendEmail`-Aufruf:
    - `attachments` muss Array oder undefined sein
    - max 25 Anhänge
    - jeder: `filename` non-empty String, `content` non-empty Base64-
      String (Regex), optional `contentType` String
    - max 10 MB/Datei, 25 MB gesamt (Schätzung via base64-Länge × 0.75,
      kein Buffer.from-Aufruf während der Validierung)
  - Bei Verstoß harte 400 mit klarer Meldung. Sanity-Test: 18/18 Cases
    grün inkl. aller R97.1-Pentest-Payloads.

- [x] **🆕 E-Mail-Compose: Vertragsdokumente anhängen + Kundendaten einfügen**
  - Im Compose-Modal (nur wenn Vertrag-Kontext) zwei neue Buttons neben
    "Datei anhängen":
    - **Vertragsdokumente**: listet alle am Vertrag gespeicherten Dokumente
      gruppiert nach `documentType`. Auswahl → Server-Download via
      `fileUrl` (Token-Auth, Per-File-Ownership-Check greift) → base64 →
      direkt in die Anhang-Liste. Respektiert das 25-MB-Gesamtlimit.
    - **Kundendaten einfügen**: zeigt nur Sections die tatsächlich Daten
      haben (Customer, Lieferadresse, ggf. Rechnungsadresse, Vertrag,
      Bank, Ausweis). Pro Section Checkbox + Preview. Bei Bank +
      Ausweis zusätzlich Sub-Checkbox "als PDF anhängen", wenn ein
      `documentPath` vorhanden ist.
  - Beim Bestätigen werden die Text-Blöcke an das Body-Ende gehängt
    (mit `\n\n`-Separator), Anhänge per `serverFileToAttachment` aus
    `composeAttachmentHelpers.ts` gezogen. Anhang-Limit (25 MB gesamt)
    wird beidseitig geprüft, drüberlaufende Dateien werden mit Toast
    übersprungen statt silent weggeschluckt.
  - Helpers (`composeAttachmentHelpers.ts`):
    - `serverFileToAttachment(path, filename)` – fetch via Token-URL
      → Blob → base64 → `EmailAttachment`.
    - `totalAttachmentBytes` – Größen-Check unter Berücksichtigung der
      ~33 % base64-Overhead.
    - `bankCardAttachmentName` / `identityDocAttachmentName` –
      sprechende Dateinamen für den Empfänger.

- [x] **🔒 Pentest R95 – Portal-Username (Manual-Modus) härten**
  - R95.1 (MEDIUM): `foo\r\nBcc:evil@x.de` → Header-Injection-Vektor
    sobald der Wert in Mail-Templates / PDF-Footer landet.
  - R95.3 (LOW): `<script>…</script>@x.de` → silent stripHtml-Mutation
    (R87.1-Pattern, dritter Treffer auf demselben Bug).
  - R95.4 (LOW): >190 Zeichen → VARCHAR-Overflow → 500 statt 400.
  - Fix: `validatePortalUsername()` in `sanitize.ts` mit Whitelist
    `^[A-Za-z0-9_\-/.@+ ]{0,100}$`. Strukturell sind CRLF, Tab, alle
    Control-Chars, Tags und Quotes raus → R95.1+R95.3 ohne extra
    Check. Max 100 → `ApiError(400)` → R95.4 sauber. Raw-Input direkt
    validiert (R87-Pattern). Eingehängt in `sanitizeContractBody`.
  - Frontend: `maxLength={100}` am Input.
  - **R95.2 bewusst nicht übernommen** (Email-Format-Pflicht): das
    Feld ist im Manual-Modus nicht zwingend eine E-Mail – Vodafone,
    1&1, EWE und Stadtwerke nutzen Kundennummern oder Pseudonyme als
    Portal-Login. Doku in `SECURITY-HARDENING.md § Runde 95`.

- [x] **🔒 Pentest R93 – Leerer String != fehlender Param**
  - R93.1 (INFO): `?accountId=` (explizit-leer) wurde wie `?accountId`
    weggelassen behandelt → 200 statt 400 auf optionalen Endpunkten.
    Pentester-Spec: leerer String ist KEINE gültige Zahl.
  - Fix im `parsePositiveIntQuery()`-Helper: striktere Absent-Logik
    (`v === undefined` ist absent; `''`, `'  '`, alles andere muss
    parsen). Required + optional Modes unverändert.
  - Float-Grenzfall (`accountId=5.5` → 5 via `parseInt`) bleibt als
    by-design akzeptiert (Pentester-Bestätigung, kein Security-Impact).

- [x] **🔒 Pentest R92 – Strict-400 für accountId auf Vertrags-Endpunkten**
  - R91-Fix war silent-undefined bei invaliden Werten: `accountId=abc`
    auf `GET /contracts/:id/emails` ergab "kein Filter" → Mailbox-
    Isolation brach (alle Postfächer sichtbar). Pentester R92: per
    Design sind Vertrags-Endpunkte immer pro Postfach, also strict-400.
  - Fix: `parsePositiveIntQuery(v, label, res, { required? })`
    ersetzt den alten silent-Helper. Modes:
    - default (optional): fehlend/leer → `undefined` (kein Filter),
      invalid → 400
    - `{ required: true }`: fehlend/leer **oder** invalid → 400
  - Verteilung:
    - Contract-Emails, Contract-Folder-Counts: `{ required: true }`
    - Customer-Emails, Trash, Trash-Count: optional (Cross-Mailbox-
      View ist legitim), invalid → 400
  - Frontend hat schon ein `enabled: !!selectedAccountId`-Guard auf
    den Vertrags-Queries – kein UX-Bruch.

- [x] **🔒 Pentest R91 – NaN-Bypass auf accountId-Query-Param**
  - R91.1 (LOW): `accountId=abc` → `parseInt('abc')` = `NaN` → der
    Ternary im Controller gab `NaN` an den Service, `if (NaN)` ist
    falsy → der Postfach-Filter fiel weg. Folge: ein Portal-User mit
    ungültigem `accountId` sah alle Mailbox-Mails für seinen Vertrag
    statt nur die aus dem gewählten Postfach (kein Cross-Customer-
    Leak — `canAccessContract` greift weiter).
  - Fix: zentraler `parsePositiveIntParam()` im `cachedEmail.controller.ts`,
    der nur positive Ganzzahlen aus dem Query-String akzeptiert und
    alles andere zu `undefined` macht. Eingesetzt in allen 5
    Endpunkten, die `accountId`/`contractId` aus Query nehmen
    (Contract-Emails, Contract-Folder-Counts, Customer-Emails,
    Trash, Trash-Count) – auch da, wo der Pentester nicht getestet
    hat, weil derselbe Pattern überall stand.

- [x] **🐞 E-Mail-Ansicht: Postfach-Filter griff in Trash/Sent nicht**
  - Bug-Bericht 2026-06-21: im Vertrags-Tab (Gesendet/Gelöscht) und im
    Kunden-Haupt-Postfach (Gelöscht) wurden E-Mails aus ALLEN Postfächern
    des Kunden angezeigt, egal welches Postfach im Selector aktiv war.
    Im Vertrag fehlte zusätzlich der Vertrags-Filter für den Papierkorb.
  - Backend:
    - `getEmailsForContract` controller akzeptiert jetzt `accountId`-
      Query-Param und reicht ihn als `stressfreiEmailId` an
      `getCachedEmails` weiter (der hat den Filter eh schon implementiert,
      nur niemand hat ihn aufgerufen).
    - `getTrashEmails` (controller + service) akzeptiert `accountId` und
      `contractId` als optionale Filter. Default-Verhalten unverändert,
      wenn keiner gesetzt ist.
    - `getFolderCountsForContract` akzeptiert optional `stressfreiEmailId`,
      bekommt zusätzlich `trash` + `trashUnread` ins Result – sonst läge
      der Trash-Badge im Vertrag wieder account-global, während die Liste
      contract-scoped ist.
  - Frontend:
    - `cachedEmailApi.getForContract` / `getTrash` / `getContractFolderCounts`
      nehmen den Filter entgegen.
    - `ContractEmailsSection` reicht `selectedAccountId` in alle drei
      Queries durch und nimmt es in den queryKey mit auf – sonst greift
      der React-Query-Cache beim Postfach-Wechsel nicht. Der Trash-Badge
      kommt jetzt aus den contract-scoped Counts, damit Badge und Liste
      synchron laufen.
    - `EmailClientTab` reicht `selectedAccountId` in die Trash-Query
      durch (Inbox/Sent waren schon korrekt).

- [x] **🔒 Pentest R89 – Provider-Adressfelder härten**
  - R89.1 (MEDIUM): `sanitizeNotes(…, 500)` macht silent `slice(0, 500)`
    statt 400 – 501+ Zeichen wurden auf 500 abgeschnitten und mit
    200 OK gespeichert.
  - R89.2 (LOW): `stripHtml` lief vor dem Length-Check – `<script>…</script>`
    reduzierte auf leeren String → `null` in der DB → vorheriger Wert
    silent überschrieben (R87.1-Pattern auf Adress-Feldern).
  - Fix: eigener `validateProviderAddress()` in `sanitize.ts`. Raw-Input,
    max 500 → `ApiError(400)`, Blacklist `<`, `>`, Tab, alle Control-
    Chars außer `\n`. CRLF → LF normalisiert vor Length-Check.
    Eingehängt in `stripProviderStrings`.
  - R89.3 (Quotes) + R89.4 (`\n`): bewusst nicht gefixt – Pentester
    bestätigt "kein unmittelbares Risiko", React escaped korrekt,
    sind legitime Bestandteile mehrzeiliger Postadressen.
  - Doku in `SECURITY-HARDENING.md § Runde 89`.

- [x] **🆕 Anbieter: Kontakt + Kündigung als Stammdaten**
  - Sieben neue optionale Felder am `Provider`-Modell: `contactEmail`,
    `contactPhone`, `contactFax`, `contactAddress`, `cancellationEmail`,
    `cancellationFax`, `cancellationAddress`. Postadressen als `TEXT`
    (mehrzeilig), Rest `VARCHAR(191)`. Migration
    `20260621100000_provider_contact_and_cancellation` mit `IF NOT EXISTS`.
  - Modal „Anbieter bearbeiten" bekommt eine neue Sektion **Kontakt &
    Kündigung** unterhalb der Auto-Login-Felder, getrennt in zwei
    Untergruppen (Kontakt / Kündigung) mit kleinen Headern.
    Email-/Telefon-/Fax-Felder als Single-Line-Inputs, Postadressen
    als `<textarea rows={3}>` mit `maxLength={500}`.
  - Backend-Validierung: contactEmail/cancellationEmail laufen durch
    `isValidEmail` (Header-Injection-Schutz für Mail-Templates),
    contactPhone/contactFax/cancellationFax durch `sanitizePhoneField`
    (kein CRLF/Control-Char), Postadressen durch `sanitizeNotes` mit
    500-Cap.
  - Factory-Defaults Export/Import mitgezogen, sonst gingen die neuen
    Felder beim Backup/Restore verloren.

- [x] **🔒 Pentest R87 – Whitelist vor Sanitizer (silent-mutation-Schutz)**
  - R87.1 (LOW): `stripHtml` lief im R86-Fix VOR der Whitelist.
    Tags wurden still weggestrippt → 200 OK mit mutierten Werten,
    `<script>…</script>` reduzierte auf leeren String → `null` in
    der DB → vorheriger Wert ohne Fehlermeldung überschrieben.
  - Fix: Validierungs-Reihenfolge für die fünf Identifier-Felder
    umgedreht – `validateContractIdentifier` läuft jetzt direkt
    gegen den Raw-Input. Die strikte Whitelist lehnt eh alles
    ab, was stripHtml normalerweise auffangen würde (Tags,
    Schemes, Zero-Width, Homoglyphe, Percent-Encoding) – Defense-
    in-Depth bleibt, nur ehrlich (400 statt silent-200).
  - Single-Line-Patch in `contract.controller.ts`, Doku in
    `SECURITY-HARDENING.md § Runde 87`.

- [x] **🔒 Pentest R86 – Vertrags-Identifier härten**
  - R86.1 (LOW): >999-Zeichen-Strings auf Kunden-/Vertrags-/
    Auftragsnummer warfen 500 (DB-Overflow `VARCHAR(191)`) statt 400.
  - R86.2 (LOW/INFO): Attribut-Injection ohne umschließenden Tag
    (`foo" onerror=…`) überlebte `stripHtml` – kein Risiko in der React-
    UI, aber relevant für PDF/Mail/CSV-Export.
  - Fix: zentraler `validateContractIdentifier()` in `sanitize.ts`
    mit Max-100 und Whitelist `^[A-Za-z0-9_\-/. ]{0,100}$`. Bewusst
    literales Space statt `\s`, damit kein CRLF/Tab passiert (Header-
    Injection). Wirft `ApiError(400, …)` mit klarer Meldung.
  - Eingehängt in `sanitizeContractBody` → läuft automatisch für alle
    fünf Identifier-Felder bei Create/Update. ContractForm bekommt
    `maxLength={100}` als UX-Schicht. Doku in
    `docs/SECURITY-HARDENING.md` § Runde 86.

- [x] **🆕 Vertrag: Auftragsnummer bei Vertriebsplattform**
  - Neues optionales Feld `Contract.orderNumberAtSalesPlatform`
    (`VARCHAR(191) NULL`), Migration
    `20260619100000_contract_order_number_at_sales_platform` mit
    `IF NOT EXISTS`.
  - Im ContractForm direkt **vor** der Kundennummer der
    Vertriebsplattform angeordnet (Wunsch des Users).
    ContractDetail zeigt sie als eigene Zeile mit Copy-Button vor
    den anderen beiden Sales-Platform-Feldern.
  - Audit-Log-Mapping, Renewal-Copy (VVL-Folgevertrag) und
    XSS-Strip-Allowlist (`CONTRACT_DISPLAY_STRING_FIELDS`)
    mitgezogen, damit das neue Feld die gleichen Garantien wie
    Kunden-/Vertragsnummer bekommt.

- [x] **🐞 Entfernte Weiterleitungen kamen via Auto-Import zurück**
  - Folge-Bug: User löscht Adresse im Modal → DB-Liste wird kürzer →
    Plesk-Sync läuft → Auto-Import (`Pentest 83.x`) sieht „c ist in
    Plesk aber nicht in DB" → schreibt `c` zurück in
    `additionalForwardingEmails` → Diff sagt nichts zu entfernen.
  - Ursache: Auto-Import war für **alle** Sync-Aufrufe aktiv. Beim
    Sync-Button-Klick will der User Plesk-Bestand übernehmen (Import
    sinnvoll), beim Add/Remove im Modal ist die DB-Liste die
    explizite Intent (Import schädlich).
  - Fix: `syncForwardingForEmail(id, { autoImportPleskMembers? })`
    mit Default `true`. `setAdditionalForwards` ruft mit
    `false` auf → entfernte Adressen verschwinden jetzt sauber bei
    Plesk. Sync-Button-Pfad bleibt unverändert (importiert weiterhin
    alte Bestands-Members).

- [x] **🐞 Plesk-Sync: `-forwarding-addresses set:` existiert gar nicht**
  - Folge-Bug nach `a83358b`/`24e152b`: Sync verändert Plesk weiterhin
    nicht. `plesk bin mail --help` zeigt: `-forwarding-addresses`
    akzeptiert ausschließlich `add:` und `del:` – unser `set:` wurde
    von Plesk silent verworfen. Außerdem gibt es keine separate
    `-mailgroup`-Option; was Plesk im `--info` als `Mailgroup: true`
    zeigt, ist genau das, was `-forwarding true` in der CLI setzt
    (doppelt benannt). Mein vorheriges `-mailgroup false` lief auf
    den Phantom-Parameter und triggerte `Unrecognized option`.
  - `updateForwardTargets` baut jetzt den Diff: aktuelle Mailgroup-
    Members (aus `emailExists`) gegen Soll-Liste; `del:<entfernt>` +
    `add:<neu>` in zwei separaten CLI-Calls. Idempotent.
    Case-insensitive – `Bruns.Gerhard` ≡ `bruns.gerhard`.
  - Phantom-`-mailgroup`-Parameter entfernt.
  - Smoke-Test gegen Prod-Stand (3 Bestands-Members + 1 neuer Eintrag):
    nichts entfernt, nur `bzirks@gmx.de` hinzugefügt.

- [x] **🔒 Pentest 83.1-83.3: Auto-Import-Pfad härten**
  - **83.1 MEDIUM:** Auto-Import in `syncForwardingForEmail` umging
    `assertValidForwardingEmail`. Plesk-Member wie `attacker@plesk.internal`
    oder `evil@x.local` wären ohne TLD-Block-Check (71.1) in unsere
    DB gewandert. Fix: jeder importierte Member läuft durch
    `assertValidForwardingEmail`; ungültige werden silent gedroppt
    und auf `console.debug`-Level geloggt.
  - **83.2 LOW:** Self-Forward-Schutz (81.1) lief nur im Add-Pfad.
    Wenn Plesk die eigene Adresse als Mailgroup-Member führte, wäre
    sie beim Auto-Import in die DB-Liste gerutscht → nach dem
    Umschalten auf Forwarding Mail-Loop. Fix:
    `seenKeys.add(canonicalEmailKey(stressfreiEmail.email))` vor
    der Import-Schleife.
  - **83.3 INFO:** PII-Log auf `console.debug` umgestellt (statt
    `console.log` auf Default-Level).
  - Smoke-Test mit gemischter Plesk-Liste: `karibik61@web.de` (legit)
    importiert, `attacker@plesk.internal` + `evil@x.local` per 83.1
    abgelehnt, exakte Self-Mail + Plus-Tag-Variante per 83.2
    abgelehnt, Customer-Stamm-Mail + Default deduped.

- [x] **🐞 Plesk-Sync: Legacy-Mailgroup-Adressen synchronisierten nicht**
  - Prod-Bug: User trägt zusätzliche Weiterleitung ein, Toast meldet
    Erfolg, aber Plesk übernimmt nichts. Ursache: Plesk hat zwei
    Verteil-Mechanismen, **Mailgroup** (alte CLI-Anlagen,
    `Group member(s):`) und **Forwarding** (`Forward request:`). Unser
    Sync schrieb nur in Forwarding, die Adresse lief aber via Mailgroup
    → unsere `set:`-Befehle landeten in einer ungenutzten Tabelle.
    Stage funktionierte, weil dort die Adressen frisch vom CRM angelegt
    wurden (Forwarding-Modus von Anfang an).
  - `EmailExistsResult` um `mailgroupActive` + `mailgroupMembers` +
    `forwardingActive` + `forwardingTargets` erweitert.
  - `pleskProvider.emailExists` parst alle vier Felder aus dem
    `--info`-stdout (`Mailgroup: true|false`, `Group member(s): ...`,
    `Forward request: ...`).
  - `pleskProvider.updateForwardTargets` setzt jetzt zusätzlich
    `-mailgroup false`, damit der Legacy-Mechanismus deaktiviert wird
    und nur noch Forwarding aktiv ist.
  - `syncForwardingForEmail`: vor dem Plesk-Update werden bestehende
    Mailgroup-Members + Forwarding-Targets abgeholt und in unsere
    `additionalForwardingEmails`-Liste **importiert** (canonical-Key-
    Dedup). Verlustfrei – kein bestehender Empfänger fällt beim
    Umschalten auf Forwarding raus. Import-Fehler werden geloggt,
    aber der eigentliche Sync läuft trotzdem.

- [x] **🔒 Pentest 81.1 (MEDIUM): Self-Forward erzeugte Mail-Loop am Provider**
  - Bug: User konnte die Stressfrei-Adresse selbst (z.B.
    `max.mustermann@stressfrei-wechseln.net`) als zusätzliches
    Weiterleitungsziel eintragen – auch Plus-Varianten davon. Plesk
    leitet auf sich selbst um → Mail-Loop.
  - Backend (`setAdditionalForwards`): zieht jetzt zusätzlich
    `meta.email` aus der DB und vergleicht `canonicalEmailKey(eintrag)`
    gegen `canonicalEmailKey(meta.email)`. Bei Treffer hartes
    `ApiError(400)` mit klarer Self-Forward-Meldung statt silent dedup
    – der User soll merken, dass sein Eintrag bewusst abgelehnt wurde.
  - Frontend (`AdditionalForwardsModal`): zusätzlich proaktive
    Validierung im Sub-Modal mit identischem `canonicalize`-Helper
    (Plus-Tag strippen, lowercase). Neuer Prop `selfEmail`, damit
    auch der Create-Modus (vor dem Persistieren) den Check fahren
    kann. Spart einen Roundtrip + zeigt sofort eine sprechende
    Meldung „… zeigt auf die Adresse selbst – Mail-Loop".

- [x] **🔒 Pentest 77.3 (LOW): `requireIdParam` ließ Float-IDs durch**
  - `Number.isInteger(parseInt('4.5'))` ist `true`, weil `parseInt`
    den Nachkomma-Teil silent abschneidet. Damit traf `/.../4.5/...`
    auf die echte ID 4 statt 400 zurückzuliefern. Gleiches gilt für
    `4.0` und Exp-Notation `4e1`.
  - Fix: vorm Parsen Regex `/^\d+$/` auf die rohe `req.params.<name>`-
    Eingabe. Nur reine Ziffern erlaubt – keine Floats, Exp, Vorzeichen,
    Whitespace, Hex.
  - Smoke-Test (17 Cases): `4.0`, `4.5`, `4e1`, `4E2`, `0`, `-4`, `+4`,
    `0x10`, `1.0e0`, leading/trailing Space alle abgelehnt; `1`, `4`,
    `100`, `9999999` durchgewunken.

- [x] **🐞 Stressfrei-Adressen: doppelte E-Mails beim Anlegen erlaubt**
  - Bug: User konnte dieselbe Adresse zweimal beim selben Kunden
    anlegen (siehe Screenshot mit 2× `max.mustermann@...`). `createEmail`
    hatte keinen Duplikatscheck, `updateEmail` ebenfalls nicht.
  - Service: Vor `prisma.create` jetzt `findFirst` auf
    `(customerId, email)` (case-insensitive). Bei Treffer → `ApiError(409)`.
    Unterschiedliche Meldung für aktive vs. inaktive Duplikate
    (Hinweis bei inaktiv: alten Eintrag reaktivieren statt neu anlegen).
  - `updateEmail`: gleicher Check beim Umbenennen, mit `NOT id`-Exclude.
  - Controller: `catch`-Blöcke honorieren jetzt den `ApiError.statusCode`
    (vorher pauschal 400) → 409 kommt sauber durch.
  - Frontend: `updateMutation` bekam ein `onError`, damit der 409 nicht
    nur ins Leere lief.

- [x] **🔒 Pentest 71.1–71.4: Härtung der Zusatz-Weiterleitungen**
  - **71.1 MEDIUM:** Reservierte/private TLDs (`local`, `internal`,
    `corp`, `lan`, `home`, `private`, `invalid`, `test`, `localhost`,
    `example`, `intranet`, `localdomain`, `arpa`) werden in
    `assertValidForwardingEmail` jetzt hart abgelehnt. Verhindert
    Plesk-DNS-Probing ins interne Netz bei On-Prem-Setups.
  - **71.2 LOW:** Neuer Helper `canonicalEmailKey` normalisiert Mail-
    Adressen für den Dedup-Vergleich (Plus-Tag wegstrippen,
    lowercase). `billing+pentest@x.de` und `billing@x.de` werden als
    dasselbe Ziel erkannt – auch im Vergleich zur Kunden-Stamm-Mail
    und im sync-Pfad gegen `config.defaultForwardEmail`.
  - **71.3 INFO:** Neuer `requireIdParam(req, res, paramName)`-Helper
    fängt nicht-numerische Route-Parameter und liefert 400 statt 500.
    Alle acht parseInt-Stellen in `stressfreiEmail.controller.ts`
    umgestellt (auch über das gemeldete Finding hinaus).
  - **71.4 INFO:** `setAdditionalForwards` rollt den DB-Stand bei
    Provider-Sync-Fehler zurück, damit DB und Plesk nicht
    auseinanderlaufen. Vorheriger `additionalForwardingEmails`-Wert
    wird vor dem Update gemerkt und bei Fail wieder eingespielt.
  - Smoke-Tests bestätigen: 11 reservierte TLDs abgelehnt, 4 echte
    TLDs (`de`, `com`, `co.uk`, `museum`) durchgewinkt, Plus-Tag-
    Strip funktioniert (auch mit Multi-Plus + Casing).

- [x] **🆕 Stressfrei-Adressen: Zusatz-Weiterleitungen auch beim Anlegen**
  - Im „Adresse hinzufügen"-Modal erscheint der „Weitere
    Weiterleitungen"-Button jetzt auch, sobald „Beim E-Mail-Provider
    anlegen" angehakt ist. Liste wird lokal gepflegt, Provider-Sync
    läuft direkt nach `createEmail` mit der vollen Liste.
  - Sub-Modal generalisiert: `value`/`onChange`-Pattern (controlled).
    Mit `email`-Prop → API-Persist pro Änderung (Edit). Ohne `email`
    → lokaler State (Create). Counter-Badge am Button zeigt die
    Anzahl Adressen.

- [x] **🆕 Stressfrei-Wechseln-Adressen: zusätzliche Weiterleitungsziele**
  - Neues Feld `StressfreiEmail.additionalForwardingEmails` (Text/
    JSON-Array), Migration `20260608100000_stressfrei_email_additional_forwards`
    mit `IF NOT EXISTS`.
  - `syncForwardingForEmail` zieht die zusätzlichen Adressen mit
    in die Plesk-`set:`-Liste ein (case-insensitive Dedup gegen
    `customer.email` und `config.defaultForwardEmail`).
  - Neuer Endpoint `PUT /api/stressfrei-emails/:id/additional-forwards`
    mit Body `{ emails: string[] }` – ersetzt die Liste und syncht
    direkt mit dem Provider. Hard-Cap 20 Adressen, Format-Check per
    Regex, Audit-Log.
  - Im StressfreiEmailModal neuer „Weitere Weiterleitungen"-Button
    (Edit-Modus + `providerStatus === exists`) öffnet ein Sub-Modal
    mit Liste + Add/Remove. Jede Änderung geht sofort live.

- [x] **🐞 Modal-Felder ließen sich nicht editieren (Zähler/Bankkarte/Ausweis/Zählerstand)**
  - Vier identische Vorkommen desselben Anti-Patterns wie beim
    AddressModal-Fix von 2026-06-03: `setFormData(getInitialFormData())`
    im Render-Body, getriggert durch `formData.X !== prop.X`. Jeder
    Tastendruck setzte den State zurück.
  - Fix in allen vier Modals (MeterModal, BankCardModal,
    IdentityDocumentModal, MeterReadingModal): nach `useEffect` mit
    `[<entity>?.id]`-Dependency umgezogen.

- [x] **🐞 JpgToPdfModal: PDF blieb trotz vorherigem Fix bei 20+ MB**
  - Stage-Test: 2 Handy-JPGs → 23 MB PDF. Ursache: Smartphone-Fotos
    haben 4000-6000 px Kante (24 MP), das vergrößert die JPEG-Datei
    auch ohne Re-Encode auf 5-10 MB pro Bild.
  - Fix: Bilder **beim Hinzufügen** auf max. 2400 px lange Kante
    runterskaliert (~290 DPI auf A4 = Druckqualität) und als JPEG mit
    Quality 0.92 (Lightroom-Default, kein wahrnehmbarer Unterschied)
    persistiert. Vorschau-Thumbnail, Rotation/Flip und finaler
    PDF-Embed laufen alle auf dem skalierten Bild.
  - Erwartete Größe: 2 Handy-Fotos ≈ 1-2 MB PDF (statt 23 MB).

- [x] **🆕 Kunden-Detail-Tabs: Pro-Tab-Link „in neuem Tab öffnen"**
  - `Tabs`-Komponente um optionalen Prop `tabHrefBuilder(tabId)` erweitert.
    Wenn gesetzt, erscheint neben jedem Tab-Label ein kleines
    `ExternalLink`-Icon. CustomerDetail übergibt den Builder mit
    `?tab=<id>`-Query-Param, der eh schon vom URL-Sync genutzt wird.
  - `target="_blank"` + `rel="noopener noreferrer"` + Klick-stopPropagation,
    damit der Tab-Wechsel nicht parallel zur Tab-Aktivierung passiert.

- [x] **🆕 Vertrag: Kunden-/Vertragsnummer bei Vertriebsplattform**
  - Zwei neue optionale Felder
    `Contract.customerNumberAtSalesPlatform` +
    `contractNumberAtSalesPlatform`, Migration
    `20260603150000_contract_sales_platform_numbers` mit
    `IF NOT EXISTS`.
  - Im ContractForm direkt unter „Kundennummer/Vertragsnummer beim
    Anbieter" angeordnet. ContractDetail zeigt sie als eigene Zeilen
    mit Copy-Button. Audit-Log-Mapping + Renewal-Copy + XSS-Strip
    (CONTRACT_DISPLAY_STRING_FIELDS) mitgezogen.
  - Bonus: das fehlende `contractNumberAtProvider` im Renewal-Copy
    und Audit-Label-Mapping ist gleich mit drin – wurde bisher
    nicht in VVL-Folgeverträge kopiert.

- [x] **🆕 Email-Links öffnen im neuen Tab**
  - In `EmailDetail` nach der DOMPurify-Sanitize jedes `<a>`-Element
    auf `target="_blank"` + `rel="noopener noreferrer"` gesetzt. Letzteres
    verhindert window.opener-Tab-Hijacking. Sanitize + DOM-Walk laufen
    in einem `useMemo`, das nur bei Wechsel der Email neu rechnet.

- [x] **🐞 assertSafePdf: jspdf-PDFs mit JPEGs fälschlich als „JavaScript" blockiert**
  - Stage-Bug: User lädt Ausweis als „JPGs → PDF" hoch → 415 mit
    Meldung „PDF enthält JavaScript-Action". Backend hat den jspdf-
    Output korrekt strukturell, aber die JPEG-Bytes im Image-Stream
    enthielten zufällig die Byte-Folge „/JavaScript" → Pattern-Match.
  - Fix: vor dem Pattern-Scan `stream..endstream`-Blöcke aus dem
    PDF-Text rausnehmen. Echte aktive Inhalte stehen IMMER außerhalb
    von Streams (in PDF-Object-Dictionaries) – Binär-Streams enthalten
    Bilder/Fonts/Komprimiertes und werden jetzt zu Recht ignoriert.
  - Smoke-Test: jspdf-Style-PDF mit `/JavaScript`-Bytes im Stream
    durchgewinkt, echte `/OpenAction /S /JavaScript` weiterhin
    blockiert, clean PDF weiterhin OK.

- [x] **🐞 AddressModal: Straße-Feld ließ sich nicht editieren**
  - `setFormData` wurde unbedingt im Render-Body aufgerufen, wenn
    `formData.street !== address.street`. Jeder Tastendruck löste neu
    aus → Reset auf DB-Wert → Cursor sprang zurück → keine Eingabe
    möglich.
  - Fix: in `useEffect` mit `[address?.id]`-Dependency umgezogen.
    Re-Init nur beim Wechsel/Open, nicht bei jedem Render.

- [x] **🐞 Upload-Limit: Multer 10 MB → 25 MB (Ausweis-Scans, JPGs→PDF)**
  - Zwei Smartphone-Fotos zu PDF kombiniert kratzten am 10-MB-Limit
    (auch mit Original-Bytes-Optimierung aus 431792e). Limits in
    `upload.routes.ts`, `gdpr.routes.ts`, `contract.routes.ts` auf
    25 MB hochgezogen. `pdfTemplate.routes.ts` war eh schon bei 20 MB.
  - Frontend: `FileUpload`-Hinweis „max. 10 MB" → „max. 25 MB".

- [x] **🆕 SIM-Karten: Checkbox „eSIM" zwischen „Hauptkarte" und „Multisim"**
  - Hardware-Plastikkarte vs. eSIM-Profil ist eine eigene Eigenschaft –
    eSIM kann sowohl Hauptkarte als auch Multisim sein, also zusätzlich
    statt entweder/oder.
  - Schema: `SimCard.isEsim Boolean @default(false)`, Migration
    `20260603100000_sim_card_esim` mit `IF NOT EXISTS`.
  - Backend: alle vier SimCard-Schreibpfade in `contract.service.ts`
    (Create + Update + Follow-Up + Renewal) plus FE-Type-Definition.
  - UI: dritte Checkbox in `ContractForm` zwischen Hauptkarte und
    Multisim. ContractDetail zeigt blauen `eSIM`-Badge neben Hauptkarte.

- [x] **🆕 JpgToPdfModal: PDF-Größe drastisch reduziert (Original-Bytes + Quality 0.95)**
  - Stage-Bug: 2 Handy-JPGs à 2 MB → PDF >10 MB → Multer 413. Ursache:
    Canvas-Re-Encode mit JPEG-Quality 1.0 blies jedes Bild auf 8-15 MB
    auf (Quality 100 % heißt nicht „identisch zum Original", sondern
    „möglichst viele Bits pro Pixel" – ein schon JPEG-komprimiertes
    Smartphone-Foto wird so künstlich 4-8× größer).
  - **Fix 1:** Wenn Rotation/Flip unverändert (Standardfall), wird die
    Original-DataURL 1:1 in die PDF eingebettet – kein Canvas-Roundtrip,
    keine Quality-Aufblähung. 2-MB-JPEG bleibt 2 MB. Funktioniert für
    JPEG und PNG (Format-Detection per `data:image/png`-Prefix).
  - **Fix 2:** Bei Transformation: `toDataURL('image/jpeg', 0.95)` statt
    `1.0`. Visuell identisch für Foto-Inhalte (Adobe-Lightroom-Default),
    aber 50-70 % kleiner.
  - Kombiniert: 2 untransformierte Handy-Fotos ≈ 4 MB PDF (vorher
    16-30 MB), 2 gedrehte ≈ 5-8 MB.

- [x] **🔒 Pentest 70.2 (LOW): falscher 500 statt 415 bei verbotenem MIME-Type**
  - Globaler Error-Handler in `index.ts:461` matcht
    `/sind erlaubt|nicht erlaubt/i` und mappt auf 415. Meine 70.1-
    Message „… WebP erlaubt" (ohne „sind") rutschte durch und landete
    bei 500 + Error-Log-Spam.
  - Fix: 1 Zeile in `contract.routes.ts` – `… WebP-Dateien sind
    erlaubt` macht den Regex glücklich. Andere Routes
    (`upload.routes.ts`, `gdpr.routes.ts`, `pdfTemplate.routes.ts`,
    `factoryDefaults.routes.ts`, `appSetting.routes.ts`) nutzen alle
    schon „sind erlaubt".

- [x] **🔒 Pentest 70.1 (INFO): GIF/WebP-Inkonsistenz in contract.routes Multer-Filter**
  - `contract.routes.ts` Vertragsdokumente: Multer-fileFilter blockte
    `image/gif` und `image/webp`, obwohl `validateUploadedFile` beide
    Typen zulässt. Folge: GIF mit korrektem MIME `image/gif` → 415, GIF
    mit gespooftem MIME `image/jpeg` → akzeptiert (vom Magic-Byte als
    `.gif` erkannt). Kein Sicherheitsproblem (Magic-Byte ist der echte
    Guard), aber inkonsistent.
  - Fix: Whitelist um `image/gif` + `image/webp` ergänzt → konsistent
    zum zentralen `validateUploadedFile` und zu `upload.routes.ts`.

- [x] **🔒 Pentest 69.3 (INFO → Defense-in-Depth): Magic-Byte-Check auf Vertragsdokumente erweitert**
  - `contract.routes.ts` Vertragsdokumente-Upload hatte bisher nur den
    PDF-Inhalts-Scan (`scanUploadedPdfIfPresent` aus 68.1). JPG/PNG-
    Uploads waren ungeprüft – kompensiert durch Download-Layer
    (`fileDownload.controller.ts` liefert nur bei Magic-Byte-Match
    inline aus, sonst attachment). Pentester selbst: "ohne Exploit-
    Pfad", aber inkonsistent zu `upload.routes.ts`.
  - **Refactor:** `detectType` + `validateUploadedFile` aus
    `upload.routes.ts` in neue Middleware
    `middleware/uploadFileTypeValidator.ts` ausgelagert (Single Source
    of Truth). Beide Routes nutzen jetzt denselben Helper.
  - **contract.routes.ts:** `validateUploadedFile` ersetzt das
    schlankere `scanUploadedPdfIfPresent` – jetzt greift Magic-Byte +
    canonical Rename + PDF-Scan für Vertragsdokumente analog zu allen
    anderen Upload-Pfaden.
  - **pdfUploadSafety.ts:** `scanUploadedPdfIfPresent` entfernt (tot,
    da nur in contract.routes verwendet wurde). `requireSafeUploadedPdf`
    bleibt für gdpr.routes Vollmacht + pdfTemplate.routes.

- [x] **🔒 Pentest 68.1 (LOW) + 68.2 (INFO): PDF-Inhalts-Validierung + Modal-Limit**
  - **68.1 PDF-Active-Content-Filter:** Magic-Byte-Check prüfte bisher
    nur `%PDF-`. PDFs mit `/JavaScript`, `/JS`, `/Launch` (externes
    Programm), `/EmbeddedFile`, `/RichMedia` (Flash) wurden inline an
    den Viewer ausgeliefert – Browser-PDF-Viewer (Chrome/Firefox)
    ignorieren JS, Adobe Acrobat aber nicht.
  - Neuer Helper `assertSafePdf(buf)` in `utils/sanitize.ts`:
    String-Scan auf die fünf Action-Pattern (case-sensitive nach
    PDF 32000-1:2008 §7.3.5). Wirft `ApiError(415, ...)` bei Treffer.
  - Neue Middleware `pdfUploadSafety.ts` mit zwei Varianten:
    - `requireSafeUploadedPdf` – Datei MUSS PDF sein, sonst 415.
    - `scanUploadedPdfIfPresent` – durchwinkt JPG/PNG, scannt nur PDFs.
  - Eingehängt:
    - `upload.routes.ts` (Magic-Byte-Validator erweitert)
    - `gdpr.routes.ts` Vollmacht-Upload
    - `pdfTemplate.routes.ts` Template-Upload
    - `contract.routes.ts` Vertragsdokumente
    - `cachedEmail.controller.ts` Email-Anhang-Pfade (3 Stellen:
      saveAttachmentTo, saveAttachmentAsInvoice,
      saveAttachmentAsContractDocument)
  - **Inline-Vorschau bleibt erhalten** – das war die explizite
    Anforderung (Augen-Button öffnet PDF im neuen Tab). Pentester-
    Empfehlung „disposition=inline abschalten" wurde bewusst NICHT
    umgesetzt, weil sie das eigentliche Acrobat-Risiko nicht löst
    (PDF auf Disk + Doppelklick → Acrobat → JS läuft trotzdem).
  - Edge-Case-Test bestätigt: `/JSXForm` und `/JavaScriptFooter` werden
    NICHT als JavaScript-Action erkannt (word-boundary `\b` greift).
  - **68.2 Modal-Limit:** `JpgToPdfModal` hatte kein Bild-/Größen-Limit.
    Jetzt `MAX_IMAGES = 50` + `MAX_IMAGE_BYTES = 25 MB` pro Bild.
    UX-Schutz, kein Security-Bug (Self-DoS only).

- [x] **🆕 JPGs → PDF: Button überall bei PDF-Upload**
  - Neue Komponente `JpgToPdfModal` (lokal im Browser via `jspdf`,
    keine Backend-Round-Trip nötig). Mehrere Bilder hinzufügen per
    Klick, Drag&Drop oder `Strg+V` (Clipboard-Image), Reihenfolge
    per Drag&Drop sortierbar, pro Bild 90°/180°-Drehung +
    Horizontal/Vertikal-Spiegelung. Quality 100%, 1 Bild = 1 Seite,
    A4 mit automatischer Hoch-/Querformat-Wahl je Bild.
  - `FileUpload`-Komponente (11 Stellen: Datenschutz-PDF,
    Vollmacht, Bankkarten-Dokumente, Ausweise, Gewerbeanmeldung,
    Handelsregister, Kündigungsschreiben + -Bestätigung +
    deren Optionen) bekommt automatisch einen sekundären
    "JPGs → PDF"-Button, wenn `accept` PDF einschließt.
  - Direkt-Inputs ebenfalls erweitert: Vertragsdokumente
    (ContractDetail), Vollmacht-Dokumente (CustomerDetail Tab),
    Rechnungen (InvoicesSection).
  - PdfTemplates **bewusst ausgenommen** – braucht echte
    AcroForm-PDFs mit Formularfeldern, Bild-PDFs wären unbrauchbar.

- [x] **🆕 EmailProvider-Settings: Override-Feld „Bezeichnung im UI"**
  - `customerEmailLabel` existierte im Backend (Schema +
    Update-Logik + Public-Endpoint), war im UI aber nicht
    erreichbar – das Label kam ausschließlich aus
    `deriveLabelFromDomain`. Jetzt neuer optionaler Input
    „Bezeichnung im UI" unter dem Domain-Block in
    Einstellungen → E-Mail-Provider. Leer = Auto-Ableitung,
    befüllt = überschreibt das Tab-Label und alle anderen
    Stellen, die `customerEmailLabel` nutzen (max 60 Zeichen).

- [x] **🆕 SIM-Karten: Feld „Kartennutzer"**
  - Bei Firmen- und Familienverträgen weicht der
    Vertragsinhaber (Firma/Eltern) vom tatsächlichen Nutzer
    (Mitarbeiter/Kind) ab. Neuer optionaler `SimCard.cardUser`
    (String), Migration `20260601100000_sim_card_user` mit
    `IF NOT EXISTS`.
  - ContractForm: eigene Zeile „Kartennutzer" oberhalb der
    technischen SIM-Felder mit erklärendem Placeholder.
  - ContractDetail: zeigt „Nutzer: \<Name\>" neben den
    Hauptkarte/Multisim-Badges (nur wenn gefüllt).

- [x] **🆕 Vorgängervertrag-Modal: Kundennr./Vertragsnr. sichtbar machen**
  - Die „Anbieter & Tarif"-Card im ContractDetailModal war
    nur sichtbar, wenn Provider oder Tarif gesetzt waren –
    Bei Entwürfen ohne Anbieter wurden so auch
    `customerNumberAtProvider` + `contractNumberAtProvider`
    versteckt, obwohl sie gepflegt sein können. Fix:
    Sichtbarkeitsbedingung um die beiden Felder erweitert.

- [x] **🆕 ContractEmails-Card: Quicklinks auch ohne Postfach**
  - „Postfach öffnen" + „Stressfrei wechseln Adressen" waren
    nur im Normal-Zweig sichtbar. Jetzt in einer gemeinsamen
    `cardTitle`-Variable extrahiert und auch im
    „Kein-Mailbox"-State sichtbar.

- [x] **🆕 Vertrags-Forms: Mini-Links zu Stammdaten**
  - In ContractEmailsSection neben „Postfach öffnen" jetzt
    zusätzlich „Stressfrei wechseln Adressen" → Kundenakte-Tab.
  - In ContractForm kleine ExternalLink-Icons neben Select-Labels:
    Lieferadresse/Rechnungsadresse → `?tab=addresses`,
    Bankkarte → `?tab=bankcards`, Ausweis → `?tab=documents`,
    Anbieter + Tarif → `/settings/providers`,
    Vertriebsplattform → `/settings/platforms`.
  - Select-Komponente nimmt jetzt `ReactNode` als label
    (statt nur string), rückwärtskompatibel.
  - In ContractDetail (Strom/Gas Card-Header) zusätzlich
    permanenter „Zähler verwalten"-Link in neuem Tab.

- [x] **🆕 Vertragshistorie: Vertragsnummern als Link**
  - Erkennt Vertragsnummern (`PREFIX-RANDOM`) in `title` und
    `description` via Regex, löst sie gegen
    `previousContract` + `followUpContract` des aktuellen
    Vertrags auf und rendert sie als Link in neuem Tab.
    Nicht aufgelöste Nummern bleiben als Text.

- [x] **🆕 UI-Bug-Fix: „Wurde sondergekündigt?"-Checkbox-Label**
  - Label-Klasse war `flex` → Block-Layout über volle
    `col-span-2`-Breite. Klick rechts neben dem Text
    triggerte ungewollt die Checkbox. Fix: `inline-flex`.

- [x] **🆕 „Zähler verwalten"-Link im Folgezähler-Form**
  - In der SuccessorMeterForm (Vertragsansicht) im Header
    rechts neben „Folgezähler hinzufügen (Zählerwechsel)"
    ein Link in neuem Tab zur Zähler-Verwaltung des Kunden,
    damit man bei „Kein passender Zähler verfügbar" direkt
    rüberspringen kann.

- [x] **🆕 Folgezähler-Forms: Checkbox „Alten Zähler deaktivieren"**
  - Beide Folgezähler-Forms (Kundenakte MeterModal +
    Vertragsansicht SuccessorMeterForm) bekommen eine
    Checkbox, die standardmäßig angehakt ist. Beim Speichern
    wird der Vorgänger automatisch auf `isActive=false`
    gesetzt – ein-klick-fähiger Zählerwechsel.
  - Backend: `createMeter` mit `successorOf` und
    `addSuccessorMeter` akzeptieren `deactivatePredecessor`
    (Default true).

- [x] **🆕 Kundenakte → Zähler: Verträge-Aufklappliste + Filter**
  - Pro Zähler ein „Verträge (N)"-Aufklapp, listet alle
    Verträge auf, die diesen Zähler nutzen – als Hauptzähler
    (`energyDetails.meterId`) oder über Folgezähler-Kette
    (`ContractMeter`). Dedupliziert. Jeder Eintrag ist Link
    in neuen Tab mit Vertragsnummer, Anbieter, Status-Badge.
  - Neue Checkbox „Zähler ohne Verträge anzeigen" neben
    „Inaktive anzeigen". Filtert die Liste auf Orphans
    (Zähler ohne Vertragszuordnung):
    | Inaktive | ohne Verträge | Ergebnis |
    |----------|---------------|----------|
    | ☐ | ☐ | nur aktive Zähler (Default) |
    | ☑ | ☐ | alle Zähler |
    | ☐ | ☑ | aktive Zähler ohne Vertrag |
    | ☑ | ☑ | alle Zähler ohne Vertrag |

- [x] **🆕 Folgezähler-Button auch bei Single-Meter-Verträgen**
  - Folgeverträge ohne ContractMeter-Eintrag (alte Daten oder
    pure Single-Meter-Verträge) bekamen den Button nie zu
    Gesicht. Fix: Button wird jetzt aus dem if/else gerendert,
    sobald entweder ein Single-Meter oder ContractMeter-
    Einträge vorhanden sind. Im Backend wird der bisherige
    `energyDetails.meterId` bei Single-Meter-Verträgen
    automatisch als ContractMeter (position 0,
    `removedAt` = Wechseldatum) backfillt, damit der alte
    Zähler in der Historie bleibt.

- [x] **🆕 Multi-Meter-Verbrauch auf Vertragslaufzeit clampen**
  - Bei Verträgen, die Vorgänger einer Folgevertrags-Kette
    sind, hängen über `ContractMeter` auch Folgezähler dran,
    die nach Vertragsende installiert wurden. Die Berechnung
    nahm `cm.installedAt..cm.removedAt` 1:1 ohne Clamp gegen
    `contract.startDate/endDate` – damit flossen Zählerstände
    aus der Folgevertrags-Phase in den Verbrauch des
    Vorvertrags ein. Fix:
    `meterStart = max(installedAt, contractStart)`,
    `meterEnd = min(removedAt, contractEnd)`,
    Zähler komplett außerhalb der Laufzeit werden übersprungen.

- [x] **🆕 Vertragsansicht: Standort + Inaktiv-Badge beim Zähler**
  - Strom/Gas-Card zeigt jetzt neben der Zählernummer ein
    rotes „Inaktiv"-Badge (falls deaktiviert) und darunter
    eine kleine Zeile „Standort: …" (falls gepflegt).

- [x] **🛡️ Pentest 43.5 (INFO) + 43.6 (MEDIUM): History-XSS + blocked:-Marker**
  - 43.6: `ContractHistoryEntry.title` + `.description` waren
    auf beiden Pfaden ungestrippt – Admin konnte HTML/Script-
    Tags einschreiben, Portal-User las sie roh zurück. Fix:
    `stripHtml()` auf Create + Update (Write-Pfad) und
    `sanitizeEntry()` im List + Get (Read-Pfad).
  - 43.5: `stripHtml` ersetzt `javascript:` → `blocked:` –
    sinnvoll bei URL-Feldern, hässlich in Tarif-/Preis-Namen.
    Neuer `stripForDisplay`-Wrapper entfernt den Marker
    zusätzlich in den Display-Feldern.

- [x] **🛡️ Pentest 42.5 (MEDIUM): priceFirst12Months XSS**
  - Drei Preisfelder sind im Schema `String?` (freitextliche
    Tarifangaben). `sanitizeContract` strippte sie auf dem
    Read-Pfad nicht – Alt-Daten mit XSS-Payloads kamen 1:1
    raus. Fix: `priceFirst12Months`, `priceFrom13Months`,
    `priceAfter24Months` in `CONTRACT_DISPLAY_STRING_FIELDS`
    aufgenommen.

- [x] **🛡️ Anzeige-Defense: HTML in display-relevanten Strings strippen**
  - `sanitizeContract` und `sanitizeCustomer` strippen jetzt
    zusätzlich HTML in den definierten Display-Feldern
    (`providerName`, `tariffName`, `customerNumberAtProvider`,
    `firstName`, `lastName`, `companyName`, etc.). Wirkt auch
    auf nested `previousContract` + `energyDetails`. Damit
    sehen Pentester-Payloads (`<script>`, `<img onerror>`)
    in der Vertragsliste nicht mehr als hässlicher Klartext
    aus, sondern verschwinden komplett.

- [x] **🆕 Vorvertrag-Verbrauch als Schätzwert im Folgevertrag**
  - **ContractForm** (Strom/Gas): Wenn ein `previousContractId`
    gesetzt ist, wird der Vorvertrag samt Readings nachgeladen und
    der Verbrauch clientseitig über
    `calculateMultiMeterConsumption` / `calculateConsumption`
    berechnet. Unter dem Jahresverbrauch-Feld erscheint
    `Vorvertrag: 1.698 kWh (hochgerechnet) [Übernehmen]` mit
    Ein-Klick-Button, der den Wert ins Feld kopiert. Bei Gas
    erscheint der Hinweis sowohl unter „Jahresverbrauch (m³)"
    (mit m³-Wert) als auch unter „Jahresverbrauch (kWh)".
  - **ContractDetail** (Strom/Gas): Wenn `annualConsumption` leer
    ist und ein berechenbarer Vorvertrag existiert, wird die
    Jahresverbrauch-Zelle stattdessen mit `~1.698 kWh` in blau
    angezeigt, darunter klein „geschätzt aus Vorvertrag
    (hochgerechnet)". Verschwindet automatisch, sobald der Wert
    im Vertrag eingetragen ist.
  - Funktioniert nur bei Verträgen mit explizitem `previousContract`
    (Folgevertrag-Kette). Ohne Vorvertrag oder ohne genügend
    Zählerstände kommt kein Hinweis.

- [x] **🆕 Endstand alter Zähler fließt in Verbrauchsberechnung ein**
  - Bisher wurde der Wert „Letzter Stand alter Zähler" zwar als
    `ContractMeter.finalReading` gespeichert, aber nirgends gelesen
    – weder in der UI noch in `calculateMultiMeterConsumption`.
  - Neuer Helper `recordPredecessorFinalReading(meterId, switchAt, value)`
    in customer.service.ts: legt am Wechseldatum einen regulären
    `MeterReading`-Eintrag für den Vorgänger an (Notes:
    „Endstand bei Zählerwechsel"). Idempotent: existiert am
    Wechseltag schon ein Reading, wird nichts geschrieben.
    Validierung (monoton-steigend) wird vorab durchgeführt –
    Konflikt führt zu sprechender 400-Fehlermeldung, ohne
    halb-geschriebene Zustände zu hinterlassen.
  - Wird aus beiden Pfaden aufgerufen: `addSuccessorMeter` im
    contract.controller (Vertragsansicht → „Folgezähler hinzufügen")
    und `createMeter` mit `successorOf` im customer.service
    (Kundenakte → „Als Folgezähler deklarieren").
  - Folge: Der Endstand erscheint jetzt in der Zählerstände-Liste
    des Vorgänger-Zählers und fließt über
    `calculateMultiMeterConsumption` automatisch in den Verbrauch
    (Zeitraum bis `removedAt` ist inklusive).
  - UI-Hinweise im Folgezähler-Form (Vertragsansicht + MeterModal)
    erklären den neuen Effekt.

- [x] **🆕 Folgezähler-Button auch bei Single-Meter-Verträgen**
  - Bisher nur sichtbar im Multi-Meter-Zweig (`contractMeters.length > 0`)
    – Folgeverträge ohne ContractMeter-Eintrag konnten so keinen
    Folgezähler bekommen.
  - Fix: Button wird jetzt aus dem if/else-Block gerendert, sobald
    entweder ein Single-Meter (`energyDetails.meter`) oder
    ContractMeter-Einträge vorhanden sind.
  - Im Backend `addSuccessorMeter`: bei Single-Meter-Verträgen wird
    der bisherige `energyDetails.meterId` automatisch als
    ContractMeter (position 0, `removedAt` = Wechseldatum) backfillt,
    damit der alte Zähler nicht aus der Vertragshistorie verschwindet.

- [x] **🆕 Folgezähler-Deklaration in der Kundenakte (Auto-Propagation)**
  - **Backend**: Neues Feld `Meter.predecessorMeterId`
    (Self-Relation, `ON DELETE SET NULL`). Migration
    `20260530140000_meter_predecessor` mit `IF NOT EXISTS`.
    `createMeter` akzeptiert optional `successorOf: { predecessorMeterId,
    installedAt?, finalReadingPrevious? }`. Wenn gesetzt: Vorgänger
    wird validiert (gleicher Kunde + gleicher Typ), und für alle
    Verträge, die den Vorgänger als aktuellen Zähler nutzen, wird
    der ContractMeter-Eintrag analog zu `addSuccessorMeter`
    propagiert (vorhandener ContractMeter wird `removedAt` +
    `finalReading` gesetzt; neuer ContractMeter wird mit nächster
    Position + `installedAt` angelegt; `energyDetails.meterId`
    auf den Neuzähler aktualisiert). Idempotent gegen Doppel-Klick.
  - **MeterModal** (Kundenakte → Zähler): Bei Neuanlage neue
    Checkbox „Diesen Zähler als Folgezähler deklarieren". Wenn
    aktiv: Dropdown Vorgänger-Zähler (alle Zähler des Kunden,
    inkl. inaktive – mit Suffix), Wechseldatum (default heute),
    Endstand alter Zähler (optional). Bei Vorgänger-Auswahl werden
    Typ, Tarifmodell und Adresse vom Vorgänger übernommen und
    disabled. Info-Banner: „Alle Verträge mit dem alten Zähler
    werden automatisch umgestellt".
  - Audit-Log: „Zähler angelegt als Folgezähler von X für Kunde #N".

- [x] **🆕 Vertragsansicht: Standort + Inaktiv-Badge beim Zähler**
  - In den Strom/Gas-Details neben der Zählernummer zusätzlich ein
    rotes „Inaktiv"-Badge und eine Zeile mit Standort, falls
    hinterlegt.

- [x] **🆕 Zähler → Lieferadresse-Pflichtfeld + Vertragsfilter**
  - **Backend**: Neues Feld `Meter.addressId` (optional FK auf
    `Address`, `ON DELETE SET NULL`). Migration
    `20260530100000_meter_address` mit `IF NOT EXISTS`. Service
    erzwingt beim Create: Lieferadresse muss vorhanden, zum Kunden
    gehören und Typ `DELIVERY_RESIDENCE` haben.
  - **MeterModal** (Kundenakte → Zähler): Pflicht-Dropdown
    "Lieferadresse" über allen Feldern. Button "Zähler hinzufügen"
    ist disabled, wenn keine Lieferadresse existiert – mit gelbem
    Hinweis-Banner. Bestandszähler ohne Adresse zeigen im
    MetersTab "Lieferadresse: nicht zugeordnet – bitte über
    Bearbeiten nachpflegen" in gelb.
  - **ContractForm** (Strom/Gas): Zähler-Dropdown filtert jetzt
    auf die im Vertrag gewählte Lieferadresse. Deaktivierte
    Zähler bleiben sichtbar (Label-Suffix `(deaktiviert)`); wenn
    sie ausgewählt werden, kommt ein Toast: *„Deaktivierter
    Zähler ausgewählt. Ist das gewollt? Handelt es sich um einen
    Altvertrag?"*. Platzhalter wechselt zwischen "Erst
    Lieferadresse wählen…" / "Kein Zähler für diese Adresse
    vorhanden" / "Zähler wählen…".
  - Audit-Log loggt Adress-Änderung am Zähler als Feld
    "Lieferadresse".

- [x] **🆕 Backup-Operations-Log + EBUSY-Fix beim Restore**
  - Zwei neue Log-Panels auf der DB-Backup-Seite: links
    "Backup-Erstellung", rechts "Backup-Wiederherstellung". Jeder
    Eintrag zeigt ✓/✗-Status, Summary, Timestamp und User. Klick
    öffnet ein Modal mit dem vollständigen Verlauf (alle
    `console.log/error/warn/info`-Zeilen werden während der
    Operation in einen Puffer mitgefangen).
  - Persistiert in neuer Tabelle `BackupLog`
    (Migration `20260519100000_backup_log` mit `IF NOT EXISTS`).
    Limit 1 MB pro `fullLog`, Auto-Refresh alle 5s.
  - Endpoints (settings:update):
    `GET /api/settings/backup-logs?operation=CREATE|RESTORE`,
    `GET /api/settings/backup-logs/:id`.
  - **Bonus**: Das neue Log hat sofort einen alten Bug aufgedeckt –
    `EBUSY: rmdir '/app/uploads'` beim Restore. Ursache: das
    Backup-Service rief `deleteDirectory(UPLOADS_DIR)` mit dem
    finalen `rmdirSync`, aber `/app/uploads` ist ein Bind-Mount,
    den Linux nicht aushängen lässt. Fix: neuer Helper
    `emptyDirectory()` löscht nur die Inhalte, das Verzeichnis
    selbst bleibt stehen.
  - **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
    wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.

- [x] **🛡️ Pentest 2026-05-28 LOW 34.5: Backend-URL-Validierung für AppSettings**
  - Schema-Whitelist + Trailing-Slash-Strip standen NUR im Frontend.
    API-Endpoint akzeptierte sonst `/relative/path`, `javascript:`,
    `ftp://`, `http://192.168.1.1` etc. → Open-Redirect / SSRF-Vektor
    in den an Kunden verschickten Portal-Mails.
  - Neuer `validateSettingValue(key, value)` in appSetting.service.ts
    mit per-Key-Logik: portalLoginUrl → nur http(s), absoluter Host,
    `isBlockedSsrfHost`-Check, Trailing-Slash-Strip. Schwellenwerte
    (deadline*/documentExpiry*) → positive Integer. Bool-Settings
    → strict `true`/`false`. monitoringAlertEmail → RFC-5322-light.
  - Controller (updateSetting + updateSettings) ruft Validator nach
    der HTML-Strip-Sanitisierung; bei Fehler 400 mit aussagekräftiger
    Message. Bulk-PUT validiert ALLE Werte bevor irgendwas gespeichert
    wird (kein halb-committed-State bei einem ungültigen Eintrag).
  - **Live-verifiziert** auf dev:
    - `/evil/path` → 400 "muss absolute http(s)-URL sein"
    - `javascript:alert(1)` → 400 (durch stripHtml zu blocked: → Validator: unzulässiges Schema)
    - `ftp://evil.com` / `data:text/html` → 400
    - `http://169.254.169.254` → 400 (Cloud-Metadata immer geblockt)
    - `http://192.168.1.1` → 200 (on-prem-Default; mit SSRF_BLOCK_PRIVATE_IPS=true → 400)
    - `https://crm.example.de/` → DB: `https://crm.example.de` (Slash gestrippt)
    - `https://crm.example.de//abc/` → DB: `https://crm.example.de//abc`
      (nur trailing slash; doppelte slashes mittendrin bleiben)

- [x] **🐛 Bugfix: Portal-Passwörter in Verträgen wurden mutiliert**
  - Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive
    `sanitizeContractBody`-Funktion lief auch über `portalPassword`.
    Passwörter mit HTML-Pattern (`Pass<TAG>word!` → `Password!`) oder
    URI-Schema-Prefix (`data:secret` → `blocked:secret`) wurden
    irreparabel zerstört.
  - Fix: `PASSTHROUGH_KEYS = {'portalPassword', 'password'}` – beim
    Walk werden String-Werte unter diesen Keys NICHT durch
    `stripHtml` geschickt. PW wird sowieso `encrypt()`-verschlüsselt
    persistiert und niemals als HTML ausgegeben → kein XSS-Risk.
  - Live-verifiziert: PW `MyP@ss<word>123!&data:foo` → byte-genau im
    GET-Decrypt-Endpoint zurück. `providerName: <script>…` → weiter
    auf `EvilProvider` gestrippt. `portalUsername: u<test>` → weiter
    auf `u` gestrippt.

- [x] **🛡️ Pentest 2026-05-24 Pen-31-Befunde (2× MEDIUM)**
  - **31.1 Stored XSS in Vertragsfeldern**: `providerName`, `tariffName`,
    `priceFirst12Months`, `priceFrom13Months`, `priceAfter24Months`
    nahmen rohe HTML/Script-Payloads an und lieferten sie 1:1 zurück.
    Fix: rekursiver `sanitizeContractBody()` (Walk-and-Strip) im
    contract.controller wird auf `req.body` von POST + PUT
    angewandt. Nutzt das bestehende `stripHtml()` aus utils/sanitize,
    inkl. URI-Schema-Block + Entity-Decode. Verträge enthalten keine
    legitimen HTML-Felder (Editor-HTML lebt in AppSettings), daher
    Strip ohne Risiko. Audit-Vergleich nutzt jetzt die sanitisierte
    Version, sonst Audit ↔ DB-Drift.
  - **31.2 IDOR auf `GET /customers/:id/stressfrei-emails`** (und 4
    weiteren Routes mit `requireCustomerAccess`): das Middleware
    short-circuitete auf `customers:read` – aber Portal-User haben
    diese Perm im JWT (für eigene Daten). Damit kam Portal-Kunde 1
    an IMAP-Konten/Adressen/Bank-Cards/Documents/Meters von
    Kunde 3. Fix in `middleware/auth.ts:requireCustomerAccess`:
    erst `isCustomerPortal`-Check (eigene + vertretene IDs), DANN
    erst Perm-Check für Mitarbeiter. Damit sind alle 6 Routes
    mit einem Middleware-Patch dicht. Defense-in-Depth: in
    `stressfreiEmail.controller.getEmailsByCustomer` zusätzlich
    `canAccessCustomer`-Call analog zum POST-Handler.
  - **Infos** (keine Code-Änderung):
    - `type:"STROM"` ist deprecated – richtige Enum ist `ELECTRICITY`.
    - HSTS auf Staging fehlt: HSTS macht der nginx-Reverse-Proxy,
      Backend setzt's bewusst nicht (Doppel-Header-Vermeidung).
      Auf Staging muss der Proxy-Op das HSTS-Header-Add aktivieren.
    - Portal-Login-Rate-Limit 5 vs 10: Env-Drift, identische Codebase.
  - **Live-verifiziert** auf dev:
    - Portal-User 1 vs Customer 3: alle 6 Routes 403
      (`/customers/3`, `.../addresses`, `.../bank-cards`,
      `.../documents`, `.../meters`, `.../stressfrei-emails`).
    - XSS-Payloads `<script>`, `<svg/onload>`, `<img onerror>`,
      `javascript:`, `&#60;script&#62;` in 5 Vertragsfeldern →
      DB-Werte bereinigt (`EvilProvider`, `blocked:alert(4) 35€` etc.).

- [x] **🆕 Vertragsansicht: Kunden-Schnellansicht-Modal + Cent/Euro-Doppel-Input**
  - **Info-Icon neben Kundennamen** öffnet ein Modal mit den
    wichtigsten Kundendaten (Firma, Name, Geburtsdatum/-ort,
    Gründungsdatum, primäre Adresse, Telefon, Mobil, E-Mail,
    Portal-E-Mail, Steuer-/Handelsregister-Nr.). Jedes Feld hat
    einen Copy-Button (bestehende `CopyableValue`-Komponente).
    Neue Komponente: `CustomerInfoModal.tsx`. Lazy-Fetch via
    `customerApi.getById`, staleTime 30s.
  - **Cent/Euro-Doppel-Input** für Arbeitspreise (Strom + Gas):
    Neben dem €/kWh-Feld jetzt ein zweites Feld ct/kWh. Bidirektional
    verkoppelt – Tippen in € aktualisiert ct (×100), Tippen in ct
    aktualisiert € (÷100). Im Backend wird unverändert nur der
    Euro-Wert persistiert; Cent ist reine UI-Hilfe. Float-Rausch-
    Schutz (Math.round × 1e6) verhindert "0.25 → 25.0000000000…".
    Greift für `unitPrice` und (bei DUAL-Zählern) `unitPriceNt`.

- [x] **🆕 Bonus-Feld aufgeteilt: Sofort-Bonus + Neukunden-Bonus (Strom/Gas)**
  - Bisher gab es ein einzelnes `bonus`-Feld auf `EnergyContractDetails`.
    Jetzt zwei Felder `instantBonus` (Sofort) und `newCustomerBonus`
    (Neukunden), die zusammen den Gesamtbonus ergeben.
  - **Migration** `20260524100000_split_energy_bonus`:
    `ADD COLUMN IF NOT EXISTS instantBonus`, `... newCustomerBonus`,
    `UPDATE` kopiert bestehendes `bonus` → `instantBonus`
    (Annahme: Bestandsverträge hatten primär Sofort-Bonus), dann
    `DROP COLUMN IF EXISTS bonus`. Idempotent.
  - **Form** (`ContractForm.tsx`): zwei Input-Felder „Sofort-Bonus (€)"
    + „Neukunden-Bonus (€)" statt einem.
  - **Detail-Ansicht**: zeigt beide Felder einzeln und einen
    aggregierten „Gesamtbonus" (fett) darunter.
  - **Kostenvorschau**: listet Sofort + Neukunden einzeln auf
    (jeweils grün), dann „Gesamtbonus" (mittel grün, fett), danach
    Effektive Jahreskosten.
  - **Cost-Calc** (`energyCalculations.ts`): `calculateCosts()` nimmt
    jetzt beide Bonus-Werte; `CostCalculation` liefert `instantBonus`,
    `newCustomerBonus` + `totalBonus`.
  - **PDF-Template-Variablen**: drei neue Placeholder
    `energyDetails.instantBonus`, `.newCustomerBonus`, `.totalBonus`
    (alter `.bonus`-Placeholder entfernt).
  - **Audit-Log** unterscheidet jetzt beide Felder.
  - **Live-verifiziert** auf dev: PUT mit beiden Feldern → DB hat
    `instantBonus=75, newCustomerBonus=125`, GET liefert beide
    Werte zurück.

- [x] **🛡️ Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)**
  - **30.13 MIME-Extension-XSS** (MEDIUM): `GET /api/files/download`
    lieferte hochgeladene Dateien via `res.sendFile` aus. Da multer
    nur den client-gemeldeten MIME prüft, konnte eine als
    `application/pdf` deklarierte `.html`-Datei auf Disk landen –
    Express bestimmt beim Senden den Content-Type aus der Extension
    (`.html` → `text/html`) und der Browser hätte gerendert. Stored
    XSS für eingeloggte Empfänger. Fix: `Content-Disposition:
    attachment; filename=<safe>` + bestehendes `X-Content-Type-
    Options: nosniff`. Browser lädt jetzt herunter statt zu rendern,
    selbst wenn der Type stimmt. Filename wird auf
    `[A-Za-z0-9._-]` gesäubert.
  - **30.14 SSRF Private-IP-Block opt-in** (INFO): Neuer Env-Flag
    `SSRF_BLOCK_PRIVATE_IPS=true` erweitert die SSRF-Block-Liste auf
    127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1,
    fc00::/7 + IPv4-mapped Varianten + Hostnamen "localhost"/
    "ip6-localhost". Default off, damit On-Prem-Installationen
    (Plesk/Dovecot auf 127.0.0.1) nicht brechen. Cloud-Deployments
    setzen den Flag.
  - **Live-verifiziert** auf dev:
    - Upload + Download: Header zeigt
      `Content-Disposition: attachment; filename="…"` +
      `X-Content-Type-Options: nosniff`
    - Default-ssrfGuard: 127.0.0.1 / 10.x / 192.168.x / localhost → false
      (durchgelassen für on-prem); 169.254.169.254 → true (Cloud-
      Metadata weiter geblockt)
    - Mit `SSRF_BLOCK_PRIVATE_IPS=true`: alle privaten Ranges → true;
      8.8.8.8 (legit public) → false

- [x] **🛡️ Pentest 2026-05-20 Pen-29-Befunde (LOW/INFO)**
  - **28.1 Restarbeit**: `DANGEROUS_URI_SCHEMES` jetzt vollständig –
    `blob:`, `about:`, `ws:`, `wss:`, `ldap:`, `dict:` ergänzt. Bewusst
    NICHT geblockt: `http(s):`, `mailto:`, `tel:` (legitime URLs in
    Notizfeldern).
  - **29.1 Cyrillic-Homoglyph**: `jаvascript:` mit kyrillischem
    `а` (U+0430) lief vorher durch. `HOMOGLYPH_TO_ASCII`-Map mit den
    13 üblichen Spoofing-Buchstaben (а→a, е→e, о→o, …) wird VOR dem
    Scheme-Strip angewendet. Legitime Buchstaben außerhalb der Map
    bleiben unangetastet.
  - **29.2 Percent-Encoding**: `java%73cript:` umging die Regex.
    `percentDecode()` läuft jetzt iterativ bis zu 5 Runden (fängt
    auch `java%2573cript:` ab, das zuerst zu `java%73cript:` und
    dann zu `javascript:` wird).
  - **29.3 Zero-Width-Joiner**: `j​av​ascript:` mit U+200B/200C/200D
    etc. wird durch `ZERO_WIDTH_CHARS`-Regex entfernt, bevor irgendein
    Match läuft.
  - **28.3 Partial – PDF-Validierung tiefer**: nicht nur Magic-Bytes,
    sondern auch (1) `%%EOF`-Marker in den letzten 1 KB und (2)
    Pattern-Scan der ersten 4 KB auf `#!/`, `<script`, `<?php`, `<%`,
    `MZ ` (PE-Header). Dadurch wird `%PDF-1.4\n#!/bin/bash` jetzt
    sauber abgelehnt. Voll-PDF-Parsing wäre überzogen – die simplen
    Spoofs sind weg.
  - **29.4 Email-Format-Validator**: neuer `isValidEmail()` in
    sanitize.ts lehnt Whitespace/Newlines/Tabs (= SMTP-Header-
    Injection-Vektor wie `test@x.de\nBcc:attacker@…`) sowie Format-
    Müll wie `notanemail` ab. Verdrahtet in
    `createCustomer`/`updateCustomer`/`updatePortalSettings`/
    `createUser`/`updateUser`. Liefert 400.
  - **29.5 GET /api/providers/email 500 → 404**: `parseInt("email")`
    war `NaN`, Prisma-Query crashte. Controller validiert jetzt
    `Number.isFinite(id) && id ≥ 1` und liefert 404 ("Anbieter nicht
    gefunden"). Numerische IDs funktionieren weiter.
  - **Live-verifiziert** auf dev:
    - blob:/about:/ws:/wss:/ldap:/dict: alle → `blocked:`
    - `jаvascript:alert(1)` (cyr) → `blocked:alert(1)`
    - `java%73cript:alert(1)` → `blocked:alert(1)`
    - `java%2573cript:alert(1)` → `blocked:alert(1)`
    - ZWJ-Variante → `blocked:alert(1)`
    - `%PDF-1.4\n#!/bin/bash` → 400 + Datei gelöscht
    - PDF ohne `%%EOF` → 400
    - Echtes PDF mit `%%EOF` → 200
    - `email: "notanemail"` → 400
    - `email: "test@x.de\nBcc:..."` → 400
    - `GET /providers/email` → 404 (war 500)
    - `GET /providers/<valid_id>` → 200

- [x] **🛡️ Pentest 2026-05-20 Pen-28-Befunde (LOW/INFO)**
  - **28.1 URI-Schema unvollständig**: `DANGEROUS_URI_SCHEMES`-Regex
    erweitert um `file:` und `ftp:`. `ftp://evil.com/x.js` und
    `file:///etc/passwd` in companyName werden jetzt zu
    `blocked://...` bzw. `blocked:///...` neutralisiert.
  - **28.2 HTML-Entity-Decoding-Bypass**: `stripHtml` lief vorher
    direkt über den Roh-String, sodass `&#106;avascript:`,
    `&#x3C;script&#x3E;` und `&lt;script&gt;` an der Regex
    vorbeischlüpften. Neuer `decodeHtmlEntities()` läuft VOR dem
    Strip und dekodiert numerische (`&#NN;`/`&#xHH;`) + gängige
    Named-Entities (lt/gt/quot/apos/amp). Danach greift die normale
    Tag- und URI-Säuberung wieder.
  - **28.3 Vollmacht-Upload Magic-Byte-Check**: multer prüfte nur
    den client-gemeldeten MIME-Type, eine HTML/PHP-Datei als
    `application/pdf` kam durch. Neuer Check liest die ersten 5
    Bytes nach dem Upload und verlangt `%PDF-` – sonst wird die
    Datei gelöscht und 400 geliefert. Greift bevor irgendwas in
    der DB landet.
  - **28.4 Rate-Limit auf /api/public/consent**: 30 Requests pro
    IP pro 15 Minuten. Brute-Force-sicher war der 128-Bit-UUID-Hash
    schon, aber ohne Limit konnte ein Angreifer das System per
    POST-Spam mit Audit-Logs und Mail-Versand belasten. Neuer
    `publicConsentRateLimiter` greift jetzt auf alle drei
    Sub-Routes (`/:hash`, `/:hash/grant`, `/:hash/pdf`).
  - **Live-verifiziert** auf dev:
    - `ftp://evil.com/x.js` → `blocked://evil.com/x.js`
    - `file:///etc/passwd` → `blocked:///etc/passwd`
    - `&#106;avascript:alert(1)` → `blocked:alert(1)`
    - `&#x3C;script&#x3E;alert(1)&#x3C;/script&#x3E;OK` → `OK`
    - `&lt;script&gt;bad()&lt;/script&gt;Legit` → `Legit`
    - HTML-Datei als PDF hochgeladen → 400 + Datei gelöscht
    - Echtes PDF (Magic-Bytes ok) → 200
    - 35× POST auf public-consent → Req 1–30 = 404, Req 31+ = 429

- [x] **🧹 Pentest 2026-05-20 LOW/INFO-Sammelfix**
  - **27.1 Path-Traversal-Strings in DB**: `cleanupConsents` validierte
    `documentPath` zuvor nur per stripHtml, was `../../../etc/passwd`
    durchließ (kein File-Read, aber dreckige Datenbasis). Neuer
    `isValidDocumentPath`-Check akzeptiert nur `/?uploads/<safe>`,
    alles andere wird auf `NULL` gesetzt.
  - Generischer `cleanupDocumentPaths`-Pass über die fünf weiteren
    Tabellen mit `documentPath` (BankCard, IdentityDocument, Invoice,
    RepresentativeAuthorization als nullable; ContractDocument
    NOT NULL → wird nur berichtet, manuell entscheiden).
  - **Orphaned User**: Neuer Report-Step `reportOrphanedUsers` warnt
    beim Container-Start vor User ohne Rollenzuordnung (= im
    Permission-System unsichtbar). Löschen tut das Skript nicht
    (false-positive-Risiko bei legitimen Spezial-Usern).
  - **Seed-PW-Policy**: `generateInitialPassword()` nutzte
    `Math.random()` (vorhersagbar). Jetzt `crypto.randomInt()` für
    Pick + Shuffle, 28 Zeichen aus 4 Klassen.
  - **`PUT /users/:id` mit `permissions` / `password`**: vorher
    silent-drop durch Whitelist + HTTP 200. Jetzt explizit HTTP 400
    mit Hinweis auf den dedizierten `/password`-Endpoint bzw. die
    Role-Steuerung. Gleicher Pattern wie `PUT /portal` für password.
  - **`/api/health` ohne Auth**: BEWUSST so – Container-Healthcheck +
    Reverse-Proxy pingen ohne Bearer-Token. Antwort liefert nur
    `{status,timestamp}` – keine Version, kein DB-Status, kein
    Hostname → kein Info-Leak. Kommentar im Code dokumentiert die
    Entscheidung.
  - **Live-verifiziert** auf dev:
    - CustomerConsent.documentPath=`../../../etc/passwd` → NULL
    - PUT /users mit `permissions` → 400 mit klarer Message
    - PUT /users mit `password` → 400 mit Hinweis auf /password
    - Orphan-User angelegt → vom Cleanup-Lauf gemeldet
    - `crypto.randomInt`-Pfad rauscht durch ohne Fehler

- [x] **🛡️ Pentest 2026-05-20 MEDIUM+LOW: Consent + URI-Sanitization**
  - **MEDIUM Consent-Mass-Assignment**: PUT `/api/gdpr/customer/:id/consents/:type`
    nahm `source`, `documentPath`, `version` ungefiltert aus dem Body
    – Portal-User konnte `source: "ADMIN_OVERRIDE"`, `version:
    "<script>"` oder `documentPath: "../../etc/passwd"` durchschmuggeln.
    Fix: nur noch `status` aus Body, source server-seitig hardcoded
    auf `'portal'`, documentPath/version bleiben NULL (werden vom
    dedizierten Authorization-Upload-Endpoint server-seitig gesetzt).
  - Whitelist für `CustomerConsent.source` ergänzt
    (`portal | public-link | telefon | papier | email | crm-backend`).
    `grantAuthorization` (Admin) erzwingt sie ebenfalls; `notes`
    läuft jetzt durch `stripHtml`.
  - **LOW javascript:-URI**: `stripHtml()` filtert jetzt zusätzlich
    `javascript:`, `data:`, `vbscript:` – ersetzt durch `blocked:`,
    damit `<a href={companyName}>` nichts feuert. Verträglich mit
    legitimem Text, der nicht "javascript:" enthält.
  - Cleanup-Skript erweitert um `cleanupConsents()`:
    Whitelist-Reset für source, stripHtml für version/documentPath
    – idempotent, läuft beim Container-Start automatisch.
  - **Live-verifiziert** auf dev:
    - PUT mit `{status:"GRANTED",source:"ADMIN_OVERRIDE",
      version:"<script>",documentPath:"../../etc/passwd"}` →
      DB hat `source=portal, documentPath=NULL, version=NULL`.
    - companyName `javascript:alert(1)` → `blocked:alert(1)`.
    - companyName `<a href=data:text/html,<script>...>Click</a>` → `Click`.
    - Cleanup auf dirty DB: source `ADMIN_OVERRIDE` → `unknown`,
      documentPath/version gesäubert.

- [x] **🚨 Pentest 2026-05-20 KRITISCH: Backup-Restore ohne Confirm-Body**
  - `POST /api/settings/backup/:name/restore` startete bei leerem
    Body sofort den destruktiven Restore. Im Unterschied zu
    `/factory-reset` fehlte der Magic-String-Confirm-Check. Risiko:
    versehentlicher Re-Fire (Doppelklick, Browser-Replay, eingeloggter
    Admin auf bösartiger Drittseite) überschrieb stillschweigend die
    komplette DB.
  - Fix: gleicher Defensive-Pattern wie factoryReset – Body muss
    `{ "confirm": "RESTORE-BESTAETIGT" }` enthalten, sonst 400.
    Frontend-Client schickt den String beim Klick im Bestätigungs-
    Dialog automatisch (kein UX-Change für den User).
  - **Live-verifiziert** auf dev:
    - leerer Body → 400 "Bestätigung fehlt"
    - `{"confirm":"ja"}` → 400 (wrong)
    - `{"confirm":"RESTORE-BESTAETIGT"}` → 200, Restore lief

- [x] **🛡️ XSS-Sanitization für Plain-Text-AppSettings (Pentest MEDIUM)**
  - `companyName` (und weitere Plain-Text-Keys wie `defaultEmailDomain`,
    `monitoringAlertEmail`, Schwellenwerte) konnten via PUT
    `/api/settings/:key` mit XSS-Payloads befüllt werden – das war
    nur Admin-triggerbar, aber E-Mail-Templates/PDF-Generatoren
    hätten den Wert ungescaped rendern können.
  - Fix: neuer `sanitizeSettingValue(key, value)` in
    `appSetting.service.ts` strippt HTML außer für die expliziten
    HTML-Editor-Keys (`imprintHtml`, `privacyPolicyHtml`,
    `authorizationTemplateHtml`, `websitePrivacyPolicyHtml`).
    Greift in `updateSetting` (Einzel) und `updateSettings` (Bulk).
  - Cleanup-Skript erweitert: bestehende AppSettings mit HTML in
    Plain-Text-Keys werden beim Container-Start gestrippt
    (idempotent).
  - **Live-verifiziert** auf dev: `<img src=x onerror=alert(1)>OpenCRM
    <script>alert(2)</script>` via PUT → DB-Wert: `"OpenCRM"`.
    `imprintHtml` mit `<h1><p>` → unverändert.

- [x] **🐛 Rollen-Perms-Sync beim Container-Start (Follow-up DSGVO-Fix)**
  - Bestehende Installationen liefen weiter mit veraltetem
    Permission-Set für die DSGVO-Rolle (audit:read u.a. fehlten),
    weil `prisma db seed` per docker-entrypoint nur auf leeren DBs
    läuft. Folge: Settings.tsx-Fix vom Vorgänger-Commit half nicht,
    weil das JWT die fehlende Perm gar nicht enthielt.
  - Neuer Step im Entrypoint: `npx tsx prisma/sync-roles.ts` läuft
    bei jedem Start. Idempotent, fasst nur Permission- und
    Role-Tabellen an (keine User/Customers/Contracts), führt
    `syncRolePermissions` für Admin, Developer, DSGVO,
    Mitarbeiter, Mitarbeiter (Nur-Lesen), Kunde aus.
  - Live-verifiziert: `audit:read` aus DSGVO-Rolle gelöscht, Script
    laufen lassen → "+1 Permission an Rolle #27", wieder vollständig.

- [x] **🐛 DSGVO-Rolle: Menüpunkte in den Einstellungen unsichtbar**
  - Symptom: User mit ausschließlich DSGVO-Rolle sah keinerlei
    Karten unter Einstellungen → System (DSGVO-Dashboard,
    Datenschutzerklärung, Vollmacht-Vorlage, Impressum,
    Website-Datenschutz, E-Mail-Versandlog, Audit-Protokoll).
  - Ursache: Der gesamte System-Block in `frontend/src/pages/Settings.tsx`
    war in `hasPermission('settings:update')` eingewickelt. DSGVO
    hat aber nur `audit:*` und `gdpr:*` Perms – kein `settings:update`.
  - Fix: Outer-Check erweitert auf
    `settings:update || audit:read || gdpr:admin`. Jede Karte hat
    weiterhin ihren eigenen feingranularen Check; für DSGVO-User
    erscheinen nur die DSGVO-/Audit-Karten.
  - Backend-API mit reiner DSGVO-Rolle (kein Admin) live durchgetestet:
    `/api/gdpr/dashboard`, `/api/audit-logs`, `/api/email-logs`,
    `/api/gdpr/privacy-policy`, `/api/gdpr/authorization-template`,
    `/api/gdpr/imprint`, `/api/gdpr/website-privacy-policy`,
    `/api/gdpr/consents/overview`, `/api/gdpr/deletions` → alle 200.
    Backend war nicht das Problem.

- [x] **🛡️ Login-Rate-Limit jetzt pro (IP + Email)-Tupel**
  - Vorher reine IP-basierte Sperre, was zwei Schwächen hatte:
    a) Familie hinter NAT: Max vertippt sich → Nina kommt nicht rein
    b) Angreifer wechselt Proxy → wieder 10 freie Versuche pro
       Account, dieselbe IP-only-Sperre umgangen.
  - Eine reine Email-Sperre wurde verworfen wegen Account-Lockout-
    DoS (jeder kann fremde Accounts sperren) + denselben Shared-IP-
    Problem.
  - **Lösung**: Bucket-Key ist `${ip}|${email-lowercase}`. Damit:
    * Max von IP-A 10x vergeigt → (IP-A, max) gesperrt
    * Nina von IP-A → eigenes Bucket (IP-A, nina), unbetroffen
    * Admin von IP-A mit richtigem PW → erfolgreicher Login
    * Max von IP-B → eigenes Bucket (IP-B, max), darf wieder
  - Implementation: `loginRateLimiter.keyGenerator = ${ip}|${email}`
    in `middleware/rateLimit.ts`; nur ein Limiter, kein zusätzlicher
    Email-only.
  - Admin-UI: Listing zeigt Tupel (IP, Email), Reset schickt
    beides mit, Audit-Log resourceId = `${ip}|${email}`.
  - **Live-verifiziert** (4 Schritte):
    11x falsch max → 429, Nina/Admin von gleicher IP → durch,
    max bleibt gesperrt, Reset → max wieder 401.

- [x] **🚨 PUT /customers/:id/portal mit `password` im Body → 400**
  - Endpoint nahm `password` silent entgegen, ignorierte es, gab
    aber HTTP 200 zurück → Client glaubte fälschlich, das Passwort
    sei gesetzt. Fix: explizite Body-Validierung – `password`,
    `portalPassword`, `portalPasswordHash`, `portalPasswordEncrypted`
    sind verbotene Felder, HTTP 400 mit Hinweis auf den dedizierten
    `POST /portal/password`-Endpoint.

- [x] **🚨 Pentest Runde 17 – JWT-TTL + Pentest-Marker-Detection**
  - **21.1 Access-Token 7 Tage**: Bug-Quelle waren die `.env`-Files,
    die noch die alte Konvention vor der Refresh-Token-Trennung
    hatten (`JWT_EXPIRES_IN=7d`). docker-compose.yml und
    `.env.example` standen schon richtig auf 15m als Default.
    Alle `.env`-Files (Root, backend/, docker/.env.example,
    backend/.env.example) jetzt auf `JWT_EXPIRES_IN=15m` mit
    explizitem `JWT_REFRESH_EXPIRES_IN=7d`. Auf prod kann der
    Container mit dem neuen Default neu hochgezogen werden.
  - **17.5 Alte Pentest-Daten in DB**: das Cleanup-Script läuft
    schon bei jedem Container-Start, strippt HTML aus Customer/
    User-Strings und entfernt nicht-whitelisted AppSettings. Es
    erkannte aber keine Test-Records ohne HTML (z.B. Customer mit
    `email: hacker@evil.de`). Erweiterung:
    * Neue Marker-Pattern-Liste: `^hacker@`, `^attacker@`,
      `^pentest@`, `@evil\.`, `<script\b`, `onerror=`,
      `javascript:`, SQL-Injection + Path-Traversal-Pattern.
    * Bewusst eng: nur EMail-Adressen die mit dem Marker BEGINNEN,
      damit legitime Kunden mit "hacker" o.ä. im Nachnamen
      (z.B. `stefanhacker@gmx.de`) NICHT als Pentest-Marker
      durchgehen.
    * Default-Verhalten: nur warnen + Aufzählen. Mit
      `CLEANUP_PURGE_PENTEST=true`-ENV werden die markierten
      Customer/User-Records gelöscht.
    * Live-verifiziert: `stefanhacker@gmx.de` (echt) → durch;
      `hacker@evil.de` (Test) → erkannt + Warnung; mit Purge-Env
      → gelöscht.
  - **18.4 Klartext-Portal-PW-Abruf**: BEWUSST DRIN GELASSEN auf
    Wunsch (Admin-UI-Komfort). Klartext bleibt für Admin via
    `GET /customers/:id/portal/password` abrufbar; ist mit
    `customers:update`-Permission gated und mit Audit-Log
    (`READ → PortalPassword`) auditiert.

- [x] **🚨 Pentest Runde 15 – KRITISCH: portalPasswordHash in PUT/POST-Response**
  - **20.3 KRITISCH**: `PUT /customers/:id` gab den vollen bcrypt-Hash
    (`$2a$12$…`) zurück, weil `updateCustomer` Service-Output ohne
    sanitize-Aufruf direkt durchreichte. GET-Endpoints waren dicht,
    die Update-Response nicht. **20.4 HOCH** gleicher Klasse:
    `portalPasswordResetToken`, `consentHash`,
    `portalPasswordMustChange`, `portalTokenInvalidatedAt` leakten
    ebenfalls über PUT/POST.
  - **Fix**:
    * `updateCustomer` + `createCustomer` rufen jetzt
      `sanitizeCustomer`/`sanitizeCustomerStrict` auf den Service-
      Output (je nach `customers:update`-Permission).
    * `updateContract` + `createContract` + `createFollowUp` +
      `createRenewal` analog mit `sanitizeContract`/Strict
      (Portal-Hint via `req.user.isCustomerPortal`).
    * `portalPasswordMustChange` und `portalTokenInvalidatedAt`
      zusätzlich von `PORTAL_HIDDEN_CUSTOMER_FIELDS` zu
      `SENSITIVE_CUSTOMER_FIELDS` hochgezogen – damit greift der
      Schutz auch bei der normalen `sanitizeCustomer`-Variante
      (Admin-Sicht). Auch Pentester-Empfehlung in HOCH-Klasse.
  - **Live-verifiziert**:
    * Admin `PUT /customers/3 {firstName:…}` → 0 Leaks bei
      portalPasswordHash/ResetToken/Expires/MustChange/consentHash/
      TokenInvalidatedAt; `portalPasswordEncrypted` bleibt für
      Admin sichtbar (UI-Workflow)
    * Portal-User `GET /customers/3` → 0 Leaks auch bei
      portalPasswordEncrypted/notes
    * `POST /customers` (create) ebenfalls dicht

- [x] **🛟 Admin-Rescue-Script (PW-Reset direkt in DB + Rate-Limit-Reset)**
  - Use Case: Admin sperrt sich aus (z.B. `admin@admin.com` ist
    keine echte E-Mail → Passwort-vergessen-Flow kann keine Mail
    senden) oder Brute-Force-Lockout will sich nicht auflösen.
  - **Node-Script** `backend/prisma/reset-admin-password.ts`:
    findet User per Email, hasht PW mit bcrypt cost 12, schreibt
    direkt in `user.password`, setzt `tokenInvalidatedAt = now()`
    (kickt alle bestehenden Sessions), löscht ggf. anhängende
    Reset-Tokens. Mit oder ohne PW-Argument (random 28-char wenn
    leer), Komplexitäts-Check 25 Zeichen für eigene PWs.
  - **Bash-Wrapper** `scripts/admin-rescue.sh`:
    * `password <email> [pw]` – PW-Reset im laufenden Container
      via `docker exec opencrm-app npx tsx prisma/reset-admin-…`
    * `unlock` – Container-Restart leert den In-Memory-Rate-Limit-
      Store komplett (alle IPs frei)
    * `all <email> [pw]` – beides
  - **Live-verifiziert**: random-Modus liefert 28-char PW, schwaches
    eigenes PW wird mit allen Defizit-Punkten abgelehnt, langes
    eigenes PW akzeptiert, unbekannter User → klarer Fehler,
    bash-Syntax-Check ok.

- [x] **🔐 Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)**
  - 12 Zeichen sind heute der untere akzeptable Rand. NIST/OWASP/BSI
    empfehlen 14-25+ Zeichen. Mitarbeiter/Admin nutzen Passwort-Manager
    → Länge kostet nichts, Sicherheitsgewinn ist real.
  - **Schwellwerte**: `STAFF_MIN_PASSWORD_LENGTH = 25`,
    `PORTAL_MIN_PASSWORD_LENGTH = 12` (Endkunden tippen das auch auf
    dem Handy ein).
  - **Backend-Pfade**:
    * `createUser` + `register` + `setUserPassword` → 25 Zeichen
    * `setPortalPassword` + `changeInitialPortalPassword` → bleibt 12
    * `confirmPasswordReset`: Server bestimmt Audience anhand des
      Tokens (`getPasswordResetAudience`) → User-Token = 25, Customer-
      Token = 12. Damit kann ein Angreifer nicht durch Body-Hint
      auf den schwächeren Schwellwert ausweichen.
  - **Seed-Admin**: Default-Passwort jetzt 28-char Zufallspasswort
    (alle 4 Klassen garantiert), via `SEED_ADMIN_PASSWORD`-ENV
    überschreibbar – aber nur wenn ≥ 25 Zeichen, sonst ignoriert
    mit Log-Warnung.
  - **Frontend**:
    * UserList: Hinweis-Text "Mind. 25 Zeichen". Update + Passwort
      gleichzeitig → Frontend macht jetzt zwei Calls (PUT + neuer
      `POST /users/:id/password`) statt Passwort durch Update-Body
      durchzuschmuggeln.
    * PasswordResetConfirm: Hinweis "Mind. 12 Zeichen (Mitarbeiter:
      25)", Server entscheidet endgültig.
    * `userApi.setPassword(id, password)` neu in api.ts.
  - **Live-verifiziert**:
    * `POST /users/6/password "Hallo123!Test"` (12 chars) → 400
      "mindestens 25 Zeichen"
    * `POST /users/6/password "MeinExtremLangesPW2026!Test"` → 200,
      Login mit dem neuen PW → success
    * `POST /customers/3/portal/password "Hallo123!Test"` (12) → 200
    * `POST /users {…,password:"Hallo123!Test"}` → 400 (25-char-Floor)
  - **Nächster größerer Sprung** wäre **MFA für Mitarbeiter-Login**
    (TOTP via Authenticator). Eigenes Thema, separate Aufgabe.

- [x] **🚨 Pentest Runde 12 – Folge-Fixes: XSS-Reste, User-PW-Endpoint, JS-Error-Leak, Seed-PW**
  - **M2-Reste (XSS-Strings noch in DB)**: neues idempotentes Script
    `prisma/cleanup-xss-and-mass-assignment.ts` läuft beim
    Container-Start. Strippt HTML aus Customer/User-String-Feldern;
    entfernt AppSettings, deren Key nicht in `ALLOWED_SETTING_KEYS`
    steht. Mehrfacher Aufruf ändert nichts.
  - **User-Update akzeptierte `password`-Feld**: stillschweigend ohne
    dedizierten Audit-Eintrag. Jetzt: `password` aus
    `USER_UPDATABLE_FIELDS` raus (CREATE behält es weiterhin); neuer
    Endpoint `POST /api/users/:id/password` mit eigenem Audit-Log
    "Passwort … durch Admin gesetzt", Komplexitäts-Check inklusive.
  - **JS-Runtime-Fehler leakten weiter**: ORM-Leak-Patterns erweitert
    um `TypeError`, `ReferenceError`, `SyntaxError`, `RangeError`,
    "Cannot read propert(y|ies) of (undefined|null)", "is not a
    function", "is not defined". Greift im globalen
    `res.json()`-Wrapper.
  - **POST /contracts substring-Crash**: defensiv – `type` fehlt →
    400 mit klarer Meldung; `generateContractNumber()` fängt auch
    leere/null type ab (Fallback "CON").
  - **Seed-Admin-Passwort "admin" verletzte Policy**: jetzt
    16-Zeichen-Zufallspasswort beim Seed (mit allen 4 Klassen
    garantiert) oder via `SEED_ADMIN_PASSWORD`-ENV überschreibbar;
    BCRYPT-Cost auf 12 (war 10); Passwort wird **einmalig** beim
    Seed in stdout ausgegeben mit Warnung.
  - **AppSettings-Whitelist ergänzt**: `companyName`,
    `defaultEmailDomain` (kommen aus seed.ts, waren in der ersten
    Whitelist vergessen).
  - **Live-verifiziert**: POST /contracts {} → klare 400 statt
    JS-Crash; PUT /users/6 {password:...} ignoriert (Login mit
    altem PW geht weiter); POST /users/6/password mit kurz → 400;
    Cleanup-Script: 1 Customer bereinigt, 2 unbekannte AppSettings
    entfernt (hackerSetting, debugMode), Re-Lauf → 0 Änderungen.

- [x] **🚨 Pentest Runde 11 – Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip**
  - **C2 KRITISCH – Factory Reset ohne Bestätigung**:
    Eingeloggter Admin konnte mit leerem oder beliebigem Body
    (`{confirm:true}`, `{confirm:false}`, `{}`) die komplette DB
    plätten (3× in einer Session passiert). Fix: server-side wird
    jetzt `confirm: "FACTORY-RESET-BESTAETIGT"` als String erzwungen,
    sonst HTTP 400. Frontend-API schickt den exakten String mit.
  - **M1 – Settings Mass Assignment**:
    `PUT /api/settings` und `PUT /api/settings/:key` nahmen JEDEN
    Key-Value-Pair an (`superAdminEmail`, `debugMode`,
    `allowedOrigins` etc. landeten direkt in der DB). Fix:
    Whitelist `ALLOWED_SETTING_KEYS` in `appSetting.service.ts`,
    Helper `isAllowedSettingKey()`. Unbekannte Keys → HTTP 400 mit
    expliziter Aufzählung der ungültigen Keys.
  - **M3 – Prisma-Error-Leak in jeder Response**:
    Statt 30+ Controller einzeln zu fixen: globaler `res.json()`-
    Wrapper unter `/api`, der `error`/`details`-Strings durch einen
    Pattern-Filter schickt. Bekannte ORM-/Stack-Trace-Muster
    (`Invalid \`prisma.`, `PrismaClient`, Stack-Frames) werden zu
    `"Operation fehlgeschlagen"` ersetzt. Original-Text bleibt im
    Server-Log via `[orm-leak-guard]`.
  - **M2 – Stored XSS in Customer/User-Strings**:
    `<script>alert(1)</script>` und ähnliche Payloads landeten
    ungefiltert in der DB. Fix: neuer `stripHtml()`-Helper, von
    `pickCustomerUpdate/Create` und `pickUserUpdate/Create` auf
    allen String-Werten angewandt (Defense-in-Depth – React
    auto-escaped schon, aber PDF-Generator/E-Mail-Templates
    könnten exec-Vektoren sein).
  - **Live-verifiziert (alle vier)**:
    * `/factory-reset` mit `{}`, `{confirm:true}`, `{confirm:false}`
      → HTTP 400, DB unangetastet
    * `PUT /settings {superAdminEmail,debugMode,allowedOrigins}` →
      400 + Keys aufgezählt; gültige Keys → 200
    * `PUT /users/99999` → `"Operation fehlgeschlagen"` statt
      Prisma-Stack; Server-Log behält Original
    * `PUT /customers/3 {companyName:"<script>...</script>EvilCorp"}`
      → gespeichert als `"EvilCorp"`; `<img onerror>` weg

- [x] **🚨 Pentest Runde 10 – Live-Vollmacht-Konsistenz + DTO-Leaks in embedded Objekten**
  - **MEDIUM – Stale Token nach Vollmacht-Widerruf**:
    Selbst ein FRISCHER Portal-Login lieferte JWT mit
    `representedCustomerIds: [7]` und `representedCustomers: [{Nina,…}]`,
    obwohl die Vollmacht widerrufen war. Live-Check beim Datenzugriff
    funktionierte (403), aber die UI zeigte dem Vertreter weiter, dass
    er Nina vertreten könne.
    * **Fix**: `customerLogin` und `getCustomerPortalUser` (= /me +
      Refresh-Pfad) filtern `representingFor` jetzt zusätzlich über
      `getAuthorizedCustomerIds()` – nur Beziehungen mit
      `isGranted: true` landen im Token und in /me.
    * Verifiziert: Customer 1 (vertritt 2,3 aber alle Vollmachten
      widerrufen) → JWT.representedCustomerIds = `[]`, /me ebenfalls.
  - **MEDIUM – DTO-Leak in embedded Objekten**:
    `GET /customers/:id` lieferte zwar Customer-Top-Level sanitisiert,
    aber `contracts[]` darin enthielt weiterhin `commission`, `notes`,
    `portalPasswordEncrypted`, `nextReviewDate`. Analog `notes` auf
    embedded customer in `/contracts/:id`.
    * **Fix**: `sanitizeCustomer(Strict)` ruft jetzt
      `sanitizeContract(Strict)` für jedes Element in `contracts[]`
      auf. `notes` zu `PORTAL_HIDDEN_CUSTOMER_FIELDS` ergänzt
      (interne CRM-Vermerke).
    * Verifiziert: Portal-User sieht in `customers/1.contracts[*]`
      keine commission/notes/PW-Encrypted/nextReviewDate mehr;
      Admin sieht sie weiterhin (Workflow-Bedarf);
      `portalPasswordEncrypted` ist generell entfernt (Klartext nur
      via `/contracts/:id/password` mit Audit-Log).
  - **LOW – `/tasks?customerId=X` 200 statt 403 für fremde IDs**:
    Konsistenz-Issue: nach Vollmacht-Widerruf gab der Endpoint
    leeres Array statt einen klaren 403-Fehler. Jetzt: wenn der
    Portal-User explizit nach einer customerId filtert, die er nicht
    (mehr) vertreten darf → 403 mit "Kein Zugriff auf diese
    Kundendaten". Verifiziert.

- [x] **🚨 Pentest Runde 7 (Anschlussrunde) – Information-Disclosure + Input-Validation**
  - **MEDIUM – Interne Felder in Portal-Responses**:
    * `sanitizeCustomerStrict` strippt jetzt zusätzlich
      `portalTokenInvalidatedAt`, `portalLastLogin`,
      `portalPasswordMustChange`, `lastBirthdayGreetingYear`,
      `privacyPolicyPath`, `businessRegistrationPath`,
      `commercialRegisterPath`.
    * Neue `sanitizeContract` / `sanitizeContractStrict` /
      `sanitizeContracts(Strict)`: entfernt
      `portalPasswordEncrypted` (immer; ist nur über den dedizierten
      `/password`-Endpoint mit Audit-Log abrufbar) und für Portal-
      User zusätzlich `commission`, `notes`, `nextReviewDate`.
    * `getContract` + `getContracts` rufen jetzt die passende
      Sanitize-Variante je nach `req.user.isCustomerPortal` auf;
      Mitarbeiter sehen weiterhin commission/notes (Admin-Workflow),
      nur `portalPasswordEncrypted` ist generell entfernt (Klartext
      nur über dedicated Endpoint).
    * Live-verifiziert: Portal sieht 0 Leaks, Admin sieht
      commission/notes weiterhin.
  - **LOW – Integer-Truncation bei IDs**:
    `parseInt('6abc')` → `6` hat alle Endpoints durchgewunken.
    Neuer middleware in `index.ts`: jedes URL-Pfad-Segment unter
    `/api`, das mit Ziffer beginnt aber nicht aus reinen Ziffern
    besteht, wird mit HTTP 400 abgelehnt. Heuristik trifft alle
    `/resource/<id>(\D+)`-Patterns ohne dass jeder einzelne
    Sub-Router angefasst werden muss.
    * Live-verifiziert: `/customers/6abc` → 400 mit klarer Meldung,
      `/customers/3` weiterhin 200, `/contracts/1abc/history`
      → 400, normaler Pfade `/audit-logs/customer/3` → 200.
  - **INFO – Login-Rate-Limit „nach 6 nicht aktiv"**:
    Code-Stand `limit: 10` für `loginRateLimiter`, lokal verifiziert:
    11. Versuch = 429. Pentester sah vermutlich noch alten Build
    oder eine andere Lokation (PW-Reset hat `limit: 5`). Kein
    Code-Change.

- [x] **🛠 Rate-Limit-Sperren: Admin-UI zum Freigeben**
  - Bei einer Pentest-Runde hat der Tester sich selbst durch zu viele
    Login-Versuche ausgesperrt → ohne Container-Restart kein Weg zurück.
    Jetzt: Admin sieht die Sperren und kann sie einzeln aufheben.
  - **Datenquelle für die Liste**: `SecurityEvent`-Tabelle filtert nach
    `type = RATE_LIMIT_HIT` im 15-Min-Fenster (= Login-Window), gruppiert
    nach IP. Pro Eintrag: IP, zuletzt versuchte E-Mail, Limiter-Typ
    (Login / Passwort-Reset), Hit-Anzahl, Zeit seit letztem Hit.
  - **Reset**: ruft `loginRateLimiter.resetKey(ip)` und
    `passwordResetRateLimiter.resetKey(ip)` auf – exposiert von
    `express-rate-limit` v7. Idempotent, audited.
  - **Backend**:
    * `GET /api/settings/rate-limits/active` (`settings:read`)
    * `POST /api/settings/rate-limits/reset` (`settings:update`) mit
      Body `{ ipAddress }`
    * neuer Controller `rateLimitAdmin.controller.ts`
  - **Frontend**: neue Seite `/settings/rate-limits` mit Tabelle +
    Freigeben-Button, 15s Auto-Refresh; Kachel in Settings-Übersicht
    (orange, neben „Sicherheits-Monitoring").
  - **Live-verifiziert (4 Schritte)**: 11 falsche Logins von
    127.0.0.1 → 11. → 429; Liste zeigt IP + Email + Hits;
    POST Reset → 200; nächster Login mit falschem PW → 401 statt
    429 (Sperre weg); Audit-Log enthält Eintrag.

- [x] **🚨 Pentest Runde 7 – Hit-List durchgegangen + kurzlebige Download-Tokens**
  - **Credential-Endpoints** (Contracts password/internet/sip/simcard +
    Stressfrei mailbox/send/reset-password): ALLE bereits durch
    `canAccessContract`/`canAccessStressfreiEmail` gesichert – keine
    Lücke gefunden.
  - **`GET /customers/:id/portal/password`** (Klartext-Portal-Passwort-
    Abruf): hatte KEINEN `canAccessCustomer`-Check. Fix: eingefügt.
    Defense in depth gegen versehentlich falsch vergebene
    `customers:update`-Permission.
  - **Admin-Funktionen** (factory-reset, developer/*, audit-logs/rehash,
    audit-logs/customer): alle durch admin-level Permissions
    (`settings:update`, `developer:access`, `audit:admin`, `audit:read`)
    geschützt – Portal-User haben diese nicht.
  - **Token-in-URL (NIEDRIG)**: Langlebige Access-JWTs landeten als
    `?token=` in URLs für PDF-iframe, Audit-Log-Export, PDF-Generate
    und Portal-Privacy-PDF → nginx-Access-Logs, Browser-History,
    Referer-Header.
    * **Neuer Mechanismus**: `POST /api/auth/download-token` liefert
      ein kurzlebiges JWT mit `type: 'download'` und `exp: 60s`.
    * Auth-Middleware akzeptiert `type: 'download'` AUSSCHLIESSLICH
      via `?token=` Query, niemals als Bearer-Header. So kann ein in
      Logs geleaktes Download-Token nicht für reguläre API-Aufrufe
      missbraucht werden.
    * Frontend-Migration: 4 Stellen umgestellt (Audit-Log-Export,
      PDF-Template-Preview, PDF-Generate von ContractDetail + Modal,
      Portal-Privacy-PDF). `fileUrl` und `getAttachmentUrl` sind
      synchron und in vielen Components verstreut – Migration dieser
      bleibt als Folge-Aufgabe.
    * Live-verifiziert: Download-Token = 1773 Zeichen, type=download,
      exp-iat=60s, als Header → 401, als ?token= → 200.

- [x] **🚨 Pentest Runde 6 – Sammelfix + Strukturelles Audit (8 Findings + Audit-Sweep)**
  - **KRITISCH-01 `GET /emails/:id/thread`**: kein Owner-Check →
    Portal-Kunde konnte alle Mail-Threads durchsuchen. Fix:
    `canAccessCachedEmail` im Controller.
  - **KRITISCH-02 `GET /customers/:customerId/representatives/search`**:
    kein `canAccessCustomer` auf den Pfad → DSGVO-GAU, Portal-Kunde
    konnte mit Buchstaben-Brute-Force die komplette Kunden-DB
    auslesen. Fix eingefügt.
  - **HOCH-01 `GET /birthdays/upcoming`**: kein Portal-Filter → Name,
    E-Mail, Telefon, Geburtsdatum aller Kunden lesbar. Fix:
    `isCustomerPortal` → 403.
  - **HOCH-02 `*/contracts/:contractId/history`**: kein Owner-Check
    auf GET/POST/PUT/DELETE. Fix: `canAccessContract` in allen vier
    History-Handlern.
  - **HOCH-03 Mailbox-Endpoints**: `mailbox-accounts`, `unread-count`,
    `contracts/:id/emails/folder-counts` ohne Check. Fix:
    `canAccessCustomer` bzw. `canAccessContract` in allen drei.
  - **HOCH-04 Live-Vollmacht-Check in Tasks**: `getTasks`,
    `createSupportTicket`, `createCustomerReply`, `getAllTasks`,
    `getTaskStats` prüften nur `representedCustomerIds.includes(...)`
    aus dem JWT – widerrufene Vollmachten hatten weiter Zugriff
    (JWT lebt bis zu 15min nach Widerruf). Neuer Helper
    `getPortalAllowedCustomerIds()` in `accessControl.ts` ruft
    `hasAuthorization()` live ab. Auch `updateCustomerConsent`
    (GDPR) auf diesen Pfad umgestellt.
  - **MITTEL-01 `confirmPasswordReset` Klartext-Speicherung**:
    Self-Service-Reset speicherte `portalPasswordEncrypted = encrypt(pw)`.
    Klartext-Speicherung ist nur für Admin-OTPs sinnvoll. Fix:
    Field auf null, zusätzlich `portalPasswordMustChange = false`.
  - **MITTEL-02 Pagination-Total leakt globale Kunden-Anzahl**:
    `GET /customers` gab `total: 4271` auch wenn Portal-User nur
    1 Kunde sah. Fix: `customer.service.ts` erweitert um
    `allowedIds`-Filter, der direkt in der DB-Query landet → die
    pagination zählt nur über erlaubte IDs.
  - **Strukturelles Audit-Sweep** (Sub-CRUD + Email-Operationen):
    Folgende Handler bekamen jetzt erstmals einen `canAccess*`-
    Check, defense in depth gegen falsch vergebene Rollen:
    `markAsRead`, `toggleStar`, `assignToContract`,
    `unassignFromContract`, `deleteEmail`, `getTrashEmails`,
    `getTrashCount`, `restoreEmail`, `permanentDeleteEmail`,
    `getAttachmentTargets`, `saveAttachmentTo`, `saveEmailAsPdf`,
    `saveEmailAsInvoice`, `saveAttachmentAsInvoice`,
    `saveAttachmentAsContractDocument`, `createFollowUp`,
    `createRenewal`, `snoozeContract`, `removeContractMeter`,
    `updateAddress`, `deleteAddress`, `updateBankCard`,
    `deleteBankCard`, `updateDocument`, `deleteDocument`,
    `updateMeter`, `deleteMeter`, `addMeterReading`,
    `updateMeterReading`, `deleteMeterReading`,
    `markReadingTransferred`, `addRepresentative`,
    `removeRepresentative`.
  - **Live-verifiziert** (Portal-User Customer 3 auf fremde IDs):
    `customers/1/representatives/search` → 403,
    `birthdays/upcoming` → 403 (Admin → 200),
    `emails/21/thread` → 403,
    `customers/2/mailbox-accounts` → 403,
    `emails/unread-count?customerId=2` → 403,
    `contracts/8/{history,folder-counts,follow-up,renewal,snooze}` → 403,
    eigene `customers/3` → 200,
    pagination.total für Portal = 1 (statt 3),
    Customer 1 mit widerrufener Vollmacht → 0 fremde Verträge.

- [x] **🚨 Pentest Runde 5 – KRITISCH: change-initial-portal-password ohne Pflicht-Check**
  - **Realer Angriff**: Jeder Portal-User konnte jederzeit mit
    seinem eingeloggten Token `POST /api/auth/change-initial-portal-
    password` aufrufen und das eigene Passwort ohne Kenntnis des
    alten ersetzen. Der OTP-Flow-Endpoint hatte den Check
    `portalPasswordMustChange === true` nicht.
  - **Konsequenz**: Bei XSS oder kurzlebigem Token-Diebstahl konnte
    ein Angreifer das Passwort dauerhaft übernehmen.
  - **Fix**: Eine Zeile in `auth.controller.ts` –
    `prisma.customer.findUnique` auf `portalPasswordMustChange`,
    bei `false` → 403 "Nicht erlaubt".
  - **Live-verifiziert**: ohne Flag → 403; mit Flag (nach
    send-credentials) → 200, danach Flag automatisch zurück auf
    `false` → erneuter Aufruf → 403.

- [x] **Pentest Runde 5 – NIEDRIG: consentHash + Public-Grant-Response**
  - `consentHash` wurde über `GET /api/customers/:id` zurückgegeben.
    Der Hash ist Pseudo-Credential für den öffentlichen Consent-Link
    (wer ihn hat, sieht Customer-Name + Kundennummer ohne Auth und
    kann Einwilligungen erteilen). **Fix**: in
    `SENSITIVE_CUSTOMER_FIELDS` aufgenommen. Wer ihn legitim braucht,
    holt ihn über `/gdpr/customer/:id/consent-status` (eigener Check).
  - `POST /api/public/consent/:hash/grant` gab den vollen
    `CustomerConsent[]`-Array inkl. IP-Adressen und `createdBy`
    (Kunden-Name) zurück. **Fix**: Response auf
    `{ granted: <count> }` reduziert. Frontend nutzt eh nur
    `success`-Flag.
  - **Live-verifiziert**: `consentHash: null` in customer-Response,
    `consentHash` weiterhin in `/gdpr/.../consent-status`,
    Grant-Response liefert nur `{granted: 4}` ohne Extra-Keys.

- [x] **🚨 Pentest Runde 4 – HOCH: Cockpit-IDOR (Portal-User sah ALLE Kunden)**
  - **Realer Angriff**: Portal-User Max bekam mit seinem Token
    `GET /api/contracts/cockpit` → komplette Vertragsliste ALLER
    Kunden (Customer-Namen, Vertragsnummern, Statūs).
  - **Root Cause**: `contractCockpitService.getCockpitData()` filterte
    nicht nach Customer, weil das Cockpit ursprünglich nur für Admins
    gedacht war. Die `contracts:read`-Permission haben aber auch
    Portal-User → Endpoint war erreichbar.
  - **Fix**: Service-Signatur erweitert auf
    `getCockpitData({ customerIds? })`. Wenn `customerIds` gesetzt
    sind, werden Haupt-Vertrags-Query, Consent-Maps, Ausweis-
    Warnungen und gemeldete Zählerstände allesamt auf diese IDs
    eingeschränkt. Controller bestimmt `customerIds` analog zu
    `getContracts`: bei `isCustomerPortal` → eigene + vertretene
    Kunden (nur mit Vollmacht); sonst undefined (= alle).
  - **Live-verifiziert**: Admin sieht 17 Verträge (3 Kunden);
    Portal-User Customer 1 sieht 12 (nur seine); Portal-User
    Customer 3 sieht 3 (nur seine); 0 Leaks.

- [x] **🚨 Pentest Runde 3 – drei Findings gefixt**
  - **KRITISCH – `POST /api/developer/setup` ohne Auth (Privilege
    Escalation)**: Endpoint war komplett ohne Authentifizierung
    erreichbar und konnte der Admin-Rolle die `developer:access`-
    Permission verleihen → kompletter DB-Zugriff über `/developer/*`.
    **Fix**: Endpoint ersatzlos gelöscht. Manuelles Setzen geht
    weiterhin über `prisma/add-developer-permission.ts` (CLI).
    Live-verifiziert: `POST /api/developer/setup` → HTTP 404.
  - **HOCH – Customer-Login DoS auf Prod (fehlende Migration)**:
    `portalPasswordMustChange` war im Code, aber prod-DB kannte die
    Spalte nicht → Prisma warf bei jedem Kunden-Login. Root Cause:
    in dieser Session wurde `prisma db push` benutzt (kein Migration-
    File). **Fix**: handgenerierte Migration
    `20260516173552_portal_password_must_change/migration.sql` (via
    `prisma migrate diff` + `migrate resolve --applied`). Verifiziert
    durch shadow-DB-Reset + `migrate deploy`: Spalte landet korrekt
    in einer frischen DB. `entrypoint.sh` führt `migrate deploy`
    beim Container-Start bereits aus → Prod-Restart applied jetzt
    automatisch.
  - **MITTEL – Prisma-Internals-Leak im Login-Error-Body**: Bei
    unerwarteten Fehlern (Schema-Bruch, DB-Down) wurde
    `error.message` direkt zurückgegeben → Tabellen-/Spaltennamen
    leakten. **Fix**: Whitelist-Filter `safeLoginError()` in
    `auth.controller.ts`: nur bekannte Messages
    (`'Ungültige Anmeldedaten'`, `'E-Mail und Passwort
    erforderlich'`) werden durchgereicht, alles andere wird zu
    generischem `'Anmeldung fehlgeschlagen'` und das Original
    landet im Server-Log. Greift für Mitarbeiter- UND Portal-
    Login. Live-verifiziert: Spalte testweise gedropped → Client
    sieht generisch, Server-Log enthält Original.

- [x] **🔐 Einmalpasswort-Flow für Portal-Credentials**
  - **Intention**: Wenn wir Zugangsdaten per E-Mail an den Kunden
    schicken, kennen wir das Passwort als Admin – das ist solange OK,
    bis er sich einmal eingeloggt hat. Danach soll er gezwungen sein,
    sich ein eigenes zu vergeben, und das per-Mail-Passwort ist tot.
  - **Datenmodell**: neues Feld `portalPasswordMustChange: Boolean
    @default(false)` am Customer.
  - **Flow**:
    1. Admin klickt **Zugangsdaten versenden** → Flag wird gesetzt,
       Mail-Template weist explizit auf „Einmalpasswort" hin.
    2. Kunde loggt sich mit dem OTP ein → Backend gibt
       `mustChangePassword: true` im Login-Response zurück UND
       **konsumiert das OTP sofort**: setzt `portalPasswordHash =
       null` und `portalPasswordEncrypted = null`. Ein zweiter
       Login mit demselben Passwort schlägt fehl (401).
    3. Frontend (`ProtectedRoute`) sieht `mustChangePassword=true`
       und leitet auf `/change-initial-password` um – egal welche
       Route der Kunde aufrufen will, er kommt nicht weiter.
    4. Auf der Seite gibt er ein neues, komplexes Passwort vor
       (Live-Hint mit ✓/○, dieselben Regeln wie Backend).
    5. `POST /api/auth/change-initial-portal-password` speichert
       neuen Hash, **löscht das Encrypted-Feld** (Admin kann das
       eigene Passwort des Kunden nicht mehr im Klartext lesen),
       setzt `portalTokenInvalidatedAt = now()` und
       `portalPasswordMustChange = false`.
    6. Frontend loggt aus, leitet zu `/login?changed=1`,
       Erfolgs-Banner: „Passwort wurde geändert. Bitte mit dem
       neuen Passwort anmelden."
  - **Edge case**: Tab geschlossen ohne Setzen → Kunde ist
    ausgesperrt (OTP weg, eigenes Passwort nicht gesetzt). Lösung
    aus seiner Sicht: Passwort-vergessen-Funktion oder Admin
    versendet neue Zugangsdaten.
  - **Edge case**: Admin macht zwischendurch nochmal manuelles
    „Setzen" → `mustChange` wird automatisch wieder `false`. So
    kann ein versehentlich versendetes OTP problemlos durch ein
    direkt-gesetztes Passwort ersetzt werden.
  - **Live-verifiziert (10 Schritte)**: Setzen → Send → Flag in
    DB=true → Login mit OTP gibt mustChange=true zurück + Hash
    in DB ist null → Re-Login mit OTP → 401 → Change-Endpoint
    schwach → 400 → komplex → 200 → Login mit neuem PW →
    mustChange=false + tokenInvalidatedAt gesetzt.

- [x] **🔐 Passwort-Komplexität + Portal-Credentials-UX**
  - **Problem**: Bisher reichten 6 Zeichen für gesetzte Passwörter
    (Portal-Login, User-Reset, Registrierung, User-Anlage). Das hat
    der Pentest bemängelt, und es entsprach auch nicht dem, was wir
    selbst von Endkunden erwarten würden.
  - **Lösung**:
    * `validatePasswordComplexity()` in `passwordGenerator.ts`:
      mind. 12 Zeichen + Großbuchstaben + Kleinbuchstaben + Ziffer
      + Sonderzeichen, mit detaillierter Fehlerliste auf deutsch.
    * Erzwungen in **5 Endpoints**: `setPortalPassword`,
      `confirmPasswordReset`, `register`, `createUser`, `updateUser`.
  - **Neue UX im Kunden-Portal-Block (CustomerDetail)**:
    * **Generate-Button**: erzeugt 16-Zeichen-Zufallspasswort, das
      garantiert allen Komplexitätsregeln entspricht, und füllt
      das Eingabefeld direkt aus.
    * **Send-Credentials-Button**: schickt Login-URL + Username +
      Klartext-Passwort an die Kunden-E-Mail. Funktioniert nur,
      wenn "Portal aktiviert" tatsächlich aktiviert ist.
    * **Live-Komplexitäts-Hint** beim Tippen: ✓/○-Liste zeigt
      sofort, welche Regeln noch fehlen.
    * `alert()`-Boxen durch Toast-Notifications ersetzt.
  - **Live-verifiziert**: schwaches Passwort `hallo123` → HTTP 400
    mit Fehlerliste, komplexes Passwort `Hallo123!Test` → HTTP 200,
    Generator-Endpoint liefert 16-Zeichen-Passwort, Send-Credentials
    versendet Mail nur bei portalEnabled=true.

- [x] **🌐 Real-IP hinter Nginx-Proxy-Manager**
  - **Problem**: Rate-Limiter und Security-Monitor haben statt der
    echten Client-IP nur die NPM-IP (`172.0.2.12`) geloggt. Damit
    wären alle Threshold-basierten Blockings nutzlos – ein Brute-
    Force von 100 verschiedenen Clients wäre für uns 1 Quelle.
  - **Root Cause**: `app.set('trust proxy', 'loopback')` – das passt
    nur, wenn der Proxy auf 127.0.0.1 läuft. NPM läuft aber auf
    einem anderen Host, also wurde X-Forwarded-For ignoriert.
  - **Fix**: trust-proxy abhängig von `HTTPS_ENABLED`:
    `HTTPS_ENABLED=true` → `1` (genau 1 Hop, der NPM), sonst
    `loopback` (Direkt-Verbindungen lokal).
  - **Live-verifiziert**: req.ip zeigt jetzt die echte Browser-IP
    statt der NPM-IP, Threshold-Events triggern korrekt.

- [x] **🚨 KRITISCH: IDOR auf Stressfrei-Email-Sub-Routes (Pentest-Fund)**
  - **Realer Angriff erfolgreich durchgespielt**: Portal-User konnte über
    `/api/stressfrei-emails/{id}/credentials` die kompletten Klartext-
    IMAP/SMTP-Zugangsdaten der Mailbox eines anderen Kunden abrufen.
  - **Root Cause**: der Haupt-Endpoint `GET /:id` hatte
    `canAccessStressfreiEmail`-Check, die **8 Sub-Endpoints** unter
    `:id/*` hatten alle KEINEN Ownership-Check (nur `authenticate +
    requirePermission('customers:read')`, was Portal-User von Haus aus
    haben).
  - **Fix**: `canAccessStressfreiEmail(req, res, id)` als erste Zeile in
    allen 9 betroffenen Controllern: `getMailboxCredentials`,
    `getFolderCounts`, `syncAccount`, `sendEmailFromAccount`,
    `enableMailbox`, `syncMailboxStatus`, `resetPassword`, `updateEmail`,
    `deleteEmail`.
  - **Security-Monitor**: `canAccessResourceByCustomerId` emittiert
    bei jedem Fehlversuch automatisch ein `ACCESS_DENIED MEDIUM`-Event
    → Threshold-Detection (>5 in 5 min) erzeugt `CRITICAL SUSPICIOUS` +
    Sofort-Alert.
  - **Live-verifiziert**: Portal-User Kunde A probiert Email-ID von
    Kunde B durch alle 8 Sub-Routes → **alle 8× HTTP 403**, eigene
    Email-ID kommt sauber durch (200/400), 8× `ACCESS_DENIED`-Events
    im Security-Monitor.

- [x] **🛡️ JWT-Tokens raus aus localStorage – Refresh-Cookie-Pattern**
  - Pentest-Finding „JWT in localStorage (MITTEL)": bei XSS könnte JS
    den Token klauen + alle Anbieter-Credentials abrufen. Lösung:
    Branchenstandard für SPAs.
  - **Access-Token**: kurzlebig (15 min), lebt nur im
    JavaScript-Memory (Modul-State + AuthContext). Kein localStorage
    mehr → XSS-Angriff klaut maximal einen 15-min-Token, mit dem er
    eh nicht weit kommt.
  - **Refresh-Token**: 7 Tage Lifetime, im **httpOnly-Cookie** (`Secure`
    bei HTTPS_ENABLED, `SameSite=Strict`, `Path=/api/auth`). JavaScript
    hat **keinen Zugriff** → XSS kann ihn nicht klauen.
  - Backend:
    * `signAccessToken/signRefreshToken` mit `type`-Claim als
      Unterscheidung; Auth-Middleware lässt nur `type=access` durch
    * Login + Customer-Login setzen Cookie + geben Access im Body
    * `POST /api/auth/refresh` liest Cookie, gibt neuen Access aus,
      rotiert Refresh-Cookie, prüft `tokenInvalidatedAt`
      (sofortige Invalidation bei Rolle-Ändern/Logout)
    * Logout löscht Cookie + setzt `tokenInvalidatedAt`
    * `cookie-parser` als neue dependency
  - Frontend:
    * `api.ts`: in-memory `tokenStore` + axios-Interceptor mit
      Auto-Refresh-Retry bei 401 (single-flight gegen
      Concurrent-Requests)
    * `AuthContext`: beim App-Start `/auth/refresh` aufrufen → wenn
      Cookie noch gültig, ist der User automatisch eingeloggt
      (kein Re-Login nach Tab-Reload trotz memory-only Access-Token)
    * 9 alte `localStorage.getItem('token')`-Stellen migriert auf
      `getAccessToken()` (PDF-Vorschau-iframe, Audit-Log-Export,
      Backup-Download, File-Download-URL, …)
  - Live verifiziert: Login setzt Cookie+Bearer, API-Calls mit
    Bearer→200, ohne→401, Refresh-Endpoint rotiert Cookie sauber,
    Refresh-Token wird als Bearer (Access) abgelehnt („Falscher
    Token-Typ"), Logout löscht Cookie + invalidiert Token.

- [x] **🔒 Audit-Log für alle Klartext-Passwort-Reads**
  - Pentest-Finding „Klartext-Passwörter über API abrufbar (HIGH,
    post-auth)" → reversible Verschlüsselung ist by-design (Feature
    „Anbieter-Login anzeigen" braucht es), aber jeder Decrypt-Vorgang
    sollte im Audit-Log auftauchen. Bisher: keiner der 6 Endpoints
    schrieb ein Log.
  - Audit-Logs jetzt für: `getPortalPassword`, `getContractPassword`,
    `getSimCardCredentials`, `getInternetCredentials`,
    `getSipCredentials`, `getMailboxCredentials`.
  - `action: 'READ'`, eigene Resource-Types (PortalPassword,
    ContractPassword, SimCardCredentials, InternetCredentials,
    SipCredentials, MailboxCredentials), alle mit `sensitivity:
    CRITICAL` über die Sensitivity-Map.
  - Label nennt explizit „Klartext … entschlüsselt" + Ressourcen-ID,
    damit im Audit-Log-Viewer auf einen Blick erkennbar ist, was
    passiert ist (DSGVO-Nachvollziehbarkeit + Insider-Threat-Erkennung).

- [x] **↗ E-Mail-Postfach: Weiterleiten + Erneut senden**
  - **Weiterleiten** (Compose-Modal-Erweiterung): neuer Button im
    EmailDetail öffnet das ComposeEmailModal im Forward-Modus –
    To-Feld leer (User trägt den neuen Empfänger ein), Betreff mit
    „Fwd:"-Prefix, Body mit zitierten Original-Headern (Von, An,
    Datum, Betreff) + Original-Text.
  - **Erneut senden** (One-Click): schickt die Mail noch einmal an
    die ursprüngliche Empfänger-Adresse (= die Stressfrei-Adresse
    selbst). Damit läuft sie durch die heute hinterlegten Forwards
    und landet beim aktuell konfigurierten Kunden-Postfach – Use-Case:
    Stressfrei-Adresse wurde nach Empfang umgestellt, Original ist nur
    in der alten Inbox. Confirm-Dialog mit Hinweis, dass Anhänge nicht
    erneut mit gesendet werden (Weiterleiten dafür nutzen). Toast für
    Erfolg/Fehler.

- [x] **🔍 E-Mail-Postfach: Suche + erweiterte Filter (Variante B)**
  - Suchleiste über der Email-Liste – durchsucht parallel Subject,
    From-Address/Name und Body.
  - Filter-Button mit Badge (Anzahl aktiver Filter) klappt eine Box mit
    Detail-Filtern auf: Von, An, Betreff, Inhalt, Datum von/bis,
    Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status.
    Alle Filter werden im Backend mit UND verknüpft.
  - „Alle zurücksetzen"-Button räumt komplett auf.
  - Backend: `GET /api/customers/:id/emails` nimmt die Filter als
    Query-Parameter entgegen, `getCachedEmails` übersetzt sie in eine
    Prisma `where`-Klausel.
  - **Bewusst nicht gebaut**: voller AND/OR-Builder mit Plus-Button und
    Bool-Verschachtelung – Trade-off-Diskussion mit User: reale
    Use-Cases sind quasi immer AND, UI-Komplexität verschachtelter
    Bool-Builder bringt mehr Bedienprobleme als Mehrwert.

- [x] **🔁 Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren**
  - Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse
    (Tooltip erklärt: „ersetzt die Forwards am Provider durch
    Kunden-Stamm-E-Mail + Service-Adresse"). Use-Case: nach Änderung der
    Stamm-E-Mail eines Kunden, oder nach Wechsel der
    `defaultForwardEmail` in den Provider-Settings.
  - **Bei `hasMailbox: true`** wird zusätzlich das im CRM verschlüsselt
    hinterlegte Mailbox-Passwort am Provider neu gesetzt. Self-Healing
    für den Fall, dass jemand im Plesk-UI manuell ein anderes Passwort
    gesetzt hat und IMAP/SMTP im CRM nicht mehr passt.
  - Backend nutzt Plesk's `updateForwardTargets` (`set:email1,email2`
    → ersetzt komplett, idempotent) + bei Mailbox auch
    `updateMailboxPassword` (Plesk-Passwort-Update).
  - Endpoint: `POST /api/stressfrei-emails/:id/sync-forwarding`,
    `customers:update`-Permission, Audit-Log mit Forward-Targets +
    Passwort-Reset-Marker.
  - Self-Healing: `isProvisioned`-Flag wird bei erfolgreichem
    Provider-Aufruf automatisch auf `true` korrigiert (historischer Bug:
    Flag wurde beim `createEmail` mit `provisionAtProvider: true` nie
    gesetzt – jetzt behoben + Backfill via Sync).
  - Erfolgs-/Fehler-Meldungen via `react-hot-toast` (statt `alert()`)
    mit Liste der gesetzten Forward-Targets + Hinweis ob Passwort-Reset
    durchgeführt wurde.
  - In der Kundenakte (Stammdaten → Kontakt → E-Mail) externes
    Link-Icon, das in neuem Tab direkt den Stressfrei-Tab des Kunden
    öffnet – sichtbar nur wenn Stressfrei-Adressen vorhanden sind.

- [x] **🛡️ Pentest-Hardening-Runde 11: Header-Hygiene**
  - **HSTS-Doppel-Header** (18× low im Audit): Helmet's
    `Strict-Transport-Security` komplett deaktiviert. Der Nginx Proxy Manager
    vor der CRM-VM setzt HSTS bereits, doppelter Header verletzte RFC 6797.
  - **Cache-Control** (≥10× info im Audit):
    `/api/*` bekommt `no-store` (sensible JSON-Daten),
    SPA-HTML (`/`, `/sitemap.xml`, `/robots.txt`, `/vite.svg`) bekommt
    `no-store, must-revalidate` (sonst hängt Browser an alter index.html
    fest nach Deploy),
    `/assets/*` (Vite-Build mit Content-Hash im Filename) bekommt
    `public, max-age=31536000, immutable`.
  - **CSP No-Fallback-Direktiven** (2× medium): `worker-src`, `manifest-src`,
    `media-src` explizit auf `'self'` – ZAP markiert sonst „Failure to
    Define Directive with No Fallback".
  - Bewusst NICHT angefasst: `style-src 'unsafe-inline'` (Tailwind/React-
    inline-styles, kompletter Refactor unverhältnismäßig).
  - Live verifiziert: Headers für `/`, `/api/*`, `/assets/*.js` und SPA-
    Fallback-Pfade alle wie erwartet.

- [x] **🐛 PDF-Vorschau im PDF-Template-Editor lädt nicht**
  - CSP-Direktive `frame-ancestors 'none'` blockte ALLE iframe-Embeddings
    der eigenen Resourcen, auch same-origin – Browser zeigte je nach
    Variante "Verbindung abgelehnt" oder CSP-Violation.
  - Fix: `frame-ancestors 'self'` (statt `'none'`). App darf eigene
    Resourcen embeden (z.B. die annotierte PDF-Vorschau), externe Sites
    bleiben weiterhin gesperrt.

- [x] **🔁 Factory-Defaults Sync-Scripts (dev ↔ prod ↔ Image)**
  - `./factory-export.sh` zieht eine ZIP per API in `factory-exports/`
    (gitignored Drop-Box).
  - `./factory-import.sh [zip]` lädt die ZIP per API in eine andere Instanz
    – ohne Argument wählt es die jüngste ZIP automatisch.
  - `./factory-import.sh --save-as-builtin` entpackt die ZIP zusätzlich nach
    `backend/factory-defaults/` (vorher aufgeräumt). Damit landet sie beim
    nächsten `docker-compose up --build` als Werkseinstellung im Image und
    seedet frische DBs automatisch.
  - Konfigurierbar per Env: `OPENCRM_URL`, `OPENCRM_EMAIL`,
    `OPENCRM_PASSWORD` (sonst interaktive Abfrage).
  - README-Abschnitt „Factory-Defaults: Stammdaten-Kataloge teilen"
    komplett überarbeitet (drei Transport-Pfade, Auto-Seed, Whitelist).

- [x] **🚀 Auto-Seed: Werkseinstellungen beim Erst-Deploy**
  - Inhalt von `backend/factory-defaults/` wird via Dockerfile als
    `/app/factory-defaults-builtin/` ins Image gebrannt.
  - Entrypoint spielt sie nach erfolgreichem Auto-Seed (frische DB) automatisch
    via `tsx scripts/seed-factory-defaults.ts` ein – steuerbar über
    `FACTORY_DEFAULTS_DIR`.
  - Damit bringen neue VMs sofort Anbieter, Tarife, PDF-Auftragsvorlagen +
    Datenschutzerklärung/Impressum mit, ohne manuelles UI-/CLI-Import.
  - Bestehende Installs werden NIE überschrieben (Trigger nur wenn der
    Auto-Seed im selben Start-Lauf gelaufen ist).

- [x] **📦 Factory-Defaults: HTML-Templates + Import via UI**
  - Datenschutzerklärung, Impressum, Vollmacht-Vorlage und Website-Datenschutz
    werden jetzt mit ins Factory-Defaults-ZIP gepackt (`app-settings/`-Ordner,
    Whitelist-geschützt – andere AppSetting-Keys werden ignoriert).
  - Import läuft jetzt auch über die UI (Einstellungen → Factory-Defaults →
    „ZIP hochladen"). Der CLI-Weg `npm run seed:defaults` bleibt erhalten und
    wurde gleichermaßen um die HTML-Templates erweitert.
  - Zwei-Wege-Roundtrip live verifiziert: Export → AppSetting löschen →
    Import → Wert wieder vollständig hergestellt; Counts in Audit-Log.

- [x] **🐛 Benutzer-Verwaltung: DSGVO- + Entwickler-Zugriff zuweisbar**
  - Mass-Assignment-Whitelist (`pickUserUpdate`) hat `hasGdprAccess` /
    `hasDeveloperAccess` rausgefiltert → Service erhielt sie nie → Rollen
    DSGVO/Developer waren in der UI nicht zuweisbar (Checkbox ohne Wirkung).
  - Beide Felder zur Whitelist hinzugefügt + Audit-Log liest die Pre-Werte
    jetzt aus den geladenen Rollen (kein False-Positive-Change mehr).

- [x] **🔒 HTTPS-only-Header per Flag (`HTTPS_ENABLED`)**
  - HSTS + `upgrade-insecure-requests` (CSP) sperrten den Browser bei
    direktem `http://ip:port`-Zugriff aus (`ERR_SSL_PROTOCOL_ERROR`).
  - Beide Header default OFF, kommen nur mit `HTTPS_ENABLED=true` (sobald
    TLS-Reverse-Proxy davor steht).

- [x] **🗃️ Prisma-Migrations-System (statt `db push`)**
  - Initial-Migration `0_init` aus aktuellem Schema generiert
    (`prisma migrate diff --from-empty --to-schema-datamodel`).
  - 24 alte gedriftete Migrations gelöscht – frischer Start.
  - `migration_lock.toml` für MySQL hinzugefügt.
  - Container-Entrypoint umgebaut:
    - Auto-Baseline-Detection: bestehende DB ohne `_prisma_migrations` →
      `migrate resolve --applied 0_init` läuft automatisch.
    - Statt `db push --accept-data-loss` jetzt `migrate deploy` (idempotent,
      datenerhaltend, keine stillen DROPs mehr).
  - Neuer npm-Script `schema:sync` (lokal/Dev): legt automatisch eine
    versionierte Migration mit Zeitstempel-Namen an
    (`prisma migrate dev --name auto_$(date +%Y%m%d_%H%M%S)`).
  - Workflow ab jetzt: schema.prisma ändern → `npm run schema:sync` →
    Migration committen → Push → Container-Restart wendet sie automatisch an.

- [x] **🔄 Automatische Vertrags-Status-Übergänge**
  - Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
    `status=ACTIVE` und `endDate < heute` → `EXPIRED` (mit Audit-Log).
  - Beim Upload der Kündigungsbestätigung (`cancellationConfirmationPath`):
    wenn Vertrag aktuell `ACTIVE` → auf `CANCELLED` setzen (Audit-Log).
    Frontend fragt per Modal das Bestätigungs-Datum ab (Default: heute),
    wird direkt als `cancellationConfirmationDate` gespeichert.
    Der "Optionen"-Upload löst den Status-Wechsel bewusst NICHT aus, da er
    für Vertragsänderungen (nicht echte Kündigungen) gedacht ist, setzt
    aber `cancellationConfirmationOptionsDate` analog.
  - Beim Upload einer `Lieferbestätigung` (ContractDocument via direkt-Upload
    oder Email-Anhang-Import): wenn Vertrag aktuell `DRAFT` → auf `ACTIVE`
    setzen + `startDate` auf das erfasste Lieferdatum (falls leer).
    Frontend zeigt Datums-Input conditional, wenn Typ "Lieferbestätigung"
    ausgewählt ist.
  - Keine neuen Status eingeführt: `cancellationSentDate` vs.
    `cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
    abzubilden. `ACTIVE` bleibt bis zur Bestätigung.

- [x] **🛡️ Security-Hardening vor Production-Deployment (10 Runden)**
  - Vollständige Story inkl. aller Live-Test-Tabellen + Trade-offs:
    **[SECURITY-HARDENING.md](./SECURITY-HARDENING.md)**
  - Erste 2 Runden zusätzlich ausführlich in
    [SECURITY-REVIEW.md](./SECURITY-REVIEW.md)
  - Highlights:
    - Runde 1–3: CORS, Helmet, JWT-Fallback, IDOR-Welle 1, XSS, Mass
      Assignment, Zip-Slip, Path-Traversal, JWT-Algorithm, Rate-Limiter
    - Runde 4: 9 Live-IDORs (customer.\*/gdpr.\*) + Error-Handler
    - Runde 5: `/api/uploads`-Auth (DSGVO-GAU), Login-Timing,
      Privacy-Policy-XSS
    - Runde 6: Customer-List-Leak, XFF-Rate-Limit-Bypass,
      Self-Grant + Existence-Disclosure
    - Runde 7: SSRF-Schutz (Cloud-Metadata-Block), Logout-Endpoint
    - Runde 8: DNS-Rebinding-Schutz, Per-File-Ownership-Check
    - Runde 9: `npm audit fix` (8 Vulns weg), Audit-Chain-Rehash, keine
      neuen Critical-Findings → diminishing returns erreicht
    - Runde 10: Security-Monitoring (SecurityEvent-Tabelle + Hooks an
      Login/IDOR/SSRF/Reset/Logout/JWT-Reject + Threshold-Detection +
      Sofort-Alert für CRITICAL + Hourly-Digest + UI in Einstellungen)
  - Deployment-Checkliste komplett (in HARDENING.md)

- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
  - **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link)
    - Email-Reset-Token mit 2h Gültigkeit (kryptografisch sicher: 32 Byte Random)
    - Funktioniert für Mitarbeiter UND Portal-Kunden (Typ-Auswahl)
    - User-Enumeration-Schutz: immer 200 OK, egal ob Email existiert
    - Reset-Link per Email mit schönem HTML-Template
    - Nach Reset: alle bestehenden Sessions werden gekickt
  - **Rate-Limiting** gegen Brute-Force
    - Login: 10 Versuche pro 15 Min pro IP (erfolgreiche zählen nicht)
    - Passwort-Reset-Anfrage: 5 Versuche pro Stunde pro IP
  - **Cron-Job für automatische Geburtstagsgrüße**
    - Täglich 08:00 Uhr: alle Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true
    - Email-Versand über System-E-Mail, Du/Sie-abhängiger Text
    - Catch-up 30s nach Server-Start (falls Server am Geburtstag kurz down war)
    - Marker lastBirthdayGreetingYear verhindert Doppel-Versand

- [x] **Mandantenfähigkeit: Domain + Kunden-E-Mail-Label dynamisch pro Provider**
  - Neues Feld `customerEmailLabel` am EmailProviderConfig (z.B. "Stressfrei-Wechseln", "Meine-Firma")
  - Wenn leer, wird das Label automatisch aus der Domain abgeleitet ("stressfrei-wechseln.de" → "Stressfrei-Wechseln")
  - Neuer Frontend-Hook `useProviderSettings()` liefert Domain + Label
  - Alle hardcoded "Stressfrei-Wechseln" und `@stressfrei-wechseln.de` Strings durch dynamische Werte ersetzt
    (CustomerDetail, ContractForm, ContractDetail, EmailClientTab, Settings)
  - Modal-Eingabefeld "Bezeichnung für Kunden-E-Mails" in Provider-Einstellungen
  - Notwendig für Multi-Mandanten-Betrieb wenn das CRM an Dritte vermietet wird

- [x] **Factory-Defaults: Export + Import von Stammdaten-Katalogen**
  - Enthält: Anbieter, Tarife, Kündigungsfristen, Laufzeiten, Vertragskategorien, PDF-Auftragsvorlagen (+ PDF-Dateien)
  - Enthält NICHT: Kundendaten, Verträge, Dokumente, Emails, Einstellungen (dafür gibt es den Datenbank-Backup)
  - Neue Einstellungsseite „Factory-Defaults" mit Übersicht (Anzahl pro Kategorie) und Export-Button
  - Export: ZIP mit manifest.json + Kategorie-JSONs + PDF-Dateien, Download über Browser
  - Import-Script: `npm run seed:defaults` liest `backend/factory-defaults/`, merged mehrere JSONs pro Kategorie, upsertet idempotent + kopiert PDFs in uploads/
  - Ordner `backend/factory-defaults/` gitignoriert (außer .gitkeep + README), damit firmen-spezifische Kataloge nicht ins Repo kommen

- [x] **Email-Anhänge → Vertragsdokumente + Rechnungen für alle Vertragstypen**
  - Im SaveAttachmentModal (bei einem per Email zugeordneten Vertrag) gibt es jetzt drei Modi:
    1. **Als Dokument** (in feste Slots wie Kündigungsschreiben) – wie bisher
    2. **Als Vertragsdokument** – neu, mit Typ-Dropdown (Auftragsformular, Lieferbestätigung, Vertragsunterlagen, Vollmacht, Widerrufsbelehrung, Preisblatt, Sonstiges) + Notizen
    3. **Als Rechnung** – jetzt für **alle** Vertragstypen (vorher nur Strom/Gas)
  - Gleiches gilt für das Speichern der gesamten Email als PDF-Rechnung
  - Neuer Backend-Endpoint `saveAttachmentAsContractDocument` für die flexible ContractDocument-Tabelle

- [x] **Geburtstag-Management-Modal in Kundenstammdaten**
  - Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal
  - **Gruß zurücksetzen:** setzt `lastBirthdayGreetingYear` auf null zurück (fürs Debugging + Fallback)
  - **Gruß jetzt senden:** per Email (direkt), WhatsApp/Telegram/Signal (öffnet vorbefülltes Fenster)
  - Beide Aktionen mit Ja/Nein-Bestätigungsdialog (kein versehentliches Klicken)
  - Text respektiert Du/Sie-Einstellung des Kunden
  - Checkbox "Automatisch senden" mit Kanal-Dropdown (neue Felder am Customer)
  - Audit-Log für Reset + Send

- [x] **Anrede-Verhältnis Du/Sie pro Kunde**
  - Neues Feld `useInformalAddress` in Stammdaten (auch bei Firmenkunden)
  - Default: Sie (formell)
  - Geburtstagsgruß im Portal nutzt die Anrede: "Du"-Kunden bekommen "Herzlichen Glückwunsch, Max!", "Sie"-Kunden "Herzlichen Glückwunsch, Herr Müller!"
  - Komplett konsistent auch bei nachträglichen Glückwünschen ("hattest" vs "hatten")

- [x] **Geburtsdatum + Geburtsort auch bei Firmenkunden**
  - Felder werden jetzt unabhängig vom Kundentyp angezeigt
  - Ermöglicht z.B. Geburtstage für Ansprechpartner bei Firmen

- [x] **Geburtstagskalender + Geburtstagsgruß-Modal**
  - Admin: Section im Vertrags-Cockpit mit Kunden, die in den nächsten 30 Tagen oder letzten 7 Tagen Geburtstag haben
  - Portal: Modal mit Gruß am Geburtstag (inkl. nachträglichem Glückwunsch bis 7 Tage danach)
  - Wird pro Jahr nur einmal angezeigt

- [x] **Typspezifische Zusatzinfos in Vertragslisten**
  - Strom/Gas → "Lieferadresse: ..."
  - DSL/Glasfaser/Kabel → "Anschlussadresse: ..."
  - Mobilfunk → "Rufnummer: ..."
  - KFZ → "Kennzeichen: ..."
  - Sichtbar in Admin-Liste, Portal-Liste und Kunden-Tab

- [x] **Datenschutzerklärung PDF ↔ Online-Einwilligungen synchronisieren**
  - PDF hochgeladen → alle 4 Consents auf GRANTED
  - Haken entfernt im Portal → PDF löschen + Tabs sperren
  - Entsperrung nur durch alle Haken oder neues PDF

- [x] **Zweitarif-Zähler (HT/NT)** bei Strom + Verbrauchsberechnung

- [x] **Datumsformate vereinheitlichen** (01.01.2026 statt 1.1.2026)

- [x] **Audit-Log aussagekräftig** (Vorher/Nachher bei allen Änderungen)

- [x] **Impressum + Website-Datenschutzerklärung** im Kundenportal
  - Editor in Einstellungen
  - Vorschlagstexte

- [x] **Consent-Bestätigungs-Flow per Email**
  - Alle Hebel müssen gesetzt sein
  - Bestätigungsbutton + Bestätigungsemail

- [x] **Vertragsdokumente-Upload** (Auftragsformular, Lieferbestätigung, Vertragsunterlagen als PDF/PNG)

- [x] **Bug: Stressfrei-Email im Auftragsgenerator** (funktioniert jetzt im Vertrag)

- [x] **PDF-Auftragsvorlagen-System**
  - Template-Editor in Einstellungen
  - PDF hochladen, Formularfelder automatisch auslesen
  - CRM-Felder zuordnen (visuell mit Vorschau)
  - Seitenweise Sortierung der Felder
  - Dynamische Rufnummern-Felder mit Vorwahl-Extraktion
  - Nicht zugeordnete Felder bleiben editierbar
  - Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)

- [x] **Eigentümer-Verwaltung**
  - An Adresse gehängt (Firma, Vorname, Nachname, Anschrift, Kontakt)
  - Fallback auf Kundendaten wenn leer
  - Nur bei Liefer-/Meldeadressen (nicht Rechnung)
  - Namens-Kombinationen (Firma + Vorname + Nachname etc.)

- [x] **Gruppenauswahl Liefer-/Rechnungs-/Eigentümer-Adresse** im Auftragsgenerator

- [x] **Objekttyp + Lage + Lage des Anschlusses** bei Festnetz-Verträgen (DSL/Glasfaser/Kabel)

- [x] **Bankverbindung-Fallback** im PDF-Generator (neueste aktive Bankverbindung des Kunden)
