diff --git a/crates/tauri-app/src-tauri/src/appview_client.rs b/crates/tauri-app/src-tauri/src/appview_client.rs index e219572..30590c2 100644 --- a/crates/tauri-app/src-tauri/src/appview_client.rs +++ b/crates/tauri-app/src-tauri/src/appview_client.rs @@ -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, @@ -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 { 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::() @@ -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::() @@ -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::() @@ -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::() @@ -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::() @@ -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::() @@ -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 { 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::() @@ -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 { + /// `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 { 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::() @@ -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 { 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::() @@ -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::() @@ -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>>; + + /// Byte offset of the `\r\n\r\n` that ends the header block. + fn headers_end(buf: &[u8]) -> Option { + 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::().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 = 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 { + 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}"); + } + } } diff --git a/crates/tauri-app/src-tauri/src/lib.rs b/crates/tauri-app/src-tauri/src/lib.rs index d213254..39dee81 100644 --- a/crates/tauri-app/src-tauri/src/lib.rs +++ b/crates/tauri-app/src-tauri/src/lib.rs @@ -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 { + 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, ) -> Result { 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, ) -> Result { 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 { + 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, ) -> Result { + 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()) } diff --git a/crates/tauri-app/src/App.svelte b/crates/tauri-app/src/App.svelte index 6d23b3e..3ef281d 100644 --- a/crates/tauri-app/src/App.svelte +++ b/crates/tauri-app/src/App.svelte @@ -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; } diff --git a/crates/tauri-app/src/lib/api/appview-auth.test.ts b/crates/tauri-app/src/lib/api/appview-auth.test.ts new file mode 100644 index 0000000..f17f95a --- /dev/null +++ b/crates/tauri-app/src/lib/api/appview-auth.test.ts @@ -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` +// 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` 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", + ); + }); +}); diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index 70c22a0..fabc48c 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -59,14 +59,22 @@ export async function getAppviewUrl(): Promise { * 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(cmd: string, args?: Record): Promise { if (!isTauri()) { @@ -82,17 +90,74 @@ async function safeInvoke(cmd: string, args?: Record): 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`, 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: { diff --git a/crates/tauri-app/src/lib/components/NotificationsView.svelte b/crates/tauri-app/src/lib/components/NotificationsView.svelte index e888a07..fe71e4f 100644 --- a/crates/tauri-app/src/lib/components/NotificationsView.svelte +++ b/crates/tauri-app/src/lib/components/NotificationsView.svelte @@ -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; } diff --git a/crates/tauri-app/src/lib/components/NotificationsView.test.ts b/crates/tauri-app/src/lib/components/NotificationsView.test.ts index 10a56fc..135135c 100644 --- a/crates/tauri-app/src/lib/components/NotificationsView.test.ts +++ b/crates/tauri-app/src/lib/components/NotificationsView.test.ts @@ -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()],