# 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 10). 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. Authentifizierung Die AppView prüft seit Phase 9 Bearer-Tokens. Wie das zusammenhängt: 1. Die PDS stellt beim Login ein ES256-Access-JWT aus (`sub` = DID, `scope` = `com.atproto.access`, eine Stunde gültig). 2. Die PDS veröffentlicht den *öffentlichen* Teil ihres P-256-Schlüssels unter `GET /.well-known/did.json`. `PDS_JWT_SECRET` verlässt den PDS-Prozess nicht. 3. Die AppView holt dieses Dokument beim Start von `PDS_INTERNAL_URL` (Fallback: `PDS_PUBLIC_URL`), cached den Schlüssel und lädt ihn bei einem Verifikationsfehler einmal nach — höchstens einmal pro Minute, damit Müll-Tokens kein Werkzeug werden, die PDS zu fluten. Ein Schlüsselwechsel braucht also keinen Neustart der AppView. Ist die PDS beim Start nicht erreichbar, warnt die AppView nur und startet trotzdem — sie indiziert den Firehose, was von der lokalen PDS unabhängig ist. Der Schlüssel wird dann beim ersten authentifizierten Request geholt. Klappt auch das nicht, antwortet sie `503 AuthUnavailable`: **fail closed**, nie fail open. ### Welche Endpoints | Endpoint | Zugriff | |---|---| | `/api/timeline/home`, `/api/notifications`, `/api/notifications/count`, `/api/notifications/seen` | Token nötig, `sub` muss dem `did`-Parameter entsprechen | | `/api/profile*`, `/api/search`, `/api/post/*`, `/api/thread*`, `/api/followers`, `/api/following` | öffentlich (in AT Proto öffentliche Records) | | `/internal/ingest-commit` | `APPVIEW_INGEST_SECRET`, server-zu-server | ### Fehlercodes | Fall | Status | `error` | |---|---|---| | Header fehlt oder ist kein Bearer | 401 | `AuthMissing` | | Signatur falsch, abgelaufen, falscher `scope` | 401 | `TokenInvalid` | | Token gültig, aber `sub` ≠ `did` | 403 | `Forbidden` | | Schlüssel der PDS nicht beschaffbar | 503 | `AuthUnavailable` | `TokenInvalid` ist ein Vertrag mit dem Desktop-Client: daran erkennt er, dass er sein Access-JWT erneuern und den Request einmal wiederholen muss. Wer den Code umbenennt, loggt jeden Nutzer eine Stunde nach dem Login aus. ### `APPVIEW_AUTH_REQUIRED=false` Schaltet die Prüfung ab und stellt das alte Verhalten her — gedacht für eine Instanz hinter VPN und für die fail-open-Integrationstests. Die AppView warnt beim Start in Großbuchstaben. Öffentlich erreichbar heißt das: jeder kann die Notifications jeder DID lesen und als gelesen markieren. ## 7. Firehose Die PDS liefert `com.atproto.sync.subscribeRepos` als WebSocket aus, die AppView konsumiert ihn. Betrieblich wichtig: * **Das Event liegt in derselben Transaktion wie der Commit.** Es kann keinen Commit ohne Event geben und umgekehrt. * **Die `seq` ist lückenfrei.** Ein globaler `pg_advisory_xact_lock` sorgt dafür, dass Commit-Reihenfolge und `seq`-Reihenfolge übereinstimmen — sonst könnte ein Consumer eine Nummer überspringen, die erst danach sichtbar wird, und sie nie nachholen. Preis: das Ende jeder schreibenden Transaktion ist über alle Accounts hinweg serialisiert. * **Cursor:** `?cursor=` liefert alles mit `seq > cursor` aus der Datenbank nach und geht dann nahtlos live weiter. Ohne Cursor nur live. Ein Cursor aus der Zukunft ist ein Fehler-Frame, ein zu alter ein `#info`/`OutdatedCursor`. * **`firehose_events` wächst unbegrenzt.** Es gibt keine Retention. Beschneiden ist sicher, weil ein zu alter Cursor sauber behandelt wird — wer die Tabelle aufräumt, sollte aber wissen, wie weit die eigenen Consumer zurückhängen dürfen (`pds_firehose_seq` in `/healthz` der AppView gegen `MAX(seq)`). * **Reverse-Proxy:** die Route braucht ein WebSocket-Upgrade (`Upgrade`/ `Connection`-Header durchreichen) und einen Read-Timeout, der längere Ruhephasen überlebt. * `PDS_FIREHOSE_ENABLED=false` schaltet den Consumer in der AppView ab; lokale Commits hängen dann wieder allein am Best-Effort-Push. ## 8. 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. Die Service-DID wird inzwischen aus `PDS_PUBLIC_URL` abgeleitet (`AppConfig::pds_did()`, did:web mit `%3A`-kodiertem Port) und von `describeServer` **und** `/.well-known/did.json` identisch ausgeliefert. Das heißt auch: ändert sich `PDS_PUBLIC_URL`, ändert sich die Service-DID. ### AppView Die AppView setzt ihr CORS selbst (`cors_layer()` in `crates/appview/src/routes.rs`). Ohne `APPVIEW_CORS_ORIGINS` bleibt es beim alten `Access-Control-Allow-Origin: *` — die AppView warnt dann beim Start. Mit gesetzter Variable gilt eine Allowlist: ``` APPVIEW_CORS_ORIGINS=tauri://localhost,http://tauri.localhost,http://127.0.0.1:1430 ``` Der Tauri-Webview ruft die AppView von einem anderen Origin aus auf — im Dev die Vite-Adresse, im Bundle `tauri://localhost` (macOS/Linux) bzw. `http://tauri.localhost` (Windows). Alle drei gehören in die Liste, sonst scheitert der Preflight. `/internal/ingest-commit` liegt bewusst **außerhalb** der CORS-Schicht: die Route wird von der PDS server-zu-server aufgerufen, nie von einem Browser. Ein `Access-Control-Allow-Origin` darauf würde ausschließlich einer Webseite helfen, in den Index zu schreiben. 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. ## 9. 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 | ## 10. 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. ## 11. 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. * `aud` wird beim Token-Check nicht validiert (`verify_jwt` setzt `validate_aud = false`), obwohl die PDS `did:web:appview.maarcadetweet.local` einsetzt. Signatur, Ablauf, `scope` und `sub` werden geprüft. * Notifications werden nie gelöscht; ein Unlike/Unfollow lässt die Zeile stehen. * Kein Backfill-Werkzeug für Jetstream-Lücken. * **Keine Retention für `firehose_events`.** Die Tabelle wächst mit jedem Commit und wird nie beschnitten. Pruning ist sicher — ein Consumer mit zu altem Cursor bekommt `#info`/`OutdatedCursor` und läuft ab der ältesten überlebenden Zeile weiter — aber es gibt weder Job noch Policy dafür. * Der globale Advisory-Lock, der die `seq`-Vergabe ordnet, serialisiert das Ende jeder schreibenden Transaktion über alle Accounts hinweg. Das begrenzt den Schreibdurchsatz auf ein COMMIT nach dem anderen.