Commit Graph
100 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 5 389fd30094 Build-Fehler im Benutzerformular behoben
Zwei Typfehler aus dem vorigen Commit: Der Formular-Reset beim Anlegen eines
neuen Benutzers kannte isServiceAccount nicht, und der Typ von userApi.create
ebenfalls nicht.

Ursache meines Uebersehens: Ich hatte mit 'npx vite build' geprueft, das
Projekt nutzt aber 'npm run build' = 'tsc && vite build'. Die Typpruefung lief
bei mir damit gar nicht mit. Ab jetzt npm run build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:45:26 +02:00
duffyduckandClaude Opus 5 b696980793 Dienstkonto-Kennzeichen in der Benutzerverwaltung setzbar machen
Nachgezogen: Das Feld isServiceAccount lag 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 mit Erklaerung im
Klartext, Feld in der Mass-Assignment-Whitelist (gilt 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:09:28 +02:00
duffyduckandClaude Opus 5 910c94daa1 Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)
R183-01: 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 geladene Waffe war ungegatet,
der Abzug gegatet. Fix: Absenken verlangt confirm SHORTEN, wird als CRITICAL mit
Vorher/Nachher protokolliert, Untergrenze 30 Tage fuer Authentication/AuditLog.

R183-02: Nach dem Cleanup meldete verify "Keine Manipulation" bei valid:false
und 3010 endgueltig geloeschten Anmeldeprotokollen - der Befund stand nur im
Feld, die Prosa beruhigte. Fix: ehrliche Formulierung bei Luecken, und das
Gegenbuch ruft /verify mit und wertet valid:false hart, egal wie der Text
klingt.

R183-03: verify warnte dauerhaft "Altbestand nicht versiegelt", waehrend
seal-backlog zu Recht ablehnte. Eine unaufloesbare Warnung lernt man zu
ignorieren. Fix: eigener Zustand nicht_noetig, echte Warnung nennt den Befehl.

Heartbeat-Wachhund: 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 genau
eine Meldung. Verifiziert in allen drei Faellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:01:32 +02:00
duffyduckandClaude Opus 5 41671cbb96 Dienstkonto-Anmeldungen als Routine statt CRITICAL (Pentest R182)
Das Gegenbuch meldet sich stuendlich an - protokolliert wurde das als
Authentication/CRITICAL. Damit stand das vorhersagbarste Ereignis im System auf
der hoechsten Stufe: Jeder legitime Lauf trainiert den Betreiber darauf,
CRITICAL wegzuklicken, und der erste echte Vorfall erbt diesen Reflex. Dieselbe
Alarm-Muedigkeits-Klasse wie R162, diesmal von uns selbst erzeugt.

Nicht unterdrueckt, sondern eingestuft: Neues Kennzeichen isServiceAccount am
Benutzer (Migration 20260822100000). Anmeldungen solcher Konten erscheinen
weiterhin im Log - man soll sehen, dass das Gegenbuch arbeitet - aber als
Routine (LOW) mit eigenem Label 'Dienstkonto ... angemeldet (planmaessig)'.
Alle anderen Anmeldungen bleiben CRITICAL.

