deployment.md bekommt einen eigenen Abschnitt zur Authentifizierung (Schlüsselweg, geschützte Endpoints, Fehlercodes, der Schalter für VPN-Instanzen) und eine CORS-Beschreibung, die die Allowlist statt des alten Wildcards erklärt — inklusive der Tauri-Origins, die sonst am Preflight scheitern. Im README steht jetzt der Hinweis, der diese Runde am meisten gekostet hat: ohne DATABASE_URL_APPVIEW in der Umgebung überspringen sich die DB-Tests selbst und `cargo test --workspace` meldet grün, ohne sie ausgeführt zu haben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
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 │
│ /.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, Abschnitt 6.
Zwei Wege führen in die AppView, und das ist Absicht:
- Direkter Push (schnell, lokal). Jeder erfolgreiche Commit auf der PDS
wird per
POST /internal/ingest-commitan 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. - Jetstream (global, verzögert).
at-firehose::JetstreamConsumerhä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):
Authorization: Bearer <access_jwt>prüfen — verifiziert gegen den ausPDS_JWT_SECRETabgeleiteten P-256-Serverschlüssel.claims.submuss derrepo-DID entsprechen.- Lexicon-Validierung über
state.lex(abschaltbar mitvalidate: falseim Request-Body). Hier greift das 160-Zeichen-Limit vonapp.twi.post. - Transaktion öffnen und
SELECT … FROM repos WHERE did = $1 FOR UPDATE— der Row-Lock serialisiert konkurrierende Writes desselben Repos. - Record als DAG-CBOR kodieren, CID bilden, Block in den Blockstore legen, MST aktualisieren.
repo.commit()signiert den neuen Commit mit dem User-Signing-Key;revist eine TID.- Blocks + neuer Head landen in
repo_blocks/repos, Commit der Transaktion. - Antwort mit
uri(at://did/collection/rkey),cidund dem Commit-Objekt. - 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):
SELECT subject_did FROM follows WHERE follower_did = $1.- Zielmenge = Followees + eigene DID, dedupliziert, auf
MAX_FOLLOWED_DIDS = 1000gedeckelt (sortiert, damit die Kürzung stabil ist; die eigene DID bleibt garantiert drin). - 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.
- Posts aus
postsmitcollection IN ('app.twi.post','app.bsky.feed.post'). - Paginierung über einen opaken Cursor
base64url(micros):uri(routes/cursor.rs), passend zu den Indizes aus0002_pagination_indexes.sql. decorate_handlesersetzt 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 alleposts-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
createAccounterzeugt zwei secp256k1-Keypairs (Signing + Rotation), baut daraus einePlcOperation, 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.resolveHandleist polymorph: beginnt der Wert mitdid:, wird per Primärschlüssel inusersgesucht, sonst über den Handle (mit Suffix-Matching gegenPDS_HANDLE_DNS_ZONE). Genau diesen Pfad nutzt derPdsHandleResolverder AppView, damit lokale Nutzer — auchdid: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.