Files
maarcadetweet/docs/architecture.md
T
tomdeboneandClaude Opus 5 ec8fe187fe 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
2026-09-09 21:37:12 +02:00

15 KiB

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

ComposeBoxinvoke("post_create")src-tauri/src/lib.rsPdsHttpClient::create_recordPOST /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:

  • commitapply_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).
  • identitybackfill_handle: schreibt den neuen Handle in alle posts-Zeilen der DID.
  • accounthandle_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).

Lesehinweise für den Betrieb

Ports, Umgebungsvariablen, Health-Checks und das Neustart-Verhalten des Jetstream-Cursors stehen in deployment.md.