docs: Deployment-, Architektur- und Release-Doku; README aktualisiert

docs/ war leer. Jetzt drei Dateien, jede Behauptung am Code verifiziert:

* deployment.md — docker compose, Migrationsweg, vollständige Env-Referenz
  mit den Fallstricken (P-256-taugliches PDS_JWT_SECRET, Pflicht-aber-tot
  S3_BUCKET_APPVIEW), Release-Build, systemd-Units, Reverse-Proxy inkl. des
  Hinweises, dass die AppView CORS Any liefert und der Proxy den Header
  ersetzen statt ergänzen muss, Health-Checks und Cursor-Verhalten beim
  Neustart.
* architecture.md — Crate-Verantwortlichkeiten, ASCII-Datenfluss, beide
  DB-Schemata. Hält fest, was die Topologie erklärt: die eigene PDS speist
  keinen Firehose, eigene Records erreichen die AppView nur über den
  Best-Effort-Push.
* tauri-release.md — Signing-Keys, v2-Updater-Config, latest.json, Build pro
  Plattform. Der _comment in tauri.conf.json war irreführend: active/dialog
  sind v1-Reste, die der v2-Updater ignoriert; dass nichts passiert, liegt
  daran, dass niemand check() aufruft und das Plugin nicht installiert ist.

