Compare commits

...
13 Commits
Author SHA1 Message Date
tomdeboneandClaude Opus 5 f7b78fd5db 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
2026-09-09 23:03:12 +02:00
tomdeboneandClaude Opus 5 9ee717bbc7 fix(tauri-app): Token an die AppView senden — und die Erneuerung reparieren
Die vier viewer-bezogenen AppView-Aufrufe (Timeline, Notifications,
Count, Seen) senden jetzt das Access-JWT. Ohne Session gibt es einen
sprechenden Fehler statt eines leeren Bearer-Headers.

Dabei kam heraus, dass die automatische Token-Erneuerung noch nie
funktioniert hat: isTokenInvalid() stieg mit `typeof e !== "object"`
sofort aus, aber Tauri lehnt bei Commands mit Result<T, String> mit
einem blanken String ab — der Zweig war seit seiner Einführung tot.
Belegt per Mutationstest: mit der alten Zeile fallen acht der neuen
Tests um. Die Prüfung liest den Fehlertext jetzt über einen Helfer,
der Strings und Objekte behandelt.

Dazu: der Badge-Poll bricht ab, wenn die Erneuerung endgültig
scheitert, statt weiter gegen einen 401 zu laufen. 503 AuthUnavailable
gilt dabei bewusst nicht als Auth-Fehler — die PDS kann kurz weg sein,
der Poll soll das überdauern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:03:12 +02:00
tomdeboneandClaude Opus 5 ac18ff7a16 test(appview): handle-sync-Tests messen wieder, was ihr Name sagt
Mit gesetztem DATABASE_URL_APPVIEW liefen diese Tests zum ersten Mal
überhaupt (ohne die Variable überspringen sie sich still) — und fielen
um. Zwei Ursachen:

1. Sechs Integrationstests hingen wie zuvor die Unit-Tests am globalen
   run_once()-Batch. select_candidates/resolve_batch sind dafür jetzt
   pub, damit auch die Integrationstests ihren eigenen DID durchreichen
   können statt zu hoffen, dass er es in den Batch schafft.
2. Die Dispatch-Tests für did:web und did:plc verdrahteten den fremden
   Stub als pds_resolver — also eine lokale PDS, die behauptet, eine
   fremde DID zu kennen. Die Moduldoku sagt ausdrücklich, dass die PDS
   vor der Methodenverzweigung befragt wird, damit ein did:key-Nutzer
   der eigenen PDS ohne Umweg über plc.directory auflöst. Die Fixtures
   haben also gegen die dokumentierte Regel getestet statt gegen die
   Verzweigung, um die es ihnen ging. Jetzt kennt die PDS-Stub die DID
   nicht, wie es der Realität entspricht.

Neu: pds_resolves_did_key_before_method_dispatch pinnt die PDS-zuerst-
Regel selbst — dasselbe DID-Verfahren, umgekehrtes Ergebnis, und der
Unterschied ist allein, ob die PDS den Nutzer hostet.

sync_skips_already_resolved prüft weiter über select_candidates: dass
ein DID mit Handle gar nicht erst bei einem Resolver landet, ist der
Punkt des Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:03:12 +02:00
tomdeboneandClaude Opus 5 73da8f0140 perf(appview): Profil, Cold-Start-Feed und Follow-Timeline entlasten
Gemessen gegen die Dev-Instanz (3,3 Mio. Posts):

* GET /api/profile/<handle>  9,5 s → 0,04 s
* Cold-Start-Timeline        7,4 s → 0,006 s
* Timeline mit 2300 Follows   28 s → 0,02 s

Drei unabhängige Ursachen, alle drei ein Seq-Scan über die posts-Tabelle:

1. resolve_profile sucht die DID über profiles.LOWER(handle) und, als
   Fallback, über posts.handle. Für beides gab es keinen Index. Auf
   profiles hatte Migration 0007 genau diesen Index entfernt, mit der
   Begründung, jeder Aufrufer leite ohnehin zuerst eine DID ab — das
   stimmt nicht mehr, seit resolve_profile den profiles-Cache zuerst
   befragt.
2. Der Cold-Start-Feed filtert `collection IN (…)` und sortiert nach
   indexed_at. Der vorhandene (collection, indexed_at, uri)-Index taugt
   dafür nicht: mit zwei führenden Werten liefert er keine
   indexed_at-Ordnung mehr. Ein partieller Index über genau das
   Prädikat schiebt den Filter in die Definition und lässt
   (indexed_at DESC, uri DESC) als Sortierschlüssel übrig.
3. Genau dieser neue Index wurde dann zur Falle für den Graph-Zweig:
   der Planer sah einen Index, der schon in indexed_at-Ordnung liefert,
   und nahm an, er treffe früh genug auf n passende Zeilen — bei dünn
   besetzten Followees hieß "früh" 2,87 Mio. verworfene Zeilen. Je nach
   Anzahl bisheriger Ausführungen des Prepared Statements kippte er
   zwischen diesem und dem guten Plan, was intermittierend aussah.

Der Graph-Zweig formuliert die Absicht jetzt aus: pro Followee die
neuesten Posts über ein LATERAL, dann mergen. Damit ist der globale
Scan kein wählbarer Plan mehr, und jede Iteration ist ein begrenzter
Range-Scan auf posts_did_indexed_at_uri_idx. Korrekt ist das, weil die
globalen Top-N immer eine Teilmenge der Vereinigung der Top-N je
Followee sind — deshalb wird pro Followee limit+1 geholt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:02:47 +02:00
tomdeboneandClaude Opus 5 a2a371b7d9 feat(appview): Bearer-Auth für Timeline und Notifications
Die AppView hatte keinerlei Authentifizierung: jeder konnte
/api/notifications?did=<beliebig> lesen und per /seen als gelesen
markieren. Mit Phase 8 sind das die ersten privaten Daten im System.

Das Access-JWT der PDS trug von Anfang an sub, scope
"com.atproto.access" und aud "did:web:appview…" — es war für die
AppView ausgestellt, nur hat sie es nie geprüft. Neu ist deshalb vor
allem die Schlüsselbeschaffung: auth.rs holt das DID-Dokument der PDS
(PDS_INTERNAL_URL, sonst PDS_PUBLIC_URL), cached den Schlüssel und lädt
ihn bei einem Verifikationsfehler nach — höchstens einmal pro Minute,
damit Müll-Tokens kein Werkzeug werden, die PDS zu fluten. Ein
Schlüsselwechsel braucht damit keinen Neustart.

Ist die PDS beim Start weg, warnt die AppView nur und startet trotzdem
(sie indiziert den Firehose, der von der lokalen PDS unabhängig ist).
Ist der Schlüssel beim Prüfen eines Tokens nicht zu beschaffen, gibt es
503 — fail closed.

Geschützt: /api/timeline/home und die drei Notification-Endpoints, jeweils
mit sub == did. Öffentlich bleiben Profile, Suche, Posts, Threads und die
Follower-Listen; das sind in AT Proto öffentliche Records.

401 AuthMissing / 401 TokenInvalid / 403 Forbidden / 503 AuthUnavailable.
TokenInvalid ist ein Vertrag mit dem Client: daran erkennt er, dass er
sein Token erneuern und einmal wiederholen muss.

Dazu CORS: statt Any für alles jetzt eine Allowlist über
APPVIEW_CORS_ORIGINS (unset = altes Verhalten plus Warnung), und
/internal/ingest-commit liegt außerhalb der CORS-Schicht — die Route
wird server-zu-server aufgerufen, ein Allow-Origin darauf würde nur
einer Webseite helfen, in den Index zu schreiben.

APPVIEW_AUTH_REQUIRED=false stellt das alte Verhalten her (VPN-Instanz,
fail-open-Tests) und warnt beim Start in Großbuchstaben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:02:27 +02:00
tomdeboneandClaude Opus 5 786a892658 feat(pds): DID-Dokument unter /.well-known/did.json ausliefern
Die AppView soll die Access-Tokens der PDS prüfen können, ohne dass
PDS_JWT_SECRET den PDS-Prozess verlässt. Verifiziert wird ES256 mit dem
*öffentlichen* Teil des P-256-Schlüssels — den veröffentlicht die PDS
jetzt als verificationMethod (Multikey) in ihrem DID-Dokument.

Damit fällt auch die hartkodierte Service-DID: describeServer gab stur
did:web:pds.maarcadetweet.local zurück, unabhängig von PDS_PUBLIC_URL.
Beide Endpoints leiten sie jetzt aus einer Quelle ab
(AppConfig::pds_did(), did:web-Regel mit %3A-kodiertem Port). Der `iss`
des Access-Tokens baute die DID zuvor ohne Port-Kodierung zusammen —
also in einer Form, der kein did:web-Resolver folgen könnte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:01:16 +02:00
tomdeboneandClaude Opus 5 ec8fe187fe 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
2026-09-09 21:37:12 +02:00
tomdeboneandClaude Opus 5 31880e1005 feat(tauri-app): Notifications-View mit Badge, Follower-Listen, DID-Navigation
Bindet die neuen AppView-Endpoints an: sechs IPC-Commands
(fetch_notifications, notification_count, mark_notifications_seen,
fetch_followers, fetch_following, fetch_thread) plus profile_get_by_did,
dazu die TS-Gegenstücke.

* NotificationsView: Liste mit Icon/Text je Art, Avatar, Vorschau des
  subject_text, Cursor-Pagination; Klick auf eine Zeile mit Subject öffnet
  den Thread. Beim Öffnen wird mit dem indexed_at der obersten Zeile als
  Wasserzeichen quittiert.
* NavRail: Eintrag mit Unread-Badge, gepollt im vorhandenen 5s-Timer und
  über den bestehenden Teardown-Pfad abgeräumt; der Poll pausiert, solange
  die Liste offen ist.
* ProfileView: Follower- und Following-Zahlen sind jetzt Buttons und öffnen
  die jeweilige Liste inline.

Navigiert wird über die DID, nicht über den Handle: für Actors, die weder
profiles noch posts kennen, liefert die AppView einen abgeschnittenen
Platzhalter im handle-Feld ("did:plc:abcd…"), und ein Klick darauf landete
in einem synthetischen Leerprofil. Die echte DID steht im DTO und wird jetzt
explizit durchgereicht — keine Heuristik auf das Platzhalter-Format.

Dabei aufgefallen und mitgefixt: ProfileView lud nur in onMount, obwohl die
Komponente bei Profil-zu-Profil-Navigation gemountet bleibt — Posts und
Zahlen des vorherigen Nutzers wären unter dem neuen Namen stehengeblieben.
Jetzt ein auf (did, handle) gekeyter Effekt.

Das Thread-Overlay hing im else-Zweig der leeren Timeline und wäre aus dem
Notifications-View unsichtbar gewesen; es ist jetzt ein Snippet, das beide
Views rendern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:37:00 +02:00
tomdeboneandClaude Opus 5 bce4c7862f test(pds-server): listRepos-Test unabhängig von der Tabellengröße machen
sync_list_repos_includes_recent_user paginierte von vorn durch listRepos und
gab nach 50 Seiten à 50 Zeilen auf. Die repos-Tabelle der Dev-Instanz ist
inzwischen auf ~3800 Zeilen gewachsen, der frisch angelegte DID sortierte
dahinter — der Test war rot, obwohl der Endpoint korrekt antwortet
(manuell mit passendem Cursor verifiziert).

Der Cursor startet jetzt unmittelbar *vor* dem Ziel-DID. did_cursor_lt()
taugte dafür nicht: es dekrementiert das erste Byte und landet damit vor
jedem did:..., also wieder am Tabellenanfang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:47 +02:00
tomdeboneandClaude Opus 5 465a88e4e5 test(appview): handle-sync-Tests vom globalen DB-Zustand entkoppeln
Fünf handle_sync-Tests schlugen fehl, sobald der Indexer parallel lief oder
die Datenbank schon andere handle-lose Posts enthielt: run_once() scannt
global, geordnet nach did und gedeckelt auf BATCH_SIZE, also landete der
frisch geseedete Test-DID schlicht nicht im Batch — die Assertions über
report.resolved sagten dann etwas über fremde Zeilen aus.

run_once() ist jetzt select_candidates() + resolve_batch(dids); das
Verhalten in Produktion ist unverändert. Die Dispatch-Tests treiben
resolve_batch mit ihrem eigenen DID, der Batch-Limit-Test prüft den Deckel
dort, wo er sitzt (im SELECT), statt ihn aus einem globalen Report
abzuleiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:47 +02:00
tomdeboneandClaude Opus 5 c4ca218d97 feat(appview): Notifications, Follower-/Following-Listen, Thread-Route
Bisher erfuhr ein Nutzer nie, dass jemand anderes mit ihm interagiert hat:
Like, Repost, Follow und Reply hinterließen keine Spur, an der der Client
hätte pollen können. Der Tray-/Notification-Pfad im Desktop-Client (Phase 7)
hing damit in der Luft.

Migration 0008:
* notifications(recipient, author, kind, subject_uri, created_at,
  indexed_at, read_at) mit Keyset-Index (recipient, indexed_at DESC, id DESC)
  und Partial-Index auf ungelesene Zeilen für den Badge-Poll.
* Dedupe-Unique-Index über COALESCE(subject_uri, '') — plain NULLs
  kollidieren nicht, sonst gäbe es pro Follow beliebig viele Zeilen.
  Folge: Unlike-Relike erzeugt keine zweite Notification, Toggle-Spam ist
  damit ausgeschlossen.
* Bewusst kein CHECK (recipient <> author): ein Ausrutscher dort würde die
  umgebende Like-Transaktion abbrechen, also das Like wegen eines
  Notification-Bugs verlieren. Gefiltert wird in Rust und im INSERT.

Indexer: record_notification() hängt an upsert_like/-repost (in derselben
Transaktion wie die Counter) sowie upsert_follow/-post. Selbst-Interaktionen
sind still. Empfänger muss uns bekannt sein (profiles- oder posts-Zeile),
sonst würden wir für den gesamten öffentlichen Firehose Zeilen anlegen —
als ein INSERT ... SELECT ... WHERE EXISTS, also ohne TOCTOU-Fenster.
Reply-Notifications tragen die URI der *Antwort* als subject_uri, weil die
Liste den Text zeigt, den der Empfänger noch nicht kennt.

Endpoints: GET /api/notifications, /api/notifications/count,
POST /api/notifications/seen (seenAt als Wasserzeichen),
GET /api/followers, /api/following, GET /api/thread (beide Schreibweisen).
Cursor-Codec, Limit-Clamping und Fehlerform sind die der bestehenden
Endpoints.

