Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b78fd5db | ||
|
|
9ee717bbc7 | ||
|
|
ac18ff7a16 | ||
|
|
73da8f0140 | ||
|
|
a2a371b7d9 | ||
|
|
786a892658 |
@@ -62,8 +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) ---
|
# --- Tauri-Client (Build-/Laufzeit-Overrides des Desktop-Clients) ---
|
||||||
# MAARCADETWEET_PDS_URL=http://127.0.0.1:2583
|
# MAARCADETWEET_PDS_URL=http://127.0.0.1:2583
|
||||||
# MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584
|
# MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584
|
||||||
|
|||||||
Generated
+1
@@ -53,6 +53,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
|
"p256",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ cargo run -p appview
|
|||||||
| 6 Tauri-UI-Logik an Backend koppeln | ✅ done — LoginScreen, NavRail, PostCard, ComposeBox, Profile/Compose/Search/Settings-Views |
|
| 6 Tauri-UI-Logik an Backend koppeln | ✅ done — LoginScreen, NavRail, PostCard, ComposeBox, Profile/Compose/Search/Settings-Views |
|
||||||
| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev inert (Production-Weg: [`docs/tauri-release.md`](docs/tauri-release.md)) |
|
| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev inert (Production-Weg: [`docs/tauri-release.md`](docs/tauri-release.md)) |
|
||||||
| 8 Social-Graph + Benachrichtigungen | ✅ done — `notifications`-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count|/seen]`, `/api/followers`, `/api/following`, eigene `/api/thread`-Route; im Client Notifications-View mit Unread-Badge und klickbare Follower-/Following-Listen im Profil |
|
| 8 Social-Graph + Benachrichtigungen | ✅ done — `notifications`-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count|/seen]`, `/api/followers`, `/api/following`, eigene `/api/thread`-Route; im Client Notifications-View mit Unread-Badge und klickbare Follower-/Following-Listen im Profil |
|
||||||
|
| 9 Auth + Performance | ✅ done — AppView prüft Bearer-Tokens (ES256, Schlüssel aus dem neuen `/.well-known/did.json` der PDS, fail closed); Timeline und Notifications nur noch für die eigene DID; CORS-Allowlist statt `Any`; Indizes für Handle-Lookup und Cold-Start-Feed |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -81,11 +82,13 @@ Stand zuletzt gegen den lokalen Dev-Stack (docker compose + laufender PDS + AppV
|
|||||||
Rust-Workspace grün (u.a. 27 MST, 24 PDS-Integration, 49 AppView-Lib, 14 AppView-Integration),
|
Rust-Workspace grün (u.a. 27 MST, 24 PDS-Integration, 49 AppView-Lib, 14 AppView-Integration),
|
||||||
Frontend grün. Zwei Vorbehalte:
|
Frontend grün. Zwei Vorbehalte:
|
||||||
|
|
||||||
* Die DB-gestützten Integrationstests sind *fail-open* — ohne erreichbare Postgres/PDS
|
* Die DB-gestützten Tests sind *fail-open*: **ohne `DATABASE_URL_APPVIEW` in der
|
||||||
überspringen sie sich selbst und melden das nur auf stderr. Ein grüner Lauf ohne
|
Umgebung überspringen sie sich selbst** und melden das nur auf stderr. `cargo test
|
||||||
laufenden Stack sagt also weniger, als er aussieht.
|
--workspace` in einer nackten Shell meldet dann grün, ohne sie ausgeführt zu haben —
|
||||||
* Einige Tests hängen am Zustand der Dev-Datenbank; auf einer frischen DB können
|
für einen aussagekräftigen Lauf `set -a; . ./.env; set +a` voranstellen und PDS +
|
||||||
`handle_sync`-Tests abweichen. Wer sie ernst nimmt, prüft sie gegen eine definierte DB.
|
AppView laufen lassen.
|
||||||
|
* Läuft die Auth (Default), holen sich die Integrationstests echte Tokens von der PDS
|
||||||
|
bzw. signieren sie aus `PDS_JWT_SECRET`; ohne erreichbare PDS überspringen sie.
|
||||||
|
|
||||||
`crates/tauri-app/src-tauri` hat ein eigenes `[workspace]` und ist **nicht** Teil des
|
`crates/tauri-app/src-tauri` hat ein eigenes `[workspace]` und ist **nicht** Teil des
|
||||||
Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
|
Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
|
||||||
@@ -103,8 +106,8 @@ Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
|
|||||||
* Die eigene PDS speist **keinen** Firehose (`com.atproto.sync.subscribeRepos` fehlt) —
|
* Die eigene PDS speist **keinen** Firehose (`com.atproto.sync.subscribeRepos` fehlt) —
|
||||||
eigene Records erreichen die AppView nur über den Best-Effort-Push
|
eigene Records erreichen die AppView nur über den Best-Effort-Push
|
||||||
`POST /internal/ingest-commit`.
|
`POST /internal/ingest-commit`.
|
||||||
* Die AppView-Leseschnittstelle hat **keine Auth** und CORS `Any`; bei
|
* `aud` wird beim Token-Check nicht validiert (Signatur, Ablauf, `scope` und
|
||||||
`/api/notifications` sind das erstmals halbwegs private Daten.
|
`sub` schon).
|
||||||
* Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der
|
* Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der
|
||||||
Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer".
|
Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer".
|
||||||
* Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das
|
* Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das
|
||||||
@@ -112,8 +115,6 @@ Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
|
|||||||
* Reply-Notifications gehen verloren, wenn die Antwort vor ihrem Parent indiziert wird
|
* Reply-Notifications gehen verloren, wenn die Antwort vor ihrem Parent indiziert wird
|
||||||
(kein Nachlauf) — bei Jetstream möglich.
|
(kein Nachlauf) — bei Jetstream möglich.
|
||||||
* `at-blob` spricht MinIO ohne Signature V4 — echtes AWS S3 funktioniert damit nicht.
|
* `at-blob` spricht MinIO ohne Signature V4 — echtes AWS S3 funktioniert damit nicht.
|
||||||
* Die PDS liefert kein `.well-known/did.json`; `describeServer` gibt die DID hart
|
|
||||||
als `did:web:pds.maarcadetweet.local` zurück.
|
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
|
|||||||
@@ -35,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 }
|
||||||
@@ -45,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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,7 +153,10 @@ impl HandleSyncWorker {
|
|||||||
|
|
||||||
/// The SELECT half of [`Self::run_once`]: up to [`BATCH_SIZE`]
|
/// The SELECT half of [`Self::run_once`]: up to [`BATCH_SIZE`]
|
||||||
/// distinct DIDs still waiting for a handle.
|
/// distinct DIDs still waiting for a handle.
|
||||||
async fn select_candidates(&self) -> Result<Vec<String>> {
|
///
|
||||||
|
/// 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(
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
r#"SELECT DISTINCT did
|
r#"SELECT DISTINCT did
|
||||||
FROM posts
|
FROM posts
|
||||||
@@ -177,7 +180,7 @@ impl HandleSyncWorker {
|
|||||||
/// freshly seeded DID may simply not make the batch — which made
|
/// freshly seeded DID may simply not make the batch — which made
|
||||||
/// the dispatch tests fail for reasons that had nothing to do with
|
/// the dispatch tests fail for reasons that had nothing to do with
|
||||||
/// dispatch. Passing the DIDs in removes that coupling.
|
/// dispatch. Passing the DIDs in removes that coupling.
|
||||||
async fn resolve_batch(&self, dids: Vec<String>) -> Result<SyncReport> {
|
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);
|
||||||
@@ -576,6 +579,66 @@ mod tests {
|
|||||||
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.
|
||||||
@@ -606,9 +669,18 @@ 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,
|
||||||
@@ -665,9 +737,16 @@ 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -83,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
|
||||||
@@ -102,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 {
|
||||||
|
|||||||
+187
-42
@@ -8,6 +8,30 @@
|
|||||||
//! - `ingest_commit`: the internal-only writer used by the PDS, owned
|
//! - `ingest_commit`: the internal-only writer used by the PDS, owned
|
||||||
//! in `crate::ingest`.
|
//! in `crate::ingest`.
|
||||||
//!
|
//!
|
||||||
|
//! ## Public vs. private
|
||||||
|
//!
|
||||||
|
//! Most of what the AppView serves is public by construction: in AT
|
||||||
|
//! Proto a post, a profile, a follow edge and a like are records in a
|
||||||
|
//! public repo, replicated over the firehose. Those endpoints
|
||||||
|
//! (`/api/profile*`, `/api/search`, `/api/post/*`, `/api/thread*`,
|
||||||
|
//! `/api/followers`, `/api/following`) need no credential.
|
||||||
|
//!
|
||||||
|
//! Two things are *not* public, and they are the reason this service
|
||||||
|
//! has authentication at all:
|
||||||
|
//!
|
||||||
|
//! - `/api/notifications*` — who interacted with you, and the read
|
||||||
|
//! state of that list. Nothing in the protocol makes it readable by
|
||||||
|
//! anyone but the recipient.
|
||||||
|
//! - `/api/timeline/home` — the timeline is assembled from the
|
||||||
|
//! viewer's follow graph, so serving it to an arbitrary `did`
|
||||||
|
//! parameter answers "what does this person's feed look like" for
|
||||||
|
//! any DID a caller cares to type.
|
||||||
|
//!
|
||||||
|
//! Both require a valid PDS-issued access token whose `sub` equals the
|
||||||
|
//! `did` in the request ([`crate::auth`]). The check is two steps on
|
||||||
|
//! purpose: the extractor proves *who* the caller is, and
|
||||||
|
//! `ensure_matches` proves they are asking about themselves.
|
||||||
|
//!
|
||||||
//! The cursor format used by `timeline_home` is opaque: it's a
|
//! The cursor format used by `timeline_home` is opaque: it's a
|
||||||
//! `base64url(micros):uri` pair, which is what [`cursor::encode`] and
|
//! `base64url(micros):uri` pair, which is what [`cursor::encode`] and
|
||||||
//! [`cursor::decode`] produce/consume.
|
//! [`cursor::decode`] produce/consume.
|
||||||
@@ -19,11 +43,14 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use axum::http::{header, HeaderValue, Method};
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::auth::AuthedDid;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub mod cursor;
|
pub mod cursor;
|
||||||
@@ -35,25 +62,73 @@ use types::{
|
|||||||
ProfileResponse, SearchResponse, ThreadFullResponse, TimelineResponse,
|
ProfileResponse, SearchResponse, ThreadFullResponse, TimelineResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn router(state: AppState) -> Router {
|
/// Build the CORS layer for the browser-facing routes.
|
||||||
// CORS: the Tauri webview's origin is the Vite dev server
|
///
|
||||||
// (`http://127.0.0.1:1430`) in dev or the bundled `tauri://` /
|
/// The Tauri webview is a cross-origin caller: in dev its origin is the
|
||||||
// `asset://` origin in production. Either way it's a cross-origin
|
/// Vite server (`http://127.0.0.1:1430`), in a packaged build it is a
|
||||||
// fetch against this service's `http://127.0.0.1:2584` listen
|
/// platform-specific scheme — `tauri://localhost` on macOS/iOS,
|
||||||
// address, so the browser blocks the response without an explicit
|
/// `http://tauri.localhost` on Windows. Either way the browser drops
|
||||||
// allow-origin header. We allow any origin — the AppView's
|
/// the response unless we send `Access-Control-Allow-Origin`.
|
||||||
// public read endpoints (`/api/...`) carry no auth cookie and
|
///
|
||||||
// the AppView runs alongside the user's own PDS, not on the
|
/// `APPVIEW_CORS_ORIGINS` is a comma-separated allowlist, e.g.
|
||||||
// open internet; production deployments behind a reverse proxy
|
/// `tauri://localhost,http://127.0.0.1:1430`. When it is unset we keep
|
||||||
// can tighten this via the proxy itself.
|
/// the historic wildcard so no existing deployment breaks on upgrade —
|
||||||
let cors = CorsLayer::new()
|
/// [`crate::auth::log_startup_posture`] warns about that at startup.
|
||||||
.allow_origin(Any)
|
///
|
||||||
.allow_methods(Any)
|
/// `Authorization` has to be in `allow_headers`: it is not a
|
||||||
.allow_headers(Any);
|
/// CORS-safelisted header, so without it the browser's preflight fails
|
||||||
|
/// and the authenticated endpoints become unreachable from the webview
|
||||||
|
/// — with an error that looks nothing like an auth problem.
|
||||||
|
///
|
||||||
|
/// `allow_credentials` stays off. We authenticate with a bearer token
|
||||||
|
/// the client attaches deliberately, never with an ambient cookie, so
|
||||||
|
/// there is nothing for a hostile page to replay — and turning it on
|
||||||
|
/// would additionally make the wildcard origin illegal.
|
||||||
|
fn cors_layer(cfg: &at_shared::config::AppConfig) -> CorsLayer {
|
||||||
|
let base = CorsLayer::new()
|
||||||
|
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||||
|
.allow_headers([
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
header::ACCEPT,
|
||||||
|
]);
|
||||||
|
|
||||||
Router::new()
|
if cfg.appview_cors_origins.is_empty() {
|
||||||
|
return base.allow_origin(Any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything unparseable as a header value is dropped with a warning
|
||||||
|
// rather than panicking the process — a stray quote in the env var
|
||||||
|
// must not take the service down. If *every* entry is bad we fall
|
||||||
|
// back to the wildcard and say so, because an empty allowlist would
|
||||||
|
// silently break every browser client.
|
||||||
|
let mut origins: Vec<HeaderValue> = Vec::new();
|
||||||
|
for raw in &cfg.appview_cors_origins {
|
||||||
|
match HeaderValue::from_str(raw) {
|
||||||
|
Ok(v) => origins.push(v),
|
||||||
|
Err(_) => warn!(origin = %raw, "APPVIEW_CORS_ORIGINS: ignoring unparseable origin"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if origins.is_empty() {
|
||||||
|
warn!("APPVIEW_CORS_ORIGINS contained no usable origin; falling back to allow-any");
|
||||||
|
return base.allow_origin(Any);
|
||||||
|
}
|
||||||
|
base.allow_origin(AllowOrigin::list(origins))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
let cors = cors_layer(&state.cfg);
|
||||||
|
|
||||||
|
// Browser-facing surface. Everything here answers a `fetch()` from
|
||||||
|
// the desktop client, so it carries the CORS layer.
|
||||||
|
let api = Router::new()
|
||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
|
// --- private: token required, `sub` must equal `did` ---
|
||||||
.route("/api/timeline/home", get(timeline_home))
|
.route("/api/timeline/home", get(timeline_home))
|
||||||
|
.route("/api/notifications", get(notifications))
|
||||||
|
.route("/api/notifications/count", get(notifications_count))
|
||||||
|
.route("/api/notifications/seen", post(notifications_seen))
|
||||||
|
// --- public: AT Proto public records ---
|
||||||
.route("/api/profile", get(profile_query))
|
.route("/api/profile", get(profile_query))
|
||||||
.route("/api/profile/:handle", get(profile_path))
|
.route("/api/profile/:handle", get(profile_path))
|
||||||
.route("/api/search", get(search))
|
.route("/api/search", get(search))
|
||||||
@@ -66,15 +141,20 @@ pub fn router(state: AppState) -> Router {
|
|||||||
// implementation, so they can't drift.
|
// implementation, so they can't drift.
|
||||||
.route("/api/thread", get(thread_query))
|
.route("/api/thread", get(thread_query))
|
||||||
.route("/api/thread/*uri", get(thread_path))
|
.route("/api/thread/*uri", get(thread_path))
|
||||||
.route("/api/notifications", get(notifications))
|
|
||||||
.route("/api/notifications/count", get(notifications_count))
|
|
||||||
.route("/api/notifications/seen", post(notifications_seen))
|
|
||||||
.route("/api/followers", get(followers))
|
.route("/api/followers", get(followers))
|
||||||
.route("/api/following", get(following))
|
.route("/api/following", get(following))
|
||||||
.route("/healthz", get(healthz))
|
.route("/healthz", get(healthz))
|
||||||
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
|
.layer(cors);
|
||||||
.layer(cors)
|
|
||||||
.with_state(state)
|
// Server-to-server surface. `/internal/ingest-commit` is called by
|
||||||
|
// the PDS with a shared secret, never by a browser, so it stays
|
||||||
|
// outside the CORS layer: handing it an
|
||||||
|
// `Access-Control-Allow-Origin` header would only ever help a web
|
||||||
|
// page try to write to the index.
|
||||||
|
let internal = Router::new()
|
||||||
|
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit));
|
||||||
|
|
||||||
|
api.merge(internal).with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn root() -> Json<Value> {
|
async fn root() -> Json<Value> {
|
||||||
@@ -115,13 +195,21 @@ const MAX_LIMIT: i64 = 100;
|
|||||||
/// surface.
|
/// surface.
|
||||||
const MAX_FOLLOWED_DIDS: usize = 1000;
|
const MAX_FOLLOWED_DIDS: usize = 1000;
|
||||||
|
|
||||||
|
/// `GET /api/timeline/home?did=…`
|
||||||
|
///
|
||||||
|
/// **Authenticated.** The timeline is derived from the viewer's follow
|
||||||
|
/// graph, so `did` must be the caller's own DID — otherwise this
|
||||||
|
/// endpoint would answer "what does this account's feed look like" for
|
||||||
|
/// any DID at all.
|
||||||
async fn timeline_home(
|
async fn timeline_home(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
auth: AuthedDid,
|
||||||
Query(q): Query<TimelineQuery>,
|
Query(q): Query<TimelineQuery>,
|
||||||
) -> Result<Json<TimelineResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<TimelineResponse>, (StatusCode, Json<Value>)> {
|
||||||
if q.did.is_empty() {
|
if q.did.is_empty() {
|
||||||
return Err(bad_request("did is required"));
|
return Err(bad_request("did is required"));
|
||||||
}
|
}
|
||||||
|
auth.ensure_matches(&q.did)?;
|
||||||
let limit = clamp_limit(q.limit);
|
let limit = clamp_limit(q.limit);
|
||||||
|
|
||||||
// Look up the set of DIDs this user follows, then build the
|
// Look up the set of DIDs this user follows, then build the
|
||||||
@@ -230,20 +318,51 @@ async fn timeline_home(
|
|||||||
.map_err(db_err)?,
|
.map_err(db_err)?,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Graph-aware branch: filter `posts.did` to the followee set
|
// Graph-aware branch: the followee set plus the requesting
|
||||||
// plus the requesting user's own DID. `target_dids` has been
|
// user's own DID. `target_dids` has been deduped and capped at
|
||||||
// deduped and capped at MAX_FOLLOWED_DIDS, and the user's own
|
// MAX_FOLLOWED_DIDS, and the user's own DID is guaranteed to
|
||||||
// DID is guaranteed to be in the set.
|
// be in the set.
|
||||||
|
//
|
||||||
|
// ## Why this is a LATERAL and not `did = ANY($2)`
|
||||||
|
//
|
||||||
|
// The straightforward `WHERE did = ANY($2) ORDER BY indexed_at
|
||||||
|
// DESC LIMIT n` is a plan-stability trap once
|
||||||
|
// `posts_feed_indexed_at_uri_idx` exists (migration 0009, added
|
||||||
|
// for the cold-start feed). The planner sees an index that
|
||||||
|
// already yields rows in `indexed_at DESC` order and assumes it
|
||||||
|
// will hit `n` matching rows early — so it walks the global
|
||||||
|
// feed and filters. When the followees are sparse (a fresh
|
||||||
|
// account following accounts that haven't posted), "early"
|
||||||
|
// means millions of rows: measured on the dev instance, 2.87 M
|
||||||
|
// rows discarded and 28 s per request, while the same query
|
||||||
|
// took 62 ms with the per-DID index. It also flipped between
|
||||||
|
// the two plans depending on how often the prepared statement
|
||||||
|
// had run, so it looked intermittent.
|
||||||
|
//
|
||||||
|
// Expressing the intent — "for each followee, their newest
|
||||||
|
// posts, merged" — takes that plan off the table: `unnest` is a
|
||||||
|
// relation the planner can size, and each iteration is a bounded
|
||||||
|
// range scan on `posts_did_indexed_at_uri_idx`. Fetching `$1`
|
||||||
|
// per followee is what makes it correct: the global top-N is
|
||||||
|
// always a subset of the union of the per-followee top-Ns.
|
||||||
match cursor_ts {
|
match cursor_ts {
|
||||||
Some(ts) => sqlx::query_as::<_, PostRowWithIndexed>(
|
Some(ts) => sqlx::query_as::<_, PostRowWithIndexed>(
|
||||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
r#"SELECT t.uri, t.did, t.handle, t.rkey, t.collection,
|
||||||
parent_uri, root_uri, embed, langs, created_at,
|
t.text, t.cid, t.parent_uri, t.root_uri,
|
||||||
indexed_at
|
t.embed, t.langs, t.created_at, t.indexed_at
|
||||||
FROM posts
|
FROM unnest($2::text[]) AS f(did)
|
||||||
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
|
CROSS JOIN LATERAL (
|
||||||
AND did = ANY($2::text[])
|
SELECT p.uri, p.did, p.handle, p.rkey, p.collection,
|
||||||
AND (indexed_at, uri) < ($3, $4)
|
p.text, p.cid, p.parent_uri, p.root_uri,
|
||||||
ORDER BY indexed_at DESC, uri DESC
|
p.embed, p.langs, p.created_at, p.indexed_at
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.did = f.did
|
||||||
|
AND p.collection IN ('app.twi.post','app.bsky.feed.post')
|
||||||
|
AND (p.indexed_at, p.uri) < ($3, $4)
|
||||||
|
ORDER BY p.indexed_at DESC, p.uri DESC
|
||||||
|
LIMIT $1
|
||||||
|
) t
|
||||||
|
ORDER BY t.indexed_at DESC, t.uri DESC
|
||||||
LIMIT $1"#,
|
LIMIT $1"#,
|
||||||
)
|
)
|
||||||
.bind(fetch)
|
.bind(fetch)
|
||||||
@@ -254,13 +373,21 @@ async fn timeline_home(
|
|||||||
.await
|
.await
|
||||||
.map_err(db_err)?,
|
.map_err(db_err)?,
|
||||||
None => sqlx::query_as::<_, PostRowWithIndexed>(
|
None => sqlx::query_as::<_, PostRowWithIndexed>(
|
||||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
r#"SELECT t.uri, t.did, t.handle, t.rkey, t.collection,
|
||||||
parent_uri, root_uri, embed, langs, created_at,
|
t.text, t.cid, t.parent_uri, t.root_uri,
|
||||||
indexed_at
|
t.embed, t.langs, t.created_at, t.indexed_at
|
||||||
FROM posts
|
FROM unnest($2::text[]) AS f(did)
|
||||||
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
|
CROSS JOIN LATERAL (
|
||||||
AND did = ANY($2::text[])
|
SELECT p.uri, p.did, p.handle, p.rkey, p.collection,
|
||||||
ORDER BY indexed_at DESC, uri DESC
|
p.text, p.cid, p.parent_uri, p.root_uri,
|
||||||
|
p.embed, p.langs, p.created_at, p.indexed_at
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.did = f.did
|
||||||
|
AND p.collection IN ('app.twi.post','app.bsky.feed.post')
|
||||||
|
ORDER BY p.indexed_at DESC, p.uri DESC
|
||||||
|
LIMIT $1
|
||||||
|
) t
|
||||||
|
ORDER BY t.indexed_at DESC, t.uri DESC
|
||||||
LIMIT $1"#,
|
LIMIT $1"#,
|
||||||
)
|
)
|
||||||
.bind(fetch)
|
.bind(fetch)
|
||||||
@@ -1046,13 +1173,19 @@ struct NotificationsQuery {
|
|||||||
/// of the list. The tiebreak here is the row's `id` rather than a URI
|
/// of the list. The tiebreak here is the row's `id` rather than a URI
|
||||||
/// (a notification has no URI of its own), which the shared
|
/// (a notification has no URI of its own), which the shared
|
||||||
/// [`cursor`] codec carries in its string slot.
|
/// [`cursor`] codec carries in its string slot.
|
||||||
|
///
|
||||||
|
/// **Authenticated**: `did` is the recipient, so the caller has to be
|
||||||
|
/// that recipient. This is the endpoint that made authentication
|
||||||
|
/// necessary in the first place.
|
||||||
async fn notifications(
|
async fn notifications(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
auth: AuthedDid,
|
||||||
Query(q): Query<NotificationsQuery>,
|
Query(q): Query<NotificationsQuery>,
|
||||||
) -> Result<Json<NotificationsResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<NotificationsResponse>, (StatusCode, Json<Value>)> {
|
||||||
if q.did.is_empty() {
|
if q.did.is_empty() {
|
||||||
return Err(bad_request("did is required"));
|
return Err(bad_request("did is required"));
|
||||||
}
|
}
|
||||||
|
auth.ensure_matches(&q.did)?;
|
||||||
let limit = clamp_limit(q.limit);
|
let limit = clamp_limit(q.limit);
|
||||||
let keyset = decode_cursor(q.cursor.as_deref())?;
|
let keyset = decode_cursor(q.cursor.as_deref())?;
|
||||||
// The cursor's string slot holds the row id. A client that hands
|
// The cursor's string slot holds the row id. A client that hands
|
||||||
@@ -1121,13 +1254,18 @@ struct NotificationCountQuery {
|
|||||||
/// scales with the number of *unread* rows, not the user's lifetime
|
/// scales with the number of *unread* rows, not the user's lifetime
|
||||||
/// notification history. That matters because the client polls this
|
/// notification history. That matters because the client polls this
|
||||||
/// for its tray badge.
|
/// for its tray badge.
|
||||||
|
///
|
||||||
|
/// **Authenticated**, same rule as the list itself: an unread count is
|
||||||
|
/// still information about someone else's inbox.
|
||||||
async fn notifications_count(
|
async fn notifications_count(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
auth: AuthedDid,
|
||||||
Query(q): Query<NotificationCountQuery>,
|
Query(q): Query<NotificationCountQuery>,
|
||||||
) -> Result<Json<NotificationCountResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<NotificationCountResponse>, (StatusCode, Json<Value>)> {
|
||||||
if q.did.is_empty() {
|
if q.did.is_empty() {
|
||||||
return Err(bad_request("did is required"));
|
return Err(bad_request("did is required"));
|
||||||
}
|
}
|
||||||
|
auth.ensure_matches(&q.did)?;
|
||||||
let count: i64 = sqlx::query_scalar(
|
let count: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*)::BIGINT FROM notifications \
|
"SELECT COUNT(*)::BIGINT FROM notifications \
|
||||||
WHERE recipient_did = $1 AND read_at IS NULL",
|
WHERE recipient_did = $1 AND read_at IS NULL",
|
||||||
@@ -1165,13 +1303,20 @@ struct NotificationsSeenReq {
|
|||||||
/// updates nothing and reports `updated: 0`. `read_at` is set to
|
/// updates nothing and reports `updated: 0`. `read_at` is set to
|
||||||
/// `now()` (when we recorded the ack), not to `seenAt` (which is a
|
/// `now()` (when we recorded the ack), not to `seenAt` (which is a
|
||||||
/// client-supplied watermark and could be arbitrarily far in the past).
|
/// client-supplied watermark and could be arbitrarily far in the past).
|
||||||
|
///
|
||||||
|
/// **Authenticated**, and the only *write* among the private
|
||||||
|
/// endpoints: without the check anyone could clear another user's
|
||||||
|
/// unread badge. `AuthedDid` runs before `Json` because the body
|
||||||
|
/// extractor consumes the request — axum requires body extractors last.
|
||||||
async fn notifications_seen(
|
async fn notifications_seen(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
auth: AuthedDid,
|
||||||
Json(req): Json<NotificationsSeenReq>,
|
Json(req): Json<NotificationsSeenReq>,
|
||||||
) -> Result<Json<NotificationSeenResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<NotificationSeenResponse>, (StatusCode, Json<Value>)> {
|
||||||
if req.did.is_empty() {
|
if req.did.is_empty() {
|
||||||
return Err(bad_request("did is required"));
|
return Err(bad_request("did is required"));
|
||||||
}
|
}
|
||||||
|
auth.ensure_matches(&req.did)?;
|
||||||
let res = sqlx::query(
|
let res = sqlx::query(
|
||||||
r#"UPDATE notifications
|
r#"UPDATE notifications
|
||||||
SET read_at = now()
|
SET read_at = now()
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -157,12 +191,7 @@ async fn timeline_returns_seeded_posts() {
|
|||||||
// machine that has run this suite twice) the three rows we just
|
// machine that has run this suite twice) the three rows we just
|
||||||
// seeded fall outside a 10-row window and the assertions below
|
// seeded fall outside a 10-row window and the assertions below
|
||||||
// fail for reasons that have nothing to do with the timeline.
|
// fail for reasons that have nothing to do with the timeline.
|
||||||
let resp = c
|
let resp = get_timeline(&c, &auth, &did, &[("limit", "100")]).await;
|
||||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
|
||||||
.query(&[("did", did.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");
|
||||||
@@ -222,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.
|
||||||
@@ -246,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");
|
||||||
@@ -285,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");
|
||||||
@@ -484,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();
|
||||||
|
|
||||||
@@ -503,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");
|
||||||
@@ -555,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");
|
||||||
@@ -605,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();
|
||||||
|
|
||||||
@@ -624,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:?}"
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
//! API. That's deliberate: it's the only way to catch a mismatch
|
//! 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.
|
//! between what the write path stores and what the read path joins.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::TestAuth;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -55,8 +58,12 @@ async fn db_pool() -> Option<sqlx::PgPool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Guard used at the top of every test. Returns `None` (→ skip) unless
|
/// Guard used at the top of every test. Returns `None` (→ skip) unless
|
||||||
/// both the HTTP service and the database are up.
|
/// the HTTP service is up, the database is reachable, **and** we know
|
||||||
async fn ready() -> Option<(reqwest::Client, sqlx::PgPool)> {
|
/// 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 {
|
if !wait_for_appview_db().await {
|
||||||
eprintln!("appview not running, skipping");
|
eprintln!("appview not running, skipping");
|
||||||
return None;
|
return None;
|
||||||
@@ -65,7 +72,32 @@ async fn ready() -> Option<(reqwest::Client, sqlx::PgPool)> {
|
|||||||
eprintln!("appview DB unreachable, skipping");
|
eprintln!("appview DB unreachable, skipping");
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
Some((client().await, pool))
|
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 {
|
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
|
||||||
@@ -181,7 +213,7 @@ async fn seed_follow(c: &reqwest::Client, follower: &str, subject: &str) {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn notifications_list_count_and_seen() {
|
async fn notifications_list_count_and_seen() {
|
||||||
let base = appview_url();
|
let base = appview_url();
|
||||||
let Some((c, _pool)) = ready().await else {
|
let Some((c, _pool, auth)) = ready().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let alice = did_for_test("alice");
|
let alice = did_for_test("alice");
|
||||||
@@ -196,8 +228,7 @@ async fn notifications_list_count_and_seen() {
|
|||||||
let reply_uri = seed_reply(&c, &carol, &post_uri, &post_uri, "carol's reply").await;
|
let reply_uri = seed_reply(&c, &carol, &post_uri, &post_uri, "carol's reply").await;
|
||||||
seed_follow(&c, &bob, &alice).await;
|
seed_follow(&c, &bob, &alice).await;
|
||||||
|
|
||||||
let resp = c
|
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||||
.get(format!("{base}/api/notifications"))
|
|
||||||
.query(&[("did", alice.as_str()), ("limit", "50")])
|
.query(&[("did", alice.as_str()), ("limit", "50")])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -253,8 +284,7 @@ async fn notifications_list_count_and_seen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The unread count agrees with the list.
|
// The unread count agrees with the list.
|
||||||
let resp = c
|
let resp = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
|
||||||
.get(format!("{base}/api/notifications/count"))
|
|
||||||
.query(&[("did", alice.as_str())])
|
.query(&[("did", alice.as_str())])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -264,8 +294,7 @@ async fn notifications_list_count_and_seen() {
|
|||||||
assert_eq!(body["count"], json!(3));
|
assert_eq!(body["count"], json!(3));
|
||||||
|
|
||||||
// Mark everything seen.
|
// Mark everything seen.
|
||||||
let resp = c
|
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||||
.post(format!("{base}/api/notifications/seen"))
|
|
||||||
.json(&json!({ "did": alice }))
|
.json(&json!({ "did": alice }))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -276,8 +305,7 @@ async fn notifications_list_count_and_seen() {
|
|||||||
assert_eq!(body["updated"], json!(3));
|
assert_eq!(body["updated"], json!(3));
|
||||||
|
|
||||||
// Idempotent: a second call updates nothing and still succeeds.
|
// Idempotent: a second call updates nothing and still succeeds.
|
||||||
let resp = c
|
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||||
.post(format!("{base}/api/notifications/seen"))
|
|
||||||
.json(&json!({ "did": alice }))
|
.json(&json!({ "did": alice }))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -286,8 +314,7 @@ async fn notifications_list_count_and_seen() {
|
|||||||
assert_eq!(body["updated"], json!(0));
|
assert_eq!(body["updated"], json!(0));
|
||||||
|
|
||||||
// Count is now zero and the rows carry a read_at.
|
// Count is now zero and the rows carry a read_at.
|
||||||
let resp = c
|
let resp = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
|
||||||
.get(format!("{base}/api/notifications/count"))
|
|
||||||
.query(&[("did", alice.as_str())])
|
.query(&[("did", alice.as_str())])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -295,8 +322,7 @@ async fn notifications_list_count_and_seen() {
|
|||||||
let body: Value = resp.json().await.unwrap();
|
let body: Value = resp.json().await.unwrap();
|
||||||
assert_eq!(body["count"], json!(0));
|
assert_eq!(body["count"], json!(0));
|
||||||
|
|
||||||
let resp = c
|
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||||
.get(format!("{base}/api/notifications"))
|
|
||||||
.query(&[("did", alice.as_str())])
|
.query(&[("did", alice.as_str())])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -311,8 +337,7 @@ async fn notifications_list_count_and_seen() {
|
|||||||
"/api/notifications",
|
"/api/notifications",
|
||||||
"/api/notifications/count",
|
"/api/notifications/count",
|
||||||
] {
|
] {
|
||||||
let resp = c
|
let resp = authed_get(&c, &auth, format!("{base}{path}"), &alice)
|
||||||
.get(format!("{base}{path}"))
|
|
||||||
.query(&[("did", "")])
|
.query(&[("did", "")])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -326,7 +351,7 @@ async fn notifications_list_count_and_seen() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn notifications_skip_self_interactions() {
|
async fn notifications_skip_self_interactions() {
|
||||||
let base = appview_url();
|
let base = appview_url();
|
||||||
let Some((c, _pool)) = ready().await else {
|
let Some((c, _pool, auth)) = ready().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let alice = did_for_test("solo");
|
let alice = did_for_test("solo");
|
||||||
@@ -335,8 +360,7 @@ async fn notifications_skip_self_interactions() {
|
|||||||
seed_reply(&c, &alice, &post_uri, &post_uri, "and replying too").await;
|
seed_reply(&c, &alice, &post_uri, &post_uri, "and replying too").await;
|
||||||
seed_follow(&c, &alice, &alice).await;
|
seed_follow(&c, &alice, &alice).await;
|
||||||
|
|
||||||
let resp = c
|
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||||
.get(format!("{base}/api/notifications"))
|
|
||||||
.query(&[("did", alice.as_str())])
|
.query(&[("did", alice.as_str())])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -355,7 +379,7 @@ async fn notifications_skip_self_interactions() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn notifications_paginate_with_cursor() {
|
async fn notifications_paginate_with_cursor() {
|
||||||
let base = appview_url();
|
let base = appview_url();
|
||||||
let Some((c, _pool)) = ready().await else {
|
let Some((c, _pool, auth)) = ready().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let alice = did_for_test("popular");
|
let alice = did_for_test("popular");
|
||||||
@@ -372,9 +396,9 @@ async fn notifications_paginate_with_cursor() {
|
|||||||
let c = c.clone();
|
let c = c.clone();
|
||||||
let alice = alice.clone();
|
let alice = alice.clone();
|
||||||
let base = base.clone();
|
let base = base.clone();
|
||||||
|
let auth = auth.clone();
|
||||||
async move {
|
async move {
|
||||||
let mut req = c
|
let mut req = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||||
.get(format!("{base}/api/notifications"))
|
|
||||||
.query(&[("did", alice.as_str()), ("limit", "5")]);
|
.query(&[("did", alice.as_str()), ("limit", "5")]);
|
||||||
if let Some(cur) = cursor {
|
if let Some(cur) = cursor {
|
||||||
req = req.query(&[("cursor", cur.as_str())]);
|
req = req.query(&[("cursor", cur.as_str())]);
|
||||||
@@ -417,8 +441,7 @@ async fn notifications_paginate_with_cursor() {
|
|||||||
assert_eq!(all.len(), 12);
|
assert_eq!(all.len(), 12);
|
||||||
|
|
||||||
// A mangled cursor is a 400, not a silent restart at page 1.
|
// A mangled cursor is a 400, not a silent restart at page 1.
|
||||||
let resp = c
|
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||||
.get(format!("{base}/api/notifications"))
|
|
||||||
.query(&[("did", alice.as_str()), ("cursor", "!!!garbage!!!")])
|
.query(&[("did", alice.as_str()), ("cursor", "!!!garbage!!!")])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -431,7 +454,7 @@ async fn notifications_paginate_with_cursor() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn notifications_seen_respects_watermark() {
|
async fn notifications_seen_respects_watermark() {
|
||||||
let base = appview_url();
|
let base = appview_url();
|
||||||
let Some((c, _pool)) = ready().await else {
|
let Some((c, _pool, auth)) = ready().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let alice = did_for_test("watermark");
|
let alice = did_for_test("watermark");
|
||||||
@@ -442,8 +465,7 @@ async fn notifications_seen_respects_watermark() {
|
|||||||
|
|
||||||
// Read back the first notification's indexed_at — that's the
|
// Read back the first notification's indexed_at — that's the
|
||||||
// watermark a client would echo after rendering page 1.
|
// watermark a client would echo after rendering page 1.
|
||||||
let body: Value = c
|
let body: Value = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||||
.get(format!("{base}/api/notifications"))
|
|
||||||
.query(&[("did", alice.as_str())])
|
.query(&[("did", alice.as_str())])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -461,8 +483,7 @@ async fn notifications_seen_respects_watermark() {
|
|||||||
let second = did_for_test("late");
|
let second = did_for_test("late");
|
||||||
seed_like(&c, &second, &post_uri).await;
|
seed_like(&c, &second, &post_uri).await;
|
||||||
|
|
||||||
let resp = c
|
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||||
.post(format!("{base}/api/notifications/seen"))
|
|
||||||
.json(&json!({ "did": alice, "seenAt": watermark }))
|
.json(&json!({ "did": alice, "seenAt": watermark }))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -475,8 +496,7 @@ async fn notifications_seen_respects_watermark() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The later one is still unread.
|
// The later one is still unread.
|
||||||
let body: Value = c
|
let body: Value = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
|
||||||
.get(format!("{base}/api/notifications/count"))
|
|
||||||
.query(&[("did", alice.as_str())])
|
.query(&[("did", alice.as_str())])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -487,8 +507,7 @@ async fn notifications_seen_respects_watermark() {
|
|||||||
assert_eq!(body["count"], json!(1));
|
assert_eq!(body["count"], json!(1));
|
||||||
|
|
||||||
// snake_case spelling must work identically.
|
// snake_case spelling must work identically.
|
||||||
let resp = c
|
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||||
.post(format!("{base}/api/notifications/seen"))
|
|
||||||
.json(&json!({ "did": alice, "seen_at": null }))
|
.json(&json!({ "did": alice, "seen_at": null }))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -502,7 +521,7 @@ async fn notifications_seen_respects_watermark() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn followers_and_following_lists() {
|
async fn followers_and_following_lists() {
|
||||||
let base = appview_url();
|
let base = appview_url();
|
||||||
let Some((c, _pool)) = ready().await else {
|
let Some((c, _pool, _auth)) = ready().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let hub = did_for_test("hub");
|
let hub = did_for_test("hub");
|
||||||
@@ -591,7 +610,8 @@ async fn followers_and_following_lists() {
|
|||||||
assert_eq!(unique.len(), seen.len(), "paged followers repeat: {seen:?}");
|
assert_eq!(unique.len(), seen.len(), "paged followers repeat: {seen:?}");
|
||||||
assert_eq!(seen.len(), 3, "paging lost a follower: {seen:?}");
|
assert_eq!(seen.len(), 3, "paging lost a follower: {seen:?}");
|
||||||
|
|
||||||
// `did` is mandatory.
|
// `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"] {
|
for path in ["/api/followers", "/api/following"] {
|
||||||
let resp = c
|
let resp = c
|
||||||
.get(format!("{base}{path}"))
|
.get(format!("{base}{path}"))
|
||||||
@@ -610,7 +630,7 @@ async fn followers_and_following_lists() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn thread_returns_parents_and_replies() {
|
async fn thread_returns_parents_and_replies() {
|
||||||
let base = appview_url();
|
let base = appview_url();
|
||||||
let Some((c, _pool)) = ready().await else {
|
let Some((c, _pool, _auth)) = ready().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let a = did_for_test("root");
|
let a = did_for_test("root");
|
||||||
@@ -689,7 +709,7 @@ async fn thread_returns_parents_and_replies() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn post_by_uri_stays_backwards_compatible() {
|
async fn post_by_uri_stays_backwards_compatible() {
|
||||||
let base = appview_url();
|
let base = appview_url();
|
||||||
let Some((c, _pool)) = ready().await else {
|
let Some((c, _pool, _auth)) = ready().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let a = did_for_test("compat_a");
|
let a = did_for_test("compat_a");
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -73,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),
|
||||||
@@ -163,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 {
|
||||||
|
|||||||
@@ -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>,
|
||||||
@@ -192,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)]);
|
||||||
@@ -211,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>()
|
||||||
@@ -244,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>()
|
||||||
@@ -268,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>()
|
||||||
@@ -292,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>()
|
||||||
@@ -323,13 +328,7 @@ 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>()
|
||||||
@@ -363,13 +362,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.context("appview: failed to send thread request")?;
|
.context("appview: failed to send thread request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("thread", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: thread returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<ThreadFullResponse>()
|
.json::<ThreadFullResponse>()
|
||||||
@@ -377,17 +370,20 @@ impl AppViewClient {
|
|||||||
.context("appview: thread JSON parse")
|
.context("appview: thread JSON parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/notifications?did=&limit=&cursor=` — newest first,
|
/// `GET /api/notifications?did=&limit=&cursor=` — **authenticated**;
|
||||||
/// same opaque-cursor pagination contract as the timeline.
|
/// newest first, same opaque-cursor pagination contract as the
|
||||||
|
/// timeline.
|
||||||
pub async fn fetch_notifications(
|
pub async fn fetch_notifications(
|
||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
|
access_jwt: &str,
|
||||||
) -> Result<NotificationsResponse> {
|
) -> Result<NotificationsResponse> {
|
||||||
let mut req = self
|
let mut req = self
|
||||||
.client
|
.client
|
||||||
.get(format!("{}/api/notifications", self.base_url))
|
.get(format!("{}/api/notifications", 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)]);
|
||||||
@@ -397,13 +393,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.context("appview: failed to send notifications request")?;
|
.context("appview: failed to send notifications request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("notifications", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: notifications returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<NotificationsResponse>()
|
.json::<NotificationsResponse>()
|
||||||
@@ -411,25 +401,24 @@ impl AppViewClient {
|
|||||||
.context("appview: notifications JSON parse")
|
.context("appview: notifications JSON parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/notifications/count?did=` — unread count for the
|
/// `GET /api/notifications/count?did=` — **authenticated**; unread
|
||||||
/// NavRail badge. Cheap enough to poll (partial index on the
|
/// count for the NavRail badge. Cheap enough to poll (partial index
|
||||||
/// server side).
|
/// on the server side).
|
||||||
pub async fn notification_count(&self, did: &str) -> Result<NotificationCountResponse> {
|
pub async fn notification_count(
|
||||||
|
&self,
|
||||||
|
did: &str,
|
||||||
|
access_jwt: &str,
|
||||||
|
) -> Result<NotificationCountResponse> {
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.get(format!("{}/api/notifications/count", self.base_url))
|
.get(format!("{}/api/notifications/count", self.base_url))
|
||||||
|
.bearer_auth(access_jwt)
|
||||||
.query(&[("did", did)])
|
.query(&[("did", did)])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("appview: failed to send notification-count request")?;
|
.context("appview: failed to send notification-count request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("notification count", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: notification count returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<NotificationCountResponse>()
|
.json::<NotificationCountResponse>()
|
||||||
@@ -437,9 +426,10 @@ impl AppViewClient {
|
|||||||
.context("appview: notification count JSON parse")
|
.context("appview: notification count JSON parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/notifications/seen` — mark everything indexed at or
|
/// `POST /api/notifications/seen` — **authenticated**; mark
|
||||||
/// before `seen_at` as read. Passing `None` marks *all* currently
|
/// everything indexed at or before `seen_at` as read. Passing `None`
|
||||||
/// unread rows. Idempotent; a second call reports `updated: 0`.
|
/// marks *all* currently unread rows. Idempotent; a second call
|
||||||
|
/// reports `updated: 0`.
|
||||||
///
|
///
|
||||||
/// The server accepts both `seenAt` and `seen_at`; we send the
|
/// The server accepts both `seenAt` and `seen_at`; we send the
|
||||||
/// camelCase spelling because that's what the wire contract
|
/// camelCase spelling because that's what the wire contract
|
||||||
@@ -448,6 +438,7 @@ impl AppViewClient {
|
|||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
seen_at: Option<&str>,
|
seen_at: Option<&str>,
|
||||||
|
access_jwt: &str,
|
||||||
) -> Result<NotificationSeenResponse> {
|
) -> Result<NotificationSeenResponse> {
|
||||||
let mut body = serde_json::json!({ "did": did });
|
let mut body = serde_json::json!({ "did": did });
|
||||||
if let Some(ts) = seen_at {
|
if let Some(ts) = seen_at {
|
||||||
@@ -456,18 +447,13 @@ impl AppViewClient {
|
|||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.post(format!("{}/api/notifications/seen", self.base_url))
|
.post(format!("{}/api/notifications/seen", self.base_url))
|
||||||
|
.bearer_auth(access_jwt)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("appview: failed to send notifications-seen request")?;
|
.context("appview: failed to send notifications-seen request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("notifications seen", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: notifications seen returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<NotificationSeenResponse>()
|
.json::<NotificationSeenResponse>()
|
||||||
@@ -517,9 +503,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.with_context(|| format!("appview: failed to send {path} request"))?;
|
.with_context(|| format!("appview: failed to send {path} request"))?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error(path, resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!("appview: {} returned {}: {}", path, status, body));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<ActorListResponse>()
|
.json::<ActorListResponse>()
|
||||||
@@ -528,6 +512,33 @@ impl AppViewClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
/// `axum`'s path extractor will decode it back. We use this rather
|
/// `axum`'s path extractor will decode it back. We use this rather
|
||||||
/// than `url::Url::parse(...).path_segments()` because AT-Protocol
|
/// than `url::Url::parse(...).path_segments()` because AT-Protocol
|
||||||
@@ -553,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() {
|
||||||
@@ -563,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())
|
||||||
}
|
}
|
||||||
@@ -460,9 +483,10 @@ async fn fetch_notifications(
|
|||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
) -> Result<appview_client::NotificationsResponse, String> {
|
) -> Result<appview_client::NotificationsResponse, 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, "notifications")?;
|
||||||
state
|
state
|
||||||
.appview
|
.appview
|
||||||
.fetch_notifications(&did, cursor.as_deref(), lim)
|
.fetch_notifications(&did, cursor.as_deref(), lim, &jwt)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -473,9 +497,10 @@ async fn notification_count(
|
|||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
did: String,
|
did: String,
|
||||||
) -> Result<appview_client::NotificationCountResponse, String> {
|
) -> Result<appview_client::NotificationCountResponse, String> {
|
||||||
|
let jwt = require_access_jwt(&state, "the unread-notification count")?;
|
||||||
state
|
state
|
||||||
.appview
|
.appview
|
||||||
.notification_count(&did)
|
.notification_count(&did, &jwt)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -491,9 +516,10 @@ async fn mark_notifications_seen(
|
|||||||
did: String,
|
did: String,
|
||||||
seen_at: Option<String>,
|
seen_at: Option<String>,
|
||||||
) -> Result<appview_client::NotificationSeenResponse, String> {
|
) -> Result<appview_client::NotificationSeenResponse, String> {
|
||||||
|
let jwt = require_access_jwt(&state, "marking notifications seen")?;
|
||||||
state
|
state
|
||||||
.appview
|
.appview
|
||||||
.mark_notifications_seen(&did, seen_at.as_deref())
|
.mark_notifications_seen(&did, seen_at.as_deref(), &jwt)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
fetchTimeline,
|
fetchTimeline,
|
||||||
fetchSearch,
|
fetchSearch,
|
||||||
fetchPost,
|
fetchPost,
|
||||||
|
errorMessage,
|
||||||
|
isAuthFailure,
|
||||||
notificationCount,
|
notificationCount,
|
||||||
openExternalUrl,
|
openExternalUrl,
|
||||||
showError,
|
showError,
|
||||||
@@ -377,6 +379,16 @@
|
|||||||
/// Pull the unread count for the NavRail badge. Swallows errors:
|
/// Pull the unread count for the NavRail badge. Swallows errors:
|
||||||
/// the badge is ambient information, and a transient AppView hiccup
|
/// the badge is ambient information, and a transient AppView hiccup
|
||||||
/// shouldn't produce a toast every 5 seconds.
|
/// 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() {
|
async function refreshUnreadCount() {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
// While the notifications view is open the user is by definition
|
// While the notifications view is open the user is by definition
|
||||||
@@ -386,8 +398,13 @@
|
|||||||
if (view === "notifications") return;
|
if (view === "notifications") return;
|
||||||
try {
|
try {
|
||||||
unreadCount = await notificationCount(currentUser.did);
|
unreadCount = await notificationCount(currentUser.did);
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* ignore — keep the last known count */
|
if (isAuthFailure(e)) {
|
||||||
|
console.warn("notification poll stopped: session rejected", e);
|
||||||
|
stopPoll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* otherwise ignore — keep the last known count */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,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;
|
||||||
@@ -440,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import Avatar from "./Avatar.svelte";
|
import Avatar from "./Avatar.svelte";
|
||||||
import Skeleton from "./Skeleton.svelte";
|
import Skeleton from "./Skeleton.svelte";
|
||||||
import {
|
import {
|
||||||
|
errorMessage,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
markNotificationsSeen,
|
markNotificationsSeen,
|
||||||
notificationIcon,
|
notificationIcon,
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
|
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
|
||||||
cursor = r.cursor;
|
cursor = r.cursor;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,48 @@ describe("NotificationsView actor navigation", () => {
|
|||||||
expect(onThreadClick).not.toHaveBeenCalled();
|
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 () => {
|
it("opens the thread for a row that has a subject", async () => {
|
||||||
fetchNotificationsMock.mockResolvedValue({
|
fetchNotificationsMock.mockResolvedValue({
|
||||||
notifications: [row()],
|
notifications: [row()],
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
|
|||||||
┌──────────────────────────────────┐ ┌──────────────────────────────┐
|
┌──────────────────────────────────┐ ┌──────────────────────────────┐
|
||||||
│ pds-server (axum, :2583) │ │ appview (axum, :2584) │
|
│ pds-server (axum, :2583) │ │ appview (axum, :2584) │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ /xrpc/com.atproto.* │ │ GET /api/timeline/home │
|
│ /xrpc/com.atproto.* │ │ GET /api/timeline/home 🔒 │
|
||||||
│ /xrpc/app.bsky.actor.profile.* │ │ GET /api/profile[/:handle] │
|
│ /xrpc/app.bsky.actor.profile.* │ │ GET /api/profile[/:handle] │
|
||||||
│ /blob/:cid │ │ GET /api/search │
|
│ /blob/:cid │ │ GET /api/search │
|
||||||
│ /healthz │ │ GET /api/post|thread/*uri │
|
│ /healthz │ │ GET /api/post|thread/*uri │
|
||||||
│ │ │ GET /api/notifications… │
|
│ /.well-known/did.json ──────────┼───┼─▶ Schlüssel für 🔒 │
|
||||||
|
│ │ │ GET /api/notifications… 🔒 │
|
||||||
│ │ │ GET /api/followers|following│
|
│ │ │ GET /api/followers|following│
|
||||||
│ │ │ GET /healthz │
|
│ │ │ GET /healthz │
|
||||||
│ at-lexicon Validierung (160) │ │ │
|
│ at-lexicon Validierung (160) │ │ │
|
||||||
@@ -50,6 +51,11 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
|
|||||||
└──────────────────────────────────────────┘ JetstreamConsumer
|
└──────────────────────────────────────────┘ JetstreamConsumer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
🔒 = Bearer-Token nötig, und der `sub` des Tokens muss der angefragten DID
|
||||||
|
entsprechen. Die AppView verifiziert die ES256-Signatur mit dem öffentlichen
|
||||||
|
Schlüssel, den die PDS in ihrem DID-Dokument veröffentlicht — `PDS_JWT_SECRET`
|
||||||
|
verlässt die PDS nie. Details in [`deployment.md`](deployment.md), Abschnitt 6.
|
||||||
|
|
||||||
Zwei Wege führen in die AppView, und das ist Absicht:
|
Zwei Wege führen in die AppView, und das ist Absicht:
|
||||||
|
|
||||||
1. **Direkter Push (schnell, lokal).** Jeder erfolgreiche Commit auf der PDS
|
1. **Direkter Push (schnell, lokal).** Jeder erfolgreiche Commit auf der PDS
|
||||||
|
|||||||
+76
-21
@@ -287,7 +287,7 @@ Hinweise:
|
|||||||
* Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop`
|
* Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop`
|
||||||
beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte
|
beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte
|
||||||
Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird —
|
Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird —
|
||||||
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 8). Das ist
|
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 9). Das ist
|
||||||
unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit
|
unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit
|
||||||
zeigt und Events idempotent verarbeitet werden.
|
zeigt und Events idempotent verarbeitet werden.
|
||||||
* Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf
|
* Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf
|
||||||
@@ -296,7 +296,55 @@ Hinweise:
|
|||||||
`PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn
|
`PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn
|
||||||
der erste Connect scheitert. `Restart=on-failure` fängt das ab.
|
der erste Connect scheitert. `Restart=on-failure` fängt das ab.
|
||||||
|
|
||||||
## 6. Reverse-Proxy
|
## 6. Authentifizierung
|
||||||
|
|
||||||
|
Die AppView prüft seit Phase 9 Bearer-Tokens. Wie das zusammenhängt:
|
||||||
|
|
||||||
|
1. Die PDS stellt beim Login ein ES256-Access-JWT aus (`sub` = DID,
|
||||||
|
`scope` = `com.atproto.access`, eine Stunde gültig).
|
||||||
|
2. Die PDS veröffentlicht den *öffentlichen* Teil ihres P-256-Schlüssels unter
|
||||||
|
`GET /.well-known/did.json`. `PDS_JWT_SECRET` verlässt den PDS-Prozess nicht.
|
||||||
|
3. Die AppView holt dieses Dokument beim Start von `PDS_INTERNAL_URL`
|
||||||
|
(Fallback: `PDS_PUBLIC_URL`), cached den Schlüssel und lädt ihn bei einem
|
||||||
|
Verifikationsfehler einmal nach — höchstens einmal pro Minute, damit
|
||||||
|
Müll-Tokens kein Werkzeug werden, die PDS zu fluten. Ein Schlüsselwechsel
|
||||||
|
braucht also keinen Neustart der AppView.
|
||||||
|
|
||||||
|
Ist die PDS beim Start nicht erreichbar, warnt die AppView nur und startet
|
||||||
|
trotzdem — sie indiziert den Firehose, was von der lokalen PDS unabhängig ist.
|
||||||
|
Der Schlüssel wird dann beim ersten authentifizierten Request geholt. Klappt
|
||||||
|
auch das nicht, antwortet sie `503 AuthUnavailable`: **fail closed**, nie
|
||||||
|
fail open.
|
||||||
|
|
||||||
|
### Welche Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Zugriff |
|
||||||
|
|---|---|
|
||||||
|
| `/api/timeline/home`, `/api/notifications`, `/api/notifications/count`, `/api/notifications/seen` | Token nötig, `sub` muss dem `did`-Parameter entsprechen |
|
||||||
|
| `/api/profile*`, `/api/search`, `/api/post/*`, `/api/thread*`, `/api/followers`, `/api/following` | öffentlich (in AT Proto öffentliche Records) |
|
||||||
|
| `/internal/ingest-commit` | `APPVIEW_INGEST_SECRET`, server-zu-server |
|
||||||
|
|
||||||
|
### Fehlercodes
|
||||||
|
|
||||||
|
| Fall | Status | `error` |
|
||||||
|
|---|---|---|
|
||||||
|
| Header fehlt oder ist kein Bearer | 401 | `AuthMissing` |
|
||||||
|
| Signatur falsch, abgelaufen, falscher `scope` | 401 | `TokenInvalid` |
|
||||||
|
| Token gültig, aber `sub` ≠ `did` | 403 | `Forbidden` |
|
||||||
|
| Schlüssel der PDS nicht beschaffbar | 503 | `AuthUnavailable` |
|
||||||
|
|
||||||
|
`TokenInvalid` ist ein Vertrag mit dem Desktop-Client: daran erkennt er, dass
|
||||||
|
er sein Access-JWT erneuern und den Request einmal wiederholen muss. Wer den
|
||||||
|
Code umbenennt, loggt jeden Nutzer eine Stunde nach dem Login aus.
|
||||||
|
|
||||||
|
### `APPVIEW_AUTH_REQUIRED=false`
|
||||||
|
|
||||||
|
Schaltet die Prüfung ab und stellt das alte Verhalten her — gedacht für eine
|
||||||
|
Instanz hinter VPN und für die fail-open-Integrationstests. Die AppView warnt
|
||||||
|
beim Start in Großbuchstaben. Öffentlich erreichbar heißt das: jeder kann die
|
||||||
|
Notifications jeder DID lesen und als gelesen markieren.
|
||||||
|
|
||||||
|
## 7. Reverse-Proxy
|
||||||
|
|
||||||
### PDS
|
### PDS
|
||||||
|
|
||||||
@@ -357,25 +405,31 @@ im DID-Doc-`serviceEndpoint`, im JWT-`iss` und als Basis der Blob-URLs.
|
|||||||
kein `/.well-known/atproto-did` aus (im Router nicht vorhanden). `at-identity`
|
kein `/.well-known/atproto-did` aus (im Router nicht vorhanden). `at-identity`
|
||||||
kann solche Dokumente *auflösen*, aber wer `did:web`-Handles auf dieser PDS
|
kann solche Dokumente *auflösen*, aber wer `did:web`-Handles auf dieser PDS
|
||||||
betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern.
|
betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern.
|
||||||
Ebenfalls offen: `describeServer` gibt die DID hart als
|
Die Service-DID wird inzwischen aus `PDS_PUBLIC_URL` abgeleitet
|
||||||
`did:web:pds.maarcadetweet.local` zurück, unabhängig von `PDS_PUBLIC_URL`.
|
(`AppConfig::pds_did()`, did:web mit `%3A`-kodiertem Port) und von
|
||||||
|
`describeServer` **und** `/.well-known/did.json` identisch ausgeliefert. Das
|
||||||
|
heißt auch: ändert sich `PDS_PUBLIC_URL`, ändert sich die Service-DID.
|
||||||
|
|
||||||
### AppView
|
### AppView
|
||||||
|
|
||||||
Die AppView setzt ihr CORS selbst — `crates/appview/src/routes.rs`:
|
Die AppView setzt ihr CORS selbst (`cors_layer()` in
|
||||||
|
`crates/appview/src/routes.rs`). Ohne `APPVIEW_CORS_ORIGINS` bleibt es beim
|
||||||
|
alten `Access-Control-Allow-Origin: *` — die AppView warnt dann beim Start.
|
||||||
|
Mit gesetzter Variable gilt eine Allowlist:
|
||||||
|
|
||||||
```rust
|
```
|
||||||
let cors = CorsLayer::new()
|
APPVIEW_CORS_ORIGINS=tauri://localhost,http://tauri.localhost,http://127.0.0.1:1430
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Also `Access-Control-Allow-Origin: *` für alle Routen, inklusive
|
Der Tauri-Webview ruft die AppView von einem anderen Origin aus auf — im Dev
|
||||||
`/internal/ingest-commit`. Der Grund steht im Code: der Tauri-Webview ruft die
|
die Vite-Adresse, im Bundle `tauri://localhost` (macOS/Linux) bzw.
|
||||||
AppView von einem anderen Origin aus auf (`http://127.0.0.1:1430` im Dev,
|
`http://tauri.localhost` (Windows). Alle drei gehören in die Liste, sonst
|
||||||
`tauri://` / `asset://` im Bundle), und die Read-Endpoints tragen keine
|
scheitert der Preflight.
|
||||||
Auth-Cookies.
|
|
||||||
|
`/internal/ingest-commit` liegt bewusst **außerhalb** der CORS-Schicht: die
|
||||||
|
Route wird von der PDS server-zu-server aufgerufen, nie von einem Browser. Ein
|
||||||
|
`Access-Control-Allow-Origin` darauf würde ausschließlich einer Webseite
|
||||||
|
helfen, in den Index zu schreiben.
|
||||||
|
|
||||||
Für ein öffentliches Deployment heißt das:
|
Für ein öffentliches Deployment heißt das:
|
||||||
|
|
||||||
@@ -408,7 +462,7 @@ HTTP-Aufrufe an PDS/AppView laufen über den Rust-IPC-Layer
|
|||||||
(`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview —
|
(`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview —
|
||||||
die CSP muss also für neue Backend-URLs nicht angefasst werden.
|
die CSP muss also für neue Backend-URLs nicht angefasst werden.
|
||||||
|
|
||||||
## 7. Health-Checks und Logs
|
## 8. Health-Checks und Logs
|
||||||
|
|
||||||
### PDS
|
### PDS
|
||||||
|
|
||||||
@@ -472,7 +526,7 @@ Log-Zeilen, auf die es sich lohnt zu achten:
|
|||||||
| `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar |
|
| `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar |
|
||||||
| `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert |
|
| `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert |
|
||||||
|
|
||||||
## 8. Neustart-Verhalten
|
## 9. Neustart-Verhalten
|
||||||
|
|
||||||
**PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore
|
**PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore
|
||||||
(`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent
|
(`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent
|
||||||
@@ -515,14 +569,15 @@ nach — zuerst über die lokale PDS (`PdsHandleResolver`, 2 s Timeout), dann PL
|
|||||||
bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der
|
bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der
|
||||||
Zustand ist reine Anzeigekosmetik.
|
Zustand ist reine Anzeigekosmetik.
|
||||||
|
|
||||||
## 9. Was noch offen ist
|
## 10. Was noch offen ist
|
||||||
|
|
||||||
* Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur
|
* Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur
|
||||||
Postgres und MinIO ab. Es gibt kein Dockerfile im Repo.
|
Postgres und MinIO ab. Es gibt kein Dockerfile im Repo.
|
||||||
* `at-blob` spricht ausschließlich MinIO ohne Signature V4 (siehe Modul-Doku in
|
* `at-blob` spricht ausschließlich MinIO ohne Signature V4 (siehe Modul-Doku in
|
||||||
`crates/at-blob/src/s3.rs`); echtes AWS S3 funktioniert damit nicht.
|
`crates/at-blob/src/s3.rs`); echtes AWS S3 funktioniert damit nicht.
|
||||||
* Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe.
|
* Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe.
|
||||||
* Keine konfigurierbare CORS-Allowlist in der AppView.
|
* `aud` wird beim Token-Check nicht validiert (`verify_jwt` setzt
|
||||||
* Kein `.well-known`-Handling in der PDS, `describeServer` liefert eine
|
`validate_aud = false`), obwohl die PDS `did:web:appview.maarcadetweet.local`
|
||||||
hartkodierte DID.
|
einsetzt. Signatur, Ablauf, `scope` und `sub` werden geprüft.
|
||||||
|
* Notifications werden nie gelöscht; ein Unlike/Unfollow lässt die Zeile stehen.
|
||||||
* Kein Backfill-Werkzeug für Jetstream-Lücken.
|
* Kein Backfill-Werkzeug für Jetstream-Lücken.
|
||||||
|
|||||||
@@ -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