diff --git a/docs/realtime/issues/01-research-webrtc-tauri-mobile.md b/docs/realtime/issues/01-research-webrtc-tauri-mobile.md new file mode 100644 index 0000000..8cd67bd --- /dev/null +++ b/docs/realtime/issues/01-research-webrtc-tauri-mobile.md @@ -0,0 +1,87 @@ +# Recherche: WebRTC im Tauri-WebView (Mobile) + +Type: research +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Funktioniert WebRTC — insbesondere `getUserMedia` für Mikrofonzugriff und `RTCPeerConnection` — +im WebView einer Tauri-v2-App auf **Android** und **iOS**? Und wenn ja, unter welchen +Bedingungen? + +Das ist die riskanteste Unbekannte des gesamten Efforts. Fällt die Antwort negativ aus, +ist das Voice-Signaling-Design für die Hälfte der Zielplattformen wertlos, und die +Voice-Topologie-Frage sieht völlig anders aus. + +### Zu klären + +- **iOS**: Tauri v2 rendert in `WKWebView`, nicht in Safari. `getUserMedia` ist dort + historisch problematisch gewesen. Gilt das noch? Ab welcher iOS-Version? Braucht es + Entitlements, `Info.plist`-Einträge (`NSMicrophoneUsageDescription`), oder + WebView-Konfiguration, die Tauri nicht standardmäßig setzt? +- **Android**: Tauri v2 nutzt Android System WebView. Wie werden + `PermissionRequest`-Callbacks (`RESOURCE_AUDIO_CAPTURE`) durchgereicht? Muss die + Tauri-App das abfangen, oder passiert es automatisch? +- **Berechtigungen**: Wie beantragt eine Tauri-v2-App Mikrofonrechte auf beiden + Plattformen? Gibt es dafür ein offizielles Plugin oder Muster? +- **Alternative**: Falls WebView-WebRTC ausfällt — gibt es einen brauchbaren nativen + Pfad? (`webrtc-rs` im Tauri-Rust-Layer, Audio-Streams zwischen Rust und WebView). + Reifegrad, Mobile-Tauglichkeit, Aufwand. +- **Desktop-Gegenprobe**: Läuft es auf Windows (WebView2) und Linux (WebKitGTK)? + Auf Linux ist WebRTC in WebKitGTK-Builds nicht selbstverständlich einkompiliert. + +### Quellenqualität + +Primärquellen bevorzugen: Tauri-v2-Dokumentation und Issue-Tracker, WebKit-/Chromium- +Release-Notes, Apple-Entwicklerdoku. Blogposts nur als Hinweisgeber, nicht als Beleg — +und immer mit Datum, dieses Feld bewegt sich. + +### Erwartetes Ergebnis + +Eine Markdown-Datei im Repo mit: klarer Ja/Nein/Teilweise-Antwort pro Plattform, +den nötigen Konfigurationsschritten, den Versionsuntergrenzen, und den Quellen. Wenn +irgendwo „unklar" steht, muss dabeistehen, welches Experiment die Frage entscheiden würde. + +## Answer + +Vollständiges Ergebnis: [`../research/01-webrtc-tauri-mobile.md`](../research/01-webrtc-tauri-mobile.md) + +| Plattform | Antwort | Aufwand | +|---|---|---| +| **iOS** (WKWebView) | **Ja**, ab 14.3 (praktisch 15.0) | eine Zeile: `NSMicrophoneUsageDescription` in `Info.ios.plist` | +| **Android** (System WebView) | **Ja**, ab API 21 | zwei Manifest-Zeilen: `RECORD_AUDIO` **und** `MODIFY_AUDIO_SETTINGS` | +| **Windows** (WebView2) | **Ja**, ohne Konfiguration | — | +| **Linux** (WebKitGTK 4.1) | **Nein** | drei unabhängige Gates, eines davon distro-seitig unlösbar | + +**Council-Risiko 2 ist positiv aufgelöst.** Mobile trägt WebRTC im WebView; das +Voice-Signaling-Design ist für Android und iOS nicht wertlos. *Voice-Topologie* ist entblockt. + +Belegt gegen die tatsächlich aufgelösten Versionen (`wry 0.55.1` über +`tauri-runtime-wry 2.11.4` mit `^0.55.0`), nicht gegen `dev` — wichtig, weil wry 0.56 +die Permission-Lage geändert hat, aber von Tauri 2.11.x nicht gezogen werden kann. + +### Drei Befunde, die andere Tickets verändern + +1. **Der Problemfall ist Linux, nicht Mobile.** `ENABLE_WEB_RTC` folgt + `ENABLE_EXPERIMENTAL_FEATURES` (= OFF); Debian/Ubuntu und Alpine setzen es belegbar + nicht. Dazu `WebKitSettings:enable-webrtc` Default FALSE (von wry nie gesetzt) und + `permission-request` Default Deny (in 0.55.1 nicht verbunden). Gate 2 und 3 wären per + `with_webview` lösbar — Gate 1 hängt am Distro-Paket des Nutzers. + → eigenes Ticket *Voice auf Linux*. +2. **Der Signaling-Server kann DTLS-Fingerprints tauschen** (RFC 8827) und damit den + Audio-Stream vollständig MITMen. Der spezifizierte Gegenmechanismus (W3C Identity for + WebRTC, IdP) ist in **keiner** der vier Ziel-Engines implementiert. Fingerprints müssen + durch einen E2EE-authentifizierten Kanal übertragen und gepinnt werden. + → harte Randbedingung für *Voice-Signaling-Protokoll*. +3. **wry vergibt Mikrofonrechte ohne App-Beteiligung** — auf iOS in 0.55.1 bedingungslos + `Grant` für jeden Origin. Unkritisch, solange nur eigener gebündelter Code im WebView + läuft; sofort kritisch, sobald fremde Origins (iframe, Preview-Einbettung) dazukommen. + → Architekturregel in den Notes der Map. + +### Offen (blockiert die Spec nicht, gehört vor die Implementierung) + +Sieben benannte Experimente in §8 der Ergebnisdatei. Höchste Priorität E1: `getUserMedia` +auf einem physischen iPhone, je einmal mit und ohne Info.plist-Key — entscheidet zugleich +das offene Upstream-Issue tauri#12068. diff --git a/docs/realtime/issues/02-monotoner-cursor.md b/docs/realtime/issues/02-monotoner-cursor.md new file mode 100644 index 0000000..cd7aeb2 --- /dev/null +++ b/docs/realtime/issues/02-monotoner-cursor.md @@ -0,0 +1,229 @@ +# Monotoner Cursor + +Type: grilling +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Worauf zeigt der `?since=`-Backfill, sodass ein Client garantiert **keine** +Nachricht überspringt? + +Das ist Risiko 1 aus dem Council-Verdikt und die Voraussetzung dafür, dass die +Best-effort-Zustellung überhaupt tragfähig ist. Ohne monotonen Cursor ist „WS-Event +verpasst, hol es per REST nach" ein Versprechen, das der Server nicht halten kann. + +### Warum die naheliegenden Antworten falsch sind + +- **`created_at`** — Zeitstempel kollidieren bei gleichzeitigen Sendern, und ein + Cursor auf einem nicht-eindeutigen Wert verliert oder dupliziert an der Grenze. +- **`BIGSERIAL` / `id`** — Sequenzen vergeben IDs bei `INSERT`, aber Transaktionen + werden in **Commit-Reihenfolge** sichtbar. Transaktion A holt ID 100, Transaktion B + holt ID 101 und committet zuerst. Ein Client liest bis `since=101`, dann committet A. + Nachricht 100 wird **nie** ausgeliefert. Bei zwei gleichzeitigen Sendern ist das kein + Randfall, sondern der Normalfall. + +Unter E2EE kann der Server den Verlust weder erkennen noch reparieren — er sieht den +Inhalt nicht und kann keine Lückenprüfung über die Nachrichtenfolge machen. + +### Optionen, die zu bewerten sind + +1. **Sequenz pro Conversation unter Lock** — beim Insert `SELECT ... FOR UPDATE` auf + eine Zählerzeile der Conversation, dann `seq = counter + 1`. Lückenlos und monoton + *pro Conversation*, was genau die Granularität des Dumb-Pipe-Events ist. Kosten: + Serialisierung der Schreiber pro Conversation. Ist das bei den Zielgrößen ein Problem? +2. **Snowflake-/ULID-artige ID** — zeitbasiert + Zufall, monoton generierbar ohne + DB-Rundtrip. Löst die Eindeutigkeit, aber **nicht** das Commit-Order-Problem. +3. **Sichtbarkeitsbasierter Cursor** — `pg_current_snapshot()` / `xmin`-basiertes + Nachlesen, sodass der Cursor die Transaktionssichtbarkeit abbildet statt der + Insert-Reihenfolge. Korrekt, aber deutlich exotischer. +4. **Outbox-Tabelle mit eigener Sequenz**, geschrieben in derselben Transaktion, + gelesen in Commit-Reihenfolge. + +### Zu entscheiden + +- Welche Option — und warum überwiegt sie die anderen bei den Faktoren Simplizität + (harte Regel: eine Binary + Postgres) und Korrektheit? +- Ist der Cursor **pro Conversation** oder **pro Server/Nutzer**? Das Dumb-Pipe-Event + trägt `{conversation, cursor}`, also spricht viel für pro Conversation — aber ein + Client, der 40 Conversations offen hat, muss dann 40 Cursor verwalten. +- Wie verhalten sich **Edits** und **Soft-Deletes**? Beide ändern eine bestehende Zeile, + ohne einen neuen Cursor-Wert zu erzeugen — ein Client, der nur ab `since` nachlädt, + sieht sie nie. Braucht es einen zweiten Kanal, eine `updated_at`-Achse, oder wird jede + Änderung als neuer Cursor-Eintrag geführt? +- Wie sieht die **Migration** aus? `messages` hat heute keine solche Spalte. + +### Abnahmekriterium + +Das Ticket ist gelöst, wenn es einen kleinen, lauffähigen Test gibt, der unter zwei +gleichzeitigen Sendern die Lücke **im gewählten Ansatz nicht mehr** reproduziert — und +sie im naiven `BIGSERIAL`-Ansatz reproduziert, damit der Test beweist, dass er misst, +was er messen soll. + +--- + +## Answer + +Entschieden am 14.08.2026. Vier Entscheidungen, die zusammen einen Mechanismus ergeben. + +### 1. Ein Zähler pro Conversation, vergeben unter Zeilen-Lock + +```sql +ALTER TABLE conversations ADD COLUMN seq BIGINT NOT NULL DEFAULT 0; +``` + +Jede Mutation zieht die nächste Nummer **in derselben Transaktion** wie die Änderung: + +```sql +UPDATE conversations SET seq = seq + 1 WHERE id = $1 RETURNING seq; +``` + +Der `UPDATE` nimmt einen Zeilen-Lock auf die Conversation. Ein zweiter Schreiber in +dieselbe Conversation blockiert bis zum Commit des ersten — **damit ist Commit-Reihenfolge +gleich `seq`-Reihenfolge**, lückenlos und monoton. Genau das ist die Eigenschaft, die +`BIGSERIAL` nicht hat: dort wird die Nummer beim `INSERT` vergeben, sichtbar wird die Zeile +aber erst beim Commit, und diese beiden Reihenfolgen können auseinanderlaufen. + +Die Korrektheit folgt hier aus der Konstruktion, nicht aus einer Heuristik: solange die +Nummer unter demselben Lock vergeben wird, unter dem die Zeile entsteht, kann ein Leser +mit `seq > N` nichts überspringen. + +**Bonus, der die Wahl mitentschieden hat:** der Zähler *ist* der aktuelle Maximal-Cursor. +Erstabruf, Ungelesen-Anzeige und der Dumb-Pipe-Event lesen ein Feld statt zu scannen. + +**Bewusst in Kauf genommen:** Schreiber derselben Conversation serialisieren (bei Chat +irrelevant — Menschen tippen), und pro Mutation entsteht eine neue Zeilenversion auf +`conversations`, die Autovacuum abräumt. + +**Verworfen:** Snowflake/ULID lösen nur Eindeutigkeit, nicht die Commit-Reihenfolge. +Advisory-Lock + `MAX(seq)+1` ist gleich korrekt und vermeidet den Schreib-Churn, braucht +aber einen Hash der UUID auf int64 (Kollisionen kosten Durchsatz) und einen Scan für den +Maximal-Cursor. Sichtbarkeitsbasierte Cursor (`pg_current_snapshot()`, `xmin`) sind +theoretisch am saubersten, brechen aber die Dumb-Pipe-Form `{conversation, cursor}`, weil +der Cursor kein `bigint` mehr wäre. + +### 2. Die Änderungsachse ist ein Event-Log, keine Spalte auf `messages` + +```sql +CREATE TABLE conversation_events ( + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + seq BIGINT NOT NULL, + kind TEXT NOT NULL, -- message_created | message_edited | message_deleted | reaction_added | ... + target_id UUID NOT NULL, + at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (conversation_id, seq) +); +``` + +Jede Mutation hängt eine Zeile an, mit der Nummer aus dem Conversation-Zähler. Das löst +das Problem, an dem eine bloße Spalte auf `messages` scheitert: eine bearbeitete oder +soft-gelöschte Nachricht erzeugt keinen neuen Cursor-Wert und wäre für einen +`?since`-Backfill unsichtbar. + +Das Log deckt zusätzlich alles ab, was **keine** Nachrichtenzeile hat — Reactions, +Attachments, Mitgliederwechsel, Umbenennungen — und trägt damit die Fog-Themen Presence, +Typing und Read-Receipts später ohne Umbau mit. Und: **die Log-Zeile ist buchstäblich der +Dumb-Pipe-Event**; `NOTIFY` kann direkt darauf feuern. + +Preis, bewusst akzeptiert: eine zweite Tabelle und ein zusätzlicher Insert pro Mutation. + +### 3. Genau ein Zähler, und `created_at` verliert seine Sortierrolle + +Es gibt **einen** Zähler pro Conversation, den das Log verbraucht. Eine Nachricht speichert +die `seq` ihres `message_created`-Events als **unveränderlichen Sortierschlüssel**: + +```sql +ALTER TABLE messages ADD COLUMN seq BIGINT NOT NULL; +CREATE UNIQUE INDEX messages_conversation_seq_idx ON messages(conversation_id, seq); +``` + +Damit fällt `created_at` als Sortierkriterium weg und wird reine Anzeigeinformation. Das +ist kein Nebeneffekt, sondern eine Korrektur: `created_at` ist heute `NOW()`, in Postgres +der **Transaktionsstart**. Zwei gleichzeitige Sender bekommen Zeitstempel, deren +Reihenfolge der Commit-Reihenfolge widersprechen kann — und der Wert ist nicht einmal +eindeutig. Die bestehende Sortierung in `list_messages` ist damit schon heute falsch, +unabhängig vom Realtime-Umbau. + +Eine bearbeitete Nachricht bekommt ein neues Event mit höherer `seq`, behält aber ihre +eigene `seq` — sie springt also im Cursor-Raum ans Ende, nicht im Chatverlauf. + +### 4. Der Backfill liefert aufgelösten Zustand, nicht rohe Events + +`GET …?since=N` liest das Log ab `N`, sammelt die betroffenen IDs und gibt den **aktuellen +Zustand** dieser Entitäten zurück, plus den neuen Cursor. Der Client ersetzt schlicht, was +er bekommt — keine Event-Interpretation, keine Abspiel-Logik. + +Drei Eigenschaften, die daraus folgen: + +- **Idempotent.** Ein doppelt zugestelltes Signal kann nichts kaputtmachen. +- **Koaleszierend.** Zehn Edits derselben Nachricht ergeben eine Zeile im Ergebnis. +- **Das Log ist reine Signalisierung** und damit wegwerfbar — es ist nicht die + Wahrheitsquelle, die Tabellen sind es. + +**Wichtig:** der Backfill darf Soft-Deletes **nicht** herausfiltern. Er muss Tombstones +liefern, sonst erfährt der Client nie von einer Löschung. Das weicht vom heutigen +Verhalten von `list_messages` ab (`WHERE deleted_at IS NULL`). + +Verworfen wurde die Alternative „rohe Events, Client spielt sie ab": sie böte eine echte +Änderungshistorie, macht aber das Log zur Wahrheitsquelle (also unbeschneidbar), verlangt +idempotente Client-Logik pro Event-Typ, und unter E2EE müssten die Events selbst +verschlüsselt werden — sonst leckt das Log genau die Metadaten, die der Blob schützen soll. + +### 5. Keine Retention, aber ein Antwortlimit und ein `resync`-Pfad + +Das Log wird vorerst **nicht** beschnitten. Eine Zeile pro Mutation ist für Postgres nichts, +und eine Aufräum-Mechanik, die niemand braucht, ist Wartungslast mit Fehlerpotenzial — +zumal sie bei der Regel „eine Binary + Postgres" entweder als Timer-Task in den Server +wandert oder `pg_cron` dazuzieht. + +Stattdessen bekommt der Backfill ein hartes Limit pro Antwort. Wird es überschritten, +antwortet der Server mit `resync` statt mit einem Delta; der Client verwirft seinen Cache +für diese Conversation und lädt frisch. + +**Dieser Pfad wird ohnehin gebraucht** — für das Handy, das drei Wochen aus war, für die +allererste Verbindung ohne Cursor, und laut +[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) auch als +Reaktion auf `RecvError::Lagged`. Damit sind drei verschiedene Situationen auf **einen** +Mechanismus abgebildet. + +Retention lässt sich später nachrüsten, ohne das Protokoll zu ändern. + +### Begriffe + +Für die einheitliche Sprache im Projekt festgehalten: + +| Begriff | Bedeutung | +|---|---| +| **`seq`** | Monoton steigende Nummer pro Conversation, vergeben unter dem Zeilen-Lock der Conversation. Der einzige Ordnungsbegriff — nicht `created_at`, nicht `id`. | +| **Cursor** | Der `seq`-Stand, den ein Client für eine Conversation zuletzt gesehen hat. Ein `bigint`, mehr nicht. | +| **Event** | Eine Zeile in `conversation_events`. Signalisiert, *dass* sich etwas geändert hat; nicht *was* der neue Zustand ist. | +| **Backfill** | `?since=`: der aufgelöste aktuelle Zustand aller seither betroffenen Entitäten. | +| **Resync** | Antwort des Servers, wenn ein Delta nicht sinnvoll bildbar ist (Cursor zu alt, kein Cursor, Limit überschritten). Der Client verwirft seinen Cache für die Conversation. | +| **Tombstone** | Eine soft-gelöschte Zeile, die der Backfill **mitliefert**, damit der Client die Löschung erfährt. | + +### Abnahmekriterium — Status + +Das Kriterium verlangte einen lauffähigen Test. **Er ist spezifiziert, aber nicht +geschrieben** — dieser Effort plant, und der Test setzt die Migration und den geänderten +Handler voraus, also Implementierungsarbeit. Die Spezifikation: + +> Zwei Transaktionen A und B fügen gleichzeitig in dieselbe Conversation ein. A beginnt +> zuerst und committet zuletzt. Ein Leser pollt zwischen den Commits mit `?since=` +> und merkt sich alles Gelieferte. Erwartung: nach beiden Commits hat der Leser **beide** +> Nachrichten gesehen. +> +> Der Test muss in der naiven Variante (`BIGSERIAL` oder `created_at` als Cursor) +> **fehlschlagen** — sonst misst er nicht, was er messen soll. + +Der eigentliche Beweis liegt in der Konstruktion (§1); der Test bewacht die Implementierung +dagegen, dass jemand den `UPDATE … RETURNING` später aus der Transaktion herauszieht oder +durch ein `SELECT max(...)` ersetzt. + +### Offene Kopplung + +**Wo die Event-Zeile geschrieben wird — im Handler oder per DB-Trigger — ist hier bewusst +nicht entschieden.** Diese Frage steht in +[*Fan-out-Architektur*](08-fan-out-architektur.md), weil sie dort mit `NOTIFY` zusammenfällt: +ein Trigger feuert garantiert auf jedem Schreibweg und macht die zweite +Fan-out-Implementierung fast geschenkt, ein Handler-Aufruf ist dafür leichter zu lesen. diff --git a/docs/realtime/issues/03-research-ws-auth-reconnect.md b/docs/realtime/issues/03-research-ws-auth-reconnect.md new file mode 100644 index 0000000..a592efe --- /dev/null +++ b/docs/realtime/issues/03-research-ws-auth-reconnect.md @@ -0,0 +1,116 @@ +# Recherche: WS-Auth, Heartbeat und Reconnect + +Type: research +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Welche etablierten Muster gibt es für Authentifizierung, Verbindungserhalt und Reconnect +bei langlebigen WebSocket-Verbindungen — und welche davon passen zu Axum 0.8 und einem +JWT-basierten Auth-Modell? + +### Zu klären + +- **Token-Übergabe beim Handshake.** Die Browser-`WebSocket`-API erlaubt keine + benutzerdefinierten Header. Übliche Auswege: Token im Query-String (landet in + Server-Logs — bei diesem Bedrohungsmodell heikel), `Sec-WebSocket-Protocol`-Missbrauch, + Cookie, oder ein erstes Auth-Frame nach dem Handshake mit kurzem Timeout. Was ist + gängige Praxis, was sind die dokumentierten Nachteile? **Achtung:** falls die + Verbindung im Tauri-Rust-Layer lebt (siehe *Wo lebt die WS-Verbindung im Client*), + entfällt die Header-Beschränkung komplett — die Recherche soll beide Fälle abdecken. +- **Token-Ablauf bei laufender Verbindung.** Das JWT ist beim Handshake gültig und läuft + nach Minuten ab, während die Verbindung Stunden steht. Muster: Verbindungs-TTL mit + erzwungenem Reconnect, In-Band-Re-Auth-Frame, serverseitige Revocation-Liste. Was + machen andere? +- **Heartbeat.** WebSocket hat Ping/Pong auf Protokollebene. Wann reicht das, wann braucht + es Application-Level-Heartbeats? Übliche Intervalle. Verhalten hinter Proxies und + Corporate-Firewalls (relevant: Firmenkunden sind Zielgruppe). +- **Reconnect-Strategie.** Exponentielles Backoff mit Jitter — konkrete Parameter aus + der Praxis. Thundering-Herd-Verhalten, wenn ein Server neu startet und alle Clients + gleichzeitig zurückkommen. +- **Axum 0.8 konkret.** Wie sieht ein WebSocket-Upgrade mit State-Zugriff und Middleware + aus? Greift `middleware::from_fn_with_state` beim Upgrade-Request? Wie werden viele + gleichzeitige Verbindungen sauber verwaltet und beim Shutdown beendet? +- **`tokio::sync::broadcast` und langsame Empfänger.** Der Kanal verwirft Nachrichten für + hinterherhinkende Receiver (`RecvError::Lagged`). Wie behandelt man das korrekt, wenn + „Lagged" eigentlich „Resync jetzt" bedeuten soll? Gibt es dafür ein etabliertes Muster + oder bessere Kanaltypen für diesen Zweck? + +### Quellenqualität + +Axum-/Tokio-Dokumentation und Quellcode, RFC 6455, MDN. Praxisberichte nur mit Datum. + +### Erwartetes Ergebnis + +Eine Markdown-Datei im Repo: pro Teilfrage die Optionen mit ihren dokumentierten Vor- +und Nachteilen, jeweils belegt. **Keine Empfehlung** — die Entscheidung fällt im Ticket +*Verbindungsaufbau & Auth über WS*, dieses Ticket liefert nur die Faktenlage. + +## Answer + +Vollständiges Ergebnis: [`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md) +— Faktenlage zu allen sechs Teilfragen, Quellenverzeichnis und ein Anhang „Was nicht belegt +werden konnte" mit 17 Punkten. Keine Empfehlung, wie gefordert. + +### Die vier Befunde, die andere Tickets verändern + +**1. Der `Authorization`-Header ist der leiseste Kanal — und das ist belegbar, nicht Folklore.** +In den Default-Konfigurationen von nginx, Apache und Caddy landet der Query-String im +Access-Log; Caddy redigiert `Authorization` aktiv zu `REDACTED`, `request.uri` dagegen nicht. +Kein Transportweg schützt vor dem Betreiber selbst — der Server *muss* das Token sehen. Der +Unterschied zwischen den Optionen ist ausschließlich, was den Handshake in Logfiles, Backups +und APM-Systemen **überlebt**. Nebenbei entkräftet: die verbreiteten Referrer- und +Browser-History-Argumente gegen den Query-String ließen sich für `new WebSocket(url)` **nicht** +belegen und gehören nicht ins Entscheidungsticket. + +**2. `Sec-WebSocket-Protocol` ist für dieses Bedrohungsmodell schlechter als gedacht** — Caddy +loggt per Default *alle* Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu +kommt: ein JWT ist zwar ein legaler Subprotocol-Token, aber `=` ist als Separator verboten +(Base64-Padding), und der Browser erzwingt ein Echo des Subprotokolls durch den Server. + +**3. Die Browser-API verschluckt alle Fehlerdetails — der Rust-Layer nicht.** WHATWG schreibt +vor, dass DNS-Fehler, Connection-Refused, TLS-Fehler, Subprotocol-Mismatch und Abbruch nach +dem Handshake für das Skript **ununterscheidbar** sein müssen: immer `CloseEvent.code === 1006`. +Im WebView ist „Token abgelaufen" also nicht von „Server down" unterscheidbar. `tungstenite::Error::Http` +trägt dagegen die vollständige HTTP-Response inklusive Statuscode und Body. Diese Asymmetrie +zieht sich durch fast jede Teilfrage → starkes Argument in *Wo lebt die WS-Verbindung im Client*. + +**4. `axum::serve(...).with_graceful_shutdown(...)` beendet WebSocket-Verbindungen nicht und +wartet nicht auf sie.** Belegt vierfach: der `close_rx`-Guard hängt nur an der hyper-Task, das +`on_upgrade`-`tokio::spawn` nicht; `UpgradeableConnection::graceful_shutdown` ist nach dem Hijack +laut hyper-util-Doku „does nothing"; axum-Issue #3003 (offen seit 21.10.2024) und PR #3831 +(offen seit 11.07.2026) beschreiben genau das. **Das Projekt muss WS-Verbindungen selbst +tracken und beenden** — `CancellationToken` + `TaskTracker` sind die belegten Bausteine. + +### Weitere Faktenlage, kompakt + +- **`middleware::from_fn_with_state` greift beim Upgrade-Request** — das Upgrade ist ein normaler + GET, 401/403 sind regulär sendbar. +- **Token-Ablauf:** SignalR dokumentiert ausdrücklich, dass Auth **nicht** revalidiert wird. + Optionen sind TTL/Reconnect (AWS API GW: 2 h / 10 min idle, hart), In-Band-Refresh (Centrifugo, + Ably — Token landet dann in keinem Log) oder eine Revocation-Liste (im Betreiber-Angreifer-Modell + per Definition wirkungslos). +- **Heartbeat:** Protokoll-Ping ist aus dem WebView **nicht auslösbar** (WHATWG), im Rust-Layer + antwortet tungstenite automatisch. Proxy-Korridor der dokumentierten Idle-Timeouts: Azure + AppGW 20 s, GCP 30 s, nginx 60 s. Die verbreiteten Cloudflare-„100 s" und die HAProxy-Defaults + sind Mythen und ließen sich nicht belegen. Etablierte Intervalle: Socket.IO 25 s, Phoenix 30 s, + Centrifugo 25 s. +- **Reconnect:** Praxiswerte reichen von 10 ms (Phoenix, ohne Jitter) bis 15 s (Ably). Gegen + Thundering Herd sind vier Ansätze belegt: Client-Jitter, server-gesteuerter Hinweis + (`Retry-After`, Discord Op 7), Draining und Accept-Rate-Limiting — mit Postmortems von Discord + (29.04.2026) und Slack (15.03.2021). +- **`RecvError::Lagged(n)`:** `n` = übersprungene Nachrichten, der Receiver bleibt brauchbar, der + Cursor springt auf die älteste noch vorhandene Nachricht. ⚠️ **„Lagged = Resync" ist nirgends + als offizielles Muster dokumentiert** — gängige Community-Praxis, aber ohne Primärquelle. + Belegbar sind nur die Bausteine (`resubscribe()`, `Receiver::len()` als Frühwarnung, + `mpsc::try_send` pro Client). +- **`tokio::sync::watch` passt auffällig gut zum Dumb Pipe** — „retains only the last sent value" + ist exakt die Semantik von „der Cursor der Conversation X ist jetzt N". Kein Lag-Fehler möglich. + → *Fan-out-Architektur*. +- **`PgListener` belegt eine dedizierte Postgres-Connection außerhalb des Pools** (aktuell + `max_connections = 10`), reconnected aber selbst. `NOTIFY` ist transaktional. Neue Gefahr: + eine volle Notify-Queue lässt Transaktionen **beim Commit fehlschlagen**. +- **Bestandsaufnahme:** `axum = "0.8.9"` ist **ohne `ws`-Feature** eingebunden, es gibt keinen + Graceful Shutdown, und `Extension` wird nirgends gelesen. diff --git a/docs/realtime/issues/04-voice-topologie.md b/docs/realtime/issues/04-voice-topologie.md new file mode 100644 index 0000000..0865ea0 --- /dev/null +++ b/docs/realtime/issues/04-voice-topologie.md @@ -0,0 +1,151 @@ +# Voice-Topologie: Mesh oder SFU + +Type: grilling +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Läuft Audio direkt zwischen den Clients (P2P-Mesh) oder über einen serverseitigen +Medien-Router (SFU) — und was davon legt das Signaling-Protokoll heute schon fest? + +Diese Frage wurde beim Charting bewusst offengelassen (Q6). **Entblockt am 14.08.2026:** +[*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md) hat +bestätigt, dass Android, iOS und Windows WebRTC im WebView tragen. Linux nicht — das ist +eine eigene Frage in [*Voice auf Linux*](11-voice-auf-linux.md), deren Antwort auf diese +hier zurückwirken kann. + +### Spannungsfeld + +- **Mesh** passt zum Bedrohungsmodell: der Server sieht nie Klartext-Audio, DTLS-SRTP + bleibt zwischen den Peers. Passt außerdem zur harten Deployment-Regel — nichts + Zusätzliches zu betreiben. **Skaliert aber nicht**: bei n Teilnehmern hält jeder n−1 + Verbindungen und lädt sein Audio n−1 mal hoch. Praktische Obergrenze liegt bei etwa + vier bis sechs Teilnehmern, auf Mobile eher darunter. +- **SFU** skaliert auf Dutzende Teilnehmer, **terminiert aber DTLS** und sieht damit den + Audio-Klartext. Das widerspricht dem Bedrohungsmodell „Betreiber ist Angreifer" + frontal. E2EE über einen SFU braucht Insertable Streams / SFrame — das ist ein + eigenes Forschungsprojekt und im Rust-Ökosystem dünn besiedelt. Zusätzlich ein + weiterer zu betreibender Dienst, gegen die Simplizitätsregel. + +### Zu entscheiden + +- Welche **Teilnehmerzahl** muss ein Voice-Room realistisch tragen? Zwei Personen im + Gespräch, ein Team-Standup mit acht, oder ein Discord-artiger Kanal mit dreißig? + Diese Zahl entscheidet die Frage fast allein. +- Wenn Mesh: was passiert **oberhalb** der Grenze — harte Begrenzung mit klarer + Fehlermeldung, oder Degradation? +- Wenn SFU irgendwann: welche **Nähte** muss das Signaling-Protokoll heute schon haben, + damit der Wechsel kein Bruch ist? (Wer bietet an, wer antwortet, wie werden Streams + identifiziert.) +- **TURN**: P2P scheitert in restriktiven Firmennetzen ohne Relay — und Firmen sind + Zielgruppe. Bestätigt ist bereits: TURN bleibt optionale externe Abhängigkeit. Was + genau ist die Zusage an den Nutzer, wenn kein TURN konfiguriert ist? Und wie merkt + der Client das früh genug, um es ehrlich anzuzeigen statt in einen Timeout zu laufen? +- Wie werden **TURN-Credentials** übermittelt, ohne sie fest in den Client zu backen? + (Zeitbeschränkte HMAC-Credentials sind der übliche Weg — gehört das ins + Signaling-Protokoll?) + +### Zwei SDP-Fragen aus der WebRTC-Recherche, die hierher gehören + +Beide sind formal Codec-/Transport-Themen, aber es sind SDP- bzw. ICE-Entscheidungen und +damit Topologie-nah: + +- **`iceTransportPolicy: "relay"` erzwingen?** Verhindert, dass der Peer die IP-Adresse + des Gegenübers lernt (W3C WebRTC 1.0 nennt das ausdrücklich als Zweck). Der Server + kennt die IP ohnehin aus der WebSocket-Verbindung, also kostet es nichts an Vertraulichkeit + gegenüber dem Betreiber — es reduziert strikt die Exposition gegenüber dem *Peer*. Preis: + ohne TURN gibt es dann gar keine Verbindung mehr, auch im LAN nicht. +- **Opus mit `cbr=1` (und ggf. `usedtx=0`) verhandeln?** Variable Bitrate leckt über + Paketgrößen ein Sprech-/Pausen-Muster — ein feindlicher TURN-Betreiber bekommt damit + eine Voice-Activity-Detection frei Haus, obwohl er den Klartext nicht sieht. Konstante + Bitrate schließt das gegen Bandbreite. + +### Randbedingung + +Diese Entscheidung darf die Grenze „dieser Effort endet beim Signaling" nicht +überschreiten. Codecs, Echo-Cancellation und Audio-Pipeline sind out of scope — die beiden +Punkte oben sind die Ausnahme, weil sie im SDP ausgehandelt werden und damit ins Protokoll +gehören. + +--- + +## Answer + +Entschieden am 14.08.2026. + +### 1. Mesh mit harter Obergrenze von sechs Teilnehmern + +P2P-Mesh, DTLS-SRTP bleibt zwischen den Peers, **der Server fasst nie Audio an**. Das +entspricht dem Bedrohungsmodell direkt und braucht keinen weiteren Dienst — die harte Regel +„eine Binary + Postgres" bleibt für Voice unangetastet. + +Oberhalb von sechs wird der Beitritt **abgelehnt**, nicht degradiert: eine klare Meldung ist +ehrlicher als ein Gespräch, das für alle schlechter wird, ohne dass jemand versteht warum. +Bei sechs Teilnehmern lädt jeder Client fünf Ströme hoch, also grob 120 kbit/s aufwärts — die +Grenze, ab der Mesh in der Praxis kippt. + +**Kein spekulativer Vorbau für einen späteren SFU.** Das ist der Kern der Entscheidung und +bewusst gegen den Reflex getroffen: ein SFU-Protokoll ist client-zu-Server adressiert, ein +Mesh-Protokoll peer-zu-peer. Eine „SFU-taugliche Naht" heute wäre Selbsttäuschung — sie würde +Komplexität kosten und im Ernstfall trotzdem nicht passen. **Ein SFU wäre ein bewusster +Protokollbruch in einem eigenen Effort**, und das steht so in der Map. + +Verworfen: SFU von Anfang an. Er trüge Dutzende Teilnehmer, und SFrame/Insertable Streams +sind laut Recherche auf allen Zielplattformen verfügbar (WKWebView ab 15.4, Chromium ab 141). +Dagegen stehen drei Kosten: ein weiterer zu betreibender Dienst gegen die Deployment-Regel, +ein erheblich schwereres Krypto-Design zulasten des E2EE-Efforts, und selbst mit SFrame +behielte der SFU Zugriff auf RTP-Metadaten — die `audio-level`-Header-Erweiterung ist ein +direktes Sprechaktivitäts-Orakel und müsste eigens wegverhandelt werden. + +Ebenfalls verworfen: den SFU dauerhaft auszuschließen. Das hätte das Profil geschärft, aber +die Tür für Firmenkunden mit größeren Runden zugezogen — eine Produktentscheidung, die es +ohne Not heute nicht braucht. + +### 2. Eine Datenschutz-Politik je Instanz, vom Server gesetzt + +Die Instanz konfiguriert **einen** Schalter, der zwei Dinge zugleich regelt und im Signaling +an den Client übermittelt wird; der Client zeigt sichtbar an, welcher Modus gilt. + +| Schalterstellung | `iceTransportPolicy` | Opus | +|---|---|---| +| **streng** | `"relay"` erzwungen — der Peer lernt die IP-Adresse nie | `cbr=1`, `usedtx=0` | +| **offen** | Standard-ICE (host / srflx / relay) | Standard (VBR mit DTX) | + +Die Kopplung ist nicht Bequemlichkeit, sondern folgt der Bedrohung: **beide Lecks entstehen +am selben Ort.** Läuft Audio über das Relay, sieht der Betreiber — der erklärte Angreifer — +die Paketgrößen und hat damit einen Sprechaktivitäts-Detektor, obwohl er den Inhalt nicht +lesen kann. Läuft es direkt, bekommt er die Pakete gar nicht zu sehen, dafür lernt der Peer +die IP. Ein Schalter, ein anzuzeigender Zustand. + +**Warum je Instanz und nicht global:** auf einem öffentlichen Server telefonieren Fremde +miteinander, und dass zwei Unbekannte gegenseitig ihre Anschlussadresse lernen, ist für ein +Datenschutzprodukt untragbar — dort gehört „streng" hin, und TURN wird auf dieser Instanz zur +Voraussetzung. Auf einer Firmeninstanz sind die Peers Kollegen im selben Netz; dort wäre +erzwungenes Relay nur Bandbreitenverschwendung, und ein Gespräch zwischen zwei Rechnern im +selben Büro liefe ohne Not über den Server. + +**Bewusst in Kauf genommen:** im offenen Modus bleibt das Sprechmuster für Netzbeobachter auf +dem Pfad sichtbar, und die Zusage an den Nutzer hängt am Betreiber — deshalb muss der Client +sie anzeigen und nicht nur intern kennen. + +**Damit bleibt TURN die optionale Abhängigkeit**, die die Map festgelegt hat: ohne TURN läuft +Voice im offenen Modus eingeschränkt (Firmen-NATs), im strengen Modus gar nicht — und der +Betreiber weiß, worauf er sich einlässt, weil er den Schalter selbst umlegt. + +### 3. Beide Medienpfade müssen die Politik umsetzen + +Aus [*Voice auf Linux*](11-voice-auf-linux.md): `RTCPeerConnection` im WebView auf drei +Plattformen, `livekit/rust-sdks` auf Linux. Sowohl `iceTransportPolicy` als auch die +Opus-`fmtp`-Parameter müssen auf beiden Wegen gesetzt werden können — beides ist +Standard-WebRTC und in beiden Stacks vorhanden, gehört aber in die Spec, damit es nicht auf +einem der Pfade vergessen wird. + +### Folgen + +[*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md) ist damit entblockt. Zwei +Dinge gehen von hier dorthin: die **Raumgröße** muss serverseitig durchgesetzt werden (der +Beitritt wird abgelehnt, nicht nur in der UI verhindert), und die **Politik-Übermittlung** +gehört ins Signaling — zusammen mit den TURN-Zugangsdaten, die im strengen Modus zwingend +sind. diff --git a/docs/realtime/issues/05-wo-lebt-die-ws-verbindung.md b/docs/realtime/issues/05-wo-lebt-die-ws-verbindung.md new file mode 100644 index 0000000..06c825c --- /dev/null +++ b/docs/realtime/issues/05-wo-lebt-die-ws-verbindung.md @@ -0,0 +1,194 @@ +# Wo lebt die WS-Verbindung im Client + +Type: grilling +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Läuft die WebSocket-Verbindung im **Tauri-Rust-Layer** oder im **WebView-JavaScript** — +und wie geht das mit N Verbindungen zu N unabhängigen Servern zusammen? + +### Ausgangslage + +Heute liegt die gesamte Serverkommunikation in Rust: `src-tauri/src/state.rs` hält den +`reqwest::Client`, das Token und die User-ID; `commands/*.rs` exponiert Tauri-Commands +ans Frontend. `BASE_URL` ist eine Konstante — das trägt das Multi-Server-Modell nicht +und muss ohnehin fallen. + +### Die Spannung + +- **Rust** (`tokio-tungstenite`, Events per `app.emit()` ans Frontend): konsistent mit + dem bestehenden Muster, Token bleibt im Rust-State und nie im WebView, eine + Implementierung für alle vier Plattformen, volle Header-Kontrolle beim Handshake + (löst das Token-im-Query-String-Problem aus *Recherche: WS-Auth, Heartbeat und + Reconnect*), robusterer Reconnect ohne Abhängigkeit vom WebView-Lebenszyklus. +- **JavaScript** (`WebSocket`-API im WebView): eine Zeile statt einer Abhängigkeit, + kein IPC-Hop zwischen Rust und Frontend — und **WebRTC lebt später ohnehin im + WebView**. Ein Signaling-Kanal in Rust und ein Peer-Connection-Objekt in JS bedeutet + zwei Lebenszyklen, die konsistent gehalten werden müssen. + +Genau diese Aufteilung ist der Kern der Frage: Chat-Push in Rust ist unstrittig +angenehm, Voice-Signaling in Rust ist es nicht. + +### Neues Argument aus der Auth-Recherche (14.08.2026) + +[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) hat eine +Asymmetrie gefunden, die schwerer wiegt als erwartet: **die Browser-API verschluckt alle +Fehlerdetails.** WHATWG schreibt vor, dass DNS-Fehler, Connection-Refused, TLS-Fehler, +Subprotocol-Mismatch und Abbruch nach dem Handshake für das Skript ununterscheidbar sein +müssen — immer `CloseEvent.code === 1006`, das `error`-Event ist ein leeres `Event`. + +Im WebView ist damit **„Token abgelaufen" nicht von „Server down" unterscheidbar.** +`tungstenite::Error::Http` trägt dagegen die vollständige HTTP-Response inklusive Statuscode +und Body. Bei N Servern gleichzeitig — wo der Nutzer pro Server einen aussagekräftigen +Zustand sehen soll — ist das kein Detail. Dazu kommt: nur im Rust-Layer ist +`Authorization: Bearer` überhaupt möglich, der laut derselben Recherche einzige Kanal, der in +den Proxy-Defaults nicht mitgeloggt wird. + +Beides zeigt in dieselbe Richtung. Wer sich für JS entscheidet, muss diese beiden Kosten +ausdrücklich in Kauf nehmen — der WebRTC-Nähe zuliebe kann das trotzdem richtig sein, aber +es ist jetzt eine bewusste Abwägung und keine Nebenwirkung mehr. + +### Zu entscheiden + +- Ein Ort für beides, oder eine bewusste Trennung (Dumb-Pipe in Rust, Voice-Signaling + in JS)? Falls Trennung: was ist der Preis an doppelter Auth- und + Reconnect-Verwaltung, und rechtfertigt ihn die WebRTC-Nähe? +- **Multi-Server**: N Verbindungen zu N Servern. Wo liegt der Verbindungs-Pool, wie + werden Events dem richtigen Server zugeordnet, wie sieht das im Frontend-State aus? +- Wie ersetzt `BASE_URL` sich durch eine Server-Liste? Wo wird sie persistiert + (heute `tauri-plugin-store` laut README, im Code noch nicht vorhanden)? +- Wenn Rust: welche Ereignisse gehen per `emit` ans Frontend, und in welcher Granularität? +- Wenn JS: wie kommt das Token dorthin, ohne es dauerhaft im WebView zu halten? + +### Randbedingung + +Die Antwort beeinflusst *Verbindungsaufbau & Auth über WS* (Header-Verfügbarkeit) und +*Client-Integration*. Sie ist bewusst **nicht** von der WebRTC-Recherche blockiert — +falls diese negativ ausfällt, wird das Ticket neu bewertet. + +--- + +## Answer + +Entschieden am 14.08.2026. + +### 1. Die Verbindung lebt im Tauri-Rust-Layer, eigene Implementierung + +`tokio-tungstenite`, Verbindungs-Pool im `AppState` neben Token und Server-Liste, Zustellung +ans Frontend per `app.emit()`. Das ist keine neue Architektur, sondern die bestehende — +`reqwest`, Token und `user_id` liegen bereits dort. + +Vier Gründe, jeder einzeln belegt in +[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md): + +- **`Authorization: Bearer` ist möglich.** Laut der Log-Matrix der Recherche der einzige + Kanal, den die Defaults von nginx, Apache und Caddy nicht mitschreiben — Caddy redigiert + ihn sogar aktiv. +- **Fehler sind diagnostizierbar.** `tungstenite::Error::Http` trägt die vollständige + Server-Antwort. Im WebView schreibt WHATWG vor, dass alle Fehlerdetails ununterscheidbar + sind (immer `1006`) — bei N Servern, für die je ein aussagekräftiger Zustand angezeigt + werden soll, ist das disqualifizierend. +- **Das Token verlässt den Rust-Prozess nie.** +- **Eine Implementierung statt vier WebView-Engines** (WebView2, WKWebView, Android-Chromium, + WebKitGTK), und Reconnect und Pool überleben einen WebView-Reload. + +**Geprüft und verworfen: `tauri-plugin-websocket`.** Der Mittelweg wäre real — der Socket +läuft dort in Rust, `connect()` nimmt `headers`. Dagegen sprechen: die API ist dünn +(connect/send/addListener/disconnect), Reconnect, Backoff und Multi-Server-Pool schreibt man +trotzdem selbst, nur eben in TypeScript; es gibt ein dokumentiertes Rennen mit +Nachrichtenverlust zwischen `connect()` und `addListener()` +([plugins-workspace#3152](https://github.com/tauri-apps/plugins-workspace/issues/3152)); +WebView-Reloads hinterlassen hängende Verbindungen +([#1507](https://github.com/tauri-apps/plugins-workspace/issues/1507)); und die Maintainer +schreiben selbst, sie könnten derzeit keine Zeit in das Plugin stecken +([Discussion #10096](https://github.com/orgs/tauri-apps/discussions/10096)). Man bekäme +JS-Lebenszyklus, IPC-Hop und eine ungepflegte Abhängigkeit zugleich. + +### 2. Die WebRTC-Spannung löst sich auf + +Voice-Signaling läuft **ebenfalls über den Rust-Socket**; nur die `RTCPeerConnection` selbst +lebt im WebView. Eine SDP-Nachricht kostet damit zwei IPC-Hops — bei einer Handvoll +Nachrichten pro Anrufaufbau irrelevant, IPC liegt im Sub-Millisekunden-Bereich. + +Die im Ticket formulierte Sorge („zwei Lebenszyklen, die konsistent gehalten werden müssen") +war die falsche Abwägung: eine zweite Browser-WebSocket nur für Signaling würde eine zweite +Auth **ohne Header**, einen zweiten Reconnect und ein Token im WebView zurückholen — also +genau die drei Nachteile, die gerade verworfen wurden. + +### 3. Rust macht den Backfill und emittiert aufgelöste Entitäten + +Rust empfängt den Dumb-Pipe-Event, ruft selbst `?since=` über das bestehende +`reqwest` und emittiert die fertigen Entitäten. Damit liegt die **Cursor-Buchhaltung pro +Conversation und pro Server dort, wo Verbindung, Token und Server-Liste ohnehin liegen** — +an einer Stelle statt an zweien. Das Debouncing (zehn Signale in zwei Sekunden ergeben einen +Fetch) sitzt ebenfalls in Rust. + +Das Frontend hat **keine Cursor-Logik**. Es bekommt „hier sind aktualisierte Entitäten für +Conversation X auf Server Y" und ersetzt. + +Der `resync`-Fall aus [*Monotoner Cursor*](02-monotoner-cursor.md) muss als **eigenes +Signal** ans Frontend durchgereicht werden, damit es seinen Cache für die betroffene +Conversation verwirft — er ist kein Datenereignis, sondern eine Anweisung. + +**Bewusst in Kauf genommen:** Rust lädt auch für Conversations nach, die gerade nicht +sichtbar sind. Ob das gedrosselt werden soll — „nur einen Ungelesen-Punkt setzen statt zu +laden" — ist eine Frage des Zustandsmodells und gehört nach +[*Client-Integration*](10-client-integration.md). + +### 4. Alle konfigurierten Server sind verbunden, solange die App im Vordergrund ist + +Nicht nur der sichtbare. Das ist der Zweck des Multi-Server-Modells: Ungelesen-Anzeigen über +Server hinweg funktionieren nur, wenn alle verbunden sind — sonst ist der Realtime-Layer für +alles außer dem aktiven Server wertlos. Bei realistischen zwei bis fünf Servern sind N +Sockets plus N Heartbeats unkritisch, und Background-Betrieb ist ohnehin out of scope, die +Akkufrage stellt sich also nur im Vordergrund. + +Keine plattformabhängige Politik: fehlende Ungelesen-Punkte auf dem Handy, die auf dem +Desktop da sind, werden als Fehler wahrgenommen und nicht als Sparmaßnahme. + +### 5. `BASE_URL` fällt; Geheimnisse kommen hinter eine Naht + +Die Konstante `BASE_URL` in `src-tauri/src/state.rs` wird durch eine **Server-Liste** +ersetzt (URL, Anzeigename, User-ID), persistiert über `tauri-plugin-store` — unkritische +Metadaten. + +Tokens und Refresh-Tokens laufen über ein schmales **`SecretStore`-Trait** mit zwei +Methoden. Dessen Implementierung darf vorerst das Einfachste sein, das funktioniert; die +endgültige Wahl trifft der **E2EE-Folge-Effort**, weil er exakt dasselbe Problem für +Geräteschlüssel hat — in verschärfter Form, denn ein Geräteschlüssel ist unwiederbringlich, +ein Token läuft ab. Dasselbe Problem zweimal zu lösen wäre Verschwendung; es jetzt falsch zu +lösen teurer als es zu vertagen. + +Zur Einordnung der verworfenen Alternative: der OS-Keychain ist inhaltlich das richtige +Ziel, hat aber auf Linux — einer erklärten Client-Plattform — die schwächste Stelle, weil +Secret Service einen laufenden gnome-keyring oder KWallet voraussetzt. + +### Begriffe + +| Begriff | Bedeutung | +|---|---| +| **Server-Liste** | Die im Client eingetragenen, voneinander unabhängigen Instanzen. Kein Server-zu-Server-Verkehr; der Client ist der einzige Aggregator. | +| **Verbindungs-Pool** | Die N offenen Sockets im `AppState`, einer je eingetragenem Server. | +| **`SecretStore`** | Naht für alles, was nicht im Klartext auf die Platte darf. Implementierung offen, wird vom E2EE-Effort gewählt. | + +### Bestandskorrektur + +Der Client pollt heute **doch** — `MessageList.tsx:33` fährt ein `setInterval(load, 3000)` +zusätzlich zum `refreshKey`. Die frühere Notiz in der Map („kein Realtime, nur `refreshKey`") +war falsch; der Client-README lag richtig. Ersetzt werden also **beide** Mechanismen. + +### Folgen für andere Tickets + +- [*Verbindungsaufbau & Auth über WS*](07-verbindungsaufbau-und-auth.md): die + Token-Transportfrage ist damit praktisch entschieden (`Authorization: Bearer`). Offen + bleiben dort Token-Ablauf, Autorisierung pro Conversation, Heartbeat-Intervall, + Verbindungslimits und der selbstgebaute Graceful Shutdown. +- [*Client-Integration*](10-client-integration.md): das Zustandsmodell im Frontend wird + deutlich kleiner, weil dort keine Cursor-Logik mehr liegt. Neu dazu kommt die Frage, ob + Rust für unsichtbare Conversations überhaupt nachladen soll. +- [*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md): Signaling läuft über den + Rust-Socket, die `RTCPeerConnection` im WebView. Wo der signierte DTLS-Fingerprint + entsteht — Rust oder WebCrypto im WebView — bleibt dort zu entscheiden. diff --git a/docs/realtime/issues/06-dumb-pipe-event-und-backfill.md b/docs/realtime/issues/06-dumb-pipe-event-und-backfill.md new file mode 100644 index 0000000..6d487cd --- /dev/null +++ b/docs/realtime/issues/06-dumb-pipe-event-und-backfill.md @@ -0,0 +1,183 @@ +# Dumb-Pipe-Event & Backfill-Endpunkt + +Type: grilling +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Wie sieht der Änderungs-Event genau aus, und welche Semantik hat der +`?since`-Backfill-Endpunkt, der ihn einlöst? + +Die Protokollform ist entschieden (Dumb Pipe: der Socket trägt „hier hat sich etwas +geändert", der Client lädt per REST nach). Dieses Ticket macht sie präzise. + +**Entblockt am 14.08.2026** durch [*Monotoner Cursor*](02-monotoner-cursor.md) — das hat +mehrere Fragen dieses Tickets bereits beantwortet. Was dort feststeht und hier **nicht** +neu aufgemacht wird: + +- Ein Zähler `seq` pro Conversation, vergeben unter Zeilen-Lock. Der Cursor ist ein `bigint`. +- Änderungsachse ist die Tabelle `conversation_events`; **eine Log-Zeile ist der Event**. + Damit sind Edits, Soft-Deletes, Reactions und Attachments abgedeckt — sie brauchen hier + keine eigene Achse mehr. +- Der Backfill liefert **aufgelösten Zustand inkl. Tombstones**, nicht rohe Events. +- Initialer Zustand ohne Cursor, zu alter Cursor und Antwortlimit laufen alle über + **denselben `resync`-Pfad**. +- Sortierung erfolgt über `messages.seq`, nicht über `created_at`. + +### Zu entscheiden + +- **Event-Format.** `{"conversation": "", "cursor": }` ist die Arbeitsannahme. + Reicht das? Braucht es einen Ressourcen-Diskriminator (`messages` vs. `reactions` vs. + `members`), oder ist ein Cursor pro Conversation, der alle Änderungen umfasst, + einfacher und ausreichend? Ein Diskriminator spart Fetches, kostet aber die Einfachheit, + die den Dumb Pipe überhaupt rechtfertigt. +- **Serialisierung.** JSON oder etwas Kompakteres. Bei ~40 Byte Nutzlast ist der + Unterschied vermutlich irrelevant — dann ist JSON die faule, richtige Antwort. Prüfen, + nicht annehmen. +- **Versionierung.** Braucht ein Protokoll mit einem Event-Typ überhaupt eine Version? + Was ist die Regel für die Zukunft, wenn Presence und Typing dazukommen (siehe Fog)? +- **Backfill-Endpunkt.** Erweiterung von `GET /conversations/{id}/messages` um `?since`, + oder ein neuer Endpunkt? Paginierung, Obergrenze pro Antwort, Verhalten bei sehr + großem Rückstand (Client war zwei Wochen offline). +- **Genaue Form der `resync`-Antwort.** Eigener HTTP-Status, ein Feld in der normalen + Antwort, oder ein eigener Endpunkt? Und wie groß ist das Antwortlimit tatsächlich? +- **`kind`-Taxonomie im Log.** Welche Werte gibt es, und wie fein? `message_created` vs. + `message_edited` vs. `message_deleted` einzeln, oder ein gemeinsames `message_changed`? + Der Backfill löst ohnehin auf — feinere Werte nützen also nur der Server-Seite und + späteren Konsumenten. +- **Endpunkt-Zuschnitt.** Ein Backfill pro Conversation, oder einer über alle Conversations + eines Servers (der Client hat womöglich 40 offene)? Letzteres spart Requests beim + Wiederverbinden, verkompliziert aber Limit und `resync`, weil beides dann pro Conversation + unterschiedlich ausfallen kann. +- **Was bleibt im Klartext.** Auch wenn der Inhalt später ein Blob ist: Conversation-ID, + Cursor, Absender und Zeitstempel muss der Server sehen, um zu routen und zu ordnen. + Diese Grenze hier explizit festhalten — sie ist die Zusage an den E2EE-Folge-Effort. + +### Randbedingung + +Der Event muss unter dem **8-kB-Payload-Limit von Postgres `NOTIFY`** bleiben. Das ist +kein Zufall, sondern der Grund, warum diese Protokollform gewählt wurde — siehe +*Fan-out-Architektur*. + +Flüchtiges (SDP/ICE, später Typing) läuft **nicht** über diesen Event. Die Naht verläuft +entlang persistiert vs. flüchtig; das flüchtige Format entsteht in +*Voice-Signaling-Protokoll*. + +--- + +## Answer + +Entschieden am 14.08.2026. + +### 1. Der Event ist minimal: `{conversation, seq}` + +Rund 60 Byte. Die Server-Identität steckt nicht drin — sie ergibt sich aus dem Socket, über +den der Event kam. Der `seq` ist kein Luxus: der Empfänger verwirft damit sofort, was er +schon hat, statt blind nachzuladen. + +**Verworfen wurden `kind` und `target_id` im Event.** Das entscheidende Argument war die +Umkehrbarkeit: ein zusätzliches Feld lässt sich später **additiv** ergänzen, weil ein +unbekanntes Feld ignorierbar ist. Es gibt also keinen Grund, heute dafür zu bezahlen. Sollte +sich der vorhergesehene Fall einstellen — Read-Receipts aus der Fog bumpen den Zähler in +einem aktiven Channel und lösen bei allen Clients Backfills aus, die nichts Sichtbares +ändern — wird `kind` nachgerüstet, ohne dass Bestehendes bricht. + +Damit bleibt `conversation_events.kind` **rein serverintern**. Die Taxonomie ist ein +Implementierungsdetail, kein Protokollbestandteil, und muss nicht versioniert werden. + +### 2. Aufholen nach Reconnect: Manifest, dann gezielte Backfills + +Ein leichter Endpunkt liefert für alle Conversations, in denen der Nutzer Mitglied ist, den +aktuellen `seq`-Stand — eine Liste `{conversation_id, seq}`. Das ist **eine** günstige +Abfrage, weil `conversations.seq` seit [*Monotoner Cursor*](02-monotoner-cursor.md) als +Spalte existiert: ein Join über `conversation_members`, kein Scan über Nachrichten. + +Der Client vergleicht mit seinen eigenen Cursorn und holt nur die tatsächlich geänderten. +Bei vierzig Conversations und drei geänderten sind das 1 + 3 Requests statt 40 — pro Server, +und der Client hält mehrere gleichzeitig. + +Der Endpunkt beantwortet zwei weitere Fragen gratis: die Ungelesen-Anzeige über alle +Conversations, und den Erstverbindungsfall ohne jeden Cursor. + +Verworfen: ein Sammel-Backfill mit Cursor-Map (müsste ein POST mit Body sein, Limit und +`resync` würden pro Conversation unterschiedlich, und eine große Antwort ist bei Abbruch +komplett verloren) sowie N parallele Backfills (vierzig Requests pro Reconnect, davon +siebenunddreißig leer — genau das Verhalten, das beim Server-Neustart einen Thundering Herd +erzeugt). + +### 3. `resync` ist ein unterschiedener Antworttyp, kein Fehlerstatus + +Der Backfill antwortet immer mit 200: + +```json +{ "kind": "delta", "cursor": 4711, "messages": [...], "reactions": [...], "attachments": [...] } +{ "kind": "resync", "cursor": 4711 } +``` + +Der Grund ist praktisch, nicht ästhetisch: `resync` ist kein Fehler, sondern ein erwarteter +Ausgang — er tritt bei jeder Erstverbindung auf. Als 4xx liefe er durch +`error_for_status()`-Reflexe, landete in Fehler-Logs und Alarmen, und die Reconnect-Logik +könnte ihn mit einem echten Netzfehler verwechseln. Ein unterschiedener Antworttyp zwingt +den Client, beide Fälle explizit zu behandeln — was er ohnehin muss. + +**Antwortlimit: 500 Entitäten als Startwert.** Eine Stellschraube, nachträglich änderbar +ohne Protokolländerung. Wird es überschritten, ist die Antwort `resync`. + +Der `delta`-Fall liefert **Tombstones mit** — soft-gelöschte Zeilen müssen im Ergebnis +erscheinen, sonst erfährt der Client nie von einer Löschung. Das weicht vom heutigen +Verhalten von `list_messages` ab (`WHERE deleted_at IS NULL`). + +### 4. Die Klartext-Grenze, konkret gezogen + +Bindend für den E2EE-Folge-Effort. **Klartext bleibt**, weil der Server es zum Routen, +Ordnen, Autorisieren und Durchsetzen von Eindeutigkeit braucht: + +`conversation_id` · `seq` · alle IDs · `user_id` (nur der Autor darf bearbeiten) · +`created_at` / `updated_at` / `deleted_at` · `attachments.size_bytes` · `attachments.url` + +Zeitstempel zu verschlüsseln wurde erwogen und verworfen: der Betreiber sieht den Zeitpunkt +ohnehin am Socket-Verkehr, der Gewinn wäre null. + +**Blob wird:** + +| Feld | Begründung | +|---|---| +| `messages.content` | der eigentliche Inhalt | +| `attachments.filename` | ein Dateiname wie `Kündigung_Mueller.pdf` ist so sensibel wie der Nachrichtentext | +| `attachments.mime_type` | verrät die Art des Dokuments | +| `reactions.emoji` | **deterministisch** verschlüsselt, damit `UNIQUE (message_id, user_id, emoji)` weiter greift — der Server weist Doppelreaktionen ab, ohne sie lesen zu können | + +`size_bytes` bleibt zwangsläufig sichtbar. + +Der **Mechanismus** bleibt dem E2EE-Effort überlassen, die **Feldliste** nicht. Begründung: +die Migration über diese Tabellen fällt in diesem Effort an — die Grenze später zu ziehen +hieße, dieselben Tabellen ein zweites Mal zu migrieren, und bis dahin lägen Dateinamen im +Klartext auf einem Server, dessen Betreiber als Angreifer gilt. + +### 5. Abgeleitet, nicht eigens entschieden + +Diese drei folgen aus dem Obigen; sie sind als Vorschlag zu lesen und jederzeit umkehrbar: + +- **Serialisierung: JSON.** Bei 60 Byte Nutzlast bringt ein Binärformat nichts, was den + Werkzeugverlust aufwöge. +- **Keine Protokollversion.** Ein Event-Typ, Felder werden additiv ergänzt, unbekannte + Felder ignoriert. Ein Versionsfeld wäre Zeremonie ohne Gegenwert. +- **Drei Endpunkte statt einer Erweiterung von `/messages`:** + + | Endpunkt | Zweck | + |---|---| + | `GET /sync` | Manifest: `{conversation_id, seq}` für alle Mitgliedschaften | + | `GET /conversations/{id}/changes?since=N` | Delta oder `resync` | + | `GET /conversations/{id}/messages?before=&limit=` | seitenweise Historie — für `resync` und für Zurückscrollen | + + `/changes` bekommt einen eigenen Endpunkt, weil seine Antwort nicht nur Nachrichten + enthält, sondern auch Reactions, Attachments und Tombstones — eine andere Form als die von + `/messages`. + +### Nebenbefund + +`list_messages` gibt heute **alle** Nachrichten einer Conversation unbegrenzt zurück +(`ORDER BY created_at ASC`, kein Limit). Das ist unabhängig vom Realtime-Umbau ein Problem +und wird durch die Seitenaufteilung oben mit behoben. diff --git a/docs/realtime/issues/07-verbindungsaufbau-und-auth.md b/docs/realtime/issues/07-verbindungsaufbau-und-auth.md new file mode 100644 index 0000000..f456383 --- /dev/null +++ b/docs/realtime/issues/07-verbindungsaufbau-und-auth.md @@ -0,0 +1,207 @@ +# Verbindungsaufbau & Auth über WS + +Type: grilling +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Wie authentifiziert sich eine WebSocket-Verbindung, wie bleibt sie am Leben, und was +passiert, wenn das Token abläuft, während sie steht? + +Die Faktenlage liegt vor: [*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) +(**entblockt am 14.08.2026**), Volltext in +[`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md). Dieses Ticket +entscheidet. + +### Vier Befunde, die die Optionen bereits verschoben haben + +1. **Kein Transportweg schützt vor dem Betreiber** — der Server muss das Token sehen. Der + Unterschied ist ausschließlich, was den Handshake in Logs, Backups und APM-Systemen + **überlebt**. In den Defaults von nginx, Apache und Caddy landet der Query-String im + Access-Log; Caddy redigiert `Authorization` aktiv, `request.uri` nicht. +2. **`Sec-WebSocket-Protocol` ist schlechter als erwartet** — Caddy loggt per Default alle + Header außer der Redaktionsliste, und dieser gehört nicht dazu. Dazu: `=` ist als + Separator verboten (JWT-Base64-Padding), und der Server muss das Subprotokoll echoen. +3. **Die Browser-API verschluckt alle Fehlerdetails** (WHATWG-Vorgabe): im WebView ist + „Token abgelaufen" nicht von „Server down" unterscheidbar — immer `CloseEvent.code === 1006`. + `tungstenite::Error::Http` trägt dagegen die vollständige Response. Wenn die Verbindung im + Rust-Layer lebt, ist `Authorization: Bearer` möglich **und** die Fehlerdiagnose brauchbar; + beides zusammen macht die Entscheidung in + [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md) fast zur Vorentscheidung + für dieses Ticket. +4. **Verworfen, nicht übernehmen:** Referrer-Leak und Browser-History als Argumente gegen den + Query-String ließen sich für `new WebSocket(url)` **nicht** belegen. Belegbar bleiben + Access-Logs, Log-Aggregation und deren Backups — das reicht als Argument. + +### Zusätzlich zu entscheiden: Graceful Shutdown selbst bauen + +`axum::serve(...).with_graceful_shutdown(...)` **beendet WebSocket-Verbindungen nicht und +wartet nicht auf sie** — vierfach belegt (hyper-util-Doku „does nothing" nach dem Hijack, +axum-Issue #3003 offen seit 21.10.2024, PR #3831 offen seit 11.07.2026, und der `close_rx`-Guard +hängt nur an der hyper-Task). Solange #3831 nicht gemerged ist, muss das Projekt WS-Verbindungen +selbst tracken. Belegte Bausteine: `CancellationToken` (ein Child-Token pro Verbindung) plus +`TaskTracker` (auf N Tasks warten). Beim Shutdown einen Close-Frame senden — `WebSocket::close` +existiert seit axum 0.8 nicht mehr, der Frame geht explizit über `send`; Code 1001 „going away" +oder 1012 „service restart". + +Weitere belegte Randbedingungen: `middleware::from_fn_with_state` **greift** beim +Upgrade-Request (401/403 regulär sendbar); Protokoll-Ping ist aus dem WebView **nicht auslösbar**; +dokumentierter Proxy-Idle-Korridor: Azure AppGW 20 s, GCP 30 s, nginx 60 s (die verbreiteten +Cloudflare- und HAProxy-Werte sind Mythen und ließen sich nicht belegen). + +**Bestandsaufnahme:** `axum = "0.8.9"` ist **ohne `ws`-Feature** eingebunden, es gibt keinen +Graceful Shutdown, und `Extension` wird nirgends gelesen. + +--- + +## Answer + +Entschieden am 14.08.2026. Bestand: Access-Token 15 Minuten (`auth.rs:22`), Refresh-Token +30 Tage, gehasht in `auth_tokens` und löschbar — Widerruf existiert bereits auf Refresh-Ebene. + +### 1. Handshake: `Authorization: Bearer` (abgeleitet) + +Folgt aus [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md): die +Verbindung lebt in Rust, eigene Header sind also möglich. Laut Recherche der einzige Kanal, +den die Defaults von nginx, Apache und Caddy nicht mitschreiben. +`middleware::from_fn_with_state` greift beim Upgrade-Request, ein 401 mit Body ist regulär +sendbar — und `tungstenite::Error::Http` trägt ihn vollständig zum Client. + +### 2. Token-Ablauf: In-Band-Re-Auth über den offenen Socket + +Der Client erneuert sein Access-Token wie bisher per REST und schiebt es als Frame über den +bestehenden Socket; der Server prüft und verlängert. Kein Reconnect, keine Handshake-Welle, +kein erneuter Manifest-Abruf — bei 15 Minuten Tokenlebensdauer und drei verbundenen Servern +wären das sonst zwölf Reconnects pro Stunde. + +Der Socket ist für Voice-Signaling ohnehin bidirektional; dies ist damit der **zweite** +client→server-Frame-Typ, nicht der erste. + +**Widerruf wirkt trotzdem:** schlägt die REST-Erneuerung fehl, weil das Refresh-Token +gelöscht wurde, kann der Client nicht verlängern, und der Server schließt bei Ablauf mit +`4001`. Die Wirkungsverzögerung ist damit auf die Restlaufzeit des Access-Tokens begrenzt, +also höchstens 15 Minuten — dieselbe Schranke wie bei einer Verbindungs-TTL. + +Verworfen: eine serverseitige Widerrufsliste. Sie machte Widerruf sofort wirksam, kostet aber +geteilten Zustand über Instanzen hinweg plus eine Prüfung je Event — und gegen den Betreiber +als Angreifer wirkt sie per Definition gar nicht, weil er die Liste kontrolliert. + +### 3. Autorisierung: Registry ist maßgeblich, REST prüft je Anfrage + +Die Zustellung folgt der Registry aus [*Fan-out-Architektur*](08-fan-out-architektur.md). Die +**Sicherheitsgrenze liegt bei den REST-Endpunkten** — Manifest, Backfill und Historie prüfen +Mitgliedschaft bei jeder Anfrage. + +Das trägt, weil der Event nur `{conversation, seq}` enthält und **keinen Inhalt**. Eine +Abweichung der Registry von der Datenbank leckt folglich nur Metadaten („Conversation X war +um 14:03 aktiv"), niemals Inhalte — und Metadaten gegenüber dem Server hat die Map +ausdrücklich als akzeptabel eingestuft. + +Verworfen: eine Datenbankprüfung je Event (bei einem Channel mit fünfhundert Mitgliedern +fünfhundert Abfragen für eine Nachricht, zusätzlich zum Backfill, der ohnehin prüft) und +periodische Neuvalidierung (ein weiterer Mechanismus ohne nachgewiesenes Driftproblem — +bleibt als billiges Sicherheitsnetz in der Hinterhand). + +### 4. Heartbeat: der Client pingt alle 15 Sekunden + +Protokoll-Ping, kein Application-Frame — im WebView wäre das nicht möglich, in Rust schon. +Der Server antwortet (tungstenite automatisch) und führt `last_seen`; nach zwei ausbleibenden +Intervallen räumt er die Registry auf. Der Client erkennt eine tote Verbindung am +ausbleibenden Pong. **Eine Richtung bedient beide Bedürfnisse.** + +Der Client treibt, weil er hinter NAT und Proxy sitzt und sein Intervall situativ anpassen +kann — im Hintergrund oder bei schlechtem Funk seltener, ohne dass der Server davon wissen +muss. Bei N verbundenen Servern kann er die Pings zeitlich verteilen statt sie zu bündeln. + +**15 Sekunden**, um unter dem striktesten dokumentierten Proxy-Idle-Timeout der Recherche zu +bleiben (Azure Application Gateway, 20 s). Konfigurierbar — die meisten Aufstellungen sind +nginx mit 60 s. Die verbreiteten Cloudflare- und HAProxy-Werte ließen sich laut Recherche +nicht belegen und sind nicht Grundlage dieser Wahl. + +### 5. Verbindungslimit: acht je Nutzer, die älteste weicht + +Bei Überschreitung wird die **älteste** Verbindung mit `4002` getrennt, nicht die neue +abgewiesen. Das ist der wichtige Teil: ein Gerät mit hängengebliebener Verbindung könnte sich +sonst selbst aussperren — und genau das passiert auf Mobile regelmäßig, wenn das +Betriebssystem eine App abräumt, ohne dass der Socket sauber schließt. + +Acht ist großzügig gegenüber dem legitimen Fall (Desktop, Handy, Tablet, Zweitrechner) und +trotzdem eine harte Obergrenze. Gezählt wird in der `user_id`-Registry, die es seit +Ticket 08 ohnehin gibt. + +Ein Rate-Limit auf Handshakes wurde erwogen und **nicht** jetzt entschieden: es trifft ein +anderes Missbrauchsmuster, ist aber ohne Betriebserfahrung nicht kalibrierbar — ein +Reconnect-Sturm nach einem Server-Neustart sieht genauso aus wie ein Angriff. Bleibt in der +Fog-Notiz zum Rate-Limiting. + +### 6. Close-Codes und Reconnect-Politik + +Drei Handlungsklassen im Client: + +| Code | Bedeutung | Klasse | +|---|---|---| +| `1012` | Server-Neustart | **sofort neu verbinden**, mit Jitter | +| `4001` | Token abgelaufen, keine gültige Re-Auth | Token erneuern, dann sofort neu verbinden; scheitert die Erneuerung → Anmeldung | +| `4002` | durch neuere Verbindung verdrängt | **nicht** neu verbinden | +| `4003` | Konto gesperrt oder gelöscht | **nicht** neu verbinden, zur Anmeldung | +| `1001`, `1006`, alles Übrige | Netzfehler, unbekannt | mit Backoff neu verbinden | + +`4002` ist der Grund, warum es diese Tabelle überhaupt gibt: ohne ein „nicht neu verbinden" +verdrängt ein verdrängtes Gerät sofort das nächste, und bei mehr aktiven Geräten als erlaubt +entsteht ein Kreisverkehr. + +Der Nutzen reicht über den Reconnect hinaus: der Client muss je Server einen aussagekräftigen +Zustand anzeigen, und „Anmeldung abgelaufen" von „Server nicht erreichbar" zu unterscheiden +geht nur so. + +**Backoff (abgeleitet):** Full Jitter nach dem in der Recherche belegten Muster — +`warte = random(0, min(30 s, 0,5 s · 2^Versuch))`. Der Zufallsanteil ist nicht optional; ohne +ihn kehren nach einem Neustart alle Clients gleichzeitig zurück. + +### 7. Graceful Shutdown selbst bauen (abgeleitet) + +Vierfach belegt in der Recherche: `axum::serve(...).with_graceful_shutdown(...)` beendet +WebSocket-Verbindungen nicht und wartet nicht auf sie (axum-Issue #3003 offen seit +21.10.2024, PR #3831 offen seit 11.07.2026). Bausteine: + +- Ein `CancellationToken` je Verbindung, abgeleitet vom globalen Shutdown-Token. +- Ein `TaskTracker`, um beim Herunterfahren auf die Verbindungstasks zu warten. +- Beim Shutdown Close-Frame mit `1012` senden — `WebSocket::close` gibt es seit axum 0.8 + nicht mehr, der Frame geht explizit über `send`. +- `broadcast::Receiver::recv` und `CancellationToken::cancelled` sind dokumentiert cancel-safe; + für `SplitStream::next()` fehlt eine explizite Zusage, weshalb die axum-Beispiele + durchweg in zwei Tasks aufteilen statt über den Socket-Stream zu selektieren. + +Sobald PR #3831 gemerged ist, kann das eigene Tracking entfallen — bis dahin ist es zwingend. + +### Nebenbefund + +`axum = "0.8.9"` ist heute **ohne das `ws`-Feature** eingebunden; es fehlt also die +Voraussetzung überhaupt. Und `Extension` wird nirgends gelesen — die Middleware +authentifiziert, aber kein Handler nutzt die Identität. Beides gehört in die Umsetzung. + +### Zu entscheiden + +- **Handshake-Auth.** Token im Query-String ist bei diesem Bedrohungsmodell heikel + (Server-Logs, Proxy-Logs, Browser-History). Welcher Weg wird es? Falls die Verbindung + im Rust-Layer lebt (siehe *Wo lebt die WS-Verbindung im Client*), ist ein + `Authorization`-Header möglich — dann ist die Frage fast beantwortet. +- **Token-Ablauf bei laufender Verbindung.** Verbindungs-TTL mit erzwungenem Reconnect, + In-Band-Re-Auth, oder Revocation-Prüfung. Wichtig: eine Verbindung darf einen + **Token-Widerruf nicht überleben** — bei „Nutzersicherheit als Erfolgsfaktor" ist eine + Sitzung, die nach dem Logout weiterläuft, ein echter Fehler, kein Schönheitsfleck. +- **Autorisierung.** Der Nutzer darf nur Events für Conversations sehen, in denen er + Mitglied ist. Wird die Mitgliedschaft beim Verbindungsaufbau einmal geladen oder pro + Event geprüft? Was passiert, wenn er während der Verbindung aus einer Conversation + entfernt wird — oder hinzugefügt? +- **Heartbeat.** Protokoll-Ping/Pong oder Application-Level, mit welchen Intervallen. + Zielgruppe Firmen heißt: hinter Proxies, die Idle-Verbindungen abräumen. +- **Reconnect-Parameter.** Backoff-Kurve und Jitter, konkret. Was passiert beim + Server-Neustart, wenn alle Clients gleichzeitig zurückkommen? +- **Verbindungslimits.** Wieviele Verbindungen pro Nutzer sind erlaubt? Multi-Device ist + gefordert (Desktop + Mobile parallel), also mehr als eine — aber nicht unbegrenzt, + sonst ist es ein Vektor auf einem öffentlichen Server. +- **Sauberes Herunterfahren.** Wie werden offene Verbindungen beim Server-Shutdown + beendet, sodass Clients einen geordneten Reconnect machen statt in Timeouts zu laufen? diff --git a/docs/realtime/issues/08-fan-out-architektur.md b/docs/realtime/issues/08-fan-out-architektur.md new file mode 100644 index 0000000..ca08236 --- /dev/null +++ b/docs/realtime/issues/08-fan-out-architektur.md @@ -0,0 +1,203 @@ +# Fan-out-Architektur + +Type: grilling +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Wie kommt ein Änderungs-Event von „Nutzer A hat geschrieben" zu allen betroffenen offenen +Verbindungen — und wie sieht die Naht aus, hinter der später `LISTEN/NOTIFY` steckt? + +Entschieden ist bereits: Start mit In-Process-Fan-out, aber hinter einem Trait; `NOTIFY` +als zweite Implementierung; **kein Redis** (harte Regel: eine Binary + Postgres). + +### Zu entscheiden + +- **Registry-Struktur.** `conversation_id → Menge von Sendern`, `user_id → Menge von + Verbindungen`, oder beides? Ein Nutzer kann in vielen Conversations sein und mehrere + Geräte haben — welche Indizierung hält beides aus, ohne bei jedem Event über alles zu + iterieren? +- **Kanaltyp.** `tokio::sync::broadcast` pro Conversation, `mpsc` pro Verbindung, oder + ein globaler Broadcast mit clientseitigem Filter. Bei `broadcast` ist zu klären, wie + `RecvError::Lagged` behandelt wird — es bedeutet hier „Resync", nicht „Fehler", und + das muss explizit im Design stehen (Council-Hinweis des Engineer). +- **Auf- und Abräumen.** Wann wird ein Eintrag entfernt? Verbindungsabbrüche sind der + Normalfall, nicht die Ausnahme — Leaks in der Registry sind der klassische Fehler. +- **Trait-Zuschnitt.** Wie schmal muss die Schnittstelle sein, damit die + `NOTIFY`-Implementierung sie wirklich erfüllen kann? `NOTIFY` ist *fire and forget* + ohne Zustellgarantie und ohne Rückkanal — ein Trait, das mehr verspricht als das, + ist eine Lüge, die beim Wechsel auffliegt. +- **`NOTIFY`-Pfad konkret genug, um ihn zu bewerten.** Ein Channel pro Conversation + (Postgres hat ein Limit an Channels pro Verbindung), oder ein Channel mit + Conversation-ID im Payload? Wer hält die `LISTEN`-Verbindung, und was passiert, wenn + sie abreißt? +- **Wo wird die `conversation_events`-Zeile geschrieben?** [*Monotoner Cursor*](02-monotoner-cursor.md) + hat die Tabelle festgelegt, diese Frage aber bewusst hierher verschoben, weil sie mit + `NOTIFY` zusammenfällt. Also: im Handler in derselben Transaktion, oder per + Datenbank-Trigger? Ein Trigger feuert garantiert bei jedem Schreibweg, ein + Handler-Aufruf ist einfacher zu verstehen. Bei Trigger + `NOTIFY` fällt die zweite + Implementierung fast von selbst ab — das ist ein Argument, das geprüft gehört. +### Faktenlage aus der Auth-Recherche (14.08.2026) + +Aus [`../research/03-ws-auth-reconnect.md`](../research/03-ws-auth-reconnect.md) §6, belegt: + +- **`tokio::sync::watch` passt auffällig gut zum Dumb Pipe.** Es „retains only the last sent + value" — was exakt die Semantik von „der Cursor der Conversation X ist jetzt N, hol dir den + Rest per REST" ist. **Kein Lag-Fehler möglich**, weil Zwischenwerte per Konstruktion + entbehrlich sind. Das ist ein ernstzunehmender Kandidat gegen `broadcast` und sollte hier + gegen es antreten, nicht stillschweigend übergangen werden. +- **`RecvError::Lagged(n)` präzise:** `n` = Anzahl übersprungener Nachrichten, der Receiver + bleibt **brauchbar**, sein Cursor springt auf die älteste noch vorhandene Nachricht. + ⚠️ **„Lagged = Resync" ist nirgends als offizielles Muster dokumentiert** — gängige + Community-Praxis ohne Primärquelle. Belegbar sind nur die Bausteine: `resubscribe()` + (verwirft alles Alte), `Receiver::len()` als Frühwarnung *vor* dem Datenverlust, und + `mpsc::try_send` pro Verbindung, um die Drop-Entscheidung **pro Client** statt global zu + treffen. +- **`broadcast` speichert einmal und klont pro Receiver** ⇒ der Event-Typ sollte billig + klonbar sein (`Arc<…>`, `bytes::Bytes`). Kapazität wird auf die nächste Zweierpotenz + aufgerundet. +- **`async_broadcast` scheidet vermutlich aus:** es bietet echte Backpressure, womit ein + langsamer WS-Client den **gesamten Producer** bremst (Head-of-Line über alle Clients) — das + widerspricht der Map-Entscheidung „Best-effort + `?since`-Backfill" frontal. + **`flume` scheidet sicher aus:** MPMC heißt dort Work-Queue, jede Nachricht geht an genau + einen Receiver — kein Broadcast. +- **`PgListener` (sqlx)** reconnected selbst und re-subscribed alle Channels, belegt **aber + eine dedizierte Postgres-Connection außerhalb des `PgPool`** (heute `max_connections = 10`) + — beim Sizing einplanen. `NOTIFY` ist transaktional, feuert also erst bei COMMIT, was gut + zur DB-als-Queue-Semantik passt. **Neue Gefahr:** wird die Notify-Queue voll (Listener + hängen), schlagen Transaktionen **beim Commit fehl**. Und `NOTIFY` liefert keine Historie — + ein verpasstes Signal ist weg, also dieselbe Resync-Frage eine Ebene tiefer. + +- **Reihenfolge.** Garantiert das Fan-out die Reihenfolge der Events pro Conversation? + Falls nein: ist das egal, weil der Cursor monoton ist und der Client ohnehin nachlädt? + (Vermutlich ja — aber das explizit festhalten.) + +--- + +## Answer + +Entschieden am 14.08.2026. + +### Vorbemerkung, die das ganze Ticket entschärft + +Weil der Event `{conversation, seq}` ist und der Backfill über den Cursor läuft, zählt **nur +der jeweils neueste `seq` je Conversation**. 5, dann 6, dann 7 zuzustellen ist gleichwertig +damit, nur 7 zuzustellen. Das Fan-out darf zusammenfassen und sogar verlieren, ohne dass +Daten verlorengehen — schlimmstenfalls ist ein Client kurz veraltet, und das Manifest beim +nächsten Reconnect fängt alles auf. + +Damit löst sich die `RecvError::Lagged`-Sorge aus +[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) weitgehend +auf: Lag ist hier kein Datenverlust, sondern Latenz. + +### 1. Registry: `conversation_id → Verbindungen`, ein Kanal je Verbindung + +Eine `HashMap>` im Serverzustand. Beim Verbindungsaufbau +werden die Mitgliedschaften einmal geladen und die Verbindung unter jeder eingetragen, beim +Trennen entfernt. Der Publisher schlägt einmal nach und stellt gezielt zu. + +Entscheidend: **jede Verbindung hat genau einen Empfänger**. Die Verbindungsschleife bleibt +ein `select!` mit zwei Zweigen, statt über vierzig Kanäle gleichzeitig warten zu müssen. Die +Mitgliedschaftsliste braucht die Verbindung ohnehin zur Autorisierung — die Registry ist also +kein zusätzlicher Zustand. + +Verworfen: ein Kanal je Conversation (eine Verbindung müsste auf N Empfängern warten, +`SelectAll` plus dynamisches An- und Abmelden während des Wartens) und ein globaler Kanal mit +Filter (zehn Zeilen Code, aber jede Verbindung wird bei jedem Event serverweit geweckt — +bei tausend Verbindungen und hundert Events pro Sekunde hunderttausend Aufwachvorgänge, die +fast alle nichts tun). + +### 2. Zwei Registries, weil Mitgliedschaftswechsel anders adressiert werden + +Ein Beitritt kann nicht über die Conversation-Registry laufen: der Nutzer ist dort noch nicht +eingetragen, das ist ja gerade der Punkt. Es gibt daher eine zweite Karte +`user_id → Verbindungen`. Mitgliedschafts-Events werden **pro Nutzer** zugestellt; die +Instanz, die diese Verbindung hält, trägt sie daraufhin in die Conversation-Registry ein oder +aus. + +Damit funktioniert es auch über Instanzgrenzen: die Mitgliedschaftsänderung reist als `NOTIFY` +mit der `user_id` im Payload, und jede Instanz aktualisiert nur ihre eigenen Verbindungen. + +### 3. Puffer je Verbindung: Dirty-Set mit `Notify` + +Je Verbindung eine `HashMap` hinter einem Mutex plus ein `Notify`. Der +Publisher trägt ein und weckt; die Schreibtask leert die Karte und schickt einen Event je +Conversation. + +Das kodiert „nur der neueste zählt" direkt im Datentyp: perfektes Zusammenfassen, kein +Verlust, Speicher begrenzt durch die Anzahl der Conversations statt durch die Event-Rate. +**Kein Überlauf, also kein Überlauf-Fehlerpfad.** + +⚠️ **Wichtiges Detail:** der Eintrag ist `max(vorhandener, neuer)`, kein blindes `insert`. +`NOTIFY` garantiert Reihenfolge nur je Listener-Verbindung; ein blindes Überschreiben würde +sonst im ungünstigen Fall einen neueren `seq` durch einen älteren ersetzen. + +Verworfen: `mpsc` + `try_send`. Bei vollem Puffer verwirft es den **neuen** Event und behält +die alten — also ausgerechnet den einzigen, auf den es ankommt, und im Log nicht von +Normalbetrieb unterscheidbar. Die Variante „bei Überlauf trennen" wäre korrekt, macht die +Puffergröße aber zur Stellschraube zwischen „trennt zu früh" und „merkt Blockade zu spät", +mit Pendelrisiko bei langsamen Mobilclients. + +### 4. Log-Zeile im Handler, `NOTIFY` per Trigger auf `conversation_events` + +Die Fachlogik bleibt in Rust: der Handler zieht den `seq` +(`UPDATE conversations … RETURNING`, was laut [*Monotoner Cursor*](02-monotoner-cursor.md) +ohnehin in seiner Transaktion passieren muss) und schreibt die Log-Zeile mit passendem `kind`. + +Auf `conversation_events` sitzt ein Trigger, der **nichts** tut außer `pg_notify` zu feuern — +drei Zeilen PL/pgSQL ohne Fachlogik. Damit ist der Benachrichtigungspfad unvergesslich: wer +immer eine Log-Zeile schreibt, löst das Signal aus, auch künftige Handler und manuelle +Eingriffe. Und weil `NOTIFY` transaktional ist, feuert es erst beim Commit — genau dann, wenn +die Zeile sichtbar wird. + +Verworfen: alles im Handler (jeder künftige Schreibweg müsste daran denken — die Sorte +Vergessen, die sich als „manchmal aktualisiert sich der Client nicht" äußert) und alles per +Trigger auf den Fachtabellen (die Kernlogik des Realtime-Layers läge dann in PL/pgSQL, +verteilt über drei Trigger, schlecht testbar und schwer zu lesen). + +### 5. Kein Trait — immer über `NOTIFY`, auch bei einer Instanz + +**Das revidiert eine Randbedingung der Map.** Sie sah „In-Process hinter einem Trait, +`NOTIFY` als zweite Implementierung" vor. Das wurde formuliert, bevor feststand, dass der +Event 60 Byte groß ist und der Trigger `NOTIFY` ohnehin feuert. + +Der Server hört sich per `PgListener` selbst zu und speist die Registry. **Ein** Pfad für +Ein- und Mehr-Instanz-Betrieb: kein Trait, keine zweite Implementierung, und die +Mehr-Instanz-Fähigkeit ist ab Tag eins da statt nur vorgesehen. Der getestete Pfad ist +automatisch der Produktionspfad — der eigentliche Gewinn, denn beim Trait wäre ausgerechnet +der `NOTIFY`-Pfad beim ersten Skalierungsschritt der ungetestete gewesen. + +Ein einziger Channel mit der Conversation-ID im Payload, nicht einer je Conversation: +dynamisches `LISTEN`/`UNLISTEN` über alle Conversations wäre unbegrenzt, und 60 Byte liegen +weit unter der 8-kB-Grenze. + +**Betriebliche Kosten, bewusst akzeptiert:** + +- Jeder Event läuft durch Postgres — auf demselben Host im Sub-Millisekunden-Bereich. +- `PgListener` belegt eine **dedizierte Verbindung außerhalb des Pools** (heute + `max_connections = 10`) — beim Sizing einplanen. +- Hängt ein Listener und die Notify-Queue läuft voll, **scheitern Transaktionen beim + Commit**. Das braucht Überwachung und gehört in die Fog-Notiz zur Observability. + +⚠️ **Lücke, die diese Entscheidung einführt:** `PgListener` reconnected laut sqlx-Doku selbst, +aber Events während der Unterbrechung sind **weg** — `NOTIFY` hat keine Historie. Regel +daher: **nach jedem Listener-Reconnect schickt der Server allen Verbindungen einen +Resync-Hinweis**, woraufhin die Clients das Manifest neu ziehen. Das ist billig, selten, und +schließt die Lücke vollständig. + +### 6. Auf- und Abräumen + +- **Verbindungsaufbau:** Mitgliedschaften laden, in beide Registries eintragen. +- **Verbindungsabbau:** aus beiden entfernen. Abbrüche sind der Normalfall, nicht die + Ausnahme — Leaks in der Registry sind hier der klassische Fehler. +- **Mitgliedschaftswechsel während der Verbindung:** über die `user_id`-Registry, siehe §2. +- Eine leere Conversation-Menge wird aus der Karte entfernt, damit sie nicht unbegrenzt wächst. + +### 7. Reihenfolge + +Das Fan-out garantiert **keine** Reihenfolge über Instanzen hinweg und braucht sie auch +nicht: der Cursor ist monoton, der Client lädt ohnehin den aktuellen Zustand, und das +Dirty-Set nimmt das Maximum. Ein zu früh oder zu spät zugestellter Event kann nichts +beschädigen — er kann nur überflüssig sein. diff --git a/docs/realtime/issues/09-voice-signaling-protokoll.md b/docs/realtime/issues/09-voice-signaling-protokoll.md new file mode 100644 index 0000000..53116ef --- /dev/null +++ b/docs/realtime/issues/09-voice-signaling-protokoll.md @@ -0,0 +1,255 @@ +# Voice-Signaling-Protokoll + +Type: grilling +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Wie sieht das flüchtige Signaling-Format aus, mit dem Clients einen Voice-Room betreten, +sich gegenseitig finden und SDP-/ICE-Daten austauschen? + +Das ist die eine Stelle, an der echte Nutzlast über den Socket geht — der Dumb Pipe +trägt es nicht, weil ein SDP-Angebot flüchtig ist und in keiner Tabelle liegt. + +**Entblockt am 14.08.2026.** Was aus [*Voice-Topologie*](04-voice-topologie.md) feststeht und +hier nicht neu aufgemacht wird: + +- **Mesh, harte Obergrenze sechs Teilnehmer**, serverseitig durchgesetzt — der Beitritt wird + abgelehnt, nicht nur in der UI verhindert. Kein SFU-Vorbau; ein SFU wäre ein bewusster + Protokollbruch in einem eigenen Effort. +- **Eine Datenschutz-Politik je Instanz** (streng: `iceTransportPolicy: "relay"` + Opus + `cbr=1`/`usedtx=0`; offen: Standard-ICE + Standard-Opus). Sie muss über das Signaling an den + Client übermittelt werden, damit er sie umsetzen **und anzeigen** kann. +- Im strengen Modus sind **TURN-Zugangsdaten zwingend** — ihre Übermittlung gehört damit in + dieses Ticket. + +### Zu entscheiden + +- **Room-Modell.** Ist ein Voice-Room an eine Conversation gebunden („Sprachkanal dieses + Channels"), oder eine eigenständige Entität mit eigener Mitgliedschaft? Das erste ist + einfacher und passt zum bestehenden Datenmodell; das zweite erlaubt Ad-hoc-Anrufe + zwischen Leuten, die keine gemeinsame Conversation haben. +- **Persistenz.** Ist der Room-Zustand rein im Speicher (weg beim Neustart), oder liegt + er in der DB? In-Memory ist einfacher und passt zur Flüchtigkeit — aber beim Neustart + fallen alle Gespräche, und bei Mehr-Instanz-Betrieb später ist In-Memory ein Problem. +- **Teilnehmerliste.** Das ist die **einzige** Presence-Form im Scope dieses Efforts + (allgemeine Chat-Presence liegt in der Fog). Wie erfährt ein Beitretender, wer schon + drin ist? Wie erfahren die anderen von ihm? Was passiert bei einem + Verbindungsabbruch — ab wann gilt jemand als weg? +- **Nachrichtentypen.** Minimal nötig: `join`, `leave`, `participants`, `offer`, + `answer`, `ice-candidate`. Braucht es mehr — `mute`, `speaking`, `renegotiate`? +- **Adressierung.** SDP und ICE-Kandidaten gehen an einen *bestimmten* Peer, nicht an + den Raum. Wie werden Peers identifiziert — User-ID reicht nicht, weil Multi-Device + gefordert ist. Also eine Verbindungs- oder Session-ID. +- **Wer bietet an?** Bei n Teilnehmern muss geregelt sein, wer bei einem Beitritt das + Angebot macht, sonst gibt es Glare (beide bieten gleichzeitig). Deterministische Regel + festlegen. +- **TURN-Credentials.** Wenn TURN konfiguriert ist: wie kommen zeitbeschränkte + Credentials zum Client? Gehört das in dieses Protokoll oder in einen REST-Endpunkt? + Und was ist das Verhalten, wenn **kein** TURN konfiguriert ist — der Nutzer muss früh + und ehrlich erfahren, dass die Verbindung scheitern kann, statt in einen Timeout zu laufen. +- **Transport.** Derselbe WebSocket wie der Dumb Pipe, oder ein zweiter Endpunkt? Ein + Socket spart Auth- und Reconnect-Verwaltung; zwei trennen die Belange sauberer. Hängt + auch an *Wo lebt die WS-Verbindung im Client* — falls Chat in Rust und Voice in JS + liegt, ist die Frage bereits beantwortet. + +### Harte Randbedingung: DTLS-Fingerprint-Pinning + +Aus [*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md) §7.4, +und für dieses Projekt der wichtigste Sicherheitsbefund überhaupt: + +**RFC 8827 benennt den Angriff wörtlich** — ein On-Path-Angreifer kann die DTLS-SRTP-Fingerprints +im Handshake austauschen und die Identität beider Endpunkte ersetzen. Der Signaling-Server +relayed die SDP, kann also `a=fingerprint:` tauschen und zwei separate DTLS-Sitzungen +terminieren: vollständiges MITM auf dem Audio, ohne dass irgendein WebRTC-Mechanismus anschlägt. +Bei einem Bedrohungsmodell, in dem der Betreiber der Angreifer ist, ist das kein Randfall, +sondern der Hauptfall. + +**Der spezifizierte Gegenmechanismus existiert nicht.** W3C *Identity for WebRTC 1.0* steht +seit 2018 unverändert als Candidate Recommendation; `setIdentityProvider` / `peerIdentity` +sind in Chrome, Safari und Android WebView **nicht implementiert** — also in keiner der vier +Ziel-Engines. + +Was das Protokoll deshalb leisten muss: + +1. Fingerprint deterministisch vorab bestimmen (`RTCPeerConnection.generateCertificate()` + + `getFingerprints()`), statt ihn aus `localDescription.sdp` zu lesen. +2. Ihn **durch einen E2EE-authentifizierten Kanal** übertragen, signiert mit dem + Langzeit-Identity-Key — nicht im Klartext-SDP, das der Server relayed. +3. Beim Empfang gegen den Fingerprint im relayed SDP vergleichen. Mismatch ⇒ Call abbrechen + und als Sicherheitsereignis anzeigen, nicht still weiterlaufen lassen. +4. **Key Continuity** (RFC 8827): Fingerprints pro Gerät über Calls hinweg cachen, bei + Änderung warnen. +5. Optional eine **SAS** (Emoji-/Wortliste aus dem Hash beider Fingerprints, à la ZRTP) + für den paranoiden Pfad — verteidigt zusätzlich gegen einen kompromittierten Identity-Key. + +Genau so löst es Matrix in MSC3401/Element Call: Call-SDP läuft über verschlüsselte +To-Device-Messages, der Homeserver sieht den Fingerprint nie im Klartext. + +**Konsequenz für die Effort-Grenzen:** Punkt 2 braucht einen E2EE-Kanal, den es heute nicht +gibt — das volle E2EE-Design ist ein Folge-Effort. Dieses Ticket muss die **Naht** festlegen +(wo im Signaling-Fluss der signierte Fingerprint sitzt, wie ein Mismatch behandelt wird), +kann sie aber nicht einlösen. Sicheres Voice ist damit von der Fertigstellung des +E2EE-Efforts abhängig — das explizit festhalten, statt es implizit zu lassen. + +### Randbedingung aus *Voice auf Linux* + +Es gibt **zwei Medienpfade**: `RTCPeerConnection` im WebView auf Windows, Android und iOS, +und `livekit/rust-sdks` im Rust-Layer auf Linux. Das Signaling-Protokoll muss beide bedienen. + +Konkret für das Fingerprint-Pinning oben: der signierte DTLS-Fingerprint muss auf **beiden** +Pfaden erzeugbar sein — im WebView über `RTCPeerConnection.generateCertificate()`, nativ über +die Zertifikats-API von libwebrtc. Die Naht darf also nicht an einer WebView-API hängen. + +### Randbedingung + +**Kein Audio.** Codecs, Echo-Cancellation, Audio-Pipeline und SFU-Betrieb sind out of +scope. Dieses Ticket endet bei „zwei Clients haben genug Information, um eine +`RTCPeerConnection` aufzubauen". + +--- + +## Answer + +Entschieden am 14.08.2026. + +### 1. Der Voice-Room ist eine eigenständige Entität mit zwei möglichen Eltern + +**Discord-Modell:** ein Sprachkanal hängt am **Workspace** und ist per Default für alle +Workspace-Mitglieder sichtbar und betretbar. Zusätzlich kann ein Raum an einer **Conversation** +hängen — das ist der Direktanruf aus einer DM oder Gruppe heraus. + +```sql +CREATE TABLE voice_rooms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE, + conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, + name TEXT, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK ((workspace_id IS NULL) <> (conversation_id IS NULL)) +); +``` + +Genau eines der beiden Elternfelder ist gesetzt — dasselbe Muster, das `conversations` heute +schon benutzt (dort ist `workspace_id` für DMs null). **Eine Entität, ein Signaling-Protokoll, +eine Prüfstelle für die Autorisierung**, die nur danach verzweigt, welcher Elternteil gesetzt +ist. + +**Lebenszyklus:** Workspace-Räume sind dauerhaft (ein Ort, den man betritt und verlässt). +Conversation-Räume entstehen mit dem Anruf und werden beim Auflegen abgeräumt. + +⚠️ **Die Autorisierung muss durch genau eine Funktion laufen.** Später sollen Rollen +*Conversations und Voice-Rooms gleichermaßen* einschränken — es darf deshalb **kein +raumspezifisches Berechtigungsmodell** entstehen, sonst gibt es zwei Systeme, die auseinander +laufen. Heute prüft diese Funktion Workspace- bzw. Conversation-Mitgliedschaft; das +Rollenmodell setzt später an derselben Stelle an. + +### 2. Raumzustand in Postgres, aufgeräumt über den bestehenden Heartbeat + +```sql +CREATE TABLE voice_participants ( + room_id UUID NOT NULL REFERENCES voice_rooms(id) ON DELETE CASCADE, + session_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES users(id), + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (room_id, session_id) +); +``` + +Über Instanzen hinweg konsistent per Konstruktion — nötig, weil das Fan-out seit +[*Fan-out-Architektur*](08-fan-out-architektur.md) immer über `NOTIFY` läuft und +Mehr-Instanz-Betrieb damit ab Tag eins möglich ist. Die Teilnehmerliste, die ein Beitretender +für seine Angebote braucht, ist eine simple Abfrage. + +**Geisterzeilen** nach einem Instanz-Absturz fängt `last_seen` ab, das der bestehende +15-Sekunden-Heartbeat aus [*Verbindungsaufbau & Auth*](07-verbindungsaufbau-und-auth.md) +gratis mitführt: wer zwei Intervalle fehlt, wird entfernt. Bei sauberem Socket-Schluss geht +der Abgang sofort. + +Verworfen: In-Memory je Instanz (`NOTIFY` hat keine Historie — nach einem Listener-Reconnect +fehlt einer Instanz die Sicht, und abgestürzte Instanzen hinterlassen unaufräumbare Geister) +und Raum-zu-Instanz-Bindung (bräuchte selbst wieder geteilten Zustand plus eine Umleitung, und +ein Instanz-Absturz nähme den ganzen Raum mit statt ihn nur auszudünnen). + +### 3. Adressierung über `session_id`, nicht `user_id` + +Multi-Device ist gefordert — derselbe Nutzer kann mit Desktop und Handy im selben Raum sein. +SDP und ICE-Kandidaten gehen deshalb an eine **Verbindung**, nicht an ein Konto. Jede +WebSocket-Verbindung bekommt beim Aufbau eine `session_id`; sie ist die Peer-Identität im +Signaling. + +### 4. Der Beitretende bietet an + +Wer beitritt, erhält die Teilnehmerliste und sendet n−1 Angebote; die Anwesenden antworten +nur. Eine Regel statt einer Aushandlung darüber, wer aushandelt — und der Beitretende ist der +Einzige, der in diesem Moment etwas Neues weiß. + +Kollisionen können praktisch nicht entstehen, weil es reines Audio ohne mitten im Gespräch +wechselnde Spuren gibt. Der einzige verbleibende Fall ist ein ICE-Neustart: **wer neu startet, +bietet an.** + +⚠️ Das ist eine **Konvention, kein Mechanismus**. Kämen später Bildschirmfreigabe oder Video +hinzu, muss sie durch Perfect Negotiation (höflich/unhöflich per `session_id`-Vergleich) +ersetzt werden. Als Notiz festgehalten, nicht heute gebaut. + +### 5. Fingerprint-Pinning: Naht jetzt, Füllung durch den E2EE-Effort + +Das Protokoll sieht das Feld für den signierten DTLS-Fingerprint und den Abgleich gegen das +relayte SDP **von Anfang an** vor. Solange kein Identitätsschlüssel existiert, bleibt es leer, +und der Client zeigt **dauerhaft und nicht wegklickbar** an, dass das Gespräch nicht +verifiziert ist. + +Begründung gegen die verlockende Alternative (eine kleine Identitätsschicht nur für Voice): +der Identitätsschlüssel ist das Kernstück des E2EE-Efforts. Ihn separat für Voice zu erzeugen +hieße, dass Nutzer später zwei Identitäten und zwei Verifikationsrituale haben — oder dass +eine davon migriert werden muss, und das ist bei Schlüsseln der unangenehmste aller +Migrationsfälle. + +**Bewusst in Kauf genommen und in der Map vermerkt:** bis der E2EE-Effort steht, ist Voice +gegen den Betreiber ungeschützt. Sichtbar, nicht stillschweigend. + +Beide Medienpfade aus [*Voice auf Linux*](11-voice-auf-linux.md) müssen den Fingerprint +deterministisch vorab erzeugen können — im WebView über +`RTCPeerConnection.generateCertificate()`, nativ über die Zertifikats-API von libwebrtc. Die +Naht darf nicht an einer WebView-API hängen. + +### 6. Nachrichtensatz (abgeleitet) + +Client → Server: `room.join` · `room.leave` · `peer.signal` (trägt Offer, Answer oder +ICE-Kandidat, adressiert an eine `session_id`) + +Server → Client: `room.joined` (Antwort auf `join`: Teilnehmerliste, Politik, +TURN-Zugangsdaten) · `room.participant_added` · `room.participant_removed` · `peer.signal` +(weitergereicht) · `room.rejected` (Raum voll, nicht berechtigt) + +`mute` und `speaking` sind **nicht** enthalten: Stummschaltung ist lokal, und +Sprechaktivität würde die Information über den Server leiten, die +[*Voice-Topologie*](04-voice-topologie.md) im strengen Modus gerade per Opus-CBR versteckt. +Beides gehört, falls gewünscht, über die Peer-Verbindung statt über das Signaling. + +### 7. Politik und TURN-Zugangsdaten in der Beitrittsantwort (abgeleitet) + +`room.joined` trägt die Instanz-Politik aus [*Voice-Topologie*](04-voice-topologie.md) +(*streng* → `iceTransportPolicy: "relay"` und Opus `cbr=1`/`usedtx=0`; *offen* → Standard) — +der Client setzt sie um **und zeigt sie an**. + +Im strengen Modus sind TURN-Zugangsdaten zwingend und reisen mit: **zeitbeschränkte +HMAC-Credentials** nach dem üblichen coturn-Muster, damit nichts Dauerhaftes im Client liegt. +Erneuerung erfolgt durch erneuten Beitritt, nicht durch einen eigenen Mechanismus. + +### 8. Raumgröße serverseitig durchgesetzt + +Die Obergrenze von sechs wird beim `room.join` geprüft und mit `room.rejected` abgelehnt — +**nicht** nur in der Oberfläche verhindert. + +### Offene Kopplung zum E2EE-Effort + +`voice_rooms.name` ist nutzererzeugter Inhalt und fiele damit unter die Blob-Regel aus +[*Dumb-Pipe-Event & Backfill*](06-dumb-pipe-event-und-backfill.md). Der Server braucht ihn +nicht — aber ein neues Workspace-Mitglied müsste die Raumliste lesen können, ohne vorher +Schlüsselmaterial zu haben. Das ist ein Schlüsselverwaltungsproblem und gehört dem +E2EE-Effort; hier nur festgehalten, damit es dort nicht übersehen wird. diff --git a/docs/realtime/issues/10-client-integration.md b/docs/realtime/issues/10-client-integration.md new file mode 100644 index 0000000..f1af7d8 --- /dev/null +++ b/docs/realtime/issues/10-client-integration.md @@ -0,0 +1,121 @@ +# Client-Integration: `refreshKey` ersetzen + +Type: prototype +Status: resolved +Blocked by: — +Parent: ../map.md + +## Question + +Wie sieht der Frontend-State aus, wenn Nachrichten gepusht statt nachgeladen werden — +und wie fühlt sich das bei N gleichzeitig verbundenen Servern an? + +Ein Prototyp, kein Produktionscode. Ziel ist, an etwas Konkretem zu sehen, ob das +Zustandsmodell trägt, bevor die Spec es festschreibt. + +### Ausgangslage + +`ChatPage.tsx` hält heute `refreshKey` als Zähler und reicht ihn an `MessageList` +durch; `MessageInput` zählt nach dem Senden hoch. Das ist der Hack, dessen Verschwinden +den ganzen Effort motiviert. + +**Zugeschnitten am 14.08.2026** durch [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md): +die Verbindung und der Backfill liegen in Rust, das Frontend hat **keine Cursor-Logik** und +kein Debouncing. Es bekommt aufgelöste Entitäten per `emit` plus ein separates +`resync`-Signal. Das schrumpft dieses Ticket erheblich — und verschiebt eine Frage hierher: +**soll Rust für Conversations nachladen, die gar nicht sichtbar sind?** Das weiß nur das +Frontend, also muss es diese Information nach unten reichen. + +### Zu klären + +- **Zustandsform.** Wo leben Nachrichten — pro Conversation gecacht, oder nur die + gerade offene? Was passiert beim Wechsel zwischen Conversations? Braucht es überhaupt + eine State-Bibliothek, oder reicht React-State plus ein Cursor pro Conversation? +- **Ungelesen-Anzeige.** Der Dumb-Pipe-Event trifft auch für Conversations ein, die + nicht offen sind. Wird dann nachgeladen (kostet Requests) oder nur ein Punkt gesetzt + und erst beim Öffnen geladen? Zweiteres wirkt richtig — am Prototyp prüfen. +- **Optimistisches Senden.** Erscheint die eigene Nachricht sofort, und wie wird sie + mit der später gepushten Version zusammengeführt, ohne zu doppeln oder zu springen? +- **Multi-Server im UI.** N Server heißt N Conversation-Listen, N Verbindungszustände, + N Fehlerfälle. Wie sieht das aus, wenn ein Server nicht erreichbar ist, während die + anderen laufen? Das ist der Punkt, an dem das Multi-Server-Modell konkret wird. +- **Verbindungszustand sichtbar machen.** „Verbunden", „reconnecting", „offline" — was + sieht der Nutzer, und wo? +- **Koaleszenz.** Zehn Events in zwei Sekunden sollen einen Fetch auslösen, nicht zehn. + Wo sitzt das Debouncing, und mit welcher Verzögerung fühlt es sich noch sofort an? + +### Erwartetes Ergebnis + +Ein Wegwerf-Prototyp, der die Zustandsform greifbar macht, plus eine kurze Notiz, was +er über das Zustandsmodell gezeigt hat. Der Prototyp wird als Asset verlinkt, nicht in +die Antwort kopiert. + +--- + +## Answer + +Entschieden am 14.08.2026. + +**Asset:** `PROTOTYPE-frontend-zustandsmodell.html` im Repo `rustification-client`, +auf dem Wegwerf-Branch `prototype/frontend-zustandsmodell` (bewusst nicht auf `master`) — eine +eigenständige HTML-Datei, im Browser zu öffnen. Freies Ausprobieren plus sechs geführte +Durchläufe. Der Reducer im ersten `