1use 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);
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
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
47pub 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
72async 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 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
153fn 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 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 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 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"); 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}