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, 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/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
40/// specialised to its snapshot.
41pub type FetchOutcome = crate::outcome::Outcome<CursorSnapshot>;
42
43/// Cache-aware fetch. `db_path` is Cursor's `state.vscdb` — the caller resolves
44/// `[cursor] db_path` (config override) vs [`db::default_db_path`], the same
45/// override pattern as `openai.codex_auth_path`. `agent_auth_path` is the
46/// headless `cursor-agent` CLI's own `auth.json`, tried when `db_path` is
47/// missing — see `db::resolve_access_token`, which on macOS also tries the
48/// CLI's Keychain items.
49pub async fn fetch_snapshot(
50    client: &reqwest::Client,
51    db_path: &Path,
52    agent_auth_path: &Path,
53    cache: &Cache,
54    endpoints: &Endpoints,
55    cache_ttl: Duration,
56) -> Result<FetchOutcome> {
57    fetch_snapshot_at(
58        client,
59        db_path,
60        agent_auth_path,
61        cache,
62        endpoints,
63        cache_ttl,
64        Utc::now(),
65    )
66    .await
67}
68
69/// Clock seam for cache rollover tests. Cursor's payload describes one billing
70/// cycle, so serving it after `reset_at` would knowingly show the prior cycle.
71async fn fetch_snapshot_at(
72    client: &reqwest::Client,
73    db_path: &Path,
74    agent_auth_path: &Path,
75    cache: &Cache,
76    endpoints: &Endpoints,
77    cache_ttl: Duration,
78    now: DateTime<Utc>,
79) -> Result<FetchOutcome> {
80    cache.ensure_dir()?;
81    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
82
83    // Resolve the local identity before accepting a cache hit. Cursor can switch
84    // accounts in-place in this database; returning the cache first would show
85    // the previous account's private usage until the TTL elapsed.
86    let token = db::resolve_access_token(db_path, agent_auth_path)?;
87    let auth = db::session_auth(&token)?;
88
89    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
90        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &auth.account_key, now)
91    {
92        return Ok(outcome);
93    }
94
95    match fetch_live(client, endpoints, &auth).await {
96        Ok(snap) => {
97            let bytes = serde_json::to_vec(&snap_to_json(&snap, &auth.account_key))?;
98            cache.write_payload(&bytes)?;
99            Ok(crate::outcome::Outcome::fresh(snap))
100        }
101        Err(e) if e.is_transient() => fallback_silent(cache, &auth.account_key, now, e),
102        Err(e) => {
103            cache.mark_stale();
104            if let Some((code, msg)) = error_to_pair(&e) {
105                cache.write_last_error(code, &msg);
106            }
107            fallback_with_error(cache, &auth.account_key, now, e)
108        }
109    }
110}
111
112fn fallback_silent(
113    cache: &Cache,
114    account: &str,
115    now: DateTime<Utc>,
116    original: AppError,
117) -> Result<FetchOutcome> {
118    crate::outcome::fallback(cache, None, original, |bytes| {
119        parse_cache_at(bytes, account, now)
120    })
121}
122
123fn fallback_with_error(
124    cache: &Cache,
125    account: &str,
126    now: DateTime<Utc>,
127    original: AppError,
128) -> Result<FetchOutcome> {
129    let last_error = error_to_pair(&original);
130    crate::outcome::fallback(cache, last_error, original, |bytes| {
131        parse_cache_at(bytes, account, now)
132    })
133}
134
135/// Never surface upstream bodies for auth failures: the request carried a
136/// session cookie derived from a signed-in token, and 401/403 bodies from a
137/// scraped web endpoint are not guaranteed not to echo it back.
138fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
139    match e {
140        AppError::Http { status, .. } if matches!(status, 401 | 403) => {
141            Some((*status, "Cursor authentication failed".into()))
142        }
143        AppError::Http { status, body } => Some((*status, body.clone())),
144        e => Some((0, e.to_string())),
145    }
146}
147
148fn reuse_cache(
149    bytes: &[u8],
150    cache: &Cache,
151    stale: bool,
152    account: &str,
153    now: DateTime<Utc>,
154) -> Result<FetchOutcome> {
155    let snap = parse_cache_at(bytes, account, now)?;
156    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
157}
158
159fn parse_cache_at(bytes: &[u8], account: &str, now: DateTime<Utc>) -> Result<CursorSnapshot> {
160    let v: serde_json::Value = serde_json::from_slice(bytes)?;
161    if v.get("account").and_then(serde_json::Value::as_str) != Some(account) {
162        return Err(AppError::Schema(
163            "cursor cache belongs to a different account; refetching".into(),
164        ));
165    }
166    let int = |key: &str| -> Result<i32> {
167        v[key]
168            .as_i64()
169            .filter(|n| *n >= 0)
170            .and_then(|n| i32::try_from(n).ok())
171            .ok_or_else(|| AppError::Schema(format!("cursor cache: invalid {key}")))
172    };
173    let optional_cents = |key: &str| -> Result<Option<i64>> {
174        match v.get(key) {
175            None | Some(serde_json::Value::Null) => Ok(None),
176            Some(value) => value
177                .as_i64()
178                .filter(|n| *n >= 0)
179                .map(Some)
180                .ok_or_else(|| AppError::Schema(format!("cursor cache: invalid {key}"))),
181        }
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        on_demand_used_cents: optional_cents("on_demand_used_cents")?,
207        on_demand_limit_cents: optional_cents("on_demand_limit_cents")?,
208        reset_at: Some(reset_at),
209        cycle_start: v
210            .get("cycle_start")
211            .and_then(|c| parse_cache_datetime(c).ok())
212            .flatten(),
213    })
214}
215
216fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
217    match v {
218        serde_json::Value::Null => Ok(None),
219        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
220            .map(|dt| Some(dt.into()))
221            .map_err(|e| AppError::Schema(format!("cursor cache: invalid reset timestamp: {e}"))),
222        _ => Err(AppError::Schema(
223            "cursor cache: invalid reset timestamp".into(),
224        )),
225    }
226}
227
228fn snap_to_json(snap: &CursorSnapshot, account: &str) -> serde_json::Value {
229    serde_json::json!({
230        "account": account,
231        "plan": snap.plan,
232        "auto_pct": snap.auto_pct,
233        "api_pct": snap.api_pct,
234        "total_pct": snap.total_pct,
235        "unlimited": snap.unlimited,
236        "on_demand_enabled": snap.on_demand_enabled,
237        "on_demand_used_cents": snap.on_demand_used_cents,
238        "on_demand_limit_cents": snap.on_demand_limit_cents,
239        "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
240        "cycle_start": snap.cycle_start.map(|dt| dt.to_rfc3339()),
241    })
242}
243
244async fn fetch_live(
245    client: &reqwest::Client,
246    endpoints: &Endpoints,
247    auth: &db::SessionAuth,
248) -> Result<CursorSnapshot> {
249    // usage-summary keys off the session cookie alone (no `?user=` param); the
250    // browser-ish headers get past its CORS gate.
251    let resp = tokio::time::timeout(
252        HTTP_TIMEOUT,
253        client
254            .get(&endpoints.summary)
255            .header(
256                "Cookie",
257                format!("WorkosCursorSessionToken={}", auth.cookie_value),
258            )
259            .header("Origin", BASE_URL)
260            .header("Referer", format!("{BASE_URL}/dashboard"))
261            .header("User-Agent", BROWSER_UA)
262            .send(),
263    )
264    .await
265    .map_err(|_| AppError::Transport(format!("cursor timeout: {}", endpoints.summary)))??;
266
267    let status = resp.status();
268    if !status.is_success() {
269        let body = if matches!(status.as_u16(), 401 | 403) {
270            "Cursor authentication failed".into()
271        } else {
272            format!("Cursor API returned HTTP {}", status.as_u16())
273        };
274        return Err(AppError::Http {
275            status: status.as_u16(),
276            body,
277        });
278    }
279
280    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
281    let parsed: UsageSummary = serde_json::from_slice(&bytes)
282        .map_err(|e| AppError::Schema(format!("cursor usage-summary response: {e}")))?;
283    types::to_snapshot(parsed)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use rusqlite::Connection;
290    use tempfile::TempDir;
291
292    fn cache_fixture() -> (TempDir, Cache) {
293        let td = TempDir::new().unwrap();
294        let cache = Cache::at(td.path().join("cursor"));
295        cache.ensure_dir().unwrap();
296        (td, cache)
297    }
298
299    /// A minimal, unsigned JWT with `sub: "auth0|<user_id>"` — signature
300    /// verification is never performed (see `db::parse_jwt_claims`).
301    fn fake_token(user_id: &str) -> String {
302        use base64::Engine;
303        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
304        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
305            .encode(serde_json::json!({"sub": format!("auth0|{user_id}")}).to_string());
306        format!("{header}.{payload}.sig")
307    }
308
309    fn seed_state_db(dir: &TempDir, token: &str) -> std::path::PathBuf {
310        let path = dir.path().join("state.vscdb");
311        let conn = Connection::open(&path).unwrap();
312        conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
313            .unwrap();
314        conn.execute(
315            "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', ?1)",
316            [token],
317        )
318        .unwrap();
319        path
320    }
321
322    fn account_key(token: &str) -> String {
323        db::session_auth(token).unwrap().account_key
324    }
325
326    /// A path that never exists, for tests that only care about the IDE
327    /// `db_path` and want the agent fallback to stay out of the way.
328    fn no_agent_auth() -> std::path::PathBuf {
329        std::path::PathBuf::from("/nonexistent/cursor-agent-auth.json")
330    }
331
332    fn cached_snapshot(account: &str, reset_at: &str) -> Vec<u8> {
333        serde_json::to_vec(&serde_json::json!({
334            "account": account,
335            "plan": "Ultra",
336            "auto_pct": 40,
337            "api_pct": 10,
338            "total_pct": 30,
339            "unlimited": false,
340            "on_demand_enabled": false,
341            "reset_at": reset_at,
342        }))
343        .unwrap()
344    }
345
346    fn sample_json() -> String {
347        r#"{
348            "billingCycleEnd": "2099-08-04T00:35:51.000Z",
349            "membershipType": "ultra",
350            "isUnlimited": false,
351            "individualUsage": {
352                "plan": { "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5 },
353                "onDemand": { "enabled": false }
354            }
355        }"#
356        .to_string()
357    }
358
359    #[tokio::test]
360    async fn live_fetch_reads_token_from_db_and_sends_the_session_cookie() {
361        let mut server = mockito::Server::new_async().await;
362        let token = fake_token("user_123");
363        let m = server
364            .mock("GET", "/api/usage-summary")
365            .match_header(
366                "cookie",
367                format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
368            )
369            .with_status(200)
370            .with_body(sample_json())
371            .create_async()
372            .await;
373
374        let db_dir = TempDir::new().unwrap();
375        let db_path = seed_state_db(&db_dir, &token);
376        let (_cache_dir, cache) = cache_fixture();
377        let client = reqwest::Client::new();
378        let endpoints = Endpoints {
379            summary: format!("{}/api/usage-summary", server.url()),
380        };
381
382        let out = fetch_snapshot(
383            &client,
384            &db_path,
385            &no_agent_auth(),
386            &cache,
387            &endpoints,
388            Duration::from_secs(0),
389        )
390        .await
391        .unwrap();
392        m.assert_async().await;
393        assert_eq!(out.snapshot.plan, "Ultra");
394        assert_eq!(out.snapshot.auto_pct, 98);
395        assert_eq!(out.snapshot.api_pct, 100);
396        assert!(!out.stale);
397    }
398
399    #[tokio::test]
400    async fn missing_db_file_is_a_credentials_error_with_no_cache_to_fall_back_on() {
401        let (_cache_dir, cache) = cache_fixture();
402        let client = reqwest::Client::new();
403        let endpoints = Endpoints::default();
404        let db_path = std::path::Path::new("/nonexistent/state.vscdb");
405
406        let err = fetch_snapshot(
407            &client,
408            db_path,
409            &no_agent_auth(),
410            &cache,
411            &endpoints,
412            Duration::from_secs(0),
413        )
414        .await
415        .unwrap_err();
416        assert!(matches!(err, AppError::Credentials(_)));
417    }
418
419    #[tokio::test]
420    async fn agent_auth_file_is_used_when_the_ide_db_is_missing() {
421        let mut server = mockito::Server::new_async().await;
422        let token = fake_token("user_123");
423        let m = server
424            .mock("GET", "/api/usage-summary")
425            .match_header(
426                "cookie",
427                format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
428            )
429            .with_status(200)
430            .with_body(sample_json())
431            .create_async()
432            .await;
433
434        let dir = TempDir::new().unwrap();
435        let db_path = dir.path().join("state.vscdb"); // deliberately never seeded
436        let agent_path = dir.path().join("auth.json");
437        std::fs::write(
438            &agent_path,
439            serde_json::json!({"accessToken": token, "refreshToken": "r"}).to_string(),
440        )
441        .unwrap();
442        let (_cache_dir, cache) = cache_fixture();
443        let client = reqwest::Client::new();
444        let endpoints = Endpoints {
445            summary: format!("{}/api/usage-summary", server.url()),
446        };
447
448        let out = fetch_snapshot(
449            &client,
450            &db_path,
451            &agent_path,
452            &cache,
453            &endpoints,
454            Duration::from_secs(0),
455        )
456        .await
457        .unwrap();
458        m.assert_async().await;
459        assert_eq!(out.snapshot.plan, "Ultra");
460        assert!(!out.stale);
461    }
462
463    #[tokio::test]
464    async fn http_error_falls_back_to_cache_and_hides_the_upstream_body() {
465        let mut server = mockito::Server::new_async().await;
466        let token = fake_token("user_123");
467        server
468            .mock("GET", "/api/usage-summary")
469            .with_status(401)
470            .with_body(r#"{"detail":"leaked-looking body"}"#)
471            .create_async()
472            .await;
473
474        let db_dir = TempDir::new().unwrap();
475        let db_path = seed_state_db(&db_dir, &token);
476        let (_cache_dir, cache) = cache_fixture();
477        cache
478            .write_payload(&cached_snapshot(
479                &account_key(&token),
480                "2099-08-04T00:00:00Z",
481            ))
482            .unwrap();
483
484        let client = reqwest::Client::new();
485        let endpoints = Endpoints {
486            summary: format!("{}/api/usage-summary", server.url()),
487        };
488        let out = fetch_snapshot(
489            &client,
490            &db_path,
491            &no_agent_auth(),
492            &cache,
493            &endpoints,
494            Duration::from_secs(0),
495        )
496        .await
497        .unwrap();
498        assert!(out.stale);
499        assert_eq!(out.snapshot.auto_pct, 40);
500        let (code, msg) = out.last_error.unwrap();
501        assert_eq!(code, 401);
502        assert_eq!(msg, "Cursor authentication failed");
503        assert!(!msg.contains("leaked-looking"));
504    }
505
506    #[tokio::test]
507    async fn fresh_cache_is_used_after_verifying_the_current_account() {
508        let token = fake_token("user_123");
509        let db_dir = TempDir::new().unwrap();
510        let db_path = seed_state_db(&db_dir, &token);
511        let (_cache_dir, cache) = cache_fixture();
512        cache
513            .write_payload(
514                serde_json::json!({
515                    "account": account_key(&token),
516                    "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
517                    "unlimited": false, "on_demand_enabled": true,
518                    "on_demand_used_cents": 1785,
519                    "on_demand_limit_cents": 35000,
520                    "reset_at": "2099-08-04T00:00:00Z",
521                })
522                .to_string()
523                .as_bytes(),
524            )
525            .unwrap();
526
527        let client = reqwest::Client::new();
528        let endpoints = Endpoints::default();
529        let out = fetch_snapshot(
530            &client,
531            &db_path,
532            &no_agent_auth(),
533            &cache,
534            &endpoints,
535            Duration::from_secs(3600),
536        )
537        .await
538        .unwrap();
539        assert_eq!(out.snapshot.auto_pct, 7);
540        assert!(out.snapshot.on_demand_enabled);
541        assert_eq!(out.snapshot.on_demand_used_cents, Some(1785));
542        assert_eq!(out.snapshot.on_demand_limit_cents, Some(35000));
543        assert!(!out.stale);
544    }
545
546    #[tokio::test]
547    async fn switching_accounts_rejects_a_fresh_cache_and_refetches() {
548        let old_token = fake_token("old_account");
549        let new_token = fake_token("new_account");
550        let db_dir = TempDir::new().unwrap();
551        let db_path = seed_state_db(&db_dir, &new_token);
552        let (_cache_dir, cache) = cache_fixture();
553        cache
554            .write_payload(&cached_snapshot(
555                &account_key(&old_token),
556                "2099-08-04T00:00:00Z",
557            ))
558            .unwrap();
559
560        let mut server = mockito::Server::new_async().await;
561        let request = server
562            .mock("GET", "/api/usage-summary")
563            .match_header(
564                "cookie",
565                format!("WorkosCursorSessionToken=new_account%3A%3A{new_token}").as_str(),
566            )
567            .with_status(200)
568            .with_body(sample_json())
569            .expect(1)
570            .create_async()
571            .await;
572        let endpoints = Endpoints {
573            summary: format!("{}/api/usage-summary", server.url()),
574        };
575
576        let out = fetch_snapshot(
577            &reqwest::Client::new(),
578            &db_path,
579            &no_agent_auth(),
580            &cache,
581            &endpoints,
582            Duration::from_secs(3600),
583        )
584        .await
585        .unwrap();
586        request.assert_async().await;
587        assert_eq!(out.snapshot.auto_pct, 98);
588        assert!(!out.stale);
589    }
590
591    #[tokio::test]
592    async fn cache_past_its_billing_reset_is_not_served_during_an_outage() {
593        let token = fake_token("user_123");
594        let db_dir = TempDir::new().unwrap();
595        let db_path = seed_state_db(&db_dir, &token);
596        let (_cache_dir, cache) = cache_fixture();
597        cache
598            .write_payload(&cached_snapshot(
599                &account_key(&token),
600                "2026-08-04T00:00:00Z",
601            ))
602            .unwrap();
603
604        let mut server = mockito::Server::new_async().await;
605        server
606            .mock("GET", "/api/usage-summary")
607            .with_status(503)
608            .create_async()
609            .await;
610        let endpoints = Endpoints {
611            summary: format!("{}/api/usage-summary", server.url()),
612        };
613        let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
614            .unwrap()
615            .with_timezone(&Utc);
616        let err = fetch_snapshot_at(
617            &reqwest::Client::new(),
618            &db_path,
619            &no_agent_auth(),
620            &cache,
621            &endpoints,
622            Duration::from_secs(0),
623            now,
624        )
625        .await
626        .unwrap_err();
627        assert!(matches!(err, AppError::Http { status: 503, .. }));
628    }
629
630    #[test]
631    fn cached_percentages_are_range_checked_before_narrowing() {
632        let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
633            .unwrap()
634            .with_timezone(&Utc);
635        let mut payload: serde_json::Value =
636            serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
637        payload["auto_pct"] = serde_json::json!(i64::MAX);
638        let err =
639            parse_cache_at(&serde_json::to_vec(&payload).unwrap(), "account", now).unwrap_err();
640        assert!(matches!(err, AppError::Schema(_)));
641    }
642}