Compare commits
13
Commits
main
...
f7b78fd5db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b78fd5db | ||
|
|
9ee717bbc7 | ||
|
|
ac18ff7a16 | ||
|
|
73da8f0140 | ||
|
|
a2a371b7d9 | ||
|
|
786a892658 | ||
|
|
ec8fe187fe | ||
|
|
31880e1005 | ||
|
|
bce4c7862f | ||
|
|
465a88e4e5 | ||
|
|
c4ca218d97 | ||
|
|
9d009bfcba | ||
|
|
3c6f4dd67c |
+60
-5
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+1069
-145
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¶ms),
|
||||||
|
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");
|
||||||
|
|||||||
@@ -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(¶ms)
|
||||||
|
.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}");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(¶ms),
|
||||||
|
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"]
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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">>_</text>
|
<text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">>_</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;
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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');
|
||||||
Reference in New Issue
Block a user