Files
tomdeboneandClaude Opus 5 b7ce114677 docs: Firehose, Follow-Lexicon und Phase 10
architecture.md zeigt den subscribeRepos-Pfad im Diagramm (er stand zuerst
irrtümlich am Pfeil zum externen Relay) und erklärt, warum es Push *und*
Firehose gibt: der eine ist schnell, der andere verlässlich. Dazu, wo die
Spec-Treue endet — die Frame-Hülle ist konform, die Blöcke darin nicht.

deployment.md bekommt einen Firehose-Abschnitt: Transaktionsgarantie, warum
die seq lückenfrei ist und was der globale Advisory-Lock an Durchsatz
kostet, Cursor-Semantik, WebSocket-Upgrade im Proxy, und die fehlende
Retention für firehose_events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 07:08:46 +02:00

27 KiB

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), der Aufbau der Komponenten steht in 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

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:

    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

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:

[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:

[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 subdid 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=<seq> 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:

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:
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_connectedfalse 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:

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.