1use 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);
21const 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
39pub type FetchOutcome = crate::outcome::Outcome<CursorSnapshot>;
42
43pub 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
68async 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 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
134fn 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 plan = v["plan"]
173 .as_str()
174 .filter(|plan| !plan.trim().is_empty())
175 .ok_or_else(|| AppError::Schema("cursor cache: invalid plan".into()))?
176 .to_string();
177 let reset_at = parse_cache_datetime(&v["reset_at"])?
178 .ok_or_else(|| AppError::Schema("cursor cache: missing reset timestamp".into()))?;
179 if reset_at <= now {
180 return Err(AppError::Schema(
181 "cursor cache is past its billing-cycle reset; refetching".into(),
182 ));
183 }
184 Ok(CursorSnapshot {
185 plan,
186 auto_pct: int("auto_pct")?,
187 api_pct: int("api_pct")?,
188 total_pct: int("total_pct")?,
189 unlimited: v["unlimited"]
190 .as_bool()
191 .ok_or_else(|| AppError::Schema("cursor cache: invalid unlimited flag".into()))?,
192 on_demand_enabled: v["on_demand_enabled"]
193 .as_bool()
194 .ok_or_else(|| AppError::Schema("cursor cache: invalid on-demand flag".into()))?,
195 reset_at: Some(reset_at),
196 cycle_start: v
197 .get("cycle_start")
198 .and_then(|c| parse_cache_datetime(c).ok())
199 .flatten(),
200 })
201}
202
203fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
204 match v {
205 serde_json::Value::Null => Ok(None),
206 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
207 .map(|dt| Some(dt.into()))
208 .map_err(|e| AppError::Schema(format!("cursor cache: invalid reset timestamp: {e}"))),
209 _ => Err(AppError::Schema(
210 "cursor cache: invalid reset timestamp".into(),
211 )),
212 }
213}
214
215fn snap_to_json(snap: &CursorSnapshot, account: &str) -> serde_json::Value {
216 serde_json::json!({
217 "account": account,
218 "plan": snap.plan,
219 "auto_pct": snap.auto_pct,
220 "api_pct": snap.api_pct,
221 "total_pct": snap.total_pct,
222 "unlimited": snap.unlimited,
223 "on_demand_enabled": snap.on_demand_enabled,
224 "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
225 "cycle_start": snap.cycle_start.map(|dt| dt.to_rfc3339()),
226 })
227}
228
229async fn fetch_live(
230 client: &reqwest::Client,
231 endpoints: &Endpoints,
232 auth: &db::SessionAuth,
233) -> Result<CursorSnapshot> {
234 let resp = tokio::time::timeout(
237 HTTP_TIMEOUT,
238 client
239 .get(&endpoints.summary)
240 .header(
241 "Cookie",
242 format!("WorkosCursorSessionToken={}", auth.cookie_value),
243 )
244 .header("Origin", BASE_URL)
245 .header("Referer", format!("{BASE_URL}/dashboard"))
246 .header("User-Agent", BROWSER_UA)
247 .send(),
248 )
249 .await
250 .map_err(|_| AppError::Transport(format!("cursor timeout: {}", endpoints.summary)))??;
251
252 let status = resp.status();
253 if !status.is_success() {
254 let body = if matches!(status.as_u16(), 401 | 403) {
255 "Cursor authentication failed".into()
256 } else {
257 format!("Cursor API returned HTTP {}", status.as_u16())
258 };
259 return Err(AppError::Http {
260 status: status.as_u16(),
261 body,
262 });
263 }
264
265 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
266 let parsed: UsageSummary = serde_json::from_slice(&bytes)
267 .map_err(|e| AppError::Schema(format!("cursor usage-summary response: {e}")))?;
268 types::to_snapshot(parsed)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use rusqlite::Connection;
275 use tempfile::TempDir;
276
277 fn cache_fixture() -> (TempDir, Cache) {
278 let td = TempDir::new().unwrap();
279 let cache = Cache::at(td.path().join("cursor"));
280 cache.ensure_dir().unwrap();
281 (td, cache)
282 }
283
284 fn fake_token(user_id: &str) -> String {
287 use base64::Engine;
288 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
289 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
290 .encode(serde_json::json!({"sub": format!("auth0|{user_id}")}).to_string());
291 format!("{header}.{payload}.sig")
292 }
293
294 fn seed_state_db(dir: &TempDir, token: &str) -> std::path::PathBuf {
295 let path = dir.path().join("state.vscdb");
296 let conn = Connection::open(&path).unwrap();
297 conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
298 .unwrap();
299 conn.execute(
300 "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', ?1)",
301 [token],
302 )
303 .unwrap();
304 path
305 }
306
307 fn account_key(token: &str) -> String {
308 db::session_auth(token).unwrap().account_key
309 }
310
311 fn no_agent_auth() -> std::path::PathBuf {
314 std::path::PathBuf::from("/nonexistent/cursor-agent-auth.json")
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 &no_agent_auth(),
371 &cache,
372 &endpoints,
373 Duration::from_secs(0),
374 )
375 .await
376 .unwrap();
377 m.assert_async().await;
378 assert_eq!(out.snapshot.plan, "Ultra");
379 assert_eq!(out.snapshot.auto_pct, 98);
380 assert_eq!(out.snapshot.api_pct, 100);
381 assert!(!out.stale);
382 }
383
384 #[tokio::test]
385 async fn missing_db_file_is_a_credentials_error_with_no_cache_to_fall_back_on() {
386 let (_cache_dir, cache) = cache_fixture();
387 let client = reqwest::Client::new();
388 let endpoints = Endpoints::default();
389 let db_path = std::path::Path::new("/nonexistent/state.vscdb");
390
391 let err = fetch_snapshot(
392 &client,
393 db_path,
394 &no_agent_auth(),
395 &cache,
396 &endpoints,
397 Duration::from_secs(0),
398 )
399 .await
400 .unwrap_err();
401 assert!(matches!(err, AppError::Credentials(_)));
402 }
403
404 #[tokio::test]
405 async fn agent_auth_file_is_used_when_the_ide_db_is_missing() {
406 let mut server = mockito::Server::new_async().await;
407 let token = fake_token("user_123");
408 let m = server
409 .mock("GET", "/api/usage-summary")
410 .match_header(
411 "cookie",
412 format!("WorkosCursorSessionToken=user_123%3A%3A{token}").as_str(),
413 )
414 .with_status(200)
415 .with_body(sample_json())
416 .create_async()
417 .await;
418
419 let dir = TempDir::new().unwrap();
420 let db_path = dir.path().join("state.vscdb"); let agent_path = dir.path().join("auth.json");
422 std::fs::write(
423 &agent_path,
424 serde_json::json!({"accessToken": token, "refreshToken": "r"}).to_string(),
425 )
426 .unwrap();
427 let (_cache_dir, cache) = cache_fixture();
428 let client = reqwest::Client::new();
429 let endpoints = Endpoints {
430 summary: format!("{}/api/usage-summary", server.url()),
431 };
432
433 let out = fetch_snapshot(
434 &client,
435 &db_path,
436 &agent_path,
437 &cache,
438 &endpoints,
439 Duration::from_secs(0),
440 )
441 .await
442 .unwrap();
443 m.assert_async().await;
444 assert_eq!(out.snapshot.plan, "Ultra");
445 assert!(!out.stale);
446 }
447
448 #[tokio::test]
449 async fn http_error_falls_back_to_cache_and_hides_the_upstream_body() {
450 let mut server = mockito::Server::new_async().await;
451 let token = fake_token("user_123");
452 server
453 .mock("GET", "/api/usage-summary")
454 .with_status(401)
455 .with_body(r#"{"detail":"leaked-looking body"}"#)
456 .create_async()
457 .await;
458
459 let db_dir = TempDir::new().unwrap();
460 let db_path = seed_state_db(&db_dir, &token);
461 let (_cache_dir, cache) = cache_fixture();
462 cache
463 .write_payload(&cached_snapshot(
464 &account_key(&token),
465 "2099-08-04T00:00:00Z",
466 ))
467 .unwrap();
468
469 let client = reqwest::Client::new();
470 let endpoints = Endpoints {
471 summary: format!("{}/api/usage-summary", server.url()),
472 };
473 let out = fetch_snapshot(
474 &client,
475 &db_path,
476 &no_agent_auth(),
477 &cache,
478 &endpoints,
479 Duration::from_secs(0),
480 )
481 .await
482 .unwrap();
483 assert!(out.stale);
484 assert_eq!(out.snapshot.auto_pct, 40);
485 let (code, msg) = out.last_error.unwrap();
486 assert_eq!(code, 401);
487 assert_eq!(msg, "Cursor authentication failed");
488 assert!(!msg.contains("leaked-looking"));
489 }
490
491 #[tokio::test]
492 async fn fresh_cache_is_used_after_verifying_the_current_account() {
493 let token = fake_token("user_123");
494 let db_dir = TempDir::new().unwrap();
495 let db_path = seed_state_db(&db_dir, &token);
496 let (_cache_dir, cache) = cache_fixture();
497 cache
498 .write_payload(
499 serde_json::json!({
500 "account": account_key(&token),
501 "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
502 "unlimited": false, "on_demand_enabled": true,
503 "reset_at": "2099-08-04T00:00:00Z",
504 })
505 .to_string()
506 .as_bytes(),
507 )
508 .unwrap();
509
510 let client = reqwest::Client::new();
511 let endpoints = Endpoints::default();
512 let out = fetch_snapshot(
513 &client,
514 &db_path,
515 &no_agent_auth(),
516 &cache,
517 &endpoints,
518 Duration::from_secs(3600),
519 )
520 .await
521 .unwrap();
522 assert_eq!(out.snapshot.auto_pct, 7);
523 assert!(out.snapshot.on_demand_enabled);
524 assert!(!out.stale);
525 }
526
527 #[tokio::test]
528 async fn switching_accounts_rejects_a_fresh_cache_and_refetches() {
529 let old_token = fake_token("old_account");
530 let new_token = fake_token("new_account");
531 let db_dir = TempDir::new().unwrap();
532 let db_path = seed_state_db(&db_dir, &new_token);
533 let (_cache_dir, cache) = cache_fixture();
534 cache
535 .write_payload(&cached_snapshot(
536 &account_key(&old_token),
537 "2099-08-04T00:00:00Z",
538 ))
539 .unwrap();
540
541 let mut server = mockito::Server::new_async().await;
542 let request = server
543 .mock("GET", "/api/usage-summary")
544 .match_header(
545 "cookie",
546 format!("WorkosCursorSessionToken=new_account%3A%3A{new_token}").as_str(),
547 )
548 .with_status(200)
549 .with_body(sample_json())
550 .expect(1)
551 .create_async()
552 .await;
553 let endpoints = Endpoints {
554 summary: format!("{}/api/usage-summary", server.url()),
555 };
556
557 let out = fetch_snapshot(
558 &reqwest::Client::new(),
559 &db_path,
560 &no_agent_auth(),
561 &cache,
562 &endpoints,
563 Duration::from_secs(3600),
564 )
565 .await
566 .unwrap();
567 request.assert_async().await;
568 assert_eq!(out.snapshot.auto_pct, 98);
569 assert!(!out.stale);
570 }
571
572 #[tokio::test]
573 async fn cache_past_its_billing_reset_is_not_served_during_an_outage() {
574 let token = fake_token("user_123");
575 let db_dir = TempDir::new().unwrap();
576 let db_path = seed_state_db(&db_dir, &token);
577 let (_cache_dir, cache) = cache_fixture();
578 cache
579 .write_payload(&cached_snapshot(
580 &account_key(&token),
581 "2026-08-04T00:00:00Z",
582 ))
583 .unwrap();
584
585 let mut server = mockito::Server::new_async().await;
586 server
587 .mock("GET", "/api/usage-summary")
588 .with_status(503)
589 .create_async()
590 .await;
591 let endpoints = Endpoints {
592 summary: format!("{}/api/usage-summary", server.url()),
593 };
594 let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
595 .unwrap()
596 .with_timezone(&Utc);
597 let err = fetch_snapshot_at(
598 &reqwest::Client::new(),
599 &db_path,
600 &no_agent_auth(),
601 &cache,
602 &endpoints,
603 Duration::from_secs(0),
604 now,
605 )
606 .await
607 .unwrap_err();
608 assert!(matches!(err, AppError::Http { status: 503, .. }));
609 }
610
611 #[test]
612 fn cached_percentages_are_range_checked_before_narrowing() {
613 let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
614 .unwrap()
615 .with_timezone(&Utc);
616 let mut payload: serde_json::Value =
617 serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
618 payload["auto_pct"] = serde_json::json!(i64::MAX);
619 let err =
620 parse_cache_at(&serde_json::to_vec(&payload).unwrap(), "account", now).unwrap_err();
621 assert!(matches!(err, AppError::Schema(_)));
622 }
623}