diff --git a/src/api/rest.rs b/src/api/rest.rs index 920c09450..e1f4a06ac 100644 --- a/src/api/rest.rs +++ b/src/api/rest.rs @@ -27,6 +27,19 @@ pub enum BackendApiError { /// Provider-specific message id from the URL. message_id: String, }, + /// Backend rejected the bearer JWT with `401 Unauthorized`. This is an + /// expected user-session state (token expired, revoked, rotated + /// server-side) — not a code bug. Callers can route to a re-sign-in + /// flow; the auth domain owns recovery. Targets `OPENHUMAN-TAURI-4K8` + /// (12 events on `/openai/v1/audio/speech` mascot TTS, but the same + /// shape fires on every authed endpoint once the session lapses). + #[error("backend rejected session token on {method} {path}")] + Unauthorized { + /// HTTP method as a static string (`"GET"`, `"POST"`, …). + method: String, + /// Request path the 401 came back from (no query string). + path: String, + }, } /// Extract `(provider, message_id)` from a backend channel path of the @@ -526,6 +539,32 @@ impl BackendOAuthClient { let status_code = status.as_u16(); let status_str = status_code.to_string(); + // 401 on any authed backend endpoint is an expected user-session + // state (token expired / revoked / rotated server-side), not a + // code bug — every authed endpoint will see this once the session + // lapses. Surface a typed `BackendApiError::Unauthorized` so the + // auth domain can drive recovery, and skip `report_error` to + // avoid Sentry noise. Targets `OPENHUMAN-TAURI-4K8` (mascot TTS + // surfaced it first on `/openai/v1/audio/speech`, but the same + // shape applies to every `authed_json` path). + if status_code == 401 { + tracing::info!( + domain = "backend_api", + operation = "authed_json", + method = method.as_str(), + path = url.path(), + status = status_code, + failure = "non_2xx", + "[backend_api] 401 on {} {} — session token rejected, surfacing typed error", + method.as_str(), + url.path(), + ); + return Err(anyhow::Error::new(BackendApiError::Unauthorized { + method: method.as_str().to_string(), + path: url.path().to_string(), + })); + } + // 404 on `/channels//messages/` is an expected // state (user deleted the message provider-side, or backend // GC'd the relay row) — not a code bug. Surface a typed diff --git a/src/api/rest_tests.rs b/src/api/rest_tests.rs index cc6cf1c91..ff11fc341 100644 --- a/src/api/rest_tests.rs +++ b/src/api/rest_tests.rs @@ -332,7 +332,10 @@ async fn authed_json_surfaces_message_not_found_on_404() { let BackendApiError::MessageNotFound { provider, message_id, - } = typed; + } = typed + else { + panic!("expected MessageNotFound, got {typed:?}"); + }; assert_eq!(provider, "telegram"); assert_eq!(message_id, "1103"); @@ -350,11 +353,100 @@ async fn authed_json_surfaces_message_not_found_on_404() { let BackendApiError::MessageNotFound { provider, message_id, - } = typed; + } = typed + else { + panic!("expected MessageNotFound, got {typed:?}"); + }; assert_eq!(provider, "discord"); assert_eq!(message_id, "abc"); } +#[tokio::test] +async fn authed_json_surfaces_unauthorized_on_401() { + // OPENHUMAN-TAURI-4K8: 401 on any authed backend endpoint must surface a + // typed `BackendApiError::Unauthorized` and NOT funnel into `report_error`. + // The mascot TTS path (`/openai/v1/audio/speech`) was the loudest reporter, + // but the same shape fires on every authed endpoint once a session lapses, + // so we cover two different paths/methods to prove the suppression is + // status-driven, not path-keyed. + let app = Router::new() + .route( + "/openai/v1/audio/speech", + post(|| async { (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized") }), + ) + .route( + "/referral/stats", + get(|| async { (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized") }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let base_url = format!("http://{addr}"); + let client = BackendOAuthClient::new(&base_url).unwrap(); + + // Mascot TTS path — the original reporter. + let err = client + .authed_json( + "mock-jwt", + Method::POST, + "/openai/v1/audio/speech", + Some(json!({ "text": "hello" })), + ) + .await + .unwrap_err(); + let typed = err.downcast_ref::().unwrap(); + let BackendApiError::Unauthorized { method, path } = typed else { + panic!("expected Unauthorized, got {typed:?}"); + }; + assert_eq!(method, "POST"); + assert_eq!(path, "/openai/v1/audio/speech"); + + // Generic GET on a non-TTS path — proves the suppression is per-status, + // not per-path. (Same root cause: expired/revoked backend session.) + let err = client + .authed_json("mock-jwt", Method::GET, "/referral/stats", None) + .await + .unwrap_err(); + let typed = err.downcast_ref::().unwrap(); + let BackendApiError::Unauthorized { method, path } = typed else { + panic!("expected Unauthorized, got {typed:?}"); + }; + assert_eq!(method, "GET"); + assert_eq!(path, "/referral/stats"); +} + +#[tokio::test] +async fn authed_json_403_is_not_demoted_to_unauthorized() { + // 403 (Forbidden) is a genuine authorization/permission problem — the + // token authenticated but lacked scope. That IS a code/config bug we + // want to keep in Sentry; only 401 (token rejected as a whole) maps + // to the expected-state `Unauthorized` variant. + let app = Router::new().route( + "/openai/v1/audio/speech", + post(|| async { (axum::http::StatusCode::FORBIDDEN, "Forbidden") }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let base_url = format!("http://{addr}"); + let client = BackendOAuthClient::new(&base_url).unwrap(); + + let err = client + .authed_json("mock-jwt", Method::POST, "/openai/v1/audio/speech", None) + .await + .unwrap_err(); + assert!( + err.downcast_ref::().is_none(), + "403 must not be classified as Unauthorized" + ); +} + #[tokio::test] async fn authed_json_404_outside_messages_path_still_reports() { // 404 on a non-`/channels//messages/` path should NOT be @@ -486,7 +578,10 @@ async fn authed_json_patch_404_with_base_path_prefix_does_not_report() { let BackendApiError::MessageNotFound { provider, message_id, - } = typed; + } = typed + else { + panic!("expected MessageNotFound, got {typed:?}"); + }; assert_eq!(provider, "telegram"); assert_eq!(message_id, "9999"); }