Compare commits

...

2 Commits

Author SHA1 Message Date
55a2af4756 docs(realtime): record the design decisions behind the realtime layer
Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
2026-08-14 19:04:41 +02:00
099315a538 feat(realtime): add monotonic cursor, change log and WebSocket fan-out
Messages were only reachable by polling. This adds the server half of the
realtime path: a cursor that cannot skip rows, a change log, and a socket that
pushes "conversation X is at seq N".

The cursor is a counter on conversations, drawn with UPDATE ... RETURNING
inside the mutating transaction. That takes the conversation row lock, so a
second writer blocks until the first commits and commit order equals seq order.
A BIGSERIAL on messages does not have this property: it hands out numbers at
INSERT time while rows become visible at COMMIT time, and those two orders
diverge under concurrent senders — a reader following seq > n then loses a
message permanently. Two tests cover this; the second one asserts that the
naive design *does* lose a row, so the first is proven to measure something.

created_at loses its ordering role. It is NOW(), i.e. transaction start, so it
is neither unique nor commit-ordered — the existing ORDER BY created_at was
already wrong, independently of realtime.

Every mutation appends to conversation_events, and that row is the event. A
three-line trigger on that table fires pg_notify, so no future write path can
forget to signal, and NOTIFY being transactional means it fires exactly when
the row becomes visible.

Fan-out always goes through NOTIFY, even with a single instance. One path
instead of two means the tested path is the production path, and running more
than one instance needs no second implementation. Per connection the pending
work is a map rather than a queue: only the newest seq per conversation
matters, so a newer value overwrites an older one, there is no overflow and no
overflow error path. Entries are merged with max(), because NOTIFY only orders
per listener. When the listener reconnects, every client is told to resync —
notifications sent during the gap are gone, as NOTIFY keeps no history.

