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 |
| 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 |
| 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
@@ -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),
Frontend grün. Zwei Vorbehalte:
* Die DB-gestützten Integrationstests sind *fail-open*ohne erreichbare Postgres/PDS
überspringen sie sich selbst und melden das nur auf stderr. Ein grüner Lauf ohne
laufenden Stack sagt also weniger, als er aussieht.
* Einige Tests hängen am Zustand der Dev-Datenbank; auf einer frischen DB können
`handle_sync`-Tests abweichen. Wer sie ernst nimmt, prüft sie gegen eine definierte DB.
* Die DB-gestützten Tests sind *fail-open*: **ohne `DATABASE_URL_APPVIEW` in der
Umgebung überspringen sie sich selbst** und melden das nur auf stderr. `cargo test
--workspace` in einer nackten Shell meldet dann grün, ohne sie ausgeführt zu haben —
für einen aussagekräftigen Lauf `set -a; . ./.env; set +a` voranstellen und PDS +
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
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) —
eigene Records erreichen die AppView nur über den Best-Effort-Push
`POST /internal/ingest-commit`.
* Die AppView-Leseschnittstelle hat **keine Auth** und CORS `Any`; bei
`/api/notifications` sind das erstmals halbwegs private Daten.
* `aud` wird beim Token-Check nicht validiert (Signatur, Ablauf, `scope` und
`sub` schon).
* Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der
Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer".
* Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das
@@ -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
(kein Nachlauf) — bei Jetstream möglich.
* `at-blob` spricht MinIO ohne Signature V4 — echtes AWS S3 funktioniert damit nicht.
* Die PDS liefert kein `.well-known/did.json`; `describeServer` gibt die DID hart
als `did:web:pds.maarcadetweet.local` zurück.
## Design
+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) │
│ │ │ │
│ /xrpc/com.atproto.* │ │ GET /api/timeline/home
│ /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…
/.well-known/did.json ──────────┼───┼─▶ Schlüssel für 🔒
│ │ │ GET /api/notifications… 🔒 │
│ │ │ GET /api/followers|following│
│ │ │ GET /healthz │
│ at-lexicon Validierung (160) │ │ │
@@ -50,6 +51,11 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
└──────────────────────────────────────────┘ 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:
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`
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
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 9). 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
@@ -296,7 +296,55 @@ Hinweise:
`PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn
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
@@ -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`
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`.
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 `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()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
```
APPVIEW_CORS_ORIGINS=tauri://localhost,http://tauri.localhost,http://127.0.0.1:1430
```
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.
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:
@@ -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 —
die CSP muss also für neue Backend-URLs nicht angefasst werden.
## 7. Health-Checks und Logs
## 8. Health-Checks und Logs
### 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 |
| `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
(`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
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
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.
* `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.