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
This commit is contained in:
co-authored by
Claude Opus 5
parent
31880e1005
commit
ec8fe187fe
@@ -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 <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).
|
||||
@@ -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 `<version>_<name>.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.
|
||||
@@ -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 <PFAD>` | Privaten Schlüssel in eine Datei schreiben (sonst nur stdout) |
|
||||
| `-p, --password <PW>` | 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 <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": "<Inhalt von maarcadetweet.app.tar.gz.sig>",
|
||||
"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 <TRIPLE>` | Ziel-Triple, oder `universal-apple-darwin` |
|
||||
| `-b, --bundles <LISTE>` | Nur bestimmte Bundle-Typen erzeugen |
|
||||
| `--no-bundle` | Nur das Binary, kein Installer |
|
||||
| `-c, --config <PFAD>` | 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 <triple>`
|
||||
schiebt sich das Triple dazwischen:
|
||||
`src-tauri/target/<triple>/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_<version>_<arch>.dmg` | nein (nur für die Erstinstallation) |
|
||||
| Windows | `msi/maarcadetweet_<version>_<arch>_<lang>.msi` | `.msi` + `.msi.sig` |
|
||||
| Windows | `nsis/maarcadetweet_<version>_<arch>-setup.exe` | `.exe` + `.exe.sig` |
|
||||
| Linux | `appimage/maarcadetweet_<version>_<arch>.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.
|
||||
Reference in New Issue
Block a user