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
254 lines
15 KiB
Markdown
254 lines
15 KiB
Markdown
# 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 <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).
|