docs: Auth-Abschnitt, korrigierte Test-Anleitung, Phase 9

deployment.md bekommt einen eigenen Abschnitt zur Authentifizierung
(Schlüsselweg, geschützte Endpoints, Fehlercodes, der Schalter für
VPN-Instanzen) und eine CORS-Beschreibung, die die Allowlist statt des
alten Wildcards erklärt — inklusive der Tauri-Origins, die sonst am
Preflight scheitern.

Im README steht jetzt der Hinweis, der diese Runde am meisten gekostet
hat: ohne DATABASE_URL_APPVIEW in der Umgebung überspringen sich die
DB-Tests selbst und `cargo test --workspace` meldet grün, ohne sie
ausgeführt zu haben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-09 23:03:12 +02:00
co-authored by Claude Opus 5
parent 9ee717bbc7
commit f7b78fd5db
3 changed files with 94 additions and 32 deletions
+10 -9
View File
@@ -68,6 +68,7 @@ cargo run -p appview
| 6 Tauri-UI-Logik an Backend koppeln | ✅ done — LoginScreen, NavRail, PostCard, ComposeBox, Profile/Compose/Search/Settings-Views | | 6 Tauri-UI-Logik an Backend koppeln | ✅ done — LoginScreen, NavRail, PostCard, ComposeBox, Profile/Compose/Search/Settings-Views |
| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev inert (Production-Weg: [`docs/tauri-release.md`](docs/tauri-release.md)) | | 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev inert (Production-Weg: [`docs/tauri-release.md`](docs/tauri-release.md)) |
| 8 Social-Graph + Benachrichtigungen | ✅ done — `notifications`-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count|/seen]`, `/api/followers`, `/api/following`, eigene `/api/thread`-Route; im Client Notifications-View mit Unread-Badge und klickbare Follower-/Following-Listen im Profil | | 8 Social-Graph + Benachrichtigungen | ✅ done — `notifications`-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count|/seen]`, `/api/followers`, `/api/following`, eigene `/api/thread`-Route; im Client Notifications-View mit Unread-Badge und klickbare Follower-/Following-Listen im Profil |
| 9 Auth + Performance | ✅ done — AppView prüft Bearer-Tokens (ES256, Schlüssel aus dem neuen `/.well-known/did.json` der PDS, fail closed); Timeline und Notifications nur noch für die eigene DID; CORS-Allowlist statt `Any`; Indizes für Handle-Lookup und Cold-Start-Feed |
## Tests ## Tests
@@ -81,11 +82,13 @@ Stand zuletzt gegen den lokalen Dev-Stack (docker compose + laufender PDS + AppV
Rust-Workspace grün (u.a. 27 MST, 24 PDS-Integration, 49 AppView-Lib, 14 AppView-Integration), Rust-Workspace grün (u.a. 27 MST, 24 PDS-Integration, 49 AppView-Lib, 14 AppView-Integration),
Frontend grün. Zwei Vorbehalte: Frontend grün. Zwei Vorbehalte:
* Die DB-gestützten Integrationstests sind *fail-open*ohne erreichbare Postgres/PDS * Die DB-gestützten Tests sind *fail-open*: **ohne `DATABASE_URL_APPVIEW` in der
überspringen sie sich selbst und melden das nur auf stderr. Ein grüner Lauf ohne Umgebung überspringen sie sich selbst** und melden das nur auf stderr. `cargo test
laufenden Stack sagt also weniger, als er aussieht. --workspace` in einer nackten Shell meldet dann grün, ohne sie ausgeführt zu haben —
* Einige Tests hängen am Zustand der Dev-Datenbank; auf einer frischen DB können für einen aussagekräftigen Lauf `set -a; . ./.env; set +a` voranstellen und PDS +
`handle_sync`-Tests abweichen. Wer sie ernst nimmt, prüft sie gegen eine definierte DB. AppView laufen lassen.
* Läuft die Auth (Default), holen sich die Integrationstests echte Tokens von der PDS
bzw. signieren sie aus `PDS_JWT_SECRET`; ohne erreichbare PDS überspringen sie.
`crates/tauri-app/src-tauri` hat ein eigenes `[workspace]` und ist **nicht** Teil des `crates/tauri-app/src-tauri` hat ein eigenes `[workspace]` und ist **nicht** Teil des
Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht. Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
@@ -103,8 +106,8 @@ Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
* Die eigene PDS speist **keinen** Firehose (`com.atproto.sync.subscribeRepos` fehlt) — * Die eigene PDS speist **keinen** Firehose (`com.atproto.sync.subscribeRepos` fehlt) —
eigene Records erreichen die AppView nur über den Best-Effort-Push eigene Records erreichen die AppView nur über den Best-Effort-Push
`POST /internal/ingest-commit`. `POST /internal/ingest-commit`.
* Die AppView-Leseschnittstelle hat **keine Auth** und CORS `Any`; bei * `aud` wird beim Token-Check nicht validiert (Signatur, Ablauf, `scope` und
`/api/notifications` sind das erstmals halbwegs private Daten. `sub` schon).
* Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der * Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der
Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer". Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer".
* Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das * Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das
@@ -112,8 +115,6 @@ Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
* Reply-Notifications gehen verloren, wenn die Antwort vor ihrem Parent indiziert wird * Reply-Notifications gehen verloren, wenn die Antwort vor ihrem Parent indiziert wird
(kein Nachlauf) — bei Jetstream möglich. (kein Nachlauf) — bei Jetstream möglich.
* `at-blob` spricht MinIO ohne Signature V4 — echtes AWS S3 funktioniert damit nicht. * `at-blob` spricht MinIO ohne Signature V4 — echtes AWS S3 funktioniert damit nicht.
* Die PDS liefert kein `.well-known/did.json`; `describeServer` gibt die DID hart
als `did:web:pds.maarcadetweet.local` zurück.
## Design ## Design
+8 -2
View File
@@ -19,11 +19,12 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
┌──────────────────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────────────────┐ ┌──────────────────────────────┐
│ pds-server (axum, :2583) │ │ appview (axum, :2584) │ │ pds-server (axum, :2583) │ │ appview (axum, :2584) │
│ │ │ │ │ │ │ │
│ /xrpc/com.atproto.* │ │ GET /api/timeline/home │ /xrpc/com.atproto.* │ │ GET /api/timeline/home 🔒
│ /xrpc/app.bsky.actor.profile.* │ │ GET /api/profile[/:handle] │ │ /xrpc/app.bsky.actor.profile.* │ │ GET /api/profile[/:handle] │
│ /blob/:cid │ │ GET /api/search │ │ /blob/:cid │ │ GET /api/search │
│ /healthz │ │ GET /api/post|thread/*uri │ │ /healthz │ │ GET /api/post|thread/*uri │
│ │ GET /api/notifications… /.well-known/did.json ──────────┼───┼─▶ Schlüssel für 🔒
│ │ │ GET /api/notifications… 🔒 │
│ │ │ GET /api/followers|following│ │ │ │ GET /api/followers|following│
│ │ │ GET /healthz │ │ │ │ GET /healthz │
│ at-lexicon Validierung (160) │ │ │ │ at-lexicon Validierung (160) │ │ │
@@ -50,6 +51,11 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
└──────────────────────────────────────────┘ JetstreamConsumer └──────────────────────────────────────────┘ JetstreamConsumer
``` ```
🔒 = Bearer-Token nötig, und der `sub` des Tokens muss der angefragten DID
entsprechen. Die AppView verifiziert die ES256-Signatur mit dem öffentlichen
Schlüssel, den die PDS in ihrem DID-Dokument veröffentlicht — `PDS_JWT_SECRET`
verlässt die PDS nie. Details in [`deployment.md`](deployment.md), Abschnitt 6.
Zwei Wege führen in die AppView, und das ist Absicht: Zwei Wege führen in die AppView, und das ist Absicht:
1. **Direkter Push (schnell, lokal).** Jeder erfolgreiche Commit auf der PDS 1. **Direkter Push (schnell, lokal).** Jeder erfolgreiche Commit auf der PDS
+76 -21
View File
@@ -287,7 +287,7 @@ Hinweise:
* Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop` * Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop`
beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte
Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird — Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird —
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 8). Das ist praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 9). Das ist
unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit
zeigt und Events idempotent verarbeitet werden. zeigt und Events idempotent verarbeitet werden.
* Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf * Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf
@@ -296,7 +296,55 @@ Hinweise:
`PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn `PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn
der erste Connect scheitert. `Restart=on-failure` fängt das ab. der erste Connect scheitert. `Restart=on-failure` fängt das ab.
## 6. Reverse-Proxy ## 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. Reverse-Proxy
### PDS ### PDS
@@ -357,25 +405,31 @@ im DID-Doc-`serviceEndpoint`, im JWT-`iss` und als Basis der Blob-URLs.
kein `/.well-known/atproto-did` aus (im Router nicht vorhanden). `at-identity` 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 kann solche Dokumente *auflösen*, aber wer `did:web`-Handles auf dieser PDS
betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern. betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern.
Ebenfalls offen: `describeServer` gibt die DID hart als Die Service-DID wird inzwischen aus `PDS_PUBLIC_URL` abgeleitet
`did:web:pds.maarcadetweet.local` zurück, unabhängig von `PDS_PUBLIC_URL`. (`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 ### AppView
Die AppView setzt ihr CORS selbst `crates/appview/src/routes.rs`: 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:
```rust ```
let cors = CorsLayer::new() APPVIEW_CORS_ORIGINS=tauri://localhost,http://tauri.localhost,http://127.0.0.1:1430
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
``` ```
Also `Access-Control-Allow-Origin: *` für alle Routen, inklusive Der Tauri-Webview ruft die AppView von einem anderen Origin aus auf — im Dev
`/internal/ingest-commit`. Der Grund steht im Code: der Tauri-Webview ruft die die Vite-Adresse, im Bundle `tauri://localhost` (macOS/Linux) bzw.
AppView von einem anderen Origin aus auf (`http://127.0.0.1:1430` im Dev, `http://tauri.localhost` (Windows). Alle drei gehören in die Liste, sonst
`tauri://` / `asset://` im Bundle), und die Read-Endpoints tragen keine scheitert der Preflight.
Auth-Cookies.
`/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: Für ein öffentliches Deployment heißt das:
@@ -408,7 +462,7 @@ HTTP-Aufrufe an PDS/AppView laufen über den Rust-IPC-Layer
(`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview — (`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. die CSP muss also für neue Backend-URLs nicht angefasst werden.
## 7. Health-Checks und Logs ## 8. Health-Checks und Logs
### PDS ### PDS
@@ -472,7 +526,7 @@ Log-Zeilen, auf die es sich lohnt zu achten:
| `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar | | `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 | | `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert |
## 8. Neustart-Verhalten ## 9. Neustart-Verhalten
**PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore **PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore
(`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent (`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent
@@ -515,14 +569,15 @@ nach — zuerst über die lokale PDS (`PdsHandleResolver`, 2 s Timeout), dann PL
bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der
Zustand ist reine Anzeigekosmetik. Zustand ist reine Anzeigekosmetik.
## 9. Was noch offen ist ## 10. Was noch offen ist
* Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur * Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur
Postgres und MinIO ab. Es gibt kein Dockerfile im Repo. Postgres und MinIO ab. Es gibt kein Dockerfile im Repo.
* `at-blob` spricht ausschließlich MinIO ohne Signature V4 (siehe Modul-Doku in * `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. `crates/at-blob/src/s3.rs`); echtes AWS S3 funktioniert damit nicht.
* Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe. * Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe.
* Keine konfigurierbare CORS-Allowlist in der AppView. * `aud` wird beim Token-Check nicht validiert (`verify_jwt` setzt
* Kein `.well-known`-Handling in der PDS, `describeServer` liefert eine `validate_aud = false`), obwohl die PDS `did:web:appview.maarcadetweet.local`
hartkodierte DID. 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. * Kein Backfill-Werkzeug für Jetstream-Lücken.