diff --git a/README.md b/README.md index cfee0a9..4e7fe29 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ crates/tauri-app/ Tauri 2 + Svelte 5 + Vite + TS Desktop-Client └── src-tauri/ Rust-IPC-Layer lexicons/app/twi/post.json Custom Lexicon mit maxLength: 160 +lexicons/app/bsky/ like, repost, follow, actor.profile migrations/pds/ PDS-DB-Schema (users, repos, blobs, sessions, plc_ops) migrations/appview/ AppView-DB-Schema (posts, likes, follows, notifications, profiles, jetstream_cursor) docs/ Deployment, Architektur, Tauri-Release (siehe unten) @@ -69,6 +70,7 @@ cargo run -p appview | 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev inert (Production-Weg: [`docs/tauri-release.md`](docs/tauri-release.md)) | | 8 Social-Graph + Benachrichtigungen | ✅ done — `notifications`-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count|/seen]`, `/api/followers`, `/api/following`, eigene `/api/thread`-Route; im Client Notifications-View mit Unread-Badge und klickbare Follower-/Following-Listen im Profil | | 9 Auth + Performance | ✅ done — AppView prüft Bearer-Tokens (ES256, Schlüssel aus dem neuen `/.well-known/did.json` der PDS, fail closed); Timeline und Notifications nur noch für die eigene DID; CORS-Allowlist statt `Any`; Indizes für Handle-Lookup und Cold-Start-Feed | +| 10 Lokaler Firehose | ✅ done — `com.atproto.sync.subscribeRepos` auf der PDS (Event in derselben Transaktion wie der Commit, `seq`-Cursor mit lückenfreiem Replay, WebSocket-Frames in atproto-Form); die AppView konsumiert ihn mit persistiertem Cursor. Ein verlorener Push ist damit nicht mehr endgültig. | ## Tests @@ -103,11 +105,14 @@ Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht. ## Bekannte Lücken -* Die eigene PDS speist **keinen** Firehose (`com.atproto.sync.subscribeRepos` fehlt) — - eigene Records erreichen die AppView nur über den Best-Effort-Push - `POST /internal/ingest-commit`. -* `aud` wird beim Token-Check nicht validiert (Signatur, Ablauf, `scope` und - `sub` schon). +* Der Firehose ist **lokal**: er verbindet die eigenen zwei Dienste. Ein fremder + Relay erfährt von dieser PDS weiterhin nichts. +* Die Frame-Hülle ist spec-konformes DAG-CBOR, die Blöcke darin nicht: CIDs + innerhalb von Commit-Blöcken sind Strings statt Tag-42-Links. Ein fremder + atproto-Consumer liest die Frames, scheitert aber an den Blockinhalten. Das + zu ändern hieße, jede CID im System zu ändern — inklusive der + `did:plc:`-Ableitung. +* `firehose_events` wird nie beschnitten. * Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer". * Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das diff --git a/docs/architecture.md b/docs/architecture.md index 27c9d46..083bb4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,8 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück. │ /healthz │ │ GET /api/post|thread/*uri │ │ /.well-known/did.json ──────────┼───┼─▶ Schlüssel für 🔒 │ │ │ │ GET /api/notifications… 🔒 │ - │ │ │ GET /api/followers|following│ + │ /xrpc/…sync.subscribeRepos ─────┼───┼─▶ pds_firehose.rs (WS) │ + │ (WebSocket, seq-Cursor) │ │ GET /api/followers|following│ │ │ │ GET /healthz │ │ at-lexicon Validierung (160) │ │ │ │ at-repo/at-mst MST + Commit │ │ indexer.rs Upserts │ @@ -41,13 +42,13 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück. ┌───────────┐ ┌────────┐ ┌─────────────────┐ │ │ Postgres │ │ MinIO │ │ Postgres │ │ │ pds :5434 │ │ :9100 │ │ appview :5435 │ │ - └───────────┘ └────────┘ └─────────────────┘ │ - │ │ - │ (heute: kein eigener Firehose-Ausgang) │ - ▼ │ + │ +firehose │ └────────┘ │ +cursor │ │ + │ _events │ └─────────────────┘ │ + └───────────┘ │ + │ ┌──────────────────────────────────────────┐ │ │ Jetstream-Relay (extern, WebSocket) │──────────────────┘ - │ JETSTREAM_URL │ at-firehose + │ JETSTREAM_URL — kennt diese PDS nicht │ at-firehose └──────────────────────────────────────────┘ JetstreamConsumer ``` @@ -56,27 +57,46 @@ entsprechen. Die AppView verifiziert die ES256-Signatur mit dem öffentlichen Schlüssel, den die PDS in ihrem DID-Dokument veröffentlicht — `PDS_JWT_SECRET` verlässt die PDS nie. Details in [`deployment.md`](deployment.md), Abschnitt 6. -Zwei Wege führen in die AppView, und das ist Absicht: +Drei Wege führen in die AppView, und das ist Absicht: 1. **Direkter Push (schnell, lokal).** Jeder erfolgreiche Commit auf der PDS wird per `POST /internal/ingest-commit` an die AppView geschoben (`crates/pds-server/src/appview_push.rs`). Best effort, 5 s Timeout, blockiert den Record-Write nie. Damit sieht der Nutzer seinen eigenen Post sofort. -2. **Jetstream (global, verzögert).** `at-firehose::JetstreamConsumer` hängt an +2. **PDS-Firehose (lokal, garantiert).** Die PDS führt in derselben + Transaktion wie den Commit ein Event in `firehose_events` und liefert es + über `com.atproto.sync.subscribeRepos` als WebSocket aus + (`crates/pds-server/src/firehose.rs`). Die AppView konsumiert das mit + persistiertem Cursor (`crates/appview/src/pds_firehose.rs`). +3. **Jetstream (global, verzögert).** `at-firehose::JetstreamConsumer` hängt an einem externen Jetstream-Relay und liefert alles, was in den konfigurierten Collections weltweit passiert. -Wichtig für das Verständnis der Topologie: **die eigene PDS speist den -Jetstream nicht.** Es gibt keinen `com.atproto.sync.subscribeRepos`-Endpoint im -PDS-Router. Der Firehose-Weg ist ein reiner Konsum-Pfad für fremde Repos; die -eigenen Records erreichen die AppView ausschließlich über den Push aus -Punkt 1 (noch offen). +Warum 1 **und** 2: Der Push ist der schnelle Weg, der Firehose der +verlässliche. Ein verlorener Push (AppView kurz weg, Netzwerkfehler) war +früher endgültig — der öffentliche Jetstream kennt diese PDS nicht, also +wäre der Post nie angekommen. Jetzt holt der Cursor-Replay ihn nach. +Dass beide Wege denselben Commit liefern, ist unkritisch: die Indexer-Pfade +sind Upserts, und der Dedupe-Index der Notifications fängt den Rest. + +Wichtig für das Verständnis der Topologie bleibt: **die eigene PDS speist den +*öffentlichen* Jetstream nicht.** Weg 2 ist ein lokaler Firehose zwischen den +eigenen zwei Diensten; ein fremder Relay erfährt von dieser PDS weiterhin +nichts. + +Zur Spec-Treue: Die Frame-Hülle ist konformes DAG-CBOR mit Tag-42-CID-Links. +Die Blöcke *darin* tragen die Konvention dieses Codebases — CIDs innerhalb von +Commit-Blöcken sind Strings, nicht Links (`at-repo/src/commit.rs`). Ein +fremder atproto-Consumer kann die Frames also lesen, scheitert aber beim +Validieren der Blockinhalte. Das zu ändern hieße, die Blockkodierung zu +ändern, und damit ändern sich sämtliche CIDs inklusive der +`did:plc:`-Ableitung — eine eigene, bewusste Migration. ## Crates | Crate | Typ | Aufgabe | |---|---|---| -| `at-lexicon` | lib | Lexicon-Schemas laden (`Lex::from_json`) und Records validieren. `LexRegistry` in der PDS kennt `app.twi.post` (160 Zeichen), `app.bsky.feed.like`, `app.bsky.feed.repost`, `app.bsky.actor.profile` — alle vier per `include_str!` einkompiliert | +| `at-lexicon` | lib | Lexicon-Schemas laden (`Lex::from_json`) und Records validieren. `LexRegistry` in der PDS kennt `app.twi.post` (160 Zeichen), `app.bsky.feed.like`, `app.bsky.feed.repost`, `app.bsky.graph.follow`, `app.bsky.actor.profile` — alle per `include_str!` einkompiliert | | `at-crypto` | lib | secp256k1/P-256-Keypairs, DAG-CBOR-CIDs, multibase/base58btc, JWT (`issue_jwt` / `verify_jwt`), PLC-Operationen inkl. `did_plc_from_op` | | `at-identity` | lib | Handle- und DID-Auflösung. Drei Resolver hinter dem Trait `DidHandleResolver`: `PlcClient` (PLC-Directory), `WebResolver` (`.well-known/did.json`), `PdsHandleResolver` (fragt die lokale PDS) | | `at-mst` | lib | Merkle-Search-Tree: Knoten, `split_around`, `wrap_with_split`, spec-konformes `encode_key` | @@ -164,9 +184,11 @@ Alle Schreibpfade sind Upserts, das Replay nach einem Reconnect ist damit unschädlich. Fehlerhafte Events rücken den Cursor **nicht** vor. `ingest.rs` bedient denselben Indexer über HTTP, mit den Aktionen -`create` / `delete`; für `app.bsky.graph.follow`-Deletes braucht der Aufrufer -`subject_did` im Body, weil der Record-Wert bei Deletes nicht garantiert -mitkommt. +`create` / `delete`. Für `app.bsky.graph.follow`-Deletes gibt es zwei Wege: +der Push schickt `subject_did` im Body mit, der Firehose kennt nur `did` + +`rkey` — deshalb speichert `follows` seit Migration 0011 den rkey des +Follow-Records und löst darüber auf. Ohne den rkey war ein Unfollow über den +Firehose nicht anwendbar und hing allein am Push. ## Datenbanken und Tabellen diff --git a/docs/deployment.md b/docs/deployment.md index d2b22cf..38c8090 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -287,7 +287,7 @@ Hinweise: * Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop` beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird — - praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 9). Das ist + praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 10). Das ist unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit zeigt und Events idempotent verarbeitet werden. * Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf @@ -344,7 +344,33 @@ Instanz hinter VPN und für die fail-open-Integrationstests. Die AppView warnt beim Start in Großbuchstaben. Öffentlich erreichbar heißt das: jeder kann die Notifications jeder DID lesen und als gelesen markieren. -## 7. Reverse-Proxy +## 7. Firehose + +Die PDS liefert `com.atproto.sync.subscribeRepos` als WebSocket aus, die +AppView konsumiert ihn. Betrieblich wichtig: + +* **Das Event liegt in derselben Transaktion wie der Commit.** Es kann keinen + Commit ohne Event geben und umgekehrt. +* **Die `seq` ist lückenfrei.** Ein globaler `pg_advisory_xact_lock` sorgt + dafür, dass Commit-Reihenfolge und `seq`-Reihenfolge übereinstimmen — sonst + könnte ein Consumer eine Nummer überspringen, die erst danach sichtbar wird, + und sie nie nachholen. Preis: das Ende jeder schreibenden Transaktion ist + über alle Accounts hinweg serialisiert. +* **Cursor:** `?cursor=` liefert alles mit `seq > cursor` aus der + Datenbank nach und geht dann nahtlos live weiter. Ohne Cursor nur live. Ein + Cursor aus der Zukunft ist ein Fehler-Frame, ein zu alter ein + `#info`/`OutdatedCursor`. +* **`firehose_events` wächst unbegrenzt.** Es gibt keine Retention. Beschneiden + ist sicher, weil ein zu alter Cursor sauber behandelt wird — wer die Tabelle + aufräumt, sollte aber wissen, wie weit die eigenen Consumer zurückhängen + dürfen (`pds_firehose_seq` in `/healthz` der AppView gegen `MAX(seq)`). +* **Reverse-Proxy:** die Route braucht ein WebSocket-Upgrade (`Upgrade`/ + `Connection`-Header durchreichen) und einen Read-Timeout, der längere + Ruhephasen überlebt. +* `PDS_FIREHOSE_ENABLED=false` schaltet den Consumer in der AppView ab; lokale + Commits hängen dann wieder allein am Best-Effort-Push. + +## 8. Reverse-Proxy ### PDS @@ -462,7 +488,7 @@ HTTP-Aufrufe an PDS/AppView laufen über den Rust-IPC-Layer (`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview — die CSP muss also für neue Backend-URLs nicht angefasst werden. -## 8. Health-Checks und Logs +## 9. Health-Checks und Logs ### PDS @@ -526,7 +552,7 @@ Log-Zeilen, auf die es sich lohnt zu achten: | `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar | | `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert | -## 9. Neustart-Verhalten +## 10. Neustart-Verhalten **PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore (`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent @@ -569,7 +595,7 @@ nach — zuerst über die lokale PDS (`PdsHandleResolver`, 2 s Timeout), dann PL bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der Zustand ist reine Anzeigekosmetik. -## 10. Was noch offen ist +## 11. Was noch offen ist * Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur Postgres und MinIO ab. Es gibt kein Dockerfile im Repo. @@ -581,3 +607,10 @@ Zustand ist reine Anzeigekosmetik. einsetzt. Signatur, Ablauf, `scope` und `sub` werden geprüft. * Notifications werden nie gelöscht; ein Unlike/Unfollow lässt die Zeile stehen. * Kein Backfill-Werkzeug für Jetstream-Lücken. +* **Keine Retention für `firehose_events`.** Die Tabelle wächst mit jedem + Commit und wird nie beschnitten. Pruning ist sicher — ein Consumer mit zu + altem Cursor bekommt `#info`/`OutdatedCursor` und läuft ab der ältesten + überlebenden Zeile weiter — aber es gibt weder Job noch Policy dafür. +* Der globale Advisory-Lock, der die `seq`-Vergabe ordnet, serialisiert das + Ende jeder schreibenden Transaktion über alle Accounts hinweg. Das + begrenzt den Schreibdurchsatz auf ein COMMIT nach dem anderen.