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(
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
69async 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 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
135fn 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 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 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 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"); 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}