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:
co-authored by
Claude Opus 5
parent
31880e1005
commit
ec8fe187fe
@@ -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).
|
||||
Reference in New Issue
Block a user