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