Sockets are closed by hand on shutdown: axum's graceful shutdown neither waits
for nor closes upgraded connections (axum#3003, fix PR #3831 still open).

Also adds membership checks to the message history endpoint, which had none,
and paginates it — it previously returned entire conversations unbounded.

Not yet done, marked as TODO in the code: reactions and attachments do not emit
events, and create/update/delete still trust user_id from the request body.
2026-08-14 18:54:09 +02:00
28 changed files with 5836 additions and 37 deletions

173
Cargo.lock generated
View File

@@ -57,6 +57,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"base64",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@@ -75,8 +76,10 @@ dependencies = [
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"serde_urlencoded", "serde_urlencoded",
"sha1 0.10.7",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-tungstenite",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -297,6 +300,12 @@ dependencies = [
"cmov", "cmov",
] ]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -469,6 +478,17 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.34" version = "0.3.34"
@@ -489,6 +509,7 @@ checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro",
"futures-sink", "futures-sink",
"futures-task", "futures-task",
"memchr", "memchr",
@@ -519,6 +540,18 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.3" version = "0.4.3"
@@ -527,7 +560,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
@@ -1044,6 +1077,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@@ -1062,12 +1104,28 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "6.0.0" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.2" version = "0.10.2"
@@ -1079,6 +1137,16 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.6.4" version = "0.6.4"
@@ -1088,6 +1156,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.10.1" version = "0.10.1"
@@ -1125,12 +1202,14 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"dotenv", "dotenv",
"futures-util",
"jsonwebtoken", "jsonwebtoken",
"rand_core 0.6.4", "rand_core 0.6.4",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
"tokio-util",
"uuid", "uuid",
] ]
@@ -1252,6 +1331,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.11.0" version = "0.11.0"
@@ -1457,7 +1547,7 @@ dependencies = [
"log", "log",
"percent-encoding", "percent-encoding",
"serde", "serde",
"sha1", "sha1 0.11.0",
"sha2 0.11.0", "sha2 0.11.0",
"sqlx-core", "sqlx-core",
"thiserror", "thiserror",
@@ -1489,7 +1579,7 @@ dependencies = [
"log", "log",
"md-5", "md-5",
"memchr", "memchr",
"rand", "rand 0.10.2",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
@@ -1704,6 +1794,32 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -1764,6 +1880,22 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1 0.10.7",
"thiserror",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@@ -1851,6 +1983,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.127" version = "0.2.127"
@@ -2052,6 +2193,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"
@@ -2081,6 +2228,26 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"

View File

@@ -4,7 +4,9 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
axum = "0.8.9" axum = { version = "0.8.9", features = ["ws"] }
tokio-util = { version = "0.7", features = ["rt"] }
futures-util = "0.3"
dotenv = "0.15.0" dotenv = "0.15.0"
serde_json = "1.0.151" serde_json = "1.0.151"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

View File

@@ -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.

View File

@@ -0,0 +1,229 @@
# Monotoner Cursor
Type: grilling
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Worauf zeigt der `?since=<cursor>`-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=<cursor>`: 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=<cursor>`
> 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.

View File

@@ -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<Claims>` wird nirgends gelesen.

View File

@@ -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.

View File

@@ -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=<cursor>` ü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.

View File

@@ -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": "<uuid>", "cursor": <n>}` 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=<seq>&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.

View File

@@ -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<Claims>` 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<Claims>` 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?

View File

@@ -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<ConversationId, Set<Verbindung>>` 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<ConversationId, seq>` 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.

View File

@@ -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.

View File

@@ -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 `<script>`-Block ist der übernehmbare Teil; die Seite
drumherum ist Wegwerfcode.
Geprüft: die Datei läuft fehlerfrei (nur ein Favicon-404). Die beiden entscheidenden
Durchläufe wurden durchgeklickt, siehe unten.
### Was der Prototyp aufgedeckt hat
**1. Der Client muss die Nachrichten-ID vergeben.** Das war vorher keine Frage — es wurde
eine, sobald optimistisches Senden konkret wurde. Vergibt der Server die ID, hat der Client
beim Echo nichts, woran er seine eigene Nachricht wiedererkennt: sie steht dann zweimal da,
einmal mit `seq` und einmal ohne. Im Prototyp beide Fälle nachgestellt und bestätigt.
Folge für die Spec: `messages.id` wird vom Client geliefert, nicht per
`DEFAULT gen_random_uuid()` erzeugt. **Nebengewinn:** damit wird das Senden idempotent —
ein Wiederholungsversuch nach unklarem Netzfehler ist ein No-op statt eines Duplikats, was
gerade auf Mobile den Unterschied macht.
⚠️ Client-gelieferte IDs sind eine Eingabe an einer Vertrauensgrenze. Der Primärschlüssel
weist Kollisionen von selbst ab, aber der Handler muss das als **Konflikt** behandeln und
nicht als Serverfehler — und die ID auf UUID-Form prüfen, statt sie ungeprüft zu übernehmen.
Auf einem öffentlichen Server ist das keine Formalie.
**2. Eine Ungelesen-*Zahl* ist ohne Nachladen nicht ableitbar.** `seq` zählt **Events**,
nicht Nachrichten — Reactions, Bearbeitungen und später Read-Receipts erhöhen ihn mit. Aus
der Differenz zwischen altem Cursor und neuem `seq` lässt sich also kein „3 neue
Nachrichten" berechnen, nur „hier hat sich etwas getan". Das war im Entwurf implizit falsch
angenommen.
**Entscheidung daraus:** Rust lädt **für DMs** nach (Zahl möglich), für Channels und Gruppen
nicht (nur ein Punkt). Das ist dasselbe Discord-Verhalten wie beim Raummodell, die Regel
kostet nichts, weil `conversations.kind` sie schon hergibt, und sie begrenzt die Last dort,
wo sie entsteht: ein aktiver Channel feuert ständig, eine DM selten. Sobald es Erwähnungen
gibt, kommen die nach derselben Logik dazu.
### Was der Prototyp bestätigt hat
- **Bearbeitete Nachrichten springen nicht.** Die Sortierung hängt an `messages.seq`, nicht
am Änderungszeitpunkt — eine Bearbeitung bewegt die Nachricht nur im Cursor-Raum, nicht im
Verlauf.
- **Löschen erzeugt einen Grabstein, keine Lücke.**
- **Der Zustand ist pro Server, nicht global.** Ein ausgefallener Server lässt die anderen
unberührt; „verdrängt" (4002) ist ein Endzustand, aus dem bewusst nicht neu verbunden wird.
- **Das Frontend rechnet nichts aus.** Es setzt gelieferte Entitäten ein und ersetzt anhand
der ID. Keine Cursor-Logik, kein Debouncing — beides liegt seit
[*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md) in Rust.
### Zustandsform
Pro Server ein Verbindungszustand; darunter je Conversation `{ geladen, ungelesen,
nachrichten[] }`. `ungelesen` ist entweder eine Zahl (DMs) oder eine Markierung (Channels) —
die Zweiwertigkeit ist keine Nachlässigkeit, sondern der Befund aus Punkt 2. Kein
Zustandsbibliothek nötig: React-State reicht, weil es keinen abgeleiteten Zustand gibt.
### Offen geblieben
Der `resync`-Pfad verwirft im Prototyp den Zwischenspeicher der betroffenen Conversation und
lädt beim nächsten Öffnen neu. Ob das für die gerade **sichtbare** Conversation sichtbar
ruckelt (kurz leer, dann voll), lässt sich am Zustandsmodell nicht beantworten — das ist eine
Frage der Oberfläche und gehört in die Umsetzung.

View File

@@ -0,0 +1,165 @@
# Voice auf Linux
Type: grilling
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Was ist die Zusage an Linux-Nutzer bezüglich Sprachanrufen — und wenn Voice dort laufen
soll, über welchen Medien-Stack?
Aufgeworfen von [*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md).
Die erwartete schlechte Nachricht kam nicht von Mobile, sondern von Linux.
### Befundlage (belegt, siehe §5 der Recherche)
WebRTC ist in Tauris Linux-WebView (WebKitGTK 4.1) an **drei unabhängigen Gates** blockiert:
1. **Compile-Time.** `ENABLE_WEB_RTC` folgt `ENABLE_EXPERIMENTAL_FEATURES`, das auf `OFF`
steht. Debian/Ubuntu und Alpine setzen es belegbar nicht → `RTCPeerConnection` existiert
im JS-Global gar nicht. Arch/Fedora unverifiziert (Experiment E5 in der Recherche).
2. **Runtime-Setting.** `WebKitSettings:enable-webrtc` ist Default `FALSE` und wird von wry
nie gesetzt.
3. **Permission.** `permission-request` ist Default Deny; wry 0.55.1 verbindet das Signal
überhaupt nicht.
Gate 2 und 3 wären aus dem App-Code über `WebviewWindow::with_webview` lösbar (bzw. ab
Tauri 2.12 sauber über `on_permission_request`). **Gate 1 hängt am Distro-Paket des
Nutzers und ist von uns nicht lösbar.**
Das trifft ausgerechnet die Gruppe, die für dieses Projekt zentral ist: Selbsthoster sind
linux-lastig.
### Optionen, alle unattraktiv
1. **Voice auf Linux als nicht unterstützt deklarieren.** Chat läuft voll, Voice nicht.
Ehrlich und billig — und wahrscheinlich der Weg, der die Simplizitätsdoktrin am wenigsten
verletzt. Aber schmerzhaft für die Kernzielgruppe.
2. **Nativer Medien-Stack nur auf Linux.** `livekit/rust-sdks` (`libwebrtc`-Crate, echtes
libwebrtc mit NetEQ/AEC/Opus, laut README inkl. Linux) im Tauri-Rust-Layer. Bricht die
Architektur-Einheitlichkeit: Voice läge auf drei Plattformen im WebView und auf einer in
Rust. Zusätzlich ein großer vorkompilierter nativer Blob pro Plattform.
*(`webrtc-rs` ist hierfür **kein** Ersatz — es ist ein reiner Protokoll-Stack ohne
Geräte-Capture, ohne Codecs, ohne Jitter-Buffer, ohne Echo-Cancellation.)*
3. **Nativer Stack auf allen Plattformen.** Einheitlich, aber verwirft den kostenlosen
WebView-Pfad auf drei von vier Zielen und zieht den nativen Blob überallhin.
4. **Eigenes WebKitGTK bündeln.** Widerspricht der Simplizitätsdoktrin frontal.
5. **Auf Upstream warten.** Löst Gate 2 und 3, **nie Gate 1**. Also keine Lösung.
### Zu entscheiden
- Welche Option — und wie wird die Einschränkung dem Nutzer gegenüber **sichtbar** gemacht,
statt ihn in einen Timeout laufen zu lassen? (Feature-Detection `typeof RTCPeerConnection`
ist trivial und sollte in jedem Fall passieren.)
- Falls (2) oder (3): wo verläuft dann die Rust↔WebView-Naht? Bei nativem Audio ist der
WebView **nicht mehr der Medien-Endpunkt** — Capture und Wiedergabe liegen in Rust, und
die UI bekommt nur heruntergerechnete Metering-Werte über IPC, niemals rohes PCM.
Zurück-Injizieren von PCM in den WebView geht in Chromium, aber **nicht** in WebKitGTK.
Rückkopplung auf [*Wo lebt die WS-Verbindung im Client*](05-wo-lebt-die-ws-verbindung.md).
**Beantwortet (14.08.2026): Linux ist eine vollwertige Client-Plattform.** Damit ist der
billige Ausweg verbaut — Option 1 bedeutet, auf einer erklärten Zielplattform ein
Kernfeature nicht anzubieten. Sie bleibt formal wählbar, muss aber als bewusster
Produktverzicht begründet werden, nicht als technische Notwendigkeit.
Realistisch bleiben damit **(2)** nativer Stack nur auf Linux und **(3)** nativer Stack
überall. Die eigentliche Frage dieses Tickets ist die Abwägung zwischen diesen beiden:
Einheitlichkeit gegen Kostenminimierung.
### Kopplung
Wirkt auf [*Voice-Topologie*](04-voice-topologie.md) zurück: fällt hier ein nativer Pfad,
ändern sich dort die Optionen. Die beiden Tickets können in beliebiger Reihenfolge laufen,
aber das zweite muss das Ergebnis des ersten kennen.
---
## Answer
Entschieden am 14.08.2026.
### 0. Zusätzlich geprüft: Flatpak löst Gate 1 nicht
Die Recherche hatte Gate 1 als „hängt am Distro-Paket" eingestuft. Das gilt aber nur, solange
der Nutzer das Distro-Paket benutzt — ein Flatpak bringt WebKitGTK aus seinem Runtime mit.
Geprüft, Ergebnis negativ:
- Das **GNOME-Runtime baut WebKitGTK ohne den WebRTC-Flag**; die Antwort im Flathub-Forum auf
die Frage nach dem Patchen lautete sinngemäß „macht ein Issue bei gnome-build-meta auf"
([Flathub Discourse](https://discourse.flathub.org/t/patching-gnome-runtime/10682)).
- Das einzige dokumentierte funktionierende Rezept verlangt, **WebKitGTK selbst zu
kompilieren** (`-DENABLE_MEDIA_STREAM=ON -DENABLE_WEB_RTC=ON`) **und zusätzlich wry zu
patchen** — erprobt auf NixOS unter X11, mit GBM-Fehlern unter Wayland und nicht
aufgelisteten v4l2loopback-Geräten
([Tauri Discussion #8426](https://github.com/tauri-apps/tauri/discussions/8426)).
Für ein Entwicklungssystem machbar, für Endnutzer nicht. **Gate 1 hat heute keine
nutzerseitige Lösung.**
### 1. WebView auf drei Plattformen, nativer Stack nur auf Linux
Windows, Android und iOS nutzen den kostenlosen, funktionierenden WebView-Pfad. Linux bekommt
`livekit/rust-sdks` (`libwebrtc`-Crate) im Rust-Layer — echtes libwebrtc mit NetEQ, AEC/AGC/NS
und Opus, laut README inklusive Linux.
`webrtc-rs` ist **kein** Ersatz: reiner Protokoll-Stack ohne Geräte-Capture, ohne Codecs, ohne
Jitter-Buffer, ohne Echo-Cancellation.
Die Alternative „nativ überall" war knapp und hat ein starkes Argument — ein Pfad statt zwei,
eine Berechtigungslogik, eine Testmatrix, und die wry-Eigenheit (Mikrofonrechte werden auf iOS
bedingungslos an jeden Origin vergeben) wäre komplett aus dem Spiel. Sie verliert, weil sie
einen auf drei Plattformen funktionierenden Gratis-Pfad wegwirft. **Der akzeptierte Preis sind
zwei Medienpfade auf Dauer**, mit zwei Berechtigungsmodellen und zwei Fehlerbildern.
**Nützliche Nebenwirkung:** weil Linux den nativen Stack *unbedingt* nutzt und nicht nur dort,
wo die WebView-Variante versagt, spielt es keine Rolle mehr, ob eine Distribution WebKitGTK
mit WebRTC gebaut hat. **Experiment E5 der Recherche (Arch/Fedora prüfen) entfällt damit.**
Und weil es hier nur um Audio geht, ist der GBM-/Wayland-Fehler aus #8426 gegenstandslos — er
betrifft die Videoausgabe des WebViews.
### 2. Die Naht ist eine explizite Verzweigung im Frontend, ganz oben
Rust meldet dem Frontend eine Fähigkeit — sinngemäß `mediaOwner: "webview" | "native" | "none"`
— und das Voice-Modul verzweigt **einmal**, an seiner Spitze. Darunter zwei klar getrennte
Implementierungen: `RTCPeerConnection` im WebView, oder Tauri-Commands in den nativen Stack.
Das ist ehrlich gegenüber der Realität, dass sich die Fälle nicht nur mechanisch
unterscheiden, sondern auch in der Oberfläche: **auf Linux gibt es kein `<audio>`-Element**,
weil PCM sich in WebKitGTK nicht zurückinjizieren lässt (in Chromium ginge es). Der native
Pfad besitzt dort die Wiedergabe vollständig, und Pegelanzeigen kommen als
heruntergerechnete Werte über IPC — **niemals rohes PCM**.
Verworfen: ein `MediaEngine`-Trait in Rust. Es wäre die sauberere Fassade, verlangte aber eine
Steuerungsumkehr — die WebView-Implementierung müsste ihre eigentliche Arbeit per IPC nach
oben ans Frontend delegieren. Ebenfalls verworfen: eine Naht nur auf Signaling-Ebene, weil die
Duplizierung dann bis in die Oberfläche reicht und Verhaltensunterschiede versehentlich statt
absichtlich entstehen.
### 3. Gestaffelt: Voice erscheint zuerst auf den WebView-Plattformen
Linux zeigt Voice solange sauber als „noch nicht verfügbar" an — über die gemeldete Fähigkeit
`none`, **nicht** über eine WebView-Feature-Erkennung, weil die auf Linux dauerhaft negativ
bliebe und nichts über den nativen Pfad aussagt. Chat läuft dort voll.
Das Argument ist Risikoreihenfolge: der WebView-Pfad validiert das Signaling-Protokoll billig
auf drei Plattformen, und der native Stack steckt danach in ein bereits bewährtes Protokoll.
Den schweren, unerprobten Medien-Stack gegen ein gleichzeitig unerprobtes Protokoll zu bauen,
verdoppelt die Unbekannten in einem Schritt — und bei einem Fehler wüsste niemand, welche
Seite schuld ist.
**Bewusst in Kauf genommen:** die Zielgruppe ist linuxlastig, und „Voice, aber nicht bei dir"
ist keine gute erste Begegnung. Wer es einmal als „geht auf Linux nicht" abgespeichert hat,
prüft später nicht nach. Das ist der Preis dafür, das Feature überhaupt in Nutzerhände zu
bringen, bevor Monate in den nativen Stack fließen — und er ist erträglich, weil die Ansage
ehrlich und sichtbar ist statt stillschweigend.
### Folgen für andere Tickets
- [*Voice-Topologie*](04-voice-topologie.md): der native Stack liefert auf Linux echtes
libwebrtc, Mesh funktioniert dort also genauso. Die Topologie-Entscheidung wird durch dieses
Ticket **nicht** eingeschränkt.
- [*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md): der signierte
DTLS-Fingerprint muss auf **beiden** Pfaden erzeugbar sein — im WebView über
`RTCPeerConnection.generateCertificate()`, nativ über die Zertifikats-API von libwebrtc. Die
Naht für das Pinning darf also nicht an einer WebView-API hängen.

325
docs/realtime/map.md Normal file
View File

@@ -0,0 +1,325 @@
# Map: WebSocket-Realtime-Layer + Voice-Signaling-Fundament
**Status: abgeschlossen.** Alle elf Tickets sind aufgelöst; das Ziel — eine
implementierbare Spec — ist erreicht. Dieses Verzeichnis ist die Begründungsschicht
zum Realtime-Code: *warum* etwas so gebaut ist, samt der verworfenen Alternativen.
Offene Arbeit steht nicht hier, sondern als Issue im GitLab.
Der Effort umfasst beide Repos (`rustification-server` und `rustification-client`);
die Dokumente liegen hier, weil das Gewicht der Spec serverseitig ist.
Entstanden mit `/wayfinder`; Label dort: `wayfinder:map`.
## Destination
Eine implementierbare Spec für einen WebSocket-Realtime-Layer **neben** dem bestehenden
REST-API (Nachrichten-Push für alles Persistierte) plus das **Voice-Signaling-Fundament**
(Room join/leave, Teilnehmerliste, SDP/ICE-Relay — kein Audio), zugeschnitten für den
Tauri-Client auf Desktop und Mobile im Foreground.
Fertig, wenn jemand die Spec ohne Rückfragen implementieren kann und der spätere
E2EE-Effort sie nicht ungültig macht.
**Dieser Effort plant, er baut nicht.** Jedes Ticket beantwortet eine Entscheidung.
Die Umsetzung ist ein eigener Effort.
## Notes
### Produktkontext
Rustification ist als Alternative zu Matrix gedacht, mit zwei erklärten Erfolgsfaktoren:
1. **Deployment-Simplizität** — wie leicht, schnell und reproduzierbar ist ein Server
aufgesetzt und einsatzbereit?
2. **Nutzersicherheit** — Schutz vor Angriffen und Data-Leaks.
Zielbild: Firmen, die mit sensiblen Daten umgehen (eigene Instanz), und eine öffentliche
Discord-Alternative für Privatnutzer. Jeder Nutzer soll einen eigenen Server hosten können.
**Bedrohungsmodell: der Serverbetreiber ist ein Angreifer.** Das ist der Grund, warum
echtes E2EE gerechtfertigt ist und nicht bloß Verschlüsselung at rest.
### Codebases
| | |
|---|---|
| `rustification-server` | Rust, Axum 0.8, SQLx 0.9 / PostgreSQL 17, JWT + Argon2, Tokio. Migrationen embedded. |
| `rustification-client` | Tauri v2. Frontend React 18 + TS (Vite). Client-Backend Rust, ruft REST via `reqwest`. Ziele: Windows, Linux, Android, iOS. |
Stand heute: **Polling.** `MessageList.tsx:33` fährt ein `setInterval(load, 3000)`,
zusätzlich zählt `ChatPage.tsx` nach dem Senden einen `refreshKey` hoch. Beides wird
ersetzt. (Der Client-README beschreibt darüber hinaus Member-Panel und Settings-Seite,
die es im Code noch nicht gibt.)
Weitere Bestandsfakten aus den Recherchen: `axum = "0.8.9"` ist **ohne `ws`-Feature**
eingebunden, es gibt keinen Graceful Shutdown, und `Extension<Claims>` wird nirgends gelesen.
Auf Client-Seite existieren `src-tauri/gen/android` und `gen/apple` noch nicht — Mobile ist
nie initialisiert worden, es gibt also nichts zu migrieren. Und es gibt weder WebRTC- noch
WebSocket-Code: grüne Wiese.
### Feststehende Randbedingungen
Diese sind **entschieden** und werden von den Tickets nicht neu aufgemacht:
| Thema | Entscheidung |
|---|---|
| REST vs. WS | REST bleibt für Auth, CRUD und History. WS macht Push + Voice-Signaling. |
| Protokollform | **Dumb Pipe** `{conversation, cursor}` für alles Persistierte; eigenes flüchtiges Format nur für Voice-Signaling. Die Naht verläuft entlang *persistiert vs. flüchtig*. |
| Deployment | **Harte Regel: eine Binary + Postgres.** TURN ist optionale, extern konfigurierbare Abhängigkeit — ohne sie läuft Chat voll, Voice eingeschränkt. |
| Zustellung | Best-effort + `?since`-Backfill nach Reconnect. Keine Server-Queue, keine ACKs — die DB *ist* die Queue. |
| Fan-out | **Revidiert am 14.08.2026 durch *Fan-out-Architektur*:** kein Trait, keine zweite Implementierung — **immer** über `pg_notify`, auch bei einer Instanz. Ein Trigger auf `conversation_events` feuert, der Server hört sich per `PgListener` selbst zu. Ein Pfad für Ein- und Mehr-Instanz-Betrieb, der getestete Pfad ist der Produktionspfad. Kein Redis. *(Ursprünglich: In-Process hinter einem Trait, `NOTIFY` als zweite Impl — formuliert, bevor feststand, dass der Event 60 Byte groß ist und der Trigger ohnehin feuert.)* |
| Server-Modell | **Keine Föderation.** Der *Client* hält N Verbindungen zu N unabhängigen Servern; kein Server-zu-Server-Protokoll. |
| Krypto | Nachrichteninhalte sind im Protokoll undurchsichtige Blobs. Metadaten darf der Server sehen. Volles E2EE-Design ist ein Folge-Effort. |
| Mobile | Foreground ja. Background-Delivery und Push out of scope. |
| Bestandsdaten | Keine. Migrationen dürfen brechen. |
| Client-Plattformen | Windows, Linux, Android, iOS — **Linux ist vollwertige Client-Plattform**, nicht nur Server-Host (bestätigt 14.08.2026). Das verbaut den billigen Ausweg bei *Voice auf Linux*. |
| **Keine fremden Origins im WebView** | wry vergibt Mikrofonrechte ohne App-Beteiligung — auf iOS in 0.55.1 bedingungslos `Grant` für jeden Origin. Solange nur eigener gebündelter Code läuft, ist das harmlos; ein `<iframe>`, eine Link-Vorschau oder eine Einbettung fremder Inhalte bekäme sofort Mikrofonzugriff, ohne dass die App gefragt wird. Ab Tauri 2.12 (`on_permission_request`) kontrollierbar — bis dahin ist diese Regel der einzige Schutz. |
### Skills pro Session
Jede Session, die ein Ticket bearbeitet: `/grilling` und `/domain-modeling`.
Recherche-Tickets: `/research` als Subagent. Prototyp-Tickets: `/prototype`.
### Council-Verdikt (14.08.2026)
Position: *Zuschnitt grundsätzlich richtig, aber zu groß und an einer Stelle sachlich
falsch — Protokoll auf einen generischen Änderungs-Event reduzieren, Mobile-WebRTC-
Recherche vor alle Voice-Entscheidungen ziehen.* Konfidenz 71 %.
Drei kritische Risiken:
1. **Cursor-Lücke** — **aufgelöst am 14.08.2026**: Zähler pro Conversation unter Zeilen-Lock,
damit ist Commit-Reihenfolge gleich Cursor-Reihenfolge. Siehe *Monotoner Cursor*.
2. **Mobile-WebRTC-Unbekannte** — **aufgelöst am 14.08.2026, positiv**: Android, iOS und
Windows tragen WebRTC im WebView. An seine Stelle tritt ein neues Risiko, das die
Recherche gefunden hat: **Linux trägt es nicht**, und Linux ist die Heimplattform der
Zielgruppe Selbsthoster. → *Voice auf Linux*.
3. **Bedrohungsmodell-Lücke** — **aufgelöst**: der Betreiber ist Angreifer (siehe oben).
Korrektur am Verdikt: das Argument „Dumb Pipe ist E2EE-agnostisch, weil kein Inhalt
durch den Socket geht" ist **falsch** — unter E2EE ist der Inhalt ohnehin ein Blob, den
der Server nicht lesen kann. Der Dumb Pipe gewinnt bei Simplizität, `NOTIFY`-Tauglichkeit
und Cache-Konsistenz, nicht bei der Sicherheit.
Minderheitsvotum (🎨 Visionary): *„Matrix ist seit zehn Jahren exakt dieses Produkt —
ein Matrix-Client in Tauri bringt dich in einem Wochenende weiter."* Bewusst verworfen:
Föderation ist der Grund für Matrix' Betriebsaufwand, und Simplizität im Deployment ist
hier das Differenzierungsmerkmal.
## Decisions so far
<!-- eine Zeile pro geschlossenem Ticket: Gist + Link -->
- [Recherche: WebRTC im Tauri-WebView (Mobile)](issues/01-research-webrtc-tauri-mobile.md) —
**iOS, Android und Windows tragen WebRTC im WebView** (iOS ab 14.3 mit
`NSMicrophoneUsageDescription`; Android ab API 21 mit `RECORD_AUDIO` **und**
`MODIFY_AUDIO_SETTINGS`; Windows ohne Konfiguration). **Linux nicht** — drei unabhängige
Gates in WebKitGTK, eines davon distro-seitig unlösbar. Council-Risiko 2 positiv
aufgelöst. Zwei Befunde mit Folgen: der Signaling-Server kann DTLS-Fingerprints tauschen
(RFC 8827) und der spezifizierte Gegenmechanismus ist nirgends implementiert; wry vergibt
Mikrofonrechte ohne App-Beteiligung.
Details: [`research/01-webrtc-tauri-mobile.md`](research/01-webrtc-tauri-mobile.md)
- [Recherche: WS-Auth, Heartbeat und Reconnect](issues/03-research-ws-auth-reconnect.md) —
Faktenlage zu allen sechs Teilfragen, ohne Empfehlung. Vier Befunde mit Folgen: **(a)** der
`Authorization`-Header ist der einzige Kanal, der in den Proxy-Defaults nicht mitgeloggt wird
(Caddy redigiert ihn aktiv, `request.uri` nicht) — und `Sec-WebSocket-Protocol` wird sehr wohl
geloggt; **(b)** die Browser-API muss laut WHATWG alle Fehlerdetails verschlucken, im WebView
ist „Token abgelaufen" nicht von „Server down" unterscheidbar, im Rust-Layer schon;
**(c)** `axum::serve(...).with_graceful_shutdown(...)` beendet WS-Verbindungen nicht und
wartet nicht auf sie — eigenes Tracking ist zwingend; **(d)** `tokio::sync::watch` passt
semantisch besser zum Dumb Pipe als `broadcast`.
Details: [`research/03-ws-auth-reconnect.md`](research/03-ws-auth-reconnect.md)
- [Monotoner Cursor](issues/02-monotoner-cursor.md) — **ein Zähler `seq` pro Conversation,
vergeben per `UPDATE conversations SET seq = seq + 1 … RETURNING` in derselben Transaktion
wie die Änderung.** Der Zeilen-Lock erzwingt Commit-Reihenfolge == `seq`-Reihenfolge; die
Korrektheit folgt aus der Konstruktion. Die Änderungsachse ist eine neue Tabelle
`conversation_events` (die Log-Zeile *ist* der Dumb-Pipe-Event), nicht eine Spalte auf
`messages` — nur so werden Edits, Soft-Deletes und Reactions vom Backfill erfasst.
`?since=N` liefert **aufgelösten Zustand inkl. Tombstones**, nicht rohe Events: idempotent
und koaleszierend, und das Log bleibt reine Signalisierung. Keine Retention, dafür ein
Antwortlimit mit `resync`-Antwort — derselbe Pfad bedient auch Erstverbindung und
`RecvError::Lagged`. **Nebenbefund: `created_at` verliert seine Sortierrolle** — es ist
`NOW()`, also Transaktionsstart, und die heutige Sortierung in `list_messages` ist damit
schon jetzt falsch. Council-Risiko 1 aufgelöst.
- [Wo lebt die WS-Verbindung im Client](issues/05-wo-lebt-die-ws-verbindung.md) — **eigene
Implementierung im Tauri-Rust-Layer** (`tokio-tungstenite`), Pool im `AppState`, Zustellung
per `emit`. Entscheidend waren `Authorization: Bearer` (einziger Kanal, den die
Proxy-Defaults nicht loggen) und dass `tungstenite::Error::Http` die volle Antwort trägt,
während der WebView laut WHATWG nur `1006` sehen darf. `tauri-plugin-websocket` geprüft und
verworfen (dünne API, dokumentierter Nachrichtenverlust, von den Maintainern erklärtermaßen
nicht gepflegt). **Voice-Signaling läuft ebenfalls über diesen Socket**, nur die
`RTCPeerConnection` lebt im WebView — die im Ticket vermutete Spannung löst sich auf.
**Rust macht den Backfill selbst und emittiert aufgelöste Entitäten**, die Cursor-Buchhaltung
liegt also bei Verbindung und Token statt im Frontend. Alle konfigurierten Server sind im
Vordergrund verbunden. `BASE_URL` wird zur Server-Liste im `tauri-plugin-store`;
Geheimnisse kommen hinter ein `SecretStore`-Trait, dessen Implementierung der E2EE-Effort
wählt.
- [Dumb-Pipe-Event & Backfill-Endpunkt](issues/06-dumb-pipe-event-und-backfill.md) — Event ist
**minimal `{conversation, seq}`** (~60 Byte, JSON, keine Protokollversion); `kind` und
`target_id` bewusst weggelassen, weil additiv nachrüstbar. `conversation_events.kind` bleibt
damit serverintern. Aufholen nach Reconnect läuft über ein **Manifest** (`GET /sync` liefert
`{conversation_id, seq}` für alle Mitgliedschaften — eine Abfrage) plus gezielte Backfills:
bei 40 Conversations und 3 geänderten 1+3 Requests statt 40. **`resync` ist ein
unterschiedener Antworttyp bei 200**, kein 4xx — es ist der Normalfall bei jeder
Erstverbindung und darf nicht durch Fehlerpfade laufen. Limit 500 Entitäten, Tombstones
werden mitgeliefert. **Klartext-Grenze konkret gezogen** und damit bindend für den
E2EE-Effort: Blob werden `messages.content`, `attachments.filename`, `attachments.mime_type`
und `reactions.emoji` (deterministisch, damit der `UNIQUE`-Constraint greift); Routing-,
Ordnungs- und Autorisierungsfelder plus Zeitstempel bleiben lesbar. Drei Endpunkte:
`/sync`, `/conversations/{id}/changes`, `/conversations/{id}/messages` (seitenweise).
- [Fan-out-Architektur](issues/08-fan-out-architektur.md) — Registry
`conversation_id → Verbindungen` plus eine zweite `user_id → Verbindungen` für
Mitgliedschaftswechsel (ein Beitretender steht noch nicht in der ersten). Je Verbindung ein
**Dirty-Set** `HashMap<Conversation, seq>` + `Notify` statt eines Kanals — perfektes
Zusammenfassen, kein Überlauf, kein Überlauf-Fehlerpfad; Eintrag als `max()`, weil `NOTIFY`
Reihenfolge nur je Listener garantiert. Log-Zeile schreibt der Handler, `pg_notify` feuert
ein dreizeiliger Trigger auf `conversation_events` — damit ist der Signalpfad unvergesslich.
**Der Trait ist gestrichen** (siehe Randbedingung Fan-out oben). Zwei akzeptierte Kosten:
`PgListener` belegt eine dedizierte Verbindung außerhalb des Pools, und eine volle
Notify-Queue lässt Transaktionen beim Commit scheitern. Eine eingeführte Lücke ist
geschlossen: **nach jedem Listener-Reconnect geht ein Resync-Hinweis an alle Verbindungen**,
weil `NOTIFY` keine Historie hat.
**Tragende Einsicht des Tickets:** weil nur der neueste `seq` je Conversation zählt, darf
das Fan-out zusammenfassen und sogar verlieren — Lag ist Latenz, nicht Datenverlust.
- [Verbindungsaufbau & Auth über WS](issues/07-verbindungsaufbau-und-auth.md) —
`Authorization: Bearer` im Handshake. **In-Band-Re-Auth** statt Verbindungs-TTL: der Client
schiebt das erneuerte Token über den ohnehin bidirektionalen Socket, statt bei 15 Minuten
Tokenlebensdauer zwölfmal pro Stunde neu zu verbinden; Widerruf wirkt trotzdem innerhalb der
Restlaufzeit. **Autorisierung liegt bei den REST-Endpunkten**, nicht bei der Registry — der
Event trägt keinen Inhalt, Registry-Drift leckt also höchstens Metadaten.
**Client pingt alle 15 s** (Protokoll-Ping, in Rust verfügbar; 15 s wegen des striktesten
belegten Proxy-Idle-Timeouts von 20 s), Server führt `last_seen`. **Acht Verbindungen je
Nutzer, die älteste weicht** — nicht die neue abweisen, sonst sperrt ein hängengebliebener
Socket das Gerät aus. **Eigene Close-Codes, drei Handlungsklassen** (sofort / Backoff /
gar nicht), Full Jitter beim Backoff. Graceful Shutdown wird selbst gebaut
(`CancellationToken` + `TaskTracker`), weil axum WS-Verbindungen nicht abwartet.
- [Voice auf Linux](issues/11-voice-auf-linux.md) — **Flatpak zusätzlich geprüft und als Lösung
ausgeschlossen**: das GNOME-Runtime baut WebKitGTK ohne WebRTC-Flag, und das einzige
funktionierende Rezept verlangt, WebKitGTK selbst zu kompilieren *und* wry zu patchen.
Gate 1 hat keine nutzerseitige Lösung. Entscheidung: **WebView-Pfad auf Windows, Android und
iOS; nativer Stack (`livekit/rust-sdks`) nur auf Linux** — der Gratis-Pfad auf drei
Plattformen wiegt schwerer als die Einheitlichkeit; akzeptierter Preis sind zwei Medienpfade
auf Dauer. Die Naht ist **eine explizite Verzweigung im Frontend**, gesteuert über eine von
Rust gemeldete Fähigkeit (`webview` / `native` / `none`), kein Trait mit Steuerungsumkehr.
**Gestaffelt:** Voice erscheint zuerst auf den drei WebView-Plattformen, Linux zeigt es
ehrlich als noch nicht verfügbar — der WebView-Pfad validiert das Signaling-Protokoll billig,
bevor der schwere native Stack hineinsteckt. **Nebenwirkung: Experiment E5 der WebRTC-Recherche
(Arch/Fedora prüfen) entfällt**, weil Linux den nativen Stack unbedingt nutzt.
- [Voice-Topologie](issues/04-voice-topologie.md) — **Mesh, harte Obergrenze sechs
Teilnehmer**, serverseitig durchgesetzt; der Server fasst nie Audio an. **Kein spekulativer
SFU-Vorbau**: ein SFU-Protokoll ist client-zu-Server adressiert, ein Mesh-Protokoll
peer-zu-peer — eine „SFU-taugliche Naht" heute wäre Selbsttäuschung. Ein SFU wäre ein
bewusster Protokollbruch in einem eigenen Effort, nicht ausgeschlossen, aber auch nicht
vorbereitet. Dazu **eine Datenschutz-Politik je Instanz**, vom Server gesetzt und im
Signaling übermittelt: *streng* = `iceTransportPolicy: "relay"` **plus** Opus `cbr=1`/`usedtx=0`,
*offen* = Standard-ICE und Standard-Opus. Die Kopplung folgt der Bedrohung — beide Lecks
entstehen am selben Ort: über das Relay sieht der Betreiber Paketgrößen und damit
Sprechaktivität, direkt sieht er nichts, dafür lernt der Peer die IP. Der Client **zeigt den
Modus an**. TURN bleibt damit optional: ohne TURN läuft Voice im offenen Modus eingeschränkt,
im strengen gar nicht — und der Betreiber legt den Schalter selbst um.
- [Voice-Signaling-Protokoll](issues/09-voice-signaling-protokoll.md) — **Discord-Modell:**
`voice_rooms` ist eine eigenständige Entität mit *zwei möglichen Eltern* — `workspace_id`
(dauerhafter Sprachkanal, für alle Workspace-Mitglieder sichtbar und betretbar) **oder**
`conversation_id` (Direktanruf aus DM/Gruppe, endet beim Auflegen), per `CHECK` genau eines.
Das spiegelt das Muster, das `conversations` schon benutzt. **Autorisierung läuft durch genau
eine Funktion**, weil Rollen später Conversations *und* Räume gleichermaßen einschränken
sollen. Raumzustand in `voice_participants` in Postgres — nötig, weil Mehr-Instanz-Betrieb
seit Ticket 08 ab Tag eins möglich ist; Geisterzeilen fängt `last_seen` über den bestehenden
15-s-Heartbeat ab. Adressierung über **`session_id`, nicht `user_id`** (Multi-Device).
**Der Beitretende bietet an** — Konvention, kein Mechanismus; bei Video/Screenshare später
durch Perfect Negotiation zu ersetzen. **Fingerprint-Pinning: Naht jetzt, Füllung durch den
E2EE-Effort** — bis dahin zeigt der Client dauerhaft und nicht wegklickbar an, dass das
Gespräch unverifiziert ist. Eine eigene Voice-Identität wurde bewusst verworfen, weil Nutzer
sonst später zwei Identitäten und zwei Verifikationsrituale hätten. Politik und
zeitbeschränkte TURN-Credentials reisen in der Beitrittsantwort; Raumgröße wird serverseitig
durchgesetzt.
- [Client-Integration](issues/10-client-integration.md) *(Prototyp:
`rustification-client`, Branch `prototype/frontend-zustandsmodell`)* — **Der Client vergibt die
Nachrichten-ID**, nicht der Server. Der Prototyp hat gezeigt, dass optimistisches Senden
sonst nicht zusammenführbar ist: dieselbe Nachricht steht zweimal da, einmal mit `seq` und
einmal ohne. `messages.id` kommt damit vom Client statt aus `DEFAULT gen_random_uuid()` —
Nebengewinn: Senden wird idempotent, ein Wiederholungsversuch ist ein No-op.
**Zweiter Befund: eine Ungelesen-Zahl ist ohne Nachladen nicht ableitbar**, weil `seq`
Events zählt und nicht Nachrichten. Daraus die Regel: Rust lädt **für DMs** nach (Zahl),
für Channels und Gruppen nicht (nur ein Punkt) — dasselbe Discord-Verhalten wie beim
Raummodell, und `conversations.kind` gibt die Unterscheidung schon her. Zustandsform: je
Server ein Verbindungszustand, je Conversation `{geladen, ungelesen, nachrichten[]}`; keine
Zustandsbibliothek nötig, weil es keinen abgeleiteten Zustand gibt.
## Not yet specified
- **Presence, Typing-Indikator und Read-Receipts als Event-Typen.** Bewusst hinter das
erste lauffähige Push geschoben — es sind Goodies, nicht der Hauptfokus. Read-Receipts
brauchen zusätzlich ein Schema (`message_reads` existiert nicht). **Haben seit
*Monotoner Cursor* ein Zuhause:** sie werden `kind`-Werte in `conversation_events`, das
Log trägt sie ohne Umbau mit. Wird spezifizierbar, sobald
*Dumb-Pipe-Event & Backfill-Endpunkt* steht. **Ausnahme:** die Teilnehmerliste
eines Voice-Rooms ist nicht dasselbe wie Chat-Presence und bleibt im Scope von
*Voice-Signaling-Protokoll*.
- **Verhalten beim Mobile-Resume aus dem Hintergrund.** Was genau passiert, wenn die App
nach Minuten oder Stunden zurückkommt — Reconnect-Backoff, Cursor-Sprung, UI-Zustand.
Hängt an *Dumb-Pipe-Event & Backfill-Endpunkt* und *Verbindungsaufbau & Auth über WS*.
- **Rate-Limiting auf Handshakes.** *Verbindungsaufbau & Auth über WS* hat das Limit pro
Nutzer entschieden (acht, älteste weicht), ein Rate-Limit auf Verbindungsversuche aber
bewusst offengelassen: es trifft ein anderes Missbrauchsmuster und ist ohne Betriebserfahrung
nicht kalibrierbar — ein Reconnect-Sturm nach einem Neustart sieht aus wie ein Angriff.
Wird scharf, sobald es Zahlen aus dem Betrieb gibt.
- **Observability des WS-Layers.** Was misst man an offenen Verbindungen, Registry-Größe,
Zustellverzögerung? Seit *Fan-out-Architektur* gibt es zwei konkrete Pflichtmetriken statt
einer vagen Frage: die **Füllung der Postgres-Notify-Queue** (läuft sie voll, scheitern
Transaktionen beim Commit) und **Reconnects des `PgListener`** (jeder löst einen
Resync-Hinweis an alle Verbindungen aus). Der Rest ist noch nicht scharf.
- **Auth-UI bei N Servern.** N Server heißt N Refresh-Zyklen und N Ablaufzeitpunkte — wie
sieht die Oberfläche aus, wenn Server C eine Neuanmeldung braucht, während A und B laufen?
Die Speicherfrage ist durch *Wo lebt die WS-Verbindung im Client* geklärt (Liste im Store,
Geheimnisse hinter dem `SecretStore`-Trait); die UI-Seite hängt an
*Verbindungsaufbau & Auth über WS*.
- **Rollenmodell für Conversations und Voice-Rooms.** Erklärtes Ziel: Rollen sollen später
steuern, welche Conversations und welche Voice-Rooms für wen sichtbar und betretbar sind.
*Voice-Signaling-Protokoll* hat dafür die Naht gesetzt — Autorisierung läuft durch genau eine
Funktion, damit kein raumspezifisches Modell entsteht. Das Modell selbst ist noch nicht
scharf: `workspace_members.role` kennt heute nur `owner`/`admin`/`member`, ein
Berechtigungsbegriff fehlt völlig.
- **Mobile-Plattform-Setup vor der Implementierung.** `src-tauri/gen/android` und
`gen/apple` existieren noch nicht — Mobile ist nie initialisiert worden. Dazu die sieben
Experimente aus §8 der WebRTC-Recherche, allen voran E1 (`getUserMedia` auf einem
physischen iPhone, je einmal mit und ohne Info.plist-Key) und E2
(`MODIFY_AUDIO_SETTINGS` wirklich zwingend?). Blockiert die Spec-Arbeit **nicht**,
gehört aber vor die Implementierung — vermutlich als Task-Ticket im Umsetzungs-Effort.
## Out of scope
- **Vollständiges E2EE-Design** — Schlüsselverwaltung, Multi-Device-Sync (Desktop + Mobile
parallel ist gefordert), Rotation, Gruppen-Mitgliederwechsel, Geräteverlust,
Attachment-Verschlüsselung. Eigener Folge-Effort in der Größenordnung dieses hier.
In *dieser* Map steckt davon nur, was das Protokoll heute festlegen muss.
**Neu bekannte Kopplung (14.08.2026):** *sicheres* Voice hängt am E2EE-Effort. Der
Signaling-Server kann DTLS-Fingerprints tauschen (RFC 8827), und der Gegenmechanismus
verlangt, den Fingerprint signiert durch einen E2EE-Kanal zu übertragen — den es heute
nicht gibt. *Voice-Signaling-Protokoll* legt die Naht fest, kann sie aber nicht einlösen.
Voice ist damit erst nach dem E2EE-Effort gegen den Betreiber geschützt.
**Zweite Kopplung:** `voice_rooms.name` ist nutzererzeugter Inhalt und fiele unter die
Blob-Regel — aber ein neues Workspace-Mitglied müsste die Raumliste lesen können, ohne
vorher Schlüsselmaterial zu haben. Ein Schlüsselverwaltungsproblem, das dort gelöst gehört.
- **Audio-Übertragung selbst** — WebRTC-Peers, Codecs, Echo-Cancellation, SFU-Betrieb.
Dieser Effort endet beim Signaling.
- **Push-Notifications / Background-Delivery auf Mobile** — FCM/APNs, Delivery-Queue,
Wakeup-Semantik. Orthogonale Infrastruktur.
- **Föderation (Server-zu-Server)** — bewusste Produktentscheidung: isolierte Instanzen
sind das Differenzierungsmerkmal gegenüber Matrix, nicht der Rückstand. Der Client
verbindet sich zu mehreren Servern; die Server reden nie miteinander.
- **Wahl der `SecretStore`-Implementierung** — *Wo lebt die WS-Verbindung im Client* hat die
Naht festgelegt (Tokens und Refresh-Tokens hinter einem Trait mit zwei Methoden), die
Implementierung aber bewusst an den E2EE-Effort abgegeben: der hat dasselbe Problem für
Geräteschlüssel, in verschärfter Form. OS-Keychain ist der naheliegende Kandidat, hat aber
auf Linux die schwächste Stelle (Secret Service braucht gnome-keyring oder KWallet).
- **Portable Schlüssel-Identität** (Schlüsselpaar statt Server-Account als Identität) —
reizvoll im E2EE-Kontext, aber eine Entscheidung des E2EE-Efforts. Lässt sich später
ergänzen, ohne den WS-Layer zu brechen.
- **Mehr-Instanz-Betrieb** — Sticky Sessions, Load-Balancer-Konfiguration, Deployment.
Im Scope ist nur die *Trait-Naht*, die den späteren Wechsel ermöglicht.
- **Serverseitige Suche** — unter E2EE ohnehin tot; braucht clientseitige Indizes.
- **Metadaten-Minimierung** (Sealed Sender, Padding) — widerspricht direkt der Entscheidung,
Read-Receipts und Presence überhaupt zu wollen.
- **TURN-Betrieb** (coturn-Konfiguration, Credential-Rotation) — optionale externe
Abhängigkeit, nicht Teil der Spec.

View File

@@ -0,0 +1,742 @@
# Recherche-Ergebnis: WebRTC im Tauri-WebView (Mobile + Desktop)
Ticket: [`../issues/01-research-webrtc-tauri-mobile.md`](../issues/01-research-webrtc-tauri-mobile.md)
Map: [`../map.md`](../map.md)
Stand: 14.08.2026
Methodik: Primärquellen (Apple/WebKit, AOSP, Chromium, Microsoft Learn, W3C/IETF, wry-/Tauri-Quellcode
auf den tatsächlich aufgelösten Versions-Tags). Blogposts nur als Hinweisgeber, jeweils mit Datum markiert.
---
## 1. Kernantwort
| Plattform | Antwort | Aufwand | Blockierender Punkt |
|---|---|---|---|
| **iOS** (WKWebView) | **Ja** | 1 Datei | `NSMicrophoneUsageDescription` in `src-tauri/Info.ios.plist` |
| **Android** (System WebView) | **Ja** | 2 Manifest-Zeilen | `RECORD_AUDIO` **und** `MODIFY_AUDIO_SETTINGS` |
| **Windows** (WebView2) | **Ja** | keiner | — (funktioniert out of the box, mit WebView2-eigenem Prompt) |
| **Linux** (WebKitGTK 4.1) | **Nein** | hoch / teils unlösbar | Drei unabhängige Gates, siehe §5 |
**Fazit für die Map:** Die im Council-Verdikt als Risiko 2 geführte „Mobile-WebRTC-Unbekannte" ist
**aufgelöst — positiv**. Android und iOS tragen WebRTC im WebView. Das Voice-Signaling-Design ist für
die Mobile-Hälfte der Zielplattformen nicht wertlos. Ticket 04 (*Voice-Topologie*) ist damit entblockt.
**Die neue schlechte Nachricht liegt woanders:** Nicht Mobile ist der Problemfall, sondern **Linux**.
Dort ist WebRTC in WebKitGTK weder einkompiliert noch zur Laufzeit aktivierbar noch berechtigt.
Das ist keine Kleinigkeit für ein Projekt, dessen Zielgruppe Selbsthoster sind.
---
## 2. Ausgangslage im Repo (Versionsanker)
Diese Recherche gilt gegen die Versionen, die `rustification-client` heute tatsächlich auflöst:
| Komponente | Version | Herkunft |
|---|---|---|
| `@tauri-apps/cli` | **2.11.4** | `package-lock.json` |
| `tauri` (Rust) | `"2"` → aktuell **2.11.5** (01.07.2026) | `src-tauri/Cargo.toml`, crates.io |
| `tauri-runtime-wry` | **2.11.4** (30.06.2026) | crates.io |
| → `wry` | Anforderung `^0.55.0` → **0.55.1** (04.05.2026) | crates.io Dependency-Auflösung |
Das ist der wichtigste Kontext des ganzen Dokuments: **wry 0.56.0 (30.07.2026) hat die Permission-Lage
grundlegend geändert**, aber `tauri-runtime-wry 2.11.4` fordert `^0.55.0` und kann 0.56 nicht ziehen
(SemVer bei `0.x`: `>=0.55.0, <0.56.0`). Alles unten Beschriebene ist gegen **wry 0.55.1** verifiziert.
Weitere relevante Repo-Fakten:
- `src-tauri/gen/android` und `src-tauri/gen/apple` existieren **noch nicht** — Mobile ist nie
initialisiert worden. Es gibt also noch nichts zu migrieren.
- `app.security.csp` ist `null`. Tauri v2 hat **keine Default-CSP**
([v2.tauri.app/security/csp](https://v2.tauri.app/security/csp/): *„The CSP protection is only enabled
if set on the Tauri configuration file."*). Für WebRTC ist das irrelevant (siehe §7.5), aber es ist gut
zu wissen, dass hier nichts im Weg steht.
- Es existiert **kein** WebRTC- oder WebSocket-Code im Client. Grüne Wiese.
---
## 3. iOS — **Ja**
### 3.1 Versionsuntergrenze
`getUserMedia` ist in `WKWebView` (nicht nur Safari) seit **iOS 14.3** verfügbar.
Primärquelle: WebKit-Blog, *MediaRecorder API*, Youenn Fablet, **23.11.2020**
([webkit.org/blog/11353](https://webkit.org/blog/11353/mediarecorder-api/)), wörtlich:
> „Speaking of Safari in latest iOS 14.3 beta and local capture, `navigator.mediaDevices.getUserMedia`
> can now be exposed to WKWebView applications. `navigator.mediaDevices.getUserMedia` is automatically
> exposed **if the embedding application is able to natively capture either audio or video**."
Der Satzteil „able to natively capture" ist die eigentliche Bedingung: Er bedeutet **die
Usage-Description-Keys sind in der Info.plist vorhanden**. Kein Entitlement, keine private API.
Tauris eigener Default `bundle.iOS.minimumSystemVersion` ist `"14.0"` — für WebRTC muss man das
faktisch auf **14.3** oder höher lesen. Praktisch relevanter Floor ist ohnehin **iOS 15.0**, weil
erst dort der `WKUIDelegate`-Callback existiert, den wry registriert (siehe 3.3).
### 3.2 Secure Context — passt, ohne Tricks
`getUserMedia` verlangt einen Secure Context. Tauri liefert das Frontend auf iOS (wie macOS und Linux)
unter dem Custom-Scheme **`tauri://localhost`** aus (`crates/tauri/src/manager/mod.rs`,
`tauri_protocol_url`). Ein Custom-Scheme allein ist *nicht* vertrauenswürdig — aber WebKit prüft den
**Host unabhängig vom Schema** (`Source/WebCore/page/SecurityOrigin.cpp`):
```cpp
if (LegacySchemeRegistry::shouldTreatURLSchemeAsSecure(protocol)) return true;
if (SecurityOrigin::isLocalHostOrLoopbackIPAddress(host)) return true;
```
Host ist `localhost` ⇒ potentially trustworthy ⇒ Secure Context. Der aus Blogposts bekannte
`_registerURLSchemeAsSecure`-Hack (private API) ist **nicht nötig**.
### 3.3 Was wry bereits erledigt — verifiziert im Quellcode
wry registriert einen `WKUIDelegate` und darin den Media-Capture-Callback.
Quelle: `src/wkwebview/class/wry_web_view_ui_delegate.rs` am Tag **`wry-v0.55.1`**:
```rust
#[unsafe(method(webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:))]
fn request_media_capture_permission(
&self, _webview: &WryWebView, _origin: &WKSecurityOrigin, _frame: &WKFrameInfo,
_capture_type: WKMediaCaptureType,
decision_handler: &Block<dyn Fn(WKPermissionDecision)>,
) {
(*decision_handler).call((WKPermissionDecision::Grant,));
}
```
Drei Beobachtungen, alle wichtig:
1. **Kein `#[cfg(target_os = "macos")]`.** Die Methode wird auf iOS mitkompiliert. (In `dev`/0.56 steht
dort `#[cfg(not(macos_12_unavailable))]` — ein Flag, das der `build.rs` nur unter
`target_os = "macos"` setzt; auch dort bleibt iOS also drin.)
2. **In 0.55.1 wird bedingungslos `Grant` zurückgegeben.** Es gibt in dieser Version gar keinen
`permission_handler` — `WryWebViewUIDelegateIvars` hat das Feld schlicht nicht. Der WebKit-Prompt
entfällt damit; es bleibt genau **ein** Prompt, nämlich der des Betriebssystems (TCC).
3. Apple dokumentiert den Default ohne Delegate-Implementierung als `WKPermissionDecision.prompt` —
also **nicht** Deny ([Apple Developer](https://developer.apple.com/documentation/webkit/wkuidelegate/webview(_:requestmediacapturepermissionfor:initiatedbyframe:type:decisionhandler:)),
verfügbar ab iOS 15.0 / macOS 12.0). wry macht daraus ein Grant. Sicherheitsrelevant, siehe §7.6.
`allowsInlineMediaPlayback` setzt wry auf iOS hart auf `true` — relevant, sobald der Remote-Stream in
ein `<audio>`/`<video>`-Element gehängt wird.
### 3.4 Der eine nötige Konfigurationsschritt
`src-tauri/Info.ios.plist` (oder `src-tauri/Info.plist`, siehe unten) anlegen:
```xml
<key>NSMicrophoneUsageDescription</key>
<string>Rustification braucht Mikrofonzugriff für Sprachanrufe.</string>
```
Fehlt der Key, gilt die App als nicht „able to natively capture" ⇒ `navigator.mediaDevices` ist
**gar nicht erst definiert** (das klassische `navigator.mediaDevices is undefined`), und ein nativer
Capture-Versuch bringt iOS zum harten Absturz.
Tauri merged die Datei mit den generierten Werten. Merge-Reihenfolge auf iOS (später gewinnt), laut
offenem Issue [tauri#13068](https://github.com/tauri-apps/tauri/issues/13068) (24.03.2025, *Unify/Document
handling of Info.plist files*): XcodeGen-`project.yml` → `Info.plist` aus `TAURI_DIR` →
`Info.ios.plist` aus `TAURI_DIR` → generierte Version-Strings. Zusätzlich existiert der Config-Key
`bundle.iOS.infoPlist` („Path to an Info.plist file for merging with defaults").
**`Info.ios.plist` ist der robuste Weg**, weil er `gen/apple` wegwerfbar hält.
Belastbares Signal, dass genau dieser Pfad vorgesehen ist: Tauris eigenes API-Beispiel
[`examples/api/src-tauri/Info.plist`](https://github.com/tauri-apps/tauri/blob/dev/examples/api/src-tauri/Info.plist)
enthält wörtlich `NSMicrophoneUsageDescription` = *„Request microphone access for WebRTC"*.
### 3.5 Bekanntes offenes Issue
[tauri#12068 — *iOS Microphone Access*](https://github.com/tauri-apps/tauri/issues/12068), geöffnet
**28.12.2024**, **noch offen**, Label `needs triage`, keine Maintainer-Antwort. Symptom: „it failed to
access the mic nor enumerate media devices. The mic permission is shown randomly once."
Der Report enthält weder `tauri info` noch die iOS-Version noch die Info.plist. **Wahrscheinlichste
Erklärung ist der fehlende `NSMicrophoneUsageDescription`-Key** — das Symptombild passt exakt. Das ist
aber eine Hypothese, kein Befund. Siehe Experiment E1 in §8.
---
## 4. Android — **Ja**
### 4.1 Versionsuntergrenze
WebRTC ist im Android System WebView seit **Android 5.0 Lollipop / API 21 / WebView 37** vorhanden.
Primärquelle: [developer.android.com/about/versions/android-5.0](https://developer.android.com/about/versions/android-5.0):
> „Android 5.0 updates the `WebView` implementation to **Chromium M37** … This release brings support
> for the following open standards: WebAudio, WebGL, **WebRTC**."
> „This release introduces the `PermissionRequest` class … **Your app must have the appropriate Android
> permissions for these resources in order to grant the permissions to the WebView.**"
Tauris Default `bundle.android.minSdkVersion` ist **24** — deutlich darüber. wrys Runtime-Permission-Pfad
ist auf `SDK_INT >= M` (API 23) gegated. Praktisch irrelevant: Das System WebView ist seit Lollipop über
den Play Store aktualisierbar, jedes reale Gerät fährt ein aktuelles Chromium.
### 4.2 Der WebView-Gate: `onPermissionRequest`
Ohne Override **verweigert** der `WebChromeClient`. AOSP,
[`core/java/android/webkit/WebChromeClient.java`](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/webkit/WebChromeClient.java):
```java
/** ... If this method isn't overridden, the permission is denied. */
public void onPermissionRequest(PermissionRequest request) {
request.deny();
}
```
**wry überschreibt es.** Verifiziert in `src/android/kotlin/RustWebChromeClient.kt` am Tag
**`wry-v0.55.1`**:
```kotlin
override fun onPermissionRequest(request: PermissionRequest) {
val isRequestPermissionRequired = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
val permissionList: MutableList<String> = ArrayList()
if (listOf(*request.resources).contains("android.webkit.resource.VIDEO_CAPTURE")) {
permissionList.add(Manifest.permission.CAMERA)
}
if (listOf(*request.resources).contains("android.webkit.resource.AUDIO_CAPTURE")) {
permissionList.add(Manifest.permission.MODIFY_AUDIO_SETTINGS)
permissionList.add(Manifest.permission.RECORD_AUDIO)
}
if (permissionList.isNotEmpty() && isRequestPermissionRequired) {
... permissionLauncher.launch(permissions) // OS-Prompt
// Callback: alle granted -> request.grant(request.resources), sonst request.deny()
} else {
request.grant(request.resources)
}
}
```
Die App muss also **nichts** abfangen. wry löst den Runtime-Prompt selbst aus und reicht das Ergebnis
an den WebView durch. Der Callback kollabiert das Ergebnis-Map mit „alle müssen `true` sein".
Diese Methode existiert bei wry seit **0.21.0 (05.09.2022, PR #685)** — sie ist in *jedem* Tauri-v2-Release
vorhanden.
### 4.3 Die zwei nötigen Manifest-Zeilen
Tauris generiertes Manifest enthält **nur `INTERNET`**. Verifiziert im Template
[`crates/tauri-cli/templates/mobile/android/app/src/main/AndroidManifest.xml`](https://github.com/tauri-apps/tauri/blob/dev/crates/tauri-cli/templates/mobile/android/app/src/main/AndroidManifest.xml):
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
```
In `src-tauri/gen/android/app/src/main/AndroidManifest.xml` ergänzen:
```xml
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
```
**`MODIFY_AUDIO_SETTINGS` ist nicht optional**, obwohl Chromium selbst es nicht braucht. Begründung aus
dem Code oben: wry fordert beide Permissions in *einem* `RequestMultiplePermissions`-Aufruf an und
verlangt, dass *alle* zurückgemeldeten Werte `true` sind. Ein nicht im Manifest deklariertes Recht
liefert dort `false` — also `request.deny()` und `NotAllowedError`, **obwohl der Nutzer den Mikrofon-Dialog
mit „Zulassen" bestätigt hat**. `MODIFY_AUDIO_SETTINGS` hat Protection-Level `normal`, erzeugt also
keinen zusätzlichen Dialog; es muss nur deklariert sein.
Das ist die wahrscheinlichste Ursache von
[tauri#10846](https://github.com/tauri-apps/tauri/issues/10846) (31.08.2024, *Audio Permission Not
Working in Tauri Android App Despite Manifest Declaration*, `needs triage`) — dort wurde ausschließlich
`RECORD_AUDIO` deklariert. Deckt sich mit der Community-Diagnose in
[Discussion #12732](https://github.com/orgs/tauri-apps/discussions/12732) (Feb–Apr 2025, getestet gegen
Tauri 2.10.3): *„Critically, MODIFY_AUDIO_SETTINGS to prevent NotReadableError."*
**Status: durch Code-Lesung sehr gut gestützt, nicht empirisch verifiziert** — siehe Experiment E2.
Es gibt **keinen** `tauri.conf.json`-Key für Android-Manifest-Permissions und **kein** offizielles
Tauri-Plugin für generische Runtime-Permissions (die offizielle Plugin-Liste auf
[v2.tauri.app/plugin](https://v2.tauri.app/plugin/) führt 30 Plugins, keines davon Mikrofon oder
Permissions). Der dokumentierte Weg ist Hand-Editieren des generierten Manifests.
### 4.4 `gen/android` gehört ins Git
Das Template-`.gitignore` schließt ausschließlich Build-Artefakte aus (`build`, `.gradle`,
`local.properties`, `/.tauri`, `/tauri.settings.gradle`, `app/src/main/**/generated`,
`app/src/main/jniLibs/**/*.so`, `app/src/main/assets/tauri.conf.json`). `AndroidManifest.xml` und
`MainActivity.kt` sind getrackte Quelldateien ohne `THIS IS AN AUTOGENERATED FILE`-Header.
Maintainer FabianLars, [Discussion #8323](https://github.com/orgs/tauri-apps/discussions/8323) (01.12.2023):
> „Generally yes. it has .gitignore folders inside that ignores the build artifacts etc. The gen folder
> also contains the normal android studio / xcode projects which you can modify if you want which would
> require you to commit it."
`tauri android build`/`dev` überschreibt die Datei nicht; **`tauri android init` rendert die Templates
neu** und ist damit als destruktiv zu behandeln. Siehe Experiment E3.
### 4.5 Secure Context
Tauri liefert auf Android und Windows unter **`http://tauri.localhost`** aus.
Verifiziert in `crates/tauri-utils/src/config.rs`:
```rust
/// Sets whether the custom protocols should use `https://<scheme>.localhost` instead of the default
/// `http://<scheme>.localhost` on Windows and Android. Defaults to `false`.
pub use_https_scheme: bool,
```
und im `Default`-Impl: `use_https_scheme: false`.
Chromium behandelt `*.localhost` auch über plain http als vertrauenswürdig.
[`net/base/url_util.cc`](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/net/base/url_util.cc):
```cpp
bool IsNormalizedLocalhostTLD(std::string_view host) {
return base::EndsWith(host, ".localhost", base::CompareCase::INSENSITIVE_ASCII);
}
```
Deckt sich mit W3C [Secure Contexts](https://w3c.github.io/webappsec-secure-contexts/), Schritt 5:
*„origin's host ends with `.localhost` or `.localhost.`"*. Das System WebView ist Chromium und linkt
denselben `//services/network`-Code.
**Gegenstimme, die ich für falsch halte:** [wry#1709](https://github.com/tauri-apps/wry/issues/1709) /
[#1710](https://github.com/tauri-apps/wry/issues/1710) (01.04.2026) behaupten, Android WebView halte nur
exakt `http://localhost`, `https://` und `file://` für sicher. Beide Issues stammen von einem
Nicht-Maintainer, #1709 hat null Antworten, und die Behauptung wird ohne Chromium-Quelle aufgestellt —
im direkten Widerspruch zum Code oben. Die dort angeführten Belege (SharedWorker, Service Worker) haben
andere, bekannte Ursachen. Trotzdem: Experiment E4 kostet zehn Sekunden.
**Risikoloser Hedge:** `useHttpsScheme: true` unter `app.windows[]`. Nachteile laut Tauri-Doc-Kommentar:
kein Mixed Content mehr (`http://`-Fetches brechen), und **die IndexedDB-/Cookie-/localStorage-Partition
wandert**. Also vor dem ersten Release entscheiden, nicht danach.
---
## 5. Linux (WebKitGTK 4.1) — **Nein**
Tauri v2 verlangt auf Linux `webkit2gtk-4.1` (`libwebkit2gtk-4.1-dev` / `webkit2gtk-4.1` /
`webkit2gtk4.1-devel`, [v2.tauri.app/start/prerequisites](https://v2.tauri.app/start/prerequisites/)).
Dort scheitert WebRTC an **drei unabhängigen Gates**, die alle passiert werden müssen.
### Gate 1 — Compile-Time: `ENABLE_WEB_RTC` ist standardmäßig AUS
[`Source/cmake/OptionsGTK.cmake`](https://github.com/WebKit/WebKit/blob/main/Source/cmake/OptionsGTK.cmake):
```cmake
WEBKIT_OPTION_DEFAULT_PORT_VALUE(ENABLE_MEDIA_STREAM PRIVATE ON)
WEBKIT_OPTION_DEFAULT_PORT_VALUE(ENABLE_WEB_RTC PRIVATE ${ENABLE_EXPERIMENTAL_FEATURES})
```
`Source/cmake/WebKitFeatures.cmake`:
```cmake
option(ENABLE_EXPERIMENTAL_FEATURES "Enable experimental features" OFF)
```
⇒ `RTCPeerConnection` ist im JS-Global schlicht **nicht vorhanden**, sofern der Packager nicht explizit
`-DENABLE_WEB_RTC=ON` setzt. `ENABLE_MEDIA_STREAM` ist zwar an (also `getUserMedia` grundsätzlich da),
aber ohne `ENABLE_WEB_RTC` nützt das nichts.
Verifizierter Distro-Stand:
- **Debian/Ubuntu** `webkit2gtk` — `debian/rules` enthält weder `ENABLE_WEB_RTC` noch
`ENABLE_EXPERIMENTAL_FEATURES`. ⇒ **AUS.** ([sources.debian.org](https://sources.debian.org/src/webkit2gtk/latest/debian/rules/))
- **Alpine** `community/webkit2gtk-4.1` APKBUILD — dito. ⇒ **AUS.**
- **Arch / Fedora** — nicht verifiziert; beide Dist-Git-Hosts sind hinter einer Anubis-Bot-Schranke
(Experiment E5). Schwaches Indiz gegen WebRTC bei Arch: `libnice` taucht in den Dependencies nicht auf.
Korroborierend: [tauri#13143](https://github.com/tauri-apps/tauri/issues/13143) *„The current browser
does not support WebRTC"* auf Ubuntu 22.04 mit WebKit2GTK 2.48.0 — geschlossen als *not planned*, Label
`status: upstream` (05.04.2025).
Historie: Der GstWebRTC-Backend kam mit WebKitGTK **2.37.1** („Add initial implementation of WebRTC using
GstWebRTC if GStreamer 1.20 is available"). Es gibt **keinen** NEWS-Eintrag, der ein
Default-On dokumentiert.
### Gate 2 — Runtime-Setting: `enable-webrtc` ist standardmäßig FALSE
[`WebKitSettings:enable-webrtc`](https://webkitgtk.org/reference/webkit2gtk/stable/property.Settings.enable-webrtc.html):
> „Enable WebRTC support for loaded pages. Enabling this setting implies that
> `WebKitSettings:enable-media-stream` will be enabled as well."
> **Default value: FALSE. Available since: 2.38.**
wry setzt es **nie** — weder in 0.55.1 noch in `dev`. In `set_webview_settings`
(`src/webkitgtk/mod.rs`) stehen `set_enable_webgl`, `set_enable_webaudio`, `set_enable_page_cache` u. a.,
aber kein `set_enable_webrtc` und kein `set_enable_media_stream`. Tauri exponiert die WebKitSettings
auch nicht ([tauri#8989](https://github.com/tauri-apps/tauri/issues/8989), *[feat] expose control over
webkit settings*, offen).
### Gate 3 — Permission: Default ist DENY
`WebKitWebView`s Default-Class-Handler für `permission-request`
(`Source/WebKit/UIProcess/API/glib/WebKitWebView.cpp`) verweigert alles außer Pointer-Lock. Die Doku ist
explizit ([`WebKitUserMediaPermissionRequest`](https://webkitgtk.org/reference/webkit2gtk/stable/class.UserMediaPermissionRequest.html)):
> „When a `WebKitUserMediaPermissionRequest` is not handled by the user, **it is denied by default**."
wry **0.55.1** verbindet `connect_permission_request` **überhaupt nicht** — im ganzen
`src/webkitgtk/mod.rs` kommt der String nicht vor. Das erklärt
[tauri#12547](https://github.com/tauri-apps/tauri/issues/12547) („access denied, no dialog",
geschlossen *not planned*, 28.01.2025).
wry **0.56+** verbindet ihn — aber nur, wenn die App einen Handler übergibt:
```rust
// Permission handler
if let Some(permission_handler) = attributes.permission_handler.take() {
webview.connect_permission_request(move |_webview, request| {
if let Some(media_request) = request.downcast_ref::<UserMediaPermissionRequest>() { ... }
```
Und docs.rs zu `PermissionResponse::Default` ist unmissverständlich: *„Windows / macOS / Android:
continue the platform or browser permission flow. **Linux: The default behavior is `Deny`.**"*
### Der Weg, der theoretisch bliebe
`WebviewWindow::with_webview` gibt auf Linux Zugriff auf die rohe `webkit2gtk::WebView`
([docs.rs/tauri](https://docs.rs/tauri/latest/tauri/webview/struct.WebviewWindow.html)). Damit ließen sich
Gate 2 (`settings.set_enable_webrtc(true)`, Binding-Feature `v2_38`) und Gate 3
(`connect_permission_request`) aus dem App-Code heraus schließen — ohne wry oder Tauri zu patchen.
**Gate 1 bleibt trotzdem.** Ist WebRTC nicht einkompiliert, hilft kein Setting. Und Gate 1 hängt am
Distro-Paket des Nutzers, nicht an unserem Code.
### Konsequenz für die Map
Die harte Deployment-Regel („eine Binary + Postgres") betrifft den *Server*. Für den *Client* auf Linux
gibt es keine analoge Zusage — aber die Zielgruppe „Selbsthoster" ist stark linux-lastig, und Linux ist
in der Map als Zielplattform gelistet. Realistische Optionen, alle unattraktiv:
1. **Voice auf Linux als nicht unterstützt deklarieren.** Chat läuft voll, Voice nicht. Ehrlich, billig,
schmerzhaft.
2. **Nativer Pfad nur auf Linux** (§6). Bricht die Architektur-Einheitlichkeit — Voice läge auf drei
Plattformen im WebView und auf einer in Rust.
3. **Eigenes WebKitGTK bündeln.** Widerspricht der Simplizitätsdoktrin des Projekts frontal.
4. **Warten/upstream drücken:** wry ≥ 0.56 + Tauri ≥ 2.12 (PR
[#14865](https://github.com/tauri-apps/tauri/pull/14865), gemergt **03.08.2026**, Milestone 2.12,
noch nicht released) lösen Gate 3 sauber. Gate 2 bräuchte zusätzlich `with_webview`-Code oder
tauri#8989. **Gate 1 löst niemand außer den Distros.**
Diese Entscheidung gehört als Ergebnis in Ticket 04 (*Voice-Topologie*), nicht hierher.
---
## 6. Windows (WebView2) — **Ja, ohne Konfiguration**
- **Engine:** Chromium. Microsoft dokumentiert `navigator.mediaDevices.getUserMedia()` explizit als in
WebView2 verfügbar, mit Secure-Context als einziger Einschränkung
([Working with local content](https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/working-with-local-content)).
- **Permission-Default ist PROMPT, nicht Deny.** `COREWEBVIEW2_PERMISSION_STATE_DEFAULT` —
*„Specifies that the default browser behavior is used, which normally prompt users for decision."*
`get_State` — *„The default value is `COREWEBVIEW2_PERMISSION_STATE_DEFAULT`."*
Permission-Kind: `COREWEBVIEW2_PERMISSION_KIND_MICROPHONE` — *„Indicates permission to capture audio."*
API vorhanden seit WebView2 **0.9.430**; jede GA-Runtime genügt.
- **wry 0.55.1** registriert zwar `add_PermissionRequested`, setzt aber **nur** für
`COREWEBVIEW2_PERMISSION_KIND_CLIPBOARD_READ` einen State (verifiziert in `src/webview2/mod.rs`).
Alles andere bleibt auf `STATE_DEFAULT` ⇒ **WebView2 zeigt seinen eigenen Prompt.**
- **Secure Context:** `http://tauri.localhost` — siehe §4.5. Kein
`--unsafely-treat-insecure-origin-as-secure` nötig.
**Eine Falle:** Die Entscheidung wird im WebView2-Profil **persistiert**. Wer einmal „Blockieren" klickt,
bekommt nie wieder einen Prompt, bis `%LOCALAPPDATA%\<bundleId>\EBWebView\Default\Preferences` gelöscht
wird ([tauri#5042](https://github.com/tauri-apps/tauri/issues/5042),
[#8979](https://github.com/tauri-apps/tauri/issues/8979)). Das muss die UI abfangen: `getUserMedia`, das
ohne sichtbaren Dialog sofort mit `NotAllowedError` zurückkommt, ist ein anderer Zustand als „Nutzer hat
gerade abgelehnt", und der Nutzer braucht eine Anleitung statt eines Timeouts.
Für MSIX/UWP-Pakete zusätzlich: `microphone`-Device-Capability im AppxManifest.
---
## 7. Nativer Fallback und Sicherheits-Eigenheiten
### 7.1 `webrtc-rs` — reiner Protokoll-Stack, **keine** Audio-Erfassung
`webrtc` **0.20.2** (11.08.2026), 5,6 Mio. Downloads,
[github.com/webrtc-rs/webrtc](https://github.com/webrtc-rs/webrtc). README-Selbstbeschreibung:
*„Sans-I/O protocol core with complete WebRTC stack (95%+ W3C API compliance)"* — ICE, DTLS, SRTP, SCTP,
RTP, DataChannels, Simulcast.
**Nicht enthalten:** Geräte-Capture, Codecs, Jitter-Buffer/NetEQ, Echo-Cancellation. Der Maintainer selbst
(webrtc-rs#94) beschreibt das Ersetzen von Google-libwebrtc als „requires porting NetEQ, audio 3A
(AEC, AGC, NS), audio/video codec" — also ausdrücklich nicht getan.
Der vollständige Eigenbau bräuchte zusätzlich: `cpal` 0.18.1 (Capture), `opus`/`audiopus` (Encoder),
RTP-Packetisierung, einen selbstgeschriebenen Jitter-Buffer mit PLC — und für AEC/AGC/NS ausgerechnet
`webrtc-audio-processing`, das seinerseits ein C++-Binding an Googles APM ist. Man entkommt libwebrtc
also nicht, man baut nur den unangenehmen Teil selbst.
**Mobile:** keine dokumentierte Plattform-Matrix. Android wird real benutzt, mit bekannten Kanten bei
Netzwerk-Interfaces und Handover (webrtc-rs #874, #272, #78). iOS: #645 *„build a static lib for
ios/macos"* wurde als **not planned** geschlossen.
### 7.2 `livekit/rust-sdks` (`libwebrtc`-Crate) — die realistische native Option
`libwebrtc` **0.3.45** (10.08.2026), `livekit` 0.8.3 —
[github.com/livekit/rust-sdks](https://github.com/livekit/rust-sdks), *„LiveKit safe bindings to
libwebrtc"*. README listet **Windows, macOS, Linux, iOS, Android**, mit Mobile-Beispiel. Liefert echtes
libwebrtc: NetEQ, AEC/AGC/NS, Opus, Geräte-Capture, HW-Encoding.
Preis: ein großer vorkompilierter nativer Blob pro Plattform/Architektur plus LiveKits Build-Toolchain.
Das steht in offener Spannung zur Simplizitäts-Doktrin des Projekts.
### 7.3 Der eigentliche Aufwand liegt in der Rust↔WebView-Naht
Geht Audio nativ, ist der WebView **nicht mehr der Medien-Endpunkt**: Mikrofon-Capture in Rust,
Remote-Wiedergabe in Rust (kein `<audio>`), Signaling/State über Tauri-IPC, und für die UI (Pegelanzeige,
Speaking-Indikator) nur heruntergerechnete Metering-Werte über IPC — niemals rohes PCM. Zurück-Injizieren
von PCM in den WebView via WebAudio/`MediaStreamTrackGenerator` geht in Chromium, aber **nicht** in
WebKitGTK — auf Linux müsste der native Pfad die Wiedergabe also komplett besitzen.
Grobe Größenordnung: `livekit/libwebrtc` ≈ Tage bis zum funktionierenden Call mit brauchbarer AEC;
`webrtc-rs` + cpal + opus + APM ≈ Wochen, und man besitzt die Echo-Cancellation-Qualität auf Dauer selbst.
**Empfehlung:** Der native Pfad ist als *Fallback für Linux* diskutabel, nicht als Ersatz für den
WebView-Pfad. Für Mobile ist er unnötig — dort funktioniert der WebView.
### 7.4 Bedrohungsmodell: der Signaling-Server kann DTLS-Fingerprints tauschen
Das ist der wichtigste Sicherheitsbefund für ein Projekt, dessen Betreiber als Angreifer gilt.
**RFC 8827** *(WebRTC Security Architecture, Januar 2021)* benennt den Angriff wörtlich:
> „any on-path attacker can replace the DTLS-SRTP fingerprints in the handshake and thus substitute its
> own identity for either endpoint"
Der Server relayed die SDP. Er kann `a=fingerprint:` austauschen und zwei separate DTLS-Sitzungen
terminieren — vollständiges MITM auf dem Audio, ohne dass irgendein WebRTC-Mechanismus anschlägt.
**Der spezifizierte Gegenmechanismus ist tot.** W3C *Identity for WebRTC 1.0* steht seit **27.09.2018**
unverändert als Candidate Recommendation. MDN-Browser-Compat für `setIdentityProvider` / `peerIdentity` /
`getIdentityAssertion`: Chrome `false`, Safari `false`, **WebView Android `false`**, nur Firefox 40.
⇒ In *keiner* der vier Ziel-Engines verfügbar.
**Was stattdessen zu tun ist** — und was ins Signaling-Protokoll (Ticket 09) gehört:
1. Fingerprint deterministisch vorab bestimmen: `RTCPeerConnection.generateCertificate()` +
`RTCCertificate.getFingerprints()`, statt ihn erst aus `localDescription.sdp` zu lesen.
2. Den Fingerprint **durch den E2EE-Kanal der App** ü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 und bei Änderung warnen.
5. Optional eine **SAS** (Emoji/Wortliste aus dem Hash beider Fingerprints, à la ZRTP/Signal) für den
paranoiden Pfad — die verteidigt zusätzlich gegen einen kompromittierten Identity-Key.
Genau das macht Matrix in MSC3401/Element Call: Call-SDP läuft über `m.room.encrypted` To-Device-Messages,
der Homeserver sieht den Fingerprint nie im Klartext. **Hier ist die eine Stelle, an der das
Voice-Signaling-Protokoll heute schon eine E2EE-Naht braucht** — auch wenn das volle E2EE-Design ein
Folge-Effort ist. Das gehört als Randbedingung in Ticket 09.
### 7.5 IP-Leaks, TURN-Metadaten, CSP
- **`iceTransportPolicy: "relay"`** ([W3C WebRTC 1.0](https://www.w3.org/TR/webrtc/)): *„The ICE Agent
uses only media relay candidates"*; Spec-Note: *„can be used to prevent the remote endpoint from
learning the user's IP addresses"*. Versteckt die IP **vor dem Peer**, nicht vor dem TURN-Server.
Da der Server die IP ohnehin aus der WebSocket-Verbindung kennt, ist `relay` nahezu gratis — es reduziert
strikt die Exposition gegenüber dem *Peer*. Relevant für Ticket 04.
- **mDNS-ICE-Kandidaten** (`.local`): IETF-Draft `draft-ietf-mmusic-mdns-ice-candidates` ist **abgelaufen**
(-03, Juni 2022, nie RFC) — trotzdem überall ausgeliefert: Chromium ab M74 (2019), WebKit ab
[bug 209050](https://bugs.webkit.org/show_bug.cgi?id=209050) (RESOLVED FIXED, März 2020). WebKit ist
strenger als Chromium: Host-Kandidaten werden bis zur Capture-Erlaubnis komplett unterdrückt
([webkit.org/blog/7763](https://webkit.org/blog/7763/a-closer-look-into-webrtc/), 03.07.2017).
Ob die `.local`-Namen im Android-WebView auch *auflösbar* sind (Multicast braucht dort einen
`MulticastLock`), ist ungeprüft — Experiment E6.
- **Was ein feindlicher TURN-Betreiber unter `relay` lernt:** nicht den Klartext (DTLS-SRTP bleibt
Ende-zu-Ende), aber beide öffentlichen IPs, exakt wer-mit-wem, Start/Ende/Dauer, und über Paketgrößen
eine **Voice-Activity-Detection**. Opus läuft per Default variabel-bitratig; das leckt Sprech-/Pausen-Muster.
**Gegenmaßnahme: `cbr=1` (und ggf. `usedtx=0`) in der Opus-`fmtp`-Zeile**, gegen Bandbreite eingetauscht.
Gehört als Notiz zu Ticket 04/09, auch wenn Codecs formal out of scope sind — es ist eine SDP-Frage.
- **CSP kann WebRTC nicht einschränken.** Der `webrtc`-Direktive existiert in
[CSP Level 3](https://www.w3.org/TR/CSP3/) (Working Draft, **13.08.2026**, §6.2.1: *„The `webrtc`
directive controls whether a global object may initiate WebRTC connections"*), ist aber **nirgends
implementiert**: Firefox Bugzilla 1783489 NEW/unassigned, Chromium 40188662 noch im „evaluate"-Stadium,
MDN-Doku-Issue als *not planned* geschlossen. `connect-src` greift bei `RTCPeerConnection` nicht — genau
deshalb brauchte es überhaupt eine eigene Direktive. Ein harter Kill-Switch geht nur JS-seitig
(Konstruktor vor dem App-Code entfernen).
### 7.6 WebView-spezifische Eigenheit: wry vergibt Mikrofonrechte ohne App-Beteiligung
Aus §3.3 und §4.2 folgt eine Eigenschaft, die man kennen sollte:
- **iOS/macOS (wry 0.55.1):** `WKPermissionDecision::Grant`, **bedingungslos**, für jeden Origin und
jeden Frame. Es gibt in dieser Version keinen Hook, um das zu verhindern.
- **Android (wry 0.55.1/0.56):** `RESOURCE_AUDIO_CAPTURE` steht in der Menge der bekannten, automatisch
weitergereichten Ressourcen.
Solange das Frontend ausschließlich gebündelter, eigener Code auf `tauri://localhost` bzw.
`http://tauri.localhost` ist, ist das Risiko gering — der feindliche Server liefert kein Skript aus.
Es wird sofort relevant, sobald irgendwann **remote Inhalte, ein `<iframe>`, eine Vorschau-Einbettung
oder ein `<webview>` für fremde Inhalte** dazukommt: Dann bekäme dieser Origin Mikrofonzugriff, ohne dass
die App gefragt würde. Für eine datenschutzorientierte App ist das eine Regel, die aufgeschrieben gehört:
**keine fremden Origins im selben WebView.**
Ab Tauri 2.12 (wry ≥ 0.56, PR #14865) lässt sich das über `on_permission_request` sauber kontrollieren.
Bis dahin ist die Architektur-Regel der einzige Schutz.
### 7.7 Insertable Streams / SFrame — falls je ein SFU kommt
[W3C WebRTC Encoded Transform](https://www.w3.org/TR/webrtc-encoded-transform/) (Working Draft, 25.06.2026),
inkl. SFrame nach RFC 9605. Support (MDN BCD):
| Engine | `RTCRtpScriptTransform` |
|---|---|
| Safari / WKWebView (iOS) | **15.4** |
| Chrome / Android WebView / WebView2 | **141** (~Okt. 2025) |
| Firefox | 117 |
| Chromium-Legacy `createEncodedStreams()` | 86 (nicht standardisiert, WebKit hat es nie) |
Wichtige Einschränkung für das Bedrohungsmodell: Der SFU behält auch mit E2EE Zugriff auf
RTP-Metadaten — *„synchronization sources, payload types, timestamps, and audio levels"*. Die
`audio-level`-Header-Extension ist ein direktes Voice-Activity-Orakel; sie müsste beim SFU-Einsatz
wegverhandelt werden.
Für die im Ticket 04 zur Debatte stehende **Mesh-Topologie ist das alles irrelevant** — dort ist
DTLS-SRTP schon Ende-zu-Ende. Es wird erst mit einem SFU nötig.
### 7.8 `enumerateDevices()`
Spec-konform sind `label`-Felder vor der Erlaubnis leer; `deviceId`/`groupId` sind aber pro Origin stabile
Hashes, und **Anzahl und Art der Geräte lecken vor der Erlaubnis**. In einer Tauri-App ist das
konstruktionsbedingt kein Angriffsvektor (das Frontend ist eigener Code). Die daraus folgende Regel ist
eine andere: **Geräte-Inventar niemals an den Server senden** — nicht in Telemetrie, nicht in Logs, nicht
in Crash-Reports. Und SDP ist selbst ein starker Fingerabdruck (Codec-Liste, Header-Extensions, `a=msid`,
in Chromium `a=ssrc cname`); wo möglich reduzieren.
---
## 8. Offen — und welches Experiment es entscheidet
Nichts davon blockiert die Spec-Arbeit; alles davon sollte vor der *Implementierung* geklärt sein.
**E1 — iOS: funktioniert `getUserMedia` real auf einem Gerät? (höchste Priorität)**
Ist die einzige Frage, die noch ein offenes Upstream-Issue hat (tauri#12068).
Minimale Tauri-v2-App zweimal bauen — einmal **mit**, einmal **ohne** `NSMicrophoneUsageDescription` —
auf einem **physischen** iPhone (iOS ≥ 15). In beiden Fällen loggen: `typeof navigator.mediaDevices`,
`window.isSecureContext`, `location.origin`, `await navigator.mediaDevices.enumerateDevices()`, dann
`getUserMedia({audio:true})` aus einem Click-Handler. Erwartung: `isSecureContext === true`,
`origin === "tauri://localhost"`, Erfolg nur in der Variante mit Key. Zusätzlich prüfen, ob der Key nach
dem Merge tatsächlich in `gen/apple/*/Info.plist` steht (tauri#13068 warnt vor stillem Überschreiben).
**E2 — Android: ist `MODIFY_AUDIO_SETTINGS` wirklich zwingend?**
Dieselbe App dreimal: nur `RECORD_AUDIO` / nur `MODIFY_AUDIO_SETTINGS` / beide. Erwartung nach
Code-Lesung: nur die dritte Variante liefert einen Stream; die erste bringt `NotAllowedError` *nachdem*
der Nutzer im Dialog zugestimmt hat. Entscheidet zugleich tauri#10846.
**E3 — Überlebt das hand-editierte `AndroidManifest.xml`?**
Auf einem Scratch-Checkout `tauri android init`, Manifest editieren, `tauri android build`, diffen;
dann `tauri android init` erneut und wieder diffen. Erwartung: Build lässt es in Ruhe, `init` rendert neu.
**E4 — Ist `http://tauri.localhost` im Android-WebView ein Secure Context?**
Über `chrome://inspect` in der laufenden App auswerten: `window.isSecureContext` und
`!!window.crypto.subtle`. Beide `true` ⇒ wry#1709 ist widerlegt und `useHttpsScheme` bleibt aus.
**E5 — Haben Arch und Fedora WebKitGTK mit `-DENABLE_WEB_RTC=ON` gebaut?**
Für Debian/Ubuntu und Alpine ist die Antwort belegt (nein). Für Arch/Fedora blockte die Bot-Schranke.
Distro-unabhängiger und definitiver Test auf einer beliebigen Maschine:
`nm -D --defined-only /usr/lib/*/libwebkit2gtk-4.1.so.0 | grep -i rtcpeerconnection`
— oder in einem beliebigen WebKitGTK-View `typeof RTCPeerConnection === 'function'`.
**E6 — Lösen sich mDNS-`.local`-ICE-Kandidaten im Android-WebView und in WKWebView auf?**
Zwei echte Geräte im selben LAN, `pc.onicecandidate` loggen, prüfen ob `typ host`-Kandidaten auf
`.local` enden **und** ob die Verbindung trotzdem zustande kommt.
**E7 — Wann kommt Tauri 2.12?**
Trägt PR #14865 (`on_permission_request`), gemergt 03.08.2026. crates.io `tauri` steht auf 2.11.5.
Relevant für Linux Gate 3 und für §7.6.
---
## 9. Konfigurationsschritte, kompakt
```
src-tauri/Info.ios.plist [NEU]
NSMicrophoneUsageDescription = "…"
src-tauri/gen/android/app/src/main/AndroidManifest.xml [EDITIEREN, committen]
+ android.permission.RECORD_AUDIO
+ android.permission.MODIFY_AUDIO_SETTINGS
+ uses-feature android.hardware.microphone (required=false)
src-tauri/tauri.conf.json [UNVERÄNDERT]
csp: null — passt, CSP betrifft WebRTC ohnehin nicht
useHttpsScheme — nicht setzen, außer E4 fällt negativ aus (dann VOR dem ersten Release)
bundle.iOS.minimumSystemVersion: mindestens "14.3", praktisch "15.0"
Windows [NICHTS ZU TUN]
Linux [NICHT LÖSBAR — siehe §5, Entscheidung in Ticket 04]
```
---
## 10. Quellen
**Apple / WebKit**
- WebKit-Blog, *MediaRecorder API*, 23.11.2020 — https://webkit.org/blog/11353/mediarecorder-api/
- WebKit-Blog, *A Closer Look Into WebRTC*, 03.07.2017 — https://webkit.org/blog/7763/a-closer-look-into-webrtc/
- Apple, `webView(_:requestMediaCapturePermissionFor:…)`, iOS 15.0+ — https://developer.apple.com/documentation/webkit/wkuidelegate/webview(_:requestmediacapturepermissionfor:initiatedbyframe:type:decisionhandler:)
- Apple, `NSMicrophoneUsageDescription` — https://developer.apple.com/documentation/BundleResources/Information-Property-List/NSMicrophoneUsageDescription
- WebKit-Quellcode, `Source/WebCore/page/SecurityOrigin.cpp` — https://github.com/WebKit/WebKit/blob/main/Source/WebCore/page/SecurityOrigin.cpp
- WebKit-Bug 209050, mDNS-Kandidaten in Safari, RESOLVED FIXED (März 2020) — https://bugs.webkit.org/show_bug.cgi?id=209050
**Android / Chromium**
- Android 5.0 Release Notes (WebView → Chromium M37, WebRTC, `PermissionRequest`) — https://developer.android.com/about/versions/android-5.0
- AOSP `WebChromeClient.java` (Default = `request.deny()`) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/webkit/WebChromeClient.java
- AOSP `PermissionRequest.java` — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/webkit/PermissionRequest.java
- AOSP `core/res/AndroidManifest.xml` (Protection Levels) — https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/res/AndroidManifest.xml
- Chromium `net/base/url_util.cc` (`IsNormalizedLocalhostTLD`) — https://chromium.googlesource.com/chromium/src/+/refs/heads/main/net/base/url_util.cc
- Chromium `services/network/public/cpp/is_potentially_trustworthy.cc` — https://chromium.googlesource.com/chromium/src/+/refs/heads/main/services/network/public/cpp/is_potentially_trustworthy.cc
**Microsoft / WebView2**
- `ICoreWebView2PermissionRequestedEventArgs` (Default = `STATE_DEFAULT`; ab 0.9.430) — https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/icorewebview2permissionrequestedeventargs
- WebView2 IDL (`COREWEBVIEW2_PERMISSION_KIND_MICROPHONE`, `…_STATE_DEFAULT`) — https://learn.microsoft.com/en-us/microsoft-edge/webview2/reference/win32/webview2-idl
- WebView2 *Working with local content* (getUserMedia / Secure Context) — https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/working-with-local-content
**WebKitGTK**
- `Source/cmake/OptionsGTK.cmake` (`ENABLE_WEB_RTC` = `${ENABLE_EXPERIMENTAL_FEATURES}`) — https://github.com/WebKit/WebKit/blob/main/Source/cmake/OptionsGTK.cmake
- `Source/cmake/WebKitFeatures.cmake` (`ENABLE_EXPERIMENTAL_FEATURES` = OFF) — https://github.com/WebKit/WebKit/blob/main/Source/cmake/WebKitFeatures.cmake
- `WebKitSettings:enable-webrtc` (Default FALSE, seit 2.38) — https://webkitgtk.org/reference/webkit2gtk/stable/property.Settings.enable-webrtc.html
- `WebKitSettings:enable-media-stream` (Default FALSE laut Doku, Port-Default ON, seit 2.4) — https://webkitgtk.org/reference/webkit2gtk/stable/property.Settings.enable-media-stream.html
- `WebKitUserMediaPermissionRequest` („denied by default") — https://webkitgtk.org/reference/webkit2gtk/stable/class.UserMediaPermissionRequest.html
- `WebKitWebView::permission-request` — https://webkitgtk.org/reference/webkit2gtk/stable/signal.WebView.permission-request.html
- WebKitGTK 2.52 Highlights (librice, Netzwerk-Sandbox), 18.03.2026 — https://webkitgtk.org/2026/03/18/webkitgtk-2.52-highlights.html
- Debian `webkit2gtk` `debian/rules` — https://sources.debian.org/src/webkit2gtk/latest/debian/rules/
- Alpine `community/webkit2gtk-4.1` APKBUILD — https://github.com/alpinelinux/aports/blob/master/community/webkit2gtk-4.1/APKBUILD
**Tauri / wry** (jeweils Tag `wry-v0.55.1`, sofern nicht anders vermerkt)
- `src/wkwebview/class/wry_web_view_ui_delegate.rs` — https://github.com/tauri-apps/wry/blob/wry-v0.55.1/src/wkwebview/class/wry_web_view_ui_delegate.rs
- `src/android/kotlin/RustWebChromeClient.kt` — https://github.com/tauri-apps/wry/blob/wry-v0.55.1/src/android/kotlin/RustWebChromeClient.kt
- `src/webview2/mod.rs` (nur Clipboard-Read) — https://github.com/tauri-apps/wry/blob/wry-v0.55.1/src/webview2/mod.rs
- `src/webkitgtk/mod.rs` (kein `connect_permission_request`) — https://github.com/tauri-apps/wry/blob/wry-v0.55.1/src/webkitgtk/mod.rs
- wry CHANGELOG (0.56.0, 30.07.2026: Permission-API; 0.56.1, 13.08.2026) — https://github.com/tauri-apps/wry/blob/dev/CHANGELOG.md
- wry PR #1654, *expanded permission handling API* — https://github.com/tauri-apps/wry/pull/1654
- `crates/tauri-utils/src/config.rs` (`use_https_scheme`, Default `false`) — https://github.com/tauri-apps/tauri/blob/dev/crates/tauri-utils/src/config.rs
- `crates/tauri/src/manager/mod.rs` (`tauri_protocol_url`) — https://github.com/tauri-apps/tauri/blob/dev/crates/tauri/src/manager/mod.rs
- AndroidManifest-Template (nur `INTERNET`) — https://github.com/tauri-apps/tauri/blob/dev/crates/tauri-cli/templates/mobile/android/app/src/main/AndroidManifest.xml
- `examples/api/src-tauri/Info.plist` (WebRTC-Usage-Strings) — https://github.com/tauri-apps/tauri/blob/dev/examples/api/src-tauri/Info.plist
- Tauri PR #14865, `on_permission_request`, gemergt 03.08.2026, Milestone 2.12 — https://github.com/tauri-apps/tauri/pull/14865
- `WebviewWindow::with_webview` — https://docs.rs/tauri/latest/tauri/webview/struct.WebviewWindow.html
- Tauri v2 Prerequisites (webkit2gtk-4.1) — https://v2.tauri.app/start/prerequisites/
- Tauri v2 CSP („only enabled if set") — https://v2.tauri.app/security/csp/
- Tauri v2 Capabilities / Permissions (ACL ≠ OS-Rechte) — https://v2.tauri.app/security/capabilities/ · https://v2.tauri.app/security/permissions/
**Tauri-Issues und -Discussions** (Hinweisgeber, mit Datum und Status)
- tauri#12068 *iOS Microphone Access* — offen, 28.12.2024 — https://github.com/tauri-apps/tauri/issues/12068
- tauri#10846 *Audio Permission Not Working on Android* — 31.08.2024 — https://github.com/tauri-apps/tauri/issues/10846
- tauri#12547 *Linux: access denied, no dialog* — not planned, 28.01.2025 — https://github.com/tauri-apps/tauri/issues/12547
- tauri#13143 *„browser does not support WebRTC"* (Ubuntu 22.04) — not planned/upstream, 05.04.2025 — https://github.com/tauri-apps/tauri/issues/13143
- tauri#13068 *Unify/Document handling of Info.plist files* — offen, 24.03.2025 — https://github.com/tauri-apps/tauri/issues/13068
- tauri#8989 *[feat] expose control over webkit settings* — offen — https://github.com/tauri-apps/tauri/issues/8989
- tauri#5042 / #8979 — WebView2 persistiert „Block" — https://github.com/tauri-apps/tauri/issues/5042
- wry#1195 *getUserMedia/getDisplayMedia prompt on macOS* — offen, 20.03.2024 — https://github.com/tauri-apps/wry/issues/1195
- wry#1709 / #1710 — Secure-Context-Behauptung, **unbelegt**, 01.04.2026 — https://github.com/tauri-apps/wry/issues/1709
- Discussion #8426 *Functional WebRTC in WebkitGTK on Linux!* — 28.01.2024 — https://github.com/tauri-apps/tauri/discussions/8426
- Discussion #12732 *Camera permissions on mobile (Android)* — Feb–Apr 2025 — https://github.com/orgs/tauri-apps/discussions/12732
- Discussion #8323 (FabianLars zu `gen/` im Git) — 01.12.2023 — https://github.com/orgs/tauri-apps/discussions/8323
**Standards**
- RFC 8827, *WebRTC Security Architecture*, Januar 2021 — https://www.rfc-editor.org/rfc/rfc8827.html
- W3C WebRTC 1.0 (`RTCIceTransportPolicy`) — https://www.w3.org/TR/webrtc/
- W3C Identity for WebRTC 1.0, CR 27.09.2018 (praktisch tot) — https://www.w3.org/TR/webrtc-identity/
- W3C WebRTC Encoded Transform, WD 25.06.2026 — https://www.w3.org/TR/webrtc-encoded-transform/
- W3C Secure Contexts — https://w3c.github.io/webappsec-secure-contexts/
- W3C CSP Level 3, WD 13.08.2026 (`webrtc`-Direktive, unimplementiert) — https://www.w3.org/TR/CSP3/
- IETF `draft-ietf-mmusic-mdns-ice-candidates` — abgelaufen, -03 (Juni 2022) — https://datatracker.ietf.org/doc/draft-ietf-mmusic-mdns-ice-candidates/
**Rust-Ökosystem**
- `webrtc` 0.20.2 (11.08.2026) — https://github.com/webrtc-rs/webrtc · https://crates.io/crates/webrtc
- `libwebrtc` 0.3.45 (10.08.2026) / `livekit` 0.8.3 — https://github.com/livekit/rust-sdks · https://crates.io/crates/libwebrtc
- `cpal` 0.18.1 (07.06.2026) — https://crates.io/crates/cpal
- `webrtc-audio-processing` 2.1.0 (13.05.2026) — https://crates.io/crates/webrtc-audio-processing

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
DROP TRIGGER IF EXISTS conversation_events_notify ON conversation_events;
DROP FUNCTION IF EXISTS notify_conversation_event();
DROP TABLE IF EXISTS conversation_events;
DROP INDEX IF EXISTS messages_conversation_seq_idx;
ALTER TABLE messages ALTER COLUMN seq DROP NOT NULL;
ALTER TABLE messages DROP COLUMN IF EXISTS seq;
ALTER TABLE conversations DROP COLUMN IF EXISTS seq;

View File

@@ -0,0 +1,69 @@
-- Realtime-Grundlage: monotoner Cursor je Conversation + Änderungs-Log.
--
-- Warum ein Zähler auf `conversations` und nicht BIGSERIAL auf `messages`:
-- Eine Sequenz vergibt die Nummer beim INSERT, sichtbar wird die Zeile aber erst
-- beim COMMIT. Diese beiden Reihenfolgen können auseinanderlaufen — Transaktion A
-- holt 100, B holt 101 und committet zuerst; ein Leser bei `since = 101` verliert
-- Nachricht 100 für immer. Der Zähler hier wird per UPDATE gezogen und nimmt damit
-- den Zeilen-Lock der Conversation: der zweite Schreiber blockiert bis zum COMMIT
-- des ersten, also ist Commit-Reihenfolge == seq-Reihenfolge.
ALTER TABLE conversations ADD COLUMN seq BIGINT NOT NULL DEFAULT 0;
-- Das Änderungs-Log. Eine Zeile je Mutation — diese Zeile *ist* der Event, der
-- über den WebSocket geht. Deckt auch ab, was keine eigene Nachrichtenzeile hat
-- (Reactions, Attachments, später Presence und Read-Receipts).
CREATE TABLE conversation_events (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
seq BIGINT NOT NULL,
kind TEXT NOT NULL,
target_id UUID NOT NULL,
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (conversation_id, seq)
);
-- Der Primärschlüssel bedient bereits `WHERE conversation_id = $1 AND seq > $2
-- ORDER BY seq` — der Backfill braucht keinen weiteren Index.
-- Unveränderlicher Sortierschlüssel einer Nachricht: die seq ihres
-- Erzeugungs-Events. Damit verliert `created_at` seine Sortierrolle — es ist NOW(),
-- also der Transaktionsstart, weder eindeutig noch commit-geordnet.
ALTER TABLE messages ADD COLUMN seq BIGINT;
-- Bestand nachziehen: nach created_at durchnummerieren, dann den Conversation-
-- Zähler auf das Maximum setzen.
WITH nummeriert AS (
SELECT id, row_number() OVER (PARTITION BY conversation_id ORDER BY created_at, id) AS n
FROM messages
)
UPDATE messages m
SET seq = nummeriert.n
FROM nummeriert
WHERE m.id = nummeriert.id;
UPDATE conversations c
SET seq = COALESCE((SELECT max(m.seq) FROM messages m WHERE m.conversation_id = c.id), 0);
ALTER TABLE messages ALTER COLUMN seq SET NOT NULL;
CREATE UNIQUE INDEX messages_conversation_seq_idx ON messages(conversation_id, seq);
-- Für Bestandsnachrichten werden bewusst KEINE Log-Zeilen nachgetragen: ein Client
-- ohne Cursor bekommt `resync` und lädt über den Historien-Endpunkt, nie über das
-- Log. Das Log braucht also keine Vergangenheit, nur Zukunft.
-- Der Signalpfad. Der Trigger enthält keine Fachlogik — er feuert nur. Damit kann
-- kein künftiger Schreibweg das Benachrichtigen vergessen, und weil NOTIFY
-- transaktional ist, feuert er erst beim COMMIT, also genau dann, wenn die Zeile
-- auch sichtbar wird.
CREATE FUNCTION notify_conversation_event() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'conversation_changed',
json_build_object('conversation', NEW.conversation_id, 'seq', NEW.seq)::text
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER conversation_events_notify
AFTER INSERT ON conversation_events
FOR EACH ROW EXECUTE FUNCTION notify_conversation_event();

View File

@@ -1,26 +1,74 @@
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
Json, Extension, Json,
}; };
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::{model::Message, schema::MessageSchema, AppState};
use super::db_err; use super::db_err;
use crate::realtime::{self, MESSAGE_CREATED, MESSAGE_DELETED, MESSAGE_EDITED};
use crate::{model::Claims, model::Message, schema::MessageSchema, AppState};
// TODO: create/update/delete still trust `user_id` and `conversation_id` from
// the request body and check no membership. Pre-existing, but the realtime
// design leans on these endpoints being the security boundary — close this
// before any instance is exposed.
// Lock ordering: every path that touches both a conversation and one of its
// messages takes the conversation row lock FIRST (that is what `record_event`
// does). Doing it the other way round in one handler and this way round in
// another is how you get deadlocks under concurrent edits.
#[derive(serde::Deserialize)]
pub struct HistoryQuery {
/// Exclusive upper bound, for scrolling back. Omit for the newest page.
pub before: Option<i64>,
pub limit: Option<i64>,
}
#[derive(serde::Serialize)]
pub struct History {
/// The conversation's current seq — what a client adopts as its cursor
/// after a resync.
pub cursor: i64,
pub messages: Vec<Message>,
}
pub async fn list_messages( pub async fn list_messages(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Extension(claims): Extension<Claims>,
Path(conversation_id): Path<Uuid>, Path(conversation_id): Path<Uuid>,
Query(q): Query<HistoryQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
let messages = sqlx::query_as::<_, Message>( let user = super::sync::user_id(&claims)?;
"SELECT * FROM messages WHERE conversation_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC", super::sync::ensure_member(&state.db, conversation_id, user).await?;
let limit = q.limit.unwrap_or(50).clamp(1, 200);
let cursor: i64 = sqlx::query_scalar("SELECT seq FROM conversations WHERE id = $1")
.bind(conversation_id)
.fetch_one(&state.db)
.await
.map_err(db_err)?;
// Ordered by seq, not created_at: created_at is NOW(), i.e. transaction
// start, so under concurrent senders its order can contradict commit order.
// Newest page first, then reversed, so `before` walks backwards.
let mut messages = sqlx::query_as::<_, Message>(
"SELECT * FROM messages
WHERE conversation_id = $1 AND deleted_at IS NULL AND ($2::BIGINT IS NULL OR seq < $2)
ORDER BY seq DESC LIMIT $3",
) )
.bind(conversation_id) .bind(conversation_id)
.bind(q.before)
.bind(limit)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
Ok(Json(messages)) messages.reverse();
Ok(Json(History { cursor, messages }))
} }
pub async fn get_message( pub async fn get_message(
@@ -40,50 +88,121 @@ pub async fn create_message(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(body): Json<MessageSchema>, Json(body): Json<MessageSchema>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
let message = sqlx::query_as::<_, Message>( let mut tx = state.db.begin().await.map_err(db_err)?;
"INSERT INTO messages (conversation_id, user_id, content) VALUES ($1, $2, $3) RETURNING *",
let seq = realtime::record_event(&mut tx, body.conversation_id, MESSAGE_CREATED, body.id)
.await
.map_err(db_err)?;
// The id comes from the client, so a retry after an unclear network error
// arrives as the same insert. DO NOTHING turns that into a no-op rather than
// a duplicate or a 500.
let inserted = sqlx::query_as::<_, Message>(
"INSERT INTO messages (id, conversation_id, user_id, content, seq)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO NOTHING
RETURNING *",
) )
.bind(body.id)
.bind(body.conversation_id) .bind(body.conversation_id)
.bind(body.user_id) .bind(body.user_id)
.bind(&body.content) .bind(&body.content)
.bind(seq)
.fetch_optional(&mut *tx)
.await
.map_err(db_err)?;
match inserted {
Some(message) => {
tx.commit().await.map_err(db_err)?;
Ok((StatusCode::CREATED, Json(message)))
}
None => {
// Roll back so the sequence bump and the log row disappear with it —
// a duplicate send must not advance anybody's cursor.
tx.rollback().await.map_err(db_err)?;
let existing = sqlx::query_as::<_, Message>("SELECT * FROM messages WHERE id = $1")
.bind(body.id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
Ok((StatusCode::CREATED, Json(message))) Ok((StatusCode::OK, Json(existing)))
}
}
} }
pub async fn update_message( pub async fn update_message(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<MessageSchema>, Json(body): Json<UpdateMessageSchema>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
sqlx::query_as::<_, Message>( let mut tx = state.db.begin().await.map_err(db_err)?;
// Read the parent first so the conversation lock is taken before the message
// row — see the lock-ordering note at the top.
let conversation_id: Option<Uuid> =
sqlx::query_scalar("SELECT conversation_id FROM messages WHERE id = $1 AND deleted_at IS NULL")
.bind(id)
.fetch_optional(&mut *tx)
.await
.map_err(db_err)?;
let Some(conversation_id) = conversation_id else {
return Err((StatusCode::NOT_FOUND, "Message not found".into()));
};
realtime::record_event(&mut tx, conversation_id, MESSAGE_EDITED, id)
.await
.map_err(db_err)?;
// `seq` deliberately stays as it was: an edit moves the message in cursor
// space, not in the conversation.
let message = sqlx::query_as::<_, Message>(
"UPDATE messages SET content = $1, updated_at = NOW() "UPDATE messages SET content = $1, updated_at = NOW()
WHERE id = $2 AND deleted_at IS NULL RETURNING *", WHERE id = $2 AND deleted_at IS NULL RETURNING *",
) )
.bind(&body.content) .bind(&body.content)
.bind(id) .bind(id)
.fetch_optional(&state.db) .fetch_one(&mut *tx)
.await .await
.map_err(db_err)? .map_err(db_err)?;
.map(|m| Json(m).into_response())
.ok_or((StatusCode::NOT_FOUND, "Message not found".into())) tx.commit().await.map_err(db_err)?;
Ok(Json(message))
} }
pub async fn delete_message( pub async fn delete_message(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
let res = sqlx::query( let mut tx = state.db.begin().await.map_err(db_err)?;
"UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
) let conversation_id: Option<Uuid> =
sqlx::query_scalar("SELECT conversation_id FROM messages WHERE id = $1 AND deleted_at IS NULL")
.bind(id) .bind(id)
.execute(&state.db) .fetch_optional(&mut *tx)
.await .await
.map_err(db_err)?; .map_err(db_err)?;
if res.rows_affected() > 0 {
Ok(StatusCode::NO_CONTENT.into_response()) let Some(conversation_id) = conversation_id else {
} else { return Err((StatusCode::NOT_FOUND, "Message not found".into()));
Err((StatusCode::NOT_FOUND, "Message not found".into())) };
}
realtime::record_event(&mut tx, conversation_id, MESSAGE_DELETED, id)
.await
.map_err(db_err)?;
sqlx::query("UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL")
.bind(id)
.execute(&mut *tx)
.await
.map_err(db_err)?;
tx.commit().await.map_err(db_err)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(serde::Deserialize)]
pub struct UpdateMessageSchema {
pub content: String,
} }

View File

@@ -7,10 +7,12 @@ pub mod conversation;
pub mod conversation_member; pub mod conversation_member;
pub mod message; pub mod message;
pub mod reaction; pub mod reaction;
pub mod sync;
pub mod user; pub mod user;
pub mod workspace; pub mod workspace;
pub mod workspace_invitation; pub mod workspace_invitation;
pub mod workspace_member; pub mod workspace_member;
pub mod ws;
pub(crate) fn db_err(e: sqlx::Error) -> (StatusCode, String) { pub(crate) fn db_err(e: sqlx::Error) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())

150
src/handlers/sync.rs Normal file
View File

@@ -0,0 +1,150 @@
//! Manifest and backfill — the two endpoints a client uses to catch up.
//!
//! The WebSocket only ever says "conversation X is now at seq N". Everything
//! with content is pulled through here, and authorization lives here too: the
//! event carries no content, so the realtime registry is a delivery index, not a
//! security boundary. These handlers are the boundary.
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Extension, Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use super::db_err;
use crate::{model::Claims, model::Message, AppState};
/// Ticket 06: above this many entities a delta is not worth building — the
/// client is told to resync instead. Tunable without a protocol change.
const DELTA_LIMIT: i64 = 500;
pub(crate) fn user_id(claims: &Claims) -> Result<Uuid, (StatusCode, String)> {
Uuid::parse_str(&claims.sub)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Malformed subject in token".into()))
}
pub(crate) async fn ensure_member(
db: &sqlx::PgPool,
conversation_id: Uuid,
user: Uuid,
) -> Result<(), (StatusCode, String)> {
let member: Option<Uuid> = sqlx::query_scalar(
"SELECT user_id FROM conversation_members WHERE conversation_id = $1 AND user_id = $2",
)
.bind(conversation_id)
.bind(user)
.fetch_optional(db)
.await
.map_err(db_err)?;
// 404 rather than 403: whether a conversation exists is itself information.
member
.map(|_| ())
.ok_or((StatusCode::NOT_FOUND, "Conversation not found".into()))
}
#[derive(Serialize, sqlx::FromRow)]
pub struct ManifestEntry {
pub conversation_id: Uuid,
pub seq: i64,
}
/// One cheap query that answers "what has moved since I was last here" for every
/// conversation at once. Without it a client with forty conversations would fire
/// forty backfills after every reconnect — and reconnects are routine on mobile.
pub async fn sync(
State(state): State<Arc<AppState>>,
Extension(claims): Extension<Claims>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let user = user_id(&claims)?;
let entries = sqlx::query_as::<_, ManifestEntry>(
"SELECT c.id AS conversation_id, c.seq
FROM conversations c
JOIN conversation_members m ON m.conversation_id = c.id
WHERE m.user_id = $1 AND c.deleted_at IS NULL
ORDER BY c.id",
)
.bind(user)
.fetch_all(&state.db)
.await
.map_err(db_err)?;
Ok(Json(entries))
}
#[derive(Deserialize)]
pub struct ChangesQuery {
pub since: i64,
}
/// `resync` is a normal outcome, not an error — it happens on every first
/// connection. Sending it as 4xx would run it through `error_for_status()`
/// reflexes, fill error logs, and let reconnect logic mistake it for a network
/// failure. Hence one 200 with a discriminated body.
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Changes {
Delta {
cursor: i64,
/// Includes tombstones — a client that never sees the soft-deleted row
/// never learns about the deletion.
messages: Vec<Message>,
},
Resync {
cursor: i64,
},
}
pub async fn changes(
State(state): State<Arc<AppState>>,
Extension(claims): Extension<Claims>,
Path(conversation_id): Path<Uuid>,
Query(q): Query<ChangesQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let user = user_id(&claims)?;
ensure_member(&state.db, conversation_id, user).await?;
let cursor: i64 = sqlx::query_scalar("SELECT seq FROM conversations WHERE id = $1")
.bind(conversation_id)
.fetch_one(&state.db)
.await
.map_err(db_err)?;
// One over the limit, so a full page is distinguishable from an overflow.
let targets: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT target_id FROM conversation_events
WHERE conversation_id = $1 AND seq > $2
ORDER BY target_id
LIMIT $3",
)
.bind(conversation_id)
.bind(q.since)
.bind(DELTA_LIMIT + 1)
.fetch_all(&state.db)
.await
.map_err(db_err)?;
if targets.len() as i64 > DELTA_LIMIT {
return Ok(Json(Changes::Resync { cursor }));
}
// Resolved current state, not the events themselves: idempotent, and ten
// edits of one message collapse into one row instead of ten.
// TODO: reactions and attachments once they emit events — today only
// message_* kinds exist, so resolving messages covers every target.
let messages = sqlx::query_as::<_, Message>(
"SELECT * FROM messages WHERE conversation_id = $1 AND id = ANY($2) ORDER BY seq",
)
.bind(conversation_id)
.bind(&targets)
.fetch_all(&state.db)
.await
.map_err(db_err)?;
Ok(Json(Changes::Delta { cursor, messages }))
}

204
src/handlers/ws.rs Normal file
View File

@@ -0,0 +1,204 @@
//! The realtime socket.
//!
//! Carries only `{conversation, seq}` downstream — no content ever crosses it,
//! which is what lets the REST handlers stay the single authorization boundary.
//!
//! Auth rides in on the normal `Authorization: Bearer` header, because the
//! client owns this connection from Rust rather than from a WebView. That also
//! means real HTTP status codes reach the client on a rejected handshake; a
//! browser would only ever see close code 1006.
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade},
extract::State,
http::StatusCode,
response::IntoResponse,
Extension,
};
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use uuid::Uuid;
use super::db_err;
use crate::hub::{close, Connection, MAX_CONNECTIONS_PER_USER};
use crate::{model::Claims, AppState};
/// The client pings; we only answer and remember. One direction serves both
/// sides: the client notices a dead link from the missing pong, we notice it
/// from the missing ping. 15s keeps us under the strictest documented proxy idle
/// timeout (Azure Application Gateway, 20s).
const HEARTBEAT: Duration = Duration::from_secs(15);
const HEARTBEAT_GRACE: i64 = 2;
/// How often we check the two deadlines. Cheap, so it can be coarse.
const TICK: Duration = Duration::from_secs(5);
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Inbound {
/// The client refreshed its access token over REST and hands it in here, so
/// the session survives without a reconnect. At 15 minutes of token life and
/// several connected servers, forced reconnects would otherwise be constant.
Reauth { token: String },
}
pub async fn handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Extension(claims): Extension<Claims>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let user_id = super::sync::user_id(&claims)?;
let conversations: Vec<Uuid> =
sqlx::query_scalar("SELECT conversation_id FROM conversation_members WHERE user_id = $1")
.bind(user_id)
.fetch_all(&state.db)
.await
.map_err(db_err)?;
// Displace the oldest rather than refuse the newest: on mobile the OS kills
// apps without closing sockets, so refusing would let a dead connection lock
// the user out of their own account.
if state.hub.connection_count(user_id) >= MAX_CONNECTIONS_PER_USER {
if let Some(oldest) = state.hub.oldest_session(user_id) {
state.hub.displace(oldest);
}
}
Ok(ws.on_upgrade(move |socket| {
run(socket, state, user_id, claims.exp as i64, conversations)
}))
}
async fn run(
socket: WebSocket,
state: Arc<AppState>,
user_id: Uuid,
token_exp: i64,
conversations: Vec<Uuid>,
) {
let session = Uuid::new_v4();
let connection = state.hub.register(session, user_id, &conversations);
let (mut sink, stream) = socket.split();
let deadline = Arc::new(AtomicI64::new(token_exp));
let last_seen = Arc::new(AtomicI64::new(now()));
let reader = tokio::spawn(read_loop(
stream,
connection.clone(),
state.clone(),
user_id,
deadline.clone(),
last_seen.clone(),
));
let mut ticker = tokio::time::interval(TICK);
let close_code = loop {
tokio::select! {
outbound = connection.take() => {
let mut failed = false;
for message in outbound {
let encoded = match serde_json::to_string(&message) {
Ok(encoded) => encoded,
Err(e) => { eprintln!("ws: could not encode {message:?}: {e}"); continue }
};
if sink.send(Message::Text(encoded.into())).await.is_err() {
failed = true;
break;
}
}
if failed { break close::RESTART }
}
_ = ticker.tick() => {
if now() >= deadline.load(Ordering::SeqCst) {
break close::TOKEN_EXPIRED;
}
if now() - last_seen.load(Ordering::SeqCst)
> HEARTBEAT.as_secs() as i64 * HEARTBEAT_GRACE
{
break close::RESTART;
}
}
_ = connection.cancel.cancelled() => break connection.close_code(),
}
};
// axum 0.8 dropped `WebSocket::close`, so the frame goes out by hand.
let _ = sink
.send(Message::Close(Some(CloseFrame {
code: close_code,
reason: reason_for(close_code).into(),
})))
.await;
connection.cancel.cancel();
reader.abort();
state.hub.unregister(session);
}
async fn read_loop(
mut stream: futures_util::stream::SplitStream<WebSocket>,
connection: Arc<Connection>,
state: Arc<AppState>,
user_id: Uuid,
deadline: Arc<AtomicI64>,
last_seen: Arc<AtomicI64>,
) {
while let Some(received) = stream.next().await {
let Ok(message) = received else { break };
last_seen.store(now(), Ordering::SeqCst);
match message {
// axum answers Ping itself; seeing it here is what keeps last_seen
// fresh.
Message::Ping(_) | Message::Pong(_) => {}
Message::Close(_) => break,
Message::Text(text) => match serde_json::from_str::<Inbound>(&text) {
Ok(Inbound::Reauth { token }) => {
match verify(&state, &token, user_id) {
Some(exp) => deadline.store(exp, Ordering::SeqCst),
None => {
connection.close_with(close::UNAUTHORIZED);
return;
}
}
}
Err(e) => eprintln!("ws: undecodable frame: {e}"),
},
Message::Binary(_) => {}
}
}
connection.close_with(close::RESTART);
}
/// A refreshed token must belong to the same user — otherwise a session could be
/// walked over to another account without reconnecting.
fn verify(state: &AppState, token: &str, expected: Uuid) -> Option<i64> {
let data = jsonwebtoken::decode::<Claims>(
token,
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&jsonwebtoken::Validation::default(),
)
.ok()?;
(Uuid::parse_str(&data.claims.sub).ok()? == expected).then_some(data.claims.exp as i64)
}
fn reason_for(code: u16) -> &'static str {
match code {
close::TOKEN_EXPIRED => "token expired",
close::DISPLACED => "displaced by a newer connection",
close::UNAUTHORIZED => "not authorized",
_ => "server going away",
}
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

325
src/hub.rs Normal file
View File

@@ -0,0 +1,325 @@
//! Fan-out: getting "conversation X moved to seq N" to the right sockets.
//!
//! Two things make this simpler than it looks.
//!
//! First, only the *newest* seq per conversation matters. Delivering 5, then 6,
//! then 7 is equivalent to delivering only 7, because the client fetches current
//! state by cursor either way. So the per-connection buffer is a map, not a
//! queue: a newer value overwrites an older one, there is no overflow, and
//! therefore no overflow error path.
//!
//! Second, the event carries no content — only an id and a number. Delivery is
//! an index, not a security boundary; the REST handlers do the authorization.
//!
//! Delivery goes through Postgres `NOTIFY` even with a single instance. One path
//! instead of two means the tested path is the production path, and running more
//! than one instance needs no second implementation.
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use serde::Serialize;
use sqlx::postgres::PgListener;
use sqlx::PgPool;
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
pub const CHANNEL: &str = "conversation_changed";
/// Ticket 07: generous against the legitimate case — desktop, phone, tablet,
/// second machine — and still a hard ceiling.
pub const MAX_CONNECTIONS_PER_USER: usize = 8;
pub type SessionId = Uuid;
/// What the server sends down a socket. Deliberately tiny: about 60 bytes, well
/// under the 8 kB `NOTIFY` payload limit that shaped this protocol.
#[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Outbound {
Changed { conversation: Uuid, seq: i64 },
/// "Re-pull the manifest." Sent when the listener reconnected and events in
/// the gap are simply gone — NOTIFY has no history.
Resync,
}
/// Close codes. The client maps these onto three behaviours: reconnect now,
/// reconnect with backoff, or do not reconnect at all. `DISPLACED` is why the
/// table exists — without an explicit "stay down", a displaced device reconnects
/// and displaces the next one, and two devices push each other around in circles.
pub mod close {
/// Server going down. Reconnect immediately, with jitter.
pub const RESTART: u16 = 1012;
/// No valid token any more. Refresh, then reconnect; if the refresh fails,
/// send the user to sign-in.
pub const TOKEN_EXPIRED: u16 = 4001;
/// A newer connection took this slot. Do not reconnect.
pub const DISPLACED: u16 = 4002;
/// Account gone or not permitted. Do not reconnect.
pub const UNAUTHORIZED: u16 = 4003;
}
pub struct Connection {
pub user_id: Uuid,
pub cancel: CancellationToken,
/// Monotonic registration order, so "displace the oldest" means the oldest
/// and not merely some deterministic pick.
registered: u64,
close_code: AtomicU16,
/// conversation -> highest seq not yet written to the socket.
dirty: Mutex<HashMap<Uuid, i64>>,
resync: AtomicBool,
signal: Notify,
}
impl Connection {
pub fn close_code(&self) -> u16 {
self.close_code.load(Ordering::SeqCst)
}
pub fn close_with(&self, code: u16) {
self.close_code.store(code, Ordering::SeqCst);
self.cancel.cancel();
}
/// Blocks until there is something to send, then hands over everything at
/// once — coalescing falls out of the map for free.
pub async fn take(&self) -> Vec<Outbound> {
loop {
{
let mut dirty = self.dirty.lock().unwrap();
let resync = self.resync.swap(false, Ordering::SeqCst);
if resync || !dirty.is_empty() {
let mut out = Vec::with_capacity(dirty.len() + 1);
if resync {
// Ordered first: the client throws its cache away, so
// anything queued behind it would be redundant anyway.
out.push(Outbound::Resync);
}
for (conversation, seq) in dirty.drain() {
out.push(Outbound::Changed { conversation, seq });
}
return out;
}
}
self.signal.notified().await;
}
}
fn mark(&self, conversation: Uuid, seq: i64) {
let mut dirty = self.dirty.lock().unwrap();
// max, not insert: NOTIFY guarantees ordering only per listener, so a
// blind overwrite could replace a newer seq with an older one.
dirty
.entry(conversation)
.and_modify(|held| *held = (*held).max(seq))
.or_insert(seq);
drop(dirty);
self.signal.notify_one();
}
fn mark_resync(&self) {
self.resync.store(true, Ordering::SeqCst);
self.signal.notify_one();
}
}
#[derive(Default)]
pub struct Hub {
next_registration: AtomicU64,
connections: RwLock<HashMap<SessionId, Arc<Connection>>>,
by_conversation: RwLock<HashMap<Uuid, HashSet<SessionId>>>,
/// Needed because a *join* cannot be routed by conversation — the joining
/// user is not in that set yet, which is the whole point of the join.
by_user: RwLock<HashMap<Uuid, HashSet<SessionId>>>,
}
impl Hub {
pub fn connection_count(&self, user_id: Uuid) -> usize {
self.by_user
.read()
.unwrap()
.get(&user_id)
.map_or(0, |s| s.len())
}
/// The oldest session of this user, for the connection limit. Displacing the
/// oldest rather than refusing the newest matters on mobile, where the OS
/// kills apps without closing sockets — otherwise a stale connection locks
/// the user out of their own account.
pub fn oldest_session(&self, user_id: Uuid) -> Option<SessionId> {
let by_user = self.by_user.read().unwrap();
let sessions = by_user.get(&user_id)?;
let connections = self.connections.read().unwrap();
sessions
.iter()
.filter_map(|s| connections.get(s).map(|c| (c.registered, *s)))
.min()
.map(|(_, session)| session)
}
pub fn register(
&self,
session: SessionId,
user_id: Uuid,
conversations: &[Uuid],
) -> Arc<Connection> {
let connection = Arc::new(Connection {
user_id,
cancel: CancellationToken::new(),
registered: self.next_registration.fetch_add(1, Ordering::SeqCst),
close_code: AtomicU16::new(close::RESTART),
dirty: Mutex::new(HashMap::new()),
resync: AtomicBool::new(false),
signal: Notify::new(),
});
self.connections
.write()
.unwrap()
.insert(session, connection.clone());
self.by_user
.write()
.unwrap()
.entry(user_id)
.or_default()
.insert(session);
let mut by_conversation = self.by_conversation.write().unwrap();
for conversation in conversations {
by_conversation
.entry(*conversation)
.or_default()
.insert(session);
}
connection
}
/// Dropped connections are the normal case, not the exception — leaking
/// registry entries is the classic bug here.
pub fn unregister(&self, session: SessionId) {
let removed = self.connections.write().unwrap().remove(&session);
let Some(connection) = removed else { return };
let mut by_user = self.by_user.write().unwrap();
if let Some(sessions) = by_user.get_mut(&connection.user_id) {
sessions.remove(&session);
if sessions.is_empty() {
by_user.remove(&connection.user_id);
}
}
drop(by_user);
let mut by_conversation = self.by_conversation.write().unwrap();
by_conversation.retain(|_, sessions| {
sessions.remove(&session);
!sessions.is_empty()
});
}
pub fn displace(&self, session: SessionId) {
if let Some(connection) = self.connections.read().unwrap().get(&session) {
connection.close_with(close::DISPLACED);
}
}
/// Close every socket with 1012 so clients reconnect promptly instead of
/// waiting for a timeout. Needed because axum's graceful shutdown does not
/// wait for — or close — upgraded connections (axum#3003, PR #3831 open).
pub fn close_all(&self) {
for connection in self.connections.read().unwrap().values() {
connection.close_with(close::RESTART);
}
}
fn publish(&self, conversation: Uuid, seq: i64) {
let by_conversation = self.by_conversation.read().unwrap();
let Some(sessions) = by_conversation.get(&conversation) else {
return;
};
let connections = self.connections.read().unwrap();
for session in sessions {
if let Some(connection) = connections.get(session) {
connection.mark(conversation, seq);
}
}
}
fn resync_everyone(&self) {
for connection in self.connections.read().unwrap().values() {
connection.mark_resync();
}
}
}
#[derive(serde::Deserialize)]
struct Notification {
conversation: Uuid,
seq: i64,
}
/// Listens to Postgres and feeds the hub. Runs for the life of the process.
///
/// `try_recv` returning `Ok(None)` means sqlx transparently re-established the
/// connection — and notifications sent during that gap are gone for good, since
/// NOTIFY keeps no history. Every client is therefore told to resync, which is
/// cheap and rare, and closes the one hole this design would otherwise have.
pub async fn run_listener(pool: PgPool, hub: Arc<Hub>, cancel: CancellationToken) {
loop {
let mut listener = match PgListener::connect_with(&pool).await {
Ok(listener) => listener,
Err(e) => {
eprintln!("listener: could not connect: {e}");
if wait_or_cancel(&cancel).await {
return;
}
continue;
}
};
if let Err(e) = listener.listen(CHANNEL).await {
eprintln!("listener: LISTEN failed: {e}");
if wait_or_cancel(&cancel).await {
return;
}
continue;
}
loop {
tokio::select! {
_ = cancel.cancelled() => return,
received = listener.try_recv() => match received {
Ok(Some(notification)) => {
match serde_json::from_str::<Notification>(notification.payload()) {
Ok(n) => hub.publish(n.conversation, n.seq),
Err(e) => eprintln!("listener: undecodable payload: {e}"),
}
}
Ok(None) => {
eprintln!("listener: reconnected — asking every client to resync");
hub.resync_everyone();
}
Err(e) => {
eprintln!("listener: {e}");
break;
}
},
}
}
if wait_or_cancel(&cancel).await {
return;
}
}
}
/// Returns true if shutdown was requested while waiting.
async fn wait_or_cancel(cancel: &CancellationToken) -> bool {
tokio::select! {
_ = cancel.cancelled() => true,
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => false,
}
}

View File

@@ -3,8 +3,10 @@ use std::sync::Arc;
use axum::Router; use axum::Router;
mod handlers; mod handlers;
mod hub;
mod middleware; mod middleware;
mod model; mod model;
mod realtime;
mod route; mod route;
mod schema; mod schema;
@@ -14,6 +16,7 @@ use sqlx::{PgPool, postgres::PgPoolOptions};
pub struct AppState { pub struct AppState {
pub db: PgPool, pub db: PgPool,
pub jwt_secret: String, pub jwt_secret: String,
pub hub: Arc<hub::Hub>,
} }
#[tokio::main] #[tokio::main]
@@ -42,12 +45,41 @@ async fn main() {
let port: String = std::env::var("API_PORT").expect("API_PORT must be set!"); let port: String = std::env::var("API_PORT").expect("API_PORT must be set!");
let addr: String = format!("{}:{}", ip_addr, port); let addr: String = format!("{}:{}", ip_addr, port);
let state = Arc::new(AppState { db: pool, jwt_secret }); let state = Arc::new(AppState {
db: pool.clone(),
jwt_secret,
hub: Arc::new(hub::Hub::default()),
});
// Fan-out always goes through Postgres NOTIFY, even with a single instance:
// one path means the tested path is the production path, and running more
// than one instance later needs no second implementation.
let shutdown = tokio_util::sync::CancellationToken::new();
tokio::spawn(hub::run_listener(
pool,
state.hub.clone(),
shutdown.clone(),
));
let app: Router = Router::new() let app: Router = Router::new()
.nest("/api/v1", route::router(state.clone())) .nest("/api/v1", route::router(state.clone()))
.with_state(state); .with_state(state.clone());
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
println!("Server started successfully at {}", addr); println!("Server started successfully at {}", addr);
axum::serve(listener, app).await.unwrap();
// axum's graceful shutdown neither closes nor waits for upgraded
// connections (axum#3003; fix PR #3831 still open), so the sockets are
// closed here by hand before the runtime goes away.
let closing = state.clone();
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = tokio::signal::ctrl_c().await;
println!("Shutting down — closing realtime connections");
closing.hub.close_all();
shutdown.cancel();
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
})
.await
.unwrap();
} }

View File

@@ -147,6 +147,10 @@ pub struct Message {
pub conversation_id: Uuid, pub conversation_id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub content: String, pub content: String,
/// Immutable sort key within the conversation. Ordering follows this, never
/// `created_at` — that is NOW(), i.e. transaction start, so it is neither
/// unique nor commit-ordered.
pub seq: i64,
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>, pub updated_at: chrono::DateTime<chrono::Utc>,
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>, pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,

235
src/realtime.rs Normal file
View File

@@ -0,0 +1,235 @@
//! Cursor allocation and the change log.
//!
//! Everything that mutates a conversation goes through `record_event`. It bumps
//! the conversation's counter and writes the log row in one step, so you cannot
//! get a sequence number without also emitting the event that goes with it.
//!
//! The `UPDATE ... RETURNING` is what makes the cursor safe: it takes the row
//! lock on the conversation, so a second writer blocks until the first commits.
//! Commit order therefore equals seq order, and a reader polling `seq > n` can
//! never skip a row. A plain BIGSERIAL does not have this property — it hands out
//! numbers at INSERT time, but rows become visible at COMMIT time, and those two
//! orders can diverge.
//!
//! Must be called inside the same transaction as the mutation it describes.
use sqlx::PgConnection;
use uuid::Uuid;
pub const MESSAGE_CREATED: &str = "message_created";
pub const MESSAGE_EDITED: &str = "message_edited";
pub const MESSAGE_DELETED: &str = "message_deleted";
pub async fn record_event(
tx: &mut PgConnection,
conversation_id: Uuid,
kind: &str,
target_id: Uuid,
) -> Result<i64, sqlx::Error> {
let seq: i64 =
sqlx::query_scalar("UPDATE conversations SET seq = seq + 1 WHERE id = $1 RETURNING seq")
.bind(conversation_id)
.fetch_one(&mut *tx)
.await?;
// The INSERT fires the pg_notify trigger — but only on COMMIT, since NOTIFY
// is transactional. Listeners therefore never see an event before the row.
sqlx::query(
"INSERT INTO conversation_events (conversation_id, seq, kind, target_id)
VALUES ($1, $2, $3, $4)",
)
.bind(conversation_id)
.bind(seq)
.bind(kind)
.bind(target_id)
.execute(&mut *tx)
.await?;
Ok(seq)
}
#[cfg(test)]
mod tests {
//! Guards the cursor against the gap described above. Needs a running
//! Postgres with the migrations applied:
//!
//! docker compose up -d db && cargo test realtime
//!
//! These tests deliberately panic instead of skipping when DATABASE_URL is
//! missing — a correctness test that quietly passes without running is worse
//! than no test at all.
use super::*;
use sqlx::{postgres::PgPoolOptions, PgPool};
use std::time::Duration;
async fn pool() -> PgPool {
dotenv::dotenv().ok();
let url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set — these tests need a real Postgres");
PgPoolOptions::new()
.max_connections(5)
.connect(&url)
.await
.expect("could not connect to Postgres")
}
/// A fresh conversation with an owner, so the tests do not interfere.
async fn fixture(pool: &PgPool) -> Uuid {
let user: Uuid = sqlx::query_scalar(
"INSERT INTO users (email, password_hash, username, display_name)
VALUES ($1, 'x', $2, 'Probe') RETURNING id",
)
.bind(format!("probe-{}@example.invalid", Uuid::new_v4()))
.bind(format!("probe-{}", Uuid::new_v4()))
.fetch_one(pool)
.await
.unwrap();
sqlx::query_scalar(
"INSERT INTO conversations (kind, name, created_by)
VALUES ('channel', 'cursor probe', $1) RETURNING id",
)
.bind(user)
.fetch_one(pool)
.await
.unwrap()
}
/// Everything visible after `since`, the way the backfill endpoint reads it.
async fn read_since(pool: &PgPool, conversation: Uuid, since: i64) -> Vec<i64> {
sqlx::query_scalar(
"SELECT seq FROM conversation_events
WHERE conversation_id = $1 AND seq > $2 ORDER BY seq",
)
.bind(conversation)
.bind(since)
.fetch_all(pool)
.await
.unwrap()
}
/// Two senders overlap: A starts first and commits last. A reader polling
/// with a cursor in between must still end up seeing both.
#[tokio::test]
async fn concurrent_senders_leave_no_gap() {
let pool = pool().await;
let conversation = fixture(&pool).await;
let mut a = pool.begin().await.unwrap();
let a_seq = record_event(&mut a, conversation, MESSAGE_CREATED, Uuid::new_v4())
.await
.unwrap();
// B runs concurrently and will block on the conversation row lock until
// A commits. That blocking IS the mechanism under test.
let pool_b = pool.clone();
let b = tokio::spawn(async move {
let mut tx = pool_b.begin().await.unwrap();
let seq = record_event(&mut tx, conversation, MESSAGE_CREATED, Uuid::new_v4())
.await
.unwrap();
tx.commit().await.unwrap();
seq
});
// Give B time to actually reach the lock.
tokio::time::sleep(Duration::from_millis(300)).await;
// Nothing is committed yet, so a reader sees nothing — in particular it
// cannot advance its cursor past A.
assert!(
read_since(&pool, conversation, 0).await.is_empty(),
"reader saw an uncommitted event"
);
a.commit().await.unwrap();
let b_seq = b.await.unwrap();
assert_eq!(a_seq, 1, "A committed first, so it must hold the lower seq");
assert_eq!(b_seq, 2);
// Read incrementally, exactly like a client following its cursor.
let mut cursor = 0i64;
let mut seen = Vec::new();
loop {
let batch = read_since(&pool, conversation, cursor).await;
if batch.is_empty() {
break;
}
cursor = *batch.last().unwrap();
seen.extend(batch);
}
assert_eq!(seen, vec![1, 2], "a message was skipped by the cursor");
}
/// The same interleaving against a plain BIGSERIAL — the obvious design.
/// This one MUST show the gap; if it ever stops failing to deliver a row,
/// the test above is no longer proving anything.
#[tokio::test]
async fn naive_sequence_does_lose_a_row() {
let pool = pool().await;
let probe = Uuid::new_v4();
sqlx::query(
"CREATE TABLE IF NOT EXISTS naive_cursor_probe (
seq BIGSERIAL PRIMARY KEY, probe UUID NOT NULL)",
)
.execute(&pool)
.await
.unwrap();
let mut a = pool.begin().await.unwrap();
let a_seq: i64 =
sqlx::query_scalar("INSERT INTO naive_cursor_probe (probe) VALUES ($1) RETURNING seq")
.bind(probe)
.fetch_one(&mut *a)
.await
.unwrap();
// No lock is taken, so B sails past A and gets the higher number.
let mut b = pool.begin().await.unwrap();
let b_seq: i64 =
sqlx::query_scalar("INSERT INTO naive_cursor_probe (probe) VALUES ($1) RETURNING seq")
.bind(probe)
.fetch_one(&mut *b)
.await
.unwrap();
b.commit().await.unwrap();
assert!(a_seq < b_seq);
// The reader polls while A is still open, sees only B, and advances its
// cursor past A's number.
let read = |since: i64| {
let pool = pool.clone();
async move {
sqlx::query_scalar::<_, i64>(
"SELECT seq FROM naive_cursor_probe WHERE probe = $1 AND seq > $2 ORDER BY seq",
)
.bind(probe)
.bind(since)
.fetch_all(&pool)
.await
.unwrap()
}
};
let first = read(0).await;
assert_eq!(first, vec![b_seq]);
let cursor = *first.last().unwrap();
a.commit().await.unwrap();
assert!(
read(cursor).await.is_empty(),
"expected the naive design to lose A — it did not, so this test no longer \
demonstrates the failure it exists to demonstrate"
);
sqlx::query("DROP TABLE naive_cursor_probe")
.execute(&pool)
.await
.ok();
}
}

View File

@@ -1,6 +1,6 @@
use crate::handlers::{ use crate::handlers::{
attachment, auth, auth_token, conversation, conversation_member, message, reaction, user, attachment, auth, auth_token, conversation, conversation_member, message, reaction, sync, user,
workspace, workspace_invitation, workspace_member, workspace, workspace_invitation, workspace_member, ws,
}; };
use crate::middleware::require_auth; use crate::middleware::require_auth;
use crate::AppState; use crate::AppState;
@@ -81,6 +81,13 @@ pub fn router(state: Arc<AppState>) -> Router<Arc<AppState>> {
"/conversations/{conversation_id}/members/{user_id}", "/conversations/{conversation_id}/members/{user_id}",
delete(conversation_member::remove_member), delete(conversation_member::remove_member),
) )
// Realtime. The socket carries only {conversation, seq}; everything with
// content is pulled through the two endpoints below.
.route("/ws", get(ws::handler))
// Catch-up: one cheap manifest for every conversation, then targeted
// backfills for the few that actually moved.
.route("/sync", get(sync::sync))
.route("/conversations/{id}/changes", get(sync::changes))
// Messages // Messages
.route("/conversations/{id}/messages", get(message::list_messages)) .route("/conversations/{id}/messages", get(message::list_messages))
.route("/messages", post(message::create_message)) .route("/messages", post(message::create_message))

View File

@@ -81,6 +81,12 @@ pub struct ConversationMemberSchema {
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct MessageSchema { pub struct MessageSchema {
/// Supplied by the client, not the server. Without it the client cannot
/// reconcile its optimistically rendered message with the one that comes
/// back over the realtime channel — it would show up twice. Bonus: sending
/// becomes idempotent, so a retry after an unclear network error is a no-op
/// instead of a duplicate.
pub id: Uuid,
pub conversation_id: Uuid, pub conversation_id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub content: String, pub content: String,