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