/api/post/*uri bleibt wire-kompatibel und teilt sich jetzt
load_thread_context() mit /api/thread — mit max_parents = 1, weil es nur
den direkten Parent serialisiert; die volle Ahnenkette wären bis zu 20
sequenzielle Queries für Zeilen, die danach verworfen werden.

Nebenbei ein Darstellungsfehler: der synthetische Platzhalter-Handle für
Actors ohne bekannten Handle trug ein führendes '@', während jeder Consumer
selbst '@{handle}' rendert — im Feed kam '@@did:plc:abcd…' heraus. Der
Platzhalter ist jetzt durchgängig sigil-frei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:32 +02:00
tomdeboneandClaude Opus 5 9d009bfcba feat(at-blob): InMemoryBlobStore echt implementieren
Der Store war ein Platzhalter aus vier unimplemented!() — jeder Codepfad,
der ihn statt S3BlobStore benutzt hätte, wäre gepaniced.

Backing store ist eine RwLock<HashMap<key, (Bytes, mime)>>, damit der Typ
Send + Sync bleibt und hinter Arc<dyn BlobStore> funktioniert. put()
berechnet die CID identisch zu s3.rs (sha256 → cid_for_raw(0x55, hash)),
get() liefert None statt Fehler, delete() ist idempotent, public_url()
zeigt auf die vorhandene PDS-Route /blob/:cid.

5 Tests: Roundtrip, fehlender Key, delete-dann-get, delete auf Unbekanntes,
gleiche Bytes → gleiche CID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:36:00 +02:00
tomdeboneandClaude Opus 5 3c6f4dd67c chore(config): Binaries laden .env selbst; .env.example korrigiert
`cp .env.example .env && cargo run` — der im README dokumentierte Ablauf —
schlug bisher mit `missing env: PDS_HOST` fehl: nichts im Prozess hat die
Datei je gelesen. Beide Bins rufen jetzt als erstes `dotenvy::dotenv()` auf;
echte Umgebungsvariablen gewinnen weiterhin.

Dazu .env.example am Code verifiziert:

* PDS_JWT_SECRET war weder Hex noch ein gültiger P-256-Skalar. jwt_issuer.rs
  macht hex::decode + p256::SecretKey::from_bytes; ein ungültiger Wert lässt
  den Server starten, aber jeder Pfad über server_p256_public_multibase
  antwortet 500 — also nicht nur create/refreshSession, sondern auch jeder
  Record-Write (repo.rs, feed.rs, blob.rs, profile.rs).
* JETSTREAM_COLLECTIONS fehlten app.twi.post (das eigene 160-Zeichen-Lexicon)
  und app.bsky.actor.profile, obwohl der Indexer beide verarbeitet.
* APP_ENV entfernt — wird nirgends gelesen.
* PDS_INTERNAL_URL, APPVIEW_INTERNAL_URL, APPVIEW_HANDLE_SYNC_INTERVAL_SECS
  und die MAARCADETWEET_*-Overrides des Clients ergänzt.
* S3_BUCKET_APPVIEW als das markiert, was es ist: Pflichtvariable ohne Leser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:35:53 +02:00
44 changed files with 9122 additions and 413 deletions
+60 -5
View File
@@ -1,26 +1,46 @@
# ===================================================== # =====================================================
# maarcadetweet — environment # maarcadetweet — environment
# ===================================================== # =====================================================
# Copy to .env and adjust. # Copy to .env and adjust. Beide Binaries laden `.env` beim Start
# selbst (dotenvy); echte Umgebungsvariablen haben Vorrang.
# --- General --- # --- General ---
RUST_LOG=info,maarcadetweet=debug,sqlx=warn RUST_LOG=info,maarcadetweet=debug,sqlx=warn
APP_ENV=dev
# --- PDS server --- # --- PDS server ---
PDS_HOST=127.0.0.1 PDS_HOST=127.0.0.1
PDS_PORT=2583 PDS_PORT=2583
PDS_PUBLIC_URL=http://127.0.0.1:2583 PDS_PUBLIC_URL=http://127.0.0.1:2583
PDS_HANDLE_DNS_ZONE=.maarcadetweet.local PDS_HANDLE_DNS_ZONE=.maarcadetweet.local
PDS_JWT_SECRET=change-me-to-a-32-byte-random-string-please # MUSS Hex sein, >= 32 Bytes ergeben und ein gültiger P-256-Skalar
# sein (also nicht lauter Nullen) — `jwt_issuer.rs` macht hex::decode()
# und p256::SecretKey::from_bytes(). Ein ungültiger Wert lässt den
# Server zwar starten, aber alles, was den Serverschlüssel ableitet,
# antwortet mit 500: createAccount/createSession/refreshSession UND
# jeder Record-Write (repo.rs, feed.rs, blob.rs, profile.rs).
# Der Wert hier ist ein Zufallswert fürs Beispiel — für echte
# Instanzen einen eigenen erzeugen:
# openssl rand -hex 32
PDS_JWT_SECRET=522084586f3d3abb835d43b7c4726735d78803dd58490f62a92571cd29033a95
# Wohin die PDS ihre Commits pusht (POST /internal/ingest-commit).
# Default: APPVIEW_PUBLIC_URL.
# APPVIEW_INTERNAL_URL=http://127.0.0.1:2584
# --- AppView service --- # --- AppView service ---
APPVIEW_HOST=127.0.0.1 APPVIEW_HOST=127.0.0.1
APPVIEW_PORT=2584 APPVIEW_PORT=2584
APPVIEW_PUBLIC_URL=http://127.0.0.1:2584 APPVIEW_PUBLIC_URL=http://127.0.0.1:2584
JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe
# Collections the AppView will index # Collections, die die AppView indexiert. `app.twi.post` ist das
JETSTREAM_COLLECTIONS=app.bsky.feed.post,app.bsky.feed.like,app.bsky.feed.repost,app.bsky.graph.follow # eigene 160-Zeichen-Lexicon und muss mit rein, sonst tauchen eigene
# Posts nur über den PDS-Push auf; `app.bsky.actor.profile` füttert
# den Profil-Cache.
JETSTREAM_COLLECTIONS=app.twi.post,app.bsky.feed.post,app.bsky.feed.like,app.bsky.feed.repost,app.bsky.graph.follow,app.bsky.actor.profile
# Für den Handle-Sync-Worker: welche PDS nach lokalen Handles gefragt
# wird. Default: PDS_PUBLIC_URL.
# PDS_INTERNAL_URL=http://127.0.0.1:2583
# Intervall des Handle-Sync-Workers in Sekunden (Default: 300).
# APPVIEW_HANDLE_SYNC_INTERVAL_SECS=60
# --- Databases --- # --- Databases ---
DATABASE_URL_PDS=postgres://pds:pds@127.0.0.1:5434/pds DATABASE_URL_PDS=postgres://pds:pds@127.0.0.1:5434/pds
@@ -32,6 +52,9 @@ S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin S3_SECRET_KEY=minioadmin
S3_BUCKET_PDS=maarcadetweet-pds S3_BUCKET_PDS=maarcadetweet-pds
# Pflichtvariable in AppConfig::from_env(), wird derzeit von keinem
# Code-Pfad gelesen — muss gesetzt sein, damit der Start nicht
# fehlschlägt.
S3_BUCKET_APPVIEW=maarcadetweet-appview S3_BUCKET_APPVIEW=maarcadetweet-appview
# --- PLC Directory (dev: leave default; can mock) --- # --- PLC Directory (dev: leave default; can mock) ---
@@ -39,4 +62,36 @@ PLC_DIRECTORY_URL=https://plc.directory
# PLC_DIRECTORY_URL=http://127.0.0.1:2582 # PLC_DIRECTORY_URL=http://127.0.0.1:2582
# --- AppView ingest auth (optional, dev ok if unset) --- # --- AppView ingest auth (optional, dev ok if unset) ---
# Wenn gesetzt, muss die PDS denselben Wert als Header
# `X-Ingest-Secret` mitschicken; ist er nicht gesetzt, nimmt
# /internal/ingest-commit unauthentifizierte Writes entgegen (jeder,
# der den Port erreicht, kann Posts/Follows/Notifications fälschen).
# Die AppView warnt beim Start, solange er fehlt.
# APPVIEW_INGEST_SECRET=change-me-to-a-shared-secret-between-pds-and-appview # APPVIEW_INGEST_SECRET=change-me-to-a-shared-secret-between-pds-and-appview
# --- AppView auth (Bearer-Token der PDS) ---
# Erzwingt ein gültiges Access-JWT auf den privaten Endpoints
# (/api/notifications, /api/notifications/count,
# /api/notifications/seen, /api/timeline/home); `sub` im Token muss
# dem `did`-Parameter entsprechen, sonst 403. Default: true.
# Den öffentlichen P-256-Schlüssel holt sich die AppView von
# `PDS_INTERNAL_URL` (sonst `PDS_PUBLIC_URL`) unter
# /.well-known/did.json — `PDS_JWT_SECRET` verlässt die PDS nie.
# Auf `false` verhält sich die AppView wie vor der Auth-Einführung
# (alles öffentlich): nötig für die fail-open-Integrationstests mit
# synthetischen DIDs und für eine Instanz, die schon per VPN
# abgeschottet ist. Die AppView warnt beim Start laut, wenn er aus ist.
# APPVIEW_AUTH_REQUIRED=true
# Kommaseparierte Allowlist der Browser-Origins, die `/api/*` aufrufen
# dürfen. Nicht gesetzt = bisheriges Verhalten
# (`Access-Control-Allow-Origin: *`) plus Startup-Warnung.
# Der Tauri-Webview hat je nach Plattform eine eigene Origin:
# macOS/iOS `tauri://localhost`, Windows `http://tauri.localhost`,
# im Dev-Modus der Vite-Server `http://127.0.0.1:1430`.
# `Authorization` steht immer in den erlaubten Headern (sonst
# scheitert der Preflight), `allow_credentials` bleibt aus.
# APPVIEW_CORS_ORIGINS=tauri://localhost,http://127.0.0.1:1430
# --- Tauri-Client (Build-/Laufzeit-Overrides des Desktop-Clients) ---
# MAARCADETWEET_PDS_URL=http://127.0.0.1:2583
# MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584
Generated
+3
View File
@@ -51,7 +51,9 @@ dependencies = [
"axum", "axum",
"base64", "base64",
"chrono", "chrono",
"dotenvy",
"futures", "futures",
"p256",
"reqwest", "reqwest",
"rustls", "rustls",
"serde", "serde",
@@ -1882,6 +1884,7 @@ dependencies = [
"chrono", "chrono",
"ciborium", "ciborium",
"cid", "cid",
"dotenvy",
"hex", "hex",
"k256", "k256",
"p256", "p256",
+1
View File
@@ -54,6 +54,7 @@ blake3 = "1"
rand = "0.8" rand = "0.8"
rand_core = "0.6" rand_core = "0.6"
hex = "0.4" hex = "0.4"
dotenvy = "0.15"
base64 = "0.22" base64 = "0.22"
parking_lot = "0.12" parking_lot = "0.12"
async-stream = "0.3" async-stream = "0.3"
+52 -18
View File
@@ -12,20 +12,22 @@ crates/
├── at-identity/ DID, PLC, Handle-Resolution ├── at-identity/ DID, PLC, Handle-Resolution
├── at-mst/ Merkle-Search-Tree ├── at-mst/ Merkle-Search-Tree
├── at-repo/ Repos, Commits, Blöcke, TID-Revs ├── at-repo/ Repos, Commits, Blöcke, TID-Revs
├── at-blob/ S3-kompatibler Blob-Store (MinIO) ├── at-blob/ S3-kompatibler Blob-Store (MinIO) + In-Memory-Store
├── at-firehose/ Jetstream-Consumer (WebSocket) ├── at-firehose/ Jetstream-Consumer (WebSocket)
├── at-shared/ Config, Errors, DID, Cursor ├── at-shared/ Config, Errors, DID, Cursor
├── pds-server/ axum HTTP PDS (bin) ├── pds-server/ axum HTTP PDS (bin)
└── appview/ Jetstream-Indexer + REST-API (bin) └── appview/ Jetstream-Indexer + REST-API (bin)
crates/tauri-app/ Tauri 2 + Svelte 5 + Vite + TS Desktop-Client crates/tauri-app/ Tauri 2 + Svelte 5 + Vite + TS Desktop-Client
├── src/ Svelte-Components (Terminal, StatusBar, NavRail, PostCard, ComposeBox, LoginScreen) ├── src/ Svelte-Components (Terminal, StatusBar, NavRail, PostCard, ComposeBox,
│ LoginScreen, ProfileView, NotificationsView)
├── src/lib/styles/ tokens.css (1:1 vom maarcade-Design) ├── src/lib/styles/ tokens.css (1:1 vom maarcade-Design)
└── src-tauri/ Rust-IPC-Layer └── src-tauri/ Rust-IPC-Layer
lexicons/app/twi/post.json Custom Lexicon mit maxLength: 160 lexicons/app/twi/post.json Custom Lexicon mit maxLength: 160
migrations/pds/ PDS-DB-Schema (users, repos, blobs, sessions, plc_ops) migrations/pds/ PDS-DB-Schema (users, repos, blobs, sessions, plc_ops)
migrations/appview/ AppView-DB-Schema (posts, likes, follows, timeline_cache, jetstream_cursor) migrations/appview/ AppView-DB-Schema (posts, likes, follows, notifications, profiles, jetstream_cursor)
docs/ Deployment, Architektur, Tauri-Release (siehe unten)
``` ```
## Setup ## Setup
@@ -34,7 +36,8 @@ migrations/appview/ AppView-DB-Schema (posts, likes, follows, timelin
# 1) Datenbanken + MinIO starten # 1) Datenbanken + MinIO starten
docker compose up -d docker compose up -d
# 2) Umgebungsvariablen # 2) Umgebungsvariablen — beide Binaries laden .env selbst (dotenvy).
# PDS_JWT_SECRET muss Hex sein: openssl rand -hex 32
cp .env.example .env cp .env.example .env
# 3) Workspace kompilieren + Tests # 3) Workspace kompilieren + Tests
@@ -61,26 +64,57 @@ cargo run -p appview
| 2 MST + Repo (Spec-konforme CBOR-Encoding) | ✅ done — `encode_key` = `base64url(sha256(raw_key))` per atproto-Spec, `split_around`/`wrap_with_split` threaden den recursive right_sub korrekt als `k_tree` weiter. 27 MST + 13 Repo + 4 Commit Tests grün. | | 2 MST + Repo (Spec-konforme CBOR-Encoding) | ✅ done — `encode_key` = `base64url(sha256(raw_key))` per atproto-Spec, `split_around`/`wrap_with_split` threaden den recursive right_sub korrekt als `k_tree` weiter. 27 MST + 13 Repo + 4 Commit Tests grün. |
| 3 PDS-Server (com.atproto.* XRPC) | ✅ done — createAccount/Session/Refresh, createRecord/deleteRecord, like/repost, follow | | 3 PDS-Server (com.atproto.* XRPC) | ✅ done — createAccount/Session/Refresh, createRecord/deleteRecord, like/repost, follow |
| 4 AppView-Foundation (Jetstream-Index) | ✅ done — Jetstream-Indexer + identity-Event-Backfill + PLC-handle-sync-Worker | | 4 AppView-Foundation (Jetstream-Index) | ✅ done — Jetstream-Indexer + identity-Event-Backfill + PLC-handle-sync-Worker |
| 5 AppView-REST-API | ✅ done — timeline, profile (by-did + by-handle), search, post-by-uri, thread-context | | 5 AppView-REST-API | ✅ done — timeline, profile (by-did + by-handle), search, post-by-uri, thread, notifications, followers/following |
| 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 deaktiviert (siehe `_comment` in `tauri.conf.json` für Production-Setup) | | 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 ## Tests
``` ```bash
running 16 tests (at-crypto) cargo test --workspace # Rust: PDS, AppView, at-* Crates
test result: ok. 16 passed; 0 failed; 0 ignored cd crates/tauri-app && npx vitest run # Svelte/TS
running 3 tests (at-lexicon) cd crates/tauri-app/src-tauri && cargo test # Tauri-IPC (eigener Workspace!)
test result: ok. 3 passed; 0 failed
running 2 tests (at-shared)
test result: ok. 2 passed; 0 failed
running 2 tests (at-repo)
test result: ok. 2 passed; 0 failed
running 4 tests (at-crypto plc_op — Phase 1)
test result: ok. 4 passed; 0 failed
``` ```
Der zuvor als "geplant für Phase 1" markierte `jwt::issue_and_verify`-Test wurde zwischenzeitlich grün gezogen (P-256-PKCS#8-PEM-Encoder ist über `p256::pkcs8::EncodePrivateKey` da). Stand zuletzt gegen den lokalen Dev-Stack (docker compose + laufender PDS + AppView):
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 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.
## Dokumentation
| Datei | Inhalt |
|---|---|
| [`docs/architecture.md`](docs/architecture.md) | Crate-Verantwortlichkeiten, Datenfluss PDS → Jetstream → AppView → Client, Tabellenübersicht |
| [`docs/deployment.md`](docs/deployment.md) | Betrieb: docker compose, Migrationen, alle Env-Variablen, Release-Build, systemd-Units, Reverse-Proxy, Health-Checks |
| [`docs/tauri-release.md`](docs/tauri-release.md) | Signing-Keys, Updater-Config, `latest.json`, Build pro Plattform, Artefaktpfade |
## Bekannte Lücken
* 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`.
* `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
Updater-Plugin ist nicht installiert (siehe `docs/tauri-release.md`).
* 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.
## Design ## Design
+6
View File
@@ -19,6 +19,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
tokio = { workspace = true } tokio = { workspace = true }
dotenvy = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
tower = { workspace = true } tower = { workspace = true }
tower-http = { workspace = true } tower-http = { workspace = true }
@@ -34,6 +35,7 @@ at-shared = { workspace = true }
at-firehose = { workspace = true } at-firehose = { workspace = true }
at-crypto = { workspace = true } at-crypto = { workspace = true }
at-identity = { workspace = true } at-identity = { workspace = true }
reqwest = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging", "tls12"] }
base64 = { workspace = true } base64 = { workspace = true }
@@ -44,3 +46,7 @@ tokio = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
# Unit tests in `src/auth.rs` build a P-256 verification key in the
# same `0x8012 + uncompressed point` encoding the PDS publishes, which
# needs the curve's `ToEncodedPoint`.
p256 = { workspace = true }
+659
View File
@@ -0,0 +1,659 @@
//! Bearer-token authentication for the AppView's private endpoints.
//!
//! ## What is being proven
//!
//! The PDS already issues an ES256 access JWT on
//! `com.atproto.server.createSession` / `createAccount`. That token
//! carries `sub = <did>`, `scope = "com.atproto.access"` and
//! `aud = "did:web:appview.maarcadetweet.local"` — it was always meant
//! to be presented *here*. All the AppView has to do is verify the
//! signature and read `sub`.
//!
//! ## Where the key comes from
//!
//! Verifying an ES256 signature needs only the public half of the PDS's
//! P-256 key. The PDS publishes it in its DID document at
//! `GET /.well-known/did.json` (see `pds-server/src/main.rs`), so
//! `PDS_JWT_SECRET` never leaves the PDS process. We fetch that
//! document once, cache the `publicKeyMultibase` behind an `RwLock`,
//! and re-fetch on a verification failure so an operator can rotate
//! `PDS_JWT_SECRET` without restarting the AppView.
//!
//! Two failure modes are deliberately handled differently:
//!
//! - **PDS unreachable at startup.** The AppView has always booted
//! independently of the PDS (it indexes the firehose, which has
//! nothing to do with the local PDS), and a crash-loop on a
//! colocated service that happens to boot second would be a
//! self-inflicted outage. So startup only *warns*; the first
//! authenticated request retries the fetch.
//! - **PDS unreachable when a token must be checked.** There is no
//! safe way to guess, so the request gets a `503`. Fail closed —
//! never fail open.
//!
//! ## Error contract
//!
//! The body shape matches every other AppView error
//! (`{"error": …, "message": …}`). The `error` codes are load-bearing:
//!
//! | case | status | `error` |
//! |----------------------------------------|--------|-------------------|
//! | no / malformed `Authorization` header | 401 | `AuthMissing` |
//! | bad signature, expired, wrong `scope` | 401 | `TokenInvalid` |
//! | valid token, but `sub` ≠ requested did | 403 | `Forbidden` |
//! | PDS key not obtainable | 503 | `AuthUnavailable` |
//!
//! **`TokenInvalid` is a contract with the desktop client**: seeing it,
//! the client refreshes its access JWT (`com.atproto.server.refreshSession`)
//! and retries the request once. Renaming it silently logs every user
//! out an hour after login.
use at_crypto::jwt::JwtClaims;
use axum::async_trait;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde_json::{json, Value};
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, warn};
use crate::state::AppState;
/// The scope an *access* token carries. Refresh tokens are minted with
/// `com.atproto.refresh` by the same key, so without this check a
/// refresh token — which lives for 90 days instead of an hour — would
/// be accepted as a session credential everywhere.
const ACCESS_SCOPE: &str = "com.atproto.access";
/// Minimum spacing between two key re-fetches triggered by a failed
/// verification.
///
/// Re-fetching on failure is what makes key rotation work without a
/// restart. Doing it on *every* failure would also hand anyone who can
/// reach the AppView a free amplifier: a stream of garbage tokens
/// becomes a stream of requests to the PDS. One re-fetch per minute is
/// far quicker than any plausible rotation cadence needs and costs the
/// PDS nothing.
const KEY_REFETCH_MIN_INTERVAL: Duration = Duration::from_secs(60);
/// HTTP timeout for the DID-document fetch. The PDS is colocated; if it
/// doesn't answer in two seconds it isn't answering, and a request
/// blocked on auth is a request the user is staring at.
const DID_DOC_TIMEOUT: Duration = Duration::from_secs(2);
// -- error type --------------------------------------------------------------
/// Everything that can go wrong between "a request arrived" and "we know
/// which DID it belongs to". Converted into the AppView's standard error
/// body by [`AuthError::into_response_parts`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthError {
/// No `Authorization` header, or one that isn't `Bearer <token>`.
Missing,
/// Signature, expiry or scope check failed. The client's cue to
/// refresh its access token and retry.
Invalid(String),
/// Authenticated fine, but the token belongs to somebody else.
Forbidden,
/// We could not obtain the PDS's public key, so we cannot decide.
Unavailable(String),
}
impl AuthError {
pub fn into_response_parts(self) -> (StatusCode, Json<Value>) {
let (status, code, message) = match self {
AuthError::Missing => (
StatusCode::UNAUTHORIZED,
"AuthMissing",
"missing Authorization: Bearer header".to_string(),
),
AuthError::Invalid(detail) => {
(StatusCode::UNAUTHORIZED, "TokenInvalid", detail)
}
AuthError::Forbidden => (
StatusCode::FORBIDDEN,
"Forbidden",
"token sub does not match the requested did".to_string(),
),
AuthError::Unavailable(detail) => (
StatusCode::SERVICE_UNAVAILABLE,
"AuthUnavailable",
format!("cannot verify tokens: {detail}"),
),
};
(
status,
Json(json!({
"error": code,
"message": message,
})),
)
}
}
impl From<AuthError> for (StatusCode, Json<Value>) {
fn from(e: AuthError) -> Self {
e.into_response_parts()
}
}
// -- key cache ---------------------------------------------------------------
#[derive(Default)]
struct CachedKey {
multibase: Option<String>,
/// When we last *attempted* a fetch — successful or not. Throttles
/// the on-failure refresh path; see [`KEY_REFETCH_MIN_INTERVAL`].
last_attempt: Option<Instant>,
}
/// The PDS signing key, lazily fetched and cached.
///
/// Lives in [`AppState`] behind an `Arc`, so all handlers share one
/// cache and one HTTP client.
pub struct PdsKeys {
http: reqwest::Client,
/// Fully-qualified URL of the PDS's DID document.
did_doc_url: String,
inner: RwLock<CachedKey>,
}
impl PdsKeys {
/// Build a cache pointed at `base_url` (no trailing slash required).
pub fn new(base_url: &str) -> Self {
let http = reqwest::Client::builder()
.timeout(DID_DOC_TIMEOUT)
.build()
.expect("reqwest client build should never fail");
Self {
http,
did_doc_url: format!(
"{}/.well-known/did.json",
base_url.trim_end_matches('/')
),
inner: RwLock::new(CachedKey::default()),
}
}
/// Same PDS the handle-sync worker talks to: `PDS_INTERNAL_URL`
/// when set, else `PDS_PUBLIC_URL`.
pub fn from_config(cfg: &at_shared::config::AppConfig) -> Self {
Self::new(&cfg.pds_base_url())
}
pub fn did_doc_url(&self) -> &str {
&self.did_doc_url
}
/// The cached key, if we have ever fetched one.
pub async fn cached(&self) -> Option<String> {
self.inner.read().await.multibase.clone()
}
/// Fetch the DID document and replace the cached key.
///
/// Called once at startup (best effort), on the first authenticated
/// request if startup failed, and — throttled — after a failed
/// verification.
pub async fn refresh(&self) -> anyhow::Result<String> {
// Record the attempt before the await so two concurrent
// failures can't both decide they're the first one.
self.inner.write().await.last_attempt = Some(Instant::now());
let resp = self.http.get(&self.did_doc_url).send().await?;
if !resp.status().is_success() {
anyhow::bail!(
"{} returned {}",
self.did_doc_url,
resp.status().as_u16()
);
}
let doc: Value = resp.json().await?;
let key = extract_public_key_multibase(&doc)?;
{
let mut guard = self.inner.write().await;
guard.multibase = Some(key.clone());
}
debug!(url = %self.did_doc_url, "loaded PDS signing key");
Ok(key)
}
/// The key to verify with, fetching it if we don't have one yet.
async fn key_or_fetch(&self) -> Result<String, AuthError> {
if let Some(k) = self.cached().await {
return Ok(k);
}
self.refresh().await.map_err(|e| {
warn!(url = %self.did_doc_url, error = %e, "PDS signing key unavailable");
AuthError::Unavailable(format!(
"PDS did document at {} not reachable: {e}",
self.did_doc_url
))
})
}
/// Re-fetch after a verification failure, at most once per
/// [`KEY_REFETCH_MIN_INTERVAL`]. Returns the new key only when it
/// actually differs from `stale` — re-running the same failing
/// verification against an unchanged key proves nothing.
async fn refetch_if_stale(&self, stale: &str) -> Option<String> {
{
let guard = self.inner.read().await;
if let Some(last) = guard.last_attempt {
if last.elapsed() < KEY_REFETCH_MIN_INTERVAL {
return None;
}
}
}
match self.refresh().await {
Ok(fresh) if fresh != stale => {
warn!("PDS signing key changed; re-verifying with the rotated key");
Some(fresh)
}
Ok(_) => None,
Err(e) => {
warn!(error = %e, "PDS key re-fetch after verification failure failed");
None
}
}
}
/// Verify an access token and return its claims.
///
/// Retries exactly once against a freshly fetched key, so a rotated
/// `PDS_JWT_SECRET` costs one extra HTTP round trip rather than a
/// restart.
pub async fn verify_access_token(&self, token: &str) -> Result<JwtClaims, AuthError> {
let key = self.key_or_fetch().await?;
match verify_with_key(token, &key) {
Ok(claims) => Ok(claims),
Err(first) => {
let Some(fresh) = self.refetch_if_stale(&key).await else {
return Err(first);
};
verify_with_key(token, &fresh).map_err(|_| first)
}
}
}
}
/// Pull `verificationMethod[].publicKeyMultibase` out of a DID document.
///
/// We take the first entry that carries a `publicKeyMultibase` rather
/// than insisting on a fragment name: the PDS writes `#atproto`, but a
/// document served by a proxy or a future PDS version may order or name
/// its methods differently, and any key in the document is a key the
/// controller published for itself. A document with none is an error,
/// not an empty key — silently caching `""` would turn every later
/// verification into a confusing signature failure.
fn extract_public_key_multibase(doc: &Value) -> anyhow::Result<String> {
let methods = doc
.get("verificationMethod")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("did document has no verificationMethod array"))?;
for m in methods {
if let Some(k) = m.get("publicKeyMultibase").and_then(|v| v.as_str()) {
if !k.is_empty() {
return Ok(k.to_string());
}
}
}
anyhow::bail!("did document has no verificationMethod with publicKeyMultibase")
}
/// Signature + expiry + scope check against one specific key.
///
/// `verify_jwt` handles the ES256 signature and `exp` (with a 30 s
/// leeway for clock skew); the scope check is ours, and it is the line
/// that keeps a 90-day refresh token from working as a session
/// credential.
fn verify_with_key(token: &str, pubkey_multibase: &str) -> Result<JwtClaims, AuthError> {
let claims = at_crypto::jwt::verify_jwt(token, pubkey_multibase)
.map_err(|e| AuthError::Invalid(format!("invalid token: {e}")))?;
match claims.scope.as_deref() {
Some(ACCESS_SCOPE) => Ok(claims),
other => Err(AuthError::Invalid(format!(
"token scope {:?} is not {ACCESS_SCOPE}",
other.unwrap_or("<none>")
))),
}
}
/// Extract the bearer token from an `Authorization` header.
///
/// The scheme match is case-insensitive (RFC 7235 says it is) — some
/// HTTP clients send `bearer`.
fn bearer_token(headers: &HeaderMap) -> Result<String, AuthError> {
let raw = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AuthError::Missing)?;
let (scheme, token) = raw.split_once(' ').ok_or(AuthError::Missing)?;
if !scheme.eq_ignore_ascii_case("bearer") {
return Err(AuthError::Missing);
}
let token = token.trim();
if token.is_empty() {
return Err(AuthError::Missing);
}
Ok(token.to_string())
}
// -- extractor ---------------------------------------------------------------
/// The authenticated DID of the caller.
///
/// `None` means auth is switched off for this instance
/// (`APPVIEW_AUTH_REQUIRED=false`) — *not* "anonymous but allowed
/// through". When auth is on, this is always `Some` by construction:
/// the extractor rejects the request otherwise.
///
/// Handlers must still call [`AuthedDid::ensure_matches`] with the DID
/// the request asks about. Proving *who you are* is not the same as
/// proving *whose notifications you may read*.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthedDid(pub Option<String>);
impl AuthedDid {
/// 403 unless the token's `sub` is the DID the request targets.
///
/// With auth disabled this is a no-op, which is exactly what
/// `APPVIEW_AUTH_REQUIRED=false` means.
pub fn ensure_matches(&self, did: &str) -> Result<(), (StatusCode, Json<Value>)> {
match self.0.as_deref() {
None => Ok(()),
Some(sub) if sub == did => Ok(()),
Some(_) => Err(AuthError::Forbidden.into_response_parts()),
}
}
/// The caller's DID, if authenticated.
///
/// No handler needs it yet — every private endpoint takes the DID
/// as a parameter and compares it — but an endpoint that acts
/// purely on "whoever is calling" would read it here instead of
/// trusting a query parameter.
#[allow(dead_code)]
pub fn did(&self) -> Option<&str> {
self.0.as_deref()
}
}
#[async_trait]
impl FromRequestParts<AppState> for AuthedDid {
type Rejection = (StatusCode, Json<Value>);
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
if !state.cfg.appview_auth_required {
// Opt-out instance. The startup log says so in capitals.
return Ok(AuthedDid(None));
}
let token = bearer_token(&parts.headers).map_err(AuthError::into_response_parts)?;
let claims = state
.pds_keys
.verify_access_token(&token)
.await
.map_err(AuthError::into_response_parts)?;
Ok(AuthedDid(Some(claims.sub)))
}
}
// -- startup posture ---------------------------------------------------------
/// Log, once at boot, every way this instance is configured to be less
/// strict than the defaults.
///
/// All three of these are legitimate configurations — a VPN-only
/// deployment, a dev box, a single-machine setup — and all three are
/// also exactly what an accidentally-public instance looks like. The
/// only defence that survives a hurried deployment is a log line the
/// operator cannot miss, so each one is a `warn!` naming the variable
/// that turns it back on.
pub fn log_startup_posture(cfg: &at_shared::config::AppConfig) {
if !cfg.appview_auth_required {
warn!(
"APPVIEW_AUTH_REQUIRED=false — /api/notifications* and \
/api/timeline/home are served to ANY caller for ANY did. \
Only safe when this instance is unreachable from untrusted \
networks."
);
}
if cfg.appview_cors_origins.is_empty() {
warn!(
"APPVIEW_CORS_ORIGINS unset — sending Access-Control-Allow-Origin: * \
so any web page can call this AppView from a browser. Set it to the \
origins your client actually uses, e.g. \
'tauri://localhost,http://127.0.0.1:1430'."
);
}
if cfg.appview_ingest_secret.is_none() {
warn!(
"APPVIEW_INGEST_SECRET unset — POST /internal/ingest-commit accepts \
unauthenticated writes into the index (anyone who can reach this port \
can forge posts, follows and notifications). Set the same value here \
and on the PDS."
);
}
}
// -- tests -------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use at_crypto::ecdsa::P256Keypair;
use at_crypto::jwt::{issue_jwt, JwtClaims};
use axum::http::HeaderValue;
/// A throwaway server key plus its `publicKeyMultibase`, in the
/// exact encoding the PDS publishes: `0x8012` (P-256) followed by
/// the *uncompressed* affine coordinates. `P256Keypair::generate`
/// stores a compressed point, which `verify_jwt` cannot decode — so
/// this mirrors `pds-server`'s `server_p256_keypair`.
fn test_key() -> (P256Keypair, String) {
let kp = P256Keypair::generate().unwrap();
let vk = kp.verifying_key().unwrap();
let pt = vk.to_encoded_point(false);
let mut raw = vec![0x80u8, 0x12u8];
raw.extend_from_slice(pt.x().unwrap());
raw.extend_from_slice(pt.y().unwrap());
let multibase = at_crypto::multibase_util::encode_b58btc(&raw);
(kp, multibase)
}
fn mint(kp: &P256Keypair, did: &str, scope: &str, ttl_secs: i64) -> String {
let now = chrono::Utc::now().timestamp();
issue_jwt(
kp,
&JwtClaims {
iss: "did:web:127.0.0.1%3A2583".into(),
sub: did.into(),
aud: "did:web:appview.maarcadetweet.local".into(),
iat: now - 1,
exp: now + ttl_secs,
jti: None,
scope: Some(scope.into()),
},
)
.unwrap()
}
fn header_map(value: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("authorization", HeaderValue::from_str(value).unwrap());
h
}
#[test]
fn missing_header_is_auth_missing() {
assert_eq!(bearer_token(&HeaderMap::new()), Err(AuthError::Missing));
}
#[test]
fn malformed_header_is_auth_missing() {
// No scheme, wrong scheme, and an empty token all read as
// "the client never presented a credential" — 401 AuthMissing,
// not TokenInvalid, so the client re-authenticates instead of
// burning a refresh round trip.
assert_eq!(bearer_token(&header_map("abc.def.ghi")), Err(AuthError::Missing));
assert_eq!(bearer_token(&header_map("Basic dXNlcjpwdw==")), Err(AuthError::Missing));
assert_eq!(bearer_token(&header_map("Bearer ")), Err(AuthError::Missing));
}
#[test]
fn bearer_scheme_is_case_insensitive() {
assert_eq!(bearer_token(&header_map("bearer tok")).unwrap(), "tok");
assert_eq!(bearer_token(&header_map("Bearer tok")).unwrap(), "tok");
}
#[test]
fn error_bodies_carry_the_documented_codes() {
// These strings are a contract: the desktop client keys its
// "refresh and retry" behaviour off `TokenInvalid`.
let (s, b) = AuthError::Missing.into_response_parts();
assert_eq!(s, StatusCode::UNAUTHORIZED);
assert_eq!(b.0["error"], "AuthMissing");
let (s, b) = AuthError::Invalid("x".into()).into_response_parts();
assert_eq!(s, StatusCode::UNAUTHORIZED);
assert_eq!(b.0["error"], "TokenInvalid");
let (s, b) = AuthError::Forbidden.into_response_parts();
assert_eq!(s, StatusCode::FORBIDDEN);
assert_eq!(b.0["error"], "Forbidden");
let (s, b) = AuthError::Unavailable("pds down".into()).into_response_parts();
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(b.0["error"], "AuthUnavailable");
// Every body carries both fields the client parses.
assert!(b.0["message"].as_str().unwrap().contains("pds down"));
}
#[test]
fn valid_access_token_verifies() {
let (kp, mb) = test_key();
let token = mint(&kp, "did:plc:alice", ACCESS_SCOPE, 3600);
let claims = verify_with_key(&token, &mb).unwrap();
assert_eq!(claims.sub, "did:plc:alice");
}
#[test]
fn garbage_token_is_invalid() {
let (_, mb) = test_key();
let err = verify_with_key("not-a-jwt", &mb).unwrap_err();
assert!(matches!(err, AuthError::Invalid(_)));
}
#[test]
fn token_signed_by_another_key_is_invalid() {
let (kp, _) = test_key();
let (_, other_mb) = test_key();
let token = mint(&kp, "did:plc:alice", ACCESS_SCOPE, 3600);
assert!(matches!(
verify_with_key(&token, &other_mb).unwrap_err(),
AuthError::Invalid(_)
));
}
#[test]
fn refresh_scope_is_rejected() {
// The refresh token is signed by the same key and lives 90
// days. Without the scope check it would be a session token.
let (kp, mb) = test_key();
let token = mint(&kp, "did:plc:alice", "com.atproto.refresh", 3600);
let err = verify_with_key(&token, &mb).unwrap_err();
match err {
AuthError::Invalid(msg) => assert!(msg.contains("com.atproto.refresh")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn expired_token_is_rejected() {
// Beyond `verify_jwt`'s 30 s leeway.
let (kp, mb) = test_key();
let token = mint(&kp, "did:plc:alice", ACCESS_SCOPE, -120);
assert!(matches!(
verify_with_key(&token, &mb).unwrap_err(),
AuthError::Invalid(_)
));
}
#[test]
fn ensure_matches_enforces_sub_equals_did() {
let me = AuthedDid(Some("did:plc:alice".into()));
assert!(me.ensure_matches("did:plc:alice").is_ok());
let (status, body) = me.ensure_matches("did:plc:bob").unwrap_err();
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body.0["error"], "Forbidden");
// A prefix of the real DID must not pass.
assert!(me.ensure_matches("did:plc:ali").is_err());
}
#[test]
fn ensure_matches_is_a_noop_when_auth_disabled() {
let off = AuthedDid(None);
assert!(off.ensure_matches("did:plc:anyone").is_ok());
assert_eq!(off.did(), None);
}
#[test]
fn did_doc_key_extraction() {
let doc = json!({
"id": "did:web:127.0.0.1%3A2583",
"verificationMethod": [{
"id": "did:web:127.0.0.1%3A2583#atproto",
"type": "Multikey",
"controller": "did:web:127.0.0.1%3A2583",
"publicKeyMultibase": "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme"
}]
});
assert_eq!(
extract_public_key_multibase(&doc).unwrap(),
"zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme"
);
// A document without a usable key must error rather than cache
// an empty string.
assert!(extract_public_key_multibase(&json!({})).is_err());
assert!(extract_public_key_multibase(&json!({
"verificationMethod": [{"id": "#x", "type": "Multikey"}]
}))
.is_err());
assert!(extract_public_key_multibase(&json!({
"verificationMethod": [{"publicKeyMultibase": ""}]
}))
.is_err());
}
#[test]
fn did_doc_url_is_built_from_the_base_url() {
assert_eq!(
PdsKeys::new("http://127.0.0.1:2583").did_doc_url(),
"http://127.0.0.1:2583/.well-known/did.json"
);
// A trailing slash must not produce a double slash — some
// servers 404 on it.
assert_eq!(
PdsKeys::new("http://pds:3000/").did_doc_url(),
"http://pds:3000/.well-known/did.json"
);
}
#[tokio::test]
async fn verification_fails_closed_when_the_pds_is_unreachable() {
// Port 1 on loopback: nothing listens there, so the fetch fails
// fast. The result must be a 503, never a pass-through.
let keys = PdsKeys::new("http://127.0.0.1:1");
let err = keys.verify_access_token("whatever").await.unwrap_err();
assert!(
matches!(err, AuthError::Unavailable(_)),
"expected Unavailable, got {err:?}"
);
let (status, body) = err.into_response_parts();
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body.0["error"], "AuthUnavailable");
}
}
+136 -15
View File
@@ -147,7 +147,17 @@ impl HandleSyncWorker {
/// `handle` is filled, so a DID that becomes resolvable later /// `handle` is filled, so a DID that becomes resolvable later
/// (e.g. the user joins the local PDS) gets re-attempted. /// (e.g. the user joins the local PDS) gets re-attempted.
pub async fn run_once(&self) -> Result<SyncReport> { pub async fn run_once(&self) -> Result<SyncReport> {
let dids: Vec<(String,)> = sqlx::query_as( let dids = self.select_candidates().await?;
self.resolve_batch(dids).await
}
/// The SELECT half of [`Self::run_once`]: up to [`BATCH_SIZE`]
/// distinct DIDs still waiting for a handle.
///
/// Public so integration tests can assert on the batch cap without
/// depending on what else the live indexer left pending.
pub async fn select_candidates(&self) -> Result<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"SELECT DISTINCT did r#"SELECT DISTINCT did
FROM posts FROM posts
WHERE handle = '' WHERE handle = ''
@@ -159,7 +169,18 @@ impl HandleSyncWorker {
.bind(BATCH_SIZE) .bind(BATCH_SIZE)
.fetch_all(&self.db) .fetch_all(&self.db)
.await?; .await?;
Ok(rows.into_iter().map(|(did,)| did).collect())
}
/// The resolve-and-write half of [`Self::run_once`], split out so
/// tests can drive it with a DID set of their own.
///
/// `run_once`'s own scan is global and capped at [`BATCH_SIZE`],
/// so against a shared database with a live indexer a test's
/// freshly seeded DID may simply not make the batch — which made
/// the dispatch tests fail for reasons that had nothing to do with
/// dispatch. Passing the DIDs in removes that coupling.
pub async fn resolve_batch(&self, dids: Vec<String>) -> Result<SyncReport> {
let mut report = SyncReport::default(); let mut report = SyncReport::default();
if dids.is_empty() { if dids.is_empty() {
return Ok(report); return Ok(report);
@@ -174,7 +195,7 @@ impl HandleSyncWorker {
// the parallel-write benefit at this batch size. // the parallel-write benefit at this batch size.
use futures::stream::{self, StreamExt}; use futures::stream::{self, StreamExt};
let dispatch_results: Vec<(String, anyhow::Result<Option<String>>)> = stream::iter(dids) let dispatch_results: Vec<(String, anyhow::Result<Option<String>>)> = stream::iter(dids)
.map(|(did,)| async move { .map(|did| async move {
let r = self.dispatch(&did).await; let r = self.dispatch(&did).await;
(did, r) (did, r)
}) })
@@ -376,7 +397,11 @@ mod tests {
.into_arc(); .into_arc();
let worker = worker_with(db, resolver); let worker = worker_with(db, resolver);
let report = worker.run_once().await.unwrap(); // Drive the resolve half with our own DID instead of
// `run_once()`: the global scan is capped at BATCH_SIZE and
// this database is shared with a live indexer, so a freshly
// seeded DID is not guaranteed to make the batch.
let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert!(report.resolved >= 2, "expected ≥2 resolved, got {report:?}"); assert!(report.resolved >= 2, "expected ≥2 resolved, got {report:?}");
assert_eq!(report.failed, 0); assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 0); assert_eq!(report.skipped, 0);
@@ -411,10 +436,14 @@ mod tests {
.into_arc(); .into_arc();
let worker = worker_with(db, resolver); let worker = worker_with(db, resolver);
let report = worker.run_once().await.unwrap(); // The SELECT is what filters an already-handled DID out, so
assert_eq!(report.resolved, 0); // this one goes through the full `run_once()` — but scoped to
assert_eq!(report.failed, 0); // the assertion that OUR did is untouched, not to global counts.
assert_eq!(report.skipped, 0); // DID was already filtered out by the SELECT let candidates = worker.select_candidates().await.unwrap();
assert!(
!candidates.contains(&did),
"a DID with a handle must not be selected"
);
let h = get_handle(&worker.db, &did).await; let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some("pre-existing.handle")); assert_eq!(h.as_deref(), Some("pre-existing.handle"));
@@ -456,8 +485,22 @@ mod tests {
web_resolver: Arc::clone(&resolver), web_resolver: Arc::clone(&resolver),
interval_secs: 999, interval_secs: 999,
}; };
let report = worker.run_once().await.unwrap(); // The cap lives in the SELECT, so that's what we assert on.
// Exactly BATCH_SIZE rows updated (one post per DID). // Asserting `report.resolved == BATCH_SIZE` after a full
// `run_once()` only holds on a database where nothing else is
// waiting for a handle — against the shared dev DB with a live
// Jetstream indexer, other DIDs legitimately fill the batch.
let candidates = worker.select_candidates().await.unwrap();
assert!(
candidates.len() as i64 <= BATCH_SIZE,
"select must never exceed BATCH_SIZE, got {}",
candidates.len()
);
// And the resolve half must handle a full batch of our own
// DIDs: one row per DID, all of them updated.
let batch: Vec<String> = all_dids.iter().take(BATCH_SIZE as usize).cloned().collect();
let report = worker.resolve_batch(batch).await.unwrap();
assert_eq!( assert_eq!(
report.resolved as i64, report.resolved as i64,
BATCH_SIZE, BATCH_SIZE,
@@ -465,7 +508,7 @@ mod tests {
); );
assert_eq!(report.failed, 0); assert_eq!(report.failed, 0);
// The remaining 5 DIDs must still have empty handles. // The 5 DIDs we left out must still have empty handles.
let remaining: i64 = let remaining: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM posts WHERE handle = '' AND did LIKE $1") sqlx::query_scalar("SELECT COUNT(*) FROM posts WHERE handle = '' AND did LIKE $1")
.bind(format!("{prefix}_%")) .bind(format!("{prefix}_%"))
@@ -528,12 +571,74 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let report = worker.run_once().await.unwrap(); // Scoped to our own DID: `run_once()`'s global batch would
// report whatever else the indexer left pending.
let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!(report.resolved, 0, "must not touch already-handled rows"); assert_eq!(report.resolved, 0, "must not touch already-handled rows");
let h = get_handle(&worker.db, &did).await; let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some("from-ingest")); assert_eq!(h.as_deref(), Some("from-ingest"));
} }
/// The documented PDS-first rule: the local PDS is asked before the
/// method dispatch, so a `did:key:` user hosted here resolves
/// without ever dialing plc.directory. This is the flip side of
/// `unknown_methods_are_skipped` — same DID method, opposite
/// outcome, and the difference is solely whether the PDS hosts it.
#[tokio::test]
async fn pds_resolves_did_key_before_method_dispatch() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = format!("did:key:z{}", uuid::Uuid::new_v4().simple());
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "").await.unwrap();
// The PDS hosts this user; the outbound resolvers know nothing
// and must never be consulted.
let pds = StubResolver::new(HashMap::from([(
did.clone(),
Some("local-user.maarcadetweet.local".into()),
)]))
.into_arc();
let plc_log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let plc = TrackingResolver::new(
HashMap::from([(did.clone(), Some("must-not-be-used".into()))]),
Arc::clone(&plc_log),
);
let plc_arc: Arc<dyn DidHandleResolver> = Arc::new(plc);
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: pds,
plc_resolver: Arc::clone(&plc_arc),
web_resolver: plc_arc,
interval_secs: 999,
};
let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!(
report.resolved, 1,
"a did:key hosted by the local PDS must resolve, got {report:?}"
);
assert_eq!(
get_handle(&worker.db, &did).await.as_deref(),
Some("local-user.maarcadetweet.local")
);
assert!(
plc_log.lock().unwrap().is_empty(),
"the PDS answered, so no outbound resolver may be consulted"
);
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// Dispatch test: a `did:web:` DID must be routed to the /// Dispatch test: a `did:web:` DID must be routed to the
/// `web_resolver` (not the PLC one). Without this routing, every /// `web_resolver` (not the PLC one). Without this routing, every
/// `did:web:` post would stay `@<did-prefix>…` forever. /// `did:web:` post would stay `@<did-prefix>…` forever.
@@ -564,14 +669,23 @@ mod tests {
)])) )]))
.into_arc(); .into_arc();
// The local PDS is consulted before the method dispatch (see the
// module docs), and it does NOT host this DID — a foreign
// `did:web:` is exactly the case where it answers "don't know".
// Wiring one of the other stubs in here instead would make the
// PDS claim a DID it doesn't have, and the test would be
// asserting against the documented PDS-first rule rather than
// against the method dispatch it's named for.
let pds = StubResolver::new(HashMap::new()).into_arc();
let worker = HandleSyncWorker { let worker = HandleSyncWorker {
db: db.clone(), db: db.clone(),
pds_resolver: Arc::clone(&plc), pds_resolver: pds,
plc_resolver: plc, plc_resolver: plc,
web_resolver: web, web_resolver: web,
interval_secs: 999, interval_secs: 999,
}; };
let report = worker.run_once().await.unwrap(); let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!( assert_eq!(
report.resolved, 1, report.resolved, 1,
"did:web must resolve through the web resolver, got {report:?}" "did:web must resolve through the web resolver, got {report:?}"
@@ -623,14 +737,21 @@ mod tests {
let plc_arc: Arc<dyn DidHandleResolver> = Arc::new(plc); let plc_arc: Arc<dyn DidHandleResolver> = Arc::new(plc);
let web_arc: Arc<dyn DidHandleResolver> = Arc::new(web); let web_arc: Arc<dyn DidHandleResolver> = Arc::new(web);
// A DID the local PDS does not host — otherwise the PDS-first
// rule would (correctly) resolve it and this test would be
// measuring the wrong thing. The "local PDS *does* host it"
// case is covered by `pds_resolves_did_key_before_method_dispatch`.
let pds_arc: Arc<dyn DidHandleResolver> =
Arc::new(StubResolver::new(HashMap::new()));
let worker = HandleSyncWorker { let worker = HandleSyncWorker {
db: db.clone(), db: db.clone(),
pds_resolver: Arc::clone(&plc_arc), pds_resolver: pds_arc,
plc_resolver: plc_arc, plc_resolver: plc_arc,
web_resolver: web_arc, web_resolver: web_arc,
interval_secs: 999, interval_secs: 999,
}; };
let report = worker.run_once().await.unwrap(); let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!(report.resolved, 0, "did:key must not resolve, got {report:?}"); assert_eq!(report.resolved, 0, "did:key must not resolve, got {report:?}");
assert_eq!( assert_eq!(
report.failed, 0, report.failed, 0,
+557
View File
@@ -348,6 +348,31 @@ pub async fn upsert_post(db: &PgPool, row: &mut PostRow) -> Result<()> {
.bind(&row.avatar_cid) .bind(&row.avatar_cid)
.execute(db) .execute(db)
.await?; .await?;
// Reply notification. A post with a `parent_uri` is a reply, so the
// parent's author gets a "someone replied to you" row.
//
// `subject_uri` is the REPLY's own URI, not the parent's: the
// notification list hydrates `subject_uri`'s text, and what the
// recipient wants to read is what the replier wrote — they already
// know the content of their own post. It also makes the row a
// direct link target for "open this reply in the thread view".
//
// The dedupe index keys on the reply URI, so re-indexing (or an
// edit that re-runs the upsert) can't produce a second row.
if let Some(parent_uri) = row.parent_uri.as_deref() {
if let Some(recipient) = post_author_did(db, parent_uri).await? {
record_notification(
db,
&recipient,
&row.did,
NOTIF_REPLY,
Some(&row.uri),
row.created_at,
)
.await?;
}
}
Ok(()) Ok(())
} }
@@ -414,6 +439,23 @@ pub async fn upsert_like(
.bind(&post_uri) .bind(&post_uri)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
// Notify the post's author. Runs inside the same transaction as
// the counter bump so a crash can't leave a like counted but
// un-notified (or vice versa). `post_author_did` returns None
// for a post we haven't indexed — then there's nobody to
// notify and we quietly skip.
if let Some(recipient) = post_author_did(&mut *tx, &post_uri).await? {
record_notification(
&mut *tx,
&recipient,
did,
NOTIF_LIKE,
Some(&post_uri),
created_at,
)
.await?;
}
} }
tx.commit().await?; tx.commit().await?;
Ok(()) Ok(())
@@ -489,6 +531,20 @@ pub async fn upsert_repost(
.bind(&post_uri) .bind(&post_uri)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
// Same transactional notification write as the like path — see
// `upsert_like` for the rationale.
if let Some(recipient) = post_author_did(&mut *tx, &post_uri).await? {
record_notification(
&mut *tx,
&recipient,
did,
NOTIF_REPOST,
Some(&post_uri),
created_at,
)
.await?;
}
} }
tx.commit().await?; tx.commit().await?;
Ok(()) Ok(())
@@ -517,6 +573,124 @@ pub async fn delete_repost(db: &PgPool, did: &str, rkey: &str) -> Result<()> {
Ok(()) Ok(())
} }
// -- notifications ---------------------------------------------------------
/// The four notification kinds the AppView produces. Kept as `&str`
/// constants rather than an enum because the value is a plain `TEXT`
/// column guarded by a CHECK constraint (migration 0008) and every
/// call site is a literal — an enum would only add a `to_string()`.
pub const NOTIF_LIKE: &str = "like";
pub const NOTIF_REPOST: &str = "repost";
pub const NOTIF_FOLLOW: &str = "follow";
pub const NOTIF_REPLY: &str = "reply";
/// Should an interaction by `author_did` aimed at `recipient_did`
/// produce a notification row?
///
/// Pure so it can be unit-tested without a database. Two rules:
///
/// 1. **No self-interactions.** Liking your own post, reposting
/// yourself, self-following or replying to yourself must stay
/// silent — the user already knows they did it, and a "you liked
/// your own post" row is pure noise.
/// 2. **No empty DIDs.** An empty recipient means we failed to resolve
/// the target (e.g. a like against a post that isn't in our index),
/// and an empty author means the event was malformed. Either way
/// the row would be unattributable in the UI.
///
/// The same two rules are ALSO enforced in SQL inside
/// [`record_notification`], so a caller that forgets this guard still
/// can't write a bad row — this function exists to skip the round trip
/// in the common self-interaction case and to make the rule testable.
pub fn should_notify(recipient_did: &str, author_did: &str) -> bool {
!recipient_did.is_empty() && !author_did.is_empty() && recipient_did != author_did
}
/// Insert one notification row, if and only if it is warranted.
///
/// Returns `Ok(true)` when a row was actually written, `Ok(false)` when
/// the write was skipped — either because the interaction failed
/// [`should_notify`], because the recipient isn't a user this AppView
/// serves, or because the same notification already exists.
///
/// **"Local user"**: the AppView deliberately has no `users` table —
/// the PDS owns account state. The closest thing we have is "a DID this
/// AppView knows about", i.e. one with a row in the `profiles` cache
/// (written by the PDS profile push / the Jetstream `app.bsky.actor.profile`
/// arm) or at least one indexed post. That is exactly the set of users
/// a client could ever poll notifications for, so restricting writes to
/// it keeps us from materialising a notification row for every like on
/// the entire public firehose while never dropping one a local user
/// would actually see.
///
/// **Idempotency**: the `ON CONFLICT` target is the expression index
/// `notifications_dedupe_idx` from migration 0008, keyed on
/// `(recipient_did, author_did, kind, COALESCE(subject_uri, ''))`. The
/// `COALESCE` is load-bearing: a plain unique index would let two
/// `follow` rows (whose `subject_uri` is NULL) coexist, because SQL
/// NULLs never collide. Re-indexing the same Jetstream event — on a
/// reconnect replay, or via the PDS `/internal/ingest-commit` push that
/// races the firehose — is therefore a no-op.
///
/// The executor is generic so this can run either on the pool (the
/// reply path) or inside the caller's transaction (the like / repost
/// paths, where the notification must commit atomically with the
/// counter bump).
pub async fn record_notification<'e, E>(
exec: E,
recipient_did: &str,
author_did: &str,
kind: &str,
subject_uri: Option<&str>,
created_at: chrono::DateTime<chrono::Utc>,
) -> Result<bool>
where
E: sqlx::PgExecutor<'e>,
{
if !should_notify(recipient_did, author_did) {
return Ok(false);
}
// One statement, so there's no TOCTOU window between "is the
// recipient local?" and "insert". Every parameter is explicitly
// cast because `INSERT ... SELECT $1, $2, ...` gives Postgres no
// column context to infer the placeholder types from.
let res = sqlx::query(
r#"INSERT INTO notifications
(recipient_did, author_did, kind, subject_uri, created_at)
SELECT $1::text, $2::text, $3::text, $4::text, $5::timestamptz
WHERE $1::text <> $2::text
AND $1::text <> ''
AND $2::text <> ''
AND (EXISTS (SELECT 1 FROM profiles WHERE did = $1::text)
OR EXISTS (SELECT 1 FROM posts WHERE did = $1::text))
ON CONFLICT (recipient_did, author_did, kind, COALESCE(subject_uri, ''))
DO NOTHING"#,
)
.bind(recipient_did)
.bind(author_did)
.bind(kind)
.bind(subject_uri)
.bind(created_at)
.execute(exec)
.await?;
Ok(res.rows_affected() > 0)
}
/// Look up the author DID of an indexed post. `None` when the post
/// isn't in our index — which is the normal case for a like/reply
/// aimed at a post hosted somewhere we don't follow. The caller then
/// simply skips the notification rather than guessing a recipient.
async fn post_author_did<'e, E>(exec: E, uri: &str) -> Result<Option<String>>
where
E: sqlx::PgExecutor<'e>,
{
let did: Option<String> = sqlx::query_scalar("SELECT did FROM posts WHERE uri = $1")
.bind(uri)
.fetch_optional(exec)
.await?;
Ok(did)
}
// -- follows --------------------------------------------------------------- // -- follows ---------------------------------------------------------------
pub async fn upsert_follow( pub async fn upsert_follow(
@@ -540,6 +714,22 @@ pub async fn upsert_follow(
.bind(created_at) .bind(created_at)
.execute(db) .execute(db)
.await?; .await?;
// Notify the followed user. `subject_uri` is NULL — a follow isn't
// about a post — which is exactly the case the dedupe index's
// `COALESCE(subject_uri, '')` exists for. Not wrapped in a
// transaction with the follow upsert: the follow row is the source
// of truth and a missed notification is recoverable noise, whereas
// taking a transaction here would serialise every follow write.
record_notification(
db,
subject_did,
follower_did,
NOTIF_FOLLOW,
None,
created_at,
)
.await?;
Ok(()) Ok(())
} }
@@ -1181,6 +1371,373 @@ mod tests {
} }
} }
#[cfg(test)]
mod notification_tests {
use super::*;
use serde_json::json;
fn did(tag: &str) -> String {
format!("did:plc:notif_{}_{}", tag, uuid::Uuid::new_v4().simple())
}
/// Seed one post so `did` counts as a user this AppView knows
/// about (see [`record_notification`]'s "local user" note) and so
/// there's something to like / reply to.
///
/// The `handle` is deliberately non-empty. `handle_sync`'s tests
/// assert exact counts over a *global* scan of empty-handle rows,
/// so a fixture that left the column blank would silently break
/// them whenever both suites share a database.
async fn seed_post(db: &PgPool, author: &str, rkey: &str) -> String {
let uri = format!("at://{author}/app.twi.post/{rkey}");
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, langs, created_at)
VALUES ($1,$2,'notif-fixture.test','x','app.twi.post','seed','bafy',
NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(&uri)
.bind(author)
.execute(db)
.await
.unwrap();
uri
}
async fn count_notifications(db: &PgPool, recipient: &str, kind: &str) -> i64 {
sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(recipient)
.bind(kind)
.fetch_one(db)
.await
.unwrap()
}
async fn cleanup(db: &PgPool, dids: &[&str]) {
for d in dids {
let _ = sqlx::query(
"DELETE FROM notifications WHERE recipient_did = $1 OR author_did = $1",
)
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM likes WHERE did = $1")
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM reposts WHERE did = $1")
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM follows WHERE follower_did = $1 OR subject_did = $1")
.bind(d)
.execute(db)
.await;
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(d)
.execute(db)
.await;
}
}
#[test]
fn should_notify_rejects_self_and_empty() {
assert!(should_notify("did:plc:a", "did:plc:b"));
// Self-interaction: liking / replying to / following yourself.
assert!(!should_notify("did:plc:a", "did:plc:a"));
// Unresolvable ends of the edge.
assert!(!should_notify("", "did:plc:b"));
assert!(!should_notify("did:plc:a", ""));
assert!(!should_notify("", ""));
// Case matters — DIDs are compared verbatim, never folded.
assert!(should_notify("did:plc:A", "did:plc:a"));
}
/// A like by someone else must produce exactly one notification,
/// and re-indexing the same event (Jetstream replay racing the PDS
/// push) must not produce a second.
#[tokio::test]
async fn like_notifies_author_once() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let author = did("author");
let liker = did("liker");
// The liker also needs to exist for nothing in particular —
// only the *recipient* is checked — but seeding both keeps the
// fixture symmetric with reality.
let post_uri = seed_post(&db, &author, "p1").await;
seed_post(&db, &liker, "p1").await;
let record = json!({
"subject": { "uri": post_uri, "cid": "bafysubject" },
"createdAt": "2026-07-01T12:00:00Z"
});
upsert_like(&db, &liker, "lrk1", Some("bafylike"), Some(&record))
.await
.unwrap();
assert_eq!(count_notifications(&db, &author, NOTIF_LIKE).await, 1);
// Replay the exact same event.
upsert_like(&db, &liker, "lrk1", Some("bafylike"), Some(&record))
.await
.unwrap();
assert_eq!(
count_notifications(&db, &author, NOTIF_LIKE).await,
1,
"replayed like must not duplicate the notification"
);
// Unlike + re-like under a NEW rkey. The like row is recreated
// but the notification tuple is unchanged, so the dedupe index
// suppresses it — see migration 0008's "Idempotency" note.
delete_like(&db, &liker, "lrk1").await.unwrap();
upsert_like(&db, &liker, "lrk2", Some("bafylike2"), Some(&record))
.await
.unwrap();
assert_eq!(
count_notifications(&db, &author, NOTIF_LIKE).await,
1,
"toggling a like must not be a notification-spam vector"
);
// The stored row must point at the liked post and name the
// liker as the author.
let row: (String, Option<String>) = sqlx::query_as(
"SELECT author_did, subject_uri FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(&author)
.bind(NOTIF_LIKE)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(row.0, liker);
assert_eq!(row.1.as_deref(), Some(post_uri.as_str()));
// Unread by default — that's what /api/notifications/count sees.
let unread: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM notifications \
WHERE recipient_did = $1 AND read_at IS NULL",
)
.bind(&author)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(unread, 1);
cleanup(&db, &[&author, &liker]).await;
}
/// Liking / reposting your own post is silent.
#[tokio::test]
async fn self_interaction_writes_no_notification() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let author = did("selfie");
let post_uri = seed_post(&db, &author, "p1").await;
let record = json!({
"subject": { "uri": post_uri, "cid": "bafysubject" },
"createdAt": "2026-07-01T12:00:00Z"
});
upsert_like(&db, &author, "lrk1", None, Some(&record))
.await
.unwrap();
upsert_repost(&db, &author, "rrk1", None, Some(&record))
.await
.unwrap();
// Self-follow is legal in the protocol; it must stay silent too.
upsert_follow(&db, &author, &author, None).await.unwrap();
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*)::BIGINT FROM notifications WHERE recipient_did = $1",
)
.bind(&author)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(total, 0, "self-interactions must not notify");
// The like/repost themselves still landed — the notification
// suppression must not swallow the interaction.
let likes: i64 = sqlx::query_scalar("SELECT COUNT(*)::BIGINT FROM likes WHERE did = $1")
.bind(&author)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(likes, 1);
cleanup(&db, &[&author]).await;
}
/// A repost notifies, and a reply notifies the *parent's* author
/// with the reply's own URI as the subject.
#[tokio::test]
async fn repost_and_reply_notify() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let author = did("parent");
let other = did("replier");
let parent_uri = seed_post(&db, &author, "p1").await;
seed_post(&db, &other, "p1").await;
let record = json!({
"subject": { "uri": parent_uri, "cid": "bafysubject" },
"createdAt": "2026-07-01T12:00:00Z"
});
upsert_repost(&db, &other, "rrk1", None, Some(&record))
.await
.unwrap();
assert_eq!(count_notifications(&db, &author, NOTIF_REPOST).await, 1);
// Now a reply from `other` to `author`'s post.
let reply_record = json!({
"text": "nice one",
"createdAt": "2026-07-01T12:05:00Z",
"reply": {
"parent": { "uri": parent_uri, "cid": "bafyparent" },
"root": { "uri": parent_uri, "cid": "bafyparent" }
}
});
let mut row = PostRow::from_record(
&other,
"replykey",
"app.twi.post",
"bafyreply",
&reply_record,
None,
);
let reply_uri = row.uri.clone();
upsert_post(&db, &mut row).await.unwrap();
let subject: Option<String> = sqlx::query_scalar(
"SELECT subject_uri FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(&author)
.bind(NOTIF_REPLY)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(
subject.as_deref(),
Some(reply_uri.as_str()),
"a reply notification's subject is the REPLY, so the list can \
preview what was written"
);
// Re-indexing the reply must not duplicate.
upsert_post(&db, &mut row).await.unwrap();
assert_eq!(count_notifications(&db, &author, NOTIF_REPLY).await, 1);
cleanup(&db, &[&author, &other]).await;
}
/// A follow notifies the followed user with a NULL subject_uri —
/// the case the dedupe index's `COALESCE(subject_uri, '')` exists
/// for, since plain SQL NULLs never collide.
#[tokio::test]
async fn follow_notifies_subject_and_dedupes_on_null_subject() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let subject = did("followee");
let follower = did("follower");
seed_post(&db, &subject, "p1").await;
let record = json!({ "subject": subject, "createdAt": "2026-01-01T00:00:00Z" });
upsert_follow(&db, &follower, &subject, Some(&record))
.await
.unwrap();
upsert_follow(&db, &follower, &subject, Some(&record))
.await
.unwrap();
assert_eq!(
count_notifications(&db, &subject, NOTIF_FOLLOW).await,
1,
"two NULL-subject follow rows must collide, not coexist"
);
let subject_uri: Option<String> = sqlx::query_scalar(
"SELECT subject_uri FROM notifications \
WHERE recipient_did = $1 AND kind = $2",
)
.bind(&subject)
.bind(NOTIF_FOLLOW)
.fetch_one(&db)
.await
.unwrap();
assert!(subject_uri.is_none(), "a follow is not about a post");
cleanup(&db, &[&subject, &follower]).await;
}
/// A recipient the AppView has never seen (no profile row, no
/// posts) gets nothing — this is what keeps us from materialising
/// a row for every like on the public firehose.
#[tokio::test]
async fn unknown_recipient_is_skipped() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let stranger = did("stranger");
let author = did("author");
let wrote = record_notification(
&db,
&stranger,
&author,
NOTIF_FOLLOW,
None,
chrono::Utc::now(),
)
.await
.unwrap();
assert!(!wrote, "unknown recipient must not get a notification row");
// Give the recipient a post — now they're a user we serve.
seed_post(&db, &stranger, "p1").await;
let wrote = record_notification(
&db,
&stranger,
&author,
NOTIF_FOLLOW,
None,
chrono::Utc::now(),
)
.await
.unwrap();
assert!(wrote, "known recipient must get the row");
// And the second call is a no-op thanks to ON CONFLICT.
let wrote = record_notification(
&db,
&stranger,
&author,
NOTIF_FOLLOW,
None,
chrono::Utc::now(),
)
.await
.unwrap();
assert!(!wrote, "duplicate must report false, not error");
cleanup(&db, &[&stranger, &author]).await;
}
}
/// Backfill the `posts.handle` column for every row belonging to /// Backfill the `posts.handle` column for every row belonging to
/// `did`. Used by the Jetstream `identity` handler when Jetstream /// `did`. Used by the Jetstream `identity` handler when Jetstream
/// tells us a DID's handle has changed — every existing post row /// tells us a DID's handle has changed — every existing post row
+32 -4
View File
@@ -15,8 +15,31 @@
//! } //! }
//! ``` //! ```
//! //!
//! In production this endpoint would be protected with mTLS and a token //! ## Who may call this
//! minted by the PDS; for now it's open inside the cluster. //!
//! This is the AppView's only write path, and it is not a browser
//! endpoint: it is excluded from the CORS layer in
//! [`crate::routes::router`], because an `Access-Control-Allow-Origin`
//! header here would only ever help a web page forge index entries.
//!
//! Authentication is the shared secret `APPVIEW_INGEST_SECRET`,
//! compared in constant time against the caller's `X-Ingest-Secret`
//! header:
//!
//! - **set** → enforced. A missing or wrong header is `401
//! AuthenticationRequired`.
//! - **unset** → anonymous writes are accepted, and the AppView shouts
//! about it once at startup (see
//! [`crate::auth::log_startup_posture`]). Refusing to start would
//! break every existing single-machine dev setup for a service that,
//! in that configuration, is bound to loopback anyway; accepting
//! silently is how an internet-facing deployment ends up letting
//! anyone forge posts, follows and notifications. So: keep working,
//! but never quietly.
//!
//! A future hardening step is mTLS or a PDS-minted token, at which
//! point the shared secret becomes the fallback rather than the only
//! line.
use crate::indexer; use crate::indexer;
use crate::state::AppState; use crate::state::AppState;
@@ -51,8 +74,13 @@ pub struct IngestCommitReq {
} }
/// Authenticate internal ingest requests. /// Authenticate internal ingest requests.
/// - If `APPVIEW_INGEST_SECRET` env var is unset: dev mode, accept anything. ///
/// - If set: require `X-Ingest-Secret: <value>` header to match. /// - `APPVIEW_INGEST_SECRET` unset: accept anonymous writes (dev mode —
/// the startup log warns, see the module docs for why this isn't a
/// hard failure).
/// - Set: require a matching `X-Ingest-Secret` header. The comparison
/// is constant-time so a caller can't recover the secret byte by byte
/// from response timings.
pub fn check_ingest_secret( pub fn check_ingest_secret(
headers: &HeaderMap, headers: &HeaderMap,
configured: Option<&str>, configured: Option<&str>,
+1
View File
@@ -3,6 +3,7 @@
//! tests under `tests/` import from here so they can build a worker //! tests under `tests/` import from here so they can build a worker
//! against a stub resolver without booting the binary. //! against a stub resolver without booting the binary.
pub mod auth;
pub mod firehose; pub mod firehose;
pub mod handle_sync; pub mod handle_sync;
pub mod indexer; pub mod indexer;
+39 -5
View File
@@ -7,6 +7,7 @@ use tokio::sync::mpsc;
use tracing::info; use tracing::info;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
mod auth;
mod firehose; mod firehose;
mod handle_sync; mod handle_sync;
mod indexer; mod indexer;
@@ -18,6 +19,12 @@ use state::AppState;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Load `.env` from the working directory (and upwards) if present.
// Nothing else in the process reads it, so without this
// `cp .env.example .env && cargo run` fails with `missing env:
// PDS_HOST`. Real environment variables always win over the file.
let _ = dotenvy::dotenv();
// Install a rustls crypto provider before any TLS connection. `ring` // Install a rustls crypto provider before any TLS connection. `ring`
// is the only one we currently support; using `aws_lc_rs` would // is the only one we currently support; using `aws_lc_rs` would
// require a non-default feature on rustls. // require a non-default feature on rustls.
@@ -77,6 +84,33 @@ async fn main() -> Result<()> {
let state = AppState::new(cfg.clone(), db.clone(), stats.clone()); let state = AppState::new(cfg.clone(), db.clone(), stats.clone());
// Announce every relaxed security switch before we serve anything.
auth::log_startup_posture(&cfg);
// Pre-load the PDS's published signing key so the first
// authenticated request doesn't pay for the round trip.
//
// Best effort on purpose: the AppView has always started
// independently of the PDS, and in a compose file the two race. A
// hard failure here would turn "the PDS booted two seconds later"
// into "the AppView is in a crash loop". If the fetch fails, the
// first authenticated request retries it — and answers `503
// AuthUnavailable` if the PDS is still unreachable. Never open.
if cfg.appview_auth_required {
match state.pds_keys.refresh().await {
Ok(_) => info!(
url = %state.pds_keys.did_doc_url(),
"loaded PDS signing key for token verification"
),
Err(e) => tracing::warn!(
url = %state.pds_keys.did_doc_url(),
error = %e,
"could not load the PDS signing key at startup; will retry on the \
first authenticated request (which fails with 503 until it works)"
),
}
}
// Back-fill the `handle` column on posts that the Jetstream // Back-fill the `handle` column on posts that the Jetstream
// indexer inserted with an empty placeholder. The worker dispatches // indexer inserted with an empty placeholder. The worker dispatches
// by DID method: `did:plc:` → PLC directory, `did:web:` → a // by DID method: `did:plc:` → PLC directory, `did:web:` → a
@@ -96,12 +130,12 @@ async fn main() -> Result<()> {
// inside docker compose) — `pds_public_url` may not be reachable // inside docker compose) — `pds_public_url` may not be reachable
// from inside the cluster when TLS / DNS is set up for outside // from inside the cluster when TLS / DNS is set up for outside
// clients only. // clients only.
let pds_base_url = cfg //
.pds_internal_url // `AppConfig::pds_base_url()` owns that fallback so the handle
.clone() // resolver and the signing-key fetch in `auth.rs` can never end up
.unwrap_or_else(|| cfg.pds_public_url.clone()); // pointed at different PDS instances.
let pds_resolver: Arc<dyn at_identity::DidHandleResolver> = Arc::new( let pds_resolver: Arc<dyn at_identity::DidHandleResolver> = Arc::new(
at_identity::pds_handle::PdsHandleResolver::new(pds_base_url), at_identity::pds_handle::PdsHandleResolver::new(cfg.pds_base_url()),
); );
let handle_sync = handle_sync::HandleSyncWorker { let handle_sync = handle_sync::HandleSyncWorker {
+1067 -143
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -199,6 +199,165 @@ pub struct SearchResponse {
pub q: String, pub q: String,
} }
// -- thread -----------------------------------------------------------------
/// `GET /api/thread` response.
///
/// This is the *full* thread shape: the ancestor chain above the post
/// and the direct replies below it. `/api/post/{uri}` keeps its own,
/// narrower `{ post, thread: { parent, root } }` shape for backwards
/// compatibility — both are built from the same
/// `routes::load_thread_context` call, so they can never disagree
/// about what the parent or root is.
///
/// `post` is `None` when the URI isn't in our index. In that case
/// `parents` / `replies` are empty and the counters are omitted, so a
/// client can render "post not found" from a single field check.
#[derive(Debug, Serialize)]
pub struct ThreadFullResponse {
pub post: Option<PostRow>,
/// Ancestor chain ordered **root first, immediate parent last**.
/// Empty for a top-level post, and truncated (from the top) when
/// the chain is longer than the walk limit or when an ancestor
/// isn't in our index — the client should treat a `parents[0]`
/// whose `parent_uri` is non-null as "chain continues above,
/// not loaded".
pub parents: Vec<PostRow>,
/// The thread root as named by the post's own `root_uri`. May be
/// the same row as `parents[0]`, and is `None` for a top-level
/// post (which is its own root).
pub root: Option<PostRow>,
/// Direct replies to `post`, oldest first. Only direct children —
/// the client re-requests `/api/thread` for a nested branch.
pub replies: Vec<PostRow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub like_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repost_count: Option<i64>,
/// Same semantics as on `/api/post/{uri}`: `None` means "no
/// viewer_did was supplied, state unknown" — not "not liked".
#[serde(skip_serializing_if = "Option::is_none")]
pub viewer_liked: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewer_reposted: Option<bool>,
}
// -- notifications ----------------------------------------------------------
/// One hydrated row of `GET /api/notifications`.
///
/// The DB row only stores DIDs and a subject URI; the read query joins
/// the `profiles` cache (falling back to the newest non-empty
/// `posts.handle` for authors we've seen post but never had a profile
/// record for) and the `posts` table so the client can render a full
/// notification line without any follow-up fetch.
///
/// `read_at` is serialised even when `None` — unlike the optional
/// fields around it — because "unread" is the state the client's badge
/// keys off, and a *missing* key would be indistinguishable from a
/// field the client forgot to read.
#[derive(Debug, Clone, Serialize)]
pub struct NotificationItem {
/// Row id. Also the tiebreaker inside the opaque cursor.
pub id: i64,
/// `"like"` | `"repost"` | `"follow"` | `"reply"`.
pub kind: String,
pub author_did: String,
/// Best known handle for the author, WITHOUT a leading `@` (the UI
/// renders `@{handle}`). Falls back to a truncated DID, and is
/// never null.
pub author_handle: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub author_display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author_avatar_cid: Option<String>,
/// The post this notification is about. `null` for `"follow"`. For
/// `"like"`/`"repost"` it's the recipient's own post; for
/// `"reply"` it's the reply itself.
pub subject_uri: Option<String>,
/// Text of `subject_uri`'s post. Omitted when the subject is not
/// (or no longer) in our index, and always for `"follow"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_text: Option<String>,
/// The interaction's own `createdAt` from the AT record.
pub created_at: DateTime<Utc>,
/// When the AppView indexed it. This is the list's sort key, and
/// the value a client should echo back as `seenAt`.
pub indexed_at: DateTime<Utc>,
/// `null` while unread.
pub read_at: Option<DateTime<Utc>>,
}
impl<'r> FromRow<'r, sqlx::postgres::PgRow> for NotificationItem {
fn from_row(row: &'r sqlx::postgres::PgRow) -> sqlx::Result<Self> {
Ok(NotificationItem {
id: row.try_get("id")?,
kind: row.try_get("kind")?,
author_did: row.try_get("author_did")?,
author_handle: row.try_get("author_handle")?,
author_display_name: row.try_get("author_display_name")?,
author_avatar_cid: row.try_get("author_avatar_cid")?,
subject_uri: row.try_get("subject_uri")?,
subject_text: row.try_get("subject_text")?,
created_at: row.try_get("created_at")?,
indexed_at: row.try_get("indexed_at")?,
read_at: row.try_get("read_at")?,
})
}
}
/// `GET /api/notifications` response. `cursor` is `None` at the end of
/// the list — same contract as [`TimelineResponse`].
#[derive(Debug, Serialize)]
pub struct NotificationsResponse {
pub notifications: Vec<NotificationItem>,
pub cursor: Option<String>,
}
/// `GET /api/notifications/count` response.
#[derive(Debug, Serialize)]
pub struct NotificationCountResponse {
/// Number of rows with `read_at IS NULL` for this DID.
pub count: i64,
}
/// `POST /api/notifications/seen` response.
#[derive(Debug, Serialize)]
pub struct NotificationSeenResponse {
pub ok: bool,
/// How many previously-unread rows this call flipped to read.
/// Zero is a normal, successful outcome (nothing was unread).
pub updated: i64,
}
// -- actor lists (followers / following) ------------------------------------
/// A minimal profile card, as returned by `GET /api/followers` and
/// `GET /api/following`.
///
/// Deliberately *not* [`ProfileResponse`]: those endpoints return a
/// list, and shipping each entry's posts + counts would turn one page
/// of 30 followers into 30 post queries. The client renders a row with
/// avatar + name + handle and navigates to `/api/profile` on click.
#[derive(Debug, Clone, Serialize)]
pub struct ActorProfile {
pub did: String,
/// Without a leading `@`; falls back to a truncated DID and is
/// never null.
pub handle: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_cid: Option<String>,
}
/// `GET /api/followers` / `GET /api/following` response.
#[derive(Debug, Serialize)]
pub struct ActorListResponse {
pub profiles: Vec<ActorProfile>,
pub cursor: Option<String>,
}
// -- Langs newtype ---------------------------------------------------------- // -- Langs newtype ----------------------------------------------------------
/// A list of language tags. Always serialises as `Vec<String>`, never /// A list of language tags. Always serialises as `Vec<String>`, never
+14 -2
View File
@@ -2,18 +2,30 @@ use at_shared::config::AppConfig;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use crate::auth::PdsKeys;
use crate::firehose::Stats; use crate::firehose::Stats;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
#[allow(dead_code)]
pub cfg: AppConfig, pub cfg: AppConfig,
pub db: PgPool, pub db: PgPool,
pub stats: Arc<Stats>, pub stats: Arc<Stats>,
/// Cache of the PDS's published signing key, used by the
/// [`crate::auth::AuthedDid`] extractor. Shared (`Arc`) so every
/// handler verifies against the same cached key and one HTTP
/// client, and so a key rotation picked up by one request is
/// immediately visible to the rest.
pub pds_keys: Arc<PdsKeys>,
} }
impl AppState { impl AppState {
pub fn new(cfg: AppConfig, db: PgPool, stats: Arc<Stats>) -> Self { pub fn new(cfg: AppConfig, db: PgPool, stats: Arc<Stats>) -> Self {
Self { cfg, db, stats } let pds_keys = Arc::new(PdsKeys::from_config(&cfg));
Self {
cfg,
db,
stats,
pds_keys,
}
} }
} }
+91 -65
View File
@@ -7,6 +7,9 @@
//! rather than panicking — so `cargo test --workspace` stays green in //! rather than panicking — so `cargo test --workspace` stays green in
//! environments where the appview hasn't been started. //! environments where the appview hasn't been started.
mod common;
use common::TestAuth;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::time::Duration; use std::time::Duration;
@@ -46,6 +49,36 @@ async fn db_reachable() -> bool {
) )
} }
/// How this suite authenticates against `/api/timeline/home`, which is
/// no longer public. `None` means the AppView enforces auth but the
/// test process cannot mint a token (no `PDS_JWT_SECRET`), in which
/// case the test skips like it does for a missing service.
async fn auth_or_skip() -> Option<TestAuth> {
TestAuth::probe(&client().await, APPVIEW_URL).await
}
/// `GET /api/timeline/home` as `did`, with the bearer token attached
/// when the instance requires one. The seeded DIDs are synthetic, so
/// the token is minted from the PDS's own signing secret — see
/// `tests/common/mod.rs`.
async fn get_timeline(
c: &reqwest::Client,
auth: &TestAuth,
did: &str,
extra: &[(&str, &str)],
) -> reqwest::Response {
let mut params: Vec<(&str, &str)> = vec![("did", did)];
params.extend_from_slice(extra);
auth.apply(
c.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&params),
did,
)
.send()
.await
.unwrap()
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response { async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
c.post(format!("{APPVIEW_URL}/internal/ingest-commit")) c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
.json(&body) .json(&body)
@@ -120,6 +153,7 @@ async fn timeline_returns_seeded_posts() {
return; return;
} }
let c = client().await; let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("tl"); let did = did_for_test("tl");
// Seed 3 posts with distinct rkeys. // Seed 3 posts with distinct rkeys.
@@ -134,7 +168,11 @@ async fn timeline_returns_seeded_posts() {
"cid": "bafyreicid", "cid": "bafyreicid",
"record": { "record": {
"text": format!("seeded post #{i}"), "text": format!("seeded post #{i}"),
"createdAt": "2026-07-01T12:00:00Z", // Strictly increasing so the ordering assertion
// below has something to actually check: the rows
// are inserted in this order, so `indexed_at` and
// `created_at` agree for *our* posts.
"createdAt": format!("2026-07-01T12:00:{:02}Z", i),
} }
}), }),
) )
@@ -147,12 +185,13 @@ async fn timeline_returns_seeded_posts() {
// three rows in the first page. // three rows in the first page.
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
let resp = c // `limit=100`, not 10: this DID follows nobody, so the endpoint
.get(format!("{APPVIEW_URL}/api/timeline/home")) // serves the cold-start *global* recent feed. On any database with
.query(&[("did", did.as_str()), ("limit", "10")]) // more than a handful of recent posts (i.e. every developer
.send() // machine that has run this suite twice) the three rows we just
.await // seeded fall outside a 10-row window and the assertions below
.unwrap(); // fail for reasons that have nothing to do with the timeline.
let resp = get_timeline(&c, &auth, &did, &[("limit", "100")]).await;
assert_eq!(resp.status().as_u16(), 200); assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array"); let posts = body["posts"].as_array().expect("posts is array");
@@ -174,15 +213,28 @@ async fn timeline_returns_seeded_posts() {
.collect(); .collect();
assert!(our_uris.len() >= 3, "missing our seeded posts in {posts:?}"); assert!(our_uris.len() >= 3, "missing our seeded posts in {posts:?}");
// Posts must be sorted with `indexed_at DESC`. We can't see // Posts come back `indexed_at DESC`, and we can't see `indexed_at`
// indexed_at directly in the response, but the URI order in // in the response — but for the three rows WE just inserted,
// `app.twi.post/<rkey>` is rkey-random here, so we only assert // insertion order == `indexed_at` order == `created_at` order, so
// `created_at` is non-increasing. // their `created_at` values must be non-increasing.
//
// Two fixes over the original assertion:
// - the wire field is `created_at`, not `createdAt` (the
// `PostRow` wire type in `routes/types.rs` carries no
// `rename_all = "camelCase"`), so `p["createdAt"]` was JSON
// `null` and `.as_str().unwrap()` panicked on the first row;
// - it ran over ALL posts, including other tests' fixtures from
// the global cold-start feed, whose `created_at` values have
// no relation to their `indexed_at` order. Restricting it to
// our own DID is the only version of this claim that holds.
let mut prev: Option<String> = None; let mut prev: Option<String> = None;
for p in posts { for p in posts.iter().filter(|p| p["did"] == json!(did)) {
let ca = p["createdAt"].as_str().unwrap().to_string(); let ca = p["created_at"].as_str().unwrap().to_string();
if let Some(p) = prev.take() { if let Some(prev) = prev.take() {
assert!(ca <= p, "createdAt must be non-increasing: {ca} <= {p}"); assert!(
ca <= prev,
"created_at must be non-increasing: {ca} <= {prev}"
);
} }
prev = Some(ca); prev = Some(ca);
} }
@@ -199,6 +251,7 @@ async fn timeline_paginates_with_cursor() {
return; return;
} }
let c = client().await; let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("pg"); let did = did_for_test("pg");
// Seed 50 posts. // Seed 50 posts.
@@ -223,28 +276,14 @@ async fn timeline_paginates_with_cursor() {
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
// Page 1: limit=20. // Page 1: limit=20.
let resp = c let resp = get_timeline(&c, &auth, &did, &[("limit", "20")]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "20")])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let page1 = body["posts"].as_array().unwrap().clone(); let page1 = body["posts"].as_array().unwrap().clone();
let cursor1 = body["cursor"].as_str().expect("page1 cursor"); let cursor1 = body["cursor"].as_str().expect("page1 cursor");
assert_eq!(page1.len(), 20, "page1 should be exactly 20"); assert_eq!(page1.len(), 20, "page1 should be exactly 20");
// Page 2: with cursor. // Page 2: with cursor.
let resp = c let resp = get_timeline(&c, &auth, &did, &[("limit", "20"), ("cursor", cursor1)]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[
("did", did.as_str()),
("limit", "20"),
("cursor", cursor1),
])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let page2 = body["posts"].as_array().unwrap().clone(); let page2 = body["posts"].as_array().unwrap().clone();
assert_eq!(page2.len(), 20, "page2 should be exactly 20"); assert_eq!(page2.len(), 20, "page2 should be exactly 20");
@@ -262,16 +301,7 @@ async fn timeline_paginates_with_cursor() {
// Page 3: tail — fewer than 20 expected, cursor=null. // Page 3: tail — fewer than 20 expected, cursor=null.
let cursor2 = body["cursor"].as_str().expect("page2 cursor"); let cursor2 = body["cursor"].as_str().expect("page2 cursor");
let resp = c let resp = get_timeline(&c, &auth, &did, &[("limit", "20"), ("cursor", cursor2)]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[
("did", did.as_str()),
("limit", "20"),
("cursor", cursor2),
])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let page3 = body["posts"].as_array().unwrap().clone(); let page3 = body["posts"].as_array().unwrap().clone();
assert!(page3.len() <= 20, "page3 should be <= 20"); assert!(page3.len() <= 20, "page3 should be <= 20");
@@ -354,16 +384,24 @@ async fn profile_returns_posts_for_handle() {
.unwrap(); .unwrap();
assert_eq!(resp.status().as_u16(), 200); assert_eq!(resp.status().as_u16(), 200);
// 404 for an unknown handle. // An unknown handle is NOT a 404. `resolve_profile` deliberately
// synthesises an empty profile (empty `did`, zero counts, no
// posts) so the UI renders an empty profile page instead of an
// error toast — see the comment on the `let Some(target_did)`
// else-branch in `routes.rs`. This assertion used to expect 404
// and contradicted the endpoint it was testing.
let unknown = format!("nobody_{}", uuid::Uuid::new_v4().simple());
let resp = c let resp = c
.get(format!( .get(format!("{APPVIEW_URL}/api/profile/{unknown}"))
"{APPVIEW_URL}/api/profile/nobody_{}",
uuid::Uuid::new_v4().simple()
))
.send() .send()
.await .await
.unwrap(); .unwrap();
assert_eq!(resp.status().as_u16(), 404); assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["did"], json!(""));
assert_eq!(body["handle"], json!(unknown));
assert!(body["posts"].as_array().unwrap().is_empty());
assert_eq!(body["post_count"], json!(0));
// /api/profile?did=... must work too. // /api/profile?did=... must work too.
let resp = c let resp = c
@@ -453,6 +491,7 @@ async fn timeline_filters_to_followees() {
return; return;
} }
let c = client().await; let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let url = std::env::var("DATABASE_URL_APPVIEW").unwrap(); let url = std::env::var("DATABASE_URL_APPVIEW").unwrap();
let pool = sqlx::PgPool::connect(&url).await.unwrap(); let pool = sqlx::PgPool::connect(&url).await.unwrap();
@@ -472,12 +511,7 @@ async fn timeline_filters_to_followees() {
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
let resp = c let resp = get_timeline(&c, &auth, &alice, &[("limit", "100")]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", alice.as_str()), ("limit", "100")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200); assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array"); let posts = body["posts"].as_array().expect("posts is array");
@@ -524,18 +558,14 @@ async fn timeline_includes_own_posts() {
return; return;
} }
let c = client().await; let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let alice = did_for_test("alone"); let alice = did_for_test("alone");
// Alice posts without seeding any follows. // Alice posts without seeding any follows.
seed_posts(&c, &alice, &["alice's first post", "alice's second post"]).await; seed_posts(&c, &alice, &["alice's first post", "alice's second post"]).await;
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
let resp = c let resp = get_timeline(&c, &auth, &alice, &[("limit", "100")]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", alice.as_str()), ("limit", "100")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200); assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array"); let posts = body["posts"].as_array().expect("posts is array");
@@ -574,6 +604,7 @@ async fn timeline_caps_followee_list() {
return; return;
} }
let c = client().await; let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let url = std::env::var("DATABASE_URL_APPVIEW").unwrap(); let url = std::env::var("DATABASE_URL_APPVIEW").unwrap();
let pool = sqlx::PgPool::connect(&url).await.unwrap(); let pool = sqlx::PgPool::connect(&url).await.unwrap();
@@ -593,12 +624,7 @@ async fn timeline_caps_followee_list() {
seed_posts(&c, &alice, &["poweruser post"]).await; seed_posts(&c, &alice, &["poweruser post"]).await;
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
let resp = c let resp = get_timeline(&c, &auth, &alice, &[("limit", "50")]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", alice.as_str()), ("limit", "50")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200); assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array"); let posts = body["posts"].as_array().expect("posts is array");
+311
View File
@@ -0,0 +1,311 @@
//! End-to-end tests for AppView authentication.
//!
//! Unlike the other suites in this directory these use a **real**
//! token: the test creates an account on the running PDS
//! (`com.atproto.server.createAccount`, the same call the desktop
//! client makes at signup) and presents the access JWT it gets back to
//! the AppView. That is the whole point — it exercises the actual key
//! distribution path (PDS signs → publishes its public key at
//! `/.well-known/did.json` → AppView fetches and verifies), which a
//! self-minted token would bypass.
//!
//! Fail-open like the rest of the suites: if the PDS or the AppView
//! isn't running, or the AppView runs with `APPVIEW_AUTH_REQUIRED=false`,
//! the test prints a notice and returns successfully.
use serde_json::{json, Value};
use std::time::Duration;
const PDS_URL: &str = "http://127.0.0.1:2583";
fn appview_url() -> String {
std::env::var("APPVIEW_TEST_URL").unwrap_or_else(|_| "http://127.0.0.1:2584".to_string())
}
/// 30 s, not the 5 s the sibling suites use.
///
/// The authenticated happy path for `/api/timeline/home` runs the
/// cold-start query (a brand-new account follows nobody, so the handler
/// falls back to the global recent feed). On a developer machine whose
/// AppView has been indexing the public firehose for a while that scan
/// takes seconds — a timeout there would look like an auth failure and
/// is nothing of the sort.
fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.unwrap()
}
async fn up(c: &reqwest::Client, base: &str) -> bool {
for _ in 0..20 {
if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
/// A freshly created PDS account.
struct Account {
did: String,
access_jwt: String,
refresh_jwt: String,
}
/// Guard for every test here: both services up **and** the AppView
/// actually enforcing auth. Returns the client plus a new account.
async fn ready() -> Option<(reqwest::Client, Account)> {
let c = client();
if !up(&c, &appview_url()).await {
eprintln!("appview not running, skipping");
return None;
}
if !up(&c, PDS_URL).await {
eprintln!("pds not running, skipping");
return None;
}
// Probe: does this instance enforce auth? An operator running with
// `APPVIEW_AUTH_REQUIRED=false` (the mode the other suites use)
// would otherwise see every assertion here fail for the one reason
// that isn't a bug.
let probe = c
.get(format!("{}/api/notifications/count", appview_url()))
.query(&[("did", "did:plc:auth_probe")])
.send()
.await
.ok()?;
if probe.status().as_u16() != 401 {
eprintln!(
"appview does not enforce auth (probe returned {}), skipping",
probe.status()
);
return None;
}
let acc = create_account(&c).await?;
Some((c, acc))
}
async fn create_account(c: &reqwest::Client) -> Option<Account> {
let handle = format!("auth_{}.maarcadetweet.local", uuid::Uuid::new_v4().simple());
let r: Value = c
.post(format!("{PDS_URL}/xrpc/com.atproto.server.createAccount"))
.json(&json!({ "handle": handle, "password": "hunter2hunter2" }))
.send()
.await
.ok()?
.json()
.await
.ok()?;
Some(Account {
did: r["did"].as_str()?.to_string(),
access_jwt: r["access_jwt"].as_str()?.to_string(),
refresh_jwt: r["refresh_jwt"].as_str()?.to_string(),
})
}
/// `GET /api/notifications?did=…` with an optional bearer token.
async fn get_notifications(
c: &reqwest::Client,
did: &str,
token: Option<&str>,
) -> reqwest::Response {
let mut rb = c
.get(format!("{}/api/notifications", appview_url()))
.query(&[("did", did)]);
if let Some(t) = token {
rb = rb.bearer_auth(t);
}
rb.send().await.unwrap()
}
/// Assert the AppView's standard error envelope: the status, and the
/// `error` code the desktop client branches on.
async fn assert_error(r: reqwest::Response, status: u16, code: &str) {
let got = r.status().as_u16();
let body: Value = r.json().await.unwrap();
assert_eq!(got, status, "unexpected status; body = {body}");
assert_eq!(body["error"], json!(code), "unexpected error code: {body}");
assert!(
body["message"].is_string(),
"error body must carry a message: {body}"
);
}
#[tokio::test]
async fn own_token_reads_own_notifications() {
let Some((c, acc)) = ready().await else { return };
let r = get_notifications(&c, &acc.did, Some(&acc.access_jwt)).await;
assert_eq!(r.status().as_u16(), 200);
let body: Value = r.json().await.unwrap();
// A brand-new account has no notifications, but the shape must be
// the normal list response, not an error.
assert!(body["notifications"].is_array(), "body = {body}");
}
#[tokio::test]
async fn no_token_is_401_auth_missing() {
let Some((c, acc)) = ready().await else { return };
let r = get_notifications(&c, &acc.did, None).await;
assert_error(r, 401, "AuthMissing").await;
}
#[tokio::test]
async fn foreign_did_is_403() {
let Some((c, acc)) = ready().await else { return };
// A valid token, but asking about somebody else's inbox. This is
// the case the endpoint used to answer with a 200.
let r = get_notifications(&c, "did:plc:somebodyelse", Some(&acc.access_jwt)).await;
assert_error(r, 403, "Forbidden").await;
}
#[tokio::test]
async fn garbage_token_is_401_token_invalid() {
let Some((c, acc)) = ready().await else { return };
let r = get_notifications(&c, &acc.did, Some("not.a.jwt")).await;
assert_error(r, 401, "TokenInvalid").await;
}
#[tokio::test]
async fn refresh_token_is_not_accepted() {
let Some((c, acc)) = ready().await else { return };
// Signed by the same key and valid for 90 days — only the `scope`
// claim separates it from a session credential.
let r = get_notifications(&c, &acc.did, Some(&acc.refresh_jwt)).await;
assert_error(r, 401, "TokenInvalid").await;
}
#[tokio::test]
async fn seen_write_requires_matching_token() {
let Some((c, acc)) = ready().await else { return };
let url = format!("{}/api/notifications/seen", appview_url());
// Somebody else's unread badge: 403, nothing written.
let r = c
.post(&url)
.bearer_auth(&acc.access_jwt)
.json(&json!({ "did": "did:plc:somebodyelse" }))
.send()
.await
.unwrap();
assert_error(r, 403, "Forbidden").await;
// No credential at all: 401.
let r = c
.post(&url)
.json(&json!({ "did": &acc.did }))
.send()
.await
.unwrap();
assert_error(r, 401, "AuthMissing").await;
// Own inbox: allowed (zero rows updated — the account is new).
let r = c
.post(&url)
.bearer_auth(&acc.access_jwt)
.json(&json!({ "did": &acc.did }))
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200);
let body: Value = r.json().await.unwrap();
assert_eq!(body["ok"], json!(true));
}
#[tokio::test]
async fn timeline_home_is_authenticated() {
let Some((c, acc)) = ready().await else { return };
let base = appview_url();
let r = c
.get(format!("{base}/api/timeline/home"))
.query(&[("did", acc.did.as_str())])
.send()
.await
.unwrap();
assert_error(r, 401, "AuthMissing").await;
let r = c
.get(format!("{base}/api/timeline/home"))
.query(&[("did", "did:plc:somebodyelse")])
.bearer_auth(&acc.access_jwt)
.send()
.await
.unwrap();
assert_error(r, 403, "Forbidden").await;
let r = c
.get(format!("{base}/api/timeline/home"))
.query(&[("did", acc.did.as_str())])
.bearer_auth(&acc.access_jwt)
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200);
}
#[tokio::test]
async fn public_endpoints_stay_public() {
let Some((c, acc)) = ready().await else { return };
let base = appview_url();
// Profiles, search, follower lists and the health probe are public
// records in AT Proto. Putting them behind auth would be a
// behaviour change no protocol rule asks for — assert they still
// answer without a token.
for (path, params) in [
("/api/profile", vec![("did", acc.did.as_str())]),
("/api/search", vec![("q", "hello")]),
("/api/followers", vec![("did", acc.did.as_str())]),
("/api/following", vec![("did", acc.did.as_str())]),
("/healthz", vec![]),
] {
let r = c
.get(format!("{base}{path}"))
.query(&params)
.send()
.await
.unwrap();
assert_eq!(
r.status().as_u16(),
200,
"{path} must remain public, got {}",
r.status()
);
}
}
/// The AppView can only verify anything because the PDS publishes its
/// public key. If this document ever stops carrying a usable key, every
/// authenticated request degrades to `503 AuthUnavailable` — so assert
/// the shape the AppView parses.
#[tokio::test]
async fn pds_publishes_a_usable_signing_key() {
let c = client();
if !up(&c, PDS_URL).await {
eprintln!("pds not running, skipping");
return;
}
let doc: Value = c
.get(format!("{PDS_URL}/.well-known/did.json"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let id = doc["id"].as_str().expect("did document needs an id");
assert!(id.starts_with("did:web:"), "id = {id}");
let vm = &doc["verificationMethod"][0];
assert_eq!(vm["type"], json!("Multikey"));
assert_eq!(vm["controller"], json!(id));
let key = vm["publicKeyMultibase"]
.as_str()
.expect("verificationMethod needs publicKeyMultibase");
// base58-btc multibase: the `z` prefix is what the AppView's
// decoder expects.
assert!(key.starts_with('z'), "key = {key}");
}
+167
View File
@@ -0,0 +1,167 @@
//! Shared test support for the AppView integration suites.
//!
//! ## Why the suites need this
//!
//! `/api/notifications*` and `/api/timeline/home` require a
//! PDS-issued access token whose `sub` equals the `did` in the request.
//! The suites, however, seed synthetic DIDs (`did:plc:ntf_…`) through
//! `/internal/ingest-commit` — accounts the PDS has never heard of, so
//! there is no `createSession` that would hand out a token for them.
//!
//! The way out is that a token is just an ES256 JWT signed with the
//! server key derived from `PDS_JWT_SECRET`. A test that can read that
//! secret (from the process environment, or from the repo `.env` the
//! dev stack itself was started with) can mint a token for any DID it
//! likes — the same thing `pds-server/src/jwt_issuer.rs` does.
//!
//! ## Fail-open, like the rest of the suites
//!
//! [`TestAuth::probe`] asks the running AppView whether it enforces
//! auth at all:
//!
//! - not enforcing (`APPVIEW_AUTH_REQUIRED=false`) → no header needed;
//! - enforcing and we have the secret → mint per-DID tokens;
//! - enforcing and we don't → `None`, and the caller skips, exactly as
//! it already skips when the service or the database is down.
#![allow(dead_code)]
use at_crypto::ecdsa::P256Keypair;
use at_crypto::jwt::{issue_jwt, JwtClaims};
/// Audience the PDS stamps into access tokens. Not validated by
/// `verify_jwt` today (`validate_aud = false`), but minting a token
/// that differs from the real thing would make this helper a poor
/// stand-in for the client.
const APPVIEW_AUD: &str = "did:web:appview.maarcadetweet.local";
/// The scope the AppView insists on. A token with any other scope —
/// `com.atproto.refresh`, say — is rejected with `TokenInvalid`.
pub const ACCESS_SCOPE: &str = "com.atproto.access";
/// How the suite should authenticate against the AppView under test.
///
/// `Clone` because a test that pages through results in a closure has
/// to hand each iteration its own copy, exactly like the client and the
/// base URL next to it.
#[derive(Clone)]
pub enum TestAuth {
/// `APPVIEW_AUTH_REQUIRED=false`: send no `Authorization` header.
Disabled,
/// Auth is enforced; mint tokens with this hex secret.
Secret(String),
}
impl TestAuth {
/// Decide how (or whether) this suite can talk to the AppView.
///
/// Returns `None` when the AppView enforces auth but no
/// `PDS_JWT_SECRET` is reachable — the caller should print a notice
/// and return, keeping `cargo test --workspace` green on a machine
/// without the dev stack's environment.
pub async fn probe(c: &reqwest::Client, base_url: &str) -> Option<Self> {
// An unauthenticated probe against a private endpoint. We only
// look at the status: 401 means the extractor is active. A DID
// that doesn't exist is fine — the auth check runs first.
let status = c
.get(format!("{base_url}/api/notifications/count"))
.query(&[("did", "did:plc:auth_probe")])
.send()
.await
.ok()?
.status()
.as_u16();
if status != 401 && status != 503 {
return Some(TestAuth::Disabled);
}
match pds_jwt_secret() {
Some(secret) => Some(TestAuth::Secret(secret)),
None => {
eprintln!(
"appview enforces auth (probe returned {status}) but PDS_JWT_SECRET \
is not set and no .env was found — skipping"
);
None
}
}
}
/// Attach an `Authorization: Bearer` header for `did`, if needed.
pub fn apply(&self, rb: reqwest::RequestBuilder, did: &str) -> reqwest::RequestBuilder {
match self {
TestAuth::Disabled => rb,
TestAuth::Secret(secret) => match mint_access_jwt(secret, did, ACCESS_SCOPE, 3600) {
Some(token) => rb.bearer_auth(token),
None => rb,
},
}
}
/// A token for `did` — for tests that want to send a *wrong* one on
/// purpose. `None` when auth is disabled, in which case the test
/// that needs it should skip.
pub fn token_for(&self, did: &str) -> Option<String> {
match self {
TestAuth::Disabled => None,
TestAuth::Secret(secret) => mint_access_jwt(secret, did, ACCESS_SCOPE, 3600),
}
}
}
/// `PDS_JWT_SECRET` from the environment, falling back to the repo
/// `.env` — the same file the running dev stack loaded at startup, so
/// the minted tokens verify against the key the PDS actually publishes.
pub fn pds_jwt_secret() -> Option<String> {
if let Ok(v) = std::env::var("PDS_JWT_SECRET") {
if !v.trim().is_empty() {
return Some(v);
}
}
// `dotenvy::dotenv` walks up from the current directory, which for
// a test binary is the crate root — so this finds the workspace
// `.env` two levels up. It never overrides a real env var.
let _ = dotenvy::dotenv();
std::env::var("PDS_JWT_SECRET")
.ok()
.filter(|v| !v.trim().is_empty())
}
/// Mint an access JWT for `did`, signed with the PDS's server key.
///
/// Mirrors `pds-server/src/jwt_issuer.rs`: the P-256 secret scalar is
/// the **first 32 bytes** of `PDS_JWT_SECRET` (the config allows a
/// longer secret), i.e. the first 64 hex characters.
///
/// `ttl_secs` may be negative to build a deliberately expired token.
pub fn mint_access_jwt(
secret_hex: &str,
did: &str,
scope: &str,
ttl_secs: i64,
) -> Option<String> {
let hex = secret_hex.trim().trim_start_matches("0x");
if hex.len() < 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
eprintln!("PDS_JWT_SECRET is not ≥32 bytes of hex; cannot mint a test token");
return None;
}
let kp = P256Keypair {
secret_hex: hex[..64].to_string(),
// Only the signing half is used by `issue_jwt`; the verifier
// fetches the public key from the PDS's DID document.
public_multibase: String::new(),
};
let now = chrono::Utc::now().timestamp();
issue_jwt(
&kp,
&JwtClaims {
iss: "did:web:test".into(),
sub: did.to_string(),
aud: APPVIEW_AUD.into(),
iat: now - 1,
exp: now + ttl_secs,
jti: None,
scope: Some(scope.to_string()),
},
)
.ok()
}
+36 -24
View File
@@ -15,6 +15,9 @@
//! and returns rather than panicking. The point of the tests is to //! and returns rather than panicking. The point of the tests is to
//! catch regressions in CI where the service IS up. //! catch regressions in CI where the service IS up.
mod common;
use common::TestAuth;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::time::Duration; use std::time::Duration;
@@ -50,6 +53,32 @@ async fn db_reachable() -> bool {
) )
} }
/// `/api/timeline/home` requires a token whose `sub` is the requested
/// DID. The DIDs here are synthetic, so the token is minted from the
/// PDS signing secret — see `tests/common/mod.rs`. `None` → skip.
async fn auth_or_skip() -> Option<TestAuth> {
TestAuth::probe(&client().await, APPVIEW_URL).await
}
/// `GET /api/timeline/home` as `did`, authenticated when required.
async fn get_timeline(
c: &reqwest::Client,
auth: &TestAuth,
did: &str,
extra: &[(&str, &str)],
) -> reqwest::Response {
let mut params: Vec<(&str, &str)> = vec![("did", did)];
params.extend_from_slice(extra);
auth.apply(
c.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&params),
did,
)
.send()
.await
.unwrap()
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response { async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
c.post(format!("{APPVIEW_URL}/internal/ingest-commit")) c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
.json(&body) .json(&body)
@@ -125,6 +154,7 @@ async fn timeline_includes_embed() {
return; return;
} }
let c = client().await; let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("img"); let did = did_for_test("img");
let uri = seed_post( let uri = seed_post(
&c, &c,
@@ -160,12 +190,7 @@ async fn timeline_includes_embed() {
) )
.await; .await;
let resp = c let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200); assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().unwrap(); let posts = body["posts"].as_array().unwrap();
@@ -199,6 +224,7 @@ async fn timeline_includes_external_embed() {
return; return;
} }
let c = client().await; let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("ext"); let did = did_for_test("ext");
let uri = seed_post( let uri = seed_post(
&c, &c,
@@ -222,12 +248,7 @@ async fn timeline_includes_external_embed() {
) )
.await; .await;
let resp = c let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200); assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().unwrap(); let posts = body["posts"].as_array().unwrap();
@@ -241,12 +262,7 @@ async fn timeline_includes_external_embed() {
break; break;
} }
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
let resp = c let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
our = body["posts"] our = body["posts"]
.as_array() .as_array()
@@ -410,6 +426,7 @@ async fn timeline_post_without_embed_has_null_embed() {
return; return;
} }
let c = client().await; let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("plain"); let did = did_for_test("plain");
let uri = seed_post( let uri = seed_post(
&c, &c,
@@ -421,12 +438,7 @@ async fn timeline_post_without_embed_has_null_embed() {
) )
.await; .await;
let resp = c let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "10")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200); assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap(); let body: Value = resp.json().await.unwrap();
let our = body["posts"] let our = body["posts"]
+43 -10
View File
@@ -175,8 +175,13 @@ async fn sync_resolves_known_did() {
seed_post(&db, &did, "rkb", "", "second").await.unwrap(); seed_post(&db, &did, "rkb", "", "second").await.unwrap();
assert_eq!(count_empty_handle_for(&db, &did).await.unwrap(), 2); assert_eq!(count_empty_handle_for(&db, &did).await.unwrap(), 2);
// Drive the resolve half with our own DID. `run_once()` scans
// globally, ordered by DID and capped at BATCH_SIZE, so on a
// database that a live indexer keeps topping up, a freshly seeded
// DID isn't guaranteed to make the batch — the assertions below
// would then be measuring someone else's rows.
let worker = worker_with(db.clone(), stub.clone()); let worker = worker_with(db.clone(), stub.clone());
let report: SyncReport = worker.run_once().await.unwrap(); let report: SyncReport = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!(report.resolved, 2, "{report:?}"); assert_eq!(report.resolved, 2, "{report:?}");
assert_eq!(report.failed, 0); assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 0); assert_eq!(report.skipped, 0);
@@ -224,9 +229,16 @@ async fn sync_skips_already_resolved() {
.into_arc(); .into_arc();
let worker = worker_with(db.clone(), stub.clone()); let worker = worker_with(db.clone(), stub.clone());
let report = worker.run_once().await.unwrap(); // This test is about the SELECT: a DID whose rows already carry a
assert_eq!(report.resolved, 0, "{report:?}"); // handle must never reach a resolver in the first place. So assert
assert_eq!(report.failed, 0); // on `select_candidates()` rather than forcing the DID through
// `resolve_batch` — that would consult the resolver by definition
// and defeat the `query_count == 0` check below.
let candidates = worker.select_candidates().await.unwrap();
assert!(
!candidates.contains(&did),
"a DID that already has a handle must not be selected"
);
// Both rows must still carry the pre-existing handle. // Both rows must still carry the pre-existing handle.
let (cnt,): (i64,) = sqlx::query_as( let (cnt,): (i64,) = sqlx::query_as(
@@ -279,7 +291,16 @@ async fn sync_respects_limit() {
let stub = StubResolver::new(mapping).into_arc(); let stub = StubResolver::new(mapping).into_arc();
let worker = worker_with(db.clone(), stub.clone()); let worker = worker_with(db.clone(), stub.clone());
let report = worker.run_once().await.unwrap(); // The cap lives in the SELECT, so assert it there; the report of a
// full `run_once()` depends on what else is pending database-wide.
let candidates = worker.select_candidates().await.unwrap();
assert!(
candidates.len() as i64 <= BATCH_SIZE,
"select must never exceed BATCH_SIZE, got {}",
candidates.len()
);
let batch: Vec<String> = all_dids.iter().take(BATCH_SIZE as usize).cloned().collect();
let report = worker.resolve_batch(batch).await.unwrap();
assert_eq!( assert_eq!(
report.resolved as i64, report.resolved as i64,
BATCH_SIZE, BATCH_SIZE,
@@ -339,7 +360,7 @@ async fn sync_skips_unresolvable_dids() {
let stub = StubResolver::new(HashMap::new()).into_arc(); let stub = StubResolver::new(HashMap::new()).into_arc();
let worker = worker_with(db.clone(), stub.clone()); let worker = worker_with(db.clone(), stub.clone());
let report = worker.run_once().await.unwrap(); let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!(report.resolved, 0); assert_eq!(report.resolved, 0);
assert_eq!(report.failed, 0); assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 1, "{report:?}"); assert_eq!(report.skipped, 1, "{report:?}");
@@ -388,14 +409,21 @@ async fn sync_resolves_did_web_via_web_resolver() {
let plc_arc: Arc<dyn DidHandleResolver> = plc.into_arc(); let plc_arc: Arc<dyn DidHandleResolver> = plc.into_arc();
let web_arc: Arc<dyn DidHandleResolver> = web.into_arc(); let web_arc: Arc<dyn DidHandleResolver> = web.into_arc();
// The local PDS is consulted before the method dispatch and does
// not host a foreign `did:web:` — wiring one of the other stubs in
// here would make it claim a DID it doesn't have, and the test
// would assert against the PDS-first rule instead of the dispatch.
let pds_arc: Arc<dyn DidHandleResolver> =
StubResolver::new(HashMap::new()).into_arc();
let worker = HandleSyncWorker { let worker = HandleSyncWorker {
db: db.clone(), db: db.clone(),
pds_resolver: Arc::clone(&plc_arc), pds_resolver: pds_arc,
plc_resolver: plc_arc, plc_resolver: plc_arc,
web_resolver: web_arc, web_resolver: web_arc,
interval_secs: 999, interval_secs: 999,
}; };
let report = worker.run_once().await.unwrap(); let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!( assert_eq!(
report.resolved, 1, report.resolved, 1,
"did:web must resolve through the web resolver, got {report:?}" "did:web must resolve through the web resolver, got {report:?}"
@@ -442,14 +470,19 @@ async fn sync_resolves_did_plc_via_plc_resolver() {
let plc_arc: Arc<dyn DidHandleResolver> = plc.into_arc(); let plc_arc: Arc<dyn DidHandleResolver> = plc.into_arc();
let web_arc: Arc<dyn DidHandleResolver> = web.into_arc(); let web_arc: Arc<dyn DidHandleResolver> = web.into_arc();
// Same reasoning as the did:web test: the PDS doesn't host this
// DID, so the method dispatch is what's under test.
let pds_arc: Arc<dyn DidHandleResolver> =
StubResolver::new(HashMap::new()).into_arc();
let worker = HandleSyncWorker { let worker = HandleSyncWorker {
db: db.clone(), db: db.clone(),
pds_resolver: Arc::clone(&plc_arc), pds_resolver: pds_arc,
plc_resolver: plc_arc, plc_resolver: plc_arc,
web_resolver: web_arc, web_resolver: web_arc,
interval_secs: 999, interval_secs: 999,
}; };
let report = worker.run_once().await.unwrap(); let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
assert_eq!( assert_eq!(
report.resolved, 1, report.resolved, 1,
"did:plc must resolve through the PLC resolver, got {report:?}" "did:plc must resolve through the PLC resolver, got {report:?}"
@@ -0,0 +1,777 @@
//! Integration tests for the Phase-5c read API additions:
//! `/api/notifications` (+ `/count`, `/seen`), `/api/followers`,
//! `/api/following` and `/api/thread`.
//!
//! Same contract as `api_integration.rs`: these run against a live
//! appview service + DB and are **fail-open**. If the service or the
//! database isn't reachable the test prints a notice and returns
//! success, so `cargo test --workspace` stays green on a machine where
//! `docker compose up` hasn't been run.
//!
//! Notification rows are written by the *indexer*, not by any HTTP
//! endpoint, so every test here seeds through `/internal/ingest-commit`
//! (the same path the PDS uses) and then reads back through the public
//! API. That's deliberate: it's the only way to catch a mismatch
//! between what the write path stores and what the read path joins.
mod common;
use common::TestAuth;
use serde_json::{json, Value};
use std::time::Duration;
/// Base URL of the appview under test. Overridable so the suite can be
/// pointed at a throwaway instance on a scratch database instead of
/// whatever the developer happens to have running on the default port.
fn appview_url() -> String {
std::env::var("APPVIEW_TEST_URL")
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string())
}
async fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap()
}
async fn wait_for_appview_db() -> bool {
let base = appview_url();
let c = client().await;
for _ in 0..20 {
if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
async fn db_pool() -> Option<sqlx::PgPool> {
let url = std::env::var("DATABASE_URL_APPVIEW").ok()?;
match tokio::time::timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await {
Ok(Ok(pool)) => Some(pool),
_ => None,
}
}
/// Guard used at the top of every test. Returns `None` (→ skip) unless
/// the HTTP service is up, the database is reachable, **and** we know
/// how to authenticate: `/api/notifications*` now requires a token
/// whose `sub` is the requested DID, and the DIDs seeded here are
/// synthetic, so the token has to be minted from the PDS's signing
/// secret. See `tests/common/mod.rs`.
async fn ready() -> Option<(reqwest::Client, sqlx::PgPool, TestAuth)> {
if !wait_for_appview_db().await {
eprintln!("appview not running, skipping");
return None;
}
let Some(pool) = db_pool().await else {
eprintln!("appview DB unreachable, skipping");
return None;
};
let c = client().await;
let auth = TestAuth::probe(&c, &appview_url()).await?;
Some((c, pool, auth))
}
/// `GET <url>` against a private endpoint, carrying the token for
/// `did` when the instance enforces auth.
fn authed_get(
c: &reqwest::Client,
auth: &TestAuth,
url: String,
did: &str,
) -> reqwest::RequestBuilder {
auth.apply(c.get(url), did)
}
/// `POST <url>` against a private endpoint. Same rule as
/// [`authed_get`] — `/api/notifications/seen` is a write into one
/// user's read state.
fn authed_post(
c: &reqwest::Client,
auth: &TestAuth,
url: String,
did: &str,
) -> reqwest::RequestBuilder {
auth.apply(c.post(url), did)
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
let base = appview_url();
c.post(format!("{base}/internal/ingest-commit"))
.json(&body)
.send()
.await
.unwrap()
}
fn did_for_test(name: &str) -> String {
format!("did:plc:ntf_{}_{}", name, uuid::Uuid::new_v4().simple())
}
fn rkey() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
/// Create one post through the ingest path and return its URI.
async fn seed_post(c: &reqwest::Client, did: &str, text: &str) -> String {
let rk = rkey();
let r = post_ingest(
c,
json!({
"did": did,
"handle": "ntf-fixture.test",
"collection": "app.twi.post",
"action": "create",
"rkey": rk,
"cid": "bafyreicid",
"record": { "text": text, "createdAt": "2026-07-01T12:00:00Z" }
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
format!("at://{did}/app.twi.post/{rk}")
}
/// Create a reply to `parent_uri` and return the reply's URI.
async fn seed_reply(
c: &reqwest::Client,
did: &str,
parent_uri: &str,
root_uri: &str,
text: &str,
) -> String {
let rk = rkey();
let r = post_ingest(
c,
json!({
"did": did,
"handle": "ntf-fixture.test",
"collection": "app.twi.post",
"action": "create",
"rkey": rk,
"cid": "bafyreicid",
"record": {
"text": text,
"createdAt": "2026-07-01T12:05:00Z",
"reply": {
"parent": { "uri": parent_uri, "cid": "bafyparent" },
"root": { "uri": root_uri, "cid": "bafyroot" }
}
}
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
format!("at://{did}/app.twi.post/{rk}")
}
async fn seed_like(c: &reqwest::Client, did: &str, post_uri: &str) {
let r = post_ingest(
c,
json!({
"did": did,
"collection": "app.bsky.feed.like",
"action": "create",
"rkey": rkey(),
"cid": "bafylike",
"record": {
"subject": { "uri": post_uri, "cid": "bafyreicid" },
"createdAt": "2026-07-01T12:01:00Z"
}
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
}
async fn seed_follow(c: &reqwest::Client, follower: &str, subject: &str) {
let r = post_ingest(
c,
json!({
"did": follower,
"collection": "app.bsky.graph.follow",
"action": "create",
"rkey": rkey(),
"subject_did": subject,
"record": { "subject": subject, "createdAt": "2026-01-01T00:00:00Z" }
}),
)
.await;
assert_eq!(r.status().as_u16(), 200);
}
// -- notifications ----------------------------------------------------------
/// A like, a repost-free reply and a follow from three different
/// people must show up as three hydrated notification rows, and the
/// unread count must agree with the list.
#[tokio::test]
async fn notifications_list_count_and_seen() {
let base = appview_url();
let Some((c, _pool, auth)) = ready().await else {
return;
};
let alice = did_for_test("alice");
let bob = did_for_test("bob");
let carol = did_for_test("carol");
// Alice posts; bob likes it and carol replies; bob also follows her.
let post_uri = seed_post(&c, &alice, "alice's original").await;
seed_post(&c, &bob, "bob exists").await;
seed_post(&c, &carol, "carol exists").await;
seed_like(&c, &bob, &post_uri).await;
let reply_uri = seed_reply(&c, &carol, &post_uri, &post_uri, "carol's reply").await;
seed_follow(&c, &bob, &alice).await;
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
.query(&[("did", alice.as_str()), ("limit", "50")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let items = body["notifications"].as_array().expect("notifications array");
assert_eq!(items.len(), 3, "expected like + reply + follow, got {items:?}");
// Newest first: `indexed_at` must be non-increasing down the list.
let mut prev: Option<String> = None;
for n in items {
let at = n["indexed_at"].as_str().unwrap().to_string();
if let Some(p) = prev.take() {
assert!(at <= p, "indexed_at must be non-increasing: {at} <= {p}");
}
prev = Some(at);
}
let by_kind = |k: &str| -> Value {
items
.iter()
.find(|n| n["kind"] == json!(k))
.unwrap_or_else(|| panic!("missing {k} notification in {items:?}"))
.clone()
};
// The like points at alice's own post and previews its text.
let like = by_kind("like");
assert_eq!(like["author_did"], json!(bob));
assert_eq!(like["subject_uri"], json!(post_uri));
assert_eq!(like["subject_text"], json!("alice's original"));
assert!(like["read_at"].is_null(), "new notifications start unread");
// The reply points at the REPLY (not the parent), so the preview
// shows what carol wrote.
let reply = by_kind("reply");
assert_eq!(reply["author_did"], json!(carol));
assert_eq!(reply["subject_uri"], json!(reply_uri));
assert_eq!(reply["subject_text"], json!("carol's reply"));
// A follow has no subject at all.
let follow = by_kind("follow");
assert_eq!(follow["author_did"], json!(bob));
assert!(follow["subject_uri"].is_null());
// `author_handle` is never empty and never carries a leading '@'
// (the UI renders `@{handle}` itself).
for n in items {
let h = n["author_handle"].as_str().expect("author_handle is a string");
assert!(!h.is_empty(), "author_handle must never be empty: {n:?}");
assert!(!h.starts_with('@'), "author_handle must not carry a sigil: {h}");
}
// The unread count agrees with the list.
let resp = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["count"], json!(3));
// Mark everything seen.
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
.json(&json!({ "did": alice }))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["ok"], json!(true));
assert_eq!(body["updated"], json!(3));
// Idempotent: a second call updates nothing and still succeeds.
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
.json(&json!({ "did": alice }))
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
assert_eq!(body["updated"], json!(0));
// Count is now zero and the rows carry a read_at.
let resp = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
assert_eq!(body["count"], json!(0));
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
for n in body["notifications"].as_array().unwrap() {
assert!(!n["read_at"].is_null(), "row should be read now: {n:?}");
}
// `did` is mandatory on all three.
for path in [
"/api/notifications",
"/api/notifications/count",
] {
let resp = authed_get(&c, &auth, format!("{base}{path}"), &alice)
.query(&[("did", "")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400, "{path} must reject an empty did");
}
}
/// Self-interactions produce nothing: alice liking and replying to her
/// own post leaves her notification list empty.
#[tokio::test]
async fn notifications_skip_self_interactions() {
let base = appview_url();
let Some((c, _pool, auth)) = ready().await else {
return;
};
let alice = did_for_test("solo");
let post_uri = seed_post(&c, &alice, "talking to myself").await;
seed_like(&c, &alice, &post_uri).await;
seed_reply(&c, &alice, &post_uri, &post_uri, "and replying too").await;
seed_follow(&c, &alice, &alice).await;
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
assert_eq!(
body["notifications"].as_array().unwrap().len(),
0,
"self-interactions must not notify: {body:?}"
);
}
/// Cursor pagination over the notification list: pages must be
/// disjoint, exactly `limit` long while more remain, and the cursor
/// must go null at the end.
#[tokio::test]
async fn notifications_paginate_with_cursor() {
let base = appview_url();
let Some((c, _pool, auth)) = ready().await else {
return;
};
let alice = did_for_test("popular");
let post_uri = seed_post(&c, &alice, "the post everyone likes").await;
// 12 distinct likers → 12 notifications. (Distinct DIDs matter:
// the dedupe index is per (recipient, author, kind, subject).)
for i in 0..12 {
let liker = did_for_test(&format!("fan{i}"));
seed_like(&c, &liker, &post_uri).await;
}
let page = |cursor: Option<String>| {
let c = c.clone();
let alice = alice.clone();
let base = base.clone();
let auth = auth.clone();
async move {
let mut req = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
.query(&[("did", alice.as_str()), ("limit", "5")]);
if let Some(cur) = cursor {
req = req.query(&[("cursor", cur.as_str())]);
}
let resp = req.send().await.unwrap();
assert_eq!(resp.status().as_u16(), 200);
resp.json::<Value>().await.unwrap()
}
};
let p1 = page(None).await;
assert_eq!(p1["notifications"].as_array().unwrap().len(), 5);
let c1 = p1["cursor"].as_str().expect("page1 cursor").to_string();
let p2 = page(Some(c1)).await;
assert_eq!(p2["notifications"].as_array().unwrap().len(), 5);
let c2 = p2["cursor"].as_str().expect("page2 cursor").to_string();
let p3 = page(Some(c2)).await;
assert_eq!(p3["notifications"].as_array().unwrap().len(), 2);
assert!(
p3["cursor"].is_null(),
"cursor must be null on the last page: {p3:?}"
);
// No id may appear on two pages.
let ids = |p: &Value| -> Vec<i64> {
p["notifications"]
.as_array()
.unwrap()
.iter()
.map(|n| n["id"].as_i64().unwrap())
.collect()
};
let mut all: Vec<i64> = ids(&p1);
all.extend(ids(&p2));
all.extend(ids(&p3));
let unique: std::collections::HashSet<i64> = all.iter().copied().collect();
assert_eq!(unique.len(), all.len(), "pages overlap: {all:?}");
assert_eq!(all.len(), 12);
// A mangled cursor is a 400, not a silent restart at page 1.
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
.query(&[("did", alice.as_str()), ("cursor", "!!!garbage!!!")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400);
}
/// `seenAt` is a watermark: only rows indexed at or before it flip to
/// read. Accepted in both camelCase and snake_case.
#[tokio::test]
async fn notifications_seen_respects_watermark() {
let base = appview_url();
let Some((c, _pool, auth)) = ready().await else {
return;
};
let alice = did_for_test("watermark");
let post_uri = seed_post(&c, &alice, "watermark subject").await;
let first = did_for_test("early");
seed_like(&c, &first, &post_uri).await;
// Read back the first notification's indexed_at — that's the
// watermark a client would echo after rendering page 1.
let body: Value = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let watermark = body["notifications"][0]["indexed_at"]
.as_str()
.unwrap()
.to_string();
// A second interaction lands *after* the watermark.
tokio::time::sleep(Duration::from_millis(20)).await;
let second = did_for_test("late");
seed_like(&c, &second, &post_uri).await;
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
.json(&json!({ "did": alice, "seenAt": watermark }))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
assert_eq!(
body["updated"], json!(1),
"only the row at/before the watermark may flip to read"
);
// The later one is still unread.
let body: Value = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
.query(&[("did", alice.as_str())])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["count"], json!(1));
// snake_case spelling must work identically.
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
.json(&json!({ "did": alice, "seen_at": null }))
.send()
.await
.unwrap();
let body: Value = resp.json().await.unwrap();
assert_eq!(body["updated"], json!(1));
}
// -- follower / following lists ---------------------------------------------
#[tokio::test]
async fn followers_and_following_lists() {
let base = appview_url();
let Some((c, _pool, _auth)) = ready().await else {
return;
};
let hub = did_for_test("hub");
seed_post(&c, &hub, "hub post").await;
// Three people follow the hub; the hub follows one of them back.
let mut fans = Vec::new();
for i in 0..3 {
let fan = did_for_test(&format!("fan{i}"));
seed_post(&c, &fan, "fan post").await;
seed_follow(&c, &fan, &hub).await;
fans.push(fan);
}
seed_follow(&c, &hub, &fans[0]).await;
// Followers.
let body: Value = c
.get(format!("{base}/api/followers"))
.query(&[("did", hub.as_str()), ("limit", "50")])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let got: std::collections::HashSet<String> = body["profiles"]
.as_array()
.expect("profiles array")
.iter()
.map(|p| p["did"].as_str().unwrap().to_string())
.collect();
for fan in &fans {
assert!(got.contains(fan), "follower {fan} missing from {body:?}");
}
assert!(
!got.contains(&hub),
"the hub must not appear in its own follower list"
);
// Following — exactly one edge.
let body: Value = c
.get(format!("{base}/api/following"))
.query(&[("did", hub.as_str()), ("limit", "50")])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let following: Vec<String> = body["profiles"]
.as_array()
.unwrap()
.iter()
.map(|p| p["did"].as_str().unwrap().to_string())
.collect();
assert_eq!(following, vec![fans[0].clone()]);
// Never an empty or '@'-prefixed handle.
for p in body["profiles"].as_array().unwrap() {
let h = p["handle"].as_str().expect("handle is a string");
assert!(!h.is_empty());
assert!(!h.starts_with('@'));
}
// Pagination: limit=1 must page through all three followers
// without repeats.
let mut seen: Vec<String> = Vec::new();
let mut cursor: Option<String> = None;
for _ in 0..5 {
let mut req = c
.get(format!("{base}/api/followers"))
.query(&[("did", hub.as_str()), ("limit", "1")]);
if let Some(cur) = cursor.as_deref() {
req = req.query(&[("cursor", cur)]);
}
let body: Value = req.send().await.unwrap().json().await.unwrap();
for p in body["profiles"].as_array().unwrap() {
seen.push(p["did"].as_str().unwrap().to_string());
}
match body["cursor"].as_str() {
Some(c) => cursor = Some(c.to_string()),
None => break,
}
}
let unique: std::collections::HashSet<&String> = seen.iter().collect();
assert_eq!(unique.len(), seen.len(), "paged followers repeat: {seen:?}");
assert_eq!(seen.len(), 3, "paging lost a follower: {seen:?}");
// `did` is mandatory. These two stay public — a follow edge is a
// public record — so no token is involved.
for path in ["/api/followers", "/api/following"] {
let resp = c
.get(format!("{base}{path}"))
.query(&[("did", "")])
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400);
}
}
// -- thread -----------------------------------------------------------------
/// `/api/thread` returns the ancestor chain above a post and its
/// direct replies, in both the query-param and the path spelling.
#[tokio::test]
async fn thread_returns_parents_and_replies() {
let base = appview_url();
let Some((c, _pool, _auth)) = ready().await else {
return;
};
let a = did_for_test("root");
let b = did_for_test("mid");
let d = did_for_test("leaf");
let root = seed_post(&c, &a, "root post").await;
let mid = seed_reply(&c, &b, &root, &root, "middle reply").await;
let leaf = seed_reply(&c, &d, &mid, &root, "leaf reply").await;
for url in [
format!("{base}/api/thread?uri={}", urlencoding(&mid)),
format!("{base}/api/thread/{mid}"),
] {
let resp = c.get(&url).send().await.unwrap();
assert_eq!(resp.status().as_u16(), 200, "GET {url}");
let body: Value = resp.json().await.unwrap();
assert_eq!(body["post"]["uri"], json!(mid), "GET {url}");
// One ancestor, and it's the root.
let parents = body["parents"].as_array().unwrap();
assert_eq!(parents.len(), 1, "GET {url}: {parents:?}");
assert_eq!(parents[0]["uri"], json!(root));
assert_eq!(body["root"]["uri"], json!(root));
// One direct reply: the leaf.
let replies = body["replies"].as_array().unwrap();
assert_eq!(replies.len(), 1, "GET {url}: {replies:?}");
assert_eq!(replies[0]["uri"], json!(leaf));
assert_eq!(body["like_count"], json!(0));
}
// The root's thread has no parents and one reply (the middle).
let body: Value = c
.get(format!("{base}/api/thread/{root}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["parents"].as_array().unwrap().len(), 0);
assert!(body["root"].is_null(), "a top-level post has no root ref");
let replies = body["replies"].as_array().unwrap();
assert_eq!(replies.len(), 1);
assert_eq!(replies[0]["uri"], json!(mid));
// An unknown URI is a 200 with a null post, not a 404 — the UI
// renders "not in index" from one field check.
let body: Value = c
.get(format!(
"{base}/api/thread/at://did:plc:nobody/app.twi.post/{}",
rkey()
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(body["post"].is_null());
assert_eq!(body["parents"].as_array().unwrap().len(), 0);
assert_eq!(body["replies"].as_array().unwrap().len(), 0);
// A missing `uri` is a 400.
let resp = c
.get(format!("{base}/api/thread"))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 400);
}
/// `/api/post/{uri}` must keep its historical shape after the thread
/// refactor — the Tauri client reads `thread.parent` / `thread.root`
/// and has no `parents` / `replies` fields.
#[tokio::test]
async fn post_by_uri_stays_backwards_compatible() {
let base = appview_url();
let Some((c, _pool, _auth)) = ready().await else {
return;
};
let a = did_for_test("compat_a");
let b = did_for_test("compat_b");
let root = seed_post(&c, &a, "compat root").await;
let mid = seed_reply(&c, &b, &root, &root, "compat reply").await;
seed_like(&c, &a, &mid).await;
let body: Value = c
.get(format!("{base}/api/post/{mid}"))
.query(&[("viewer_did", a.as_str())])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["post"]["uri"], json!(mid));
// The legacy nested shape: immediate parent + root, both hydrated.
assert_eq!(body["thread"]["parent"]["uri"], json!(root));
assert_eq!(body["thread"]["root"]["uri"], json!(root));
assert_eq!(body["like_count"], json!(1));
assert_eq!(body["repost_count"], json!(0));
assert_eq!(body["viewer_liked"], json!(true));
assert_eq!(body["viewer_reposted"], json!(false));
// The endpoint must NOT have grown the thread route's fields.
assert!(body.get("replies").is_none(), "unexpected `replies`: {body:?}");
assert!(body.get("parents").is_none(), "unexpected `parents`: {body:?}");
// And the two endpoints must agree about the parent / root.
let thread: Value = c
.get(format!("{base}/api/thread/{mid}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(
thread["parents"].as_array().unwrap().last().unwrap()["uri"],
body["thread"]["parent"]["uri"],
"/api/thread and /api/post disagree about the parent"
);
assert_eq!(thread["root"]["uri"], body["thread"]["root"]["uri"]);
}
/// Minimal percent-encoder for the `?uri=` form. Only the characters
/// an `at://did:plc:…/app.twi.post/<rkey>` URI can contain that a query
/// string would otherwise eat.
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for ch in s.chars() {
match ch {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(ch),
other => {
let mut buf = [0u8; 4];
for b in other.encode_utf8(&mut buf).as_bytes() {
out.push_str(&format!("%{b:02X}"));
}
}
}
}
out
}
+108 -9
View File
@@ -1,7 +1,10 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use at_crypto::cid::{cid_for_raw, sha256};
use bytes::Bytes; use bytes::Bytes;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobInfo { pub struct BlobInfo {
@@ -19,20 +22,116 @@ pub trait BlobStore: Send + Sync {
async fn public_url(&self, key: &str) -> Result<String>; async fn public_url(&self, key: &str) -> Result<String>;
} }
pub struct InMemoryBlobStore; /// In-process blob store backed by a `HashMap` guarded by a
/// [`tokio::sync::RwLock`]. Used in tests and any dev/single-node
/// deployment that doesn't need a real object store.
///
/// Behaves like [`crate::s3::S3BlobStore`] with respect to `BlobInfo`
/// (same CID computation over the raw bytes, same field semantics) so
/// tests can swap one for the other transparently. Data does not
/// survive process restarts.
#[derive(Default)]
pub struct InMemoryBlobStore {
blobs: RwLock<HashMap<String, (Bytes, String /* mime */)>>,
}
impl InMemoryBlobStore {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait] #[async_trait]
impl BlobStore for InMemoryBlobStore { impl BlobStore for InMemoryBlobStore {
async fn put(&self, _key: &str, _data: Bytes, _mime: &str) -> Result<BlobInfo> { async fn put(&self, key: &str, data: Bytes, mime: &str) -> Result<BlobInfo> {
unimplemented!("in-memory blob store placeholder") let hash = sha256(&data);
let cid = cid_for_raw(0x55, hash)?;
let size = data.len() as u64;
self.blobs
.write()
.await
.insert(key.to_string(), (data, mime.to_string()));
Ok(BlobInfo {
cid: cid.to_string(),
mime_type: mime.to_string(),
size,
storage_key: key.to_string(),
})
} }
async fn get(&self, _key: &str) -> Result<Option<Bytes>> {
unimplemented!() async fn get(&self, key: &str) -> Result<Option<Bytes>> {
Ok(self
.blobs
.read()
.await
.get(key)
.map(|(data, _mime)| data.clone()))
} }
async fn delete(&self, _key: &str) -> Result<()> {
unimplemented!() async fn delete(&self, key: &str) -> Result<()> {
self.blobs.write().await.remove(key);
Ok(())
} }
async fn public_url(&self, _key: &str) -> Result<String> {
unimplemented!() async fn public_url(&self, key: &str) -> Result<String> {
Ok(format!("/blob/{key}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn put_get_roundtrip() {
let store = InMemoryBlobStore::new();
let data = Bytes::from_static(b"hello world");
let info = store.put("k1", data.clone(), "text/plain").await.unwrap();
assert_eq!(info.storage_key, "k1");
assert_eq!(info.mime_type, "text/plain");
assert_eq!(info.size, data.len() as u64);
let got = store.get("k1").await.unwrap();
assert_eq!(got, Some(data));
}
#[tokio::test]
async fn get_missing_key_returns_none() {
let store = InMemoryBlobStore::new();
let got = store.get("does-not-exist").await.unwrap();
assert_eq!(got, None);
}
#[tokio::test]
async fn delete_then_get_returns_none() {
let store = InMemoryBlobStore::new();
store
.put("k2", Bytes::from_static(b"data"), "application/octet-stream")
.await
.unwrap();
store.delete("k2").await.unwrap();
let got = store.get("k2").await.unwrap();
assert_eq!(got, None);
}
#[tokio::test]
async fn delete_missing_key_is_ok() {
let store = InMemoryBlobStore::new();
store.delete("never-existed").await.unwrap();
}
#[tokio::test]
async fn same_bytes_produce_same_cid() {
let store = InMemoryBlobStore::new();
let data = Bytes::from_static(b"identical payload");
let info_a = store
.put("key-a", data.clone(), "application/octet-stream")
.await
.unwrap();
let info_b = store
.put("key-b", data.clone(), "application/octet-stream")
.await
.unwrap();
assert_eq!(info_a.cid, info_b.cid);
} }
} }
+209
View File
@@ -7,6 +7,17 @@ fn default_handle_sync_interval() -> u64 {
300 300
} }
/// Default for `APPVIEW_AUTH_REQUIRED`.
///
/// `true` — the AppView's private endpoints (notifications, home
/// timeline) reject unauthenticated requests. Fail closed: an operator
/// who forgets the variable gets the safe behaviour, and the only way
/// to serve another user's notifications to an anonymous caller is to
/// opt out explicitly.
fn default_auth_required() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
pub pds_host: String, pub pds_host: String,
@@ -46,6 +57,27 @@ pub struct AppConfig {
/// directory. Default: 300s (5 minutes). /// directory. Default: 300s (5 minutes).
#[serde(default = "default_handle_sync_interval")] #[serde(default = "default_handle_sync_interval")]
pub appview_handle_sync_interval_secs: u64, pub appview_handle_sync_interval_secs: u64,
/// Whether the AppView enforces bearer-token auth on the endpoints
/// that serve a single user's private data (`/api/notifications*`,
/// `/api/timeline/home`). Default `true`.
///
/// Set `APPVIEW_AUTH_REQUIRED=false` to get the pre-auth behaviour
/// (every endpoint public). That mode exists for two callers:
/// the fail-open integration suites, which seed synthetic DIDs the
/// PDS has never issued a token for, and an instance that is
/// already isolated at the network layer (VPN / private subnet).
/// The AppView warns loudly at startup when it is off.
#[serde(default = "default_auth_required")]
pub appview_auth_required: bool,
/// Browser origins allowed to call the AppView's `/api/*` routes,
/// from the comma-separated `APPVIEW_CORS_ORIGINS`. Empty means
/// "no allowlist configured" — the AppView then keeps the historic
/// `Access-Control-Allow-Origin: *` behaviour and warns at startup.
///
/// Example (Tauri webview origins differ per platform):
/// `APPVIEW_CORS_ORIGINS=tauri://localhost,http://127.0.0.1:1430`
#[serde(default)]
pub appview_cors_origins: Vec<String>,
} }
impl AppConfig { impl AppConfig {
@@ -82,6 +114,183 @@ impl AppConfig {
.ok() .ok()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or_else(default_handle_sync_interval), .unwrap_or_else(default_handle_sync_interval),
appview_auth_required: std::env::var("APPVIEW_AUTH_REQUIRED")
.ok()
.map(|s| parse_bool_env(&s))
.unwrap_or_else(default_auth_required),
appview_cors_origins: std::env::var("APPVIEW_CORS_ORIGINS")
.ok()
.map(|s| parse_csv_env(&s))
.unwrap_or_default(),
}) })
} }
/// The `did:web:` DID of *this* PDS, derived from `PDS_PUBLIC_URL`.
///
/// One derivation, two consumers: `com.atproto.server.describeServer`
/// (which used to return a hardcoded `did:web:pds.maarcadetweet.local`
/// no matter what the operator configured) and
/// `GET /.well-known/did.json`, which publishes the server's signing
/// key under exactly this id. If those two ever disagreed, a client
/// that trusts `describeServer` would fetch the key document of a
/// different identity.
pub fn pds_did(&self) -> String {
did_web_from_url(&self.pds_public_url)
}
/// Base URL the AppView uses to reach the PDS.
///
/// `PDS_INTERNAL_URL` when set (the cluster-internal hostname),
/// otherwise `PDS_PUBLIC_URL`. Both the handle-sync resolver and the
/// signing-key fetch go through here, so the two can't end up
/// talking to different PDS instances.
pub fn pds_base_url(&self) -> String {
self.pds_internal_url
.clone()
.unwrap_or_else(|| self.pds_public_url.clone())
}
}
/// Interpret an environment variable as a boolean.
///
/// Accepts the spellings people actually type in a `.env` file. Anything
/// unrecognised counts as `false` for an explicitly-set variable — the
/// caller decides what an *absent* variable means (see
/// [`default_auth_required`]), and a typo like `APPVIEW_AUTH_REQUIRED=ture`
/// must never silently read as "on" when the operator's intent was to
/// switch something off... nor as "off" for a security switch. Since
/// this is only reached when the variable *is* set, and the only
/// security-relevant user of it defaults to `true` when unset, we treat
/// unknown values as `false` and rely on the startup warning to make a
/// disabled auth switch impossible to miss in the logs.
fn parse_bool_env(raw: &str) -> bool {
matches!(
raw.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
/// Split a comma-separated environment variable into trimmed,
/// non-empty entries. `"a, b,,c "` → `["a", "b", "c"]`.
fn parse_csv_env(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Turn an `http(s)://host[:port][/path]` URL into a `did:web:` DID.
///
/// The did:web method spec maps the authority to the method-specific
/// id, with two wrinkles that matter here:
///
/// - a port is **percent-encoded** (`:` → `%3A`), because a bare colon
/// already separates the DID's own segments. `http://127.0.0.1:2583`
/// is therefore `did:web:127.0.0.1%3A2583`, *not*
/// `did:web:127.0.0.1:2583` (which would parse as host `127.0.0.1`
/// plus a path segment `2583`).
/// - path segments, if any, are appended separated by `:`.
///
/// The default ports (80/443) are kept rather than stripped: the
/// resolution rule is a textual one, and a client that reverses this
/// mapping has to end up at the same URL we serve the document from.
pub fn did_web_from_url(url: &str) -> String {
// Strip the scheme. We accept a bare `host:port` too, which is what
// a misconfigured `PDS_PUBLIC_URL` often contains.
let rest = url
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/');
// Drop any userinfo (`user@host`) and query/fragment — neither has
// a place in a did:web identifier.
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
let rest = rest.rsplit('@').next().unwrap_or(rest);
let mut parts = rest.split('/');
let authority = parts.next().unwrap_or("");
let host = authority.replacen(':', "%3A", 1);
let mut did = format!("did:web:{host}");
for segment in parts.filter(|s| !s.is_empty()) {
did.push(':');
did.push_str(segment);
}
did
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn did_web_encodes_port_as_percent_3a() {
// The dev default. A literal colon here would be read as a
// did:web path segment, so it has to be percent-encoded.
assert_eq!(
did_web_from_url("http://127.0.0.1:2583"),
"did:web:127.0.0.1%3A2583"
);
assert_eq!(
did_web_from_url("https://pds.example.com:8443"),
"did:web:pds.example.com%3A8443"
);
}
#[test]
fn did_web_without_port_is_plain_host() {
assert_eq!(
did_web_from_url("https://pds.maarcadetweet.local"),
"did:web:pds.maarcadetweet.local"
);
// Trailing slash must not produce an empty path segment.
assert_eq!(
did_web_from_url("https://pds.example.com/"),
"did:web:pds.example.com"
);
// Scheme-less input is tolerated.
assert_eq!(did_web_from_url("pds.example.com"), "did:web:pds.example.com");
}
#[test]
fn did_web_appends_path_segments_with_colons() {
assert_eq!(
did_web_from_url("https://example.com/user/alice"),
"did:web:example.com:user:alice"
);
// Port + path together: only the port gets percent-encoded.
assert_eq!(
did_web_from_url("http://example.com:2583/pds"),
"did:web:example.com%3A2583:pds"
);
}
#[test]
fn did_web_ignores_userinfo_query_and_fragment() {
assert_eq!(
did_web_from_url("https://user@example.com?x=1#frag"),
"did:web:example.com"
);
}
#[test]
fn bool_env_accepts_common_spellings() {
for on in ["1", "true", "TRUE", " yes ", "on"] {
assert!(parse_bool_env(on), "{on} should parse as true");
}
for off in ["0", "false", "no", "off", "", "nonsense"] {
assert!(!parse_bool_env(off), "{off} should parse as false");
}
}
#[test]
fn csv_env_trims_and_drops_empties() {
assert_eq!(
parse_csv_env("tauri://localhost, http://127.0.0.1:1430 ,,"),
vec![
"tauri://localhost".to_string(),
"http://127.0.0.1:1430".to_string()
]
);
assert!(parse_csv_env(" ").is_empty());
}
} }
+1
View File
@@ -15,6 +15,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
tokio = { workspace = true } tokio = { workspace = true }
dotenvy = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
tower = { workspace = true } tower = { workspace = true }
tower-http = { workspace = true } tower-http = { workspace = true }
+7 -1
View File
@@ -39,7 +39,13 @@ pub fn issue_access_jwt(
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
let exp = now + 3600; let exp = now + 3600;
let claims = JwtClaims { let claims = JwtClaims {
iss: format!("did:web:{}", cfg.pds_public_url.trim_start_matches("http://").trim_start_matches("https://")), // Same derivation as `describeServer` and `/.well-known/did.json`
// (`AppConfig::pds_did`), so a verifier can take `iss`, resolve
// the did:web document and arrive at the key this token is
// signed with. The previous inline version dropped the
// percent-encoding of the port, producing an `iss` that no
// did:web resolver could follow.
iss: cfg.pds_did(),
sub: did.to_string(), sub: did.to_string(),
aud: "did:web:appview.maarcadetweet.local".into(), aud: "did:web:appview.maarcadetweet.local".into(),
iat: now, iat: now,
+68 -1
View File
@@ -17,6 +17,12 @@ use tracing_subscriber::EnvFilter;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// Load `.env` from the working directory (and upwards) if present.
// Nothing else in the process reads it, so without this
// `cp .env.example .env && cargo run` fails with `missing env:
// PDS_HOST`. Real environment variables always win over the file.
let _ = dotenvy::dotenv();
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init(); .init();
@@ -67,6 +73,7 @@ pub fn router(state: AppState) -> Router {
Router::new() Router::new()
.route("/", get(root)) .route("/", get(root))
.route("/healthz", get(healthz)) .route("/healthz", get(healthz))
.route("/.well-known/did.json", get(did_document))
.route( .route(
"/xrpc/com.atproto.server.describeServer", "/xrpc/com.atproto.server.describeServer",
get(describe_server), get(describe_server),
@@ -157,9 +164,69 @@ async fn healthz() -> Json<serde_json::Value> {
Json(json!({ "ok": true })) Json(json!({ "ok": true }))
} }
/// `GET /.well-known/did.json` — the PDS's own DID document.
///
/// This is how the AppView (and any other relying party) learns the
/// P-256 public key that the access JWTs in
/// `Authorization: Bearer …` are signed with. Without it the AppView
/// could not verify a token at all, and the only alternative would be
/// shipping `PDS_JWT_SECRET` to a second service — a private signing
/// key crossing a service boundary, for a check that needs nothing but
/// the public half.
///
/// Nothing in this response is secret. `publicKeyMultibase` is the
/// uncompressed P-256 point derived from `PDS_JWT_SECRET` by
/// [`jwt_issuer::server_p256_public_multibase`]; the secret itself
/// never leaves this process.
///
/// The document id is [`AppConfig::pds_did`], i.e. it follows
/// `PDS_PUBLIC_URL` — so a `did:web:` resolver that starts from the DID,
/// rebuilds the URL and fetches this path lands back here rather than at
/// some other host's document.
async fn did_document(State(state): State<AppState>) -> Result<Json<serde_json::Value>, (axum::http::StatusCode, Json<serde_json::Value>)> {
let did = state.cfg.pds_did();
let public_multibase = jwt_issuer::server_p256_public_multibase(&state.cfg).map_err(|e| {
// A malformed `PDS_JWT_SECRET` is the one way this fails, and
// it is exactly the failure that also breaks every token this
// server issues — surface it instead of publishing a document
// with a missing key.
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "InternalServerError",
"message": format!("server key unavailable: {e}"),
})),
)
})?;
Ok(Json(json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
],
"id": did,
"verificationMethod": [{
// `#atproto` is the fragment AT Proto uses for a repo's
// signing key; we reuse it for the server key so a generic
// did:web consumer finds it in the usual place.
"id": format!("{did}#atproto"),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": public_multibase,
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": state.cfg.pds_public_url,
}],
})))
}
async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerResp> { async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerResp> {
Json(DescribeServerResp { Json(DescribeServerResp {
did: "did:web:pds.maarcadetweet.local".into(), // Derived from `PDS_PUBLIC_URL`, never hardcoded — see
// `AppConfig::pds_did`. The same value ids the document at
// `/.well-known/did.json`.
did: state.cfg.pds_did(),
available_user_domains: vec![state available_user_domains: vec![state
.cfg .cfg
.pds_handle_dns_zone .pds_handle_dns_zone
+101 -3
View File
@@ -38,11 +38,86 @@ async fn describe_server() {
.json() .json()
.await .await
.unwrap(); .unwrap();
assert!(r["did"].is_string()); // The DID is derived from `PDS_PUBLIC_URL`, not hardcoded — so we
// assert the *shape* (any deployment must produce a did:web) and
// leave the exact value to `at_shared::config`'s unit tests.
let did = r["did"].as_str().expect("describeServer must return a did");
assert!(did.starts_with("did:web:"), "did = {did}");
assert!(r["available_user_domains"].is_array()); assert!(r["available_user_domains"].is_array());
assert_eq!(r["invite_code_required"], json!(false)); assert_eq!(r["invite_code_required"], json!(false));
} }
/// `GET /.well-known/did.json` — the document the AppView fetches to
/// learn the key our access tokens are signed with.
///
/// Two properties matter beyond "it returns JSON": the document's `id`
/// must be the same DID `describeServer` advertises (otherwise a client
/// that trusts one and resolves the other ends up at a different
/// identity), and it must carry a usable `publicKeyMultibase`.
#[tokio::test]
async fn did_document_publishes_the_server_key() {
if !wait_for_pds().await {
eprintln!("pds not running, skipping");
return;
}
let c = client().await;
let doc: Value = c
.get(format!("{}/.well-known/did.json", PDS_URL))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let id = doc["id"].as_str().expect("did document needs an id");
assert!(id.starts_with("did:web:"), "id = {id}");
let described: Value = c
.get(format!("{}/xrpc/com.atproto.server.describeServer", PDS_URL))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(
described["did"].as_str().unwrap(),
id,
"describeServer and the did document must name the same identity"
);
let vm = &doc["verificationMethod"][0];
assert_eq!(vm["type"], json!("Multikey"));
assert_eq!(vm["controller"], json!(id));
assert_eq!(vm["id"], json!(format!("{id}#atproto")));
let key = vm["publicKeyMultibase"]
.as_str()
.expect("verificationMethod needs publicKeyMultibase");
// base58-btc multibase — the `z` prefix the AppView's decoder wants.
assert!(key.starts_with('z'), "key = {key}");
// And it really is the key our tokens verify against: mint a
// session and check the access JWT against the published key.
let handle = format!("didjson_{}.maarcadetweet.local", uuid::Uuid::new_v4().simple());
let acc: Value = c
.post(format!("{}/xrpc/com.atproto.server.createAccount", PDS_URL))
.json(&json!({"handle": handle, "password": "hunter2hunter2"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let jwt = acc["access_jwt"].as_str().expect("access_jwt");
let claims = at_crypto::jwt::verify_jwt(jwt, key)
.expect("access token must verify against the published key");
assert_eq!(claims.sub, acc["did"].as_str().unwrap());
assert_eq!(claims.scope.as_deref(), Some("com.atproto.access"));
// `iss` is the same did:web the document identifies.
assert_eq!(claims.iss, id);
}
#[tokio::test] #[tokio::test]
async fn create_account_session_refresh_resolve() { async fn create_account_session_refresh_resolve() {
if !wait_for_pds().await { if !wait_for_pds().await {
@@ -758,8 +833,14 @@ async fn sync_list_repos_includes_recent_user() {
return; return;
} }
let (c, did, _jwt, _cids) = fresh_user_with_records().await; let (c, did, _jwt, _cids) = fresh_user_with_records().await;
// Page through listRepos with a small limit until we see our DID. // Page through listRepos until we see our DID. Start the cursor
let mut cursor: Option<String> = None; // immediately *below* the target rather than at the beginning of
// the table: `repos` grows without bound on a long-lived dev
// instance (a few thousand rows already), and scanning from the
// top made this test fail purely because the DID sorted past the
// iteration cap. Anchoring at the DID keeps the cursor round trip
// under test while staying independent of table size.
let mut cursor: Option<String> = Some(did_cursor_just_before(&did));
let mut found = false; let mut found = false;
for _ in 0..50 { for _ in 0..50 {
let url = match &cursor { let url = match &cursor {
@@ -1022,6 +1103,23 @@ fn urlencode(s: &str) -> String {
.collect() .collect()
} }
/// The immediate keyset predecessor of `did`: the same string with
/// its last byte decremented. `listRepos` filters with `did > cursor`,
/// so paging from here puts `did` on the first page regardless of how
/// many repos precede it in the table. Unlike [`did_cursor_lt`] — which
/// decrements the *first* byte and therefore lands before every
/// `did:...` — this stays adjacent to the target.
///
/// DIDs are ASCII (`did:plc:` + base32), so byte surgery is safe here.
fn did_cursor_just_before(did: &str) -> String {
let mut bytes = did.as_bytes().to_vec();
match bytes.last_mut() {
Some(b) if *b > 0 => *b -= 1,
_ => return did.to_string(),
}
String::from_utf8(bytes).unwrap_or_else(|_| did.to_string())
}
fn did_cursor_lt(did: &str) -> String { fn did_cursor_lt(did: &str) -> String {
let bytes = did.as_bytes(); let bytes = did.as_bytes();
let mut prefix = Vec::with_capacity(bytes.len()); let mut prefix = Vec::with_capacity(bytes.len());
+579 -40
View File
@@ -1,8 +1,30 @@
//! Thin HTTP client the Tauri commands use to talk to the AppView. //! Thin HTTP client the Tauri commands use to talk to the AppView.
//! //!
//! All four methods return parsed JSON or a stringified error that the //! Every method returns parsed JSON or a stringified error that the
//! Tauri command layer surfaces to the Svelte frontend as the //! Tauri command layer surfaces to the Svelte frontend as the
//! `Result::Err` payload. //! `Result::Err` payload.
//!
//! # Authentication
//!
//! The AppView's *viewer-scoped* endpoints require the account's access
//! JWT in an `Authorization: Bearer` header, and additionally check that
//! the token's `sub` equals the `did` query parameter:
//!
//! * `GET /api/timeline/home`
//! * `GET /api/notifications`
//! * `GET /api/notifications/count`
//! * `POST /api/notifications/seen`
//!
//! Those four methods therefore take an `access_jwt` argument (last, the
//! same position `pds_client.rs` uses). Everything else —
//! `/api/profile*`, `/api/search`, `/api/post/{uri}`, `/api/thread`,
//! `/api/followers`, `/api/following` — stays public and deliberately
//! sends no token, so the read-only views keep working while logged out.
//!
//! On an auth failure the AppView answers `401` with
//! `{"error":"AuthMissing"|"TokenInvalid","message":…}` or `403` with
//! `{"error":"Forbidden",…}`. See [`status_error`] for why the response
//! body must survive into the error message.
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use reqwest::Client; use reqwest::Client;
@@ -58,9 +80,10 @@ pub struct SearchResponse {
/// ///
/// `like_count` and `repost_count` are included when the server /// `like_count` and `repost_count` are included when the server
/// resolves a real post; they're `None` for the "not in index" /// resolves a real post; they're `None` for the "not in index"
/// sentinel response (where `post` is null). The AppView has no /// sentinel response (where `post` is null). `/api/post/{uri}` is a
/// auth yet, so we don't get `viewer_liked` / `viewer_reposted` /// public endpoint that takes no token, so there is no viewer to
/// from the server. /// resolve against and we don't get `viewer_liked` /
/// `viewer_reposted`; [`Self::fetch_thread`] with a `viewer_did` does.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadResponse { pub struct ThreadResponse {
pub post: Option<PostDto>, pub post: Option<PostDto>,
@@ -77,6 +100,104 @@ pub struct ThreadView {
pub root: Option<PostDto>, pub root: Option<PostDto>,
} }
/// `GET /api/thread?uri=…` response — the *full* thread shape (the
/// whole ancestor chain plus the direct replies), as opposed to the
/// narrower `{ post, thread: { parent, root } }` of
/// [`ThreadResponse`]. Both come from the same server-side thread
/// walk, so they can never disagree about who the parent is.
///
/// `parents` is ordered root-first, `replies` oldest-first. The
/// `viewer_*` flags are only populated when a `viewer_did` was passed;
/// `None` means "unknown", not "false".
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadFullResponse {
pub post: Option<PostDto>,
#[serde(default)]
pub parents: Vec<PostDto>,
#[serde(default)]
pub root: Option<PostDto>,
#[serde(default)]
pub replies: Vec<PostDto>,
#[serde(default)]
pub like_count: Option<i64>,
#[serde(default)]
pub repost_count: Option<i64>,
#[serde(default)]
pub viewer_liked: Option<bool>,
#[serde(default)]
pub viewer_reposted: Option<bool>,
}
/// One hydrated row of `GET /api/notifications`.
///
/// `kind` is `"like" | "repost" | "follow" | "reply"` — kept as a
/// `String` rather than an enum so an unknown kind added server-side
/// deserialises instead of failing the whole page; the frontend has
/// the same fallback.
///
/// `read_at` is `None` while the notification is unread — that's the
/// state the tray badge keys off.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationDto {
pub id: i64,
pub kind: String,
pub author_did: String,
pub author_handle: String,
#[serde(default)]
pub author_display_name: Option<String>,
#[serde(default)]
pub author_avatar_cid: Option<String>,
/// `null` for `"follow"`; the recipient's own post for
/// `"like"`/`"repost"`; the reply itself for `"reply"`.
#[serde(default)]
pub subject_uri: Option<String>,
#[serde(default)]
pub subject_text: Option<String>,
pub created_at: String,
/// The list's sort key, and the value the client echoes back as
/// `seen_at` when marking the page read.
pub indexed_at: String,
#[serde(default)]
pub read_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationsResponse {
pub notifications: Vec<NotificationDto>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationCountResponse {
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSeenResponse {
pub ok: bool,
pub updated: i64,
}
/// A minimal profile card as returned by `GET /api/followers` and
/// `GET /api/following`. Deliberately not [`ProfileResponse`] — a page
/// of 30 followers would otherwise be 30 post queries server-side.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActorProfileDto {
pub did: String,
/// Without a leading `@` (the UI renders `@{handle}`).
pub handle: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub avatar_cid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActorListResponse {
pub profiles: Vec<ActorProfileDto>,
pub cursor: Option<String>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct AppViewClient { pub struct AppViewClient {
pub base_url: String, pub base_url: String,
@@ -94,16 +215,22 @@ impl AppViewClient {
} }
} }
/// `GET /api/timeline/home?did=&limit=&cursor=` /// `GET /api/timeline/home?did=&limit=&cursor=` — **authenticated**.
///
/// `access_jwt` goes out as `Authorization: Bearer`; the AppView
/// rejects the call with 401 without it and with 403 when the
/// token's `sub` doesn't match `did`.
pub async fn fetch_timeline( pub async fn fetch_timeline(
&self, &self,
did: &str, did: &str,
cursor: Option<&str>, cursor: Option<&str>,
limit: u32, limit: u32,
access_jwt: &str,
) -> Result<TimelineResponse> { ) -> Result<TimelineResponse> {
let mut req = self let mut req = self
.client .client
.get(format!("{}/api/timeline/home", self.base_url)) .get(format!("{}/api/timeline/home", self.base_url))
.bearer_auth(access_jwt)
.query(&[("did", did), ("limit", &limit.to_string())]); .query(&[("did", did), ("limit", &limit.to_string())]);
if let Some(c) = cursor { if let Some(c) = cursor {
req = req.query(&[("cursor", c)]); req = req.query(&[("cursor", c)]);
@@ -113,13 +240,7 @@ impl AppViewClient {
.await .await
.context("appview: failed to send timeline request")?; .context("appview: failed to send timeline request")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); return Err(status_error("timeline home", resp).await);
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: timeline home returned {}: {}",
status,
body
));
} }
resp resp
.json::<TimelineResponse>() .json::<TimelineResponse>()
@@ -146,13 +267,7 @@ impl AppViewClient {
.await .await
.context("appview: failed to send profile request")?; .context("appview: failed to send profile request")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); return Err(status_error("profile", resp).await);
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: profile returned {}: {}",
status,
body
));
} }
resp resp
.json::<ProfileResponse>() .json::<ProfileResponse>()
@@ -170,13 +285,7 @@ impl AppViewClient {
.await .await
.context("appview: failed to send profile-by-did request")?; .context("appview: failed to send profile-by-did request")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); return Err(status_error("profile-by-did", resp).await);
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: profile-by-did returned {}: {}",
status,
body
));
} }
resp resp
.json::<ProfileResponse>() .json::<ProfileResponse>()
@@ -194,13 +303,7 @@ impl AppViewClient {
.await .await
.context("appview: failed to send search request")?; .context("appview: failed to send search request")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); return Err(status_error("search", resp).await);
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: search returned {}: {}",
status,
body
));
} }
resp resp
.json::<SearchResponse>() .json::<SearchResponse>()
@@ -225,19 +328,215 @@ impl AppViewClient {
.await .await
.context("appview: failed to send post request")?; .context("appview: failed to send post request")?;
if !resp.status().is_success() { if !resp.status().is_success() {
let status = resp.status(); return Err(status_error("post", resp).await);
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: post returned {}: {}",
status,
body
));
} }
resp resp
.json::<ThreadResponse>() .json::<ThreadResponse>()
.await .await
.context("appview: post JSON parse") .context("appview: post JSON parse")
} }
/// `GET /api/thread?uri=…&viewer_did=…` — the full thread: the
/// whole ancestor chain (root first) plus the direct replies
/// (oldest first).
///
/// We use the query-param spelling rather than
/// `/api/thread/<uri>` because the `at://` URI survives a
/// query-string round trip without the manual percent-encoding
/// [`Self::fetch_post`] needs — `reqwest`'s `.query()` does the
/// escaping itself.
pub async fn fetch_thread(
&self,
uri: &str,
viewer_did: Option<&str>,
) -> Result<ThreadFullResponse> {
let mut req = self
.client
.get(format!("{}/api/thread", self.base_url))
.query(&[("uri", uri)]);
if let Some(v) = viewer_did {
req = req.query(&[("viewer_did", v)]);
}
let resp = req
.send()
.await
.context("appview: failed to send thread request")?;
if !resp.status().is_success() {
return Err(status_error("thread", resp).await);
}
resp
.json::<ThreadFullResponse>()
.await
.context("appview: thread JSON parse")
}
/// `GET /api/notifications?did=&limit=&cursor=` — **authenticated**;
/// newest first, same opaque-cursor pagination contract as the
/// timeline.
pub async fn fetch_notifications(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
access_jwt: &str,
) -> Result<NotificationsResponse> {
let mut req = self
.client
.get(format!("{}/api/notifications", self.base_url))
.bearer_auth(access_jwt)
.query(&[("did", did), ("limit", &limit.to_string())]);
if let Some(c) = cursor {
req = req.query(&[("cursor", c)]);
}
let resp = req
.send()
.await
.context("appview: failed to send notifications request")?;
if !resp.status().is_success() {
return Err(status_error("notifications", resp).await);
}
resp
.json::<NotificationsResponse>()
.await
.context("appview: notifications JSON parse")
}
/// `GET /api/notifications/count?did=` — **authenticated**; unread
/// count for the NavRail badge. Cheap enough to poll (partial index
/// on the server side).
pub async fn notification_count(
&self,
did: &str,
access_jwt: &str,
) -> Result<NotificationCountResponse> {
let resp = self
.client
.get(format!("{}/api/notifications/count", self.base_url))
.bearer_auth(access_jwt)
.query(&[("did", did)])
.send()
.await
.context("appview: failed to send notification-count request")?;
if !resp.status().is_success() {
return Err(status_error("notification count", resp).await);
}
resp
.json::<NotificationCountResponse>()
.await
.context("appview: notification count JSON parse")
}
/// `POST /api/notifications/seen` — **authenticated**; mark
/// everything indexed at or before `seen_at` as read. Passing `None`
/// marks *all* currently unread rows. Idempotent; a second call
/// reports `updated: 0`.
///
/// The server accepts both `seenAt` and `seen_at`; we send the
/// camelCase spelling because that's what the wire contract
/// documents.
pub async fn mark_notifications_seen(
&self,
did: &str,
seen_at: Option<&str>,
access_jwt: &str,
) -> Result<NotificationSeenResponse> {
let mut body = serde_json::json!({ "did": did });
if let Some(ts) = seen_at {
body["seenAt"] = Value::String(ts.to_string());
}
let resp = self
.client
.post(format!("{}/api/notifications/seen", self.base_url))
.bearer_auth(access_jwt)
.json(&body)
.send()
.await
.context("appview: failed to send notifications-seen request")?;
if !resp.status().is_success() {
return Err(status_error("notifications seen", resp).await);
}
resp
.json::<NotificationSeenResponse>()
.await
.context("appview: notifications seen JSON parse")
}
/// `GET /api/followers?did=&limit=&cursor=`
pub async fn fetch_followers(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ActorListResponse> {
self.actor_list("followers", did, cursor, limit).await
}
/// `GET /api/following?did=&limit=&cursor=`
pub async fn fetch_following(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ActorListResponse> {
self.actor_list("following", did, cursor, limit).await
}
/// Shared body of [`Self::fetch_followers`] and
/// [`Self::fetch_following`] — the two endpoints have an identical
/// request and response shape and differ only in the path segment.
async fn actor_list(
&self,
path: &str,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ActorListResponse> {
let mut req = self
.client
.get(format!("{}/api/{}", self.base_url, path))
.query(&[("did", did), ("limit", &limit.to_string())]);
if let Some(c) = cursor {
req = req.query(&[("cursor", c)]);
}
let resp = req
.send()
.await
.with_context(|| format!("appview: failed to send {path} request"))?;
if !resp.status().is_success() {
return Err(status_error(path, resp).await);
}
resp
.json::<ActorListResponse>()
.await
.with_context(|| format!("appview: {path} JSON parse"))
}
}
/// Turn a non-2xx AppView response into an `anyhow::Error` whose
/// message carries the server's response body **verbatim**.
///
/// Keeping the body is load-bearing, not cosmetic. The AppView answers
/// an expired or malformed access token with
/// `401 {"error":"TokenInvalid","message":…}`, and the frontend's
/// `safeInvoke` (`src/lib/api/client.ts`) decides whether to refresh the
/// session and retry by sniffing the *stringified* Rust error for the
/// literal substring `"TokenInvalid"` (or `"ExpiredSignature"`). The
/// chain is therefore:
///
/// ```text
/// AppView 401 body ──► status_error() ──► anyhow msg
/// ──► lib.rs `.map_err(|e| e.to_string())` ──► Tauri IPC reject
/// ──► safeInvoke's isTokenInvalid() ──► auth_refresh + retry once
/// ```
///
/// Every link is a plain string, so swallowing the body here (e.g.
/// formatting only the status code) silently breaks token renewal —
/// the user's timeline just stops updating an hour after login. The
/// unit tests below pin the substring so that can't regress.
async fn status_error(label: &str, resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow!("appview: {} returned {}: {}", label, status, body)
} }
/// Percent-encode every byte of `s` for use as a URL path segment. /// Percent-encode every byte of `s` for use as a URL path segment.
@@ -265,6 +564,9 @@ fn percent_encode_path(s: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[test] #[test]
fn percent_encode_path_at_uri() { fn percent_encode_path_at_uri() {
@@ -275,4 +577,241 @@ mod tests {
"at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.twi.post%2F3k2" "at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.twi.post%2F3k2"
); );
} }
// -- mock AppView -------------------------------------------------
//
// A ~40-line HTTP/1.1 server on an ephemeral port, rather than a
// mocking crate, so the test adds no dependency to a workspace that
// currently has none for this. It answers every request with a
// canned status + body and records each request head so the tests
// can assert on the `Authorization` header we did (or deliberately
// did not) send.
/// The raw request heads (request line + header block) the mock saw,
/// in arrival order.
type Recorded = Arc<Mutex<Vec<String>>>;
/// Byte offset of the `\r\n\r\n` that ends the header block.
fn headers_end(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n")
}
/// Announced body length from a request head, if any. Needed so the
/// POST test drains the JSON body before the mock closes the socket
/// — closing mid-write would surface to `reqwest` as a connection
/// error instead of the 401 we're trying to assert on.
fn content_length(head: &str) -> usize {
head.lines()
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.trim()
.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse::<usize>().ok())?
})
.unwrap_or(0)
}
/// Serve `n` connections, answering each with `status`/`reason` and
/// `body`. Returns the base URL to point an [`AppViewClient`] at,
/// plus the recording handle.
async fn spawn_mock(
status: u16,
reason: &'static str,
body: &'static str,
n: usize,
) -> (String, Recorded) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let recorded: Recorded = Arc::new(Mutex::new(Vec::new()));
let rec = recorded.clone();
tokio::spawn(async move {
for _ in 0..n {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let mut buf: Vec<u8> = Vec::new();
let mut chunk = [0u8; 2048];
loop {
let read = match sock.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(k) => k,
};
buf.extend_from_slice(&chunk[..read]);
if let Some(p) = headers_end(&buf) {
let head = String::from_utf8_lossy(&buf[..p]).into_owned();
if buf.len() - (p + 4) >= content_length(&head) {
rec.lock().unwrap().push(head);
break;
}
}
}
// `connection: close` keeps every request on a fresh
// socket, so the recorded order matches the call order.
let resp = format!(
"HTTP/1.1 {status} {reason}\r\n\
content-type: application/json\r\n\
content-length: {}\r\n\
connection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
}
});
(format!("http://{addr}"), recorded)
}
/// Value of the `Authorization` header in a recorded request head,
/// or `None` when the request carried none.
fn auth_header(head: &str) -> Option<String> {
head.lines().find_map(|l| {
let (k, v) = l.split_once(':')?;
k.trim()
.eq_ignore_ascii_case("authorization")
.then(|| v.trim().to_string())
})
}
// -- the contract the TS retry chain depends on -------------------
/// The AppView's 401 body for an expired access token.
const TOKEN_INVALID_BODY: &str =
r#"{"error":"TokenInvalid","message":"ExpiredSignature"}"#;
/// **This is the test the token-renewal path hangs off.**
///
/// `src/lib/api/client.ts`'s `safeInvoke` refreshes the session and
/// retries exactly once when `isTokenInvalid(e)` matches — and that
/// predicate is a substring search for `"TokenInvalid"` /
/// `"ExpiredSignature"` over the *stringified* error that came up
/// from Rust. The AppView only ever states the code in its JSON
/// body, so if [`status_error`] were to drop the body (or truncate
/// it to the status code) the retry would never fire and the desktop
/// client would sit on a dead timeline until the user restarts it.
///
/// So: for each of the four authenticated endpoints, assert the code
/// survives verbatim all the way into `Error::to_string()` — which
/// is exactly what `lib.rs` hands the Tauri IPC layer via
/// `.map_err(|e| e.to_string())`.
#[tokio::test]
async fn token_invalid_code_survives_into_the_error_string() {
let (base, _rec) = spawn_mock(401, "Unauthorized", TOKEN_INVALID_BODY, 4).await;
let c = AppViewClient::new(base);
let errs = vec![
c.fetch_timeline("did:plc:me", None, 30, "stale")
.await
.unwrap_err()
.to_string(),
c.fetch_notifications("did:plc:me", None, 30, "stale")
.await
.unwrap_err()
.to_string(),
c.notification_count("did:plc:me", "stale")
.await
.unwrap_err()
.to_string(),
c.mark_notifications_seen("did:plc:me", None, "stale")
.await
.unwrap_err()
.to_string(),
];
for e in &errs {
// The literal the TS `isTokenInvalid()` greps for. Both
// spellings it accepts are in this body.
assert!(
e.contains("TokenInvalid"),
"error must carry the AppView's code verbatim, got: {e}"
);
assert!(
e.contains("ExpiredSignature"),
"error must carry the AppView's message verbatim, got: {e}"
);
// The status is useful context, but it is NOT what the retry
// keys off — asserting it here documents that both travel.
assert!(e.contains("401"), "status should travel too, got: {e}");
}
// Each endpoint still labels itself, so a log line says which
// call failed.
assert!(errs[0].contains("timeline home"));
assert!(errs[1].contains("notifications"));
assert!(errs[2].contains("notification count"));
assert!(errs[3].contains("notifications seen"));
}
/// The 403 the AppView returns when the token is valid but its
/// `sub` doesn't match the `did` query parameter. Deliberately
/// *not* something `isTokenInvalid` matches: refreshing wouldn't
/// help, so the retry must not fire — but the code still has to
/// reach the UI so the message is actionable.
#[tokio::test]
async fn forbidden_body_survives_and_does_not_look_refreshable() {
let (base, _rec) = spawn_mock(
403,
"Forbidden",
r#"{"error":"Forbidden","message":"did does not match token subject"}"#,
1,
)
.await;
let c = AppViewClient::new(base);
let e = c
.fetch_timeline("did:plc:someone-else", None, 30, "good-jwt")
.await
.unwrap_err()
.to_string();
assert!(e.contains("Forbidden"), "got: {e}");
assert!(e.contains("403"), "got: {e}");
assert!(!e.contains("TokenInvalid"));
assert!(!e.contains("ExpiredSignature"));
}
/// The four viewer-scoped endpoints must actually put the JWT on
/// the wire — an empty or missing header is a 401 from the server.
#[tokio::test]
async fn authenticated_endpoints_send_the_bearer_header() {
let (base, rec) = spawn_mock(500, "Internal Server Error", "{}", 4).await;
let c = AppViewClient::new(base);
let _ = c.fetch_timeline("did:plc:me", None, 30, "jwt-abc").await;
let _ = c.fetch_notifications("did:plc:me", None, 30, "jwt-abc").await;
let _ = c.notification_count("did:plc:me", "jwt-abc").await;
let _ = c
.mark_notifications_seen("did:plc:me", Some("2026-09-09T10:00:00Z"), "jwt-abc")
.await;
let heads = rec.lock().unwrap().clone();
assert_eq!(heads.len(), 4, "every call should have reached the server");
for h in &heads {
assert_eq!(
auth_header(h).as_deref(),
Some("Bearer jwt-abc"),
"missing/!= bearer token in:\n{h}"
);
}
// The POST still carries its JSON body alongside the header.
assert!(heads[3].starts_with("POST /api/notifications/seen"));
}
/// The public half of the API must keep working while logged out,
/// so it must not grow an `Authorization` header by accident.
#[tokio::test]
async fn public_endpoints_send_no_authorization_header() {
let (base, rec) = spawn_mock(200, "OK", r#"{"posts":[],"q":"hi"}"#, 1).await;
let c = AppViewClient::new(base);
let r = c.fetch_search("hi", 30).await.unwrap();
assert_eq!(r.q, "hi");
assert!(auth_header(&rec.lock().unwrap()[0]).is_none());
let (base2, rec2) = spawn_mock(200, "OK", r#"{"profiles":[],"cursor":null}"#, 2).await;
let c2 = AppViewClient::new(base2);
c2.fetch_followers("did:plc:me", None, 30).await.unwrap();
c2.fetch_following("did:plc:me", None, 30).await.unwrap();
for h in rec2.lock().unwrap().iter() {
assert!(auth_header(h).is_none(), "unexpected auth header in:\n{h}");
}
}
} }
+159 -1
View File
@@ -354,6 +354,28 @@ async fn unfollow_user(
})) }))
} }
/// Access JWT for the AppView's viewer-scoped endpoints.
///
/// The four authenticated AppView calls (`timeline_home`,
/// `fetch_notifications`, `notification_count`,
/// `mark_notifications_seen`) all need the same thing: the stored
/// session's access JWT, or a message the UI can render when there
/// isn't one. Factored out so no call site can accidentally send an
/// empty `Authorization: Bearer` header — which the AppView would
/// answer with a 401 `TokenInvalid`, and the frontend would then burn a
/// pointless refresh round trip on before failing anyway.
///
/// The message deliberately contains neither `TokenInvalid` nor
/// `ExpiredSignature`: `safeInvoke`'s `isTokenInvalid()` greps for those
/// substrings, and a logged-out client has nothing to refresh *with*.
fn require_access_jwt(state: &AppState, what: &str) -> Result<String, String> {
state
.store
.load()
.map(|s| s.access_jwt)
.ok_or_else(|| format!("not logged in: {what} requires a signed-in session"))
}
#[tauri::command] #[tauri::command]
async fn timeline_home( async fn timeline_home(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
@@ -362,9 +384,10 @@ async fn timeline_home(
limit: Option<u32>, limit: Option<u32>,
) -> Result<appview_client::TimelineResponse, String> { ) -> Result<appview_client::TimelineResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100); let lim = limit.unwrap_or(30).clamp(1, 100);
let jwt = require_access_jwt(&state, "the home timeline")?;
state state
.appview .appview
.fetch_timeline(&did, cursor.as_deref(), lim) .fetch_timeline(&did, cursor.as_deref(), lim, &jwt)
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
@@ -381,6 +404,29 @@ async fn profile_get(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// `profile_get_by_did(did)` — resolve a profile by DID rather than
/// by handle (`GET /api/profile?did=…`).
///
/// This is the *only* correct lookup for an actor that came out of a
/// notification or a follower list: the AppView synthesises a
/// placeholder `handle` (a truncated DID, see `short_did_bare`) for
/// actors it has neither a `profiles` row nor an indexed post for, and
/// feeding that placeholder back into the handle lookup matches
/// nothing — `resolve_profile` then answers with a synthetic empty
/// profile. The DID in the notification / actor DTO is real, so we
/// navigate by that instead.
#[tauri::command]
async fn profile_get_by_did(
state: tauri::State<'_, AppState>,
did: String,
) -> Result<appview_client::ProfileResponse, String> {
state
.appview
.fetch_profile_by_did(&did)
.await
.map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
async fn search( async fn search(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
@@ -407,6 +453,111 @@ async fn post_get(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// `fetch_thread(uri, viewer_did?)` — the full thread around `uri`:
/// the whole ancestor chain (root first) plus the direct replies
/// (oldest first). `post_get` stays the narrower two-hop shape the
/// timeline's inline thread modal uses; this is what a dedicated
/// thread view wants.
#[tauri::command]
async fn fetch_thread(
state: tauri::State<'_, AppState>,
uri: String,
viewer_did: Option<String>,
) -> Result<appview_client::ThreadFullResponse, String> {
state
.appview
.fetch_thread(&uri, viewer_did.as_deref())
.await
.map_err(|e| e.to_string())
}
/// `fetch_notifications(did, cursor?, limit?)` — one page of the
/// recipient's notifications, newest first. Same limit clamp as
/// `timeline_home` (the server clamps too, but doing it here means a
/// bad `limit` never costs a round trip).
#[tauri::command]
async fn fetch_notifications(
state: tauri::State<'_, AppState>,
did: String,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<appview_client::NotificationsResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
let jwt = require_access_jwt(&state, "notifications")?;
state
.appview
.fetch_notifications(&did, cursor.as_deref(), lim, &jwt)
.await
.map_err(|e| e.to_string())
}
/// `notification_count(did)` — unread count for the NavRail badge.
#[tauri::command]
async fn notification_count(
state: tauri::State<'_, AppState>,
did: String,
) -> Result<appview_client::NotificationCountResponse, String> {
let jwt = require_access_jwt(&state, "the unread-notification count")?;
state
.appview
.notification_count(&did, &jwt)
.await
.map_err(|e| e.to_string())
}
/// `mark_notifications_seen(did, seen_at?)` — mark every notification
/// indexed at or before `seen_at` as read. The frontend passes the
/// `indexed_at` of the topmost row it actually rendered, so a
/// notification that lands mid-scroll is never silently swallowed.
/// Omitting `seen_at` marks everything currently unread.
#[tauri::command]
async fn mark_notifications_seen(
state: tauri::State<'_, AppState>,
did: String,
seen_at: Option<String>,
) -> Result<appview_client::NotificationSeenResponse, String> {
let jwt = require_access_jwt(&state, "marking notifications seen")?;
state
.appview
.mark_notifications_seen(&did, seen_at.as_deref(), &jwt)
.await
.map_err(|e| e.to_string())
}
/// `fetch_followers(did, cursor?, limit?)` — one page of the actors
/// who follow `did`.
#[tauri::command]
async fn fetch_followers(
state: tauri::State<'_, AppState>,
did: String,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<appview_client::ActorListResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
state
.appview
.fetch_followers(&did, cursor.as_deref(), lim)
.await
.map_err(|e| e.to_string())
}
/// `fetch_following(did, cursor?, limit?)` — one page of the actors
/// `did` follows.
#[tauri::command]
async fn fetch_following(
state: tauri::State<'_, AppState>,
did: String,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<appview_client::ActorListResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
state
.appview
.fetch_following(&did, cursor.as_deref(), lim)
.await
.map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
async fn status_pds(state: tauri::State<'_, AppState>) -> Result<serde_json::Value, String> { async fn status_pds(state: tauri::State<'_, AppState>) -> Result<serde_json::Value, String> {
let sess = state.store.load(); let sess = state.store.load();
@@ -791,8 +942,15 @@ pub fn run() {
resolve_handle, resolve_handle,
timeline_home, timeline_home,
profile_get, profile_get,
profile_get_by_did,
search, search,
post_get, post_get,
fetch_thread,
fetch_notifications,
notification_count,
mark_notifications_seen,
fetch_followers,
fetch_following,
like_post, like_post,
unlike_post, unlike_post,
repost_post, repost_post,
+1 -1
View File
@@ -40,7 +40,7 @@
"https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}" "https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}"
], ],
"pubkey": "", "pubkey": "",
"_comment": "Auto-update is disabled for dev. To enable for releases: (1) stand up a release-artifacts server that serves update.json, (2) run `tauri signer generate` and paste the pubkey here, (3) flip active+dialog to true. Capabilities already include `updater:default` so the frontend can request update checks via the plugin once enabled." "_comment": "Auto-update is inert in dev: nothing in the app calls the updater's check(), and `pubkey` is empty. Note that `active` and `dialog` above are Tauri v1 leftovers — the v2 updater plugin ignores unknown keys, so they do NOT switch anything on or off. The full production path (signer keys, pubkey, endpoints, bundle.createUpdaterArtifacts, latest.json format, per-platform build + artifact paths, and the still-missing check() call) is documented in docs/tauri-release.md. Enable it via a release config overlay passed to `tauri build --config`, so this dev config stays as is. Capabilities already include `updater:default` (check/download/install)."
} }
}, },
"bundle": { "bundle": {
+159 -24
View File
@@ -6,12 +6,16 @@
fetchTimeline, fetchTimeline,
fetchSearch, fetchSearch,
fetchPost, fetchPost,
errorMessage,
isAuthFailure,
notificationCount,
openExternalUrl, openExternalUrl,
showError, showError,
type Session, type Session,
type Post, type Post,
} from "./lib/api/client"; } from "./lib/api/client";
import NavRail from "./lib/components/NavRail.svelte"; import NavRail from "./lib/components/NavRail.svelte";
import NotificationsView from "./lib/components/NotificationsView.svelte";
import StatusBar from "./lib/components/StatusBar.svelte"; import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte"; import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.svelte"; import ComposeBox from "./lib/components/ComposeBox.svelte";
@@ -21,7 +25,14 @@
import Skeleton from "./lib/components/Skeleton.svelte"; import Skeleton from "./lib/components/Skeleton.svelte";
import Sidebar from "./lib/components/Sidebar.svelte"; import Sidebar from "./lib/components/Sidebar.svelte";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings"; type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
let view: View = $state("home"); let view: View = $state("home");
// Handle for the "user" view (i.e. someone else's profile). The // Handle for the "user" view (i.e. someone else's profile). The
@@ -29,6 +40,13 @@
// goes there). Selecting a handle (via the PostCard avatar link or // goes there). Selecting a handle (via the PostCard avatar link or
// a future deep-link) navigates to "user" with `selectedHandle` set. // a future deep-link) navigates to "user" with `selectedHandle` set.
let selectedHandle: string = $state(""); let selectedHandle: string = $state("");
// DID for the "user" view, when the navigation had one. Set by
// `openActor` (notification rows, follower/following lists, the
// PostCards inside a profile feed) and cleared by
// `openUserProfile` (timeline / search, where only a handle is
// available). `<ProfileView>` prefers it over the handle — see the
// comment on `openActor`.
let selectedDid: string | null = $state(null);
let currentUser: Session | null = $state(null); let currentUser: Session | null = $state(null);
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false }); let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
@@ -40,6 +58,14 @@
let seenUris: Set<string> = new Set(); let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined; let _statusTimer: number | undefined;
// Unread-notification badge on the NavRail. Polled on the same 5s
// cadence as the timeline refresh (`startPoll`) — the count query is
// backed by a partial index server-side, so it's cheap enough to sit
// next to the timeline poll rather than needing its own slower
// timer. The poll shares `_pollTimer`, so `stopPoll` (logout /
// unmount) tears both down in one place.
let unreadCount: number = $state(0);
// Home tab strip — "for you" is a placeholder (no real algo yet), // Home tab strip — "for you" is a placeholder (no real algo yet),
// "following" is the live behavior. Mirrors the X-style "For you / // "following" is the live behavior. Mirrors the X-style "For you /
// Following" tabs. // Following" tabs.
@@ -116,11 +142,44 @@
} }
/// Navigate to the "user" profile view for `handle`. Called from /// Navigate to the "user" profile view for `handle`. Called from
/// `<PostCard on_handle_click>` and the avatar/handle buttons in /// `<PostCard on_handle_click>` on the timeline / search results and
/// the post header. The actual profile fetch happens inside /// the avatar/handle buttons in the post header. The actual profile
/// `<ProfileView>` on mount. /// fetch happens inside `<ProfileView>`.
///
/// Handle-only path: those handles come from `posts.handle`, which
/// the AppView resolves from the record itself. Where a DID is
/// available, prefer [`openActor`].
function openUserProfile(handle: string) { function openUserProfile(handle: string) {
selectedHandle = handle; selectedHandle = handle;
selectedDid = null;
view = "user";
threadRoot = null;
threadParent = null;
}
/// Navigate to a profile by DID, with `handle` carried along only as
/// a label for the header while the fetch is in flight.
///
/// This is the path every actor coming out of a notification or a
/// follower/following list must take. The AppView synthesises a
/// placeholder `handle` for actors it has neither a `profiles` row
/// nor an indexed post for — a truncated DID like `"did:plc:f5…"`
/// (`short_did_bare` in the AppView's `routes.rs`). Feeding that
/// back into the handle lookup matches nothing, and the server
/// answers with a synthetic empty profile instead of an error, so
/// the click reads as a dead end rather than as a failure. The DID
/// on the DTO is always real.
///
/// We deliberately do NOT sniff the handle for the `…` placeholder
/// marker: that would bake the server's current display format into
/// the client. Passing the DID explicitly keeps the path correct
/// whatever the placeholder ends up looking like.
function openActor(did: string, handle: string) {
selectedHandle = handle;
// An empty DID would resolve to the "did is required" 400; fall
// back to the handle lookup in that case rather than guaranteeing
// an error.
selectedDid = did || null;
view = "user"; view = "user";
threadRoot = null; threadRoot = null;
threadParent = null; threadParent = null;
@@ -302,14 +361,57 @@
let _pollTimer: number | undefined; let _pollTimer: number | undefined;
function startPoll() { function startPoll() {
if (_pollTimer != null) return; if (_pollTimer != null) return;
// Prime the badge immediately — otherwise a freshly logged-in
// user stares at a blank rail for a full interval.
void refreshUnreadCount();
_pollTimer = window.setInterval(() => { _pollTimer = window.setInterval(() => {
if (view === "home") void refreshTimeline(false); if (view === "home") void refreshTimeline(false);
void refreshUnreadCount();
}, 5000); }, 5000);
} }
function stopPoll() { function stopPoll() {
if (_pollTimer == null) return; if (_pollTimer == null) return;
window.clearInterval(_pollTimer); window.clearInterval(_pollTimer);
_pollTimer = undefined; _pollTimer = undefined;
unreadCount = 0;
}
/// Pull the unread count for the NavRail badge. Swallows errors:
/// the badge is ambient information, and a transient AppView hiccup
/// shouldn't produce a toast every 5 seconds.
///
/// One class of error is *not* swallowed-and-retried, though. Since
/// the AppView started requiring the access JWT on
/// `/api/notifications/count`, a rejected token surfaces here — and
/// by the time it does, `safeInvoke` has already spent its one
/// automatic refresh. Retrying on a 5s timer would then be a request
/// loop against a server that keeps answering 401/403 for as long as
/// the app is open. So an auth failure stops the poll outright; the
/// next successful login restarts it via the `session.subscribe`
/// handler in `onMount`.
async function refreshUnreadCount() {
if (!currentUser) return;
// While the notifications view is open the user is by definition
// reading them; the view marks the page seen itself and calls
// back into `onNotificationsSeen`. Polling on top of that would
// race the ack and flicker the badge back on.
if (view === "notifications") return;
try {
unreadCount = await notificationCount(currentUser.did);
} catch (e) {
if (isAuthFailure(e)) {
console.warn("notification poll stopped: session rejected", e);
stopPoll();
return;
}
/* otherwise ignore — keep the last known count */
}
}
/// Called by `<NotificationsView on_seen>` once it has acked the
/// first page. Zeroes the badge without waiting for the next poll.
function onNotificationsSeen() {
unreadCount = 0;
} }
async function refreshTimeline(reset: boolean) { async function refreshTimeline(reset: boolean) {
@@ -335,7 +437,7 @@
if (fresh.length > 0) userPosts = [...fresh, ...userPosts]; if (fresh.length > 0) userPosts = [...fresh, ...userPosts];
} }
} catch (e) { } catch (e) {
timelineError = String(e); timelineError = errorMessage(e);
// Keep whatever we had on a transient failure. // Keep whatever we had on a transient failure.
} finally { } finally {
timelineLoading = false; timelineLoading = false;
@@ -355,7 +457,7 @@
} }
timelineCursor = r.cursor; timelineCursor = r.cursor;
} catch (e) { } catch (e) {
timelineError = String(e); timelineError = errorMessage(e);
} finally { } finally {
timelineLoading = false; timelineLoading = false;
} }
@@ -457,6 +559,35 @@
} }
</script> </script>
<!--
Thread overlay. Defined once as a snippet because two views open a
thread through the same `openThread` helper — the timeline (a
PostCard's thread button) and the notifications list (a row with a
`subject_uri`). Rendering it twice from one definition keeps the
close button, the loading skeleton and the parent/root layout from
drifting apart.
-->
{#snippet threadModal()}
{#if threadRoot || threadLoading || threadError}
<div class="thread-modal">
<header class="thread-modal__head">
<span class="crumb">// thread</span>
<button class="btn--ghost" onclick={closeThread}>close</button>
</header>
{#if threadLoading}
<Skeleton rows={2} />
{:else if threadError}
<div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if}
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
{/if}
</div>
{/if}
{/snippet}
{#if !currentUser} {#if !currentUser}
<div class="login-wrap"> <div class="login-wrap">
<LoginScreen <LoginScreen
@@ -470,6 +601,7 @@
<div class="shell"> <div class="shell">
<NavRail <NavRail
{view} {view}
unread={unreadCount}
on_select={(v) => setView(v)} on_select={(v) => setView(v)}
/> />
<div class="main"> <div class="main">
@@ -504,24 +636,7 @@
{:else if userPosts.length === 0} {:else if userPosts.length === 0}
<div class="empty">// timeline is empty. compose your first post →</div> <div class="empty">// timeline is empty. compose your first post →</div>
{:else} {:else}
{#if threadRoot} {@render threadModal()}
<div class="thread-modal">
<header class="thread-modal__head">
<span class="crumb">// thread</span>
<button class="btn--ghost" onclick={closeThread}>close</button>
</header>
{#if threadLoading}
<Skeleton rows={2} />
{:else if threadError}
<div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if}
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
{/if}
</div>
{/if}
{#each userPosts as p (p.uri)} {#each userPosts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} /> <PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
{/each} {/each}
@@ -533,6 +648,22 @@
</div> </div>
{/if} {/if}
{/if} {/if}
{:else if view === "notifications"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// benachrichtigungen —</span>
<span class="as">@{currentUser.handle}</span>
<span class="meta">
{unreadCount > 0 ? `${unreadCount} ungelesen` : "alles gelesen"}
</span>
</div>
{@render threadModal()}
<NotificationsView
did={currentUser.did}
on_thread_click={openThread}
on_actor_click={openActor}
on_seen={onNotificationsSeen}
/>
{:else if view === "compose"} {:else if view === "compose"}
<div class="head"> <div class="head">
<span class="prompt">$</span> <span class="prompt">$</span>
@@ -553,7 +684,9 @@
</div> </div>
<ProfileView <ProfileView
handle={selectedHandle} handle={selectedHandle}
did={selectedDid}
on_thread_click={openThread} on_thread_click={openThread}
on_actor_click={openActor}
current_user_did={currentUser?.did ?? null} current_user_did={currentUser?.did ?? null}
/> />
{:else if view === "profile"} {:else if view === "profile"}
@@ -565,7 +698,9 @@
</div> </div>
<ProfileView <ProfileView
handle={currentUser.handle} handle={currentUser.handle}
did={currentUser.did}
on_thread_click={openThread} on_thread_click={openThread}
on_actor_click={openActor}
current_user_did={currentUser.did} current_user_did={currentUser.did}
/> />
{/if} {/if}
@@ -0,0 +1,394 @@
// The AppView auth contract, from the client's side.
//
// Same setup as `notifications.test.ts`: `@tauri-apps/api/core` is
// mocked so no Tauri shell is needed, and every assertion is about the
// exact sequence of commands we hand the Rust IPC layer.
//
// What's pinned here:
// * the **token-renewal chain** — a `TokenInvalid` coming out of the
// AppView (not the PDS) triggers exactly one `auth_refresh` + one
// retry, for each of the four now-authenticated endpoints;
// * that the chain fires for a **bare string** rejection, which is
// what `invoke` actually rejects with for our `Result<T, String>`
// commands — the shape the old `typeof e !== "object"` guard
// silently skipped;
// * that it fires **once**, never in a loop, and not at all when the
// refresh itself fails or when the error isn't refreshable;
// * that the **public** endpoints still work with no session at all
// and never reach for a refresh.
//
// The error strings below are verbatim what the Rust side produces:
// `appview_client.rs`'s `status_error()` formats
// `"appview: {label} returned {status}: {body}"`, and `lib.rs`
// stringifies that into the command's `Err(String)`. The Rust test
// `token_invalid_code_survives_into_the_error_string` pins the other
// half of the same contract.
//
// Run with:
// npx vitest run src/lib/api/appview-auth.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const invokeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
isTauri: () => true,
}));
beforeEach(() => {
invokeMock.mockReset();
});
/// Exactly what a Tauri command rejects with once the AppView has
/// refused an expired access token: a bare string, because our
/// commands are `Result<T, String>` and `invoke` rejects with the
/// deserialised payload — not an `Error`.
function appviewTokenInvalid(label: string): string {
return (
`appview: ${label} returned 401 Unauthorized: ` +
`{"error":"TokenInvalid","message":"ExpiredSignature"}`
);
}
const FRESH_SESSION = {
did: "did:plc:me",
handle: "me.test",
access_jwt: "fresh-access",
refresh_jwt: "fresh-refresh",
};
/// The four endpoints that grew an auth guard, each with the command
/// name the Rust side registers, the AppView's label in the error
/// string, a caller, and the payload the retry should resolve with.
const AUTHED = [
{
name: "timeline_home",
label: "timeline home",
payload: { posts: [], cursor: null },
call: async () => {
const { fetchTimeline } = await import("./client");
return fetchTimeline("did:plc:me");
},
},
{
name: "fetch_notifications",
label: "notifications",
payload: { notifications: [], cursor: null },
call: async () => {
const { fetchNotifications } = await import("./client");
return fetchNotifications("did:plc:me");
},
},
{
name: "notification_count",
label: "notification count",
payload: { count: 3 },
call: async () => {
const { notificationCount } = await import("./client");
return notificationCount("did:plc:me");
},
},
{
name: "mark_notifications_seen",
label: "notifications seen",
payload: { ok: true, updated: 2 },
call: async () => {
const { markNotificationsSeen } = await import("./client");
return markNotificationsSeen("did:plc:me", "2026-09-09T10:00:00Z");
},
},
] as const;
describe("AppView token renewal", () => {
for (const ep of AUTHED) {
it(`${ep.name}: a TokenInvalid from the AppView refreshes and retries once`, async () => {
invokeMock
// 1. the call, rejected by the AppView's auth guard
.mockRejectedValueOnce(appviewTokenInvalid(ep.label))
// 2. auth_refresh mints a new access JWT from the refresh JWT
.mockResolvedValueOnce(FRESH_SESSION)
// 3. the same call again, now with the fresh token
.mockResolvedValueOnce(ep.payload);
await expect(ep.call()).resolves.toBeDefined();
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
ep.name,
"auth_refresh",
ep.name,
]);
// The retry must repeat the *same* argument bag — a dropped
// cursor or limit here would silently change what the user sees.
expect(invokeMock.mock.calls[0][1]).toEqual(invokeMock.mock.calls[2][1]);
});
}
it("returns the retry's payload, not the failed first attempt", async () => {
const { notificationCount } = await import("./client");
invokeMock
.mockRejectedValueOnce(appviewTokenInvalid("notification count"))
.mockResolvedValueOnce(FRESH_SESSION)
.mockResolvedValueOnce({ count: 7 });
await expect(notificationCount("did:plc:me")).resolves.toBe(7);
});
it("fires for a bare-string rejection — the shape Tauri actually uses", async () => {
// Regression guard. `invoke` rejects with the deserialised
// `Err(String)` payload, i.e. a primitive string. A guard that
// bails on anything that isn't an object never sees the code and
// the retry silently never runs — the user's timeline just dies an
// hour after login with no error anyone would connect to auth.
const { fetchTimeline } = await import("./client");
invokeMock
.mockRejectedValueOnce(appviewTokenInvalid("timeline home"))
.mockResolvedValueOnce(FRESH_SESSION)
.mockResolvedValueOnce({ posts: [], cursor: null });
await expect(fetchTimeline("did:plc:me")).resolves.toEqual({
posts: [],
cursor: null,
});
expect(invokeMock).toHaveBeenCalledTimes(3);
});
it("also fires when the error arrives as an Error object", async () => {
const { fetchTimeline } = await import("./client");
invokeMock
.mockRejectedValueOnce(new Error(appviewTokenInvalid("timeline home")))
.mockResolvedValueOnce(FRESH_SESSION)
.mockResolvedValueOnce({ posts: [], cursor: null });
await expect(fetchTimeline("did:plc:me")).resolves.toBeDefined();
expect(invokeMock).toHaveBeenCalledTimes(3);
});
it("retries exactly once — a still-failing retry is not refreshed again", async () => {
const { fetchNotifications } = await import("./client");
invokeMock
.mockRejectedValueOnce(appviewTokenInvalid("notifications"))
.mockResolvedValueOnce(FRESH_SESSION)
.mockRejectedValueOnce(appviewTokenInvalid("notifications"));
await expect(fetchNotifications("did:plc:me")).rejects.toThrow(
/TokenInvalid/,
);
// Three calls, not five: no second refresh, no third attempt.
expect(invokeMock).toHaveBeenCalledTimes(3);
expect(invokeMock.mock.calls.filter((c) => c[0] === "auth_refresh")).toHaveLength(1);
});
it("propagates the original error when the refresh itself fails", async () => {
// The refresh JWT is good for 90 days, but it does eventually
// expire (or get revoked). At that point there's nothing left to
// do but surface the failure — retrying with the same dead token
// would just be a second 401.
const { notificationCount } = await import("./client");
invokeMock
.mockRejectedValueOnce(appviewTokenInvalid("notification count"))
.mockRejectedValueOnce("refresh token expired");
await expect(notificationCount("did:plc:me")).rejects.toThrow(
/TokenInvalid/,
);
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
"notification_count",
"auth_refresh",
]);
});
it("does not refresh on a 403 Forbidden — a new token wouldn't help", async () => {
// The AppView returns this when the token is perfectly valid but
// its `sub` doesn't match the `did` query parameter. Refreshing
// mints another token for the same subject, so a retry is pure
// waste.
const { fetchTimeline } = await import("./client");
invokeMock.mockRejectedValueOnce(
'appview: timeline home returned 403 Forbidden: ' +
'{"error":"Forbidden","message":"did does not match token subject"}',
);
await expect(fetchTimeline("did:plc:someone-else")).rejects.toThrow(
/Forbidden/,
);
expect(invokeMock).toHaveBeenCalledTimes(1);
});
it("does not refresh when the shell says there is no session at all", async () => {
// `require_access_jwt` in lib.rs. Nothing to refresh *from*, so the
// message deliberately carries neither `TokenInvalid` nor
// `ExpiredSignature`.
const { fetchNotifications } = await import("./client");
invokeMock.mockRejectedValueOnce(
"not logged in: notifications requires a signed-in session",
);
await expect(fetchNotifications("did:plc:me")).rejects.toThrow(
/not logged in/,
);
expect(invokeMock).toHaveBeenCalledTimes(1);
});
it("treats a 503 AuthUnavailable as transient, not as an auth failure", async () => {
// The AppView answers 503 `AuthUnavailable` when it cannot reach
// the PDS to fetch the verification key — it fails closed rather
// than guessing. Our token is fine; the *server* is temporarily
// unable to check it. So: no refresh (nothing is wrong with the
// token), and `isAuthFailure` must stay false so the background
// poll keeps trying instead of shutting itself down over an outage
// that will resolve on its own.
const { notificationCount, isAuthFailure } = await import("./client");
const err =
'appview: notification count returned 503 Service Unavailable: ' +
'{"error":"AuthUnavailable","message":"could not fetch PDS key"}';
invokeMock.mockRejectedValueOnce(err);
await expect(notificationCount("did:plc:me")).rejects.toThrow(
/AuthUnavailable/,
);
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(isAuthFailure(err)).toBe(false);
});
it("does not refresh on a transient server error", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockRejectedValueOnce(
"appview: notification count returned 500 Internal Server Error: db down",
);
await expect(notificationCount("did:plc:me")).rejects.toThrow(/500/);
expect(invokeMock).toHaveBeenCalledTimes(1);
});
it("an auth_* command never triggers a refresh (no login loop)", async () => {
const { session } = await import("./client");
invokeMock.mockRejectedValueOnce("TokenInvalid");
await expect(session.login("me.test", "pw")).rejects.toBeDefined();
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual(["auth_login"]);
});
});
describe("public AppView endpoints", () => {
// These stay unauthenticated server-side, so they must keep working
// with no session in the store: one invoke, no bearer token to fetch,
// no refresh.
const PUBLIC = [
{
name: "search",
payload: { posts: [], q: "hi" },
call: async () => (await import("./client")).fetchSearch("hi"),
},
{
name: "profile_get",
payload: {
did: "did:plc:a",
handle: "a.test",
posts: [],
followers: 0,
following: 0,
post_count: 0,
},
call: async () => (await import("./client")).fetchProfile("a.test"),
},
{
name: "profile_get_by_did",
payload: {
did: "did:plc:a",
handle: "a.test",
posts: [],
followers: 0,
following: 0,
post_count: 0,
},
call: async () => (await import("./client")).fetchProfileByDid("did:plc:a"),
},
{
name: "post_get",
payload: { post: null, thread: { parent: null, root: null } },
call: async () =>
(await import("./client")).fetchPost("at://did:plc:a/app.twi.post/1"),
},
{
name: "fetch_thread",
payload: { post: null, parents: [], root: null, replies: [] },
call: async () =>
(await import("./client")).fetchThread("at://did:plc:a/app.twi.post/1"),
},
{
name: "fetch_followers",
payload: { profiles: [], cursor: null },
call: async () => (await import("./client")).fetchFollowers("did:plc:a"),
},
{
name: "fetch_following",
payload: { profiles: [], cursor: null },
call: async () => (await import("./client")).fetchFollowing("did:plc:a"),
},
] as const;
for (const ep of PUBLIC) {
it(`${ep.name} resolves without a session and without refreshing`, async () => {
invokeMock.mockResolvedValueOnce(ep.payload);
await expect(ep.call()).resolves.toBeDefined();
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock.mock.calls[0][0]).toBe(ep.name);
expect(
invokeMock.mock.calls.some((c) => c[0] === "auth_refresh"),
).toBe(false);
});
}
it("a public call's own failure surfaces untouched", async () => {
const { fetchSearch } = await import("./client");
invokeMock.mockRejectedValueOnce(
"appview: search returned 400 Bad Request: q is required",
);
await expect(fetchSearch("")).rejects.toThrow(/q is required/);
expect(invokeMock).toHaveBeenCalledTimes(1);
});
});
describe("isAuthFailure / errorMessage", () => {
it("recognises every shape the AppView's auth guard can answer with", async () => {
const { isAuthFailure } = await import("./client");
for (const msg of [
'appview: timeline home returned 401 Unauthorized: {"error":"AuthMissing","message":"no bearer"}',
'appview: notifications returned 401 Unauthorized: {"error":"TokenInvalid","message":"ExpiredSignature"}',
'appview: notification count returned 403 Forbidden: {"error":"Forbidden"}',
"not logged in: the home timeline requires a signed-in session",
]) {
expect(isAuthFailure(msg)).toBe(true);
expect(isAuthFailure(new Error(msg))).toBe(true);
}
});
it("does not mistake a server or network failure for an auth failure", async () => {
const { isAuthFailure } = await import("./client");
expect(
isAuthFailure("appview: notifications returned 500: db down"),
).toBe(false);
expect(
isAuthFailure("appview: failed to send timeline request"),
).toBe(false);
expect(isAuthFailure(null)).toBe(false);
expect(isAuthFailure(undefined)).toBe(false);
});
it("swaps the raw 401 wire string for copy the user can act on", async () => {
const { errorMessage } = await import("./client");
const raw =
'appview: notifications returned 401 Unauthorized: {"error":"TokenInvalid","message":"ExpiredSignature"}';
expect(errorMessage(raw)).toBe(
"Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.",
);
// Anything else is shown verbatim: there's nothing better to say
// about a 500 than what the server said.
expect(errorMessage("appview: search returned 500: db down")).toContain(
"500",
);
});
});
+283 -15
View File
@@ -59,14 +59,22 @@ export async function getAppviewUrl(): Promise<string> {
* webview it falls through to a normal `invoke` call. * webview it falls through to a normal `invoke` call.
* *
* **Auto-refresh on 401**: the access JWT expires after 1 hour. When * **Auto-refresh on 401**: the access JWT expires after 1 hour. When
* the PDS rejects our token with `TokenInvalid` (the rusty * the PDS *or the AppView* rejects our token with `TokenInvalid`
* `routes::auth` handlers return `{"error":"TokenInvalid",...}`), * (both return `{"error":"TokenInvalid",...}` — the PDS from its
* we ask the Rust shell for a fresh access JWT via the * `routes::auth` handlers, the AppView from the guard on
* `auth_refresh` Tauri command. The Rust side reads the stored * `/api/timeline/home`, `/api/notifications`,
* refresh JWT (valid for 90 days) and rotates both. We retry * `/api/notifications/count` and `/api/notifications/seen`), we ask
* exactly once on the same `cmd` + `args`. The `auth_*` commands * the Rust shell for a fresh access JWT via the `auth_refresh` Tauri
* themselves are skipped so a failing login doesn't trigger an * command. The Rust side reads the stored refresh JWT (valid for 90
* infinite refresh loop. * days) and rotates both. We retry exactly once on the same `cmd` +
* `args`. The `auth_*` commands themselves are skipped so a failing
* login doesn't trigger an infinite refresh loop.
*
* The whole chain is string-matching, end to end: the AppView states
* the code only in its JSON body, `appview_client.rs`'s
* `status_error()` formats that body into the `anyhow` message, and
* `lib.rs` stringifies it into the command's `Err(String)`. See the
* Rust-side test `token_invalid_code_survives_into_the_error_string`.
*/ */
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> { async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (!isTauri()) { if (!isTauri()) {
@@ -82,17 +90,74 @@ async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promi
} }
} }
/// Normalise whatever a rejected `invoke` handed us into a string.
///
/// This is not defensive padding — it is the difference between the
/// retry chain working and not. Our Tauri commands are
/// `Result<T, String>`, and `@tauri-apps/api`'s `invoke` rejects with
/// the *deserialised* error payload, i.e. a bare JS **string**, not an
/// `Error`. Anything that only reads `e.message` therefore sees
/// nothing at all on the exact path that matters. Errors thrown
/// locally (the browser-preview guard above, and the `Error` instances
/// the tests use) still arrive as objects, so both shapes are handled.
function errorText(e: unknown): string {
if (typeof e === "string") return e;
if (typeof e === "object" && e !== null) {
const m = (e as { message?: unknown }).message;
if (typeof m === "string") return m;
}
return String(e ?? "");
}
/// Sniff out a `TokenInvalid` response from the Rust error string. /// Sniff out a `TokenInvalid` response from the Rust error string.
/// Returns true when the error message looks like an expired/ /// Returns true when the error looks like an expired/invalid JWT —
/// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its /// both the PDS and the AppView use a stable `"TokenInvalid"` code in
/// JSON error body, which `@tauri-apps/api/core` surfaces verbatim). /// their JSON error body, which travels verbatim through the Rust
/// error message and out over the Tauri IPC boundary.
function isTokenInvalid(e: unknown): boolean { function isTokenInvalid(e: unknown): boolean {
if (typeof e !== "object" || e === null) return false; const msg = errorText(e);
const msg = (e as { message?: string }).message ?? String(e);
if (!msg) return false; if (!msg) return false;
return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature"); return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature");
} }
/// True when an error means "this call will not succeed until the user
/// signs in again" — as opposed to a transient network/server hiccup.
///
/// Covers everything the AppView's auth guard can answer with
/// (`AuthMissing` / `TokenInvalid` on 401, `Forbidden` on 403) plus the
/// Rust shell's own "no session stored" message from
/// `require_access_jwt`. Callers that poll in the background use this
/// to *stop* polling: by the time one of these surfaces, `safeInvoke`
/// has already spent its one refresh attempt, so retrying on a timer
/// would just be a request loop against a server that keeps saying no.
export function isAuthFailure(e: unknown): boolean {
const msg = errorText(e);
if (!msg) return false;
return (
msg.includes("AuthMissing") ||
msg.includes("TokenInvalid") ||
msg.includes("ExpiredSignature") ||
msg.includes("Forbidden") ||
msg.includes("not logged in")
);
}
/// User-facing copy for a failed call, in the app's German UI voice.
///
/// An auth failure gets a sentence naming the actual remedy. The raw
/// string a view would otherwise render —
/// `appview: timeline home returned 401 Unauthorized:
/// {"error":"TokenInvalid","message":"ExpiredSignature"}` — is precise
/// and completely unactionable for the person reading it. Everything
/// else falls through verbatim: a network error or a 500 is worth
/// showing as-is, since there is nothing better to say about it.
export function errorMessage(e: unknown): string {
if (isAuthFailure(e)) {
return "Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.";
}
return String(e);
}
export type Session = { export type Session = {
did: string; did: string;
handle: string; handle: string;
@@ -253,8 +318,10 @@ export type SearchResponse = {
/// ///
/// `like_count` and `repost_count` are present when the post was /// `like_count` and `repost_count` are present when the post was
/// found; they're `undefined` (or absent) for the "not in index" /// found; they're `undefined` (or absent) for the "not in index"
/// sentinel response (where `post` is null). AppView has no auth /// sentinel response (where `post` is null). `/api/post/{uri}` is one
/// yet, so `viewer_liked` / `viewer_reposted` aren't returned. /// of the AppView's public endpoints — it takes no token and so has no
/// viewer to resolve against, hence no `viewer_liked` /
/// `viewer_reposted`. Use [`fetchThread`] with a `viewerDid` for those.
export type ThreadResponse = { export type ThreadResponse = {
post: Post | null; post: Post | null;
thread: { thread: {
@@ -370,6 +437,24 @@ export async function fetchProfile(handle: string): Promise<ProfileResponse> {
return await safeInvoke<ProfileResponse>("profile_get", { handle }); return await safeInvoke<ProfileResponse>("profile_get", { handle });
} }
/// Resolve a profile by DID (`GET /api/profile?did=…`) instead of by
/// handle.
///
/// Use this — not [`fetchProfile`] — whenever the actor came out of a
/// notification or a follower/following list. The AppView synthesises
/// a placeholder `handle` for actors it has neither a profile row nor
/// an indexed post for: a truncated DID with an ellipsis, e.g.
/// `"did:plc:f5…"`. That string matches nothing on the way back in, and
/// the server answers with a synthetic empty profile (`did: ""`, zero
/// posts) rather than an error — so a handle-based navigation from
/// those lists is a silent dead end. The DID in the DTO is always
/// real, so navigate by that.
export async function fetchProfileByDid(
did: string,
): Promise<ProfileResponse> {
return await safeInvoke<ProfileResponse>("profile_get_by_did", { did });
}
export async function fetchSearch( export async function fetchSearch(
q: string, q: string,
limit: number = 30, limit: number = 30,
@@ -381,6 +466,189 @@ export async function fetchPost(uri: string): Promise<ThreadResponse> {
return await safeInvoke<ThreadResponse>("post_get", { uri }); return await safeInvoke<ThreadResponse>("post_get", { uri });
} }
/// `GET /api/thread?uri=…` — the *full* thread around a post: the
/// entire ancestor chain plus the direct replies, in one round trip.
///
/// `parents` is ordered root-first (so `parents[parents.length - 1]`
/// is the immediate parent) and `replies` oldest-first. A `parents[0]`
/// whose own `parent_uri` is non-null means "the chain continues
/// above but wasn't loaded" — the walk is depth-limited server-side.
///
/// `post` is `null` when the URI isn't in the index; the counters are
/// then absent too, so a single field check renders "post not found".
/// `viewer_liked` / `viewer_reposted` are `undefined` when no
/// `viewerDid` was passed — that means "unknown", not "false".
export type ThreadFullResponse = {
post: Post | null;
parents: Post[];
root: Post | null;
replies: Post[];
like_count?: number;
repost_count?: number;
viewer_liked?: boolean;
viewer_reposted?: boolean;
};
export async function fetchThread(
uri: string,
viewerDid: string | null = null,
): Promise<ThreadFullResponse> {
return await safeInvoke<ThreadFullResponse>("fetch_thread", {
uri,
viewerDid,
});
}
/// One row of `GET /api/notifications`, hydrated server-side with the
/// author's profile and the subject post's text so the list renders
/// without any follow-up fetch.
///
/// `kind` is typed as the four known values plus `string` so an
/// unknown kind added upstream still type-checks here; the UI's
/// `notificationText` falls back to a generic line.
export type NotificationKind = "like" | "repost" | "follow" | "reply";
export type Notification = {
id: number;
kind: NotificationKind | string;
author_did: string;
/// Without a leading `@` (the UI renders `@{handle}`). Never null —
/// the AppView falls back to a truncated DID.
author_handle: string;
author_display_name?: string | null;
author_avatar_cid?: string | null;
/// The post this is about. `null` for `"follow"`. For
/// `"like"`/`"repost"` it's the recipient's own post; for `"reply"`
/// it's the reply itself.
subject_uri: string | null;
subject_text?: string | null;
created_at: string;
/// When the AppView indexed it — the list's sort key and the value
/// to echo back as `seenAt`.
indexed_at: string;
/// `null` while unread.
read_at: string | null;
};
export type NotificationsResponse = {
notifications: Notification[];
cursor: string | null;
};
/// German UI copy for a notification row. Kept next to the type (and
/// exported) so the mapping is unit-testable without mounting a
/// component. An unrecognised `kind` gets a neutral fallback rather
/// than an empty line.
export function notificationText(kind: string): string {
switch (kind) {
case "like":
return "hat deinen Post geliked";
case "repost":
return "hat repostet";
case "follow":
return "folgt dir jetzt";
case "reply":
return "hat geantwortet";
default:
return "hat interagiert";
}
}
/// Single-glyph icon for a notification row, same switch as
/// [`notificationText`]. Monospace glyphs rather than SVGs so the
/// list keeps the terminal look of the rest of the UI.
export function notificationIcon(kind: string): string {
switch (kind) {
case "like":
return "♥";
case "repost":
return "⇄";
case "follow":
return "+";
case "reply":
return "↩";
default:
return "•";
}
}
/// One page of notifications, newest first. Same opaque-cursor
/// contract as [`fetchTimeline`]: pass the previous response's
/// `cursor` to page down, and `cursor === null` means end of list.
export async function fetchNotifications(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<NotificationsResponse> {
return await safeInvoke<NotificationsResponse>("fetch_notifications", {
did,
cursor,
limit,
});
}
/// Unread-notification count for the NavRail badge. Cheap enough to
/// poll on the same cadence as the timeline refresh.
export async function notificationCount(did: string): Promise<number> {
const r = await safeInvoke<{ count: number }>("notification_count", { did });
return r.count;
}
/// Mark every notification indexed at or before `seenAt` as read.
/// Pass the `indexed_at` of the topmost row the user actually sees, so
/// a notification arriving mid-scroll isn't swallowed. `null` marks
/// everything currently unread. Idempotent — a repeat call reports
/// `updated: 0`.
export async function markNotificationsSeen(
did: string,
seenAt: string | null = null,
): Promise<{ ok: boolean; updated: number }> {
return await safeInvoke<{ ok: boolean; updated: number }>(
"mark_notifications_seen",
{ did, seenAt },
);
}
/// A minimal profile card from `GET /api/followers` / `/api/following`.
/// Deliberately not a full `ProfileResponse` — a page of 30 followers
/// would otherwise be 30 post queries server-side. Click a row and the
/// UI navigates to the full profile by `handle`.
export type ActorProfile = {
did: string;
handle: string;
display_name?: string | null;
avatar_cid?: string | null;
};
export type ActorListResponse = {
profiles: ActorProfile[];
cursor: string | null;
};
export async function fetchFollowers(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<ActorListResponse> {
return await safeInvoke<ActorListResponse>("fetch_followers", {
did,
cursor,
limit,
});
}
export async function fetchFollowing(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<ActorListResponse> {
return await safeInvoke<ActorListResponse>("fetch_following", {
did,
cursor,
limit,
});
}
/// `app.bsky.feed.like.create` — Tauri command. Builds the /// `app.bsky.feed.like.create` — Tauri command. Builds the
/// flat-shape like body on the Rust side, signs a commit, pushes /// flat-shape like body on the Rust side, signs a commit, pushes
/// to the AppView. Returns the new like's `uri` and `cid`. /// to the AppView. Returns the new like's `uri` and `cid`.
@@ -0,0 +1,368 @@
// Unit tests for the notification / actor-list half of `client.ts`.
//
// Same setup as `client.test.ts`: `@tauri-apps/api/core` is mocked so
// no Tauri shell is needed, and every assertion is about the exact
// command name + argument bag we hand the Rust IPC layer. That
// argument bag is the contract — Tauri's `invoke` serialises camelCase
// JS keys to the snake_case Rust command parameters, so a typo here
// surfaces at runtime as "command not found" or a null argument, not
// at compile time.
//
// Covered:
// * `fetchNotifications` — command name, args, wire→object mapping
// (snake_case fields pass through verbatim), cursor pagination;
// * `notificationCount` — unwraps `{ count }` to a number;
// * `markNotificationsSeen` — sends the `seenAt` watermark;
// * `notificationText` / `notificationIcon` — the kind→copy map,
// including the fallback for an unknown kind;
// * `fetchFollowers` / `fetchFollowing` — distinct commands, same
// shape.
//
// Run with:
// npx vitest run src/lib/api/notifications.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const invokeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
isTauri: () => true,
}));
beforeEach(() => {
invokeMock.mockReset();
});
/// A full-fat wire row, exactly as `NotificationItem` serialises it
/// server-side (snake_case, `read_at` present even when null).
function wireNotification(over: Record<string, unknown> = {}) {
return {
id: 1,
kind: "like",
author_did: "did:plc:alice",
author_handle: "alice.test",
author_display_name: "Alice",
author_avatar_cid: "bafyavatar",
subject_uri: "at://did:plc:me/app.twi.post/3k2",
subject_text: "hello world",
created_at: "2026-09-09T10:00:00Z",
indexed_at: "2026-09-09T10:00:01Z",
read_at: null,
...over,
};
}
describe("fetchNotifications", () => {
it("invokes fetch_notifications and maps the wire rows verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [wireNotification()],
cursor: "cur1",
});
const r = await fetchNotifications("did:plc:me");
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.cursor).toBe("cur1");
expect(r.notifications).toHaveLength(1);
const n = r.notifications[0];
expect(n.id).toBe(1);
expect(n.kind).toBe("like");
expect(n.author_handle).toBe("alice.test");
expect(n.author_display_name).toBe("Alice");
expect(n.author_avatar_cid).toBe("bafyavatar");
expect(n.subject_uri).toBe("at://did:plc:me/app.twi.post/3k2");
expect(n.subject_text).toBe("hello world");
expect(n.indexed_at).toBe("2026-09-09T10:00:01Z");
// `read_at: null` is the unread marker the badge keys off — it
// must survive as null, not become undefined.
expect(n.read_at).toBeNull();
});
it("forwards the cursor and limit for a follow-up page", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({ notifications: [], cursor: null });
const r = await fetchNotifications("did:plc:me", "cur1", 50);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: "cur1",
limit: 50,
});
// A null cursor is the documented end-of-list sentinel.
expect(r.cursor).toBeNull();
});
it("handles a follow row (no subject) without inventing fields", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [
{
id: 7,
kind: "follow",
author_did: "did:plc:bob",
author_handle: "bob.test",
subject_uri: null,
created_at: "2026-09-09T09:00:00Z",
indexed_at: "2026-09-09T09:00:01Z",
read_at: "2026-09-09T09:30:00Z",
},
],
cursor: null,
});
const [n] = (await fetchNotifications("did:plc:me")).notifications;
expect(n.kind).toBe("follow");
expect(n.subject_uri).toBeNull();
expect(n.subject_text).toBeUndefined();
expect(n.author_display_name).toBeUndefined();
expect(n.read_at).toBe("2026-09-09T09:30:00Z");
});
it("propagates a Tauri-side error verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockRejectedValueOnce(
new Error("appview: notifications returned 400: did is required"),
);
await expect(fetchNotifications("")).rejects.toThrow(
/notifications returned 400/,
);
});
});
describe("notificationCount", () => {
it("unwraps the {count} envelope to a plain number", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 12 });
const n = await notificationCount("did:plc:me");
expect(n).toBe(12);
expect(invokeMock).toHaveBeenCalledWith("notification_count", {
did: "did:plc:me",
});
});
it("returns 0 for a fully-read inbox", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 0 });
await expect(notificationCount("did:plc:me")).resolves.toBe(0);
});
});
describe("markNotificationsSeen", () => {
it("sends the seenAt watermark", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 4 });
const r = await markNotificationsSeen(
"did:plc:me",
"2026-09-09T10:00:01Z",
);
expect(r).toEqual({ ok: true, updated: 4 });
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: "2026-09-09T10:00:01Z",
});
});
it("omitting the watermark sends null (mark everything read)", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 0 });
await markNotificationsSeen("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: null,
});
});
});
describe("notificationText / notificationIcon", () => {
it("maps each kind to its German line", async () => {
const { notificationText } = await import("./client");
expect(notificationText("like")).toBe("hat deinen Post geliked");
expect(notificationText("repost")).toBe("hat repostet");
expect(notificationText("follow")).toBe("folgt dir jetzt");
expect(notificationText("reply")).toBe("hat geantwortet");
});
it("falls back rather than rendering an empty line for an unknown kind", async () => {
const { notificationText } = await import("./client");
expect(notificationText("quote")).toBe("hat interagiert");
expect(notificationText("")).toBe("hat interagiert");
});
it("gives every known kind its own icon glyph", async () => {
const { notificationIcon } = await import("./client");
const icons = ["like", "repost", "follow", "reply"].map(notificationIcon);
expect(new Set(icons).size).toBe(4);
expect(icons.every((i) => i.length > 0)).toBe(true);
expect(notificationIcon("quote")).toBe("•");
});
});
describe("fetchFollowers / fetchFollowing", () => {
const wireProfile = {
did: "did:plc:carol",
handle: "carol.test",
display_name: "Carol",
avatar_cid: "bafycarol",
};
it("fetchFollowers hits the followers command", async () => {
const { fetchFollowers } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: null,
});
const r = await fetchFollowers("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_followers", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.profiles[0].handle).toBe("carol.test");
expect(r.profiles[0].display_name).toBe("Carol");
expect(r.cursor).toBeNull();
});
it("fetchFollowing hits the following command and pages", async () => {
const { fetchFollowing } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: "cur2",
});
const r = await fetchFollowing("did:plc:me", "cur1", 10);
expect(invokeMock).toHaveBeenCalledWith("fetch_following", {
did: "did:plc:me",
cursor: "cur1",
limit: 10,
});
expect(r.cursor).toBe("cur2");
});
});
describe("fetchProfileByDid", () => {
it("invokes profile_get_by_did with the DID", async () => {
const { fetchProfileByDid } = await import("./client");
invokeMock.mockResolvedValueOnce({
did: "did:plc:f5abcdefghijklmnop",
handle: "alice.test",
posts: [],
followers: 2,
following: 3,
post_count: 0,
});
const p = await fetchProfileByDid("did:plc:f5abcdefghijklmnop");
expect(invokeMock).toHaveBeenCalledWith("profile_get_by_did", {
did: "did:plc:f5abcdefghijklmnop",
});
expect(p.did).toBe("did:plc:f5abcdefghijklmnop");
expect(p.followers).toBe(2);
});
it("resolves an actor whose only known handle is a truncated-DID placeholder", async () => {
// The dead end this endpoint exists to avoid: the AppView hands
// us `handle: "did:plc:f5a…"` (from `short_did_bare`) for an actor
// it has no profile row or indexed post for. Feeding that back
// into the *handle* lookup matches nothing and the server answers
// 200 with a synthetic empty profile — `did: ""`, zero counts —
// so the UI shows a blank page instead of an error. The DID
// lookup resolves the real row.
const { fetchProfile, fetchProfileByDid } = await import("./client");
const placeholderHandle = "did:plc:f5a…";
const realDid = "did:plc:f5abcdefghijklmnop";
// 1. What the handle path actually gets back today.
invokeMock.mockResolvedValueOnce({
did: "",
handle: placeholderHandle,
posts: [],
followers: 0,
following: 0,
post_count: 0,
});
const viaHandle = await fetchProfile(placeholderHandle);
expect(viaHandle.did).toBe("");
expect(viaHandle.post_count).toBe(0);
// 2. What the DID path gets back — the real profile.
invokeMock.mockResolvedValueOnce({
did: realDid,
handle: "alice.test",
posts: [],
followers: 5,
following: 1,
post_count: 12,
});
const viaDid = await fetchProfileByDid(realDid);
expect(viaDid.did).toBe(realDid);
expect(viaDid.handle).toBe("alice.test");
expect(viaDid.post_count).toBe(12);
// The two calls must be distinct commands — a shared one would
// reintroduce the ambiguity.
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
"profile_get",
"profile_get_by_did",
]);
});
});
describe("fetchThread", () => {
it("invokes fetch_thread with the viewer DID", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
const r = await fetchThread("at://did:plc:me/app.twi.post/3k2", "did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: "did:plc:me",
});
// `post: null` is the "not in our index" sentinel — one field
// check is enough to render "post not found".
expect(r.post).toBeNull();
expect(r.parents).toEqual([]);
expect(r.replies).toEqual([]);
});
it("defaults the viewer DID to null", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
await fetchThread("at://did:plc:me/app.twi.post/3k2");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: null,
});
});
});
@@ -4,18 +4,35 @@
// `$bindable`, use a callback prop to bubble state changes up to // `$bindable`, use a callback prop to bubble state changes up to
// the parent. // the parent.
type View = "home" | "compose" | "profile" | "user" | "search" | "settings"; type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
let { let {
view = "home", view = "home",
on_select, on_select,
unread = 0,
}: { }: {
view?: View; view?: View;
on_select?: (v: View) => void; on_select?: (v: View) => void;
/// Unread-notification count from `/api/notifications/count`.
/// The parent polls it and hands it down; `0` (or anything
/// below) hides the badge entirely rather than rendering a "0".
unread?: number;
} = $props(); } = $props();
/// Badge label. Anything past 99 collapses to "99+" so a long
/// unread backlog can't widen the 88px rail.
const badge = $derived(unread > 99 ? "99+" : String(unread));
const items: Array<{ id: View; label: string; key: string; icon: string }> = [ const items: Array<{ id: View; label: string; key: string; icon: string }> = [
{ id: "home", label: "home", key: "g h", icon: "home" }, { id: "home", label: "home", key: "g h", icon: "home" },
{ id: "notifications", label: "notifs", key: "g n", icon: "bell" },
{ id: "compose", label: "compose", key: "c", icon: "compose" }, { id: "compose", label: "compose", key: "c", icon: "compose" },
{ id: "profile", label: "profile", key: "p", icon: "profile" }, { id: "profile", label: "profile", key: "p", icon: "profile" },
{ id: "search", label: "search", key: "/", icon: "search" }, { id: "search", label: "search", key: "/", icon: "search" },
@@ -37,6 +54,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M3 11l9-8 9 8v9a2 2 0 0 1-2 2h-3v-7H8v7H5a2 2 0 0 1-2-2z"/> <path d="M3 11l9-8 9 8v9a2 2 0 0 1-2 2h-3v-7H8v7H5a2 2 0 0 1-2-2z"/>
</svg> </svg>
{:else if item.icon === "bell"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 15v-4a6 6 0 1 0-12 0v4l-2 3h16z"/><path d="M10 21h4"/>
</svg>
{:else if item.icon === "compose"} {:else if item.icon === "compose"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">&gt;_</text> <text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">&gt;_</text>
@@ -55,6 +76,16 @@
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/> <circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
</svg> </svg>
{/if} {/if}
{#if item.id === "notifications" && unread > 0}
<!-- Live region so a screen reader announces a badge that
appears while the rail is already on screen. -->
<span
class="badge"
data-testid="notif-badge"
aria-live="polite"
aria-label={`${unread} ungelesene Benachrichtigungen`}
>{badge}</span>
{/if}
</span> </span>
<span class="label">{item.label}</span> <span class="label">{item.label}</span>
</button> </button>
@@ -107,6 +138,26 @@
height: 24px; height: 24px;
display: grid; display: grid;
place-items: center; place-items: center;
/* Anchor for the unread badge, which overhangs the glyph's
top-right corner the way a tray badge does. */
position: relative;
}
.badge {
position: absolute;
top: -6px;
right: -10px;
min-width: 16px;
padding: 0 4px;
border-radius: var(--r-pill);
background: var(--orange);
color: var(--bg);
font-family: var(--font-mono);
font-size: 10px;
line-height: 16px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
pointer-events: none;
} }
.icon :global(svg) { .icon :global(svg) {
width: 22px; width: 22px;
@@ -5,6 +5,10 @@
// //
// Pattern: `let view = $state("home")` in the harness, then the // Pattern: `let view = $state("home")` in the harness, then the
// `on_select` callback updates it (same shape as App.svelte). // `on_select` callback updates it (same shape as App.svelte).
//
// The rail order is: home, notifications, compose, profile, search,
// settings. The indices below follow that order — if you reorder the
// `items` array in NavRail.svelte, update them here too.
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mount, unmount, tick } from "svelte"; import { mount, unmount, tick } from "svelte";
@@ -23,16 +27,16 @@ afterEach(() => {
target.remove(); target.remove();
}); });
async function mountHarness() { async function mountHarness(props: { unread?: number } = {}) {
app = mount(NavRailHarness, { target }); app = mount(NavRailHarness, { target, props });
await tick(); await tick();
} }
describe("NavRail (callback-prop pattern)", () => { describe("NavRail (callback-prop pattern)", () => {
it("renders 5 buttons with home active", async () => { it("renders 6 buttons with home active", async () => {
await mountHarness(); await mountHarness();
const btns = target.querySelectorAll("button.rail__btn"); const btns = target.querySelectorAll("button.rail__btn");
expect(btns.length).toBe(5); expect(btns.length).toBe(6);
expect(btns[0].classList.contains("active")).toBe(true); expect(btns[0].classList.contains("active")).toBe(true);
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home"); expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
}); });
@@ -41,25 +45,68 @@ describe("NavRail (callback-prop pattern)", () => {
await mountHarness(); await mountHarness();
const btns = target.querySelectorAll("button.rail__btn"); const btns = target.querySelectorAll("button.rail__btn");
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true })); btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick(); await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("search"); expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("search");
expect(btns[3].classList.contains("active")).toBe(true); expect(btns[4].classList.contains("active")).toBe(true);
expect(btns[0].classList.contains("active")).toBe(false); expect(btns[0].classList.contains("active")).toBe(false);
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
expect(btns[1].classList.contains("active")).toBe(true);
btns[2].dispatchEvent(new MouseEvent("click", { bubbles: true })); btns[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick(); await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile"); expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
expect(btns[2].classList.contains("active")).toBe(true); expect(btns[2].classList.contains("active")).toBe(true);
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true })); btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(btns[3].classList.contains("active")).toBe(true);
btns[5].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick(); await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings"); expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings");
expect(btns[4].classList.contains("active")).toBe(true); expect(btns[5].classList.contains("active")).toBe(true);
});
it("routes to the notifications view", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe(
"notifications",
);
expect(btns[1].classList.contains("active")).toBe(true);
});
});
describe("NavRail unread badge", () => {
it("renders no badge at zero unread", async () => {
await mountHarness({ unread: 0 });
expect(target.querySelector('[data-testid="notif-badge"]')).toBeNull();
});
it("renders the count on the notifications button", async () => {
await mountHarness({ unread: 3 });
const badge = target.querySelector('[data-testid="notif-badge"]');
expect(badge).not.toBeNull();
expect(badge?.textContent?.trim()).toBe("3");
// The badge must sit on the notifications entry, not on some
// other rail button.
const btns = target.querySelectorAll("button.rail__btn");
expect(btns[1].contains(badge!)).toBe(true);
});
it("caps a large backlog at 99+ so the 88px rail can't widen", async () => {
await mountHarness({ unread: 1234 });
const badge = target.querySelector('[data-testid="notif-badge"]');
expect(badge?.textContent?.trim()).toBe("99+");
});
it("keeps 99 unabbreviated (the cap is exclusive)", async () => {
await mountHarness({ unread: 99 });
expect(
target.querySelector('[data-testid="notif-badge"]')?.textContent?.trim(),
).toBe("99");
}); });
}); });
@@ -5,12 +5,26 @@
import NavRail from "./NavRail.svelte"; import NavRail from "./NavRail.svelte";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings"; type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
// `unread` is a prop so the badge test can drive it without going
// through the polling path in App.svelte. Defaults to 0 (no badge),
// which is what every non-badge test expects.
let { unread = 0 }: { unread?: number } = $props();
let view: View = $state("home"); let view: View = $state("home");
</script> </script>
<NavRail <NavRail
{view} {view}
{unread}
on_select={(v) => { on_select={(v) => {
view = v; view = v;
}} }}
@@ -0,0 +1,367 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
import Skeleton from "./Skeleton.svelte";
import {
errorMessage,
fetchNotifications,
markNotificationsSeen,
notificationIcon,
notificationText,
type Notification,
} from "../api/client";
type Props = {
/// Recipient DID — whose notifications to list.
did: string;
/// Opens the thread for a notification's `subject_uri`. Wired to
/// App.svelte's existing `openThread` helper, so a notification
/// and a PostCard's thread button land in the same modal.
on_thread_click?: (uri: string) => void;
/// Navigates to the author's profile. Takes the DID *and* the
/// handle, and the DID is what the navigation resolves by: the
/// AppView hands us a placeholder handle (a truncated DID, see
/// `short_did_bare`) for any author it has neither a profile row
/// nor an indexed post for, and that placeholder resolves to a
/// synthetic empty profile on the way back in. `author_did` is
/// always real, so it's the one field worth navigating on. The
/// handle still travels along for the header label while the
/// profile loads.
on_actor_click?: (did: string, handle: string) => void;
/// Fired once the first page is marked read, so the parent can
/// zero the NavRail badge without waiting for the next poll.
on_seen?: () => void;
};
let { did, on_thread_click, on_actor_click, on_seen }: Props = $props();
let items: Notification[] = $state([]);
let cursor: string | null = $state(null);
let loading: boolean = $state(false);
let error: string | null = $state(null);
/// Guards `load()` against a re-entrant `$effect` run: the effect
/// tracks `did`, but `load` writes `$state` the effect would
/// otherwise see as a self-write (the same depth-guard problem
/// ProfileView documents around `untrack`).
let loadedDid: string | null = null;
async function load() {
loading = true;
error = null;
try {
const r = await fetchNotifications(did, null, 30);
items = r.notifications;
cursor = r.cursor;
// Watermark the read marker at the newest row we actually
// rendered — never `null` — so a notification that lands while
// the user is scrolling isn't silently marked as seen.
const top = r.notifications[0]?.indexed_at ?? null;
if (top) {
try {
await markNotificationsSeen(did, top);
// Reflect it locally too: the rows are already on screen,
// and re-fetching just to flip `read_at` would be a wasted
// round trip.
items = items.map((n) =>
n.read_at ? n : { ...n, read_at: top },
);
on_seen?.();
} catch (e) {
// A failed ack is not a failed load — the list is usable,
// the badge just stays until the next attempt.
console.warn("mark_notifications_seen failed", e);
}
}
} catch (e) {
error = errorMessage(e);
} finally {
loading = false;
}
}
async function loadMore() {
if (!cursor || loading) return;
loading = true;
try {
const r = await fetchNotifications(did, cursor, 30);
// De-dupe on `id`: the keyset cursor is stable, but a row
// indexed between two page fetches can otherwise shift a row
// across the page boundary.
const seen = new Set(items.map((n) => n.id));
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
cursor = r.cursor;
} catch (e) {
error = errorMessage(e);
} finally {
loading = false;
}
}
$effect(() => {
if (!did || loadedDid === did) return;
loadedDid = did;
void load();
});
/// Display label for the author — the profile's display name when
/// the AppView has one cached, else the handle (which the server
/// guarantees is non-empty, falling back to a truncated DID).
function authorName(n: Notification): string {
return n.author_display_name || n.author_handle;
}
/// Same relative-time rendering as PostCard's `timeAgo`, on
/// `indexed_at` (the list's sort key) rather than `created_at`.
function timeAgo(iso: string): string {
const timestamp = new Date(iso).getTime();
if (!Number.isFinite(timestamp)) return iso;
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
function openSubject(n: Notification) {
if (!n.subject_uri) return;
on_thread_click?.(n.subject_uri);
}
</script>
<section class="notifs">
{#if error}
<div class="notifs__err">err: {error}</div>
{/if}
{#if loading && items.length === 0}
<Skeleton rows={3} />
{:else if items.length === 0}
<div class="notifs__empty">// keine Benachrichtigungen.</div>
{:else}
<ul class="notifs__list">
{#each items as n (n.id)}
<li class="notifs__item" class:notifs__item--unread={!n.read_at}>
<!--
The whole row is the click target when there's a subject
to open; for a "follow" (no subject) the row is inert and
only the handle button navigates. `type="button"` +
`disabled` keeps the keyboard order honest instead of
faking it with a div.
-->
<button
class="notifs__row"
type="button"
disabled={!n.subject_uri}
aria-label={`${authorName(n)} ${notificationText(n.kind)}`}
onclick={() => openSubject(n)}
>
<span class="notifs__icon" data-kind={n.kind} aria-hidden="true">
{notificationIcon(n.kind)}
</span>
<span class="notifs__avatar">
<Avatar
did={n.author_did}
cid={n.author_avatar_cid ?? null}
name={authorName(n)}
size={32}
/>
</span>
<span class="notifs__body">
<span class="notifs__line">
<span class="notifs__name">{authorName(n)}</span>
<span class="notifs__text">{notificationText(n.kind)}</span>
<span class="notifs__age">{timeAgo(n.indexed_at)}</span>
</span>
{#if n.subject_text}
<span class="notifs__subject">{n.subject_text}</span>
{/if}
</span>
</button>
<button
class="notifs__handle"
type="button"
title={n.author_did}
onclick={() => on_actor_click?.(n.author_did, n.author_handle)}
>@{n.author_handle}</button>
</li>
{/each}
</ul>
{#if cursor}
<div class="notifs__loadmore">
<button
class="notifs__btn"
type="button"
disabled={loading}
onclick={() => void loadMore()}
>
{loading ? "loading…" : "load more"}
</button>
</div>
{/if}
{/if}
</section>
<style>
.notifs {
padding: 0 0 var(--s-6);
}
.notifs__err {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: var(--s-2) var(--s-3);
margin: 0 var(--s-5) var(--s-3);
border-left: 3px solid var(--red);
background: var(--orange-3);
color: var(--red);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.notifs__empty {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
padding: var(--s-4) var(--s-5);
font-style: italic;
}
.notifs__list {
list-style: none;
margin: 0;
padding: 0;
}
/* One row per notification: the clickable body plus the handle
button, which is a separate target so "open the thread" and "go
to the author" don't fight over the same click. */
.notifs__item {
display: flex;
align-items: center;
gap: var(--s-2);
padding: 0 var(--s-4) 0 0;
border-bottom: 1px solid var(--line);
}
/* Unread rows get the orange left rail + tint the rest of the app
uses for "needs attention" (same tokens as the settings hover
state), so the read/unread split is visible without a legend. */
.notifs__item--unread {
background: var(--orange-8);
box-shadow: inset 3px 0 0 var(--orange);
}
.notifs__row {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
color: var(--text);
font-family: var(--font-mono);
transition: background-color var(--dur) var(--ease);
}
.notifs__row:hover:not(:disabled) {
background: var(--orange-3);
}
.notifs__row:disabled {
cursor: default;
}
.notifs__icon {
flex: 0 0 auto;
width: 1.25rem;
text-align: center;
font-size: var(--fs-100);
color: var(--text-dim);
}
/* Per-kind accent: likes red, reposts green, follows/replies cyan
— all straight from the token palette, no new hex values. */
.notifs__icon[data-kind="like"] { color: var(--red); }
.notifs__icon[data-kind="repost"] { color: var(--green); }
.notifs__icon[data-kind="follow"] { color: var(--cyan); }
.notifs__icon[data-kind="reply"] { color: var(--orange); }
.notifs__avatar {
flex: 0 0 auto;
line-height: 0;
}
.notifs__body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.notifs__line {
display: flex;
align-items: baseline;
gap: var(--s-2);
min-width: 0;
}
.notifs__name {
color: var(--text);
font-weight: 700;
font-size: var(--fs-50);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notifs__text {
color: var(--text-dim);
font-size: var(--fs-50);
white-space: nowrap;
}
.notifs__age {
color: var(--text-dim);
font-size: var(--fs-50);
font-variant-numeric: tabular-nums;
margin-left: auto;
flex: 0 0 auto;
}
/* Subject preview — one line, clipped. The full text is a click
away in the thread, so wrapping here would only push rows apart. */
.notifs__subject {
color: var(--text-dim);
font-size: var(--fs-50);
font-family: var(--font-sans);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notifs__handle {
flex: 0 0 auto;
background: transparent;
border: 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
cursor: pointer;
padding: var(--s-1) var(--s-2);
border-radius: var(--r-sm);
transition: color var(--dur) var(--ease);
}
.notifs__handle:hover {
color: var(--orange);
}
.notifs__loadmore {
display: flex;
justify-content: center;
padding: var(--s-4) var(--s-5);
}
.notifs__btn {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.notifs__btn:hover:not(:disabled) {
color: var(--orange);
border-color: var(--orange);
}
.notifs__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,261 @@
// Regression guard for the actor-navigation path out of the
// notifications list.
//
// The AppView guarantees `author_handle` is non-empty, but it does NOT
// guarantee it's a real handle: for an author it has neither a
// `profiles` row nor an indexed post for, it synthesises a placeholder
// from the DID (`short_did_bare` in the AppView's `routes.rs`) — e.g.
// `"did:plc:f5…"`, a truncated DID with an ellipsis. Navigating with
// that string resolves to nothing, and `/api/profile/<handle>` answers
// with a *synthetic empty profile* rather than an error, so the click
// silently dead-ends on a blank page.
//
// The fix is to hand the navigation callback the real `author_did`
// alongside the handle. These tests pin that: the callback must
// receive the DID from the DTO, for both a real-handle author and a
// placeholder-handle one.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mount, unmount, tick } from "svelte";
const fetchNotificationsMock = vi.fn();
const markNotificationsSeenMock = vi.fn();
vi.mock("../api/client", async () => {
// Keep the real `notificationText` / `notificationIcon` — the row
// copy is part of what we're rendering — and stub only the two
// functions that would otherwise need a Tauri runtime.
const actual =
await vi.importActual<typeof import("../api/client")>("../api/client");
return {
...actual,
fetchNotifications: (...args: unknown[]) => fetchNotificationsMock(...args),
markNotificationsSeen: (...args: unknown[]) =>
markNotificationsSeenMock(...args),
// Avatar resolves blobs through this; every fixture below has a
// null avatar CID so it never fires, but stub it so a stray call
// can't reach for the Tauri shell.
fetchBlob: () => Promise.reject(new Error("no blob in test")),
};
});
import NotificationsView from "./NotificationsView.svelte";
let target: HTMLDivElement;
let app: ReturnType<typeof mount> | null = null;
beforeEach(() => {
target = document.createElement("div");
document.body.appendChild(target);
fetchNotificationsMock.mockReset();
markNotificationsSeenMock.mockReset();
markNotificationsSeenMock.mockResolvedValue({ ok: true, updated: 1 });
});
afterEach(() => {
if (app) unmount(app);
app = null;
target.remove();
});
/// Let the component's load effect and its awaited promises settle.
/// The mocked fetch resolves immediately, so a handful of microtask
/// turns plus a Svelte flush is enough.
async function flush(turns = 6) {
for (let i = 0; i < turns; i++) {
await Promise.resolve();
await tick();
}
}
function row(over: Record<string, unknown> = {}) {
return {
id: 1,
kind: "like",
author_did: "did:plc:realauthor",
author_handle: "alice.test",
author_avatar_cid: null,
subject_uri: "at://did:plc:me/app.twi.post/3k2",
subject_text: "hello",
created_at: "2026-09-09T10:00:00Z",
indexed_at: "2026-09-09T10:00:01Z",
read_at: null,
...over,
};
}
describe("NotificationsView actor navigation", () => {
it("hands the callback the author DID, not just the handle", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row()],
cursor: null,
});
const onActorClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_actor_click: onActorClick },
});
await flush();
const handleBtn = target.querySelector("button.notifs__handle");
expect(handleBtn).not.toBeNull();
handleBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onActorClick).toHaveBeenCalledTimes(1);
expect(onActorClick).toHaveBeenCalledWith(
"did:plc:realauthor",
"alice.test",
);
});
it("resolves an author whose handle is only a truncated-DID placeholder", async () => {
// Exactly what the AppView returns for an author it has never
// seen post and has no profile row for: `handle` is
// `short_did_bare(author_did)`, which matches nothing on the way
// back in. The DID beside it is real.
fetchNotificationsMock.mockResolvedValue({
notifications: [
row({
id: 2,
kind: "follow",
author_did: "did:plc:f5abcdefghijklmnop",
author_handle: "did:plc:f5a…",
subject_uri: null,
subject_text: undefined,
}),
],
cursor: null,
});
const onActorClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_actor_click: onActorClick },
});
await flush();
target
.querySelector("button.notifs__handle")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
const [didArg, handleArg] = onActorClick.mock.calls[0];
// The DID must be the full, real one — never the truncated
// display string.
expect(didArg).toBe("did:plc:f5abcdefghijklmnop");
expect(didArg).not.toContain("…");
// The placeholder still travels as the label, which is fine — it
// is not what the lookup keys off.
expect(handleArg).toBe("did:plc:f5a…");
});
it("renders the kind copy and marks the first page seen at the top row", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row({ kind: "follow", subject_uri: null })],
cursor: null,
});
const onSeen = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_seen: onSeen },
});
await flush();
expect(target.textContent).toContain("folgt dir jetzt");
// The read watermark is the newest rendered row's `indexed_at` —
// never null, so a notification landing mid-scroll survives.
expect(markNotificationsSeenMock).toHaveBeenCalledWith(
"did:plc:me",
"2026-09-09T10:00:01Z",
);
expect(onSeen).toHaveBeenCalledTimes(1);
});
it("a follow row has no thread target, so its row button is inert", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row({ kind: "follow", subject_uri: null })],
cursor: null,
});
const onThreadClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_thread_click: onThreadClick },
});
await flush();
const rowBtn = target.querySelector<HTMLButtonElement>("button.notifs__row");
expect(rowBtn?.disabled).toBe(true);
rowBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onThreadClick).not.toHaveBeenCalled();
});
it("shows actionable copy when the AppView rejects the session", async () => {
// Since `/api/notifications` grew an auth guard, this is what a
// rejected token looks like by the time it reaches the view: the
// AppView's JSON body, wrapped by `appview_client.rs`'s
// `status_error()` and stringified across the Tauri IPC boundary.
// `safeInvoke` has already spent its one refresh attempt getting
// here, so the only thing left to tell the user is "log in again" —
// rendering the raw wire string would be accurate and useless.
fetchNotificationsMock.mockRejectedValue(
'appview: notifications returned 401 Unauthorized: ' +
'{"error":"TokenInvalid","message":"ExpiredSignature"}',
);
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me" },
});
await flush();
expect(target.textContent).toContain("bitte neu anmelden");
expect(target.textContent).not.toContain("TokenInvalid");
expect(target.textContent).not.toContain("401");
// A failed load must not leave the spinner up or ack a page it
// never rendered.
expect(markNotificationsSeenMock).not.toHaveBeenCalled();
});
it("still shows a server error verbatim — there's nothing better to say", async () => {
fetchNotificationsMock.mockRejectedValue(
"appview: notifications returned 500 Internal Server Error: db down",
);
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me" },
});
await flush();
expect(target.textContent).toContain("500");
expect(target.textContent).toContain("db down");
});
it("opens the thread for a row that has a subject", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row()],
cursor: null,
});
const onThreadClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_thread_click: onThreadClick },
});
await flush();
target
.querySelector("button.notifs__row")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onThreadClick).toHaveBeenCalledWith(
"at://did:plc:me/app.twi.post/3k2",
);
});
});
@@ -9,22 +9,49 @@
getAppviewUrl, getAppviewUrl,
followUser, followUser,
unfollowUser, unfollowUser,
fetchFollowers,
fetchFollowing,
fetchProfileByDid,
showInfo, showInfo,
showError, showError,
type ActorProfile,
} from "../api/client"; } from "../api/client";
import { localStorageKey } from "../utils/localstorage"; import { localStorageKey } from "../utils/localstorage";
import { onDestroy, onMount, untrack } from "svelte"; import { onDestroy, untrack } from "svelte";
type Props = { type Props = {
handle: string; handle: string;
/// DID of the profile to show, when the caller already knows it.
/// Takes precedence over `handle`: the AppView synthesises a
/// placeholder handle (a truncated DID — `short_did_bare`) for
/// actors it has neither a `profiles` row nor an indexed post for,
/// and feeding that back into the handle lookup resolves to a
/// synthetic empty profile. Navigation out of a notification or a
/// follower list therefore passes the DID; `handle` is still
/// supplied so the header has a label while the fetch is in
/// flight. `null`/absent keeps the handle lookup (PostCard header,
/// search, the current user's own profile).
did?: string | null;
on_thread_click?: (uri: string) => void; on_thread_click?: (uri: string) => void;
/// DID of the authenticated user. When this matches the /// DID of the authenticated user. When this matches the
/// profile's DID, the "edit profile" button is shown; the /// profile's DID, the "edit profile" button is shown; the
/// /user-profile/<handle> route is then the user's own /// /user-profile/<handle> route is then the user's own
/// profile (and the avatar / bio are editable). /// profile (and the avatar / bio are editable).
current_user_did?: string | null; current_user_did?: string | null;
/// Navigate to another profile. Receives `(did, handle)` and the
/// caller resolves by DID — see the `did` prop above for why the
/// handle alone is not a reliable key. Used by the follower /
/// following list rows and by the PostCards in the feed (which
/// carry a real `post.did`).
on_actor_click?: (did: string, handle: string) => void;
}; };
let { handle, on_thread_click, current_user_did }: Props = $props(); let {
handle,
did = null,
on_thread_click,
current_user_did,
on_actor_click,
}: Props = $props();
type State = type State =
| { kind: "loading" } | { kind: "loading" }
@@ -91,6 +118,15 @@
async function load() { async function load() {
viewModel = { kind: "loading" }; viewModel = { kind: "loading" };
try { try {
// DID lookup wins when the caller supplied one — see the `did`
// prop. `/api/profile?did=…` is an exact match on the `posts` /
// `profiles` DID column, so it works for an actor whose only
// known "handle" is the server's truncated-DID placeholder.
if (did) {
const data = (await fetchProfileByDid(did)) as AppViewProfile;
viewModel = { kind: "ready", data };
return;
}
// Absolute URL because the Tauri webview's origin is the Vite // Absolute URL because the Tauri webview's origin is the Vite
// dev server (port 1430), not the AppView (port 2584) — a // dev server (port 1430), not the AppView (port 2584) — a
// relative `/api/profile/…` would resolve against Vite, hit a // relative `/api/profile/…` would resolve against Vite, hit a
@@ -145,8 +181,25 @@
}; };
}); });
onMount(() => { /// Identity of the profile currently loaded, as an opaque key. A
void load(); /// plain (non-`$state`) variable on purpose: the effect below reads
/// it, and making it reactive would turn "remember what we loaded"
/// into a self-write that trips Svelte 5's depth guard — the same
/// pattern the banner effect documents around `untrack`.
let loadedKey: string | null = null;
// Reload whenever the profile *identity* changes, not just on
// mount. App.svelte keeps this component mounted across a
// profile→profile navigation (the view stays `"user"`, only the
// props change), so an `onMount`-only load would leave the previous
// user's posts and counts on screen under the new name — which is
// exactly the path a follower-list or notification click takes.
$effect(() => {
const key = did ? `did:${did}` : `handle:${handle}`;
if (!did && !handle) return;
if (loadedKey === key) return;
loadedKey = key;
untrack(() => void load());
}); });
onDestroy(() => { onDestroy(() => {
@@ -299,6 +352,93 @@
type Tab = "posts" | "replies" | "likes"; type Tab = "posts" | "replies" | "likes";
let activeTab: Tab = $state("posts"); let activeTab: Tab = $state("posts");
// ─── follower / following list ──────────────────────────────────
//
// Clicking a count opens an inline list under the counts row rather
// than navigating away — the user came here for this profile, and a
// separate route would cost a second profile fetch on the way back.
// Clicking the same count again closes it (the counts double as the
// toggle, the way a disclosure does).
type ActorListKind = "followers" | "following";
let actorKind: ActorListKind | null = $state(null);
let actors: ActorProfile[] = $state([]);
let actorCursor: string | null = $state(null);
let actorLoading: boolean = $state(false);
let actorError: string | null = $state(null);
async function toggleActorList(kind: ActorListKind) {
if (actorKind === kind) {
closeActorList();
return;
}
if (viewModel.kind !== "ready") return;
const did = viewModel.data.did;
actorKind = kind;
actors = [];
actorCursor = null;
actorError = null;
actorLoading = true;
try {
const r =
kind === "followers"
? await fetchFollowers(did, null, 30)
: await fetchFollowing(did, null, 30);
// Guard against a fast double-click on the other count: only
// commit if we're still the list the user asked for.
if (actorKind !== kind) return;
actors = r.profiles;
actorCursor = r.cursor;
} catch (e) {
if (actorKind === kind) actorError = String(e);
} finally {
if (actorKind === kind) actorLoading = false;
}
}
async function loadMoreActors() {
if (viewModel.kind !== "ready") return;
const kind = actorKind;
if (!kind || !actorCursor || actorLoading) return;
const did = viewModel.data.did;
actorLoading = true;
try {
const r =
kind === "followers"
? await fetchFollowers(did, actorCursor, 30)
: await fetchFollowing(did, actorCursor, 30);
if (actorKind !== kind) return;
// De-dupe on DID — a follow edge indexed between two page
// fetches can otherwise repeat a row across the boundary.
const seen = new Set(actors.map((a) => a.did));
actors = [...actors, ...r.profiles.filter((a) => !seen.has(a.did))];
actorCursor = r.cursor;
} catch (e) {
if (actorKind === kind) actorError = String(e);
} finally {
if (actorKind === kind) actorLoading = false;
}
}
function closeActorList() {
actorKind = null;
actors = [];
actorCursor = null;
actorError = null;
actorLoading = false;
}
// Switching to another profile must not leave the previous user's
// follower list on screen. Keyed on the same `(did, handle)`
// identity the load effect uses, so the two can't disagree about
// when "the profile changed".
let actorListKey: string | null = null;
$effect(() => {
const key = did ? `did:${did}` : `handle:${handle}`;
if (actorListKey === key) return;
actorListKey = key;
untrack(() => closeActorList());
});
</script> </script>
<section class="profile"> <section class="profile">
@@ -426,17 +566,105 @@
<dt>posts</dt> <dt>posts</dt>
<dd>{viewModel.data.post_count}</dd> <dd>{viewModel.data.post_count}</dd>
</div> </div>
<!--
The follower / following counts are buttons: they open the
matching actor list inline (and close it on a second click).
The post count stays inert — there's no endpoint behind it
that the feed below doesn't already show.
The button lives *inside* the `<dd>` rather than wrapping the
`dt`/`dd` pair, because a `<dl>` may only contain `dt`/`dd`
(via an optional `div`) — a button in between would be invalid
markup. `aria-label` puts the term back on the control so a
screen reader still hears "followers", not a bare number.
-->
<div> <div>
<dt>followers</dt> <dt>followers</dt>
<dd>{viewModel.data.followers}</dd> <dd>
<button
class="profile__count-btn"
class:profile__count-btn--open={actorKind === "followers"}
type="button"
aria-expanded={actorKind === "followers"}
aria-label={`${viewModel.data.followers} followers anzeigen`}
onclick={() => void toggleActorList("followers")}
>{viewModel.data.followers}</button>
</dd>
</div> </div>
<div> <div>
<dt>following</dt> <dt>following</dt>
<dd>{viewModel.data.following}</dd> <dd>
<button
class="profile__count-btn"
class:profile__count-btn--open={actorKind === "following"}
type="button"
aria-expanded={actorKind === "following"}
aria-label={`${viewModel.data.following} following anzeigen`}
onclick={() => void toggleActorList("following")}
>{viewModel.data.following}</button>
</dd>
</div> </div>
</dl> </dl>
{/if} {/if}
<!-- ─── follower / following list ─────────────────────────────── -->
{#if actorKind}
<div class="actors">
<header class="actors__head">
<span class="actors__title">// {actorKind}</span>
<button
class="actors__close"
type="button"
onclick={closeActorList}
>close</button>
</header>
{#if actorError}
<div class="actors__err">err: {actorError}</div>
{/if}
{#if actorLoading && actors.length === 0}
<div class="actors__empty">// loading…</div>
{:else if actors.length === 0}
<div class="actors__empty">// niemand hier.</div>
{:else}
<ul class="actors__list">
{#each actors as a (a.did)}
<li>
<button
class="actors__row"
type="button"
title={a.did}
onclick={() => on_actor_click?.(a.did, a.handle)}
>
<Avatar
did={a.did}
cid={a.avatar_cid ?? null}
name={a.display_name ?? a.handle}
size={32}
/>
<span class="actors__names">
<span class="actors__name">{a.display_name ?? a.handle}</span>
<span class="actors__handle">@{a.handle}</span>
</span>
</button>
</li>
{/each}
</ul>
{#if actorCursor}
<div class="actors__loadmore">
<button
class="actors__more"
type="button"
disabled={actorLoading}
onclick={() => void loadMoreActors()}
>
{actorLoading ? "loading…" : "load more"}
</button>
</div>
{/if}
{/if}
</div>
{/if}
<!-- ─── tabs ──────────────────────────────────────────────────── --> <!-- ─── tabs ──────────────────────────────────────────────────── -->
<nav class="profile__tabs" aria-label="Profile sections"> <nav class="profile__tabs" aria-label="Profile sections">
<button <button
@@ -465,7 +693,17 @@
<div class="profile__feed"> <div class="profile__feed">
{#if viewModel.kind === "ready"} {#if viewModel.kind === "ready"}
{#each viewModel.data.posts as p (p.uri)} {#each viewModel.data.posts as p (p.uri)}
<PostCard post={p} on_thread_click={on_thread_click} /> <!--
The feed's PostCards carry a real `post.did`, so we route
their header clicks through the same DID-first path as the
actor list rather than through the (possibly decorated)
`post.handle`.
-->
<PostCard
post={p}
on_thread_click={on_thread_click}
on_handle_click={(h) => on_actor_click?.(p.did, h)}
/>
{/each} {/each}
{#if viewModel.data.posts.length === 0} {#if viewModel.data.posts.length === 0}
<div class="profile__empty">// no posts yet.</div> <div class="profile__empty">// no posts yet.</div>
@@ -705,6 +943,157 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* The count button inherits the `dd` typography so the clickable
followers/following numbers read identically to the inert post
count — only the hover/open colour marks them as interactive. */
.profile__count-btn {
background: none;
border: 0;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.profile__count-btn:hover {
color: var(--orange);
}
.profile__count-btn--open {
color: var(--orange);
border-bottom-color: var(--orange);
}
/* ─── follower / following list ─────────────────────────── */
.actors {
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: var(--bg-elev);
}
.actors__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
border-bottom: 1px solid var(--line);
}
.actors__title {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--orange);
letter-spacing: var(--tracking-label);
font-weight: 700;
}
.actors__close {
background: transparent;
border: 1px solid var(--line-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.2rem 0.6rem;
border-radius: var(--r-sm);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.actors__close:hover {
color: var(--orange);
border-color: var(--orange);
}
.actors__err {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--red);
padding: var(--s-2) var(--s-4);
}
.actors__empty {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
font-style: italic;
padding: var(--s-3) var(--s-4);
}
.actors__list {
list-style: none;
margin: 0;
padding: 0;
/* Cap the inline list so it never pushes the feed off screen —
"load more" keeps the rest reachable. */
max-height: 320px;
overflow-y: auto;
}
.actors__row {
width: 100%;
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
text-align: left;
cursor: pointer;
color: var(--text);
transition: background-color var(--dur) var(--ease);
}
.actors__row:hover {
background: var(--orange-8);
}
.actors__names {
display: flex;
flex-direction: column;
min-width: 0;
}
.actors__name {
font-family: var(--font-mono);
font-size: var(--fs-50);
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actors__handle {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actors__loadmore {
display: flex;
justify-content: center;
padding: var(--s-3);
}
.actors__more {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.actors__more:hover:not(:disabled) {
color: var(--orange);
border-color: var(--orange);
}
.actors__more:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ─── tabs ──────────────────────────────────────────────── */ /* ─── tabs ──────────────────────────────────────────────── */
.profile__tabs { .profile__tabs {
display: flex; display: flex;
+253
View File
@@ -0,0 +1,253 @@
# 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 │
│ /.well-known/did.json ──────────┼───┼─▶ Schlüssel für 🔒 │
│ │ │ 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
```
🔒 = 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
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).
+583
View File
@@ -0,0 +1,583 @@
# 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 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
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. 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.
## 8. 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 |
## 9. 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.
## 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.
* `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.
+311
View File
@@ -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.
+113
View File
@@ -0,0 +1,113 @@
-- AppView database schema 0008: notifications + follow-list pagination.
--
-- Why
--
-- Phase 5 shipped the read API (timeline / profile / search / post) but
-- the client had no way to learn that *someone else* interacted with
-- the user: a like, a repost, a follow or a reply never produced a
-- durable record. The Tauri client's tray/notification path (Phase 7)
-- therefore had nothing to poll. This migration adds the table the
-- Jetstream indexer writes into and the read API serves from.
--
-- Table shape
-- id BIGSERIAL — monotonic tiebreaker for the keyset
-- cursor. The API's opaque cursor is
-- `(indexed_at, id)`, mirroring the
-- `(indexed_at, uri)` pair used by /api/timeline/home,
-- so the same `routes::cursor` encoder is reused.
-- recipient_did the user who should SEE the notification (the post
-- author for like/repost/reply, the followed user for
-- follow).
-- author_did the user who CAUSED it (the liker / reposter /
-- follower / replier).
-- kind 'like' | 'repost' | 'follow' | 'reply'. Enforced by
-- a CHECK rather than a Postgres ENUM so adding a
-- variant later is an ALTER ... DROP/ADD CONSTRAINT
-- instead of a type migration that locks every
-- dependent object.
-- subject_uri the post the notification is *about*. NULL for
-- 'follow' (there is no post). For 'like'/'repost'
-- it's the liked/reposted post (the recipient's own
-- post); for 'reply' it's the REPLY itself, because
-- the interesting text to show in the notification
-- list is what the replier wrote, not what the
-- recipient already knows they posted.
-- created_at the interaction's own `createdAt` from the AT record.
-- indexed_at when WE saw it. This is what the list is ordered by,
-- for the same reason the timeline orders by
-- `posts.indexed_at`: a client-supplied `created_at`
-- can be arbitrarily far in the past or future and
-- would break keyset pagination.
-- read_at NULL = unread. Set in bulk by
-- `POST /api/notifications/seen`.
--
-- Deliberately NO `CHECK (recipient_did <> author_did)`
-- ------------------------------------------------------
-- Self-interactions must not produce notifications, and the indexer
-- enforces that in two places (a pure `should_notify` guard in Rust
-- plus a `WHERE $1 <> $2` in the INSERT ... SELECT). A CHECK would
-- turn a future slip into a constraint violation that aborts the
-- surrounding like/repost transaction — i.e. it would lose the *like*
-- because of a notification bug. Filtering is strictly better than
-- failing here.
--
-- Idempotency
-- -----------
-- `notifications_dedupe_idx` is the unique constraint the indexer's
-- `ON CONFLICT ... DO NOTHING` infers. `subject_uri` is nullable and
-- NULLs never collide in a plain unique index, so the index is on
-- `COALESCE(subject_uri, '')` — that makes the two follow rows
-- (subject_uri IS NULL) for the same (recipient, author) pair collide
-- as intended.
--
-- Consequence worth knowing: unlike-then-relike (or unfollow-then-
-- refollow) does NOT produce a second notification, because the tuple
-- is identical. That is the desired behaviour — it makes notification
-- spam via toggling impossible — but it does mean a notification is
-- "once per (recipient, author, kind, subject)" for all time.
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
recipient_did TEXT NOT NULL,
author_did TEXT NOT NULL,
kind TEXT NOT NULL
CHECK (kind IN ('like', 'repost', 'follow', 'reply')),
subject_uri TEXT,
created_at TIMESTAMPTZ NOT NULL,
indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ
);
-- Primary read path: `WHERE recipient_did = $1 ORDER BY indexed_at DESC,
-- id DESC`. The trailing `id DESC` makes the index cover the keyset
-- predicate `(indexed_at, id) < ($2, $3)` end-to-end, so a page fetch
-- never sorts.
CREATE INDEX notifications_recipient_indexed_at_idx
ON notifications (recipient_did, indexed_at DESC, id DESC);
-- Dedupe / ON CONFLICT target. See the "Idempotency" note above.
CREATE UNIQUE INDEX notifications_dedupe_idx
ON notifications (recipient_did, author_did, kind, COALESCE(subject_uri, ''));
-- `GET /api/notifications/count` is a hot poll from the client's tray
-- badge, so the unread slice gets its own partial index. It stays tiny
-- because rows leave it as soon as they're marked seen.
CREATE INDEX notifications_unread_idx
ON notifications (recipient_did)
WHERE read_at IS NULL;
-- =====================================================
-- follows: pagination indexes for the follower/following lists
-- =====================================================
--
-- `GET /api/followers` and `GET /api/following` page with the same
-- keyset scheme as the timeline: `(indexed_at, <other side's did>)`.
-- The pre-existing indexes cover only the equality half (the PK covers
-- `follower_did`, `follows_subject_idx` covers `subject_did`), which
-- leaves Postgres sorting the whole follower set on every page. These
-- two make both directions index-ordered.
CREATE INDEX IF NOT EXISTS follows_subject_indexed_at_idx
ON follows (subject_did, indexed_at DESC, follower_did DESC);
CREATE INDEX IF NOT EXISTS follows_follower_indexed_at_idx
ON follows (follower_did, indexed_at DESC, subject_did DESC);
@@ -0,0 +1,58 @@
-- AppView database schema 0009: indexes for handle → DID lookups.
--
-- `/api/profile/<handle>` took 9.5 s on a 3.3 M-row `posts` table
-- (measured against the dev instance). Both halves of `resolve_profile`
-- were unindexed:
--
-- 1. SELECT did FROM profiles WHERE LOWER(handle) = LOWER($1)
-- 2. SELECT did FROM posts WHERE handle = $1
-- ORDER BY indexed_at DESC LIMIT 1 -- the fallback
--
-- Step 2 was a parallel sequential scan over every post ever indexed
-- (`Rows Removed by Filter: 1101310` per worker), and it runs on every
-- profile view in the client.
--
-- On `profiles`: migration 0007 dropped exactly this index, reasoning
-- that "every caller derives a DID first (via posts.handle or the
-- handle-sync worker) and then queries profiles by PK". That stopped
-- being true when `resolve_profile` learned to prefer the profiles
-- cache — it now asks `profiles` by handle *first*, precisely the
-- lookup 0007 removed the support for. Re-added, matching the
-- expression in the query (`LOWER(handle)`) so the planner can use it.
CREATE INDEX IF NOT EXISTS profiles_handle_lower_idx
ON profiles (LOWER(handle));
-- On `posts`: `(handle, indexed_at DESC)` covers filter *and* sort, so
-- the LIMIT 1 becomes an index scan that stops at the first row.
--
-- Partial on `handle <> ''`: empty handles are the un-backfilled
-- majority on a firehose-fed instance and are never looked up by this
-- path (the handle-sync worker queries them through its own predicate),
-- so excluding them keeps the index small on the largest table we have.
CREATE INDEX IF NOT EXISTS posts_handle_indexed_at_idx
ON posts (handle, indexed_at DESC)
WHERE handle <> '';
-- =====================================================
-- posts: the cold-start global feed
-- =====================================================
--
-- `/api/timeline/home` falls back to the global recent feed for users
-- without a follow graph — every new account's first screen. It took
-- 7.4 s (parallel seq scan + top-N sort over 3.3 M rows) and timed out
-- the integration tests' 5 s client.
--
-- `posts_collection_indexed_at_uri_idx (collection, indexed_at DESC,
-- uri DESC)` cannot serve it: the query filters
-- `collection IN ('app.twi.post','app.bsky.feed.post')`, and with two
-- leading values the index no longer yields rows in `indexed_at` order,
-- so the planner falls back to scanning and sorting.
--
-- A partial index over exactly that predicate moves the collection
-- filter into the index definition, which leaves `(indexed_at DESC,
-- uri DESC)` as the sort key — the LIMIT then stops after the first
-- page. Same shape as the existing `posts_did_indexed_at_uri_idx`,
-- which is partial on the same two collections.
CREATE INDEX IF NOT EXISTS posts_feed_indexed_at_uri_idx
ON posts (indexed_at DESC, uri DESC)
WHERE collection IN ('app.twi.post', 'app.bsky.feed.post');