Offen und dem Pentester so benannt: Der eigentliche Tripwire waere der
AUSBLEIBENDE Heartbeat (Dienstkonto meldet sich N Intervalle nicht mehr) sowie
Anmeldungen ausserhalb der Kadenz oder von fremder Quell-IP. Das braucht einen
Zeitgeber und ist noch nicht gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:18:54 +02:00
duffyduckandClaude Opus 5 1f53a3304b Doku: Erster Start des Gegenbuchs als Drei-Schritt-Ablauf
Die Genesis-Bestaetigung stand nur als Nebensatz in der Anleitung. Jetzt als
eigener Abschnitt mit den drei Schritten (freigeben, starten, wieder leeren),
der erwarteten Ausgabe und der Begruendung, warum das Leeren wichtig ist:
Bleibt die Zeile gesetzt, wuerde nach einem Verlust des Datenverzeichnisses
stillschweigend eine neue Grundlage gesetzt statt nachgefragt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:58:30 +02:00
duffyduckandClaude Opus 5 601fb03b22 Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken
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. Der .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 weiterhin unprivilegiert. Schlaegt
das chown fehl (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, erzeugte Dateien gehoeren 1000:1000, der
private Schluessel liegt mit 0600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:51:10 +02:00
duffyduckandClaude Opus 5 1eb65809ec Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten
Blocker behoben: Ich hatte ein dauerhaftes API-Token vorausgesetzt - das gibt
es in OpenCRM nicht. Zugangstoken leben 15 Minuten, der Gegenbuch-Container
waere nach dem ersten Durchlauf gestorben. Aufgefallen erst durch die Frage des
Betreibers, 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 (falsches
Passwort, Anmelde-Bremse, CRM nicht erreichbar) werden unterschieden und im
Klartext gemeldet.

.env.example nennt jetzt zu jedem Schalter die gueltigen Werte - bisher liess
sich nur raten, ob es prod oder production heisst. Einschliesslich des Falls
"erst nur Staging testen, Prod spaeter dazunehmen".

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 faellt auch auf, wenn das Gegenbuch
aufhoert zu arbeiten.

Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung verlangt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:31:46 +02:00
duffyduckandClaude Opus 5 89d617ab70 Doku: Gegenbuch in der Haupt-README, zwei Betriebsarten getrennt
Zwei Luecken, beide beim Nachlesen aufgefallen:

1. Die Haupt-README erwaehnte das Gegenbuch mit keinem Wort - ausgerechnet
   dort, wo der Betreiber nach einem git pull nachschaut. Jetzt ein Abschnitt
   direkt nach dem Audit-Siegel: wozu es gut ist, die Richtung der Verbindung
   (Gegenbuch holt, CRM kennt es nicht), die vier Befehle zum Einrichten und
   der Verweis auf die ausfuehrliche Anleitung. Ausdruecklich als optional
   gekennzeichnet - ohne Gegenbuch bleibt der Schutz in der Anwendung
   vollstaendig. Projektbaum ergaenzt.

2. Die Notar-README stammte aus der Zeit mit externem Git-Server. Fuer den
   lokalen Betrieb standen darin Anforderungen, die es dort gar nicht gibt -
   "Force-Push serverseitig sperren, das ist Pflicht" ist ohne Server
   sinnlos und verwirrt. Jetzt eine Entscheidungstabelle ganz oben (lokal =
   Normalfall vs. externes Repository = zusaetzliche Haertung), und alles
   Server-Bezogene gesammelt unter einer eigenen Ueberschrift mit dem Hinweis,
   dass es im lokalen Betrieb uebersprungen werden kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:59:11 +02:00
duffyduckandClaude Opus 5 cb9f1f5fce Gegenbuch: Daten im Projektverzeichnis statt in Docker-Volumes
Projektkonvention wie beim Hauptstack: Bind-Mount auf
tools/audit-notary/data/<instanz>/ statt benannter Volumes.

Wichtiger Nebeneffekt, der vorher fehlte: Das Verzeichnis war nicht von der
Versionsverwaltung ausgenommen - Signaturschluessel und Gegenbuch waeren beim
naechsten Commit im Repository gelandet. Jetzt ist der Inhalt ignoriert,
waehrend die Verzeichnisse selbst ueber .gitkeep bestehen bleiben. Letzteres
ist noetig, weil Docker fehlende Bind-Mount-Ziele als root anlegt und der
Container als UID 1000 laeuft - der erste Start waere sonst am Schreibrecht
gescheitert.

Verifiziert mit echtem docker compose gegen eine CRM-Attrappe: Buch,
Schluessel, Beobachtungsspeicher und status.txt landen unter
tools/audit-notary/data/prod/, Normalbetrieb exit 0. Testcontainer, Image,
Attrappe und .env danach entfernt; nur die drei .gitkeep bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:54:45 +02:00
duffyduckandClaude Opus 5 a62c51b7c7 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine
Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer eigenen Maschine fuer
Prod und Staging; ein externes Git-Repository entfaellt, das Buch liegt lokal.
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.

tools/audit-notary/ enthaelt jetzt Dockerfile, entrypoint.sh,
docker-compose.yml und .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.

Lokaler Betrieb ist jetzt ein vollwertiger Modus statt eines Testschalters. Die
Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist und was nicht - statt
der frueheren pauschalen Formulierung "kein Manipulationsschutz", die im
Einsatz auf eigener Maschine schlicht falsch war.

Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne
Bestaetigung -> Code 4; mit Bestaetigung Normalbetrieb exit 0; Eintrag
veraendert -> Alarm exit 2; Eintraege geloescht -> Alarm exit 2; Siegel
verschwunden -> Alarm.

Behoben beim Bauen: useradd -u 1000 || true verschluckte, dass UID 1000 im
Node-Image vergeben ist - der Container startete gar nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:51:45 +02:00
duffyduckandClaude Opus 5 773033936d Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (R181)
R181-01: Der Pruefmodus sagte zu, nichts zu veraendern und kein Schreibrecht zu
brauchen - und pushte trotzdem, weil ankerNachziehen() in jedem Modus lief. Ein
read-only Audit mutierte damit still das geteilte Substrat.

R181-02: Ein Widerspruch zwischen den eigenen Fixes. R180-01 erhebt die
Serversperre auf refs/notary/* zur tragenden Pflicht, R180-02 verlangt dort
Schreibrecht zur Selbstheilung. Sobald je ein Anker fehlte, bekam jeder
read-only pruefende Auditor dauerhaft einen Fehler auf einer voellig gueltigen
Kette, den er nicht beheben konnte. Fix: Reparieren nur im Notar-Schreiblauf,
im Pruefmodus wird der fehlende Anker gemeldet.

Eigener Rueckgabecode 5: "Anker unvollstaendig" ist nicht "nicht feststellbar" -
die Kette ist gueltig, nur das Substrat-Gedaechtnis unvollstaendig, ein
benannter reparierbarer Defekt. Dieselbe Trennung wie bei Genesis/Adoption.

Empirisch beantwortet: Die Notar-Identitaet laesst sich eng auf das Anlegen von
refs/notary/* beschraenken, ohne Loeschen oder Ueberschreiben - serverseitig
unterscheidbar an der Null-OID. Mit pre-receive-Hook verifiziert: Backfill
greift, Loeschen und Force-Overwrite bleiben abgewiesen. Hook als Beispiel in
der README.

Verifiziert: read-only --check mit fehlendem Anker -> Code 5, nichts gepusht;
Notar-Schreiblauf unter derselben ACL -> Anker nachgetragen, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:56:36 +02:00
duffyduckandClaude Opus 5 a954f0f736 Gegenbuch: Anker belegen nichts mehr, Anker-Verlust wird laut (R180-01/-02)
R180-01: 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, aber nie Unversehrtheit belegen. Der Code-4-Diskriminator haengt
nicht mehr an ihnen und greift nur im Schreiblauf.

R180-02: Der Anker-Push-Fehlschlag war nur eine Warnung mit exit 0 -
ausgerechnet bei der tragenden Eigenschaft. Der Normalbetrieb senkte damit den
Hoechststand still um eins. Fix: exit 3 bei Fehlschlag, und jeder Lauf zieht
fehlende Anker nach, bevor er etwas als OK meldet.

Verifiziert: Teil-Loeschung mit frischem Auditoren-Klon -> exit 3 mit Vorbehalt
statt exit 0; Anker-Push per Hook abgelehnt -> exit 3 statt Hinweis; Folgelauf
traegt den fehlenden Anker nach.

README: Der Schutz von refs/notary/* gegen Loeschen und Ueberschreiben ist von
der Fussnote zur tragenden Voraussetzung erhoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:59:07 +02:00
duffyduckandClaude Opus 5 1a252c5059 Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (R180)
"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 gekoppelt.

Fix: Jeder verankerte Checkpoint bekommt einen eigenen Ref refs/notary/seq-N.
Der ueberlebt einen Force-Push auf den Zweig - die hoechste je existierende
Nummer ist damit aus dem Server rekonstruierbar. Geprueft wird, ob der hoechste
verankerte Checkpoint noch im aktuellen Kopf enthalten ist und ob die Reihe
mindestens so lang ist wie verankert.

Laute Verlustbehandlung mit Diskriminator "traegt der Remote schon
Checkpoints?": keine Historie -> Genesis, einmalig NOTARY_GENESIS_ACK; Historie
vorhanden aber kein Gedaechtnis -> Anomalie, Code 4, keine stille Adoption,
erst nach NOTARY_ADOPT_ACK.

Verifiziert: Genesis ohne Bestaetigung -> Code 4; mit Bestaetigung Kette
aufgebaut samt refs/notary/seq-1..5; Rewind -> exit 2 auch nach Loeschen des
lokalen Speichers und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft
gruen); zusaetzlich Anker-Refs geloescht -> Code 4 statt stiller Uebernahme.

Dokumentiert: Die Anker-Refs muessen serverseitig ebenfalls vor Loeschen und
Ueberschreiben geschuetzt sein, sonst verschiebt sich das Problem eine Ebene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 09:10:02 +02:00
duffyduckandClaude Opus 5 b6b6f7c0a7 Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (R179 a/b)
(a) Probe-Push verworfen. Er haette nur den geprobten Ref beurteilt, die eigene
Push-Identitaet gemessen statt die des Angreifers (Bypass-Rechte fuer Admins
gehen genau dann auseinander, wenn es zaehlt), nur einen Zeitpunkt abgedeckt -
und einen zerstoerungsfreien Force-Push gibt es nicht: die bestaetigende
Beobachtung waere derselbe Vorgang wie der Schaden.

Stattdessen ein Fast-Forward-Waechter: Der beobachtete Remote-Kopf wird
ausserhalb des Klons festgehalten; beim naechsten Lauf muss der neue Kopf ein
Nachfahre des alten sein. Das erkennt das Ereignis statt die Regel abzufragen
und wirkt unabhaengig von serverseitigem Schutz. Ein belegter Fast-Forward
gilt als Nachweis und blendet den Rewind-Vorbehalt aus.

(b) Code 3 als Tor vor dem Anhaengen statt als Status danach: Der Schreiblauf
signiert mit dem neuen Checkpoint zugleich ueber den Bestand darunter - ist die
Basis ungeklaert, waere das Anhaengen selbst das Waschmittel. Grundlage nicht
feststellbar -> nichts anhaengen, exit 3. Erster Lauf -> Basislinie, ehrlich
gemeldet, exit 3. Anhaengen geklappt, Push gescheitert -> exit 3 mit "erstellt,
aber NICHT verankert". 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
(bisher stilles Gruen); kaputtes Push-Ziel -> "erstellt, aber nicht verankert"
exit 3, Folgelauf haelt den ungepushten Commit fail-closed an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:29:20 +02:00
duffyduckandClaude Opus 5 8d1ffc0df8 Gegenbuch: Unsicherheit erreicht jetzt den Rueckgabecode (Pentest R179)
R179-b: Der Rewind-Vorbehalt stand in der Ausgabe, der Exit blieb 0. Die 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, eine Ebene hoeher. Fix: Code 0 nur bei belegter
Gewissheit (Rewind-Schutz zugesichert oder Mindesthoehe erfuellt), sonst Code 3
- bewusst nicht mit dem Manipulationsalarm 2 verschmolzen.

R179-01: NOTARY_MIN_SEQ ist eine Untergrenze, kein Ist-Stand. Ein veralteter
Wert liess einen Teil-Rewind darueber lautlos durch, und die blosse Praesenz
einer Zahl blendete den Vorbehalt aus - MIN_SEQ=0 war ein Freibrief, ein
Tippfehler wurde still verschluckt. Ein veralteter Anker erzeugte damit ein
selbstbewussteres Ergebnis als gar keiner. Fix: Der Vorbehalt haengt allein an
NOTARY_REWIND_PROTECTED=true und benennt bei gesetztem MIN_SEQ dessen Grenze;
MIN_SEQ <= 0 oder unparsbar fuehrt zu exit 1 statt stiller Annahme.

Verifiziert gegen den Pentest-Aufbau (10 Checkpoints, Rewind auf 7, DB passend
gekuerzt, frischer Klon): nicht gesetzt -> exit 3 (vorher 0); 10 und 8 -> Alarm
2; 7 veraltet -> 0 mit Vorbehalt (vorher ohne); 0 und xyz -> exit 1 (vorher
stilles 0); Rewind-Schutz zugesichert -> 0 ohne Vorbehalt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:10:36 +02:00
duffyduckandClaude Opus 5 d50d8f6036 Gegenbuch: Rewind auf signierten Praefix benennbar gemacht (Pentest R178-01)
Das 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 - ausgerechnet im dokumentierten Pruef-Fall.

Das laesst sich im Skript nicht kryptographisch erkennen, die Historie ist echt.
Deshalb zwei Dinge statt eines Scheinfixes: NOTARY_MIN_SEQ als Bezugspunkt (ist
die Reihe kuerzer, Alarm; jeder Lauf nennt die Nummer), und ohne diesen
Bezugspunkt sagt die Erfolgsmeldung ausdruecklich, dass ein Zurueckspulen nicht
erkennbar war.

README: serverseitiger Rewind-Schutz (non-fast-forward verbieten) jetzt als
Pflicht formuliert, samt Begruendung und dem Hinweis, dass ein frischer Klon
den Rewind nicht sieht.

Kleinkram: NOTARY_SIGNER_FINGERPRINT wird beim Einlesen getrimmt (ein
Zeilenumbruch loeste 4/4 Fehlalarme aus, die auf den korrekten Fingerabdruck
zeigten); die "nie gepusht"-Meldung priorisiert Untersuchen statt Pushen.
Beim Testen selbst gefunden: der erste git-Aufruf war ungeschuetzt und warf bei
kaputtem Klon einen Stacktrace.

Verifiziert: Rewind 3->1 mit gekuerzter DB -> frischer Klon ohne Bezugspunkt OK
mit Vorbehalt, mit NOTARY_MIN_SEQ=3 -> Alarm exit 2; Pin mit Zeilenumbruch kein
Fehlalarm; regulaerer Lauf unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:46:02 +02:00
duffyduckandClaude Opus 5 8d2dfb8be1 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (R177)
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 konnte das Gegenbuch waschen und force-pushen, und
%G? war G. R176-01 hatte "jeder selbst erzeugte Schluessel" geschlossen, "jeder
erlaubte Schluessel" blieb offen. Fix: Pin wird aus user.signingkey abgeleitet;
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 dauerhaft rot, behebbar nur
per Force-Push, den die Branch-Protection gerade verhindern soll. Fix:
Pin-Abgleich am frischen Commit vor dem Push, bei Abweichung Ruecknahme.

Kleinkram: NOTARY_ALLOW_LOCAL faerbt Erfolgsmeldungen ein und pusht nicht mehr
ins Leere; CRM-Fehler liefern eine erklaerende Zeile statt Node-Stacktrace.

Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff -> Alarm
exit 2 auch ohne gesetzten Pin; Schreiblauf mit falschem Schluessel ->
zurueckgerollt, nichts gepusht; CRM nicht erreichbar/401 -> saubere Meldung;
Lokalmodus eingefaerbt; saubere Historie ohne Fehlalarm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:09:58 +02:00
duffyduckandClaude Opus 5 5952fb1894 Gegenbuch: nur vertrauenswuerdige Signaturen, fail-closed ohne Remote (R176)
R176-01 (HIGH): Das Signatur-Gate akzeptierte %G? = G ODER U. Bei
SSH-Signaturen bedeutet U woertlich "gute Signatur, aber kein passender
Principal" - der Schluessel steht also NICHT in allowed_signers. Damit
passierte jeder selbst erzeugte Schluessel das Gate und der einzige
In-System-Vertrauensanker war wirkungslos. End-to-end reproduziert: Gegenbuch
mit fremdem Schluessel re-signiert und force-gepusht -> "OK", exit 0.
Vorbedingung war nur Remote-Schreibrecht, kein Host-Zugriff.
Fix: nur G an beiden Stellen, dazu 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 Ausfalls gruenes Licht auf nicht notarisiertem Zustand. Fix: fail-closed
mit Code 3, ebenso ohne konfigurierten Remote (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 gegen echten Remote: Angriff mit fremdem
Key + Force-Push -> Alarm exit 2 (vorher OK); Remote unerreichbar -> exit 3;
kein Remote -> exit 3; falscher Fingerabdruck-Pin -> Alarm; saubere Historie
ohne Fehlalarm. Rueckgabecodes in der README dokumentiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:26:55 +02:00
duffyduckandClaude Opus 5 375d4ae1e2 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)
R175-01 (HIGH): Die erste Fassung signierte zwar, prueft aber nie. Sie las ihre
Wahrheit per readFileSync aus der lokalen Arbeitsdatei, nirgends 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.

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.

R175-02 (MEDIUM): /checkpoint fuhr je Aufruf ein volles verifyIntegrity (O(n),
0,85 s bei 16k Zeilen) - authentifizierte DoS-Verstaerkung. Gebraucht wurde nur
die Siegel-Wurzel. Jetzt Kopf-Hash aus der Kopfzeile, Wurzel aus dem juengsten
gueltigen Marker, sealLeafCount statt des teuren Status.

R175-03: writeFileSync lief vor dem Commit, eine verwaiste Zeile wurde vom
Folgelauf zementiert. Jetzt Ruecknahme bei Fehlschlag, und NOTARY_SIGN=false
verlangt zusaetzlich NOTARY_INSECURE_ACK.

Verifiziert mit echtem SSH-Signaturschluessel: stilles Waschen -> Alarm;
erfundene Zeile -> Alarm; unsignierter Commit -> Alarm; Commit-Fehlschlag ->
zurueckgerollt; Reflex-Schalter verweigert; Pruefmodus haengt nichts an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:13:54 +02:00
duffyduckandClaude Opus 5 f3ded9afbc Gegenbuch: externe Notarisierung der Audit-Kette
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.

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, zeitgestempelt 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.

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.

Ehrlich dokumentiert: 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:20:55 +02:00
duffyduckandClaude Opus 5 2d55fd23f9 Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)
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
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 und "Alle Eintraege
unveraendert und lueckenlos verkettet", also null Spur. Meine Antwort auf die
Frage des Pentesters war damit falsch: die Luecke reisst nur, solange der
Marker nicht am Ende steht.

Fix: Gegen-Check "Blaetter vorhanden, aber kein gueltiger Marker" -> neuer
Status entfernt statt kein_siegel, mit ausdruecklicher Meldung. Ein gebrochenes
oder entferntes Siegel kippt jetzt valid auf false, auch ohne beanstandete
Einzelzeile. Der beruhigende Einstiegssatz entfaellt bei Siegelproblemen.

Verifiziert ueber den echten HTTP-Pfad, exakt Szenario 4: Marker nachweislich
das Ketten-Ende, Marker + Middleware-Decoy geloescht, 0 Ketten-Luecken ->
valid:false, Status entfernt, Klartext-Warnung. Auch der Nicht-Tail-Fall
geprueft. Wegwerf-Datenbanken danach geloescht, Dev unberuehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:45:10 +02:00
duffyduckandClaude Opus 5 ac62198a01 Bestandssiegel betriebstauglich gemacht (Pentest R173-01/-02/-03)
R173-01 (HIGH): Das Siegel war ueber HTTP tot. Die generische auditMiddleware
protokolliert den POST /seal-backlog-Request selbst als AuditLog/CREATE mit
demselben endpoint - exakt die Signatur, mit der der Marker gesucht wurde, nur
mit hoeherer id und ohne changesAfter. Der Selektor griff diese Zeile, root war
undefined, Ergebnis: dauerhaft "gebrochen" bei null manipulierten Zeilen.
Mein Testfehler: sealBacklog()/verifyIntegrity() direkt aufgerufen, nie ueber
HTTP - die Middleware lief nie mit. Dieselbe Fehlerklasse wie R165.
Fix: eigener Ressourcentyp AuditBacklogSeal, den die Middleware nie vergibt,
zusaetzlich muss der Marker auswertbares {toId, root} tragen.

R173-02 (MEDIUM): Fehlende Zeilen wurden uebersprungen, das Loeschen eines
gesiegelten Einbruchsbelegs erschien nur als unerklaerte Luecke, waehrend der
Indikator "intakt" meldete. Fix: fehlende gesiegelte Zeilen sind ein
Siegelbruch mit eigener Liste (backlogMissing) und werden namentlich gemeldet.

R173-03 (MEDIUM): Die R170-01-Haertung (Vorbefund im Marker) war auf
seal-backlog nie angewandt, ein Re-Seal absorbierte Manipulationen mit weniger
Spur als ein Rehash. Fix: Der Marker haelt den Befund vor dem Siegeln fest
samt Status und Wurzel des vorherigen Siegels; verify weist die Anzahl
gueltiger Siegel aus und warnt bei mehr als einem.

Verifiziert ueber den echten HTTP-Pfad inkl. Middleware (separate Wegwerf-DB,
Mischbestand 6xV1/4xV2/4xV3): Protokollzeile vorhanden, Status trotzdem
intakt; Loeschen einer gesiegelten Zeile -> gebrochen mit backlogMissing[2];
erneutes Siegeln -> Anzahl 2 gemeldet, Vorbefund im Marker. tsc + vite build
gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:20:32 +02:00
duffyduckandClaude Opus 5 2932598c98 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02)
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 umschreiben und
errorMessage leeren - alles Nicht-Hash-Felder, Hash unveraendert - und /verify
meldete weiterhin valid=true. Ein Einbruchsversuch war unsichtbar in einen
Erfolg umschreibbar.

Rueckwirkend signieren geht nicht, ein Rehash waere die falsche Medizin.
Stattdessen ein einmaliges, nicht destruktives Bestandssiegel: je Altzeile ein
Blattwert, die Wurzel darueber in einem HMAC-signierten Marker.

Umgesetzt nach den vier Bedingungen aus dem Pentest:
1. Blaetter ueber den vollen Zeileninhalt inkl. id und hashVersion, nicht ueber
   den 7-Feld-V1-Hash - sonst lebte die Luecke im Siegel weiter.
2. Wurzel signiert (steht im Marker, der selbst V3/HMAC ist). Ohne
   AUDIT_HMAC_KEY wird das Siegeln abgelehnt.
3. Bereich fix auf [1 ... v3FromId-1] statt Live-Abfrage hashVersion < 3. Sonst
   haette ein Up-Flip der Grenzzeile sie aus der geprueften Menge gedraengt.
4. Pruefung je id: vorhanden, weiterhin Altbestand, Inhalt == Blatt, dazu
   Wurzelabgleich.

Neuer Endpunkt POST /audit-logs/seal-backlog (audit:admin, confirm SEAL).
/verify meldet den Siegelzustand im Klartext, auch wenn kein Siegel existiert.

Verifiziert in separater Wegwerf-DB mit Mischbestand (6xV1, 5xV2, 5xV3): ohne
Siegel ist der Angriff unsichtbar, mit Siegel wird er erkannt und die Zeile
benannt; der Up-Flip der Grenzzeile wird ebenfalls erkannt. Wegwerf-DB danach
geloescht, Dev-Daten unberuehrt. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:12:51 +02:00
duffyduckandClaude Opus 5 2b0af772a2 Manifest-Kanal abgesichert (Pentest R171-01 HIGH, R171-03 LOW)
verifyIntegrity vertraute Loeschungs-Manifesten bedingungslos, ohne zu pruefen,
ob die Traegerzeile 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
aendern, und damit eigene Loeschungen als "erklaert" ausweisen. Damit fiel
zugleich die Eskalation an signierten Zeilen aus - Anker UND Versionsgrenze
umgangen.

Fix: Ein Manifest zaehlt nur, wenn die Traegerzeile laut Versionsgrenze Stufe 3
sein muss, dies auch deklariert, und ihre HMAC-Signatur mit einem
konfigurierten Schluessel aufgeht. Ohne Schluessel gibt es keine gueltigen
Traeger - Luecken bleiben dann unerklaert, die sichere Richtung.

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, Traegerzeile
selbst beanstandet; legitimes signiertes Manifest erklaert die Luecke
weiterhin. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:42:48 +02:00
duffyduckandClaude Opus 5 32c9efacda rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)
POST /audit-logs/rehash rechnet die Kette mit dem HMAC-Schluessel neu und macht
sie damit wieder stimmig - auch wenn sie vorher berechtigte Beanstandungen
enthielt. Der Anker schuetzt 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: Vor dem Rehash wird verifyIntegrity() erhoben und samt Kettenkopf im
Marker gesichert - Anzahl geprueft, Listen der manipulierten Zeilen, der
Ketten-Luecken, der Luecken ohne dokumentierte Loeschung, der nicht pruefbaren.
Dazu ausloesender Benutzer und IP statt pauschal "system". Der Marker entsteht
nach dem Rehash, ist Teil der neuen Kette und signiert.

Fix 2: rehash verlangt {"confirm":"REHASH"}, cleanup verlangt
{"confirm":"CLEANUP"}. Beide wurden bei blinder Methoden-Erkundung per POST
unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400.

Verifiziert: blinder POST -> 400 ohne Wirkung; mit Bestaetigung laeuft der
Rehash und der Marker enthaelt Ausloeser, Vorbefund (1 manipuliert, 7 Luecken
mit exakten IDs) und Kettenkopf.

Hinweis: Der Test hat auf der DEV-Datenbank real rehasht, die dortigen
historischen Beanstandungen sind damit geglaettet. Staging/Prod unberuehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:16:08 +02:00
duffyduckandClaude Opus 5 791711ca58 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01)
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. Das hebelte die Kern-Garantie von R164-02 aus:
Diebstahl faellt bei der naechsten Nutzung auf.

Fix: Kulanz idempotent. Die jti des Nachfolgers wird bereits 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 vorlegt, kollidiert zwangslaeufig und loest den
Familien-Widerruf aus. Fehlt der Nachfolger, wird bewusst nicht ersatzweise
rotiert (das waere wieder der Fork), sondern fail-closed als Replay gewertet.

Verifiziert (PoC nachgebaut): T0 legit -> TA, T0 replayt -> TC, TA.jti ==
TC.jti - kein Fork mehr. Ueber HTTP: zwei Tabs beide erfolgreich auf derselben
Linie; gestohlener Token nach Fensterablauf -> 401, Familie widerrufen,
Angreifer-Linie tot, SUSPICIOUS/CRITICAL gemeldet. Regression: 40 parallel ->
4 erfolgreich auf einer Linie, seriell 1-4 ok und 5. Replay, Token ohne jti
fail-closed, Logout widerruft. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:17:51 +02:00
duffyduckandClaude Opus 5 655d20db23 .env.example: Caddy-Standard "aus" unmissverstaendlich gemacht
Der Schalter stand bereits standardmaessig auf aus (COMPOSE_PROFILES leer),
aber eine leere Zeile liest sich wie ein vergessener Wert - genau diese Frage
kam beim Audit-Schluessel schon einmal auf. Jetzt steht der Standard an der
Zeile selbst ("leer lassen = Caddy aus") und im Kommentarblock mit Pfeil
markiert. Die drei Caddy-Werte darunter sind als "nur bei aktivem Caddy
noetig" gekennzeichnet.

Vorher geprueft, dass Kommentare hinter dem Wert von compose sauber
abgeschnitten werden (WERT=3010 # Kommentar -> 3010) - die Datei nutzt diesen
Stil bereits an anderer Stelle. Anschliessend mit der woertlichen Zeile aus
.env.example gegengetestet: nur der ungeschuetzte Dienst startet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:46:23 +02:00
duffyduckandClaude Opus 5 ffeb4dbfd8 Caddy per Schalter in der .env statt per CLI-Flag
Frage aus dem Betrieb: laesst sich ein Compose-Abschnitt ueber eine Variable
in der .env zu- und abschalten, statt ihn auszukommentieren? Bedingte Bloecke
kennt Compose nicht - aber COMPOSE_PROFILES darf in der .env stehen und
aktiviert das Profil, ohne dass ein Flag noetig ist.

  COMPOSE_PROFILES=        -> Caddy wird nicht angelegt (Standard)
  COMPOSE_PROFILES=caddy   -> Caddy startet bei `docker-compose up -d` mit

Vorteil gegenueber `--profile caddy`: der Schalter wirkt auch bei down, logs
und ps, wo das Flag leicht vergessen wird und dann verwaiste Container
zurueckbleiben.

Empirisch geprueft mit einem separaten Testprojekt (compose 1.29.2): ohne den
Eintrag startete nur der ungeschuetzte Dienst, mit Eintrag beide.

Dokumentiert in .env.example, im Kommentar am caddy-Dienst und in der README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:43:59 +02:00
duffyduckandClaude Opus 5 22501f4650 Nur noch eine docker-compose.yml und eine .env
Der zweite Stack unter docker/ war ein Duplikat aus Februar, das nie
mitgepflegt wurde - und genau deshalb schwer zu finden und leicht falsch zu
bedienen. Statt ihn zu loeschen (Caddy ist fuer Betreiber ohne eigenen
Reverse-Proxy zurecht gewuenscht) ist er jetzt in den Hauptstack integriert:

- caddy als optionaler Dienst mit `profiles: ["caddy"]` in docker-compose.yml.
  Ohne `--profile caddy` wird er nicht einmal angelegt - empirisch geprueft mit
  einem separaten Testprojekt: `up -d` startete nur den ungeschuetzten Dienst.
  Der laufende Stack bleibt damit unveraendert.
- Caddyfile in den Projektstamm verschoben, proxy-Ziel auf den Dienstnamen
  `opencrm` angepasst, Zertifikate unter ./data/caddy wie alle anderen Daten.
- DOMAIN, CADDY_DIR und CADDY_CONFIG_DIR in die .env.example aufgenommen.
- docker/ entfernt (Dockerfile, entrypoint.sh, docker-compose.yml,
  .env.example, README.md). Das dortige Dockerfile basierte noch auf Alpine -
  genau die Variante, von der das Projekt wegen Prisma-/TLS-Problemen bewusst
  auf node:20-slim gewechselt ist. Das gepflegte backend/Dockerfile baut
  Frontend und Backend ebenso.
- README: Abschnitt "Docker (Produktion)" ersetzt durch "Betrieb mit eigenem
  SSL", inkl. Hinweisen zu Let's-Encrypt-Limits, DNS-Voraussetzung,
  HTTPS_ENABLED und dem weiterhin offenen App-Port. Projektbaum und
  .dockerignore nachgezogen.

Geprueft: docker-compose config gueltig, caddy validate gueltig, laufende
Container unberuehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:40:43 +02:00
duffyduckandClaude Opus 5 ee83b09ed8 Caddy-Stack (docker/) auf Stand gebracht statt entfernt
Der Stack bleibt bewusst erhalten: Er richtet sich an Betreiber OHNE eigenen
Reverse-Proxy, weil Caddy das Zertifikat selbst holt und erneuert. Er war
allerdings seit Februar stehengeblieben und reichte zehn Variablen nicht an
den Container durch - teils sicherheitsrelevant.

Behoben:
- HTTPS_ENABLED ergaenzt (Default true, Caddy terminiert TLS). Fehlte bisher
  komplett, dadurch waere der Refresh-Cookie OHNE Secure-Attribut gesetzt
  worden und trust proxy falsch gewesen.
- JWT_EXPIRES_IN Default von 7d auf 15m korrigiert - der Wert galt dem
  ACCESS-Token und stammte aus der Zeit vor dem Access-/Refresh-Pattern. Ein
  Access-Token mit einer Woche Lebensdauer im Browser-Speicher macht das
  XSS-Fenster unnoetig gross.
- JWT_REFRESH_EXPIRES_IN, CORS_ORIGINS, LISTEN_ADDR, SSRF_BLOCK_PRIVATE_IPS
  ergaenzt. JWT_REFRESH_EXPIRES_IN stand bereits in docker/.env.example,
  wurde aber nie durchgereicht - dieselbe Fehlerklasse wie beim Audit-Siegel.
- Caddyfile: gzip fuer /api/* deaktiviert (BREACH, CVE-2013-3587). Die
  Konfiguration komprimierte bisher alles, obwohl die README das fuer die
  API ausdruecklich ausschliesst. Statische Assets bleiben komprimiert.
  Mit `caddy validate` geprueft.
- docker/.env.example um die neuen Variablen erweitert, dazu ein Hinweis auf
  Sonderzeichen im DB-Passwort (dieser Stack setzt die DATABASE_URL direkt
  zusammen, anders als der Entrypoint im Projektstamm).
- README benennt jetzt, wann welcher Stack der richtige ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:36:03 +02:00
duffyduckandClaude Opus 5 0ae6b13d6c Audit-Siegel: Schluessel erreicht jetzt tatsaechlich den Container
Ohne diesen Fix waere das Siegel im Docker-Betrieb wirkungslos geblieben - die
Variable wurde nirgends an den Container durchgereicht. docker-compose.yml
listet die Umgebungsvariablen einzeln auf und speist sie aus der .env im
Projektstamm; ein env_file gibt es nicht, backend/.env wird vom Container also
gar nicht gelesen. Dokumentiert und ergaenzt hatte ich bisher nur
backend/.env.example - also die Datei, die fuer den Container irrelevant ist.

- AUDIT_HMAC_KEY und AUDIT_HMAC_KEY_OLD in docker-compose.yml und in
  docker/docker-compose.yml aufgenommen (beide mit :- Default, damit ohne Wert
  weiterhin fail-safe ungesiegelt geschrieben wird)
- beide Variablen samt Erklaerung in die .env.example im Projektstamm und in
  docker/.env.example uebernommen
- README benennt jetzt explizit, welche Datei fuer welchen Betrieb gilt
  (Stamm-.env bei Docker, backend/.env nur ohne Container)

Verifiziert: `docker-compose config` loest AUDIT_HMAC_KEY korrekt auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:29:34 +02:00
duffyduckandClaude Opus 5 ab0d6214f2 Audit-Siegel: Platzhalter zaehlt nicht als Schluessel + Betriebsfalle dokumentiert
.env.example zeigt jetzt wie bei den anderen Secrets einen sichtbaren
Platzhalter an der Variablen statt eines leeren Werts - verstaendlicher, aber
mit Schutz dagegen: ein nicht ersetzter Platzhalter (enthaelt < oder >, oder
beginnt mit change/dein/your/hier) gilt NICHT als Schluessel. Sonst wuerde mit
einem oeffentlich im Repository stehenden Wert gesiegelt, was Sicherheit
vortaeuscht. Das Backend warnt in dem Fall im Log und laesst das Siegel aus.
Verifiziert: mit Platzhalter geschriebene Zeile bleibt hashVersion 2.

Dabei aufgefallen und dokumentiert: Wird das Siegel nach Aktivierung wieder
abgeschaltet (Schluessel fehlt, z. B. beim Rebuild verloren), sind die in
dieser Zeit entstandenen Eintraege ungesiegelt und werden beanstandet, sobald
der Schluessel zurueck ist. Das ist Absicht - ein ungesiegelter Eintrag
inmitten gesiegelter ist von einer Faelschung nicht zu unterscheiden. Warnung
in README und .env.example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:24:59 +02:00
duffyduckandClaude Opus 5 6de3a91aa7 .env.example: erklaert, warum beim Audit-Siegel kein Beispielwert steht
Die leeren Werte bei AUDIT_HMAC_KEY/AUDIT_HMAC_KEY_OLD wirkten wie ein
Versehen. Sie sind Absicht: leer bedeutet "Siegel aus" (fail-safe), und ein
Beispielwert waere hier gefaehrlicher als keiner - er stuende oeffentlich im
Repository, und jeder koennte damit Eintraege siegeln, waehrend der Betreiber
sich gesiegelt waehnt. Anders als bei JWT_SECRET, wo ein Platzhalter sinnvoll
ist, weil die Anwendung ohne Wert gar nicht laeuft.

Entsprechender Hinweis jetzt direkt ueber beiden Variablen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:21:33 +02:00
duffyduckandClaude Opus 5 fb0915df12 Audit-Siegel: Umstiegsweg dokumentiert + Rotations-Fussangel entschaerft
Frage aus dem Betrieb: bestehende Installation hat noch keinen
AUDIT_HMAC_KEY - was passiert beim nachtraeglichen Setzen? Antwort jetzt in
README und .env.example: setzen, neu starten, fertig. Bestehende Eintraege
bleiben unveraendert gueltig, neue werden gesiegelt, alt und neu koexistieren
ohne Fehlalarm. Nachgemessen auf gemischtem Bestand (4903 x V1, 57 x V2,
67 x V3): 0 Beanstandungen. Rueckwirkend siegeln ist nicht moeglich.

Dabei zwei Fehler in der eigenen Doku gefunden und korrigiert:

1. Behauptet war, nach dem Leeren von AUDIT_HMAC_KEY_OLD seien alte Eintraege
   "nicht mehr pruefbar". Tatsaechlich werden sie als MANIPULIERT gemeldet -
   ein falscher Schluessel ist von einer Faelschung nicht zu unterscheiden.
   Gemessen: 67 Eintraege als manipuliert. Nur wenn GAR KEIN Schluessel
   gesetzt ist, gilt "nicht pruefbar". Doku entsprechend korrigiert, inkl.
   Warnung, das Feld nicht voreilig zu leeren.

2. AUDIT_HMAC_KEY_OLD bot nur einen Platz. Beim ZWEITEN Wechsel waeren alle
   mit dem ersten Schluessel gesiegelten Eintraege faelschlich als
   manipuliert erschienen (reproduziert: 67). Das Feld nimmt jetzt eine
   kommagetrennte Liste entgegen; verifiziert: mit beiden Alt-Schluesseln
   0 Beanstandungen, mit nur dem juengsten 67.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:14:24 +02:00
duffyduckandClaude Opus 5 93cb5ffd27 Doku: Audit-Siegel (AUDIT_HMAC_KEY) verstaendlich erklaert
README und .env.example erklaeren jetzt zuerst in Alltagssprache, was das
Audit-Log ist, wogegen der Schluessel schuetzt und was passiert, wenn man ihn
weglaesst oder verliert - inklusive Bild vom Notarstempel. Die technische
Ebene bleibt vollstaendig erhalten, in der README als aufklappbarer Block
(Hash-Versionen 1/2/3, Version-Floor, Loeschungs-Manifest, Rueckgabefelder von
/verify, bekannte Grenze) und in .env.example als eigener Abschnitt.

Ausserdem dokumentiert: Schluesselwechsel ueber AUDIT_HMAC_KEY_OLD ohne
Rehash, pro Umgebung ein eigener Schluessel, und die Warnung zu den beiden
Endpunkten mit Nebenwirkung (rehash/cleanup).

AUDIT_HMAC_KEY zusaetzlich in die Production-Checkliste aufgenommen.

Korrigiert: Die Integritaetspruefung ist NICHT ueber die Oberflaeche
erreichbar (verifyIntegrity wird von keiner Komponente genutzt) - die Doku
zeigt jetzt den API-Aufruf. Rechte, Endpunkte und Rueckgabefelder gegen den
Code geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:06:35 +02:00
duffyduckandClaude Opus 5 9fcab6f17e Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)
Die Rotation war 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 Sitzungsfamilie (neue Tabelle RefreshTokenRecord). Beim
Einloesen wird die jti verbraucht; taucht sie erneut auf, wird die gesamte
Familie widerrufen und der Vorfall als SUSPICIOUS/CRITICAL gemeldet. Der Token
selbst wird nicht gespeichert - die Signatur authentifiziert ihn bereits, und
ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.

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.

Das 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 unbenutzt
lasen.

Verifiziert: 90 parallele Requests -> nur 4 erfolgreich (1 + Kulanz 3), 27 als
Replay erkannt, alle Folge-Tokens tot; 2 parallele Tabs weiterhin erfolgreich;
gestohlener Token spaeter erneut abgewiesen; Logout widerruft die Familie;
ueber HTTP kommt SUSPICIOUS/CRITICAL an. tsc + vite build gruen.

Deploy-Hinweis: Refresh-Tokens ohne jti (Bestand vor dem Deploy) werden
fail-closed abgewiesen - alle angemeldeten Nutzer muessen sich einmalig neu
anmelden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:02:53 +02:00
duffyduckandClaude Opus 5 044a12f73e Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)
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 ausserhalb der Datenbank. Ohne ihn laesst sich keine gueltige
Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr.

Fail-safe: Ohne Schluessel wird weiter Version 2 geschrieben, es faellt nichts
aus. Signierte Zeilen gelten dann als nicht pruefbar (unverifiableEntries) und
ausdruecklich nicht als manipuliert. AUDIT_HMAC_KEY_OLD erlaubt einen
Schluesselwechsel ohne Rehash.

Restluecke der Versionsgrenze geschlossen: Wird die FRUEHESTE Zeile einer Stufe
herabgestuft, wandert MIN(id) mit - die Grenze allein haette den Downgrade
durchgewunken (der erste Testlauf fiel genau darauf durch). Der Nachfolger ist
jedoch HMAC-signiert und sein previousHash ohne Schluessel nicht faelschbar;
eine unerklaerte Luecke vor einer signierten Zeile gilt deshalb als Befund.

Verifiziert: Inhalt geaendert -> erkannt; Downgrade 3->2 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.

AUDIT_HMAC_KEY in .env.example dokumentiert. Der Schluessel selbst liegt nur
lokal in .env (gitignored).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:50:57 +02:00
duffyduckandClaude Opus 5 1a349d142e hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)
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. Ergebnis: Fehl-Login als Erfolg
getarnt, Pruefung meldet "gueltig". An der letzten Zeile der Kette entsteht
dabei 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 ab, gilt die
Zeile selbst als manipuliert. Geprueft wird immer mit dem erwarteten Verfahren.
Die Grenze laesst sich durch Herabstufen einzelner Zeilen nicht verschieben.

Zusaetzlich konsultiert die Pruefung 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 mit Nachfolger erkannt, Downgrade der
Tail-Zeile erkannt, Gegenrichtung (V1 faelschlich als V2) erkannt, keine
Falschmeldungen auf Bestandsdaten. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:12:13 +02:00
duffyduckandClaude Opus 5 f2a4baacdb Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)
R166-01 (HIGH): Der 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 hingen
am selben Vorgaenger. Meine vorherige Messung war zu schwach: sie suchte Luecken
zwischen Nachbarn, nicht Forks. Fix: einzeiliger Mutex AuditChainLock mit
FOR UPDATE (InnoDB-Zeilensperren fallen erst beim COMMIT) plus
isolationLevel ReadCommitted. Belegt im Direktvergleich mit geweitetem Fenster:
Release-vor-Commit forkt, Zeilensperre nicht.

R166-02 (MEDIUM): Der Hash deckte nur 7 Felder ab. changesBefore/After, success,
ipAddress, resourceLabel, dataSubjectId, userId/customerId waren ungeschuetzt -
ein Einzeledit dort blieb unsichtbar. Fix: hashVersion + generateHashV2 ueber
alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash
gueltig. Verifiziert: 5/5 zuvor ungeschuetzte Felder werden jetzt erkannt.

R166-03 (LOW): "kein Cookie" (normaler Erstbesuch) wurde als HIGH/abgelehnt
gefuehrt - jetzt eigener Ausgang mit LOW. Nur echte Ablehnung bleibt HIGH.

R166-04 (LOW, pre-existing): GET /retention-policies wurde von GET /:id
verschluckt. Konkrete Routen jetzt vor der Parameter-Route.

Design-Empfehlungen: runRetentionCleanup schreibt ein Loeschungs-Manifest
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag - Luecken
ausserhalb bleiben erklaerungsbeduerftig. rehashAll schreibt einen Marker.

Verifiziert: 50 parallele Schreiber -> 50/50, 0 Forks, alle V2, manipuliert 0,
Luecken unveraendert 7. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:56:10 +02:00
duffyduckandClaude Opus 5 c7d6b6de7e Audit-Pruefung: "manipuliert" von "Luecke" getrennt + Retention fuer Routine-Auth
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 dadurch wertlos, dasselbe Muster wie beim
Refresh-Rauschen.

Fix: Rueckgabe um tamperedEntries (Inhalt nachtraeglich veraendert, ernst) und
chainGaps (Verkettung unterbrochen durch parallele Schreibvorgaenge oder
geloeschte Zeilen, meist harmlos) erweitert. invalidEntries bleibt als Summe
erhalten. Controller formuliert die Meldung eindeutig, Frontend-API-Typ
nachgezogen.

Problem 2 (Aufbewahrung): Token-Refreshes landen seit der Entrauschung 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. Fix: Regel Authentication/LOW mit 90 Tagen, als
idempotente Migration und im Seed.

Sensitivitaet steuert die Aufbewahrung und ist keine Alarmstufe - normale
Logins und Zugriffe auf Bankdaten/Ausweise bleiben bewusst CRITICAL, ein
Herabstufen wuerde still die Aufbewahrungsfrist verlaengern.

Verifiziert: Live-Test gegen Dev-DB - echte Manipulation einer Zeile wird als
manipuliert erkannt und nicht mit Luecken verwechselt, Ketten-Luecken bleiben
bei 7, Originalzustand exakt wiederhergestellt. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:30:36 +02:00
duffyduckandClaude Opus 5 477a850a91 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)
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 - die Kette zerriss (Bruchstellen im Bestand vom
05.05. und 07.05.2026).

Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen benannten
MySQL-Lock (GET_LOCK). Der Lock liegt in der DB und wirkt daher auch ueber
mehrere App-Instanzen hinweg. Release im finally, weil benannte Locks nicht
transaktional sind - sonst wandert die Sperre mit der Verbindung zurueck in den
Pool und blockiert alle weiteren Schreiber.

Verworfener erster Ansatz: SELECT ... FOR UPDATE auf das Kettenende nimmt Gap-/
Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren - gemessen gingen
38 von 40 parallelen Eintraegen durch Deadlocks verloren, still verschluckt vom
catch. Ein fehlender Audit-Eintrag ist unsichtbar und damit gefaehrlicher als
ein sichtbarer Kettenbruch.

Verifiziert: 100 parallele Schreiber -> 100/100 geschrieben, 0 neue Brueche
(444 ms); Folge-Schreiber in 6 ms, IS_FREE_LOCK frei (kein Lock-Leak).
Ungueltige Zeilen bleiben bei den 7 historischen. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:37:34 +02:00
duffyduckandClaude Opus 5 89ae7b73a1 Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben
verifyIntegrity meldete 3107 von 4630 Zeilen als "manipuliert" - davon 3100
Fehlalarme, exakt die Zeilen mit resourceId = NULL aus 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.

Sicherheitsrelevant, weil ein staendig grundlos ausloesender Alarm ignoriert
wird - echte Manipulation ginge im Laerm unter.

Fix: generateHashLegacy() reproduziert das alte Schreibverhalten,
verifyIntegrity akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
wenn die aktuelle Variante nicht passt). Bewusst KEIN Rehash - der wuerde die
Manipulations-Beweiskraft der Vergangenheit zerstoeren. Gespeicherte Hashes
bleiben unangetastet.

Verifiziert: ungueltig 3107 -> 7 (echte Ketten-Brueche). Adversarial
gegengetestet: Manipulation an userEmail/action/endpoint/createdAt/resourceId
wird bei alten wie neuen Zeilen zu 100 % erkannt (10/10). tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:32:22 +02:00
duffyduckandClaude Opus 5 ad520e20a7 Audit-Log: Pfad-Matching im finish-Handler gefixt (Pentest R165-01)
Die Entrauschung aus de0d6bd war live wirkungslos - aber nicht wegen eines
Deploy-Miss, sondern weil der Code nie erreicht wurde: 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). Saemtliche
path.includes('/auth/...')-Checks liefen ins Leere -> Fallback POST->CREATE mit
Default-Sensitivitaet CRITICAL.

Betraf nicht nur den neuen TOKEN_REFRESH: LOGIN/LOGOUT/LOGIN_FAILED waren im
Audit-Stream seit jeher generisch (pre-existing), ebenso das endpoint-Feld.
Der SecurityEvent-Stream war nie betroffen (eigene emit-Calls), daher lief das
Alerting korrekt.

Fix: vollen Pfad einmal synchron beim Eintritt festhalten (req.originalUrl,
wird von Express nie mutiert) und downstream ausschliesslich diesen nutzen -
determineAction, generateHumanLabel, extractDataSubjectId, manuallyLoggedPaths
und endpoint. TOKEN_REFRESH zusaetzlich in die "immer loggen"-Ausnahme.

Verifiziert (E2E mit echter Middleware gegen Dev-DB): TOKEN_REFRESH/LOW,
TOKEN_REFRESH/HIGH, LOGIN/CRITICAL, LOGIN_FAILED/CRITICAL, LOGOUT/CRITICAL,
alle mit vollem endpoint-Pfad. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:11:44 +02:00
duffyduckandClaude Opus 4.8 d599eb3702 Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)
Fehlgeschlagener /auth/refresh (Replay/Brute-Force auf geraubte
Refresh-Tokens) wurde als TOKEN_REFRESH/LOW geloggt und entging der
Alarmierung - ein Angreifer konnte von /login auf /refresh ausweichen,
um unter der LOGIN_FAILED-Schwelle zu bleiben.

Audit-Actions speisen die Alert-Engine nicht (die zaehlt SecurityEvent
via emit). Fix daher an zwei Ebenen:

- Detection: refresh()-Catch emittiert TOKEN_REJECTED -> greift die
  bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP -> CRITICAL). Severity
  wie Access-Token: abgelaufen/revoked = LOW (kein Sofort-Alert),
  ungueltige Signatur/Manipulation = HIGH. auth.service reicht dafuer
  err.code REFRESH_EXPIRED/REFRESH_INVALID durch. "Kein Cookie" emittiert
  bewusst nicht (normaler Erstbesuch).
- Audit-Triage: fehlgeschlagener Refresh -> Sensitivitaet HIGH statt LOW
  + Label "Token-Refresh abgelehnt". Action bleibt TOKEN_REFRESH
  (semantisch ein Refresh, kein Login).

Verifiziert: tsx-Test abgelaufen->LOW, manipuliert/garbage->HIGH; tsc gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 18:20:21 +02:00
duffyduckandClaude Opus 4.8 de0d6bd817 Audit-Log: stiller Token-Refresh entrauscht (eigene Action TOKEN_REFRESH)
POST /auth/refresh wurde als CREATE / "Anmeldung erstellt" / CRITICAL /
anonymous geloggt und sah damit wie eine anonyme Login-Flut aus. Es ist
aber der regulaere Silent-Refresh des Frontend-Interceptors (Access-Token
lebt nur im Speicher -> nach Reload/401 einmaliger Cookie-Refresh).

- Neuer AuditAction-Wert TOKEN_REFRESH (Migration 20260818120000,
  idempotentes MODIFY COLUMN)
- determineAction() mappt /auth/refresh -> TOKEN_REFRESH, Label
  "Sitzung verlaengert (Token erneuert)", Sensitivitaet explizit LOW
  (statt Default Authentication -> CRITICAL)
- LOGIN/LOGOUT/LOGIN_FAILED bleiben unveraendert CRITICAL
- Frontend: Filter-Option + dezente Badge-Farbe + Typ-Union
- anonymous bewusst beibehalten (Endpoint ohne authenticate-Middleware)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 17:51:21 +02:00
duffyduckandClaude Opus 4.8 7bdb885a10 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)
createContract/updateContract spreadeten energyDetails/tvDetails/
carInsuranceDetails/mobileDetails (via ...mobileData) roh an Prisma.
Injizierte id/contractId konnten ein Detail-Objekt auf einen Fremdvertrag
reparenten oder den PK frei setzen (stilles 200 statt 400) - MEDIUM
(Integritaet; kein Cross-Tenant, staff-only, Portal 403).

Fix: Feld-Whitelists (pickEnergyScalars/pickMobileScalars/pickTvScalars/
pickCarInsuranceScalars, analog R158) an allen Spread-Stellen in create+update.
internet war bereits explizit (preparedInternetData) - safe. Whitelists
programmatisch gegen die DB-Spalten abgeglichen (minus id/contractId/
verschluesselt) - alle Diffs leer.

Verifiziert: energyDetails{basePrice:99.99, id:999999, contractId:fremd}
-> basePrice aktualisiert, ecd.id + contractId unveraendert (kein Reparenting).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 16:10:45 +02:00
duffyduckandClaude Opus 4.8 8964820542 Kunde-E-Mail: Pflicht nur beim Anlegen, nicht beim Bearbeiten
Bestandskunden ohne E-Mail bleiben editierbar. Beim Anlegen ist E-Mail
weiterhin Pflicht (Frontend required + Backend createCustomer). Die
Domain-Pruefung (keine verwaltete Provider-Domain) greift unveraendert bei
create UND update, falls eine E-Mail gesetzt wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:26:03 +02:00
duffyduckandClaude Opus 4.8 15142676ef Aufgaben ohne Kunde/Vertrag anlegbar
ContractTask.contractId nullable (Migration). Neuer staff-only Endpoint
POST /tasks fuer allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag gibt
es keinen Kunden -> visibleInPortal serverseitig immer false, Portal-Reply
403 bei contractloser Aufgabe, getAllTasks-Portal-Filter schliesst sie
automatisch aus (kein contract-Match).

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)" ohne Vertrags-Link/
Zum-Vertrag-Button.

Verifiziert: contractlose Aufgabe -> contractId null, visibleInPortal
erzwungen false (auch wenn true geschickt); mit Vertrag weiterhin waehlbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:15:33 +02:00
duffyduckandClaude Opus 4.8 b8128616f2 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain erlaubt
Die private Kunden-E-Mail (Customer.email) darf nicht auf einer der bei den
E-Mail-Providern konfigurierten Domains (bzw. Subdomains) liegen - sonst
traegt man versehentlich eine unserer verwalteten Weiterleitungs-/Mailbox-
Adressen als private Adresse ein. Zudem ist E-Mail jetzt Pflichtfeld.

Backend: createCustomer/updateCustomer pruefen Pflicht + Domain (neue Helper
getConfiguredEmailDomains/emailUsesDomain im emailProvider-Service);
email aus nullableFields entfernt. Frontend: E-Mail-Feld required + Hinweis.

Verifiziert: Provider-Domain + Subdomain (case-insensitiv) verboten,
Fremd-Domains erlaubt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:08:12 +02:00
duffyduckandClaude Opus 4.8 db4b9edd13 Energievertrag: Ankreuzfeld "Keine Bonis erwuenscht"
Neues Feld EnergyContractDetails.noBonusDesired (Boolean, default false) +
Migration. Checkbox im Vertragsformular (Strom/Gas, bei den Bonus-Feldern),
Anzeige im Vertragsdetail wenn gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:03:15 +02:00
duffyduckandClaude Opus 4.8 1a45c24abf MaLo-ID an die Lieferadresse (Strom/Gas) + Address-owner-Regression gefixt
MaLo-ID (Marktlokation) gehoert zur (Liefer-)Adresse, nicht zum Vertrag.
Address bekommt maloIdElectricity + maloIdGas (getrennte Marktlokationen je
Sparte), pflegbar im AddressModal (nur Lieferadresse). Im Vertrag ist die
MaLo-ID jetzt ein Lesefeld, das je nach Vertragstyp die MaLo der gewaehlten
Lieferadresse zeigt; ContractDetail/-Modal ebenso.

Schema + Migration 20260814100000: 2 Spalten (idempotent) + Daten-Migration
(bestehende EnergyContractDetails.maloId -> jeweilige Lieferadresse,
ELECTRICITY->maloIdElectricity / GAS->maloIdGas). Migrationslogik verifiziert.

Dabei einen selbst verursachten Regressions-Bug gefixt: beim R156-Umbau waren
die 10 owner*-Adressfelder aus der Address-Whitelist gefallen -> Eigentuemer-
Sektion speicherte seit cb21a2c nicht mehr. Address-Whitelist jetzt via
Pick-Helper, programmatisch gegen alle DB-Spalten abgeglichen (owner* + MaLo
drin, id/customerId/Timestamps raus). BankCard/Document gegengeprueft: ok
(nur documentPath bewusst upload-only ausgeschlossen).

Verifiziert: tsc+build gruen; owner + maloId speichern wieder, Injection
(id/customerId) blockiert; Daten-Migration Strom->Strom / Gas->Gas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 14:38:16 +02:00
duffyduckandClaude Opus 4.8 e01c793b58 Fix: contractNumberAtProvider in Contract-Whitelist (Pentest R159)
Beim Umstieg auf die Feld-Whitelist (94e4fde) ist "Vertragsnummer beim
Anbieter" (contractNumberAtProvider) durchgerutscht - ich hatte nur die
SalesPlatform-Variante + customerNumberAtProvider. Folge: Staff konnte die
Anbieter-Vertragsnummer nicht mehr neu setzen/aendern (still gedroppt),
bestehende Werte blieben (Prisma nullt abwesende Felder nicht). Reine
funktionale Regression, kein Security-Issue (Weglassen ist fail-safe).

Whitelist jetzt programmatisch gegen ALLE DB-Spalten abgeglichen (SHOW COLUMNS
minus bewusste Ausschluesse = Whitelist, beide Diffs leer) - kein weiteres
Feld fehlt. Verifiziert: contractNumberAtProvider persistiert wieder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 20:39:09 +02:00
duffyduckandClaude Opus 4.8 94e4fdee23 Mass-Assignment-Schutz: Contract-Create/Update Feld-Whitelist (R158)
Letzter Spread-Endpunkt: createContract/updateContract reichten rohen
...contractData an Prisma durch (via as any im Controller). customerId ist
zwar legitim aenderbar (Kunden-Select aktiv), aber id/contractNumber/
createdAt/updatedAt/portalPasswordEncrypted und die cancellation*Path-Felder
waren so mit-injizierbar. Jetzt explizite Feld-Whitelist (pickContractScalars),
konsistent zur R156-Haertung von BankCard/Address/Document.

Whitelist autoritativ aus den DB-Spalten abgeleitet - der ContractCreateData-
Typ ist unvollstaendig: previousProviderId/previousContractNumber/
previousCustomerNumber/nextReviewDate sind echte Formularfelder, die sonst
still gebrochen waeren. cancellation*Path bleiben bewusst draussen (nur ueber
die Upload-/Delete-Endpunkte setzbar).

Verifiziert: legit Felder (inkl. der 4 zuvor untypisierten) persistieren;
injizierte id/contractNumber/cancellationLetterPath werden ignoriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 19:27:25 +02:00
duffyduckandClaude Opus 4.8 fc3131059b Neuer Vertragsstatus "Gekuendigt / bestaetigt" + Kuendigungs-Workflow
Bisheriges "Gekuendigt" (CANCELLED) umbenannt in "Gekuendigt / Bestaetigung
abwarten" und wird jetzt automatisch gesetzt, sobald ein Kuendigungsschreiben
hochgeladen wird. Neuer Status CANCELLED_CONFIRMED ("Gekuendigt / bestaetigt")
wird automatisch gesetzt, sobald ein Kuendigungsbestaetigungsdatum vorliegt
(Dokument fuellt das Datum oder manuell) + Vertragsende = Kuendigungsdatum.

Schema: Enum-Wert CANCELLED_CONFIRMED + Migration (idempotentes MODIFY COLUMN);
Daten-Migration hebt bestehende CANCELLED (alte Logik: nur bei Bestaetigung
gesetzt) auf CANCELLED_CONFIRMED.

Backend: neue Trigger maybeMarkAwaitingConfirmationOnLetter (Schreiben->CANCELLED)
im Upload-Handler; maybeCancelOnCancellationConfirmation setzt jetzt
CANCELLED_CONFIRMED (auch aus CANCELLED). Cockpit-Semantik mitgewandert
(Fristen-Skip/"beendet" fuer CANCELLED_CONFIRMED; Ladeliste + Kuendigungs-
bestaetigungs-Filter erweitert).

Frontend: Labels/Farben/Status-Erklaerungen + Status-Dropdown in ContractList,
ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
(CANCELLED orange "abwarten", CANCELLED_CONFIRMED rot).

Verifiziert: tsc+build gruen; Schreiben->CANCELLED, Bestaetigung->
CANCELLED_CONFIRMED+Enddatum; Daten-Migration idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:14:34 +02:00
duffyduckandClaude Opus 4.8 a5b8922dd0 README: Reseed-Anleitung fuer bekannte Admin-/Portal-Creds
Eigene Unterrubrik unter "Erster Login": wie man auf bestehender DB
(z.B. Staging nach Reset) datenerhaltend bekannte Zugangsdaten herstellt.
Betont, dass der Seed nur Rollen + Admin-User upsertet (keine Kundendaten),
das SEED_ADMIN_PASSWORD>=25-Zeichen-Verfahren + RUN_SEED, und dass
Portal-Passwoerter danach im UI gesetzt werden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 17:04:36 +02:00
duffyduckandClaude Opus 4.8 cb21a2c9be Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)
Die create/update-Services reichten rohen req.body an Prisma durch, wodurch
customerId (Owner) und id (PK) per Update mutierbar waren - staff-only, kein
Cross-Tenant-Bruch, aber echte Integritaetsschwaeche; mit dem neuen
cardNumber-Feld liegt zudem Finanz-PII auf dieser Flaeche.

Fix: explizite Feld-Whitelist im Service (create+update) fuer BankCard,
Address und IdentityDocument - nur benannte Spalten gehen an Prisma, kein
...data/req.body-Spread mehr. Controller-Helper pickBankCardFields haelt
zusaetzlich die Audit-Logs sauber (keine Phantom-Eintraege injizierter Keys).

Verifiziert: updateBankCard mit {customerId:99999, id:88888, bogusField, ...}
-> id+customerId unveraendert, nur cardNumber gesetzt, Fremdfelder ignoriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 16:40:15 +02:00
duffyduckandClaude Opus 4.8 0d42bd89e8 Bankkarte-/Ausweis-Details in Vertrag + neues Feld Kartennummer
Schema: BankCard.cardNumber (String?, optional) + Migration
20260813100000_bank_card_number (ADD COLUMN IF NOT EXISTS), auf Dev
angewandt + prisma generate; Prod via migrate deploy. Eingabefeld
"Kartennummer" im Bankkarten-Modal (Kundenakte).

Vertragsansicht (ContractDetail) und Vertrag bearbeiten (ContractForm)
zeigen jetzt bei Bankkarte zusaetzlich BIC/Bank/Kartennummer/Ablaufdatum
und bei Ausweis Behoerde/Ausstellung/Ablaufdatum sowie Geburtsort +
Geburtsdatum des Kunden - jeweils mit Copy-Button und nur wenn gesetzt.
Im Form je Select in eigenem div gewrappt (Grid-Alignment).

Verifiziert: tsc + vite build gruen, cardNumber Round-Trip (update->read),
Contract-Include liefert alle Felder inkl. customer.birthDate/birthPlace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 15:00:29 +02:00
duffyduckandClaude Opus 4.8 f3668b8a5a Magic-Byte-Seed: aus Prod-Image ausschliessen + Prod-Riegel
Der Pentester-Helfer soll nicht ins Produktions-Image und darf nie gegen
Prod laufen:
- .dockerignore schliesst backend/scripts/seed-magic-byte-test.ts aus dem
  Build-Context aus -> Datei liegt nicht mehr im Prod-Container (bleibt im
  Repo fuer Dev/Staging-Checkout).
- Script bricht bei NODE_ENV=production im "create"-Modus hart ab (exit 1);
  "cleanup" bleibt erlaubt, damit man immer aufraeumen kann.

Verifiziert: create@production -> Abbruch exit 1; cleanup@production laeuft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 13:49:32 +02:00
duffyduckandClaude Opus 4.8 2ea211cca0 Pentester-Helfer: Seed-Script fuer Magic-Byte-Mismatch-Test (R152)
backend/scripts/seed-magic-byte-test.ts legt eine dedizierte TEST-Bankkarte
(Marker im accountHolder) mit einer getarnten Datei an: .pdf-Endung, aber
SVG-mit-<script>-Inhalt (Non-Whitelist-Magic-Byte, Stored-XSS-Payload).
Damit kann der Pentester den Magic-Byte-Mismatch->attachment-Zweig des
Download-Endpoints live ausloesen (auf Staging fehlte bisher ein
Non-Whitelist-Upload). Echte Kundendaten werden nicht angefasst; cleanup
entfernt Karte + Datei.

Modi: create [--customer <id>] | cleanup.

Verifiziert (create -> Controller-Integrationstest -> cleanup): inline
angefragt -> Content-Disposition attachment + nosniff (nicht inline) + Log;
ohne disposition -> attachment; fremder Portal-User -> 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 13:31:08 +02:00
duffyduckandClaude Opus 4.8 06d51cf680 PDF-Viewer-Modal fuer Bankkarte-/Ausweis-Dokument
Neue Komponente PdfViewerModal (Modal + iframe auf viewUrl(documentPath),
inkl. Neuer-Tab/Download-Links). Buttons:
- Vertragsansicht (ContractDetail): im Card-Header von Bankkarte/Ausweis
- Vertrag bearbeiten (ContractForm): neben dem Label Bankkarte/Ausweis

Button erscheint NUR wenn ein documentPath hinterlegt/gewaehlt ist. Nutzt den
bestehenden /api/files/download-Endpoint (Per-File-Ownership-Check) - kein
neuer Zugriffspfad. iframe (nicht embed/object wegen object-src 'none');
same-origin via CSP default-src 'self' erlaubt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 13:15:57 +02:00
duffyduckandClaude Opus 4.8 b2b1cb3387 Vertragssuche: Karteninhaber (cardUser) durchsuchbar (Pentest R150)
getAllContracts durchsucht jetzt auch simCards.cardUser - zusaetzlich zu
Rufnummer/SIM-Nummer/IMEI. R150-Randnotiz, fachlich gewuenscht (Staff sucht
nach Karteninhaber). Portal-Suche bleibt durch das bestehende customerIds-
Scoping begrenzt.

Verifiziert: Suche nach gesetztem cardUser findet den Vertrag, Kontroll-
Suche 0 Treffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 12:50:36 +02:00
duffyduckandClaude Opus 4.8 2d12221c41 Vertragsliste (Staff-Tabelle): Karteninhaber/Netz/Kuendigung nachgezogen
Der vorige Commit ergaenzte nur die Baum-Render-Pfade (Kundenakte + Portal).
Die Staff-Ansicht unter /contracts rendert aber eine eigene Tabelle aus der
flachen getAll-Liste - dort fehlten die neuen Felder noch. Jetzt zeigt auch
die Staff-Tabelle Karteninhaber + Netz hinter der Rufnummer und die
Kuendigungsbestaetigung in roter Schrift. Backend liefert die Felder via
getAllContracts bereits mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 12:26:58 +02:00
duffyduckandClaude Opus 4.8 c5cf2c7416 Vertragslisten: Karteninhaber + Mobilfunknetz + Kuendigung (rot)
Beide Vertragslisten (Kundenakte-Baum + Hauptmenue /contracts) zeigen bei
Mobilfunkvertraegen mit Rufnummer jetzt zusaetzlich Karteninhaber
(SimCard.cardUser der angezeigten SIM, nur wenn gesetzt) und Netz
(mobileNetwork -> Telekom/Vodafone/Telefonica (o2)) inline. Vertraege mit
Kuendigungsbestaetigung (cancellationConfirmationDate) bekommen eine eigene
Zeile in roter, fetter Schrift.

Backend: getContractTreeForCustomer + getAllContracts selektieren nun
mobileNetwork + cardUser (Tree zusaetzlich cancellationConfirmationDate).
Shared-Helper getContractTypeInfo um cardUser/network + mobileNetworkLabel
erweitert. Beide Listen rendern ueber den Tree-Endpoint.

Verifiziert: tsc + vite build gruen, Tree-Service liefert die Felder,
Helper-Mapping getestet (Vodafone/Telekom/o2, leerer Karteninhaber ausgelassen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 12:18:24 +02:00
duffyduckandClaude Opus 4.8 8e46dbbfed Globaler /api-Rate-Limit-Backstop (Pentest R148)
Es gab bislang keinen generellen Limiter - nur Login/Passwort-Reset/
Staff-ReAuth/Consent; authentifizierte Endpoints waren gegen Enumeration/
DoS ungedrosselt. Neuer apiBackstopRateLimiter auf alle /api-Requests,
vor den Routern gemountet (ergaenzt die feineren Limiter, ersetzt sie nicht).

Key = nur IPv6-/56-normalisierte IP - bewusst nicht IP+User, da der User-
Claim hier nur unverifiziert lesbar waere (authenticate laeuft erst pro
Route) und ein Angreifer sonst per Fake-userId beliebig Buckets erzeugen
koennte. Limit per Env API_RATE_LIMIT_PER_MIN (Default 1200/min, Floor 60),
/api/health ausgenommen. Kein SecurityEvent pro Block (Flood-Amplification).
Deckt zugleich den offenen IPv6-Rate-Limit-Test ausserhalb der Auth-Pfade ab.

Verifiziert: 60x200 dann 429, health bleibt 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:54:12 +02:00
duffyduckandClaude Opus 4.8 955fceb3b8 BLZ-Guard: vollstaendige Formatpruefung aller Eintraege (Pentest R148)
Der Poisoning-Guard pruefte bisher nur die erste current-Zeile - ein Set mit
1 echten + 999 Fake-Eintraegen kaeme durch. Jetzt wird JEDER Eintrag in
current + next.upsert geprueft (8-stellige BLZ, Wert [Name] oder [Name,BIC]),
next.remove auf 8-stellige BLZ, next.valid auf ein gueltiges Datum.

Dabei korrekt beruecksichtigt: Banken ohne BIC haben nur [Name] (Laenge 1) -
lookupBlz liefert dann bic:''. Real gegen den echten Datensatz verifiziert
(3506 Eintraege, inkl. BIC-lose wie BLZ 60050009).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:46:56 +02:00
duffyduckandClaude Opus 4.8 d0619141bb Fix: Modal-Formulare nach Abbrechen nicht mehr mit alten Daten vorbefuellt
Die Modals in der Kundenakte (Bankkarte, Adresse, Ausweis, Zaehler,
Zaehlerstand) bleiben dauerhaft gemountet und wurden nur ueber isOpen
umgeschaltet. Der Reset-Effekt haengte an <entity>?.id - bei Neuanlage
immer undefined, also kein Reset beim erneuten Oeffnen: nach "Abbrechen"
standen die vorher getippten Daten noch drin.

Jetzt Reset beim Oeffnen (Guard if(!isOpen), Deps [isOpen, <entity>?.id]),
weiterhin kein Reset bei jedem Tastendruck. Ausserdem den fehlerhaften
useState(()=>{})-Init-Missbrauch im Bankkarten-Modal entfernt.
StressfreiEmail-/AdditionalForwards-Modal waren bereits korrekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:31:03 +02:00
duffyduckandClaude Opus 4.8 a28c8355fd BLZ-Updater: Versionsformat vor URL-Interpolation strikt pruefen
Die von der npm-Registry gelieferte "latest"-Version wird in die jsDelivr-
CDN-URL interpoliert. Nun nur noch rein numerisches semver (^\d.\d.\d$)
zugelassen, damit eine manipulierte Registry-Antwort (../, Slashes, Query)
den Pfad nicht verbiegen kann. Host bleibt ohnehin fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:09:24 +02:00
duffyduckandClaude Opus 4.8 91fbe12299 BLZ-Bankdaten: Laufzeit-Auto-Update ins Volume + Einstellungen-Seite
Statt npm-Rebuild-Wartung aktualisiert sich der Bankleitzahlen-Datensatz
jetzt zur Laufzeit. Ein Scheduler (taeglich 03:30 + Catch-up 90s) prueft
gemaess konfigurierbarem Intervall und laedt current.json/next.json von
npm/jsDelivr (Paket bankdata-germany) in das neue Bind-Mount-Volume
BANKDATA_DIR (./data/bankdata -> /app/bankdata).

Lookup bevorzugt den Volume-Datensatz vor den ins Image gebackenen Daten
(Fallback). Es wird kein Fremdcode ausgefuehrt - nur JSON gelesen und die
current+next-Delta-Logik nachgebaut. Validierung (>=1000 Eintraege, Format)
+ atomarer Write (tmp+rename) schuetzen den guten Stand vor Muell.

Datenschutz: Der Updater sendet keine Kundendaten, laedt nur eine
oeffentliche Datendatei; abschaltbar; bei Fehler/ohne Egress greift Builtin.

Neue Einstellungen-Seite /settings/bank-data zeigt Datenstand, Update-
Verfuegbarkeit und bietet "Jetzt aktualisieren" + Auto-Update-Schalter +
Intervall. Endpoints GET /api/settings/blz, POST /api/settings/blz/update-now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 13:15:07 +02:00
duffyduckandClaude Opus 4.8 73bd72bd9f Bankkarte: IBAN pruefen + BIC/Bank offline aus Bundesbank-BLZ ausfuellen
Neuer Button "BIC & Bank aus IBAN abrufen" im Bankkarten-Modal fuellt BIC +
Banknamen automatisch und validiert dabei die IBAN-Pruefziffer (mod-97).
Leeres IBAN-Feld -> OK-Messagebox statt Anfrage.

Datenschutzfreundlich/offline: kein Dritt-Dienst. Nachschlag im eigenen
Backend ueber die Bundesbank-Bankleitzahlendatei (bankdata-germany) +
ibantools fuer die Pruefziffer. Die IBAN verlaesst nie den Server; zurueck
kommen nur oeffentliche Bankverzeichnis-Daten.

Endpoint: POST /api/bank-cards/iban-lookup (nur eingeloggt).
Wartung: bankdata-germany/ibantools ~quartalsweise per npm update ziehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 12:32:53 +02:00
duffyduckandClaude Opus 4.8 aff3f7e84a Update-Banner: klebt oben fest (sticky), Sticky-Header rasten darunter ein
Der Hinweis "Neue Version verfuegbar" scrollte bisher weg. Jetzt sticky an
der Viewport-Oberkante; das Banner meldet seine gemessene Hoehe als CSS-Var
--app-banner-h, an der sich die Sticky-Header von ContractDetail/ContractForm
ausrichten (top-[var(--app-banner-h,0px)]), damit sie nicht dahinter
verschwinden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 12:20:31 +02:00
duffyduckandClaude Opus 4.8 e1ca56ebfb getCockpit fail-closed (R146) - konsistent zu listAll/getContracts
Pentester R146 (Konsistenz + Defense-in-Depth): getCockpit war der
einzige der drei Geschwister-Endpunkte noch auf dem alten Fail-open-
Muster. Der Cockpit-SERVICE ist bereits fail-closed (customerIds:[] ->
IN () -> 0), aber der CONTROLLER uebergab bei falsy customerId
undefined statt [] -> {} -> alle Kunden. Genau die R4-HIGH-Stelle
'Cockpit leakt alle Vertraege'.

Jetzt: Portal-Token wird IMMER gescoped (ohne customerId -> []),
identischer Einzeiler wie listAll/getContracts. Nicht erreichbar (Token
traegt immer customerId), aber der urspruengliche HIGH-Endpunkt soll
nicht das letzte Fail-open-Muster bleiben.

Verifiziert: Cockpit Staff -> 17 Vertraege; Portal []->0 (contracts +
cancellationConfirmations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 11:44:52 +02:00
duffyduckandClaude Opus 4.8 8d93c1767b R145-Hygiene: limit-Floor + getContracts fail-closed (Konsistenz)
Pentester R145 (nice-to-have, keine Findings):
1) listAll: limit bekommt einen Floor (Math.max(1, ...)) - limit=-5
   ergab vorher take:-5 an Prisma. page ebenso auf >=1 geklemmt.
2) getContracts nutzte dasselbe 'if (isCustomerPortal && customerId)'-
   Muster und war NICHT fail-closed. Jetzt konsistent zu listAll:
   - Controller: Portal-Token immer gescoped (ohne customerId -> []).
   - Service getAllContracts: 'if (customerIds)' statt '.length > 0',
     damit ein leeres Array strikt auf IN () filtert (0 Treffer) statt
     durchzufallen. Einziger Caller ist der Contract-Controller ->
     keine Regression fuer den Normalfall.

Verifiziert: Staff -> alle; Portal customerIds=[] -> 0 Vertraege/Belege.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 11:32:21 +02:00
duffyduckandClaude Opus 4.8 4c5548ea3c Belegübersicht: fail-closed Portal-Scoping (R144)
Pentester R144 (fail-closed, kein Finding): listAll scopte ueber
'if (isCustomerPortal && customerId)'. Fiele customerId bei einem
Portal-Token mal falsy aus, rutschte er in den Staff-Zweig (alle
Belege).

Jetzt: Portal-Token wird IMMER gescoped; ohne customerId -> leere
Menge (customerIds=[]) statt undefined/Staff. In:[] kann nie matchen.
Aktuell nicht erreichbar (Portal-Token traegt immer customerId), aber
robuster.

Verifiziert: customerIds=[] -> 0 Belege.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 11:18:46 +02:00
duffyduckandClaude Opus 4.8 48e65be91c Hauptmenue: Gutschriften/Lieferscheine-Gesamtuebersicht (portal-scoped)
Neuer Menuepunkt 'Gutschriften' -> Seite /credit-notes mit Tabelle
aller Belege (Beleg-Nr, Art, Kunde, Vertrag, Betrag, Datum, PDF),
Suche + Pagination.

Neuer Endpoint GET /credit-notes (NICHT staff-only wie die uebrigen
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. Fuer Portal wird receiptPath aus der Response entfernt
(Belege bleiben staff-only). Route requirePermission contracts:read.

Verifiziert: Staff -> alle Belege; Portal-scoped -> nur eigene, korrekt
zugeordnet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 10:23:53 +02:00
duffyduckandClaude Opus 4.8 bbe1aed230 Portal-Passwort: encrypted ohne Hash gilt jetzt als desync (R143)
Pentester R143 (Robustheit): getCustomerPortalPassword lieferte 'ok',
wenn portalPasswordEncrypted gesetzt, aber portalPasswordHash null ist.
Ohne Hash ist ein Login unmoeglich -> ein revealtes Passwort waere
irrefuehrend. Jetzt -> desync (Reveal/Send blocken mit 409). Aktuell
ueber die API nicht erreichbar, nur als DB-Altlast; jetzt sauber
abgefangen.

Verifiziert: encrypted+hash=null -> desync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 10:06:00 +02:00
duffyduckandClaude Opus 4.8 5047bfaab3 Gutschrift: nur mit Empfaengeradresse anlegbar (Rechnung > Liefer)
Eine Gutschrift/ein Lieferschein braucht eine Empfaengeradresse aufs
Dokument. Rechnungsadresse hat Vorrang, sonst Lieferadresse. Ist keine
von beiden hinterlegt -> Anlegen blockiert.

- Frontend: Klick auf 'Gutschrift anlegen' prueft defaults.hasRecipient-
  Address; wenn false -> Modal-OK-Meldung statt Formular.
- Backend Defense-in-Depth: createCreditNote wirft 400, wenn weder
  billingAddressId noch addressId gesetzt. getCreditNoteDefaults liefert
  hasRecipientAddress.

Verifiziert: ohne Adresse -> hasRecipientAddress false + create 400;
mit Adresse -> ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 09:39:04 +02:00
duffyduckandClaude Opus 4.8 0078654465 Gutschrift: Beleg-Upload auch fuer Sachwerte + kein Unterschriftsblock bei Geld
- Beleg-Upload jetzt fuer beide Arten: bei Geld die Ueberweisungs-
  bestaetigung, bei Sachwert das unterschriebene Dokument. ReceiptControls
  in der Liste fuer Geld UND Sachwert (Label je nach Typ). Endpoint war
  schon typ-agnostisch.
- PDF-Unterschriftsblock nur noch bei Sachwert - eine Ueberweisung wird
  nicht unterschrieben (Beleg = hochgeladene Ueberweisungsbestaetigung).
  Bei Geld entfaellt der Unterschrift/Ort-Block; im Formular sind Ort +
  'Unterschrift am' bei Geld ausgeblendet.

Verifiziert: beide PDFs erzeugen sauber (je 1 Seite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 09:26:35 +02:00
duffyduckandClaude Opus 4.8 5e606df49e Gutschrift: separater Lieferschein-Nummernkreis (GoBD, luecken-frei)
Pentester R142: Uebergang Geld->betragsloser Sachwert setzte die schon
vergebene Gutschriftsnummer auf null -> Luecke in der GS-Serie.

Loesung: betragsloser Sachwert = Lieferschein mit eigener
Lieferscheinnummer aus separatem Nummernkreis.

- Schema: CreditNote.deliveryNoteNumber (nullbar, unique) + neues Model
  DeliveryNoteNumberRange (Default-Praefix 'LS-') + Migration.
- deliveryNoteNumberRange.service (mirror, eigener Zaehler, FOR UPDATE).
- Nummern lazy pro Serie, NIE freigeben: Uebergaenge behalten die
  jeweils vergebene Nummer der anderen Serie reserviert -> kein
  Doppelverbrauch, keine Luecke. effectiveNumber() liefert je nach Typ
  die passende (LS/GS) fuer Anzeige/PDF/Audit.
- Endpunkte GET/PUT /credit-notes/delivery-note-number-range; Settings-
  Seite verwaltet jetzt beide Nummernkreise. PDF-Titel 'Sachwert-
  Uebergabe', Dateiname lieferschein-...
- Frontend: Typ + displayNumber in Liste/Modal.

Verifiziert: Sachwert 0 -> LS-Nr, GS-Zaehler unberuehrt; Geld -> GS-Nr;
Uebergaenge behalten beide Nummern (kein Neuverbrauch, keine Luecke).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 08:47:49 +02:00
duffyduckandClaude Opus 4.8 79f6f3e629 Portal-Passwort: Reveal/Send prueft Konsistenz gegen Login-Hash
Pentester-Hinweis: bcrypt-Hash (Login) und verschluesseltes Reveal-Feld
koennen out-of-sync sein -> Support liest ein Passwort vor, das beim
Login scheitert.

Analyse: alle aktuellen Schreibpfade sind konsistent (beide Felder
zusammen, oder encrypted=null, oder Rehash desselben Passworts) - der
Code erzeugt keinen Desync. Ursache = Altlast/manueller DB-Eingriff.

Fix (defensiv, unabhaengig von der Ursache):
- getCustomerPortalPassword liefert {status: ok|none|desync} und prueft
  den entschluesselten Klartext per bcrypt.compare gegen den Login-Hash.
- Bei desync (oder Entschluesselungsfehler) geben WEDER Reveal NOCH
  Send-Credentials das Passwort aus -> 409 'Dateninkonsistenz, bitte
  neu setzen'. Reveal-Read wird mit Status auditiert.
- Neues Diagnose-Script scripts/check-portal-password-sync.ts scannt
  alle Portal-Kunden auf Desync (nur Diagnose, aendert nichts) - fuer
  Prod, da der Pentester keinen FS-Zugriff hat.

Verifiziert: desync -> nicht ausgegeben; konsistent -> ok; kein PW ->
none. Scan laeuft (0 Desync auf Dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-11 22:46:53 +02:00
duffyduckandClaude Opus 4.8 15ac003dad Gutschrift: betragsloser Sachwert bekommt keine Gutschriftsnummer
Ein betragsloser Sachwert ist eher ein Lieferschein als eine Gutschrift
-> er soll KEINE Gutschriftsnummer aus dem Nummernkreis verbrauchen.

- Schema: CreditNote.number nullbar (Migration MODIFY ... NULL, UNIQUE
  bleibt - MySQL erlaubt mehrere NULLs).
- createCreditNote: betragsloser Sachwert -> number=null, assignNextNumber
  wird NICHT aufgerufen (Zaehler unangetastet).
- updateCreditNote: Uebergaenge - wird betragslos -> Nummer entfernen;
  bekommt nachtraeglich Betrag & hatte keine -> jetzt Nummer vergeben.
- PDF/Liste/Modal/Audit: Fallback 'Sachwert-Uebergabe'/'Beleg #id' wenn
  keine Nummer; PDF-Titel 'Sachwert-Uebergabe', kein ZUGFeRD (schon vorher).

Verifiziert: Sachwert 0 -> number null + Zaehler bleibt; Geld -> Nummer
+ Zaehler +1; Sachwert nachtraeglich mit Betrag -> Nummer vergeben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 17:40:15 +02:00
duffyduckandClaude Opus 4.8 6f95005530 Gutschrift: Sachwert ohne Betrag = keine Rechnung (kein ZUGFeRD)
Eine Sachwert-Gutschrift darf betragslos sein (Betrag leer/0): dann
findet keine Rechnungsstellung statt - der Kunde hat den Gegenstand
einfach als Subvention erhalten.

- Backend: leeres amount -> 0; Sachwert erlaubt 0, Geld verlangt > 0
  (400 sonst). vatRelevant bei 0 erzwungen-false.
- PDF: betragsloser Sachwert -> Titel 'Sachwert-Uebergabe', kein
  Betrags-/USt-Block (Hinweis keine Rechnungsstellung), KEIN ZUGFeRD-
  Embedding. Mit Betrag -> unveraendert ZUGFeRD.
- Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet ohne
  Betrag; Liste zeigt 'Sachwert ohne Betrag (keine Rechnung)'.

Verifiziert: Sachwert 0 -> kein factur-x.xml; Geld 0 -> 400;
Sachwert 200 -> ZUGFeRD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 13:22:13 +02:00
duffyduckandClaude Opus 4.8 4d14390b2a Cockpit-Filter Kuendigungsbestaetigung: Status EXPIRED mitzaehlen
Verträge mit hinterlegter Kuendigungsbestaetigung im Status EXPIRED
(Abgelaufen) werden jetzt auch im Cockpit-Filter gelistet, zusaetzlich
zu ACTIVE/DRAFT/CANCELLED. Die Cockpit-Query laedt EXPIRED ohnehin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 23:42:31 +02:00
duffyduckandClaude Opus 4.8 3e2d9395a7 Hygiene R140: Datei-Loesch-Helfer konsolidieren + DB-vor-Datei
Pentester-Hygiene zu a6b1dac:

1) Konsolidierung: neuer utils/fileCleanup.ts mit deleteFileAbsolute
   (absoluter Pfad, z.B. Multer-Temp) + deleteUploadByRelativePath
   (in DB gespeicherter /uploads/-Pfad). Ersetzt die 3x kopierten
   deleteFileIfExists/cleanupFile in creditNote-, upload- und
   customer-Service.

2) Reihenfolge: In deleteCreditNote/updateCreditNote erst die DB-
   Operation, DANN die Datei loeschen. Schlaegt der DB-Schritt fehl,
   bleibt die Datei erhalten (kein ins-Leere-zeigender Zustand).

Verifiziert: Update -> pdfPath null + alte Datei weg; Delete -> gibt
geloeschte Row zurueck (Audit) + Datei weg. Kein Regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:57:40 +02:00
duffyduckandClaude Opus 4.8 c29ffd7bea Vertragsformular: Link-Icon am Zaehler-Label zur Kundenakte (Strom/Gas)
Wie bei Bankkarte/Ausweis/Adresse: das 'Zaehler'-Label im Energie-
Vertragsformular bekommt ein LabelWithLink zum Zaehler-Tab der
Kundenakte (/customers/:id?tab=meters), oeffnet im neuen Tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:51:38 +02:00
duffyduckandClaude Opus 4.8 5f06ed9830 Fix Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal erweitern
1) Folgevertrag-Bug: Backend legt den Folgevertrag korrekt mit
   previousContractId an. Der Verlust passierte im Frontend - das
   Vorgaenger-Dropdown holt Vertraege ueber getAll, das DEACTIVATED
   standardmaessig ausblendet. Beim Bearbeiten des Folgevertrags war der
   deaktivierte Vorgaenger nicht als Option da -> Verknuepfung ging beim
   Speichern verloren.
   Fix: getAllContracts + Controller + contractApi.getAll um
   includeDeactivated erweitert; Vorgaenger-Dropdown nutzt es und markiert
   deaktivierte Vertraege mit '· deaktiviert'. Verifiziert (Flag inkludiert
   deaktivierte; Folgevertrag setzt previousContractId).

2) Kundendaten-Modal: zeigt jetzt zusaetzlich Lieferadresse + (falls
   abweichend) Rechnungsadresse des geoeffneten Vertrags, die Stressfrei-
   Adresse des Vertrags einzeln und darunter alle weiteren Stressfrei-
   Adressen des Kunden. CustomerInfoModal nimmt optionale Vertragskontext-
   Props; ContractDetail + ContractForm uebergeben sie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:49:41 +02:00
duffyduckandClaude Opus 4.8 a6b1dac606 Gutschriften: Dateien beim Loeschen/Bearbeiten mit aufraeumen (R138)
Pentester-Hinweis: generierte Gutschrift-PDFs blieben nach dem Loeschen
der Gutschrift als verwaiste Files im Upload-Ordner liegen (harmlos, da
ohne DB-Referenz nicht mehr abrufbar - aber unsauber).

deleteCreditNote entfernt jetzt PDF (pdfPath) + Ueberweisungsbeleg
(receiptPath) von der Platte. updateCreditNote loescht das alte PDF
beim Leeren von pdfPath. Kein verwaister Ordner-Muell mehr.

Verifiziert: PDF nach Erzeugung vorhanden, nach Loeschen der Gutschrift
weg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:31:33 +02:00
duffyduckandClaude Opus 4.8 5fb01627b0 Vertrag-Update: Kuendigungs-Datumsfelder robust normalisieren (R138)
cancellationConfirmationDate / cancellationConfirmationOptionsDate im
PUT /contracts/:id ueber validateOptionalIsoDate normalisieren: nimmt
Datum-only (YYYY-MM-DD) UND volles ISO, liefert einen sauberen Date an
Prisma. Vorher lehnte Prisma ein Datum-only ab (400 statt Verarbeitung).
Ungueltige Formate -> sauberes 400. Konsistent zum Upload-Pfad, der
confirmationDate bereits so validiert.

Verifiziert: '2027-09-13' akzeptiert + als DATETIME geschrieben;
deutsches Format abgelehnt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 21:40:31 +02:00
duffyduckandClaude Opus 4.8 0f51a1cc3b Gutschriften/Kuendigung: DRAFT-endDate-Schutz + Belege staff-only
Pentest R138 Hygiene + neue Vorgaben:

1) endDate wird bei DRAFT-Vertraegen NICHT mehr gesetzt (Entwurf = nur
   Vorlage). Nur Status wurde vorher geschont, endDate zog trotzdem mit.

2) Ueberweisungsbelege (credit-note-receipts) sind jetzt reine
   Mitarbeiter/Admin-Downloads: neuer FileOwner-kind 'contract-staff'
   blockt Portal-Kunden im fileDownload-Controller. Das generierte
   Gutschrift-PDF (credit-notes) bleibt vertragsbasiert -> der
   besitzende Kunde darf seine eigene Gutschrift laden.

Bereits vorher abgesichert (bestaetigt): Kunden koennen keine
Gutschriften anlegen (blockPortal) und keine Belege hochladen
(Portal-403 im Upload).

Verifiziert: DRAFT haelt endDate; Beleg-Owner=contract-staff,
PDF-Owner=contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 21:29:43 +02:00
duffyduckandClaude Opus 4.8 8169c74d8e Vertrag: Auto-CANCELLED bei Kuendigungsbestaetigung + Cockpit-Filter
1) Auto-Status: Wird eine Kuendigungsbestaetigung hinzugefuegt (Datum
   ueber Formular und/oder Dokument-Upload), wird der Vertrag von ACTIVE
   auf CANCELLED gesetzt und das Vertragsende = Kuendigungsdatum.
   Zentrale Funktion maybeCancelOnCancellationConfirmation (idempotent,
   nur aus ACTIVE). Upload-Route ersetzt die alte Inline-Logik (setzt
   jetzt auch endDate); Update-Controller triggert nur bei neu/geaendertem
   Bestaetigungsdatum (manuelle Status-Korrekturen bleiben unangetastet).

2) Cockpit-Filter 'Kuendigungsbestaetigung': neue Liste
   cancellationConfirmations (Vertraege mit Bestaetigung in Status
   ACTIVE/DRAFT/CANCELLED) + Filter-Option im Cockpit-Dropdown. Eigene
   Liste, weil bereits CANCELLED-Vertraege mangels Issue sonst nicht in
   der Cockpit-Liste auftauchen.

Beides lokal verifiziert (Helper: ACTIVE->CANCELLED + endDate; Cockpit:
Vertrag erscheint in der Liste mit korrektem Status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 15:13:05 +02:00
duffyduckandClaude Opus 4.8 5423c83009 Gutschriften Phase 3b/2: ZUGFeRD/Factur-X (hybrides PDF/A-3)
- zugferd.service.ts: CII-XML (EN 16931, urn:cen.eu:en16931:2017,
  Dokumenttyp 381 Gutschrift). Steuerkategorie S bei USt, sonst E mit
  Befreiungsgrund. Verkaeufer=Firma (CompanyProfile), Kaeufer=Kunde.
- zugferdPdf.service.ts: bettet factur-x.xml als AF /Data ein, setzt
  sRGB-OutputIntent + XMP (PDF/A-3B pdfaid + Factur-X-Extension-Schema)
  via pdf-lib.
- creditNotePdf: eingebettete DejaVuSans-Fonts (Pflicht fuer PDF/A)
  statt Standard-Helvetica; nach PDF-Erzeugung ZUGFeRD-Embedding.
- assets/fonts (DejaVuSans + Bold) + assets/icc (sRGB) ins Repo;
  Dockerfile kopiert backend/assets ins Runtime-Image.

Lokal strukturell verifiziert: 1 Seite, /AF, /Metadata, /OutputIntents,
EmbeddedFiles, Font eingebettet, XML wohlgeformt (xmllint), TypeCode
381, GrandTotal korrekt.

WICHTIG: vor Prod gegen einen ZUGFeRD-/Factur-X-Validator pruefen
(Staging + echte Firmendaten). Feinheiten (Trailer-ID, XMP, XML-MIME)
ggf. nach erstem Validator-Lauf nachziehen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 12:56:12 +02:00
duffyduckandClaude Opus 4.8 043bc173b7 Gutschrift-PDF: Fusszeile etwas tiefer positioniert
Unteren Rand fuer die Fusszeile temporaer auf 22 verkleinert, damit sie
naeher am Seitenende sitzt - weiterhin nur 1 Seite (verifiziert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 19:01:34 +02:00
duffyduckandClaude Opus 4.8 32236706db Gutschrift-PDF: Fusszeile auf Seite 1 (keine zweite Seite mehr)
Footer wurde mit fester y=790 gesetzt und lief dadurch ueber den
unteren Seitenrand -> pdfkit legte eine zweite Seite an. Position jetzt
aus Seitengeometrie berechnet (heightOfString + page.height/margins),
sodass die Fusszeile am unteren Rand der ersten Seite endet, auch bei
zweizeiligem Umbruch. Verifiziert: PDF hat nur noch 1 Seite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 18:39:41 +02:00
duffyduckandClaude Opus 4.8 f24c4308aa Gutschriften: Auszahlungskonto (Bankkonto des Kunden) fuer Ueberweisungen
Bei Geld-Gutschriften kann die Auszahlung auf ein anderes Konto gehen
als das Vertrags-Abbuchkonto:

- CreditNote.payoutBankCardId + Migration (FK ON DELETE SET NULL, damit
  Loeschen einer Bankkarte die Gutschrift nicht mitreisst).
- Formular: Dropdown mit allen Bankkonten des Kunden (Default =
  Vertrags-Abbuchkonto). getCreditNoteDefaults liefert bankCards +
  contractBankCardId.
- Server prueft, dass die gewaehlte Bankkarte dem Kunden des Vertrags
  gehoert (kein Fremdkonto unterschieben).
- PDF: bei Ueberweisung 'Unsere Bankverbindung' (Absender) + darunter
  'an Bankkonto: <Kunden-IBAN> (<Inhaber>)'. Section-Zeile zeigt das
  Auszahlungskonto.

Lokal verifiziert (Anlage + PDF).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:49:35 +02:00
duffyduckandClaude Opus 4.8 642fb05c76 Gutschriften Phase 3b/1: Gutschrift-PDF (pdfkit)
PDF-Erzeugung fuer Gutschriften mit Firmenstammdaten als Absender,
Empfaenger (Kunde+Adresse), Netto/USt/Brutto (bzw. ohne USt-Ausweis),
Sachwert/'Ware erhalten' bzw. Ueberweisungs-Bankverbindung,
Unterschriftsblock, Firmen-Fusszeile.

Endpoint POST /credit-notes/:id/pdf; 'PDF'-Button in CreditNotesSection
(erzeugen + im Tab oeffnen bzw. vorhandenes ansehen). PDF-Pfad wird bei
inhaltlicher Aenderung geleert -> Neu-Erzeugung. Download ueber
fileDownload (credit-notes subDir, Vertrags-Ownership).

Lokal verifiziert (valides %PDF, korrekte Betraege).

ZUGFeRD-XML-Embedding (PDF/A-3, EN 16931) folgt als Teil 2 mit
Validator-Pruefung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:32:34 +02:00
duffyduckandClaude Opus 4.8 2c705272ea Gutschriften Phase 3a: Firmenstammdaten (Absender) fuer PDF/ZUGFeRD
Neues CompanyProfile-Modell (Einzel-Zeile) + Migration (IF NOT EXISTS):
Firmenname, Anschrift, USt-IdNr, Steuernummer, Handelsregister,
Geschaeftsfuehrer, Kontakt, IBAN/BIC/Bank. Fliesst spaeter in
Gutschrift-PDF + ZUGFeRD-Verkaeuferdaten ein.

Backend: Service (getOrCreate/update mit Feld-Whitelist), Controller,
Routes GET/PUT /api/company-profile (settings:read/update), auditiert.

Frontend: Settings-Seite 'Firmenstammdaten (Absender)'
(/settings/company-profile) mit Firma/Anschrift, Steuer/Register,
Kontakt/Bank. Typ + companyProfileApi + Menue-Eintrag.

Phase 3b (PDF + ZUGFeRD-Erzeugung) folgt darauf auf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:19:36 +02:00
duffyduckandClaude Opus 4.8 9e38cb32f0 Gutschriften Phase 2b: Vertrags-UI + Beleg-Upload + Nummernkreis-Seite
Frontend:
- CreditNotesSection im Vertragsdetail (eigene Section wie Rechnungen):
  Liste + Anlegen/Bearbeiten (Modal). Geld/Sachwert mit passenden
  Feldern, USt-Block mit Live-Vorschau Netto/USt/Brutto, Datum, Ort,
  Unterschrift, 'Ware erhalten'. Loeschen mit Bestaetigung.
- Ueberweisungsbeleg-Upload/-Anzeige/-Entfernen (nur Geld-Gutschriften).
- Nummernkreis-Verwaltung als Settings-Seite mit Live-Vorschau der
  naechsten Nummer.
- Frontend-Typen (CreditNote etc.) + creditNoteApi.

Backend:
- Ueberweisungsbeleg-Upload-Route /upload/credit-notes/:id/receipt
  (+ DELETE), Portal geblockt, Ownership ueber Vertrag.
- fileDownload: Ownership-Aufloesung fuer credit-note-receipts +
  credit-notes (Vertrag) ergaenzt.

Als Section statt Tab-Balken, weil die Vertragsansicht kartenbasiert
ist (kein bestehender Tab-Balken); spaeter umbaubar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:15:02 +02:00
duffyduckandClaude Opus 4.8 3580c51cb1 Gutschriften Phase 2a: Kleinunternehmer-Flag am Kunden + USt-Default
Kunden-Feld vatExempt (Kleinunternehmer/USt-befreit §19) + Migration
(ADD COLUMN IF NOT EXISTS). Checkbox im Kundenformular nur fuer
Firmenkunden.

Gutschrift-Vorbelegung vatRelevant wird aus dem Kunden abgeleitet:
Firmenkunde ohne USt-Befreiung -> USt-relevant an (Netto), sonst aus
(wie Privat). Jede Gutschrift speichert ihren eigenen Snapshot, ein
spaeterer Statuswechsel des Kunden aendert bestehende Gutschriften
nicht.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 10:10:13 +02:00
duffyduckandClaude Opus 4.8 f1c37a7d25 Gutschriften Phase 1: Backend (Modell, Nummernkreis, CRUD, USt-Rechnung)
Neues Feature Gutschriftsverwaltung (Subventionen am Vertrag):

- Modelle CreditNote + CreditNoteNumberRange + Enums (GELD/SACHWERT,
  PRIVAT/FIRMA, NETTO/BRUTTO) + Migration (IF NOT EXISTS, auf Dev
  angewandt).
- USt pro Gutschrift waehlbar (vatRelevant + Basis Netto/Brutto +
  Satz); Netto/USt/Brutto werden berechnet und getrennt gespeichert
  (ZUGFeRD-tauglich). Kundentyp Privat/Firma aus Kunde vorbelegt.
- Nummernkreis in Settings verwaltbar; Nummernvergabe transaktional
  mit SELECT ... FOR UPDATE (keine Doppelvergabe). Bsp GS-2026-0001.
- 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 geblockt (interner
  Bereich), CREATE/UPDATE/DELETE auditiert.

Verifiziert: USt-Rechnung (200 netto->238, 200 brutto->168,07+31,93)
und fortlaufende Nummernvergabe.

Phase 2 (Vertrag-UI + Beleg-Upload + Nummernkreis-UI) und Phase 3
(PDF + ZUGFeRD) folgen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 09:03:42 +02:00
duffyduckandClaude Opus 4.8 3bbe262039 Vertrag kopieren: Legacy-phoneNumber/simCardNumber auch leeren (R135/136)
Pentester-Fund: die Legacy-Scalar-Felder mobileDetails.phoneNumber und
simCardNumber (kein sichtbares Formularfeld mehr) wurden beim Kopieren
noch uebernommen - anders als die SIM-Karten-Liste und anders als
'Rufnummern werden nicht uebernommen'. Zwei getrennte Vertraege
desselben Kunden haetten so unbemerkt dieselbe Rufnummer getragen.

Datenintegritaet, kein Security-Loch. Beide Felder jetzt im Kopier-
Leer-Block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 13:26:42 +02:00
duffyduckandClaude Opus 4.8 2aee1f1114 Vertrag kopieren: Portal-Zugangsdaten mitkopieren
Auf Wunsch: Portal-Benutzername + Stressfrei-Verknuepfung/'nicht
benoetigt'-Flag werden im Kopier-Modus behalten (bei gleichem Anbieter
oft identisch). Nur das Passwort bleibt leer, weil es verschluesselt
gespeichert und - wie beim Bearbeiten - nicht im Klartext ins Feld
geladen wird. Banner-Hinweis ergaenzt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 12:58:41 +02:00