Skip to main content

ai_usagebar/cursor/
fetch.rs

1//! Fetch Cursor's included-usage summary from `GET /api/usage-summary`,
2//! authenticated with the session token read out of the local `state.vscdb`
3//! (see `db.rs`). Cache/stale/error-fallback shape mirrors `kimi::fetch`.
4
5use std::path::Path;
6use std::time::Duration;
7
8use chrono::{DateTime, Utc};
9
10use crate::cache::{Cache, MAX_STALE, acquire_lock_async};
11use crate::error::{AppError, Result};
12use crate::usage::CursorSnapshot;
13use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
14
15use super::db;
16use super::types::{self, UsageSummary};
17
18pub const BASE_URL: &str = "https://cursor.com";
19const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
20const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
21/// The dashboard endpoint gates on browser-looking headers; a plain
22/// `reqwest` request with only the cookie is rejected by its CORS check.
23const BROWSER_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
24    AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
25
26#[derive(Debug, Clone)]
27pub struct Endpoints {
28    pub summary: String,
29}
30
31impl Default for Endpoints {
32    fn default() -> Self {
33        Self {
34            summary: format!("{BASE_URL}/api/usage-summary"),
35        }
36    }
37}
38
39#[derive(Debug, Clone)]
40pub struct FetchOutcome {
41    pub snapshot: CursorSnapshot,
42    pub stale: bool,
43    pub last_error: Option<(u16, String)>,
44    pub cache_age: Option<Duration>,
45}
46
47/// Cache-aware fetch. `db_path` is Cursor's `state.vscdb` — the caller resolves
48/// `[cursor] db_path` (config override) vs [`db::default_db_path`], the same
49/// override pattern as `openai.codex_auth_path`.
50pub async fn fetch_snapshot(
51    client: &reqwest::Client,
52    db_path: &Path,
53    cache: &Cache,
54    endpoints: &Endpoints,
55    cache_ttl: Duration,
56) -> Result<FetchOutcome> {
57    fetch_snapshot_at(client, db_path, cache, endpoints, cache_ttl, Utc::now()).await
58}
59
60/// Clock seam for cache rollover tests. Cursor's payload describes one billing
61/// cycle, so serving it after `reset_at` would knowingly show the prior cycle.
62async fn fetch_snapshot_at(
63    client: &reqwest::Client,
64    db_path: &Path,
65    cache: &Cache,
66    endpoints: &Endpoints,
67    cache_ttl: Duration,
68    now: DateTime<Utc>,
69) -> Result<FetchOutcome> {
70    cache.ensure_dir()?;
71    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
72
73    // Resolve the local identity before accepting a cache hit. Cursor can switch
74    // accounts in-place in this database; returning the cache first would show
75    // the previous account's private usage until the TTL elapsed.
76    let token = db::read_access_token(db_path)?;
77    let auth = db::session_auth(&token)?;
78
79    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
80        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &auth.account_key, now)
81    {
82        return Ok(outcome);
83    }
84
85    match fetch_live(client, endpoints, &auth).await {
86        Ok(snap) => {
87            let bytes = serde_json::to_vec(&snap_to_json(&snap, &auth.account_key))?;
88            cache.write_payload(&bytes)?;
89            Ok(FetchOutcome {
90                snapshot: snap,
91                stale: false,
92                last_error: None,
93                cache_age: Some(Duration::ZERO),
94            })
95        }
96        Err(e) if e.is_transient() => fallback_silent(cache, &auth.account_key, now, e),
97        Err(e) => {
98            cache.mark_stale();
99            if let Some((code, msg)) = error_to_pair(&e) {
100                cache.write_last_error(code, &msg);
101            }
102            fallback_with_error(cache, &auth.account_key, now, e)
103        }
104    }
105}
106
107fn fallback_silent(
108    cache: &Cache,
109    account: &str,
110    now: DateTime<Utc>,
111    original: AppError,
112) -> Result<FetchOutcome> {
113    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
114        return Err(original);
115    };
116    match reuse_cache(&bytes, cache, true, account, now) {
117        Ok(outcome) => Ok(outcome),
118        Err(_) => Err(original),
119    }
120}
121
122fn fallback_with_error(
123    cache: &Cache,
124    account: &str,
125    now: DateTime<Utc>,
126    original: AppError,
127) -> Result<FetchOutcome> {
128    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
129        return Err(original);
130    };
131    match reuse_cache(&bytes, cache, true, account, now) {
132        Ok(mut outcome) => {
133            outcome.last_error = error_to_pair(&original);
134            Ok(outcome)
135        }
136        Err(_) => Err(original),
137    }
138}
139
140/// Never surface upstream bodies for auth failures: the request carried a
141/// session cookie derived from a signed-in token, and 401/403 bodies from a
142/// scraped web endpoint are not guaranteed not to echo it back.
143fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
144    match e {
145        AppError::Http { status, .. } if matches!(status, 401 | 403) => {
146            Some((*status, "Cursor authentication failed".into()))
147        }
148        AppError::Http { status, body } => Some((*status, body.clone())),
149        e => Some((0, e.to_string())),
150    }
151}
152
153fn reuse_cache(
154    bytes: &[u8],
155    cache: &Cache,
156    stale: bool,
157    account: &str,
158    now: DateTime<Utc>,
159) -> Result<FetchOutcome> {
160    let snap = parse_cache_at(bytes, account, now)?;
161    Ok(FetchOutcome {
162        snapshot: snap,
163        stale,
164        last_error: cache.read_last_error(),
165        cache_age: cache.payload_age(),
166    })
167}
168
169fn parse_cache_at(bytes: &[u8], account: &str, now: DateTime<Utc>) -> Result<CursorSnapshot> {
170    let v: serde_json::Value = serde_json::from_slice(bytes)?;
171    if v.get("account").and_then(serde_json::Value::as_str) != Some(account) {
172        return Err(AppError::Schema(
173            "cursor cache belongs to a different account; refetching".into(),
174        ));
175    }
176    let int = |key: &str| -> Result<i32> {
177        v[key]
178            .as_i64()
179            .filter(|n| *n >= 0)
180            .and_then(|n| i32::try_from(n).ok())
181            .ok_or_else(|| AppError::Schema(format!("cursor cache: invalid {key}")))
182    };
183    let plan = v["plan"]
184        .as_str()
185        .filter(|plan| !plan.trim().is_empty())
186        .ok_or_else(|| AppError::Schema("cursor cache: invalid plan".into()))?
187        .to_string();
188    let reset_at = parse_cache_datetime(&v["reset_at"])?
189        .ok_or_else(|| AppError::Schema("cursor cache: missing reset timestamp".into()))?;
190    if reset_at <= now {
191        return Err(AppError::Schema(
192            "cursor cache is past its billing-cycle reset; refetching".into(),
193        ));
194    }
195    Ok(CursorSnapshot {
196        plan,
197        auto_pct: int("auto_pct")?,
198        api_pct: int("api_pct")?,
199        total_pct: int("total_pct")?,
200        unlimited: v["unlimited"]
201            .as_bool()
202            .ok_or_else(|| AppError::Schema("cursor cache: invalid unlimited flag".into()))?,
203        on_demand_enabled: v["on_demand_enabled"]
204            .as_bool()
205            .ok_or_else(|| AppError::Schema("cursor cache: invalid on-demand flag".into()))?,
206        reset_at: Some(reset_at),
207    })
208}
209
210fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
211    match v {
212        serde_json::Value::Null => Ok(None),
213        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
214            .map(|dt| Some(dt.into()))
215            .map_err(|e| AppError::Schema(format!("cursor cache: invalid reset timestamp: {e}"))),
216        _ => Err(AppError::Schema(
217            "cursor cache: invalid reset timestamp".into(),
218        )),
219    }
220}
221
222fn snap_to_json(snap: &CursorSnapshot, account: &str) -> serde_json::Value {
223    serde_json::json!({
224        "account": account,
225        "plan": snap.plan,
226        "auto_pct": snap.auto_pct,
227        "api_pct": snap.api_pct,
228        "total_pct": snap.total_pct,
229        "unlimited": snap.unlimited,
230        "on_demand_enabled": snap.on_demand_enabled,
231        "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
232    })
233}
234
235async fn fetch_live(
236    client: &reqwest::Client,
237    endpoints: &Endpoints,
238    auth: &db::SessionAuth,
239) -> Result<CursorSnapshot> {
240    // usage-summary keys off the session cookie alone (no `?user=` param); the
241    // browser-ish headers get past its CORS gate.
242    let resp = tokio::time::timeout(
243        HTTP_TIMEOUT,
244        client
245            .get(&endpoints.summary)
246            .header(
247                "Cookie",
248                format!("WorkosCursorSessionToken={}", auth.cookie_value),
249            )
250            .header("Origin", BASE_URL)
251            .header("Referer", format!("{BASE_URL}/dashboard"))
252            .header("User-Agent", BROWSER_UA)
253            .send(),
254    )
255    .await
256    .map_err(|_| AppError::Transport(format!("cursor timeout: {}", endpoints.summary)))??;
257
258    let status = resp.status();
259    if !status.is_success() {
260        let body = if matches!(status.as_u16(), 401 | 403) {
261            "Cursor authentication failed".into()
262        } else {
263            format!("Cursor API returned HTTP {}", status.as_u16())
264        };
265        return Err(AppError::Http {
266            status: status.as_u16(),
267            body,
268        });
269    }
270
271    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
272    let parsed: UsageSummary = serde_json::from_slice(&bytes)
273        .map_err(|e| AppError::Schema(format!("cursor usage-summary response: {e}")))?;
274    types::to_snapshot(parsed)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use rusqlite::Connection;
281    use tempfile::TempDir;
282
283    fn cache_fixture() -> (TempDir, Cache) {
284        let td = TempDir::new().unwrap();
285        let cache = Cache::at(td.path().join("cursor"));
286        cache.ensure_dir().unwrap();
287        (td, cache)
288    }
289
290    /// A minimal, unsigned JWT with `sub: "auth0|<user_id>"` — signature
291    /// verification is never performed (see `db::parse_jwt_claims`).
292    fn fake_token(user_id: &str) -> String {
293        use base64::Engine;
294        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
295        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
296            .encode(serde_json::json!({"sub": format!("auth0|{user_id}")}).to_string());
297        format!("{header}.{payload}.sig")
298    }
299
300    fn seed_state_db(dir: &TempDir, token: &str) -> std::path::PathBuf {
301        let path = dir.path().join("state.vscdb");
302        let conn = Connection::open(&path).unwrap();
303        conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
304            .unwrap();
305        conn.execute(
306            "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', ?1)",
307            [token],
308        )
309        .unwrap();
310        path
311    }
312
313    fn account_key(token: &str) -> String {
314        db::session_auth(token).unwrap().account_key
315    }
316
317    fn cached_snapshot(account: &str, reset_at: &str) -> Vec<u8> {
318        serde_json::to_vec(&serde_json::json!({
319            "account": account,
320            "plan": "Ultra",
321            "auto_pct": 40,
322            "api_pct": 10,
323            "total_pct": 30,
324            "unlimited": false,
325            "on_demand_enabled": false,
326            "reset_at": reset_at,
327        }))
328        .unwrap()
329    }
330
331    fn sample_json() -> String {
332        r#"{
333            "billingCycleEnd": "2099-08-04T00:35:51.000Z",
334            "membershipType": "ultra",
335            "isUnlimited": false,
336            "individualUsage": {
337                "plan": { "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5 },
338                "onDemand": { "enabled": false }
339            }
340        }"#
341        .to_string()
342    }
343
344    #[tokio::test]
345    async fn live_fetch_reads_token_from_db_and_sends_the_session_cookie() {
346        let mut server = mockito::Server::new_async().await;
347        let token = fake_token("user_123");
348        let m = server
349            .mock("GET", "/api/usage-summary")
350            .match_header(
351                "cookie",
352                format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
353            )
354            .with_status(200)
355            .with_body(sample_json())
356            .create_async()
357            .await;
358
359        let db_dir = TempDir::new().unwrap();
360        let db_path = seed_state_db(&db_dir, &token);
361        let (_cache_dir, cache) = cache_fixture();
362        let client = reqwest::Client::new();
363        let endpoints = Endpoints {
364            summary: format!("{}/api/usage-summary", server.url()),
365        };
366
367        let out = fetch_snapshot(
368            &client,
369            &db_path,
370            &cache,
371            &endpoints,
372            Duration::from_secs(0),
373        )
374        .await
375        .unwrap();
376        m.assert_async().await;
377        assert_eq!(out.snapshot.plan, "Ultra");
378        assert_eq!(out.snapshot.auto_pct, 98);
379        assert_eq!(out.snapshot.api_pct, 100);
380        assert!(!out.stale);
381    }
382
383    #[tokio::test]
384    async fn missing_db_file_is_a_credentials_error_with_no_cache_to_fall_back_on() {
385        let (_cache_dir, cache) = cache_fixture();
386        let client = reqwest::Client::new();
387        let endpoints = Endpoints::default();
388        let db_path = std::path::Path::new("/nonexistent/state.vscdb");
389
390        let err = fetch_snapshot(&client, db_path, &cache, &endpoints, Duration::from_secs(0))
391            .await
392            .unwrap_err();
393        assert!(matches!(err, AppError::Credentials(_)));
394    }
395
396    #[tokio::test]
397    async fn http_error_falls_back_to_cache_and_hides_the_upstream_body() {
398        let mut server = mockito::Server::new_async().await;
399        let token = fake_token("user_123");
400        server
401            .mock("GET", "/api/usage-summary")
402            .with_status(401)
403            .with_body(r#"{"detail":"leaked-looking body"}"#)
404            .create_async()
405            .await;
406
407        let db_dir = TempDir::new().unwrap();
408        let db_path = seed_state_db(&db_dir, &token);
409        let (_cache_dir, cache) = cache_fixture();
410        cache
411            .write_payload(&cached_snapshot(
412                &account_key(&token),
413                "2099-08-04T00:00:00Z",
414            ))
415            .unwrap();
416
417        let client = reqwest::Client::new();
418        let endpoints = Endpoints {
419            summary: format!("{}/api/usage-summary", server.url()),
420        };
421        let out = fetch_snapshot(
422            &client,
423            &db_path,
424            &cache,
425            &endpoints,
426            Duration::from_secs(0),
427        )
428        .await
429        .unwrap();
430        assert!(out.stale);
431        assert_eq!(out.snapshot.auto_pct, 40);
432        let (code, msg) = out.last_error.unwrap();
433        assert_eq!(code, 401);
434        assert_eq!(msg, "Cursor authentication failed");
435        assert!(!msg.contains("leaked-looking"));
436    }
437
438    #[tokio::test]
439    async fn fresh_cache_is_used_after_verifying_the_current_account() {
440        let token = fake_token("user_123");
441        let db_dir = TempDir::new().unwrap();
442        let db_path = seed_state_db(&db_dir, &token);
443        let (_cache_dir, cache) = cache_fixture();
444        cache
445            .write_payload(
446                serde_json::json!({
447                    "account": account_key(&token),
448                    "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
449                    "unlimited": false, "on_demand_enabled": true,
450                    "reset_at": "2099-08-04T00:00:00Z",
451                })
452                .to_string()
453                .as_bytes(),
454            )
455            .unwrap();
456
457        let client = reqwest::Client::new();
458        let endpoints = Endpoints::default();
459        let out = fetch_snapshot(
460            &client,
461            &db_path,
462            &cache,
463            &endpoints,
464            Duration::from_secs(3600),
465        )
466        .await
467        .unwrap();
468        assert_eq!(out.snapshot.auto_pct, 7);
469        assert!(out.snapshot.on_demand_enabled);
470        assert!(!out.stale);
471    }
472
473    #[tokio::test]
474    async fn switching_accounts_rejects_a_fresh_cache_and_refetches() {
475        let old_token = fake_token("old_account");
476        let new_token = fake_token("new_account");
477        let db_dir = TempDir::new().unwrap();
478        let db_path = seed_state_db(&db_dir, &new_token);
479        let (_cache_dir, cache) = cache_fixture();
480        cache
481            .write_payload(&cached_snapshot(
482                &account_key(&old_token),
483                "2099-08-04T00:00:00Z",
484            ))
485            .unwrap();
486
487        let mut server = mockito::Server::new_async().await;
488        let request = server
489            .mock("GET", "/api/usage-summary")
490            .match_header(
491                "cookie",
492                format!("WorkosCursorSessionToken=new_account%3A%3A{new_token}").as_str(),
493            )
494            .with_status(200)
495            .with_body(sample_json())
496            .expect(1)
497            .create_async()
498            .await;
499        let endpoints = Endpoints {
500            summary: format!("{}/api/usage-summary", server.url()),
501        };
502
503        let out = fetch_snapshot(
504            &reqwest::Client::new(),
505            &db_path,
506            &cache,
507            &endpoints,
508            Duration::from_secs(3600),
509        )
510        .await
511        .unwrap();
512        request.assert_async().await;
513        assert_eq!(out.snapshot.auto_pct, 98);
514        assert!(!out.stale);
515    }
516
517    #[tokio::test]
518    async fn cache_past_its_billing_reset_is_not_served_during_an_outage() {
519        let token = fake_token("user_123");
520        let db_dir = TempDir::new().unwrap();
521        let db_path = seed_state_db(&db_dir, &token);
522        let (_cache_dir, cache) = cache_fixture();
523        cache
524            .write_payload(&cached_snapshot(
525                &account_key(&token),
526                "2026-08-04T00:00:00Z",
527            ))
528            .unwrap();
529
530        let mut server = mockito::Server::new_async().await;
531        server
532            .mock("GET", "/api/usage-summary")
533            .with_status(503)
534            .create_async()
535            .await;
536        let endpoints = Endpoints {
537            summary: format!("{}/api/usage-summary", server.url()),
538        };
539        let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
540            .unwrap()
541            .with_timezone(&Utc);
542        let err = fetch_snapshot_at(
543            &reqwest::Client::new(),
544            &db_path,
545            &cache,
546            &endpoints,
547            Duration::from_secs(0),
548            now,
549        )
550        .await
551        .unwrap_err();
552        assert!(matches!(err, AppError::Http { status: 503, .. }));
553    }
554
555    #[test]
556    fn cached_percentages_are_range_checked_before_narrowing() {
557        let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
558            .unwrap()
559            .with_timezone(&Utc);
560        let mut payload: serde_json::Value =
561            serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
562        payload["auto_pct"] = serde_json::json!(i64::MAX);
563        let err =
564            parse_cache_at(&serde_json::to_vec(&payload).unwrap(), "account", now).unwrap_err();
565        assert!(matches!(err, AppError::Schema(_)));
566    }
567}