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 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 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 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 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"); 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}