Update projektplan.md

This commit is contained in:
2026-04-27 15:31:11 +02:00
parent cd662141e0
commit 8a531144af
+5 -1
View File
@@ -929,7 +929,7 @@ Ziel: neue Presets ohne Code-Änderung hinzufügbar.
| Ports | Nur 443 offen (Unraid), SSH nur lokal |
| Datenhaltung | /data/ nur root lesbar |
| IDOR-Prävention | Arzt-Identität kommt ausschließlich aus der Session, nie aus der URL. `AuthenticatedArzt`-Extractor leitet den Dateisystempfad serverseitig ab. |
| Input-Validierung | `case_id` wird als UUIDv4 validiert (`uuid::Uuid::parse_str`, aktuell inline in Handlern; `ValidCaseId`-Extractor als Phase-4-TODO). Ungültige Werte → 400. Verhindert Path-Traversal. |
| Input-Validierung | **Zweistufig:** strukturelle Validierung via Serde (`Json<T>`/`Form<T>`/`Query<T>`-Extraktoren), darüber das zentrale `server/src/validate.rs`-Modul mit pure Boundary-Validatoren — Slug-Shape (Login), Magic-Link-Token-Shape (Consume) und strikter `recorded_at`-RFC3339 (Upload, exakt 20 Bytes `YYYY-MM-DDTHH:MM:SSZ`, kein Subsekunden-Anteil). Info-Leak-sensitive Routen (Login → „Login fehlgeschlagen", Magic-Link → `/web/login`-Redirect) folden Shape-Fehler in den Standard-Fehlerpfad; Upload bouncet 400 mit Field-Name. `case_id` weiterhin inline UUIDv4 (`uuid::Uuid::parse_str`); `ValidCaseId`-Extractor bleibt Phase-4-TODO. Verhindert Path-Traversal und Filename-Drift (siehe Abweichungen → Sicherheit / Hardening). |
| CSRF | **Implementiert (2026-04-23):** `CsrfForm<T>`-Extractor mit constant-time-compare (`subtle`), pro-Session-Token (Login + Magic-Link minten, nicht rotiert), Askama-Macro rendert Hidden Field in allen state-changing `/web/`-POST-Formularen. `SameSite=Strict`-Cookie bleibt als zweite Ebene. Login und `/api/...` sind exempt. |
| Security Headers | **Zweischichtig:** Axum-Defense-in-Depth via `tower-http::SetResponseHeaderLayer` (in `create_router_with_state`, `if_not_present`-Mode erlaubt Per-Route-Overrides) — aktiv auch ohne nginx. Zusätzlich nginx-Layer bei TLS-Terminierung (geplant). Gesetzt: `Content-Security-Policy` (enthält `'unsafe-inline'`, weil Templates Inline-JS haben — revisiten, sobald User-Input unescaped gerendert wird), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Permissions-Policy`. **HSTS bewusst ausgelassen**, bis TLS-Terminierung steht (gecachter `max-age` auf Plain-HTTP-Deployment ist irreversibel). |
@@ -1192,6 +1192,7 @@ wiremock = "0.6"
- [x] Purge-Closed (`POST /web/cases/purge-closed`, `confirm=yes` Pflicht) — **admin-only**, entfernt geschlossene Fälle hart, emittiert `CaseEventKind::CasePurged` pro entferntem Case.
- [x] Soft-Delete mit Undo letzter Batch (ohne Bestätigungsdialog)
- [ ] `ValidCaseId`-Extractor: UUID-Validierung als Axum-Extractor (aktuell inline in Handlern)
- [x] Boundary-Input-Validatoren (`server/src/validate.rs`, 2026-04-27): pure Funktionen für Slug-, Magic-Link-Token- und strikten `recorded_at`-RFC3339-Shape; integriert in `handle_login_submit`, `handle_consume`, `handle_upload`. Info-Leak-sensitive Endpunkte folden Shape-Fehler in den vorhandenen Fehlerpfad, Upload reicht 400 mit Field-Name durch. Integration-Test in `server/tests/validate_boundary_test.rs` (167 Zeilen).
- [x] SSE-Endpunkt (`GET /web/events`) — `events`-Modul + `routes::events`, 15 s Keep-Alive, Non-Admins slug-gefiltert, Admins ungefiltert, `Lagged`-Recovery durch Reload
- [x] Vanilla-JS EventSource-Client — debounced `location.reload()` in `my_cases.html`, `case_page.html`, `case_recordings.html`
- [~] Nachtrag-Hinweis bei Aufnahmen nach Abschluss — funktional durch Auto-Trigger abgedeckt (re-analysiert automatisch), sichtbarer „⚠ N neue Aufnahmen"-Banner noch offen
@@ -1233,6 +1234,7 @@ wiremock = "0.6"
- [ ] Bluetooth-Headset Erkennung + Indikator
- [~] case_id (UUIDv4) Generierung: Neu → neue case_id, Fortsetzen → bestehende — PoC erzeugt `CaseId.new()` pro Aufnahme (`domain/CaseId.kt`). Neu/Fortsetzen-Dualität fehlt, weil keine Fallliste-UI existiert.
- [~] Persistente Speicherung im lokalen Dateisystem (UTC-Timestamps in Dateinamen) — PoC speichert in `cacheDir` und **löscht nach Upload**; Datei lebt also nur während der laufenden Pipeline. `unsynced/`-Pattern (überlebt Neustarts) fehlt.
- [x] RFC3339-Timestamp-Erzeugung mit Subsekunden-Strip (`PendingStore.nowRfc3339()`, 2026-04-27): `Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()` als zentrale Quelle für `recordedAt` (`RecordingViewModel.startRecordingFlow` + `finalizeAndUpload`) und `setAt` (`OnelinerState.Manual` in `CaseDetailScreen`). Spiegelt `doctate-common::timestamp::now_rfc3339` 1:1. Regression-Unit-Test `nowRfc3339_has_no_subsecond_component` guard'et gegen erneuten Mikrosekunden-Leak. Hintergrund: `Instant.now().toString()` lieferte plattformabhängig Mikrosekunden, der Server-Filename-Parser fiel dadurch auf `today` zurück → doppelte „Heute"-Header in der Web-UI.
- [ ] Marker-Datei pro Fall (`/recordings/cases/{case_id}.json`): minimales JSON bei Erstellung, Oneliner ergänzen nach Empfang
- [ ] Fallliste aus Marker-Dateien ableiten (`created_at` von heute + optionaler Oneliner)
- [ ] Lazy Cleanup: nur Marker-Dateien (`cases/`) von gestrigen/älteren UTC-Tagen beim Scan löschen — Audiodateien in `unsynced/` werden nie durch Cleanup gelöscht
@@ -1416,6 +1418,7 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche
|---|---|---|---|
| CSRF-Schutz | Als Phase-4-TODO skizziert („Token pro Session, Hidden Field, serverseitige Validierung") | **Implementiert (2026-04-23):** `CsrfForm<T>`-Extractor mit `subtle::ConstantTimeEq`, Token bei Login + Magic-Link-Konsum gemintet und **nicht rotiert**, Askama-Macro (`partials/csrf_field.html`) für alle state-changing Forms. Login bleibt unbeschützt, `/api/...` ist exempt. | Per-Request-Rotation würde Multi-Tab-Nutzung brechen und bringt gegenüber `SameSite=Strict` keinen realen Zusatzschutz. Constant-Time-Compare schließt Timing-Oracles am Token aus. Login-Exempt: Forced-Login-CSRF hat auf diese Codebasis keinen Impact (kein Konto-Merge, keine verknüpften Aktionen). `/api/...`-Exempt: Header-Auth (`X-API-Key`) ist nicht cookie-gebunden → kein CSRF-Vektor. |
| Security-Header | Im Plan als nginx-Aufgabe ausgewiesen (Phase 1) | **Zweischichtig:** Axum-Layer (`tower-http::SetResponseHeaderLayer` in `create_router_with_state`, `if_not_present`-Mode) + geplanter nginx-Layer bei TLS-Terminierung. HSTS bewusst ausgelassen. CSP enthält `'unsafe-inline'` für Inline-JS/CSS in Templates. | Der Axum-Layer schützt auch dann, wenn nginx wegfällt (Dev-Direkt-Zugriff, Proxy-Misconfig) oder wenn Integrationstests die Response-Shape prüfen — der Layer sitzt deshalb in `create_router_with_state`, nicht in `main.rs`. `if_not_present` erlaubt Per-Route-Overrides (z.B. Magic-Link setzt eigene `Referrer-Policy`). HSTS auf Plain-HTTP-Deployment ist irreversibel (gecachter `max-age` sperrt den Browser aus, sobald TLS mal ausfällt) — erst mit stabiler TLS-Terminierung aktivieren. `'unsafe-inline'` ist eine bewusste Schuldenposition: revisiten, sobald User-Input unescaped gerendert wird. |
| Boundary-Input-Validierung | Im Plan nur als Phase-4-TODO „Input-Validierung" (case_id-UUID, inline in Handlern) skizziert | **Zentralisiert (2026-04-27):** `server/src/validate.rs` als pure-Funktion-Modul für Slug-Shape (Login), Magic-Link-Token-Shape (Consume) und strikter `recorded_at`-RFC3339 (Upload, exakt 20 Bytes, kein Subsekunden-Anteil). Info-Leak-sensitive Routen folden den Shape-Fehler in ihren Standard-Fehlerpfad (Login → „Login fehlgeschlagen", Magic-Link → `/web/login`-Redirect); Upload macht 400 mit Field-Name. `case_id`-Pfad bleibt inline (`ValidCaseId`-Extractor weiterhin Phase-4-TODO). | Zwei verzahnte Anlässe: (1) Subsekunden-Drift im `recorded_at` von Watch-Clients (`Instant.now().toString()` ohne Truncate) hat den Filename-Parser für Tag-Gruppierung gebrochen — sichtbar als doppelte „Heute"-Header. (2) Slug + Magic-Link-Token wurden bisher direkt an User-/Store-Lookup gereicht, ohne Shape-Vorprüfung — kein konkreter Bug, aber Path-Traversal- und Info-Leak-Defense-in-Depth. Pure-Funktion + zentrales Modul: Regression-Test wandert mit dem Validator, nicht mit dem Handler; folding-vs-400 entscheidet jeder Caller selbst, sodass Info-Leak-sensitive Endpunkte den Standard-Fehlerpfad behalten. Strikte `recorded_at`-Spezifikation (20 Bytes statt „RFC3339-toleriert") fängt den Watch-Drift am Server-Boundary, parallel zum Truncate-Fix in `PendingStore.nowRfc3339()` (siehe Abweichungen → Client-Architektur). |
### Client-Architektur
@@ -1438,3 +1441,4 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche
| Watch-Core in Kotlin (nicht Rust) | „Es war geplant, die Business-Logik aller Clients in Rust zu entwickeln" (Diskussionsstand) — technisch machbar via UniFFI-Bindings auf `doctate-client-core` | **Watch-`CaseStore` in Kotlin nachgebaut**, strukturell 1:1 zum Rust-Pendant (Snapshot-Flow, Merge-/Reconcile-Asymmetrie, Sync-Flag-Semantik) | Für MVP (PoC → Watch-App-Abschluss) wiegt der zusätzliche Build-Stack (NDK, Cross-Compile für `aarch64-linux-android` + `armv7-linux-androideabi`, APK-Größe) schwerer als der Code-Sharing-Nutzen — es gibt genau *einen* Kotlin-Consumer. Natürlicher Einzug-Moment ist der Start der **Handy-App** (zweiter Kotlin-Consumer → erster echter Duplikations-Druck); bis dahin wird die Kotlin-Portierung diszipliniert strukturgleich zum Rust-Original geführt, damit ein späterer UniFFI-Swap kein Refactoring der Call-Sites erzwingt. |
| Watch-Recording-Flow | Plan: Einstieg über EdgeButton „● Neu" *oder* Tap auf Listen-Eintrag → direkt Aufnahme; Stop → zurück auf den aktiven Fall | **Drei separate Screens**: `CaseListScreen``CaseDetailScreen` (bei Tap auf einen Fall) → `RecordingScreen` (bei Tap auf den Detail-Record-Button). „● Neu" bleibt Direkt-Einstieg (überspringt Detail). `RecordingScreen` ist radikal reduziert (live `mm:ss`-Counter, Stop-EdgeButton, 300 s Safety-Cap via `elapsedSeconds`-Reducer, `FLAG_KEEP_SCREEN_ON` via `view.keepScreenOn`). Swipe-right oder Back = Discard ohne Bestätigung (`DisposableEffect.onDispose` ist der einzige Discard-Pfad). | Ein Tap auf einen Listeneintrag darf nicht stumm das Mikrofon öffnen — der Arzt soll erst den Fall (Datum + Oneliner) sehen und explizit „Record" drücken. Der State-Machine-Split kommt mit zwei neuen Invarianten: (1) `finalizeAndUpload` läuft in `applicationScope`, damit der Pop nach Stop den Upload nicht kill — `AtomicBoolean finalizationStarted` serialisiert Stop/Discard/Auto-Stop gegen Doppelspiel; (2) `formatTime` ist nach `TimeFormat.kt` extrahiert, damit Liste und Detail-Header dieselbe Relativ-/Absolut-Formatierung („Gerade eben" / „Heute HH:mm" / „dd.MM.yy HH:mm") zeigen. |
| Watch-Oneliner-Manual-Override | Plan (Zeile 1384 ff.): Oneliner wird ausschließlich serverseitig am Batch-Ende aus allen Transkripten regeneriert — der Arzt beeinflusst ihn nur über das Diktat („Bezeichnung: …") | **PoC auf der Watch**: Tap auf den Oneliner im `CaseDetailScreen` öffnet den Wear-OS-System-Input-Picker (voice/keyboard/handwriting, fest auf `de-DE` gepinnt). Ein Doctor-Edit latched ein Manual-Flag am `CaseEntry`, das den simulierten LLM-Burst blockiert; ein erneutes Manual-Edit gewinnt wieder. | Medizinische Oneliner-Typos und Fall-Bezeichnungen sollen ohne Umweg übers Diktat korrigierbar sein. „Doctor wins" ist die neue Invariante gegenüber der serverseitigen Regen-Logik. **Offene Entwurfsentscheidung für Phase 5b:** wie wird das Manual-Flag zum Server synchronisiert, damit Browser-UI, Desktop-Client und künftiger Handy-Client es respektieren (kandidierende Varianten: eigenes Upload-Feld `{oneliner, manual: true}`, oder dedizierter `PUT /api/oneliner/{case_id}` mit API-Key-Auth — konsistent zum bestehenden API-Key-Erfassungs-Vertrag). Aktuell arbeitet der PoC nur gegen den `CaseStoreStub`, das Wire-Protokoll zum Server fehlt. |
| Watch-Timestamp-Erzeugung | Plan implizit: `Instant.now().toString()` als RFC3339-Quelle (siehe ältere PoC-Beispiele) | **`PendingStore.nowRfc3339()`** als zentrale Quelle: `Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()` — spiegelt `doctate-common::timestamp::now_rfc3339()` 1:1. Konsumiert von `RecordingViewModel.startRecordingFlow`, `RecordingViewModel.finalizeAndUpload` und `CaseDetailScreen` (`OnelinerState.Manual.setAt`). Unit-Test `nowRfc3339_has_no_subsecond_component` regression-guard'et den Truncate. | `Instant.now().toString()` leakt plattformabhängig Mikro- oder Nanosekunden in den Filename. Der Server-seitige Filename-Parser (`utc_date_and_iso_of`) erwartet exakt `YYYY-MM-DDTHH-MM-SSZ` nach `:``-`-Substitution; bei Drift gibt das `<time datetime>` leer zurück und die Tag-Gruppierung fällt auf `today` zurück — sichtbar als doppelte „Heute"-Header in der Web-UI. Truncate auf Sekunden ist load-bearing, nicht kosmetisch. Zentraler Helper im `audio/PendingStore`-companion verhindert Drift bei künftigen neuen Call-Sites; flankiert vom strikten Server-Boundary-Validator (siehe Sicherheit / Hardening). |