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(
51 client: &reqwest::Client,
52 db_path: &Path,
53 cache: &Cache,
54 endpoints: &Endpoints,
55 cache_ttl: Duration,
56) -> Result<FetchOutcome> {
57 fetch_snapshot_at(client, db_path, cache, endpoints, cache_ttl, Utc::now()).await
58}
59
60async fn fetch_snapshot_at(
63 client: &reqwest::Client,
64 db_path: &Path,
65 cache: &Cache,
66 endpoints: &Endpoints,
67 cache_ttl: Duration,
68 now: DateTime<Utc>,
69) -> Result<FetchOutcome> {
70 cache.ensure_dir()?;
71 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
72
73 let token = db::read_access_token(db_path)?;
77 let auth = db::session_auth(&token)?;
78
79 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
80 && let Ok(outcome) = reuse_cache(&bytes, cache, false, &auth.account_key, now)
81 {
82 return Ok(outcome);
83 }
84
85 match fetch_live(client, endpoints, &auth).await {
86 Ok(snap) => {
87 let bytes = serde_json::to_vec(&snap_to_json(&snap, &auth.account_key))?;
88 cache.write_payload(&bytes)?;
89 Ok(FetchOutcome {
90 snapshot: snap,
91 stale: false,
92 last_error: None,
93 cache_age: Some(Duration::ZERO),
94 })
95 }
96 Err(e) if e.is_transient() => fallback_silent(cache, &auth.account_key, now, e),
97 Err(e) => {
98 cache.mark_stale();
99 if let Some((code, msg)) = error_to_pair(&e) {
100 cache.write_last_error(code, &msg);
101 }
102 fallback_with_error(cache, &auth.account_key, now, e)
103 }
104 }
105}
106
107fn fallback_silent(
108 cache: &Cache,
109 account: &str,
110 now: DateTime<Utc>,
111 original: AppError,
112) -> Result<FetchOutcome> {
113 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
114 return Err(original);
115 };
116 match reuse_cache(&bytes, cache, true, account, now) {
117 Ok(outcome) => Ok(outcome),
118 Err(_) => Err(original),
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 Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
129 return Err(original);
130 };
131 match reuse_cache(&bytes, cache, true, account, now) {
132 Ok(mut outcome) => {
133 outcome.last_error = error_to_pair(&original);
134 Ok(outcome)
135 }
136 Err(_) => Err(original),
137 }
138}
139
140fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
144 match e {
145 AppError::Http { status, .. } if matches!(status, 401 | 403) => {
146 Some((*status, "Cursor authentication failed".into()))
147 }
148 AppError::Http { status, body } => Some((*status, body.clone())),
149 e => Some((0, e.to_string())),
150 }
151}
152
153fn reuse_cache(
154 bytes: &[u8],
155 cache: &Cache,
156 stale: bool,
157 account: &str,
158 now: DateTime<Utc>,
159) -> Result<FetchOutcome> {
160 let snap = parse_cache_at(bytes, account, now)?;
161 Ok(FetchOutcome {
162 snapshot: snap,
163 stale,
164 last_error: cache.read_last_error(),
165 cache_age: cache.payload_age(),
166 })
167}
168
169fn parse_cache_at(bytes: &[u8], account: &str, now: DateTime<Utc>) -> Result<CursorSnapshot> {
170 let v: serde_json::Value = serde_json::from_slice(bytes)?;
171 if v.get("account").and_then(serde_json::Value::as_str) != Some(account) {
172 return Err(AppError::Schema(
173 "cursor cache belongs to a different account; refetching".into(),
174 ));
175 }
176 let int = |key: &str| -> Result<i32> {
177 v[key]
178 .as_i64()
179 .filter(|n| *n >= 0)
180 .and_then(|n| i32::try_from(n).ok())
181 .ok_or_else(|| AppError::Schema(format!("cursor cache: invalid {key}")))
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 reset_at: Some(reset_at),
207 })
208}
209
210fn parse_cache_datetime(v: &serde_json::Value) -> Result<Option<DateTime<Utc>>> {
211 match v {
212 serde_json::Value::Null => Ok(None),
213 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
214 .map(|dt| Some(dt.into()))
215 .map_err(|e| AppError::Schema(format!("cursor cache: invalid reset timestamp: {e}"))),
216 _ => Err(AppError::Schema(
217 "cursor cache: invalid reset timestamp".into(),
218 )),
219 }
220}
221
222fn snap_to_json(snap: &CursorSnapshot, account: &str) -> serde_json::Value {
223 serde_json::json!({
224 "account": account,
225 "plan": snap.plan,
226 "auto_pct": snap.auto_pct,
227 "api_pct": snap.api_pct,
228 "total_pct": snap.total_pct,
229 "unlimited": snap.unlimited,
230 "on_demand_enabled": snap.on_demand_enabled,
231 "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
232 })
233}
234
235async fn fetch_live(
236 client: &reqwest::Client,
237 endpoints: &Endpoints,
238 auth: &db::SessionAuth,
239) -> Result<CursorSnapshot> {
240 let resp = tokio::time::timeout(
243 HTTP_TIMEOUT,
244 client
245 .get(&endpoints.summary)
246 .header(
247 "Cookie",
248 format!("WorkosCursorSessionToken={}", auth.cookie_value),
249 )
250 .header("Origin", BASE_URL)
251 .header("Referer", format!("{BASE_URL}/dashboard"))
252 .header("User-Agent", BROWSER_UA)
253 .send(),
254 )
255 .await
256 .map_err(|_| AppError::Transport(format!("cursor timeout: {}", endpoints.summary)))??;
257
258 let status = resp.status();
259 if !status.is_success() {
260 let body = if matches!(status.as_u16(), 401 | 403) {
261 "Cursor authentication failed".into()
262 } else {
263 format!("Cursor API returned HTTP {}", status.as_u16())
264 };
265 return Err(AppError::Http {
266 status: status.as_u16(),
267 body,
268 });
269 }
270
271 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
272 let parsed: UsageSummary = serde_json::from_slice(&bytes)
273 .map_err(|e| AppError::Schema(format!("cursor usage-summary response: {e}")))?;
274 types::to_snapshot(parsed)
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use rusqlite::Connection;
281 use tempfile::TempDir;
282
283 fn cache_fixture() -> (TempDir, Cache) {
284 let td = TempDir::new().unwrap();
285 let cache = Cache::at(td.path().join("cursor"));
286 cache.ensure_dir().unwrap();
287 (td, cache)
288 }
289
290 fn fake_token(user_id: &str) -> String {
293 use base64::Engine;
294 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
295 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
296 .encode(serde_json::json!({"sub": format!("auth0|{user_id}")}).to_string());
297 format!("{header}.{payload}.sig")
298 }
299
300 fn seed_state_db(dir: &TempDir, token: &str) -> std::path::PathBuf {
301 let path = dir.path().join("state.vscdb");
302 let conn = Connection::open(&path).unwrap();
303 conn.execute("CREATE TABLE ItemTable (key TEXT, value TEXT)", [])
304 .unwrap();
305 conn.execute(
306 "INSERT INTO ItemTable (key, value) VALUES ('cursorAuth/accessToken', ?1)",
307 [token],
308 )
309 .unwrap();
310 path
311 }
312
313 fn account_key(token: &str) -> String {
314 db::session_auth(token).unwrap().account_key
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 &cache,
371 &endpoints,
372 Duration::from_secs(0),
373 )
374 .await
375 .unwrap();
376 m.assert_async().await;
377 assert_eq!(out.snapshot.plan, "Ultra");
378 assert_eq!(out.snapshot.auto_pct, 98);
379 assert_eq!(out.snapshot.api_pct, 100);
380 assert!(!out.stale);
381 }
382
383 #[tokio::test]
384 async fn missing_db_file_is_a_credentials_error_with_no_cache_to_fall_back_on() {
385 let (_cache_dir, cache) = cache_fixture();
386 let client = reqwest::Client::new();
387 let endpoints = Endpoints::default();
388 let db_path = std::path::Path::new("/nonexistent/state.vscdb");
389
390 let err = fetch_snapshot(&client, db_path, &cache, &endpoints, Duration::from_secs(0))
391 .await
392 .unwrap_err();
393 assert!(matches!(err, AppError::Credentials(_)));
394 }
395
396 #[tokio::test]
397 async fn http_error_falls_back_to_cache_and_hides_the_upstream_body() {
398 let mut server = mockito::Server::new_async().await;
399 let token = fake_token("user_123");
400 server
401 .mock("GET", "/api/usage-summary")
402 .with_status(401)
403 .with_body(r#"{"detail":"leaked-looking body"}"#)
404 .create_async()
405 .await;
406
407 let db_dir = TempDir::new().unwrap();
408 let db_path = seed_state_db(&db_dir, &token);
409 let (_cache_dir, cache) = cache_fixture();
410 cache
411 .write_payload(&cached_snapshot(
412 &account_key(&token),
413 "2099-08-04T00:00:00Z",
414 ))
415 .unwrap();
416
417 let client = reqwest::Client::new();
418 let endpoints = Endpoints {
419 summary: format!("{}/api/usage-summary", server.url()),
420 };
421 let out = fetch_snapshot(
422 &client,
423 &db_path,
424 &cache,
425 &endpoints,
426 Duration::from_secs(0),
427 )
428 .await
429 .unwrap();
430 assert!(out.stale);
431 assert_eq!(out.snapshot.auto_pct, 40);
432 let (code, msg) = out.last_error.unwrap();
433 assert_eq!(code, 401);
434 assert_eq!(msg, "Cursor authentication failed");
435 assert!(!msg.contains("leaked-looking"));
436 }
437
438 #[tokio::test]
439 async fn fresh_cache_is_used_after_verifying_the_current_account() {
440 let token = fake_token("user_123");
441 let db_dir = TempDir::new().unwrap();
442 let db_path = seed_state_db(&db_dir, &token);
443 let (_cache_dir, cache) = cache_fixture();
444 cache
445 .write_payload(
446 serde_json::json!({
447 "account": account_key(&token),
448 "plan": "Pro", "auto_pct": 7, "api_pct": 3, "total_pct": 5,
449 "unlimited": false, "on_demand_enabled": true,
450 "reset_at": "2099-08-04T00:00:00Z",
451 })
452 .to_string()
453 .as_bytes(),
454 )
455 .unwrap();
456
457 let client = reqwest::Client::new();
458 let endpoints = Endpoints::default();
459 let out = fetch_snapshot(
460 &client,
461 &db_path,
462 &cache,
463 &endpoints,
464 Duration::from_secs(3600),
465 )
466 .await
467 .unwrap();
468 assert_eq!(out.snapshot.auto_pct, 7);
469 assert!(out.snapshot.on_demand_enabled);
470 assert!(!out.stale);
471 }
472
473 #[tokio::test]
474 async fn switching_accounts_rejects_a_fresh_cache_and_refetches() {
475 let old_token = fake_token("old_account");
476 let new_token = fake_token("new_account");
477 let db_dir = TempDir::new().unwrap();
478 let db_path = seed_state_db(&db_dir, &new_token);
479 let (_cache_dir, cache) = cache_fixture();
480 cache
481 .write_payload(&cached_snapshot(
482 &account_key(&old_token),
483 "2099-08-04T00:00:00Z",
484 ))
485 .unwrap();
486
487 let mut server = mockito::Server::new_async().await;
488 let request = server
489 .mock("GET", "/api/usage-summary")
490 .match_header(
491 "cookie",
492 format!("WorkosCursorSessionToken=new_account%3A%3A{new_token}").as_str(),
493 )
494 .with_status(200)
495 .with_body(sample_json())
496 .expect(1)
497 .create_async()
498 .await;
499 let endpoints = Endpoints {
500 summary: format!("{}/api/usage-summary", server.url()),
501 };
502
503 let out = fetch_snapshot(
504 &reqwest::Client::new(),
505 &db_path,
506 &cache,
507 &endpoints,
508 Duration::from_secs(3600),
509 )
510 .await
511 .unwrap();
512 request.assert_async().await;
513 assert_eq!(out.snapshot.auto_pct, 98);
514 assert!(!out.stale);
515 }
516
517 #[tokio::test]
518 async fn cache_past_its_billing_reset_is_not_served_during_an_outage() {
519 let token = fake_token("user_123");
520 let db_dir = TempDir::new().unwrap();
521 let db_path = seed_state_db(&db_dir, &token);
522 let (_cache_dir, cache) = cache_fixture();
523 cache
524 .write_payload(&cached_snapshot(
525 &account_key(&token),
526 "2026-08-04T00:00:00Z",
527 ))
528 .unwrap();
529
530 let mut server = mockito::Server::new_async().await;
531 server
532 .mock("GET", "/api/usage-summary")
533 .with_status(503)
534 .create_async()
535 .await;
536 let endpoints = Endpoints {
537 summary: format!("{}/api/usage-summary", server.url()),
538 };
539 let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
540 .unwrap()
541 .with_timezone(&Utc);
542 let err = fetch_snapshot_at(
543 &reqwest::Client::new(),
544 &db_path,
545 &cache,
546 &endpoints,
547 Duration::from_secs(0),
548 now,
549 )
550 .await
551 .unwrap_err();
552 assert!(matches!(err, AppError::Http { status: 503, .. }));
553 }
554
555 #[test]
556 fn cached_percentages_are_range_checked_before_narrowing() {
557 let now = DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
558 .unwrap()
559 .with_timezone(&Utc);
560 let mut payload: serde_json::Value =
561 serde_json::from_slice(&cached_snapshot("account", "2026-08-04T00:00:00Z")).unwrap();
562 payload["auto_pct"] = serde_json::json!(i64::MAX);
563 let err =
564 parse_cache_at(&serde_json::to_vec(&payload).unwrap(), "account", now).unwrap_err();
565 assert!(matches!(err, AppError::Schema(_)));
566 }
567}