diff --git a/README.md b/README.md index f15f923..fb93d7e 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,22 @@ crates/ ├── at-identity/ DID, PLC, Handle-Resolution ├── at-mst/ Merkle-Search-Tree ├── at-repo/ Repos, Commits, Blöcke, TID-Revs -├── at-blob/ S3-kompatibler Blob-Store (MinIO) +├── at-blob/ S3-kompatibler Blob-Store (MinIO) + In-Memory-Store ├── at-firehose/ Jetstream-Consumer (WebSocket) ├── at-shared/ Config, Errors, DID, Cursor ├── pds-server/ axum HTTP PDS (bin) └── appview/ Jetstream-Indexer + REST-API (bin) crates/tauri-app/ Tauri 2 + Svelte 5 + Vite + TS Desktop-Client - ├── src/ Svelte-Components (Terminal, StatusBar, NavRail, PostCard, ComposeBox, LoginScreen) + ├── src/ Svelte-Components (Terminal, StatusBar, NavRail, PostCard, ComposeBox, + │ LoginScreen, ProfileView, NotificationsView) ├── src/lib/styles/ tokens.css (1:1 vom maarcade-Design) └── src-tauri/ Rust-IPC-Layer lexicons/app/twi/post.json Custom Lexicon mit maxLength: 160 migrations/pds/ PDS-DB-Schema (users, repos, blobs, sessions, plc_ops) -migrations/appview/ AppView-DB-Schema (posts, likes, follows, timeline_cache, jetstream_cursor) +migrations/appview/ AppView-DB-Schema (posts, likes, follows, notifications, profiles, jetstream_cursor) +docs/ Deployment, Architektur, Tauri-Release (siehe unten) ``` ## Setup @@ -34,7 +36,8 @@ migrations/appview/ AppView-DB-Schema (posts, likes, follows, timelin # 1) Datenbanken + MinIO starten docker compose up -d -# 2) Umgebungsvariablen +# 2) Umgebungsvariablen — beide Binaries laden .env selbst (dotenvy). +# PDS_JWT_SECRET muss Hex sein: openssl rand -hex 32 cp .env.example .env # 3) Workspace kompilieren + Tests @@ -61,26 +64,56 @@ cargo run -p appview | 2 MST + Repo (Spec-konforme CBOR-Encoding) | ✅ done — `encode_key` = `base64url(sha256(raw_key))` per atproto-Spec, `split_around`/`wrap_with_split` threaden den recursive right_sub korrekt als `k_tree` weiter. 27 MST + 13 Repo + 4 Commit Tests grün. | | 3 PDS-Server (com.atproto.* XRPC) | ✅ done — createAccount/Session/Refresh, createRecord/deleteRecord, like/repost, follow | | 4 AppView-Foundation (Jetstream-Index) | ✅ done — Jetstream-Indexer + identity-Event-Backfill + PLC-handle-sync-Worker | -| 5 AppView-REST-API | ✅ done — timeline, profile (by-did + by-handle), search, post-by-uri, thread-context | +| 5 AppView-REST-API | ✅ done — timeline, profile (by-did + by-handle), search, post-by-uri, thread, notifications, followers/following | | 6 Tauri-UI-Logik an Backend koppeln | ✅ done — LoginScreen, NavRail, PostCard, ComposeBox, Profile/Compose/Search/Settings-Views | -| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev deaktiviert (siehe `_comment` in `tauri.conf.json` für Production-Setup) | +| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev inert (Production-Weg: [`docs/tauri-release.md`](docs/tauri-release.md)) | +| 8 Social-Graph + Benachrichtigungen | ✅ done — `notifications`-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count|/seen]`, `/api/followers`, `/api/following`, eigene `/api/thread`-Route; im Client Notifications-View mit Unread-Badge und klickbare Follower-/Following-Listen im Profil | ## Tests -``` -running 16 tests (at-crypto) -test result: ok. 16 passed; 0 failed; 0 ignored -running 3 tests (at-lexicon) -test result: ok. 3 passed; 0 failed -running 2 tests (at-shared) -test result: ok. 2 passed; 0 failed -running 2 tests (at-repo) -test result: ok. 2 passed; 0 failed -running 4 tests (at-crypto plc_op — Phase 1) -test result: ok. 4 passed; 0 failed +```bash +cargo test --workspace # Rust: PDS, AppView, at-* Crates +cd crates/tauri-app && npx vitest run # Svelte/TS +cd crates/tauri-app/src-tauri && cargo test # Tauri-IPC (eigener Workspace!) ``` -Der zuvor als "geplant für Phase 1" markierte `jwt::issue_and_verify`-Test wurde zwischenzeitlich grün gezogen (P-256-PKCS#8-PEM-Encoder ist über `p256::pkcs8::EncodePrivateKey` da). +Stand zuletzt gegen den lokalen Dev-Stack (docker compose + laufender PDS + AppView): +Rust-Workspace grün (u.a. 27 MST, 24 PDS-Integration, 49 AppView-Lib, 14 AppView-Integration), +Frontend grün. Zwei Vorbehalte: + +* Die DB-gestützten Integrationstests sind *fail-open* — ohne erreichbare Postgres/PDS + überspringen sie sich selbst und melden das nur auf stderr. Ein grüner Lauf ohne + laufenden Stack sagt also weniger, als er aussieht. +* Einige Tests hängen am Zustand der Dev-Datenbank; auf einer frischen DB können + `handle_sync`-Tests abweichen. Wer sie ernst nimmt, prüft sie gegen eine definierte DB. + +`crates/tauri-app/src-tauri` hat ein eigenes `[workspace]` und ist **nicht** Teil des +Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht. + +## Dokumentation + +| Datei | Inhalt | +|---|---| +| [`docs/architecture.md`](docs/architecture.md) | Crate-Verantwortlichkeiten, Datenfluss PDS → Jetstream → AppView → Client, Tabellenübersicht | +| [`docs/deployment.md`](docs/deployment.md) | Betrieb: docker compose, Migrationen, alle Env-Variablen, Release-Build, systemd-Units, Reverse-Proxy, Health-Checks | +| [`docs/tauri-release.md`](docs/tauri-release.md) | Signing-Keys, Updater-Config, `latest.json`, Build pro Plattform, Artefaktpfade | + +## Bekannte Lücken + +* Die eigene PDS speist **keinen** Firehose (`com.atproto.sync.subscribeRepos` fehlt) — + eigene Records erreichen die AppView nur über den Best-Effort-Push + `POST /internal/ingest-commit`. +* Die AppView-Leseschnittstelle hat **keine Auth** und CORS `Any`; bei + `/api/notifications` sind das erstmals halbwegs private Daten. +* Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der + Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer". +* Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das + Updater-Plugin ist nicht installiert (siehe `docs/tauri-release.md`). +* Reply-Notifications gehen verloren, wenn die Antwort vor ihrem Parent indiziert wird + (kein Nachlauf) — bei Jetstream möglich. +* `at-blob` spricht MinIO ohne Signature V4 — echtes AWS S3 funktioniert damit nicht. +* Die PDS liefert kein `.well-known/did.json`; `describeServer` gibt die DID hart + als `did:web:pds.maarcadetweet.local` zurück. ## Design diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..aadcf10 --- /dev/null +++ b/docs/architecture.md @@ -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 ` 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). diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..7ef7502 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,528 @@ +# Deployment + +Wie der Stack real betrieben wird: zwei Rust-Binaries (`pds-server`, `appview`), +zwei Postgres-Datenbanken und ein MinIO-Bucket-Paar. Der Tauri-Client wird +separat gebaut (siehe [tauri-release.md](tauri-release.md)), der Aufbau der +Komponenten steht in [architecture.md](architecture.md). + +## Voraussetzungen + +| Komponente | Version | Woher belegt | +|---|---|---| +| Rust-Toolchain | ≥ 1.80 | `rust-version` in `Cargo.toml` | +| Docker + Compose | aktuell | `docker-compose.yml` | +| PostgreSQL | 16 | `postgres:16-alpine` im Compose-File | +| MinIO | latest | `minio/minio` im Compose-File | +| Node + npm | Node 20, npm 10.8.2 | `packageManager` in `crates/tauri-app/package.json` — nur für den Desktop-Client nötig | + +Ein C-Compiler/Linker wird für `rusqlite` (Feature `bundled`) und die +Crypto-Crates gebraucht. + +## 1. Infrastruktur starten + +```bash +docker compose up -d +``` + +Das Compose-File startet vier Container: + +| Container | Dienst | Host-Port | Zugangsdaten (Default) | +|---|---|---|---| +| `maarcadetweet-pds-db` | Postgres für die PDS | `5434` → 5432 | `pds` / `pds`, DB `pds` | +| `maarcadetweet-appview-db` | Postgres für die AppView | `5435` → 5432 | `appview` / `appview`, DB `appview` | +| `maarcadetweet-minio` | S3-kompatibler Blob-Store | `9100` (API), `9101` (Konsole) | `minioadmin` / `minioadmin` | +| `maarcadetweet-minio-init` | Einmal-Job | — | legt die Buckets an und beendet sich | + +`minio-init` legt `maarcadetweet-pds` und `maarcadetweet-appview` an und setzt +beide auf `anonymous download`. Das ist kein Zufall: `at-blob` liest Blobs über +ein einfaches `GET ${endpoint}/${bucket}/${key}` ohne Signatur. + +Alle drei Langläufer haben Healthchecks (`pg_isready` bzw. +`/minio/health/live`), Zustand prüfen mit `docker compose ps`. + +**Für Produktion ändern:** Die Passwörter im Compose-File sind Dev-Defaults und +stehen im Klartext. Wer den Stack öffentlich betreibt, ersetzt sie (und die +korrespondierenden Werte in `.env`) oder betreibt Postgres/MinIO ganz außerhalb +von Compose. Die Ports 5434/5435/9100/9101 sind auf allen Interfaces gemappt — +in Produktion auf `127.0.0.1:` binden. + +## 2. Migrationen + +**Migrationen laufen automatisch beim Start der Binaries.** Es gibt keinen +separaten Migrationsschritt und kein Skript in `scripts/` (das Verzeichnis ist +leer). + +* `crates/pds-server/src/main.rs`: `sqlx::migrate!("../../migrations/pds").run(&db)` +* `crates/appview/src/main.rs`: `sqlx::migrate!("../../migrations/appview").run(&db)` + +`sqlx::migrate!` ist ein Makro: die `.sql`-Dateien werden **zur Compile-Zeit in +das Binary eingebettet**. Konsequenzen für den Betrieb: + +* Eine neue Migration wird erst nach einem Rebuild wirksam — das Binary allein + neu zu starten reicht nicht. +* Der Migrationsstand liegt in der Tabelle `_sqlx_migrations` der jeweiligen DB. + Wird eine bereits angewendete Datei nachträglich geändert, bricht der Start + mit einem Checksum-Fehler ab. +* Die Datenbanken müssen die Extensions `uuid-ossp`, `pgcrypto` (PDS) bzw. + `uuid-ossp`, `pg_trgm` (AppView) anlegen dürfen (`CREATE EXTENSION` in + `0001_init.sql`). Beim offiziellen Postgres-Image ist der Compose-User + Superuser; bei managed Postgres müssen die Extensions vorab freigeschaltet + sein. +* `migrations/appview/0001_init.sql` legt die Cursor-Zeile an + (`INSERT INTO jetstream_cursor (id, cursor) VALUES (1, 0)`) — ohne sie fällt + die AppView auf Cursor `0` zurück und startet ohne Resume. + +Maßgeblich ist immer der Inhalt von `migrations/pds/` bzw. +`migrations/appview/` — beim Schreiben dieses Dokuments `0001_init` und +`0002_blob_mime` auf der PDS-Seite, `0001_init` bis `0008_notifications` auf +der AppView-Seite. + +Die Dateinamen folgen dem `_.sql`-Schema von sqlx, ein manueller +Lauf mit `sqlx-cli` (`sqlx migrate run --source migrations/appview`) ist damit +prinzipiell kompatibel. `sqlx-cli` ist aber **nicht** Teil des Workspaces und +wird nirgends im Code oder in CI verwendet — der dokumentierte Weg bleibt der +automatische Lauf beim Start. + +Im Code gibt es keine `sqlx::query!`-Makros (nur die Laufzeit-Varianten +`sqlx::query` / `query_as` / `query_scalar`). Für den Build wird also **keine** +erreichbare Datenbank und kein `.sqlx`-Offline-Cache gebraucht. + +## 3. Umgebungsvariablen + +`cp .env.example .env` und anpassen — das reicht für den Dev-Betrieb. Beide +Binaries rufen als erstes in `main()` `dotenvy::dotenv()` auf (`dotenvy` ist +Workspace-Dependency und in `crates/pds-server` wie `crates/appview` +eingebunden) und laden damit eine `.env` aus dem Arbeitsverzeichnis bzw. den +übergeordneten Verzeichnissen. **Echte Umgebungsvariablen gewinnen** über die +Werte aus der Datei; ist keine `.env` vorhanden, passiert schlicht nichts. + +Im Serverbetrieb ist die Datei damit optional: systemd (`EnvironmentFile=`) +oder die Container-Runtime setzen die Variablen direkt, und sie überschreiben +eine versehentlich mitdeployte `.env`. + +### Pflicht (fehlt eine, bricht `AppConfig::from_env()` mit `missing env: X` ab) + +Quelle: `crates/at-shared/src/config.rs`. Beide Binaries lesen dieselbe +`AppConfig`, also braucht **auch die AppView die PDS-Variablen** und umgekehrt. + +| Variable | Bedeutung | +|---|---| +| `PDS_HOST` | Listen-Adresse der PDS (`format!("{host}:{port}")` → `TcpListener::bind`) | +| `PDS_PORT` | Listen-Port der PDS, `u16` | +| `PDS_PUBLIC_URL` | Öffentliche Basis-URL der PDS. Geht in den DID-Doc-`serviceEndpoint` (`routes/auth.rs`), in den JWT-`iss` (`jwt_issuer.rs`), als `public_base` in den Blob-Store und als Fallback für `PDS_INTERNAL_URL` | +| `PDS_HANDLE_DNS_ZONE` | Handle-Suffix inkl. führendem Punkt, z. B. `.maarcadetweet.local`. Wird in `com.atproto.identity.resolveHandle` beim Suffix-Matching benutzt und (ohne Punkt) als `availableUserDomains` in `describeServer` gemeldet | +| `PDS_JWT_SECRET` | **Hex-String** (≥ 32 Bytes, gültiger P-256-Skalar), aus dem der Serverschlüssel für die Access-/Refresh-JWTs abgeleitet wird — siehe Fallstricke unten | +| `APPVIEW_HOST` | Listen-Adresse der AppView | +| `APPVIEW_PORT` | Listen-Port der AppView, `u16` | +| `APPVIEW_PUBLIC_URL` | Öffentliche Basis-URL der AppView; die PDS benutzt sie als Fallback-Ziel für den Ingest-Push | +| `JETSTREAM_URL` | WebSocket-URL des Jetstream-Relays | +| `JETSTREAM_COLLECTIONS` | Komma-separierte Liste der Collections im `subscribe`-Filter. Leere Einträge werden verworfen | +| `DATABASE_URL_PDS` | Postgres-DSN der PDS-DB | +| `DATABASE_URL_APPVIEW` | Postgres-DSN der AppView-DB | +| `S3_ENDPOINT` | Basis-URL des Blob-Stores (MinIO) | +| `S3_REGION` | Region-String; wird im aktuellen `at-blob` nur mitgeführt, nicht signiert | +| `S3_ACCESS_KEY` | MinIO-Access-Key | +| `S3_SECRET_KEY` | MinIO-Secret-Key | +| `S3_BUCKET_PDS` | Bucket, in den die PDS Blobs schreibt | +| `S3_BUCKET_APPVIEW` | Bucket für die AppView — Pflichtfeld in der Config, wird aber von keinem Code-Pfad gelesen | +| `PLC_DIRECTORY_URL` | PLC-Directory. Die PDS submittet dort neue `did:plc:`-Ops (best effort), die AppView löst darüber Handles auf | + +### Optional + +| Variable | Default | Bedeutung | +|---|---|---| +| `PDS_INTERNAL_URL` | `PDS_PUBLIC_URL` | Cluster-interne PDS-URL, die die AppView für ihren `PdsHandleResolver` nutzt | +| `APPVIEW_INTERNAL_URL` | `APPVIEW_PUBLIC_URL` | Cluster-interne AppView-URL für den Commit-Push der PDS (`crates/pds-server/src/state.rs`) | +| `APPVIEW_INGEST_SECRET` | unset | Shared Secret für `POST /internal/ingest-commit`. Unset = Dev-Modus, der Endpoint akzeptiert anonyme Requests. Gesetzt = Header `X-Ingest-Secret` muss (konstantzeitverglichen) passen. Muss auf **beiden** Seiten identisch gesetzt sein | +| `APPVIEW_HANDLE_SYNC_INTERVAL_SECS` | `300` | Intervall des Handle-Sync-Workers. Nicht parsbare Werte fallen still auf den Default zurück | +| `RUST_LOG` | `info` | `tracing_subscriber::EnvFilter::try_from_default_env()` in beiden Binaries und im Tauri-Client | + +### Nur für den Desktop-Client + +Gelesen in `crates/tauri-app/src-tauri/src/lib.rs`: + +| Variable | Default | +|---|---| +| `MAARCADETWEET_PDS_URL` | `http://127.0.0.1:2583` | +| `MAARCADETWEET_APPVIEW_URL` | `http://127.0.0.1:2584` | + +### Nur für Tests + +Die Integrationstests in `crates/appview/tests/` und +`crates/pds-server/tests/` lesen `DATABASE_URL_APPVIEW` bzw. `DATABASE_URL_PDS` +direkt und überspringen sich selbst, wenn die Variable fehlt. + +### Fallstricke + +`.env.example` deckt inzwischen alle Variablen ab, die der Code liest — auch +die optionalen (`PDS_INTERNAL_URL`, `APPVIEW_INTERNAL_URL`, +`APPVIEW_HANDLE_SYNC_INTERVAL_SECS`, `APPVIEW_INGEST_SECRET`, +`MAARCADETWEET_PDS_URL`, `MAARCADETWEET_APPVIEW_URL`), die dort auskommentiert +mit ihrem Default stehen. Zwei Punkte bleiben trotzdem erklärungsbedürftig: + +1. **`PDS_JWT_SECRET` ist kein beliebiger String, sondern ein P-256-Skalar.** + `jwt_issuer.rs::server_p256_keypair` macht + `hex::decode(secret.trim_start_matches("0x"))`, verlangt ≥ 32 Bytes und + gibt die ersten 32 Bytes an `p256::SecretKey::from_bytes()`. Der Wert muss + also **Hex sein und ein gültiger Skalar der P-256-Kurve** — lauter Nullen + (oder ein Wert ≥ Gruppenordnung) wird abgelehnt. + + Der Fehler zeigt sich spät und breit: Der Prozess **startet normal**, weil + der Schlüssel erst beim ersten Zugriff abgeleitet wird. Danach antwortet + aber alles mit `500`, was `server_p256_public_multibase` bzw. + `server_p256_keypair` benutzt — nicht nur + `createAccount` / `createSession` / `refreshSession` (`routes/auth.rs`), + sondern auch jeder Record-Write, weil dort der Access-Token gegen genau + diesen Serverschlüssel verifiziert wird: `routes/repo.rs`, + `routes/feed.rs`, `routes/blob.rs`, `routes/profile.rs`. + + `.env.example` enthält einen gültigen Zufallswert als Beispiel. Für jede + echte Instanz einen eigenen erzeugen: + + ```bash + openssl rand -hex 32 + ``` + +2. **`S3_BUCKET_APPVIEW` ist Pflicht, aber ungenutzt.** `AppConfig` verlangt + die Variable, kein Code-Pfad greift auf `cfg.s3_bucket_appview` zu. Der + Wert muss trotzdem gesetzt sein, sonst startet keins der Binaries. + (In `.env.example` ist das entsprechend vermerkt.) + +Beachtenswert bleibt außerdem `JETSTREAM_COLLECTIONS`: Die Liste in +`.env.example` enthält jetzt alle Collections, die der Indexer kennt — +`app.twi.post` (das eigene 160-Zeichen-Lexicon, `POST_COLLECTIONS` in +`crates/appview/src/indexer.rs`), `app.bsky.feed.post`, `app.bsky.feed.like`, +`app.bsky.feed.repost`, `app.bsky.graph.follow` und +`app.bsky.actor.profile`. Wird `app.twi.post` daraus entfernt, tauchen eigene +Posts nur noch über den direkten PDS→AppView-Push auf, nicht über den +Firehose-Weg. + +## 4. Release-Build + +```bash +cargo build --release -p pds-server -p appview +``` + +Ergebnis: `target/release/pds-server` und `target/release/appview`. Das +Release-Profil im Workspace-`Cargo.toml` setzt `lto = "thin"`, +`codegen-units = 1`, `opt-level = 3`, `strip = true` — die Binaries sind ohne +Debug-Symbole, für Crash-Analysen also vorher separat sichern. + +Da die Migrationen einkompiliert werden, gehören `migrations/` und die +`lexicons/*.json` (via `include_str!` in `crates/pds-server/src/state.rs`) zum +Build-Input, nicht zum Laufzeit-Deployment. Auf dem Zielhost muss nur das +Binary liegen. + +## 5. systemd + +Beispiel für einen Host, auf dem die Binaries unter `/opt/maarcadetweet/bin` +liegen und `/etc/maarcadetweet/env` die Variablen aus Abschnitt 3 enthält +(Modus `0600`, wegen `PDS_JWT_SECRET` und `APPVIEW_INGEST_SECRET`). + +`/etc/systemd/system/maarcadetweet-pds.service`: + +```ini +[Unit] +Description=maarcadetweet PDS +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=maarcadetweet +Group=maarcadetweet +EnvironmentFile=/etc/maarcadetweet/env +ExecStart=/opt/maarcadetweet/bin/pds-server +Restart=on-failure +RestartSec=5 +# Logs gehen über tracing auf stdout/stderr ins Journal +StandardOutput=journal +StandardError=journal +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target +``` + +`/etc/systemd/system/maarcadetweet-appview.service`: + +```ini +[Unit] +Description=maarcadetweet AppView +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=maarcadetweet +Group=maarcadetweet +EnvironmentFile=/etc/maarcadetweet/env +ExecStart=/opt/maarcadetweet/bin/appview +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target +``` + +Hinweise: + +* Beide Units brauchen dieselbe `EnvironmentFile` — beide Binaries bauen die + vollständige `AppConfig`. +* `dotenvy::dotenv()` sucht die `.env` ab dem Arbeitsverzeichnis aufwärts. + Ohne `WorkingDirectory=` ist das unter systemd `/`, es wird also keine + gefunden — die `EnvironmentFile` ist die einzige Quelle. Läge doch eine + `.env` im Arbeitsverzeichnis, hätten die systemd-Variablen trotzdem Vorrang. +* `Type=simple` ist korrekt: keins der Binaries forkt oder meldet Readiness. + Eine `Type=notify`-Integration existiert nicht. +* Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop` + beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte + Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird — + praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 8). Das ist + unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit + zeigt und Events idempotent verarbeitet werden. +* Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf + demselben Host laufen. Die AppView bricht beim Start ab, wenn ihre DB nicht + erreichbar ist (`PgPool::connect` ohne Retry); die PDS nutzt + `PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn + der erste Connect scheitert. `Restart=on-failure` fängt das ab. + +## 6. Reverse-Proxy + +### PDS + +Die PDS muss öffentlich erreichbar sein, damit andere AT-Protocol-Dienste und +der Client sie ansprechen können. Routen aus `crates/pds-server/src/main.rs`: + +``` +GET / +GET /healthz +GET /xrpc/com.atproto.server.describeServer +POST /xrpc/com.atproto.server.createAccount +POST /xrpc/com.atproto.server.createSession +POST /xrpc/com.atproto.server.refreshSession +POST /xrpc/com.atproto.identity.resolveHandle +POST /xrpc/com.atproto.repo.createRecord +POST /xrpc/com.atproto.repo.deleteRecord +POST /xrpc/com.atproto.feed.like.create +POST /xrpc/com.atproto.uploadBlob +GET /xrpc/com.atproto.sync.getRepo +GET /xrpc/com.atproto.sync.getBlocks +GET /xrpc/com.atproto.sync.getLatestCommit +GET /xrpc/com.atproto.sync.getRecord +GET /xrpc/com.atproto.sync.listRepos +GET /xrpc/com.atproto.sync.getBlob +GET /xrpc/app.bsky.actor.profile.get +POST /xrpc/app.bsky.actor.profile.set +GET /blob/:cid +``` + +nginx-Skizze: + +```nginx +server { + listen 443 ssl http2; + server_name pds.example.org; + + # TLS-Konfiguration hier + + location / { + proxy_pass http://127.0.0.1:2583; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # uploadBlob: MAX_BLOB_SIZE in routes/blob.rs ist 1 MiB. + # Der Proxy darf nicht enger sein, sonst kommt statt der + # XRPC-Fehlerantwort ein nginx-413 beim Client an. + client_max_body_size 1m; + } +} +``` + +`PDS_PUBLIC_URL` muss auf genau diese öffentliche URL zeigen — der Wert landet +im DID-Doc-`serviceEndpoint`, im JWT-`iss` und als Basis der Blob-URLs. + +**Noch offen:** Die PDS liefert selbst **kein** `/.well-known/did.json` und +kein `/.well-known/atproto-did` aus (im Router nicht vorhanden). `at-identity` +kann solche Dokumente *auflösen*, aber wer `did:web`-Handles auf dieser PDS +betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern. +Ebenfalls offen: `describeServer` gibt die DID hart als +`did:web:pds.maarcadetweet.local` zurück, unabhängig von `PDS_PUBLIC_URL`. + +### AppView + +Die AppView setzt ihr CORS selbst — `crates/appview/src/routes.rs`: + +```rust +let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); +``` + +Also `Access-Control-Allow-Origin: *` für alle Routen, inklusive +`/internal/ingest-commit`. Der Grund steht im Code: der Tauri-Webview ruft die +AppView von einem anderen Origin aus auf (`http://127.0.0.1:1430` im Dev, +`tauri://` / `asset://` im Bundle), und die Read-Endpoints tragen keine +Auth-Cookies. + +Für ein öffentliches Deployment heißt das: + +* `POST /internal/ingest-commit` gehört **nicht** ins Internet. Entweder im + Proxy blocken (`location /internal/ { deny all; }`) oder die AppView nur auf + dem internen Interface lauschen lassen und den Public-Vhost nur auf `/api/` + und `/healthz` mappen. +* `APPVIEW_INGEST_SECRET` setzen, sobald die AppView irgendwie erreichbar ist — + ohne das Secret ist der Ingest-Endpoint unauthentifiziert. +* Wer den Wildcard-Origin einschränken will, muss den Header im Proxy + überschreiben, nicht nur ergänzen — sonst stehen zwei Werte drin: + +```nginx +location /api/ { + proxy_pass http://127.0.0.1:2584; + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin "tauri://localhost" always; +} + +location /internal/ { deny all; } +``` + +Eine Konfigurierbarkeit der erlaubten Origins über eine Umgebungsvariable gibt +es im Code **nicht** — das ist noch offen. + +### CSP im Client + +`tauri.conf.json` setzt `connect-src 'self' ipc: http://ipc.localhost`. Die +HTTP-Aufrufe an PDS/AppView laufen über den Rust-IPC-Layer +(`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview — +die CSP muss also für neue Backend-URLs nicht angefasst werden. + +## 7. Health-Checks und Logs + +### PDS + +``` +GET /healthz → {"ok": true} +GET / → {"name":"maarcadetweet-pds","version":"0.1.0"} +``` + +`/healthz` ist ein reiner Liveness-Probe ohne DB-Zugriff — ein `200` bedeutet +nicht, dass Postgres erreichbar ist. + +Beim Start pingt die PDS zusätzlich MinIO (`S3BlobStore::ping`, ein `HEAD` auf +`${S3_ENDPOINT}/${S3_BUCKET_PDS}`; `2xx` und `404` gelten als erreichbar). Ein +Fehlschlag ist **kein** Startabbruch, sondern eine Warnung: + +``` +s3 ping failed at startup; uploadBlob will serve from local blockstore only +``` + +### AppView + +``` +GET /healthz → {"ok":true,"lag_ms":…,"events_processed":…,"jetstream_connected":…} +GET / → {"name":"maarcadetweet-appview","version":"0.1.0"} +``` + +Diese drei Felder sind der eigentliche Monitoring-Hook: + +* `jetstream_connected` — `false` heißt: WebSocket ist weg, der Consumer + reconnectet mit Backoff (1 s, verdoppelnd bis `max_backoff_secs = 30`). +* `events_processed` — monoton steigender Zähler. Stagniert er bei + `jetstream_connected: true`, kommt nichts an. +* `lag_ms` — Differenz zwischen lokaler Uhr und `time_us` des letzten Events, + auf `0` geklemmt, wenn negativ. `0` heißt also auch „noch kein Event gesehen". + +### Logs + +Beide Binaries loggen über `tracing_subscriber::fmt()` mit +`EnvFilter::try_from_default_env()` auf stdout/stderr, Fallback-Filter `info`. +Unter systemd landet alles im Journal: + +```bash +journalctl -u maarcadetweet-pds -f +journalctl -u maarcadetweet-appview -f +``` + +Empfehlung für den Betrieb (aus `.env.example` abgeleitet, `sqlx` sonst sehr +gesprächig): + +``` +RUST_LOG=info,sqlx=warn +``` + +Log-Zeilen, auf die es sich lohnt zu achten: + +| Meldung | Bedeutung | +|---|---| +| `jetstream error: …` / `reconnecting in Ns` | Firehose-Verbindung verloren | +| `apply_commit failed; NOT advancing cursor` | Indexer-Fehler, Event wird beim nächsten Replay erneut versucht | +| `cursor flush failed` | Cursor-Schreibvorgang auf die DB schlug fehl | +| `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar | +| `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert | + +## 8. Neustart-Verhalten + +**PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore +(`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent +sind `repo_blocks`, `repos`, `users`, `blobs`, `sessions`, `plc_ops` in der +PDS-DB. Migrationen laufen bei jedem Start (idempotent über +`_sqlx_migrations`). + +**AppView — Jetstream-Cursor.** Der Fortschritt liegt in der Tabelle +`jetstream_cursor` (genau eine Zeile, `id = 1`, Spalte `cursor` = +Mikrosekunden seit Epoch). Der Ablauf: + +1. Beim Start liest `indexer::cursor_get` die Zeile. Ist der Wert `> 0`, wird + der Consumer mit `with_cursor(...)` gebaut und schickt `cursor` im + `subscribe`-Options-Frame an Jetstream — die Verbindung setzt dort fort + statt am Live-Ende. Bei `0` startet er ohne Cursor. +2. Der Handler zählt jedes Event. Nur jedes 100. Event schickt er einen Tick + in einen `mpsc`-Kanal (Kapazität 32, `try_send` — bei vollem Kanal wird der + Tick verworfen). +3. Ein Hintergrund-Task sammelt die Ticks und schreibt alle 500 ms das Maximum + per `UPDATE jetstream_cursor SET cursor = GREATEST(cursor, $1)`. Der Cursor + kann dadurch nie zurückspringen. +4. Schlägt `apply_commit` fehl, wird der Cursor für dieses Event **nicht** + vorgerückt — der Reconnect liefert es erneut. + +Praktische Folge: Nach einem Neustart werden bis zu ~100 Events (plus die +letzten 500 ms) erneut verarbeitet. Alle Indexer-Schreibpfade sind als Upserts +gebaut, das Replay ist also unschädlich. Umgekehrt gilt: War die AppView länger +weg als Jetstreams Backfill-Fenster, sind die Events dazwischen verloren; ein +Backfill-Werkzeug dafür existiert nicht (noch offen). + +**Eigene Posts sind vom Cursor unabhängig.** Die PDS pusht jeden lokalen Commit +direkt an `POST /internal/ingest-commit`. Der Push ist best effort mit 5 s +Timeout und blockiert den Record-Write nie — fällt er aus, kommt der Record +später über Jetstream (sofern die Collection in `JETSTREAM_COLLECTIONS` steht, +siehe Abschnitt 3). + +**Handle-Sync.** Läuft als eigener Task alle +`APPVIEW_HANDLE_SYNC_INTERVAL_SECS` Sekunden und füllt leere `posts.handle` +nach — zuerst über die lokale PDS (`PdsHandleResolver`, 2 s Timeout), dann PLC +bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der +Zustand ist reine Anzeigekosmetik. + +## 9. Was noch offen ist + +* Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur + Postgres und MinIO ab. Es gibt kein Dockerfile im Repo. +* `at-blob` spricht ausschließlich MinIO ohne Signature V4 (siehe Modul-Doku in + `crates/at-blob/src/s3.rs`); echtes AWS S3 funktioniert damit nicht. +* Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe. +* Keine konfigurierbare CORS-Allowlist in der AppView. +* Kein `.well-known`-Handling in der PDS, `describeServer` liefert eine + hartkodierte DID. +* Kein Backfill-Werkzeug für Jetstream-Lücken. diff --git a/docs/tauri-release.md b/docs/tauri-release.md new file mode 100644 index 0000000..49b8407 --- /dev/null +++ b/docs/tauri-release.md @@ -0,0 +1,311 @@ +# Tauri-Release und Auto-Update + +Der Desktop-Client ist auf Auto-Update vorbereitet, aber nicht scharf +geschaltet. Dieses Dokument beschreibt den vollständigen Weg von den +Signing-Keys bis zum ausgelieferten `latest.json` — und was dafür im Repo noch +fehlt. + +Alle Pfade sind relativ zu `crates/tauri-app/`, sofern nicht anders angegeben. + +## Ist-Zustand + +| Baustein | Stand | +|---|---| +| Rust-Plugin | `tauri_plugin_updater::Builder::new().build()` ist in `src-tauri/src/lib.rs` registriert | +| Capability | `src-tauri/capabilities/default.json` enthält `updater:default` → `allow-check`, `allow-download`, `allow-install`, `allow-download-and-install` für das `main`-Window | +| Config | `src-tauri/tauri.conf.json`, Block `plugins.updater`: leerer `pubkey`, Platzhalter-Endpoint `https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}` | +| Updater-Artefakte | `bundle.createUpdaterArtifacts` ist **nicht** gesetzt (Default: aus) — der Build erzeugt keine `.sig`-Dateien und kein `.app.tar.gz` | +| Aufruf im Client | **Fehlt.** Weder das npm-Paket `@tauri-apps/plugin-updater` noch ein `check()`-Aufruf existieren (weder in `src/` noch in `src-tauri/src/`) | +| CLI | `@tauri-apps/cli` ist als devDependency installiert, lokal geprüft: `npx tauri --version` → `tauri-cli 2.11.4` | +| Plugin-Version | `tauri-plugin-updater` 2.10.1 laut `src-tauri/Cargo.lock` | + +**Wichtig:** Die Felder `active` und `dialog` in `plugins.updater` stammen aus +Tauri v1. Der v2-Updater kennt sie nicht — seine `Config` besteht aus +`endpoints`, `pubkey`, `windows`, `dangerousInsecureTransportProtocol`, +`dangerousAcceptInvalidCerts`, `dangerousAcceptInvalidHostnames`. Unbekannte +Schlüssel werden beim Deserialisieren still ignoriert. `"active": false` +schaltet den Updater also **nicht** ab; dass heute nichts passiert, liegt +allein daran, dass niemand `check()` aufruft. Wer den Updater wirklich +deaktivieren will, entfernt den `plugins.updater`-Block und die +`tauri_plugin_updater`-Registrierung. + +## 1. Signing-Keys erzeugen + +Der Updater akzeptiert nur signierte Artefakte. Das Schlüsselpaar wird einmal +erzeugt und danach nie wieder gewechselt — ein neuer Public Key macht alle +installierten Clients update-unfähig. + +```bash +cd crates/tauri-app +npx tauri signer generate -w ~/.tauri/maarcadetweet.key +``` + +Optionen laut `npx tauri signer generate --help`: + +| Flag | Bedeutung | +|---|---| +| `-w, --write-keys ` | Privaten Schlüssel in eine Datei schreiben (sonst nur stdout) | +| `-p, --password ` | Passwort für den privaten Schlüssel setzen | +| `-f, --force` | Vorhandene Datei überschreiben | +| `--ci` | Keine interaktiven Rückfragen | + +Ergebnis: `~/.tauri/maarcadetweet.key` (privat, **niemals** ins Repo) und +`~/.tauri/maarcadetweet.key.pub` (öffentlich, base64). Der Inhalt der +`.pub`-Datei ist genau das, was in `pubkey` gehört. + +Für den Build-Rechner bzw. die CI: + +```bash +export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/maarcadetweet.key)" +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="…" +``` + +`TAURI_SIGNING_PRIVATE_KEY` akzeptiert den Schlüsselinhalt oder einen Pfad. +Fehlt die Variable beim Build mit aktivierten Updater-Artefakten, bricht das +Bundling ab. + +## 2. Konfiguration — ohne den Dev-Zustand zu brechen + +Die laufende `src-tauri/tauri.conf.json` soll unangetastet bleiben (leerer +`pubkey`, keine Updater-Artefakte). Die CLI kann Konfigurationen zur Build-Zeit +zusammenführen: + +``` +-c, --config JSON strings or paths to JSON, JSON5 or TOML files to + merge with the default configuration file +``` + +Der saubere Weg ist deshalb eine **Release-Overlay-Datei**, die nur die +Release-Unterschiede enthält. Anlegen als +`crates/tauri-app/src-tauri/tauri.release.conf.json` (existiert noch nicht): + +```json +{ + "bundle": { + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "endpoints": [ + "https://releases.example.org/maarcadetweet/latest.json" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6…HIER_DER_INHALT_VON_.key.pub…", + "windows": { + "installMode": "passive" + } + } + } +} +``` + +Build damit: + +```bash +cd crates/tauri-app +npx tauri build --config src-tauri/tauri.release.conf.json +``` + +Feldbedeutungen (geprüft an `tauri-plugin-updater` 2.11 / `tauri-utils` 2.9): + +| Feld | Bedeutung | +|---|---| +| `bundle.createUpdaterArtifacts` | `true` erzeugt die Updater-Artefakte (`.sig`, unter macOS zusätzlich das `.app.tar.gz`). Alternativ `"v1Compatible"` für v1-Clients | +| `plugins.updater.pubkey` | Public Key aus Schritt 1. Pflichtfeld — fehlt es ganz, scheitert das Deserialisieren der Plugin-Config | +| `plugins.updater.endpoints` | Liste von URLs, der Reihe nach probiert. Müssen `https` sein: in Release-Builds bricht ein `http`-Endpoint mit `InsecureTransportProtocol` ab (im Debug-Build nur eine Warnung), es sei denn `dangerousInsecureTransportProtocol` ist gesetzt | +| `plugins.updater.windows.installMode` | `passive` (Default), `basicUi` oder `quiet` | + +### Endpoint-Platzhalter + +In der Endpoint-URL werden ersetzt: `{{current_version}}`, `{{target}}`, +`{{arch}}` und `{{bundle_type}}`. Achtung auf einen Fallstrick — der bereits in +der Config stehende Platzhalter-Endpoint benutzt genau diese Form: + +* `{{target}}` wird in der **URL** nur durch das Betriebssystem ersetzt + (`linux`, `darwin`, `windows`) — nicht durch `darwin-aarch64`. +* `{{arch}}` liefert die Architektur (`x86_64`, `aarch64`, `i686`, `armv7`, + `riscv64`). +* `{{bundle_type}}` liefert den laufenden Bundle-Typ: `app`, `msi`, `nsis`, + `appimage`, `deb` oder `rpm` — bzw. `unknown`, wenn er nicht bestimmbar ist. + +Ein statisches `latest.json` (eine Datei für alle Plattformen) ist der +einfachere Weg und kommt ohne Platzhalter aus. + +## 3. `latest.json` + +Der Updater versteht zwei Formate. Für einen einfachen Static-File-Server ist +das **statische Format** richtig: + +```json +{ + "version": "0.2.0", + "notes": "Thread-Ansicht, schnellere Timeline", + "pub_date": "2026-01-15T10:00:00Z", + "platforms": { + "darwin-aarch64": { + "signature": "", + "url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_aarch64.app.tar.gz" + }, + "darwin-x86_64": { + "signature": "…", + "url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_x64.app.tar.gz" + }, + "linux-x86_64": { + "signature": "…", + "url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_0.2.0_amd64.AppImage" + }, + "windows-x86_64": { + "signature": "…", + "url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_0.2.0_x64-setup.exe" + } + } +} +``` + +Regeln, die der Plugin-Code vorgibt: + +* **Schlüssel der `platforms`-Map:** Der Updater sucht in dieser Reihenfolge + nach `{os}-{arch}-{installer}` und danach nach `{os}-{arch}`. + `os` ∈ `linux` | `darwin` | `windows`, `arch` ∈ `i686` | `x86_64` | `armv7` | + `aarch64` | `riscv64`, `installer` ∈ `appimage` | `deb` | `rpm` | `app` | + `msi` | `nsis`. Die Kurzform `darwin-aarch64` reicht also; die lange Form + `windows-x86_64-nsis` ist nur nötig, wenn man MSI und NSIS gleichzeitig + ausliefert. +* **`version`** wird als Semver geparst. Ein Update wird nur angeboten, wenn + `release.version > current_version` — die Version kommt aus dem Feld + `version` in `tauri.conf.json` (heute `0.1.0`). Ein Release ohne + Versionsbump wird stillschweigend ignoriert. +* **`signature`** ist der **Inhalt** der zugehörigen `.sig`-Datei, nicht deren + URL. +* **`notes`** und **`pub_date`** sind optional; `pub_date` als RFC 3339. +* Ein dynamischer Endpoint darf mit **`204 No Content`** antworten, wenn es + kein Update gibt. Dann liefert er statt der `platforms`-Map ein flaches + Objekt mit `version`, `url` und `signature` für genau die anfragende + Plattform. + +## 4. Build pro Plattform + +`tauri build` ist ein Cross-Build nur eingeschränkt — jede Plattform wird auf +ihrem eigenen Host (oder Runner) gebaut. + +```bash +cd crates/tauri-app + +# macOS (Apple Silicon) +npx tauri build --config src-tauri/tauri.release.conf.json + +# macOS Universal (beide Rust-Targets müssen installiert sein) +npx tauri build --target universal-apple-darwin \ + --config src-tauri/tauri.release.conf.json + +# Windows / Linux jeweils auf dem passenden Host, gleicher Aufruf +``` + +Ablauf laut `build.beforeBuildCommand` in `tauri.conf.json`: erst +`npm run build` (Vite → `crates/tauri-app/dist`, entspricht `frontendDist: +"../dist"`), dann der Cargo-Release-Build, dann das Bundling. +`bundle.targets` steht auf `"all"`. + +Nützliche Flags (aus `npx tauri build --help`): + +| Flag | Zweck | +|---|---| +| `-t, --target ` | Ziel-Triple, oder `universal-apple-darwin` | +| `-b, --bundles ` | Nur bestimmte Bundle-Typen erzeugen | +| `--no-bundle` | Nur das Binary, kein Installer | +| `-c, --config ` | Config-Overlay (siehe oben) | +| `--ci` | Keine interaktiven Rückfragen | + +## 5. Wo die Artefakte landen + +`crates/tauri-app/src-tauri/Cargo.toml` deklariert ein eigenes `[workspace]`, +der Build-Output liegt deshalb **nicht** im Repo-Root-`target/`, sondern unter +`crates/tauri-app/src-tauri/target/release/bundle/`. Mit `--target ` +schiebt sich das Triple dazwischen: +`src-tauri/target//release/bundle/`. + +| Plattform | Pfad unter `bundle/` | Für den Updater relevant | +|---|---|---| +| macOS | `macos/maarcadetweet.app` | `macos/maarcadetweet.app.tar.gz` + `.tar.gz.sig` | +| macOS | `dmg/maarcadetweet__.dmg` | nein (nur für die Erstinstallation) | +| Windows | `msi/maarcadetweet___.msi` | `.msi` + `.msi.sig` | +| Windows | `nsis/maarcadetweet__-setup.exe` | `.exe` + `.exe.sig` | +| Linux | `appimage/maarcadetweet__.AppImage` | `.AppImage` + `.AppImage.sig` | +| Linux | `deb/…deb`, `rpm/…rpm` | nein | + +Die `.sig`-Dateien entstehen nur, wenn `createUpdaterArtifacts` aktiv ist und +die Signing-Env-Variablen gesetzt sind. Für das `latest.json` wird der +**Dateiinhalt** der `.sig` gebraucht, das Artefakt selbst wird nicht +ausgeliefert. + +## 6. Was im Client noch fehlt + +Config und Signaturen allein bewirken nichts — der Client fragt nirgends nach +Updates. Es gibt zwei Wege: + +### a) Vom Frontend aus + +```bash +cd crates/tauri-app +npm install @tauri-apps/plugin-updater @tauri-apps/plugin-process +``` + +```ts +import { check } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; + +const update = await check(); +if (update) { + await update.downloadAndInstall(); + await relaunch(); +} +``` + +Die Capability erlaubt `check`, `download`, `install` und +`downloadAndInstall` bereits. **Zusätzlich nötig:** `tauri-plugin-process` als +Rust-Dependency, `tauri_plugin_process::init()` in `lib.rs` und +`"process:default"` in `capabilities/default.json` — sonst schlägt `relaunch()` +fehl. + +### b) Vom Rust-Layer aus + +```rust +use tauri_plugin_updater::UpdaterExt; + +if let Ok(Some(update)) = app.updater()?.check().await { + update.download_and_install(|_, _| {}, || {}).await?; +} +``` + +Ohne npm-Abhängigkeit, dafür ohne UI-Kontrolle im Frontend. Der Aufruf gehört +in den `setup`-Hook oder hinter ein Tauri-Command, das die Svelte-Seite +aufrufen kann. + +Welcher Weg genommen wird, ist **noch offen** — im Repo existiert keiner von +beiden. + +## 7. Release-Checkliste + +1. `version` in `src-tauri/tauri.conf.json` erhöhen (Semver; der Vergleich + entscheidet, ob ein Update angeboten wird). `package.json` und + `src-tauri/Cargo.toml` der Konsistenz halber mitziehen. +2. Auf jedem Plattform-Host mit gesetzten `TAURI_SIGNING_PRIVATE_KEY*`-Variablen + und dem Release-Overlay bauen. +3. Installer und Updater-Artefakte einsammeln, `.sig`-Inhalte auslesen. +4. `latest.json` schreiben und zusammen mit den Artefakten unter einer + `https`-URL veröffentlichen. Die URL muss zum `endpoints`-Eintrag passen. +5. Mit einer älteren installierten Version gegenprüfen, dass `check()` das + Update findet und die Signaturprüfung durchgeht. + +## 8. Offene Punkte + +* Kein Release-Overlay im Repo — die Datei aus Abschnitt 2 muss angelegt + werden. Der Endpoint `https://releases.maarcadetweet.local/…` in der aktuellen + Config ist ein Platzhalter und existiert nicht. +* Kein Update-Server, kein CI-Workflow, kein Skript, das `latest.json` erzeugt + (`scripts/` ist leer). +* Keine Code-Signierung/Notarisierung für macOS und keine Authenticode-Signatur + für Windows konfiguriert (`bundle` enthält weder `macOS.signingIdentity` noch + `windows.certificateThumbprint`). Der Tauri-Updater-Schlüssel ersetzt das + nicht — ohne Notarisierung meldet Gatekeeper die App als nicht verifiziert. +* Die Felder `active` und `dialog` in `plugins.updater` sind v1-Reste ohne + Wirkung (siehe oben) und sollten beim Scharfschalten entfernt werden.