# 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 │ │ /.well-known/did.json ──────────┼───┼─▶ Schlüssel für 🔒 │ │ │ │ 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 ``` 🔒 = Bearer-Token nötig, und der `sub` des Tokens muss der angefragten DID 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: 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 ` 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 `@…` — 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).