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
|
||||
|
||||
# --- 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 auth (Bearer-Token der PDS) ---
|
||||
# Erzwingt ein gültiges Access-JWT auf den privaten Endpoints
|
||||
# (/api/notifications, /api/notifications/count,
|
||||
# /api/notifications/seen, /api/timeline/home); `sub` im Token muss
|
||||
# dem `did`-Parameter entsprechen, sonst 403. Default: true.
|
||||
# Den öffentlichen P-256-Schlüssel holt sich die AppView von
|
||||
# `PDS_INTERNAL_URL` (sonst `PDS_PUBLIC_URL`) unter
|
||||
# /.well-known/did.json — `PDS_JWT_SECRET` verlässt die PDS nie.
|
||||
# Auf `false` verhält sich die AppView wie vor der Auth-Einführung
|
||||
# (alles öffentlich): nötig für die fail-open-Integrationstests mit
|
||||
# synthetischen DIDs und für eine Instanz, die schon per VPN
|
||||
# abgeschottet ist. Die AppView warnt beim Start laut, wenn er aus ist.
|
||||
# APPVIEW_AUTH_REQUIRED=true
|
||||
# Kommaseparierte Allowlist der Browser-Origins, die `/api/*` aufrufen
|
||||
# dürfen. Nicht gesetzt = bisheriges Verhalten
|
||||
# (`Access-Control-Allow-Origin: *`) plus Startup-Warnung.
|
||||
# Der Tauri-Webview hat je nach Plattform eine eigene Origin:
|
||||
# macOS/iOS `tauri://localhost`, Windows `http://tauri.localhost`,
|
||||
# im Dev-Modus der Vite-Server `http://127.0.0.1:1430`.
|
||||
# `Authorization` steht immer in den erlaubten Headern (sonst
|
||||
# scheitert der Preflight), `allow_credentials` bleibt aus.
|
||||
# APPVIEW_CORS_ORIGINS=tauri://localhost,http://127.0.0.1:1430
|
||||
|
||||
# --- Tauri-Client (Build-/Laufzeit-Overrides des Desktop-Clients) ---
|
||||
# MAARCADETWEET_PDS_URL=http://127.0.0.1:2583
|
||||
# MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584
|
||||
|
||||
Generated
+1
@@ -53,6 +53,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"p256",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"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 |
|
||||
| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev inert (Production-Weg: [`docs/tauri-release.md`](docs/tauri-release.md)) |
|
||||
| 8 Social-Graph + Benachrichtigungen | ✅ done — `notifications`-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count|/seen]`, `/api/followers`, `/api/following`, eigene `/api/thread`-Route; im Client Notifications-View mit Unread-Badge und klickbare Follower-/Following-Listen im Profil |
|
||||
| 9 Auth + Performance | ✅ done — AppView prüft Bearer-Tokens (ES256, Schlüssel aus dem neuen `/.well-known/did.json` der PDS, fail closed); Timeline und Notifications nur noch für die eigene DID; CORS-Allowlist statt `Any`; Indizes für Handle-Lookup und Cold-Start-Feed |
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -81,11 +82,13 @@ Stand zuletzt gegen den lokalen Dev-Stack (docker compose + laufender PDS + AppV
|
||||
Rust-Workspace grün (u.a. 27 MST, 24 PDS-Integration, 49 AppView-Lib, 14 AppView-Integration),
|
||||
Frontend grün. Zwei Vorbehalte:
|
||||
|
||||
* Die DB-gestützten Integrationstests sind *fail-open* — ohne erreichbare Postgres/PDS
|
||||
überspringen sie sich selbst und melden das nur auf stderr. Ein grüner Lauf ohne
|
||||
laufenden Stack sagt also weniger, als er aussieht.
|
||||
* Einige Tests hängen am Zustand der Dev-Datenbank; auf einer frischen DB können
|
||||
`handle_sync`-Tests abweichen. Wer sie ernst nimmt, prüft sie gegen eine definierte DB.
|
||||
* Die DB-gestützten Tests sind *fail-open*: **ohne `DATABASE_URL_APPVIEW` in der
|
||||
Umgebung überspringen sie sich selbst** und melden das nur auf stderr. `cargo test
|
||||
--workspace` in einer nackten Shell meldet dann grün, ohne sie ausgeführt zu haben —
|
||||
für einen aussagekräftigen Lauf `set -a; . ./.env; set +a` voranstellen und PDS +
|
||||
AppView laufen lassen.
|
||||
* Läuft die Auth (Default), holen sich die Integrationstests echte Tokens von der PDS
|
||||
bzw. signieren sie aus `PDS_JWT_SECRET`; ohne erreichbare PDS überspringen sie.
|
||||
|
||||
`crates/tauri-app/src-tauri` hat ein eigenes `[workspace]` und ist **nicht** Teil des
|
||||
Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
|
||||
@@ -103,8 +106,8 @@ Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
|
||||
* Die eigene PDS speist **keinen** Firehose (`com.atproto.sync.subscribeRepos` fehlt) —
|
||||
eigene Records erreichen die AppView nur über den Best-Effort-Push
|
||||
`POST /internal/ingest-commit`.
|
||||
* Die AppView-Leseschnittstelle hat **keine Auth** und CORS `Any`; bei
|
||||
`/api/notifications` sind das erstmals halbwegs private Daten.
|
||||
* `aud` wird beim Token-Check nicht validiert (Signatur, Ablauf, `scope` und
|
||||
`sub` schon).
|
||||
* Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der
|
||||
Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer".
|
||||
* Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das
|
||||
@@ -112,8 +115,6 @@ Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
|
||||
* Reply-Notifications gehen verloren, wenn die Antwort vor ihrem Parent indiziert wird
|
||||
(kein Nachlauf) — bei Jetstream möglich.
|
||||
* `at-blob` spricht MinIO ohne Signature V4 — echtes AWS S3 funktioniert damit nicht.
|
||||
* Die PDS liefert kein `.well-known/did.json`; `describeServer` gibt die DID hart
|
||||
als `did:web:pds.maarcadetweet.local` zurück.
|
||||
|
||||
## Design
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ at-shared = { workspace = true }
|
||||
at-firehose = { workspace = true }
|
||||
at-crypto = { workspace = true }
|
||||
at-identity = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging", "tls12"] }
|
||||
base64 = { workspace = true }
|
||||
@@ -45,3 +46,7 @@ tokio = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde_json = { 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`]
|
||||
/// 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(
|
||||
r#"SELECT DISTINCT did
|
||||
FROM posts
|
||||
@@ -177,7 +180,7 @@ impl HandleSyncWorker {
|
||||
/// freshly seeded DID may simply not make the batch — which made
|
||||
/// the dispatch tests fail for reasons that had nothing to do with
|
||||
/// dispatch. Passing the DIDs in removes that coupling.
|
||||
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();
|
||||
if dids.is_empty() {
|
||||
return Ok(report);
|
||||
@@ -576,6 +579,66 @@ mod tests {
|
||||
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
|
||||
/// `web_resolver` (not the PLC one). Without this routing, every
|
||||
/// `did:web:` post would stay `@<did-prefix>…` forever.
|
||||
@@ -606,9 +669,18 @@ mod tests {
|
||||
)]))
|
||||
.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 {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&plc),
|
||||
pds_resolver: pds,
|
||||
plc_resolver: plc,
|
||||
web_resolver: web,
|
||||
interval_secs: 999,
|
||||
@@ -665,9 +737,16 @@ mod tests {
|
||||
let plc_arc: Arc<dyn DidHandleResolver> = Arc::new(plc);
|
||||
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 {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&plc_arc),
|
||||
pds_resolver: pds_arc,
|
||||
plc_resolver: plc_arc,
|
||||
web_resolver: web_arc,
|
||||
interval_secs: 999,
|
||||
|
||||
@@ -15,8 +15,31 @@
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! In production this endpoint would be protected with mTLS and a token
|
||||
//! minted by the PDS; for now it's open inside the cluster.
|
||||
//! ## Who may call this
|
||||
//!
|
||||
//! 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::state::AppState;
|
||||
@@ -51,8 +74,13 @@ pub struct IngestCommitReq {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
headers: &HeaderMap,
|
||||
configured: Option<&str>,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! tests under `tests/` import from here so they can build a worker
|
||||
//! against a stub resolver without booting the binary.
|
||||
|
||||
pub mod auth;
|
||||
pub mod firehose;
|
||||
pub mod handle_sync;
|
||||
pub mod indexer;
|
||||
|
||||
@@ -7,6 +7,7 @@ use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod auth;
|
||||
mod firehose;
|
||||
mod handle_sync;
|
||||
mod indexer;
|
||||
@@ -83,6 +84,33 @@ async fn main() -> Result<()> {
|
||||
|
||||
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
|
||||
// indexer inserted with an empty placeholder. The worker dispatches
|
||||
// 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
|
||||
// from inside the cluster when TLS / DNS is set up for outside
|
||||
// clients only.
|
||||
let pds_base_url = cfg
|
||||
.pds_internal_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.pds_public_url.clone());
|
||||
//
|
||||
// `AppConfig::pds_base_url()` owns that fallback so the handle
|
||||
// resolver and the signing-key fetch in `auth.rs` can never end up
|
||||
// pointed at different PDS instances.
|
||||
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 {
|
||||
|
||||
+187
-42
@@ -8,6 +8,30 @@
|
||||
//! - `ingest_commit`: the internal-only writer used by the PDS, owned
|
||||
//! 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
|
||||
//! `base64url(micros):uri` pair, which is what [`cursor::encode`] and
|
||||
//! [`cursor::decode`] produce/consume.
|
||||
@@ -19,11 +43,14 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use axum::http::{header, HeaderValue, Method};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
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;
|
||||
|
||||
pub mod cursor;
|
||||
@@ -35,25 +62,73 @@ use types::{
|
||||
ProfileResponse, SearchResponse, ThreadFullResponse, TimelineResponse,
|
||||
};
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
// CORS: the Tauri webview's origin is the Vite dev server
|
||||
// (`http://127.0.0.1:1430`) in dev or the bundled `tauri://` /
|
||||
// `asset://` origin in production. Either way it's a cross-origin
|
||||
// fetch against this service's `http://127.0.0.1:2584` listen
|
||||
// address, so the browser blocks the response without an explicit
|
||||
// allow-origin header. We allow any origin — the AppView's
|
||||
// public read endpoints (`/api/...`) carry no auth cookie and
|
||||
// the AppView runs alongside the user's own PDS, not on the
|
||||
// open internet; production deployments behind a reverse proxy
|
||||
// can tighten this via the proxy itself.
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
/// Build the CORS layer for the browser-facing routes.
|
||||
///
|
||||
/// The Tauri webview is a cross-origin caller: in dev its origin is the
|
||||
/// Vite server (`http://127.0.0.1:1430`), in a packaged build it is a
|
||||
/// platform-specific scheme — `tauri://localhost` on macOS/iOS,
|
||||
/// `http://tauri.localhost` on Windows. Either way the browser drops
|
||||
/// the response unless we send `Access-Control-Allow-Origin`.
|
||||
///
|
||||
/// `APPVIEW_CORS_ORIGINS` is a comma-separated allowlist, e.g.
|
||||
/// `tauri://localhost,http://127.0.0.1:1430`. When it is unset we keep
|
||||
/// the historic wildcard so no existing deployment breaks on upgrade —
|
||||
/// [`crate::auth::log_startup_posture`] warns about that at startup.
|
||||
///
|
||||
/// `Authorization` has to be in `allow_headers`: it is not a
|
||||
/// 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))
|
||||
// --- private: token required, `sub` must equal `did` ---
|
||||
.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/:handle", get(profile_path))
|
||||
.route("/api/search", get(search))
|
||||
@@ -66,15 +141,20 @@ pub fn router(state: AppState) -> Router {
|
||||
// implementation, so they can't drift.
|
||||
.route("/api/thread", get(thread_query))
|
||||
.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/following", get(following))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
.layer(cors);
|
||||
|
||||
// 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> {
|
||||
@@ -115,13 +195,21 @@ const MAX_LIMIT: i64 = 100;
|
||||
/// surface.
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthedDid,
|
||||
Query(q): Query<TimelineQuery>,
|
||||
) -> Result<Json<TimelineResponse>, (StatusCode, Json<Value>)> {
|
||||
if q.did.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
auth.ensure_matches(&q.did)?;
|
||||
let limit = clamp_limit(q.limit);
|
||||
|
||||
// Look up the set of DIDs this user follows, then build the
|
||||
@@ -230,20 +318,51 @@ async fn timeline_home(
|
||||
.map_err(db_err)?,
|
||||
}
|
||||
} else {
|
||||
// Graph-aware branch: filter `posts.did` to the followee set
|
||||
// plus the requesting user's own DID. `target_dids` has been
|
||||
// deduped and capped at MAX_FOLLOWED_DIDS, and the user's own
|
||||
// DID is guaranteed to be in the set.
|
||||
// Graph-aware branch: the followee set plus the requesting
|
||||
// user's own DID. `target_dids` has been deduped and capped at
|
||||
// MAX_FOLLOWED_DIDS, and the user's own DID is guaranteed to
|
||||
// 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 {
|
||||
Some(ts) => sqlx::query_as::<_, PostRowWithIndexed>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
indexed_at
|
||||
FROM posts
|
||||
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
|
||||
AND did = ANY($2::text[])
|
||||
AND (indexed_at, uri) < ($3, $4)
|
||||
ORDER BY indexed_at DESC, uri DESC
|
||||
r#"SELECT t.uri, t.did, t.handle, t.rkey, t.collection,
|
||||
t.text, t.cid, t.parent_uri, t.root_uri,
|
||||
t.embed, t.langs, t.created_at, t.indexed_at
|
||||
FROM unnest($2::text[]) AS f(did)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT p.uri, p.did, p.handle, p.rkey, p.collection,
|
||||
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')
|
||||
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"#,
|
||||
)
|
||||
.bind(fetch)
|
||||
@@ -254,13 +373,21 @@ async fn timeline_home(
|
||||
.await
|
||||
.map_err(db_err)?,
|
||||
None => sqlx::query_as::<_, PostRowWithIndexed>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
indexed_at
|
||||
FROM posts
|
||||
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
|
||||
AND did = ANY($2::text[])
|
||||
ORDER BY indexed_at DESC, uri DESC
|
||||
r#"SELECT t.uri, t.did, t.handle, t.rkey, t.collection,
|
||||
t.text, t.cid, t.parent_uri, t.root_uri,
|
||||
t.embed, t.langs, t.created_at, t.indexed_at
|
||||
FROM unnest($2::text[]) AS f(did)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT p.uri, p.did, p.handle, p.rkey, p.collection,
|
||||
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"#,
|
||||
)
|
||||
.bind(fetch)
|
||||
@@ -1046,13 +1173,19 @@ struct NotificationsQuery {
|
||||
/// 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
|
||||
/// [`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(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthedDid,
|
||||
Query(q): Query<NotificationsQuery>,
|
||||
) -> Result<Json<NotificationsResponse>, (StatusCode, Json<Value>)> {
|
||||
if q.did.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
auth.ensure_matches(&q.did)?;
|
||||
let limit = clamp_limit(q.limit);
|
||||
let keyset = decode_cursor(q.cursor.as_deref())?;
|
||||
// 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
|
||||
/// notification history. That matters because the client polls this
|
||||
/// 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(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthedDid,
|
||||
Query(q): Query<NotificationCountQuery>,
|
||||
) -> Result<Json<NotificationCountResponse>, (StatusCode, Json<Value>)> {
|
||||
if q.did.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
auth.ensure_matches(&q.did)?;
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM notifications \
|
||||
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
|
||||
/// `now()` (when we recorded the ack), not to `seenAt` (which is a
|
||||
/// 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(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthedDid,
|
||||
Json(req): Json<NotificationsSeenReq>,
|
||||
) -> Result<Json<NotificationSeenResponse>, (StatusCode, Json<Value>)> {
|
||||
if req.did.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
auth.ensure_matches(&req.did)?;
|
||||
let res = sqlx::query(
|
||||
r#"UPDATE notifications
|
||||
SET read_at = now()
|
||||
|
||||
@@ -2,18 +2,30 @@ use at_shared::config::AppConfig;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::auth::PdsKeys;
|
||||
use crate::firehose::Stats;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
#[allow(dead_code)]
|
||||
pub cfg: AppConfig,
|
||||
pub db: PgPool,
|
||||
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 {
|
||||
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
|
||||
//! environments where the appview hasn't been started.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::TestAuth;
|
||||
use serde_json::{json, Value};
|
||||
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 {
|
||||
c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
|
||||
.json(&body)
|
||||
@@ -120,6 +153,7 @@ async fn timeline_returns_seeded_posts() {
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let Some(auth) = auth_or_skip().await else { return };
|
||||
let did = did_for_test("tl");
|
||||
|
||||
// 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
|
||||
// seeded fall outside a 10-row window and the assertions below
|
||||
// fail for reasons that have nothing to do with the timeline.
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[("did", did.as_str()), ("limit", "100")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &did, &[("limit", "100")]).await;
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let posts = body["posts"].as_array().expect("posts is array");
|
||||
@@ -222,6 +251,7 @@ async fn timeline_paginates_with_cursor() {
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let Some(auth) = auth_or_skip().await else { return };
|
||||
let did = did_for_test("pg");
|
||||
|
||||
// Seed 50 posts.
|
||||
@@ -246,28 +276,14 @@ async fn timeline_paginates_with_cursor() {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Page 1: limit=20.
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[("did", did.as_str()), ("limit", "20")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &did, &[("limit", "20")]).await;
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let page1 = body["posts"].as_array().unwrap().clone();
|
||||
let cursor1 = body["cursor"].as_str().expect("page1 cursor");
|
||||
assert_eq!(page1.len(), 20, "page1 should be exactly 20");
|
||||
|
||||
// Page 2: with cursor.
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[
|
||||
("did", did.as_str()),
|
||||
("limit", "20"),
|
||||
("cursor", cursor1),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &did, &[("limit", "20"), ("cursor", cursor1)]).await;
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let page2 = body["posts"].as_array().unwrap().clone();
|
||||
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.
|
||||
let cursor2 = body["cursor"].as_str().expect("page2 cursor");
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[
|
||||
("did", did.as_str()),
|
||||
("limit", "20"),
|
||||
("cursor", cursor2),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &did, &[("limit", "20"), ("cursor", cursor2)]).await;
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let page3 = body["posts"].as_array().unwrap().clone();
|
||||
assert!(page3.len() <= 20, "page3 should be <= 20");
|
||||
@@ -484,6 +491,7 @@ async fn timeline_filters_to_followees() {
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let Some(auth) = auth_or_skip().await else { return };
|
||||
let url = std::env::var("DATABASE_URL_APPVIEW").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;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[("did", alice.as_str()), ("limit", "100")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &alice, &[("limit", "100")]).await;
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let posts = body["posts"].as_array().expect("posts is array");
|
||||
@@ -555,18 +558,14 @@ async fn timeline_includes_own_posts() {
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let Some(auth) = auth_or_skip().await else { return };
|
||||
let alice = did_for_test("alone");
|
||||
|
||||
// Alice posts without seeding any follows.
|
||||
seed_posts(&c, &alice, &["alice's first post", "alice's second post"]).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[("did", alice.as_str()), ("limit", "100")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &alice, &[("limit", "100")]).await;
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let posts = body["posts"].as_array().expect("posts is array");
|
||||
@@ -605,6 +604,7 @@ async fn timeline_caps_followee_list() {
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let Some(auth) = auth_or_skip().await else { return };
|
||||
let url = std::env::var("DATABASE_URL_APPVIEW").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;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[("did", alice.as_str()), ("limit", "50")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &alice, &[("limit", "50")]).await;
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
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
|
||||
//! catch regressions in CI where the service IS up.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::TestAuth;
|
||||
use serde_json::{json, Value};
|
||||
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 {
|
||||
c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
|
||||
.json(&body)
|
||||
@@ -125,6 +154,7 @@ async fn timeline_includes_embed() {
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let Some(auth) = auth_or_skip().await else { return };
|
||||
let did = did_for_test("img");
|
||||
let uri = seed_post(
|
||||
&c,
|
||||
@@ -160,12 +190,7 @@ async fn timeline_includes_embed() {
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[("did", did.as_str()), ("limit", "10")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let posts = body["posts"].as_array().unwrap();
|
||||
@@ -199,6 +224,7 @@ async fn timeline_includes_external_embed() {
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let Some(auth) = auth_or_skip().await else { return };
|
||||
let did = did_for_test("ext");
|
||||
let uri = seed_post(
|
||||
&c,
|
||||
@@ -222,12 +248,7 @@ async fn timeline_includes_external_embed() {
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[("did", did.as_str()), ("limit", "10")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let posts = body["posts"].as_array().unwrap();
|
||||
@@ -241,12 +262,7 @@ async fn timeline_includes_external_embed() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[("did", did.as_str()), ("limit", "10")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
our = body["posts"]
|
||||
.as_array()
|
||||
@@ -410,6 +426,7 @@ async fn timeline_post_without_embed_has_null_embed() {
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let Some(auth) = auth_or_skip().await else { return };
|
||||
let did = did_for_test("plain");
|
||||
let uri = seed_post(
|
||||
&c,
|
||||
@@ -421,12 +438,7 @@ async fn timeline_post_without_embed_has_null_embed() {
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{APPVIEW_URL}/api/timeline/home"))
|
||||
.query(&[("did", did.as_str()), ("limit", "10")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get_timeline(&c, &auth, &did, &[("limit", "10")]).await;
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let our = body["posts"]
|
||||
|
||||
@@ -175,8 +175,13 @@ async fn sync_resolves_known_did() {
|
||||
seed_post(&db, &did, "rkb", "", "second").await.unwrap();
|
||||
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 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.failed, 0);
|
||||
assert_eq!(report.skipped, 0);
|
||||
@@ -224,9 +229,16 @@ async fn sync_skips_already_resolved() {
|
||||
.into_arc();
|
||||
|
||||
let worker = worker_with(db.clone(), stub.clone());
|
||||
let report = worker.run_once().await.unwrap();
|
||||
assert_eq!(report.resolved, 0, "{report:?}");
|
||||
assert_eq!(report.failed, 0);
|
||||
// This test is about the SELECT: a DID whose rows already carry a
|
||||
// handle must never reach a resolver in the first place. So assert
|
||||
// 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.
|
||||
let (cnt,): (i64,) = sqlx::query_as(
|
||||
@@ -279,7 +291,16 @@ async fn sync_respects_limit() {
|
||||
let stub = StubResolver::new(mapping).into_arc();
|
||||
|
||||
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!(
|
||||
report.resolved as i64,
|
||||
BATCH_SIZE,
|
||||
@@ -339,7 +360,7 @@ async fn sync_skips_unresolvable_dids() {
|
||||
let stub = StubResolver::new(HashMap::new()).into_arc();
|
||||
|
||||
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.failed, 0);
|
||||
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 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 {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&plc_arc),
|
||||
pds_resolver: pds_arc,
|
||||
plc_resolver: plc_arc,
|
||||
web_resolver: web_arc,
|
||||
interval_secs: 999,
|
||||
};
|
||||
let report = worker.run_once().await.unwrap();
|
||||
let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
|
||||
assert_eq!(
|
||||
report.resolved, 1,
|
||||
"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 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 {
|
||||
db: db.clone(),
|
||||
pds_resolver: Arc::clone(&plc_arc),
|
||||
pds_resolver: pds_arc,
|
||||
plc_resolver: plc_arc,
|
||||
web_resolver: web_arc,
|
||||
interval_secs: 999,
|
||||
};
|
||||
let report = worker.run_once().await.unwrap();
|
||||
let report = worker.resolve_batch(vec![did.clone()]).await.unwrap();
|
||||
assert_eq!(
|
||||
report.resolved, 1,
|
||||
"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
|
||||
//! between what the write path stores and what the read path joins.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::TestAuth;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -55,8 +58,12 @@ async fn db_pool() -> Option<sqlx::PgPool> {
|
||||
}
|
||||
|
||||
/// Guard used at the top of every test. Returns `None` (→ skip) unless
|
||||
/// both the HTTP service and the database are up.
|
||||
async fn ready() -> Option<(reqwest::Client, sqlx::PgPool)> {
|
||||
/// the HTTP service is up, the database is reachable, **and** we know
|
||||
/// how to authenticate: `/api/notifications*` now requires a token
|
||||
/// whose `sub` is the requested DID, and the DIDs seeded here are
|
||||
/// synthetic, so the token has to be minted from the PDS's signing
|
||||
/// secret. See `tests/common/mod.rs`.
|
||||
async fn ready() -> Option<(reqwest::Client, sqlx::PgPool, TestAuth)> {
|
||||
if !wait_for_appview_db().await {
|
||||
eprintln!("appview not running, skipping");
|
||||
return None;
|
||||
@@ -65,7 +72,32 @@ async fn ready() -> Option<(reqwest::Client, sqlx::PgPool)> {
|
||||
eprintln!("appview DB unreachable, skipping");
|
||||
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 {
|
||||
@@ -181,7 +213,7 @@ async fn seed_follow(c: &reqwest::Client, follower: &str, subject: &str) {
|
||||
#[tokio::test]
|
||||
async fn notifications_list_count_and_seen() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
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;
|
||||
seed_follow(&c, &bob, &alice).await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str()), ("limit", "50")])
|
||||
.send()
|
||||
.await
|
||||
@@ -253,8 +284,7 @@ async fn notifications_list_count_and_seen() {
|
||||
}
|
||||
|
||||
// The unread count agrees with the list.
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications/count"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -264,8 +294,7 @@ async fn notifications_list_count_and_seen() {
|
||||
assert_eq!(body["count"], json!(3));
|
||||
|
||||
// Mark everything seen.
|
||||
let resp = c
|
||||
.post(format!("{base}/api/notifications/seen"))
|
||||
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||
.json(&json!({ "did": alice }))
|
||||
.send()
|
||||
.await
|
||||
@@ -276,8 +305,7 @@ async fn notifications_list_count_and_seen() {
|
||||
assert_eq!(body["updated"], json!(3));
|
||||
|
||||
// Idempotent: a second call updates nothing and still succeeds.
|
||||
let resp = c
|
||||
.post(format!("{base}/api/notifications/seen"))
|
||||
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||
.json(&json!({ "did": alice }))
|
||||
.send()
|
||||
.await
|
||||
@@ -286,8 +314,7 @@ async fn notifications_list_count_and_seen() {
|
||||
assert_eq!(body["updated"], json!(0));
|
||||
|
||||
// Count is now zero and the rows carry a read_at.
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications/count"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -295,8 +322,7 @@ async fn notifications_list_count_and_seen() {
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["count"], json!(0));
|
||||
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -311,8 +337,7 @@ async fn notifications_list_count_and_seen() {
|
||||
"/api/notifications",
|
||||
"/api/notifications/count",
|
||||
] {
|
||||
let resp = c
|
||||
.get(format!("{base}{path}"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}{path}"), &alice)
|
||||
.query(&[("did", "")])
|
||||
.send()
|
||||
.await
|
||||
@@ -326,7 +351,7 @@ async fn notifications_list_count_and_seen() {
|
||||
#[tokio::test]
|
||||
async fn notifications_skip_self_interactions() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
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_follow(&c, &alice, &alice).await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -355,7 +379,7 @@ async fn notifications_skip_self_interactions() {
|
||||
#[tokio::test]
|
||||
async fn notifications_paginate_with_cursor() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let alice = did_for_test("popular");
|
||||
@@ -372,9 +396,9 @@ async fn notifications_paginate_with_cursor() {
|
||||
let c = c.clone();
|
||||
let alice = alice.clone();
|
||||
let base = base.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
let mut req = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let mut req = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str()), ("limit", "5")]);
|
||||
if let Some(cur) = cursor {
|
||||
req = req.query(&[("cursor", cur.as_str())]);
|
||||
@@ -417,8 +441,7 @@ async fn notifications_paginate_with_cursor() {
|
||||
assert_eq!(all.len(), 12);
|
||||
|
||||
// A mangled cursor is a 400, not a silent restart at page 1.
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str()), ("cursor", "!!!garbage!!!")])
|
||||
.send()
|
||||
.await
|
||||
@@ -431,7 +454,7 @@ async fn notifications_paginate_with_cursor() {
|
||||
#[tokio::test]
|
||||
async fn notifications_seen_respects_watermark() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
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
|
||||
// watermark a client would echo after rendering page 1.
|
||||
let body: Value = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let body: Value = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -461,8 +483,7 @@ async fn notifications_seen_respects_watermark() {
|
||||
let second = did_for_test("late");
|
||||
seed_like(&c, &second, &post_uri).await;
|
||||
|
||||
let resp = c
|
||||
.post(format!("{base}/api/notifications/seen"))
|
||||
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||
.json(&json!({ "did": alice, "seenAt": watermark }))
|
||||
.send()
|
||||
.await
|
||||
@@ -475,8 +496,7 @@ async fn notifications_seen_respects_watermark() {
|
||||
);
|
||||
|
||||
// The later one is still unread.
|
||||
let body: Value = c
|
||||
.get(format!("{base}/api/notifications/count"))
|
||||
let body: Value = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -487,8 +507,7 @@ async fn notifications_seen_respects_watermark() {
|
||||
assert_eq!(body["count"], json!(1));
|
||||
|
||||
// snake_case spelling must work identically.
|
||||
let resp = c
|
||||
.post(format!("{base}/api/notifications/seen"))
|
||||
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||
.json(&json!({ "did": alice, "seen_at": null }))
|
||||
.send()
|
||||
.await
|
||||
@@ -502,7 +521,7 @@ async fn notifications_seen_respects_watermark() {
|
||||
#[tokio::test]
|
||||
async fn followers_and_following_lists() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, _auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
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!(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"] {
|
||||
let resp = c
|
||||
.get(format!("{base}{path}"))
|
||||
@@ -610,7 +630,7 @@ async fn followers_and_following_lists() {
|
||||
#[tokio::test]
|
||||
async fn thread_returns_parents_and_replies() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, _auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let a = did_for_test("root");
|
||||
@@ -689,7 +709,7 @@ async fn thread_returns_parents_and_replies() {
|
||||
#[tokio::test]
|
||||
async fn post_by_uri_stays_backwards_compatible() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, _auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let a = did_for_test("compat_a");
|
||||
|
||||
@@ -7,6 +7,17 @@ fn default_handle_sync_interval() -> u64 {
|
||||
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)]
|
||||
pub struct AppConfig {
|
||||
pub pds_host: String,
|
||||
@@ -46,6 +57,27 @@ pub struct AppConfig {
|
||||
/// directory. Default: 300s (5 minutes).
|
||||
#[serde(default = "default_handle_sync_interval")]
|
||||
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 {
|
||||
@@ -82,6 +114,183 @@ impl AppConfig {
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.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 exp = now + 3600;
|
||||
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(),
|
||||
aud: "did:web:appview.maarcadetweet.local".into(),
|
||||
iat: now,
|
||||
|
||||
@@ -73,6 +73,7 @@ pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/.well-known/did.json", get(did_document))
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.describeServer",
|
||||
get(describe_server),
|
||||
@@ -163,9 +164,69 @@ async fn healthz() -> Json<serde_json::Value> {
|
||||
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> {
|
||||
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
|
||||
.cfg
|
||||
.pds_handle_dns_zone
|
||||
|
||||
@@ -38,11 +38,86 @@ async fn describe_server() {
|
||||
.json()
|
||||
.await
|
||||
.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_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]
|
||||
async fn create_account_session_refresh_resolve() {
|
||||
if !wait_for_pds().await {
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
//! 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
|
||||
//! `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 reqwest::Client;
|
||||
@@ -58,9 +80,10 @@ pub struct SearchResponse {
|
||||
///
|
||||
/// `like_count` and `repost_count` are included when the server
|
||||
/// resolves a real post; they're `None` for the "not in index"
|
||||
/// sentinel response (where `post` is null). The AppView has no
|
||||
/// auth yet, so we don't get `viewer_liked` / `viewer_reposted`
|
||||
/// from the server.
|
||||
/// sentinel response (where `post` is null). `/api/post/{uri}` is a
|
||||
/// public endpoint that takes no token, so there is no viewer to
|
||||
/// resolve against and we don't get `viewer_liked` /
|
||||
/// `viewer_reposted`; [`Self::fetch_thread`] with a `viewer_did` does.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResponse {
|
||||
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(
|
||||
&self,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
access_jwt: &str,
|
||||
) -> Result<TimelineResponse> {
|
||||
let mut req = self
|
||||
.client
|
||||
.get(format!("{}/api/timeline/home", self.base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||
if let Some(c) = cursor {
|
||||
req = req.query(&[("cursor", c)]);
|
||||
@@ -211,13 +240,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send timeline request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: timeline home returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("timeline home", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<TimelineResponse>()
|
||||
@@ -244,13 +267,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send profile request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: profile returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("profile", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<ProfileResponse>()
|
||||
@@ -268,13 +285,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send profile-by-did request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: profile-by-did returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("profile-by-did", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<ProfileResponse>()
|
||||
@@ -292,13 +303,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send search request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: search returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("search", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<SearchResponse>()
|
||||
@@ -323,13 +328,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send post request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: post returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("post", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<ThreadResponse>()
|
||||
@@ -363,13 +362,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send thread request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: thread returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("thread", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<ThreadFullResponse>()
|
||||
@@ -377,17 +370,20 @@ impl AppViewClient {
|
||||
.context("appview: thread JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/notifications?did=&limit=&cursor=` — newest first,
|
||||
/// same opaque-cursor pagination contract as the timeline.
|
||||
/// `GET /api/notifications?did=&limit=&cursor=` — **authenticated**;
|
||||
/// newest first, same opaque-cursor pagination contract as the
|
||||
/// timeline.
|
||||
pub async fn fetch_notifications(
|
||||
&self,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
access_jwt: &str,
|
||||
) -> Result<NotificationsResponse> {
|
||||
let mut req = self
|
||||
.client
|
||||
.get(format!("{}/api/notifications", self.base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||
if let Some(c) = cursor {
|
||||
req = req.query(&[("cursor", c)]);
|
||||
@@ -397,13 +393,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send notifications request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: notifications returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("notifications", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<NotificationsResponse>()
|
||||
@@ -411,25 +401,24 @@ impl AppViewClient {
|
||||
.context("appview: notifications JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/notifications/count?did=` — unread count for the
|
||||
/// NavRail badge. Cheap enough to poll (partial index on the
|
||||
/// server side).
|
||||
pub async fn notification_count(&self, did: &str) -> Result<NotificationCountResponse> {
|
||||
/// `GET /api/notifications/count?did=` — **authenticated**; unread
|
||||
/// count for the NavRail badge. Cheap enough to poll (partial index
|
||||
/// on the server side).
|
||||
pub async fn notification_count(
|
||||
&self,
|
||||
did: &str,
|
||||
access_jwt: &str,
|
||||
) -> Result<NotificationCountResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/notifications/count", self.base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.query(&[("did", did)])
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send notification-count request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: notification count returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("notification count", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<NotificationCountResponse>()
|
||||
@@ -437,9 +426,10 @@ impl AppViewClient {
|
||||
.context("appview: notification count JSON parse")
|
||||
}
|
||||
|
||||
/// `POST /api/notifications/seen` — mark everything indexed at or
|
||||
/// before `seen_at` as read. Passing `None` marks *all* currently
|
||||
/// unread rows. Idempotent; a second call reports `updated: 0`.
|
||||
/// `POST /api/notifications/seen` — **authenticated**; mark
|
||||
/// everything indexed at or before `seen_at` as read. Passing `None`
|
||||
/// marks *all* currently unread rows. Idempotent; a second call
|
||||
/// reports `updated: 0`.
|
||||
///
|
||||
/// The server accepts both `seenAt` and `seen_at`; we send the
|
||||
/// camelCase spelling because that's what the wire contract
|
||||
@@ -448,6 +438,7 @@ impl AppViewClient {
|
||||
&self,
|
||||
did: &str,
|
||||
seen_at: Option<&str>,
|
||||
access_jwt: &str,
|
||||
) -> Result<NotificationSeenResponse> {
|
||||
let mut body = serde_json::json!({ "did": did });
|
||||
if let Some(ts) = seen_at {
|
||||
@@ -456,18 +447,13 @@ impl AppViewClient {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/notifications/seen", self.base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send notifications-seen request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: notifications seen returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("notifications seen", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<NotificationSeenResponse>()
|
||||
@@ -517,9 +503,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.with_context(|| format!("appview: failed to send {path} request"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("appview: {} returned {}: {}", path, status, body));
|
||||
return Err(status_error(path, resp).await);
|
||||
}
|
||||
resp
|
||||
.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.
|
||||
/// `axum`'s path extractor will decode it back. We use this rather
|
||||
/// than `url::Url::parse(...).path_segments()` because AT-Protocol
|
||||
@@ -553,6 +564,9 @@ fn percent_encode_path(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn percent_encode_path_at_uri() {
|
||||
@@ -563,4 +577,241 @@ mod tests {
|
||||
"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]
|
||||
async fn timeline_home(
|
||||
state: tauri::State<'_, AppState>,
|
||||
@@ -362,9 +384,10 @@ async fn timeline_home(
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::TimelineResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
let jwt = require_access_jwt(&state, "the home timeline")?;
|
||||
state
|
||||
.appview
|
||||
.fetch_timeline(&did, cursor.as_deref(), lim)
|
||||
.fetch_timeline(&did, cursor.as_deref(), lim, &jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -460,9 +483,10 @@ async fn fetch_notifications(
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::NotificationsResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
let jwt = require_access_jwt(&state, "notifications")?;
|
||||
state
|
||||
.appview
|
||||
.fetch_notifications(&did, cursor.as_deref(), lim)
|
||||
.fetch_notifications(&did, cursor.as_deref(), lim, &jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -473,9 +497,10 @@ async fn notification_count(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
) -> Result<appview_client::NotificationCountResponse, String> {
|
||||
let jwt = require_access_jwt(&state, "the unread-notification count")?;
|
||||
state
|
||||
.appview
|
||||
.notification_count(&did)
|
||||
.notification_count(&did, &jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -491,9 +516,10 @@ async fn mark_notifications_seen(
|
||||
did: String,
|
||||
seen_at: Option<String>,
|
||||
) -> Result<appview_client::NotificationSeenResponse, String> {
|
||||
let jwt = require_access_jwt(&state, "marking notifications seen")?;
|
||||
state
|
||||
.appview
|
||||
.mark_notifications_seen(&did, seen_at.as_deref())
|
||||
.mark_notifications_seen(&did, seen_at.as_deref(), &jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
fetchTimeline,
|
||||
fetchSearch,
|
||||
fetchPost,
|
||||
errorMessage,
|
||||
isAuthFailure,
|
||||
notificationCount,
|
||||
openExternalUrl,
|
||||
showError,
|
||||
@@ -377,6 +379,16 @@
|
||||
/// Pull the unread count for the NavRail badge. Swallows errors:
|
||||
/// the badge is ambient information, and a transient AppView hiccup
|
||||
/// shouldn't produce a toast every 5 seconds.
|
||||
///
|
||||
/// One class of error is *not* swallowed-and-retried, though. Since
|
||||
/// the AppView started requiring the access JWT on
|
||||
/// `/api/notifications/count`, a rejected token surfaces here — and
|
||||
/// by the time it does, `safeInvoke` has already spent its one
|
||||
/// automatic refresh. Retrying on a 5s timer would then be a request
|
||||
/// loop against a server that keeps answering 401/403 for as long as
|
||||
/// the app is open. So an auth failure stops the poll outright; the
|
||||
/// next successful login restarts it via the `session.subscribe`
|
||||
/// handler in `onMount`.
|
||||
async function refreshUnreadCount() {
|
||||
if (!currentUser) return;
|
||||
// While the notifications view is open the user is by definition
|
||||
@@ -386,8 +398,13 @@
|
||||
if (view === "notifications") return;
|
||||
try {
|
||||
unreadCount = await notificationCount(currentUser.did);
|
||||
} catch {
|
||||
/* ignore — keep the last known count */
|
||||
} catch (e) {
|
||||
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];
|
||||
}
|
||||
} catch (e) {
|
||||
timelineError = String(e);
|
||||
timelineError = errorMessage(e);
|
||||
// Keep whatever we had on a transient failure.
|
||||
} finally {
|
||||
timelineLoading = false;
|
||||
@@ -440,7 +457,7 @@
|
||||
}
|
||||
timelineCursor = r.cursor;
|
||||
} catch (e) {
|
||||
timelineError = String(e);
|
||||
timelineError = errorMessage(e);
|
||||
} finally {
|
||||
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.
|
||||
*
|
||||
* **Auto-refresh on 401**: the access JWT expires after 1 hour. When
|
||||
* the PDS rejects our token with `TokenInvalid` (the rusty
|
||||
* `routes::auth` handlers return `{"error":"TokenInvalid",...}`),
|
||||
* we ask the Rust shell for a fresh access JWT via the
|
||||
* `auth_refresh` Tauri command. The Rust side reads the stored
|
||||
* refresh JWT (valid for 90 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 PDS *or the AppView* rejects our token with `TokenInvalid`
|
||||
* (both return `{"error":"TokenInvalid",...}` — the PDS from its
|
||||
* `routes::auth` handlers, the AppView from the guard on
|
||||
* `/api/timeline/home`, `/api/notifications`,
|
||||
* `/api/notifications/count` and `/api/notifications/seen`), we ask
|
||||
* the Rust shell for a fresh access JWT via the `auth_refresh` Tauri
|
||||
* command. The Rust side reads the stored refresh JWT (valid for 90
|
||||
* 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> {
|
||||
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.
|
||||
/// Returns true when the error message looks like an expired/
|
||||
/// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its
|
||||
/// JSON error body, which `@tauri-apps/api/core` surfaces verbatim).
|
||||
/// Returns true when the error looks like an expired/invalid JWT —
|
||||
/// both the PDS and the AppView use a stable `"TokenInvalid"` code in
|
||||
/// their JSON error body, which travels verbatim through the Rust
|
||||
/// error message and out over the Tauri IPC boundary.
|
||||
function isTokenInvalid(e: unknown): boolean {
|
||||
if (typeof e !== "object" || e === null) return false;
|
||||
const msg = (e as { message?: string }).message ?? String(e);
|
||||
const msg = errorText(e);
|
||||
if (!msg) return false;
|
||||
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 = {
|
||||
did: string;
|
||||
handle: string;
|
||||
@@ -253,8 +318,10 @@ export type SearchResponse = {
|
||||
///
|
||||
/// `like_count` and `repost_count` are present when the post was
|
||||
/// found; they're `undefined` (or absent) for the "not in index"
|
||||
/// sentinel response (where `post` is null). AppView has no auth
|
||||
/// yet, so `viewer_liked` / `viewer_reposted` aren't returned.
|
||||
/// sentinel response (where `post` is null). `/api/post/{uri}` is one
|
||||
/// 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 = {
|
||||
post: Post | null;
|
||||
thread: {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Avatar from "./Avatar.svelte";
|
||||
import Skeleton from "./Skeleton.svelte";
|
||||
import {
|
||||
errorMessage,
|
||||
fetchNotifications,
|
||||
markNotificationsSeen,
|
||||
notificationIcon,
|
||||
@@ -71,7 +72,7 @@
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
error = errorMessage(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -89,7 +90,7 @@
|
||||
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
|
||||
cursor = r.cursor;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
error = errorMessage(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -194,6 +194,48 @@ describe("NotificationsView actor navigation", () => {
|
||||
expect(onThreadClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows actionable copy when the AppView rejects the session", async () => {
|
||||
// Since `/api/notifications` grew an auth guard, this is what a
|
||||
// rejected token looks like by the time it reaches the view: the
|
||||
// AppView's JSON body, wrapped by `appview_client.rs`'s
|
||||
// `status_error()` and stringified across the Tauri IPC boundary.
|
||||
// `safeInvoke` has already spent its one refresh attempt getting
|
||||
// here, so the only thing left to tell the user is "log in again" —
|
||||
// rendering the raw wire string would be accurate and useless.
|
||||
fetchNotificationsMock.mockRejectedValue(
|
||||
'appview: notifications returned 401 Unauthorized: ' +
|
||||
'{"error":"TokenInvalid","message":"ExpiredSignature"}',
|
||||
);
|
||||
|
||||
app = mount(NotificationsView, {
|
||||
target,
|
||||
props: { did: "did:plc:me" },
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(target.textContent).toContain("bitte neu anmelden");
|
||||
expect(target.textContent).not.toContain("TokenInvalid");
|
||||
expect(target.textContent).not.toContain("401");
|
||||
// A failed load must not leave the spinner up or ack a page it
|
||||
// never rendered.
|
||||
expect(markNotificationsSeenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still shows a server error verbatim — there's nothing better to say", async () => {
|
||||
fetchNotificationsMock.mockRejectedValue(
|
||||
"appview: notifications returned 500 Internal Server Error: db down",
|
||||
);
|
||||
|
||||
app = mount(NotificationsView, {
|
||||
target,
|
||||
props: { did: "did:plc:me" },
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(target.textContent).toContain("500");
|
||||
expect(target.textContent).toContain("db down");
|
||||
});
|
||||
|
||||
it("opens the thread for a row that has a subject", async () => {
|
||||
fetchNotificationsMock.mockResolvedValue({
|
||||
notifications: [row()],
|
||||
|
||||
@@ -19,11 +19,12 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
|
||||
┌──────────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ pds-server (axum, :2583) │ │ appview (axum, :2584) │
|
||||
│ │ │ │
|
||||
│ /xrpc/com.atproto.* │ │ GET /api/timeline/home │
|
||||
│ /xrpc/com.atproto.* │ │ GET /api/timeline/home 🔒 │
|
||||
│ /xrpc/app.bsky.actor.profile.* │ │ GET /api/profile[/:handle] │
|
||||
│ /blob/:cid │ │ GET /api/search │
|
||||
│ /healthz │ │ GET /api/post|thread/*uri │
|
||||
│ │ │ GET /api/notifications… │
|
||||
│ /.well-known/did.json ──────────┼───┼─▶ Schlüssel für 🔒 │
|
||||
│ │ │ GET /api/notifications… 🔒 │
|
||||
│ │ │ GET /api/followers|following│
|
||||
│ │ │ GET /healthz │
|
||||
│ at-lexicon Validierung (160) │ │ │
|
||||
@@ -50,6 +51,11 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
|
||||
└──────────────────────────────────────────┘ JetstreamConsumer
|
||||
```
|
||||
|
||||
🔒 = Bearer-Token nötig, und der `sub` des Tokens muss der angefragten DID
|
||||
entsprechen. Die AppView verifiziert die ES256-Signatur mit dem öffentlichen
|
||||
Schlüssel, den die PDS in ihrem DID-Dokument veröffentlicht — `PDS_JWT_SECRET`
|
||||
verlässt die PDS nie. Details in [`deployment.md`](deployment.md), Abschnitt 6.
|
||||
|
||||
Zwei Wege führen in die AppView, und das ist Absicht:
|
||||
|
||||
1. **Direkter Push (schnell, lokal).** Jeder erfolgreiche Commit auf der PDS
|
||||
|
||||
+76
-21
@@ -287,7 +287,7 @@ Hinweise:
|
||||
* Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop`
|
||||
beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte
|
||||
Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird —
|
||||
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 8). Das ist
|
||||
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 9). Das ist
|
||||
unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit
|
||||
zeigt und Events idempotent verarbeitet werden.
|
||||
* Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf
|
||||
@@ -296,7 +296,55 @@ Hinweise:
|
||||
`PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn
|
||||
der erste Connect scheitert. `Restart=on-failure` fängt das ab.
|
||||
|
||||
## 6. Reverse-Proxy
|
||||
## 6. Authentifizierung
|
||||
|
||||
Die AppView prüft seit Phase 9 Bearer-Tokens. Wie das zusammenhängt:
|
||||
|
||||
1. Die PDS stellt beim Login ein ES256-Access-JWT aus (`sub` = DID,
|
||||
`scope` = `com.atproto.access`, eine Stunde gültig).
|
||||
2. Die PDS veröffentlicht den *öffentlichen* Teil ihres P-256-Schlüssels unter
|
||||
`GET /.well-known/did.json`. `PDS_JWT_SECRET` verlässt den PDS-Prozess nicht.
|
||||
3. Die AppView holt dieses Dokument beim Start von `PDS_INTERNAL_URL`
|
||||
(Fallback: `PDS_PUBLIC_URL`), cached den Schlüssel und lädt ihn bei einem
|
||||
Verifikationsfehler einmal nach — höchstens einmal pro Minute, damit
|
||||
Müll-Tokens kein Werkzeug werden, die PDS zu fluten. Ein Schlüsselwechsel
|
||||
braucht also keinen Neustart der AppView.
|
||||
|
||||
Ist die PDS beim Start nicht erreichbar, warnt die AppView nur und startet
|
||||
trotzdem — sie indiziert den Firehose, was von der lokalen PDS unabhängig ist.
|
||||
Der Schlüssel wird dann beim ersten authentifizierten Request geholt. Klappt
|
||||
auch das nicht, antwortet sie `503 AuthUnavailable`: **fail closed**, nie
|
||||
fail open.
|
||||
|
||||
### Welche Endpoints
|
||||
|
||||
| Endpoint | Zugriff |
|
||||
|---|---|
|
||||
| `/api/timeline/home`, `/api/notifications`, `/api/notifications/count`, `/api/notifications/seen` | Token nötig, `sub` muss dem `did`-Parameter entsprechen |
|
||||
| `/api/profile*`, `/api/search`, `/api/post/*`, `/api/thread*`, `/api/followers`, `/api/following` | öffentlich (in AT Proto öffentliche Records) |
|
||||
| `/internal/ingest-commit` | `APPVIEW_INGEST_SECRET`, server-zu-server |
|
||||
|
||||
### Fehlercodes
|
||||
|
||||
| Fall | Status | `error` |
|
||||
|---|---|---|
|
||||
| Header fehlt oder ist kein Bearer | 401 | `AuthMissing` |
|
||||
| Signatur falsch, abgelaufen, falscher `scope` | 401 | `TokenInvalid` |
|
||||
| Token gültig, aber `sub` ≠ `did` | 403 | `Forbidden` |
|
||||
| Schlüssel der PDS nicht beschaffbar | 503 | `AuthUnavailable` |
|
||||
|
||||
`TokenInvalid` ist ein Vertrag mit dem Desktop-Client: daran erkennt er, dass
|
||||
er sein Access-JWT erneuern und den Request einmal wiederholen muss. Wer den
|
||||
Code umbenennt, loggt jeden Nutzer eine Stunde nach dem Login aus.
|
||||
|
||||
### `APPVIEW_AUTH_REQUIRED=false`
|
||||
|
||||
Schaltet die Prüfung ab und stellt das alte Verhalten her — gedacht für eine
|
||||
Instanz hinter VPN und für die fail-open-Integrationstests. Die AppView warnt
|
||||
beim Start in Großbuchstaben. Öffentlich erreichbar heißt das: jeder kann die
|
||||
Notifications jeder DID lesen und als gelesen markieren.
|
||||
|
||||
## 7. Reverse-Proxy
|
||||
|
||||
### PDS
|
||||
|
||||
@@ -357,25 +405,31 @@ im DID-Doc-`serviceEndpoint`, im JWT-`iss` und als Basis der Blob-URLs.
|
||||
kein `/.well-known/atproto-did` aus (im Router nicht vorhanden). `at-identity`
|
||||
kann solche Dokumente *auflösen*, aber wer `did:web`-Handles auf dieser PDS
|
||||
betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern.
|
||||
Ebenfalls offen: `describeServer` gibt die DID hart als
|
||||
`did:web:pds.maarcadetweet.local` zurück, unabhängig von `PDS_PUBLIC_URL`.
|
||||
Die Service-DID wird inzwischen aus `PDS_PUBLIC_URL` abgeleitet
|
||||
(`AppConfig::pds_did()`, did:web mit `%3A`-kodiertem Port) und von
|
||||
`describeServer` **und** `/.well-known/did.json` identisch ausgeliefert. Das
|
||||
heißt auch: ändert sich `PDS_PUBLIC_URL`, ändert sich die Service-DID.
|
||||
|
||||
### AppView
|
||||
|
||||
Die AppView setzt ihr CORS selbst — `crates/appview/src/routes.rs`:
|
||||
Die AppView setzt ihr CORS selbst (`cors_layer()` in
|
||||
`crates/appview/src/routes.rs`). Ohne `APPVIEW_CORS_ORIGINS` bleibt es beim
|
||||
alten `Access-Control-Allow-Origin: *` — die AppView warnt dann beim Start.
|
||||
Mit gesetzter Variable gilt eine Allowlist:
|
||||
|
||||
```rust
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
```
|
||||
APPVIEW_CORS_ORIGINS=tauri://localhost,http://tauri.localhost,http://127.0.0.1:1430
|
||||
```
|
||||
|
||||
Also `Access-Control-Allow-Origin: *` für alle Routen, inklusive
|
||||
`/internal/ingest-commit`. Der Grund steht im Code: der Tauri-Webview ruft die
|
||||
AppView von einem anderen Origin aus auf (`http://127.0.0.1:1430` im Dev,
|
||||
`tauri://` / `asset://` im Bundle), und die Read-Endpoints tragen keine
|
||||
Auth-Cookies.
|
||||
Der Tauri-Webview ruft die AppView von einem anderen Origin aus auf — im Dev
|
||||
die Vite-Adresse, im Bundle `tauri://localhost` (macOS/Linux) bzw.
|
||||
`http://tauri.localhost` (Windows). Alle drei gehören in die Liste, sonst
|
||||
scheitert der Preflight.
|
||||
|
||||
`/internal/ingest-commit` liegt bewusst **außerhalb** der CORS-Schicht: die
|
||||
Route wird von der PDS server-zu-server aufgerufen, nie von einem Browser. Ein
|
||||
`Access-Control-Allow-Origin` darauf würde ausschließlich einer Webseite
|
||||
helfen, in den Index zu schreiben.
|
||||
|
||||
Für ein öffentliches Deployment heißt das:
|
||||
|
||||
@@ -408,7 +462,7 @@ HTTP-Aufrufe an PDS/AppView laufen über den Rust-IPC-Layer
|
||||
(`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview —
|
||||
die CSP muss also für neue Backend-URLs nicht angefasst werden.
|
||||
|
||||
## 7. Health-Checks und Logs
|
||||
## 8. Health-Checks und Logs
|
||||
|
||||
### PDS
|
||||
|
||||
@@ -472,7 +526,7 @@ Log-Zeilen, auf die es sich lohnt zu achten:
|
||||
| `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar |
|
||||
| `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert |
|
||||
|
||||
## 8. Neustart-Verhalten
|
||||
## 9. Neustart-Verhalten
|
||||
|
||||
**PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore
|
||||
(`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent
|
||||
@@ -515,14 +569,15 @@ nach — zuerst über die lokale PDS (`PdsHandleResolver`, 2 s Timeout), dann PL
|
||||
bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der
|
||||
Zustand ist reine Anzeigekosmetik.
|
||||
|
||||
## 9. Was noch offen ist
|
||||
## 10. Was noch offen ist
|
||||
|
||||
* Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur
|
||||
Postgres und MinIO ab. Es gibt kein Dockerfile im Repo.
|
||||
* `at-blob` spricht ausschließlich MinIO ohne Signature V4 (siehe Modul-Doku in
|
||||
`crates/at-blob/src/s3.rs`); echtes AWS S3 funktioniert damit nicht.
|
||||
* Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe.
|
||||
* Keine konfigurierbare CORS-Allowlist in der AppView.
|
||||
* Kein `.well-known`-Handling in der PDS, `describeServer` liefert eine
|
||||
hartkodierte DID.
|
||||
* `aud` wird beim Token-Check nicht validiert (`verify_jwt` setzt
|
||||
`validate_aud = false`), obwohl die PDS `did:web:appview.maarcadetweet.local`
|
||||
einsetzt. Signatur, Ablauf, `scope` und `sub` werden geprüft.
|
||||
* Notifications werden nie gelöscht; ein Unlike/Unfollow lässt die Zeile stehen.
|
||||
* Kein Backfill-Werkzeug für Jetstream-Lücken.
|
||||
|
||||
@@ -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