README bekommt Phase 8, eine Doku-Übersicht, korrigierte Testanleitung
(src-tauri ist ein eigener Workspace und wird von cargo test --workspace
nicht erfasst) und einen Abschnitt "Bekannte Lücken" statt der bisher
lückenlosen Erfolgsmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-09 21:37:12 +02:00
co-authored by Claude Opus 5
parent 31880e1005
commit ec8fe187fe
4 changed files with 1137 additions and 18 deletions
+247
View File
@@ -0,0 +1,247 @@
# Architektur
Vertiefung zum Überblick im README: wer schreibt was, wo liegen die Daten, und
auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
## Prozesse und Datenflüsse
```
┌───────────────────────────────────┐
│ Tauri-Client (Desktop) │
│ Svelte 5 (Webview) │
│ │ invoke() │
│ src-tauri (Rust-IPC) │
│ pds_client.rs appview_client.rs│
└───────┬───────────────────┬───────┘
Schreiben │ │ Lesen
XRPC/HTTPS │ │ REST/HTTP
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────┐
│ pds-server (axum, :2583) │ │ appview (axum, :2584) │
│ │ │ │
│ /xrpc/com.atproto.* │ │ GET /api/timeline/home │
│ /xrpc/app.bsky.actor.profile.* │ │ GET /api/profile[/:handle] │
│ /blob/:cid │ │ GET /api/search │
│ /healthz │ │ GET /api/post|thread/*uri │
│ │ │ GET /api/notifications… │
│ │ │ GET /api/followers|following│
│ │ │ GET /healthz │
│ at-lexicon Validierung (160) │ │ │
│ at-repo/at-mst MST + Commit │ │ indexer.rs Upserts │
│ at-crypto Signatur, CID, JWT │ │ handle_sync.rs Worker │
│ at-identity PLC / did:web │ │ ingest.rs interner Writer │
│ at-blob MinIO │ │ │
└───┬────────────┬───────────┬─────┘ └──────┬──────────────┬────────┘
│ │ │ │ ▲
│ │ │ POST /internal/ingest-commit │
│ │ └───────────────────────────────┤
│ │ │ │
▼ ▼ ▼ │
┌───────────┐ ┌────────┐ ┌─────────────────┐ │
│ Postgres │ │ MinIO │ │ Postgres │ │
│ pds :5434 │ │ :9100 │ │ appview :5435 │ │
└───────────┘ └────────┘ └─────────────────┘ │
│ │
│ (heute: kein eigener Firehose-Ausgang) │
▼ │
┌──────────────────────────────────────────┐ │
│ Jetstream-Relay (extern, WebSocket) │──────────────────┘
│ JETSTREAM_URL │ at-firehose
└──────────────────────────────────────────┘ JetstreamConsumer
```
Zwei 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
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).
## 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-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` |
| `at-repo` | lib | Repo-Abstraktion über einem `Blockstore`, Commit-Erzeugung + Signatur, TID-Revisionen (`rev.rs`) |
| `at-blob` | lib | `S3BlobStore` gegen MinIO (unsignierte PUT/GET/DELETE) und `InMemoryBlobStore` (gleiche CID-Semantik, für Tests/Single-Node ohne Objektspeicher), MIME-Sniffing (`infer`), `ping()` für den Startup-Check |
| `at-firehose` | lib | `JetstreamConsumer`: WebSocket, Collection-Filter, Cursor-Resume, exponentielles Reconnect-Backoff, `connected`-Flag für `/healthz` |
| `at-shared` | lib | `AppConfig::from_env()`, `AtError` mit HTTP-Status-Mapping, DID- und Zeit-Helfer |
| `pds-server` | bin | axum-HTTP-PDS. Routen, Auth/JWT, Repo-Writes, Blob-Upload, AppView-Push |
| `appview` | bin | axum-REST-API + Jetstream-Indexer + Handle-Sync-Worker + interner Ingest-Endpoint |
| `tauri-app` | bin (eigener Workspace) | Desktop-Client. Svelte-5-Frontend, Rust-IPC-Layer mit HTTP-Clients für PDS und AppView |
`crates/tauri-app/src-tauri/Cargo.toml` deklariert ein eigenes `[workspace]`,
das Crate ist also **nicht** Teil des Root-Workspaces. `cargo build --workspace`
im Repo-Root baut den Client nicht mit, und sein Build-Output landet in
`crates/tauri-app/src-tauri/target/`.
## Schreibpfad: ein Post entsteht
`ComposeBox``invoke("post_create")``src-tauri/src/lib.rs`
`PdsHttpClient::create_record``POST /xrpc/com.atproto.repo.createRecord`.
Serverseitig (`crates/pds-server/src/routes/repo.rs` +
`routes/helpers.rs::apply_repo_write`):
1. `Authorization: Bearer <access_jwt>` prüfen — verifiziert gegen den aus
`PDS_JWT_SECRET` abgeleiteten P-256-Serverschlüssel. `claims.sub` muss der
`repo`-DID entsprechen.
2. Lexicon-Validierung über `state.lex` (abschaltbar mit `validate: false` im
Request-Body). Hier greift das 160-Zeichen-Limit von `app.twi.post`.
3. Transaktion öffnen und `SELECT … FROM repos WHERE did = $1 FOR UPDATE`
der Row-Lock serialisiert konkurrierende Writes desselben Repos.
4. Record als DAG-CBOR kodieren, CID bilden, Block in den Blockstore legen,
MST aktualisieren.
5. `repo.commit()` signiert den neuen Commit mit dem User-Signing-Key; `rev`
ist eine TID.
6. Blocks + neuer Head landen in `repo_blocks` / `repos`, Commit der
Transaktion.
7. Antwort mit `uri` (`at://did/collection/rkey`), `cid` und dem Commit-Objekt.
8. Danach — außerhalb des kritischen Pfads — der Push an die AppView.
Der Blockstore in `AppState` ist ein `MemoryBlockstore`; Persistenz kommt aus
`repo_blocks`. Nach einem Neustart wird der Speicher pro Write aus der DB
rehydriert.
## Lesepfad: die Timeline
`GET /api/timeline/home?did=…&limit=…&cursor=…`
(`crates/appview/src/routes.rs`):
1. `SELECT subject_did FROM follows WHERE follower_did = $1`.
2. Zielmenge = Followees + eigene DID, dedupliziert, auf
`MAX_FOLLOWED_DIDS = 1000` gedeckelt (sortiert, damit die Kürzung stabil
ist; die eigene DID bleibt garantiert drin).
3. Ist die Menge leer bzw. nur die eigene DID, fällt die Abfrage auf den
globalen Recent-Feed zurück — Cold-Start für neue Accounts.
4. Posts aus `posts` mit
`collection IN ('app.twi.post','app.bsky.feed.post')`.
5. Paginierung über einen opaken Cursor `base64url(micros):uri`
(`routes/cursor.rs`), passend zu den Indizes aus
`0002_pagination_indexes.sql`.
6. `decorate_handles` ersetzt leere Handles anzeigeseitig durch
`@<erste-12-Zeichen-der-DID>…` — die DB-Zeile bleibt unangetastet, das
Nachfüllen erledigt der Handle-Sync-Worker.
Limits: Default 30, Maximum 100.
Die vollständige, aktuelle Routenliste steht in
`crates/appview/src/routes.rs::router` — sie wächst gerade (Thread-Kontext,
Notifications, Follower-Listen) und wird hier bewusst nicht dupliziert.
## Indexer
`crates/appview/src/firehose.rs` ist der Event-Dispatcher, `indexer.rs` macht
die Schreibarbeit. Behandelte Event-Arten:
* `commit``apply_commit`, dispatcht auf die Collection:
`app.twi.post` / `app.bsky.feed.post`, `app.bsky.feed.like`,
`app.bsky.feed.repost`, `app.bsky.graph.follow`, `app.bsky.actor.profile`.
Alles andere wird übersprungen (der Cursor darf trotzdem weiterlaufen).
* `identity``backfill_handle`: schreibt den neuen Handle in alle
`posts`-Zeilen der DID.
* `account``handle_account`.
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.
## Datenbanken und Tabellen
### PDS-DB (`DATABASE_URL_PDS`, Compose-Port 5434)
| Tabelle | Inhalt |
|---|---|
| `users` | `did` (PK), `handle` (unique), `email`, `password_hash` (argon2id), `signing_key`, `rotation_key` |
| `repos` | Ein Head pro DID: `rev` (TID), `head_cid`, `head_commit` (CBOR), `prev_commit` |
| `repo_blocks` | `(did, cid)` → CBOR-Block. MST-Knoten **und** Records. `0002_blob_mime.sql` ergänzt einen `cid`-Index |
| `blobs` | `cid` (PK), `did`, `mime_type`, `size`, `storage_key` (S3-Key) |
| `sessions` | Access-/Refresh-JWT mit Ablaufzeiten und `revoked_at` |
| `plc_ops` | Audit-Log der signierten PLC-Operationen, `submitted`-Flag |
Trigger `users_touch` pflegt `users.updated_at`.
### AppView-DB (`DATABASE_URL_APPVIEW`, Compose-Port 5435)
Stand der Migrationen zum Zeitpunkt dieses Dokuments; maßgeblich bleibt
`migrations/appview/`.
| Tabelle | Inhalt |
|---|---|
| `posts` | `uri` (PK), `did`, `handle`, `rkey`, `collection`, `text`, `cid`, `parent_uri`, `root_uri`, `langs`, `created_at`, `indexed_at`. Später ergänzt: `embed` (JSONB), `reply_parent_handle`, `reply_root_handle`, `reply_root_uri` (0003), `like_count`, `repost_count` (0004), `avatar_cid` (0005), `handle_sync_attempted_at` (0006) |
| `likes` / `reposts` | `uri` (PK), `did`, `post_uri`, `post_cid`. `0004_like_repost_counters.sql` ergänzt je einen Unique-Index auf `(did, post_uri)` gegen Doppel-Likes und dedupliziert vorhandene Zeilen. Die Zähler auf `posts` pflegt der Indexer selbst (`UPDATE posts SET like_count = like_count + 1 …`, beim Entfernen mit `GREATEST(…, 0)`) — es gibt keine DB-Trigger dafür |
| `follows` | `(follower_did, subject_did)` als PK — die Basis des Timeline-Filters |
| `profiles` | `did` (PK), `handle`, `display_name`, `description`, `avatar_cid`, `banner_cid`, drei Counter. Angelegt in `0005_profiles.sql` inkl. Backfill aus `posts` |
| `jetstream_cursor` | Genau eine Zeile (`id = 1`), `cursor` = Mikrosekunden seit Epoch |
| `timeline_cache` | In `0001_init.sql` angelegt, aber von keinem Code-Pfad gelesen oder geschrieben — Platzhalter für materialisierte Timelines |
Suche läuft über den Trigram-GIN-Index `posts_text_trgm_idx` (`pg_trgm`), nicht
über Full-Text-Search.
### MinIO
Zwei Buckets aus dem Compose-Init: `maarcadetweet-pds` und
`maarcadetweet-appview`. Genutzt wird faktisch nur der erste — `S3_BUCKET_APPVIEW`
ist zwar Pflichtvariable in `AppConfig`, wird aber von keinem Code-Pfad gelesen.
`at-blob` spricht MinIO ohne Signature V4, die Buckets brauchen deshalb
`anonymous download`.
Blobs sind doppelt referenziert: der Objekt-Key liegt in `blobs.storage_key`,
zusätzlich hält die PDS den Block im lokalen Blockstore. Fällt MinIO aus, kann
`uploadBlob` weiterlaufen und `GET /blob/:cid` aus dem lokalen Bestand bedienen
(die Startup-Warnung sagt genau das).
## Identität
* `createAccount` erzeugt zwei secp256k1-Keypairs (Signing + Rotation), baut
daraus eine `PlcOperation`, signiert sie und leitet die DID **deterministisch
aus der CID der signierten Op** ab (`did_plc_from_op`).
* Das Submit ans PLC-Directory ist best effort. Schlägt es fehl, bleibt die DID
lokal gültig — sie ist dann nur global nicht auflösbar
(`plc submit failed (dev ok)` im Log).
* `com.atproto.identity.resolveHandle` ist polymorph: beginnt der Wert mit
`did:`, wird per Primärschlüssel in `users` gesucht, sonst über den Handle
(mit Suffix-Matching gegen `PDS_HANDLE_DNS_ZONE`). Genau diesen Pfad nutzt
der `PdsHandleResolver` der AppView, damit lokale Nutzer — auch
`did:key:`-Accounts — ohne Umweg über plc.directory einen Handle bekommen.
* Reihenfolge im Handle-Sync-Worker: lokale PDS → PLC → `did:web`. Andere
DID-Methoden werden übersprungen.
## Client
Der Webview ruft nie direkt HTTP: die CSP erlaubt `connect-src 'self' ipc:
http://ipc.localhost`, alle Netzwerkaufrufe laufen über `invoke()` in den
Rust-Layer (`pds_client.rs` für Schreiben/Auth, `appview_client.rs` für Lesen).
`store.rs` hält die Session.
Die Backend-URLs kommen aus `MAARCADETWEET_PDS_URL` und
`MAARCADETWEET_APPVIEW_URL` mit den Defaults `http://127.0.0.1:2583` und
`http://127.0.0.1:2584`; sie werden beim Start in `AppState` eingefroren und
sind zur Laufzeit nicht umschaltbar.
Registrierte Tauri-Plugins: `notification`, `dialog`, `shell`, `updater`,
`window-state`. Die Capability `default` (`capabilities/default.json`) gibt dem
`main`-Window unter anderem `updater:default` frei — der Updater ist damit
vorbereitet, aber in `tauri.conf.json` deaktiviert (siehe
[tauri-release.md](tauri-release.md)).
## Lesehinweise für den Betrieb
Ports, Umgebungsvariablen, Health-Checks und das Neustart-Verhalten des
Jetstream-Cursors stehen in [deployment.md](deployment.md).