diff --git a/README.md b/README.md index 49c79cc153..0e829be5e9 100755 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | LithosAI | Browser cookies / manual Cookie header | Prepaid balance, UTC spend today and this month | | Muse (muse.ai) | Browser cookies / manual Cookie header | Weekly usage, tokens left, top-up tokens | | WorkBuddy | Browser cookies / manual Cookie header | Monthly credits left, plan, cycle reset | +| X API | Browser cookies / manual Cookie header | Prepaid balance, purchased and free credits | diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index e326b05836..b09be60f13 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -256,6 +256,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::LithosAI, "langdock" => ProviderId::Langdock, "tinyapi" => ProviderId::TinyApi, + "xapi" => ProviderId::Xapi, _ => return None, }) } @@ -900,6 +901,13 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option(lang, "auto", None), + cookie_option(lang, "manual", None), + cookie_option(lang, "off", None), + ], _ => Vec::new(), } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs index 7b3507650c..88fe8d86da 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs @@ -365,3 +365,44 @@ fn tinyapi_exposes_a_cookie_source_picker_and_routes_each_choice() { assert_eq!(ctx.source_mode, SourceMode::Cli); assert!(ctx.manual_cookie_header.is_none()); } + +/// X API is web-only: the picker chooses the session, a stored header +/// always wins, an empty manual source fails closed without reading a browser, +/// and off reaches the provider as Cli (its "cookies are disabled" error). +#[test] +fn xapi_exposes_a_cookie_source_picker_and_routes_each_choice() { + let settings = Settings::default(); + assert_eq!( + super::provider_cookie_source_lookup(&settings, "xapi").as_deref(), + Some("auto") + ); + let values: Vec<_> = super::cookie_source_options_for("xapi", Language::English) + .into_iter() + .map(|option| option.value) + .collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); + + let cookie = "auth_token=fixture; ct0=fixture"; + for source in ["auto", "manual"] { + let ctx = session_context(ProviderId::Xapi, Some(source), "auto", Some(cookie)); + assert!( + matches!(ctx.source_mode, SourceMode::Auto | SourceMode::Web), + "{source}" + ); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some(cookie), + "{source}" + ); + assert!(!ctx.manual_cookie_missing, "{source}"); + } + + let ctx = session_context(ProviderId::Xapi, Some("manual"), "auto", None); + assert_eq!(ctx.source_mode, SourceMode::Web); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); + + let ctx = session_context(ProviderId::Xapi, Some("off"), "auto", Some(cookie)); + assert_eq!(ctx.source_mode, SourceMode::Cli); + assert!(ctx.manual_cookie_header.is_none()); +} diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xapi.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xapi.svg new file mode 100644 index 0000000000..fc51ae4874 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-xapi.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 285a4620e6..b3ad6bb8e7 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -58,6 +58,7 @@ import notion from "./icons/ProviderIcon-notion.svg?raw"; import nous from "./icons/ProviderIcon-nous.svg?raw"; import workbuddy from "./icons/ProviderIcon-workbuddy.svg?raw"; import xai from "./icons/ProviderIcon-xai.svg?raw"; +import xapi from "./icons/ProviderIcon-xapi.svg?raw"; import xkiro from "./icons/ProviderIcon-xkiro.svg?raw"; import ollama from "./icons/ProviderIcon-ollama.svg?raw"; import opencode from "./icons/ProviderIcon-opencode.svg?raw"; @@ -159,6 +160,7 @@ const RAW: Record = { notion: tint(notion), nous: tint(nous), xai: tint(xai), + xapi: tint(xapi), xkiro: tint(xkiro), mistral: tint(mistral), muse: tint(muse), @@ -294,6 +296,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { notion: { id: "notion", brandColor: "#337EA9", fallbackLetter: "N", svgPath: RAW.notion }, nous: { id: "nous", brandColor: "#D6A55C", fallbackLetter: "N", svgPath: RAW.nous }, xai: { id: "xai", brandColor: "#8e8e93", fallbackLetter: "X", svgPath: RAW.xai }, + xapi: { id: "xapi", brandColor: "#71767B", fallbackLetter: "X", svgPath: RAW.xapi }, xkiro: { id: "xkiro", brandColor: "#52c99b", fallbackLetter: "X", svgPath: RAW.xkiro }, meta: { id: "meta", brandColor: "#0467DF", fallbackLetter: "M", svgPath: RAW.meta }, }; diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 70f35cebb0..0139d1cc16 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -97,4 +97,5 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["lithosai", "LithosAI"], ["museai", "Muse (muse.ai)"], ["workbuddy", "WorkBuddy"], + ["xapi", "X API"], ]; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 2c5e8237c5..5c392aa38f 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -417,6 +417,32 @@ portal in Settings → Providers → IBM Bob, as token accounts, or with - 401 and 403 report that the key was rejected; response bodies are never echoed in errors. +### X API prepaid credits + +X API is disabled by default and reads prepaid credit balances from the X +(Twitter) API console. Sign in to console.x.com in your browser, and CodexBar +will read the `auth_token` and `ct0` cookies from that session. + +- **Automatic:** A previously imported Cookie header is used on its own. + Otherwise CodexBar tries each detected browser (Chrome, Edge, Brave, + Firefox) holding both cookies. If a browser session expires (401), it moves + to the next browser. No session means sign in to console.x.com. +- **Manual:** Paste the Cookie header from a console.x.com request (with + `auth_token` and `ct0`). Manual mode does not fall back to browser sessions. +- **Off:** Cookies are disabled. + +The primary lane shows the balance (paid + free credits). Details rows show: +- Balance (total: paid + free) +- Purchased credits +- Free credits + +Balance is a prepaid meter (no quota percentage); costs are always zero (no +usage tracking available). The provider reports identity as "Browser session". +Cost resets are never inferred. Session rotation on 401 is implemented: sessions +are tried in browser order, stale sessions are skipped, and a missing session +after all attempts triggers "sign in to console.x.com". Errors never echo +response bodies. + ## Listing what is enabled ```powershell diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 66609d2d9f..8fbd0af2d7 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -89,6 +89,7 @@ pub enum ProviderId { QwenCloud, Notion, Xai, + Xapi, Fireworks, AtlasCloud, #[serde(alias = "metaspark")] @@ -196,6 +197,7 @@ impl ProviderId { ProviderId::QwenCloud, ProviderId::Notion, ProviderId::Xai, + ProviderId::Xapi, ProviderId::Fireworks, ProviderId::AtlasCloud, ProviderId::Meta, @@ -324,6 +326,7 @@ impl ProviderId { ProviderId::QwenCloud => "qwen-cloud", ProviderId::Notion => "notion", ProviderId::Xai => "xai", + ProviderId::Xapi => "xapi", ProviderId::Replicate => "replicate", ProviderId::AtlasCloud => "atlascloud", } @@ -432,6 +435,7 @@ impl ProviderId { ProviderId::QwenCloud => "Qwen Cloud", ProviderId::Notion => "Notion AI", ProviderId::Xai => "xAI", + ProviderId::Xapi => "X API", ProviderId::Replicate => "Replicate", ProviderId::AtlasCloud => "Atlas Cloud", } @@ -546,6 +550,7 @@ impl ProviderId { ProviderId::ZoomMate => Some("zoommate.zoom.us"), ProviderId::QwenCloud => Some("qwencloud.com"), ProviderId::Notion => Some("app.notion.com"), + ProviderId::Xapi => Some("x.com"), ProviderId::Xai => None, } } @@ -641,6 +646,7 @@ impl ProviderId { } "grok" | "supergrok" | "super-grok" => Some(ProviderId::Grok), "xai" | "x.ai" | "x-ai" => Some(ProviderId::Xai), + "xapi" | "x-api" | "x api" => Some(ProviderId::Xapi), "elevenlabs" | "eleven-labs" | "11labs" => Some(ProviderId::ElevenLabs), "deepgram" | "dg" => Some(ProviderId::Deepgram), "groq" | "groqcloud" | "groq-cloud" | "groq cloud" => Some(ProviderId::Groq), @@ -1382,6 +1388,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::QwenCloud => "#615CED", ProviderId::Notion => "#337EA9", ProviderId::Xai => "#8E8E93", + ProviderId::Xapi => "#71767B", ProviderId::Fireworks => "#F25B1C", ProviderId::Meta => "#0467DF", ProviderId::Muse => "#0668E1", @@ -1423,7 +1430,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 100); + assert_eq!(all.len(), 101); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1480,6 +1487,7 @@ mod tests { assert!(all.contains(&ProviderId::QwenCloud)); assert!(all.contains(&ProviderId::Notion)); assert!(all.contains(&ProviderId::Xai)); + assert!(all.contains(&ProviderId::Xapi)); assert!(all.contains(&ProviderId::Meta)); assert!(all.contains(&ProviderId::Replicate)); assert!(all.contains(&ProviderId::Muse)); diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index f631f92767..e35dbe7e74 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -32,8 +32,8 @@ use crate::providers::{ QwenCloudProvider, RaycastProvider, ReplicateProvider, SailResearchProvider, SakanaProvider, SofyaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, TinyApiProvider, TypeSafeProvider, V0Provider, VeniceProvider, VercelProvider, VertexAIProvider, WarpProvider, - WayfinderProvider, WindsurfProvider, XKiroProvider, XaiProvider, ZaiProvider, ZedProvider, - ZenMuxProvider, ZoomMateProvider, + WayfinderProvider, WindsurfProvider, XKiroProvider, XaiProvider, XapiProvider, ZaiProvider, + ZedProvider, ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -129,6 +129,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::SailResearch => Box::new(SailResearchProvider::new()), ProviderId::Notion => Box::new(NotionProvider::new()), ProviderId::Xai => Box::new(XaiProvider::new()), + ProviderId::Xapi => Box::new(XapiProvider::new()), ProviderId::Fireworks => Box::new(FireworksProvider::new()), ProviderId::Meta => Box::new(MetaProvider::new()), ProviderId::Muse => Box::new(MuseProvider::new()), @@ -239,6 +240,7 @@ pub fn pace_capability(id: ProviderId) -> ProviderPaceCapability { | ProviderId::Replicate | ProviderId::SailResearch | ProviderId::Xai + | ProviderId::Xapi | ProviderId::Fireworks | ProviderId::Meta | ProviderId::Muse diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 57622074ba..fac8febc4f 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -452,7 +452,8 @@ impl TokenAccountSupport { | ProviderId::WorkBuddy | ProviderId::SailResearch | ProviderId::Sofya - | ProviderId::TinyApi => None, + | ProviderId::TinyApi + | ProviderId::Xapi => None, } } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 61b08f5d50..1af769ef3c 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -105,6 +105,7 @@ pub mod wayfinder; pub mod windsurf; pub mod workbuddy; pub mod xai; +pub mod xapi; pub mod xkiro; pub mod zai; pub mod zed; @@ -207,6 +208,7 @@ pub use wayfinder::WayfinderProvider; pub use windsurf::WindsurfProvider; pub use workbuddy::WorkBuddyProvider; pub use xai::XaiProvider; +pub use xapi::XapiProvider; pub use xkiro::XKiroProvider; pub use zai::ZaiProvider; pub use zed::ZedProvider; diff --git a/rust/src/providers/xapi/mod.rs b/rust/src/providers/xapi/mod.rs new file mode 100644 index 0000000000..cf44cf5c1f --- /dev/null +++ b/rust/src/providers/xapi/mod.rs @@ -0,0 +1,419 @@ +//! X API prepaid credits from the signed-in console. +//! +//! Ported from upstream CodexBar v0.74.0 (`Resources/Plugins/xapi.js`, +//! `Providers/XAPI/XAPIProviderDescriptor.swift`). X API has no API key for +//! prepaid credits: the console session cookies `auth_token` and `ct0` read +//! `/api/me` and `/api/accounts/{account_id}/credits` endpoints. The CSRF +//! cookie value is echoed as `X-CSRF-Token`. A pasted or imported Cookie +//! header is the only session tried. Without one, Automatic tries every +//! detected browser holding both cookies for console.x.com, in order, and +//! moves to the next after a 401 (upstream `rejectCookie`). Response bodies +//! are never echoed in errors and the cookies are never logged. + +#[cfg(test)] +mod tests; + +use std::time::Duration; + +use async_trait::async_trait; +use reqwest::{Client, StatusCode, header::ACCEPT}; +use serde_json::{Map, Value}; + +use crate::core::RateWindow; +use crate::core::{ + FetchContext, ManualEmptyCookiePolicy, ProviderDisplayDetail, ProviderError, + ProviderFetchResult, ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, + UsageSnapshot, +}; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const BASE_URL: &str = "https://console.x.com"; +const COOKIE_DOMAIN: &str = "x.com"; +const AUTH_COOKIE: &str = "auth_token"; +const CSRF_COOKIE: &str = "ct0"; +const CSRF_HEADER: &str = "X-CSRF-Token"; +const SIGN_IN_URL: &str = "https://console.x.com"; +/// Upstream's fixed web timeout. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_RESPONSE_BYTES: usize = 512 * 1024; + +/// Upstream's message when every offered session answered 401. +const SESSION_EXPIRED: &str = "X API session expired. Sign in to console.x.com again."; +const MISSING_SESSION: &str = "Sign in to console.x.com in the selected browser, or paste its Cookie header with auth_token and ct0."; +const COOKIES_OFF: &str = "X API cookies are disabled."; + +/// Every detected browser's Cookie header for the console, in browser order. +type BrowserCookieHeaders = dyn Fn() -> Result, ProviderError> + Sync; + +pub struct XapiProvider { + metadata: ProviderMetadata, + client: Option, + base_url: String, +} + +impl XapiProvider { + pub fn new() -> Self { + Self::with_base_url(BASE_URL) + } + + fn with_base_url(base_url: &str) -> Self { + let client = crate::core::credentialed_http_client_builder() + .cookie_store(false) + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + .ok(); + Self { + metadata: ProviderMetadata { + id: ProviderId::Xapi, + display_name: "X API", + session_label: "Balance", + weekly_label: "Balance", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some(SIGN_IN_URL), + status_page_url: None, + tertiary_label_key: None, + }, + client, + base_url: base_url.to_owned(), + } + } + + /// Tries each candidate session in order. Only an expired session moves + /// on to the next candidate; any other failure ends the fetch. + async fn fetch_web( + &self, + ctx: &FetchContext, + browser: &BrowserCookieHeaders, + ) -> Result { + let (sessions, browser_error) = sessions(ctx, browser); + if sessions.is_empty() { + return Err(browser_error.unwrap_or_else(missing_session)); + } + let client = self.client.as_ref().ok_or_else(|| { + ProviderError::Other("Could not create a secure X API HTTP client.".into()) + })?; + let timeout = Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT); + let mut last_error = None; + for session in &sessions { + match fetch_session(client, &self.base_url, session, timeout).await { + Err(error) if is_session_expired(&error) => last_error = Some(error), + result => return result, + } + } + Err(last_error.unwrap_or_else(missing_session)) + } +} + +impl Default for XapiProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl crate::core::Provider for XapiProvider { + fn id(&self) -> ProviderId { + ProviderId::Xapi + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::Web => { + self.fetch_web(ctx, &|| { + crate::providers::browser_cookie_headers_for_domain(COOKIE_DOMAIN) + }) + .await + } + // The shell maps an Off cookie source to Cli for web-only + // providers; upstream reports that cookies are disabled. + SourceMode::Cli => Err(ProviderError::Other(COOKIES_OFF.into())), + SourceMode::OAuth => Err(ProviderError::UnsupportedSource(SourceMode::OAuth)), + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::Web] + } + + fn supports_web(&self) -> bool { + true + } + + /// Upstream sends only the pasted header in Manual mode, so an empty + /// manual source must not fall back to a browser session. + fn manual_empty_cookie_policy(&self) -> ManualEmptyCookiePolicy { + ManualEmptyCookiePolicy::FailClosedWeb + } + + /// The shell would import only the first browser's cookies; the provider + /// reads every browser so an expired session can move to the next one. + fn owns_browser_cookie_resolution(&self) -> bool { + true + } + + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + if is_session_expired(error) { + ProviderStateKind::ExpiredSession + } else { + error.state_kind() + } + } +} + +/// One session's me → credits sequence. +async fn fetch_session( + client: &Client, + base_url: &str, + session: &Session, + timeout: Duration, +) -> Result { + let me = get_json(client, &format!("{base_url}/api/me"), session, timeout).await?; + let account_id = parse_account_id(&me)?; + let credits = get_json( + client, + &format!("{base_url}/api/accounts/{account_id}/credits"), + session, + timeout, + ) + .await?; + parse_credits(&account_id, &credits) +} + +fn is_session_expired(error: &ProviderError) -> bool { + matches!(error, ProviderError::Other(message) if message == SESSION_EXPIRED) +} + +/// A console session: the full Cookie header plus the CSRF value it echoes. +#[derive(Clone)] +struct Session { + cookie_header: String, + csrf: String, +} + +fn missing_session() -> ProviderError { + ProviderError::BrowserSignInRequired { + message: MISSING_SESSION.into(), + sign_in_url: SIGN_IN_URL.into(), + } +} + +/// Candidate sessions in upstream's order, plus a browser read failure worth +/// reporting when no candidate is usable. A pasted or shell-resolved Cookie +/// header is used alone, an empty Manual source offers nothing, and otherwise +/// every browser holding both console cookies is a candidate. +fn sessions( + ctx: &FetchContext, + browser: &BrowserCookieHeaders, +) -> (Vec, Option) { + if let Some(header) = ctx.manual_cookie_header.as_deref() { + let session = crate::providers::normalize_cookie_header(header) + .and_then(|header| session_from_header(&header)); + return (session.into_iter().collect(), None); + } + if ctx.manual_cookie_missing { + return (Vec::new(), None); + } + match browser() { + Ok(headers) => ( + headers + .iter() + .filter_map(|(_, header)| crate::providers::normalize_cookie_header(header)) + .filter_map(|header| session_from_header(&header)) + .collect(), + None, + ), + // No browser holds console cookies: the sign-in hint is clearer. + Err(ProviderError::NoCookies) => (Vec::new(), None), + Err(error) => { + tracing::debug!(%error, "X API browser sessions are unavailable"); + (Vec::new(), Some(error)) + } + } +} + +fn session_from_header(header: &str) -> Option { + let first = |name: &str| { + crate::providers::cookie_values(header, name) + .into_iter() + .find(|value| !value.is_empty()) + .map(str::to_owned) + }; + first(AUTH_COOKIE)?; + let csrf = first(CSRF_COOKIE)?; + Some(Session { + cookie_header: header.to_owned(), + csrf, + }) +} + +async fn get_json( + client: &Client, + url: &str, + session: &Session, + timeout: Duration, +) -> Result, ProviderError> { + let response = client + .get(url) + .header(ACCEPT, "application/json") + .header("Cookie", &session.cookie_header) + .header(CSRF_HEADER, &session.csrf) + .timeout(timeout) + .send() + .await?; + + let status = response.status(); + let body_bytes = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::TooLarge => invalid("JSON response"), + BoundedBodyError::Read(error) => ProviderError::Network(error), + })?; + + let body = std::str::from_utf8(&body_bytes).map_err(|_| invalid("JSON response"))?; + + // For 400, check if it's code 215 (legacy auth error) before parsing the body + if status == StatusCode::BAD_REQUEST + && let Ok(json) = serde_json::from_str::(body) + && let Some(errors) = json.get("errors").and_then(|v| v.as_array()) + && errors + .iter() + .any(|e| e.get("code").and_then(|c| c.as_i64()) == Some(215)) + { + return Err(ProviderError::Other(SESSION_EXPIRED.to_owned())); + } + + check_status(status)?; + parse_object(body) +} + +fn parse_object(text: &str) -> Result, ProviderError> { + serde_json::from_str::(text) + .ok() + .and_then(|v| v.as_object().cloned()) + .ok_or_else(|| invalid("JSON object")) +} + +fn parse_account_id(me: &Map) -> Result { + let id = me + .get("account") + .and_then(|v| v.get("id")) + .ok_or_else(|| invalid("account or dollar balance"))?; + + // Upstream: `Number.isSafeInteger(id) && id >= 0 ? String(id) : id`. + const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + let account_id = match id { + Value::Number(n) => match n.as_f64() { + Some(f) if f.fract() == 0.0 && (0.0..=MAX_SAFE_INTEGER).contains(&f) => { + // `+ 0.0` turns -0 into 0, as JavaScript's String(-0) does. + format!("{:.0}", f + 0.0) + } + _ => return Err(invalid("account or dollar balance")), + }, + Value::String(s) => s.clone(), + _ => return Err(invalid("account or dollar balance")), + }; + + // Validate account ID format: ASCII alphanumeric, underscore, hyphen; 1-128 chars + if account_id.is_empty() + || account_id.len() > 128 + || !account_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(invalid("account or dollar balance")); + } + + Ok(account_id) +} + +fn parse_credits( + _account_id: &str, + credits: &Map, +) -> Result { + // Paid credits: required + let paid = credits + .get("credits") + .and_then(|v| v.get("balance")) + .ok_or_else(|| invalid("account or dollar balance")) + .and_then(amount)?; + + // Free credits: absent means 0; present (even null) needs a valid balance. + let free = match credits.get("freeCredits") { + None => 0.0, + Some(v) => amount(v.get("balance").unwrap_or(&Value::Null))?, + }; + + // Upstream `amount(paid + free)`: two finite balances can overflow to infinity. + let balance = paid + free; + if !balance.is_finite() { + return Err(invalid("account or dollar balance")); + } + + // Upstream has no window: the balance reaches the tray and float bar only. + let usage = UsageSnapshot::new( + RateWindow::informational(format!("Balance: {}", super::format::usd(balance))) + .hidden_on_card(), + ) + .with_login_method("Browser session"); + + // Upstream's cost presenter uses `menuCardStyle: .hidden`, so no cost + // block: the Credits detail rows are the only card surface. + let mut result = ProviderFetchResult::new(usage, "web"); + + // Add display details as three rows, all with "Credits" section title + let balance_text = super::format::usd(balance); + let paid_text = super::format::usd(paid); + let free_text = super::format::usd(free); + + let detail_rows = vec![ + ProviderDisplayDetail::new("balance", "Balance", balance_text) + .and_then(|d| d.with_section_title("Credits")), + ProviderDisplayDetail::new("purchased-credits", "Purchased credits", paid_text) + .and_then(|d| d.with_section_title("Credits")), + ProviderDisplayDetail::new("free-credits", "Free credits", free_text) + .and_then(|d| d.with_section_title("Credits")), + ]; + + let detail_rows: Vec<_> = detail_rows.into_iter().flatten().collect(); + + if detail_rows.len() != 3 { + return Err(invalid("account or dollar balance")); + } + + result = result.with_display_details(detail_rows); + Ok(result) +} + +/// Any finite JSON number, integers included (upstream `amount`). +fn amount(value: &Value) -> Result { + value + .as_f64() + .filter(|f| f.is_finite()) + .ok_or_else(|| invalid("account or dollar balance")) +} + +fn invalid(field: &str) -> ProviderError { + ProviderError::Other(format!("X API returned an unrecognized {}.", field)) +} + +/// Upstream's HTTP status taxonomy; only 200 continues to the body. +/// Note: 400 with code 215 is handled specially in get_json; this is for generic 400s. +pub(crate) fn check_status(status: StatusCode) -> Result<(), ProviderError> { + let message = match status.as_u16() { + 200 => return Ok(()), + 401 => SESSION_EXPIRED.to_owned(), + 403 => "X developer console access was denied.".to_owned(), + 429 => "X API console is rate limited. Wait before refreshing.".to_owned(), + 500.. => "X developer console is unavailable.".to_owned(), + code => format!("X developer console returned HTTP {}.", code), + }; + Err(ProviderError::Other(message)) +} diff --git a/rust/src/providers/xapi/tests.rs b/rust/src/providers/xapi/tests.rs new file mode 100644 index 0000000000..d0e8d932b3 --- /dev/null +++ b/rust/src/providers/xapi/tests.rs @@ -0,0 +1,308 @@ +#[cfg(test)] +mod xapi_tests { + use crate::core::{Provider, ProviderId}; + use crate::providers::xapi::{XapiProvider, parse_account_id, parse_credits}; + use serde_json::{Map, Value, json}; + + fn object(value: Value) -> Map { + value.as_object().cloned().expect("JSON object") + } + + fn detail_values(result: &crate::core::ProviderFetchResult) -> Vec<(String, String)> { + result + .display_details + .iter() + .map(|d| (d.title().to_string(), d.value().to_string())) + .collect() + } + + #[test] + fn provider_metadata() { + let provider = XapiProvider::new(); + assert_eq!(provider.id(), ProviderId::Xapi); + assert_eq!(provider.metadata().display_name, "X API"); + assert_eq!(provider.metadata().session_label, "Balance"); + assert!(!provider.metadata().supports_opus); + assert!(!provider.metadata().supports_credits); + assert!(!provider.metadata().default_enabled); + assert!(!provider.metadata().is_primary); + } + + #[test] + fn account_id_accepts_strings_and_safe_integers() { + let id = |v: Value| parse_account_id(&object(json!({ "account": { "id": v } }))); + assert_eq!(id(json!("fixture-account")).unwrap(), "fixture-account"); + assert_eq!(id(json!(42)).unwrap(), "42"); + assert_eq!(id(json!(42.0)).unwrap(), "42"); + assert_eq!( + id(json!(9_007_199_254_740_991_u64)).unwrap(), + "9007199254740991" + ); + } + + #[test] + fn account_id_rejects_unsafe_or_malformed_values() { + let id = |v: Value| parse_account_id(&object(json!({ "account": { "id": v } }))); + assert!(id(json!(9_007_199_254_740_992_u64)).is_err()); + assert!(id(json!(-1)).is_err()); + assert!(id(json!(1.5)).is_err()); + assert!(id(json!("")).is_err()); + assert!(id(json!("a/b")).is_err()); + assert!(id(json!("a".repeat(129))).is_err()); + assert!(parse_account_id(&object(json!({}))).is_err()); + } + + #[test] + fn credits_sum_paid_and_free_into_a_card_hidden_balance() { + let result = parse_credits( + "1", + &object(json!({ "credits": { "balance": 42.5 }, "freeCredits": { "balance": 7.25 } })), + ) + .unwrap(); + + assert_eq!( + detail_values(&result), + [ + ("Balance".to_string(), "$49.75".to_string()), + ("Purchased credits".to_string(), "$42.50".to_string()), + ("Free credits".to_string(), "$7.25".to_string()), + ] + ); + let primary = &result.usage.primary; + assert!(primary.is_informational && primary.hidden_on_card); + assert_eq!( + primary.reset_description.as_deref(), + Some("Balance: $49.75") + ); + assert!(result.cost.is_none(), "upstream hides the X API cost block"); + } + + #[test] + fn credits_accept_integer_balances_and_missing_free_credits() { + let result = + parse_credits("1", &object(json!({ "credits": { "balance": 1234 } }))).unwrap(); + assert_eq!( + detail_values(&result), + [ + ("Balance".to_string(), "$1,234.00".to_string()), + ("Purchased credits".to_string(), "$1,234.00".to_string()), + ("Free credits".to_string(), "$0.00".to_string()), + ] + ); + } + + #[test] + fn credits_keep_negative_balances_signed() { + let result = + parse_credits("1", &object(json!({ "credits": { "balance": -2.15 } }))).unwrap(); + assert_eq!(detail_values(&result)[0].1, "-$2.15"); + } + + #[test] + fn credits_reject_missing_or_invalid_balances() { + let parse = |v: Value| parse_credits("1", &object(v)); + assert!(parse(json!({})).is_err()); + assert!(parse(json!({ "credits": { "balance": "10" } })).is_err()); + assert!(parse(json!({ "credits": { "balance": 1 }, "freeCredits": {} })).is_err()); + assert!(parse(json!({ "credits": { "balance": 1 }, "freeCredits": null })).is_err()); + } + + #[test] + fn credits_reject_a_sum_that_overflows() { + let result = parse_credits( + "1", + &object(json!({ + "credits": { "balance": f64::MAX }, + "freeCredits": { "balance": f64::MAX } + })), + ); + assert!(result.is_err()); + } + + #[test] + fn status_errors_match_upstream() { + use reqwest::StatusCode; + let message = |code: u16| { + crate::providers::xapi::check_status(StatusCode::from_u16(code).unwrap()) + .unwrap_err() + .to_string() + }; + assert!(crate::providers::xapi::check_status(StatusCode::OK).is_ok()); + assert!(message(401).contains("X API session expired")); + assert!(message(403).contains("X developer console access was denied.")); + assert!(message(429).contains("X API console is rate limited.")); + assert!(message(503).contains("X developer console is unavailable.")); + assert!(message(418).contains("X developer console returned HTTP 418.")); + } + + #[test] + fn provider_supports_web() { + let provider = XapiProvider::new(); + assert!(provider.supports_web()); + } + + #[test] + fn provider_available_sources() { + let provider = XapiProvider::new(); + let sources = provider.available_sources(); + assert_eq!(sources.len(), 2); + assert!(sources.contains(&crate::core::SourceMode::Auto)); + assert!(sources.contains(&crate::core::SourceMode::Web)); + } +} + +#[cfg(test)] +mod xapi_fetch_tests { + use std::sync::{Arc, Mutex}; + + use crate::core::{FetchContext, ProviderError}; + use crate::providers::xapi::{SESSION_EXPIRED, XapiProvider}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// (request path, Cookie header, X-CSRF-Token header) + type Recorded = Arc>>; + + /// Serves one canned response per connection, in order. + async fn serve_in_order( + responses: Vec<(u16, &'static str)>, + ) -> (String, Recorded, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let recorded: Recorded = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&recorded); + let task = tokio::spawn(async move { + for (status, body) in responses { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + let mut request = Vec::new(); + let mut chunk = [0u8; 1024]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(read) => request.extend_from_slice(&chunk[..read]), + } + } + let text = String::from_utf8_lossy(&request).to_string(); + let path = text + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_string(); + let header = |name: &str| { + text.lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) + .unwrap_or_default() + }; + sink.lock() + .unwrap() + .push((path, header("cookie"), header("x-csrf-token"))); + let response = format!( + "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write fixture response"); + stream.shutdown().await.expect("close fixture stream"); + } + }); + (base, recorded, task) + } + + fn two_browsers() -> Result, ProviderError> { + Ok(vec![ + ("Chrome".to_string(), "auth_token=a; ct0=c1".to_string()), + ("Edge".to_string(), "auth_token=b; ct0=c2".to_string()), + ]) + } + + #[tokio::test] + async fn an_expired_session_moves_to_the_next_browser() { + let expired = [ + ( + 400, + r#"{"errors":[{"code":215,"message":"Bad Authentication data."}]}"#, + ), + (401, "{}"), + ]; + for first in expired { + let (base, recorded, task) = serve_in_order(vec![ + first, + (200, r#"{"account":{"id":42}}"#), + ( + 200, + r#"{"credits":{"balance":5},"freeCredits":{"balance":2.5}}"#, + ), + ]) + .await; + let provider = XapiProvider::with_base_url(&base); + let result = provider + .fetch_web(&FetchContext::default(), &two_browsers) + .await + .unwrap_or_else(|error| panic!("status {}: {error:?}", first.0)); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("Balance: $7.50") + ); + let requests = recorded.lock().unwrap().clone(); + assert_eq!( + requests, + vec![ + ("/api/me".into(), "auth_token=a; ct0=c1".into(), "c1".into()), + ("/api/me".into(), "auth_token=b; ct0=c2".into(), "c2".into()), + ( + "/api/accounts/42/credits".into(), + "auth_token=b; ct0=c2".into(), + "c2".into() + ), + ], + "status {}", + first.0 + ); + task.abort(); + } + } + + #[tokio::test] + async fn a_plain_bad_request_ends_the_fetch() { + let (base, recorded, task) = serve_in_order(vec![ + (400, r#"{"errors":[{"code":88,"message":"Rate limit"}]}"#), + (200, r#"{"account":{"id":42}}"#), + ]) + .await; + let provider = XapiProvider::with_base_url(&base); + match provider + .fetch_web(&FetchContext::default(), &two_browsers) + .await + { + Err(ProviderError::Other(message)) => { + assert_eq!(message, "X developer console returned HTTP 400.") + } + other => panic!("unexpected {other:?}"), + } + assert_eq!(recorded.lock().unwrap().len(), 1); + task.abort(); + } + + #[tokio::test] + async fn every_expired_session_reports_the_upstream_message() { + let (base, _recorded, task) = serve_in_order(vec![(401, "{}"), (401, "{}")]).await; + let provider = XapiProvider::with_base_url(&base); + match provider + .fetch_web(&FetchContext::default(), &two_browsers) + .await + { + Err(ProviderError::Other(message)) => assert_eq!(message, SESSION_EXPIRED), + other => panic!("unexpected {other:?}"), + } + task.abort(); + } +} diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 56b0a6ff88..a4365fffbc 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -1210,7 +1210,8 @@ impl Settings { | ProviderId::LithosAI | ProviderId::MuseAI | ProviderId::WorkBuddy - | ProviderId::TinyApi => "auto", + | ProviderId::TinyApi + | ProviderId::Xapi => "auto", _ => DEFAULT_COOKIE_SOURCE, }) } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 8ec6df5c58..7d3802d9f8 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -106,6 +106,15 @@ fn tinyapi_cookie_source_defaults_to_automatic_session_import() { assert_eq!(settings.cookie_source(ProviderId::TinyApi), "off"); } +#[test] +fn xapi_cookie_source_defaults_to_automatic_session_import() { + // X API cookie source falls back to `.auto`. + let mut settings = Settings::default(); + assert_eq!(settings.cookie_source(ProviderId::Xapi), "auto"); + settings.set_cookie_source(ProviderId::Xapi, "off"); + assert_eq!(settings.cookie_source(ProviderId::Xapi), "off"); +} + #[test] fn preferred_currency_defaults_validates_and_round_trips() { let legacy: Settings = serde_json::from_str(r#"{"enabled_providers": []}"#)