Skip to main content

lean_ctx/
cloud_client.rs

1use std::path::PathBuf;
2
3fn config_dir() -> PathBuf {
4    // GH #439: data_dir() already honors LEAN_CTX_DATA_DIR + legacy/XDG, so the
5    // cloud cache follows the migration instead of pinning ~/.lean-ctx.
6    crate::core::paths::data_dir()
7        .unwrap_or_else(|_| PathBuf::from("."))
8        .join("cloud")
9}
10
11fn credentials_path() -> PathBuf {
12    config_dir().join("credentials.json")
13}
14
15pub fn api_url() -> String {
16    std::env::var("LEAN_CTX_API_URL").unwrap_or_else(|_| "https://api.leanctx.com".to_string())
17}
18
19#[derive(serde::Serialize, serde::Deserialize)]
20struct Credentials {
21    api_key: String,
22    user_id: String,
23    email: String,
24    #[serde(default)]
25    oauth_client_id: Option<String>,
26    #[serde(default)]
27    oauth_client_secret: Option<String>,
28    #[serde(default)]
29    oauth_access_token: Option<String>,
30    #[serde(default)]
31    oauth_expires_at_unix: Option<i64>,
32}
33
34fn load_credentials() -> Option<Credentials> {
35    let path = credentials_path();
36    // One-time migration for files written before permissions were enforced:
37    // tighten anything looser than owner-only on every load.
38    tighten_secret_permissions(&path);
39    let data = std::fs::read_to_string(&path).ok()?;
40    serde_json::from_str(&data).ok()
41}
42
43fn write_credentials(creds: &Credentials) -> std::io::Result<()> {
44    let dir = config_dir();
45    std::fs::create_dir_all(&dir)?;
46    restrict_dir_permissions(&dir);
47    let json = serde_json::to_string_pretty(creds).map_err(std::io::Error::other)?;
48    write_secret_file(&credentials_path(), json.as_bytes())
49}
50
51/// Writes a secret file atomically (tmp + rename) with owner-only permissions
52/// (0o600 on Unix), so credentials are never world-readable — not even
53/// transiently between create and chmod.
54fn write_secret_file(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
55    use std::io::Write;
56
57    let parent = path
58        .parent()
59        .ok_or_else(|| std::io::Error::other("credentials path has no parent directory"))?;
60    let name = path
61        .file_name()
62        .ok_or_else(|| std::io::Error::other("credentials path has no file name"))?
63        .to_string_lossy();
64    let tmp = parent.join(format!(".{name}.tmp.{}", std::process::id()));
65
66    let mut opts = std::fs::OpenOptions::new();
67    opts.write(true).create_new(true);
68    #[cfg(unix)]
69    {
70        use std::os::unix::fs::OpenOptionsExt;
71        opts.mode(0o600);
72    }
73
74    let result = (|| {
75        let mut f = opts.open(&tmp)?;
76        f.write_all(bytes)?;
77        f.sync_all()?;
78        drop(f);
79        #[cfg(windows)]
80        {
81            if path.exists() {
82                std::fs::remove_file(path)?;
83            }
84        }
85        std::fs::rename(&tmp, path)
86    })();
87
88    if result.is_err() {
89        let _ = std::fs::remove_file(&tmp);
90    }
91    result
92}
93
94#[cfg(unix)]
95fn restrict_dir_permissions(dir: &std::path::Path) {
96    use std::os::unix::fs::PermissionsExt;
97    let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
98}
99
100#[cfg(not(unix))]
101fn restrict_dir_permissions(_dir: &std::path::Path) {}
102
103#[cfg(unix)]
104fn tighten_secret_permissions(path: &std::path::Path) {
105    use std::os::unix::fs::PermissionsExt;
106    if let Ok(meta) = std::fs::metadata(path)
107        && meta.permissions().mode() & 0o077 != 0
108    {
109        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
110    }
111}
112
113#[cfg(not(unix))]
114fn tighten_secret_permissions(_path: &std::path::Path) {}
115
116pub fn save_credentials(api_key: &str, user_id: &str, email: &str) -> std::io::Result<()> {
117    let mut creds = load_credentials().unwrap_or(Credentials {
118        api_key: api_key.to_string(),
119        user_id: user_id.to_string(),
120        email: email.to_string(),
121        oauth_client_id: None,
122        oauth_client_secret: None,
123        oauth_access_token: None,
124        oauth_expires_at_unix: None,
125    });
126    creds.api_key = api_key.to_string();
127    creds.user_id = user_id.to_string();
128    creds.email = email.to_string();
129    // Access tokens are bound to a client and should be re-fetched after login changes.
130    creds.oauth_access_token = None;
131    creds.oauth_expires_at_unix = None;
132    write_credentials(&creds)
133}
134
135pub fn load_api_key() -> Option<String> {
136    load_credentials().map(|c| c.api_key)
137}
138
139pub fn is_logged_in() -> bool {
140    load_credentials().is_some()
141}
142
143fn now_unix() -> i64 {
144    use std::time::{SystemTime, UNIX_EPOCH};
145    SystemTime::now()
146        .duration_since(UNIX_EPOCH)
147        .unwrap_or_default()
148        .as_secs() as i64
149}
150
151/// This machine's display label for the device overview (GL #387): the
152/// hostname, attached as `X-Device-Label` to every sync push. Display
153/// metadata only — the server treats it as an opaque, sanitized string and
154/// silently skips tracking when it is empty.
155fn device_label() -> String {
156    gethostname::gethostname().to_string_lossy().into_owned()
157}
158
159fn auth_bearer_token() -> Result<String, String> {
160    let mut creds = load_credentials().ok_or("Not logged in. Run: lean-ctx login")?;
161
162    if let (Some(client_id), Some(client_secret)) = (
163        creds.oauth_client_id.clone(),
164        creds.oauth_client_secret.clone(),
165    ) {
166        let now = now_unix();
167        if let (Some(token), Some(exp)) = (
168            creds.oauth_access_token.clone(),
169            creds.oauth_expires_at_unix,
170        ) && exp > now + 10
171        {
172            return Ok(token);
173        }
174
175        let url = format!("{}/oauth/token", api_url());
176        let resp = ureq::post(&url)
177            .header("Content-Type", "application/x-www-form-urlencoded")
178            .send_form([
179                ("grant_type", "client_credentials"),
180                ("client_id", client_id.as_str()),
181                ("client_secret", client_secret.as_str()),
182            ])
183            .map_err(|e| format!("OAuth token request failed: {e}"))?;
184
185        let resp_body = resp
186            .into_body()
187            .read_to_string()
188            .map_err(|e| format!("Failed to read OAuth response: {e}"))?;
189
190        let json: serde_json::Value =
191            serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
192
193        let token = json["access_token"]
194            .as_str()
195            .ok_or("Missing access_token in response")?
196            .to_string();
197        let expires_in = json["expires_in"].as_i64().unwrap_or(3600);
198        let exp = now + expires_in.saturating_sub(30);
199
200        creds.oauth_access_token = Some(token.clone());
201        creds.oauth_expires_at_unix = Some(exp);
202        let _ = write_credentials(&creds);
203
204        return Ok(token);
205    }
206
207    Ok(creds.api_key)
208}
209
210pub fn oauth_register_client(client_name: Option<&str>) -> Result<String, String> {
211    let mut creds = load_credentials().ok_or("Not logged in. Run: lean-ctx login")?;
212    if creds.oauth_client_id.is_some() && creds.oauth_client_secret.is_some() {
213        return Ok("OAuth client already registered.".to_string());
214    }
215
216    let url = format!("{}/oauth/register", api_url());
217    let body = if let Some(name) = client_name {
218        serde_json::json!({ "client_name": name })
219    } else {
220        serde_json::json!({})
221    };
222
223    let resp = ureq::post(&url)
224        .header("Authorization", &format!("Bearer {}", creds.api_key))
225        .header("Content-Type", "application/json")
226        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
227        .map_err(|e| format!("OAuth register failed: {e}"))?;
228
229    let resp_body = resp
230        .into_body()
231        .read_to_string()
232        .map_err(|e| format!("Failed to read response: {e}"))?;
233
234    let json: serde_json::Value =
235        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
236
237    creds.oauth_client_id = Some(
238        json["client_id"]
239            .as_str()
240            .ok_or("Missing client_id in response")?
241            .to_string(),
242    );
243    creds.oauth_client_secret = Some(
244        json["client_secret"]
245            .as_str()
246            .ok_or("Missing client_secret in response")?
247            .to_string(),
248    );
249    creds.oauth_access_token = None;
250    creds.oauth_expires_at_unix = None;
251    write_credentials(&creds).map_err(|e| format!("Failed to persist OAuth credentials: {e}"))?;
252
253    Ok("OAuth client registered. Cloud requests will use short-lived access tokens.".to_string())
254}
255
256pub struct RegisterResult {
257    pub api_key: String,
258    pub user_id: String,
259    pub email_verified: bool,
260    pub verification_sent: bool,
261}
262
263pub fn register(email: &str, password: Option<&str>) -> Result<RegisterResult, String> {
264    let url = format!("{}/api/auth/register", api_url());
265    let mut body = serde_json::json!({ "email": email });
266    if let Some(pw) = password {
267        body["password"] = serde_json::Value::String(pw.to_string());
268    }
269
270    let resp = ureq::post(&url)
271        .header("Content-Type", "application/json")
272        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
273        .map_err(|e| format!("Request failed: {e}"))?;
274
275    let resp_body = resp
276        .into_body()
277        .read_to_string()
278        .map_err(|e| format!("Failed to read response: {e}"))?;
279
280    let json: serde_json::Value =
281        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
282
283    Ok(RegisterResult {
284        api_key: json["api_key"]
285            .as_str()
286            .ok_or("Missing api_key in response")?
287            .to_string(),
288        user_id: json["user_id"]
289            .as_str()
290            .ok_or("Missing user_id in response")?
291            .to_string(),
292        email_verified: json["email_verified"].as_bool().unwrap_or(false),
293        verification_sent: json["verification_sent"].as_bool().unwrap_or(false),
294    })
295}
296
297pub fn forgot_password(email: &str) -> Result<String, String> {
298    let url = format!("{}/api/auth/forgot-password", api_url());
299    let body = serde_json::json!({ "email": email });
300
301    let resp = ureq::post(&url)
302        .header("Content-Type", "application/json")
303        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
304        .map_err(|e| format!("Request failed: {e}"))?;
305
306    let resp_body = resp
307        .into_body()
308        .read_to_string()
309        .map_err(|e| format!("Failed to read response: {e}"))?;
310
311    let json: serde_json::Value =
312        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
313
314    Ok(json["message"]
315        .as_str()
316        .unwrap_or("If an account exists, a reset email has been sent.")
317        .to_string())
318}
319
320pub fn login(email: &str, password: &str) -> Result<RegisterResult, String> {
321    let url = format!("{}/api/auth/login", api_url());
322    let body = serde_json::json!({ "email": email, "password": password });
323
324    let resp = ureq::post(&url)
325        .header("Content-Type", "application/json")
326        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
327        .map_err(|e| {
328            let msg = e.to_string();
329            if msg.contains("401") {
330                "Invalid email or password".to_string()
331            } else {
332                format!("Request failed: {e}")
333            }
334        })?;
335
336    let resp_body = resp
337        .into_body()
338        .read_to_string()
339        .map_err(|e| format!("Failed to read response: {e}"))?;
340
341    let json: serde_json::Value =
342        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
343
344    Ok(RegisterResult {
345        api_key: json["api_key"]
346            .as_str()
347            .ok_or("Missing api_key in response")?
348            .to_string(),
349        user_id: json["user_id"]
350            .as_str()
351            .ok_or("Missing user_id in response")?
352            .to_string(),
353        email_verified: json["email_verified"].as_bool().unwrap_or(false),
354        verification_sent: false,
355    })
356}
357
358pub fn sync_stats(stats: &[serde_json::Value]) -> Result<String, String> {
359    let bearer = auth_bearer_token()?;
360    let url = format!("{}/api/stats", api_url());
361
362    let body = serde_json::json!({ "stats": stats });
363
364    let resp = ureq::post(&url)
365        .header("Authorization", &format!("Bearer {bearer}"))
366        .header("Content-Type", "application/json")
367        .header("X-Device-Label", &device_label())
368        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
369        .map_err(|e| format!("Sync failed: {e}"))?;
370
371    let resp_body = resp
372        .into_body()
373        .read_to_string()
374        .map_err(|e| format!("Failed to read response: {e}"))?;
375
376    let json: serde_json::Value =
377        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
378
379    Ok(json["message"].as_str().unwrap_or("Synced").to_string())
380}
381
382pub fn contribute(entries: &[serde_json::Value]) -> Result<String, String> {
383    let url = format!("{}/api/contribute", api_url());
384
385    let body = serde_json::json!({ "entries": entries });
386
387    let resp = ureq::post(&url)
388        .header("Content-Type", "application/json")
389        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
390        .map_err(|e| format!("Contribute failed: {e}"))?;
391
392    let resp_body = resp
393        .into_body()
394        .read_to_string()
395        .map_err(|e| format!("Failed to read response: {e}"))?;
396
397    let json: serde_json::Value =
398        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
399
400    Ok(json["message"]
401        .as_str()
402        .unwrap_or("Contributed")
403        .to_string())
404}
405
406/// Send an anonymous telemetry heartbeat. No authentication required.
407/// Payload: installation_id (random UUID), version, OS, arch — nothing else.
408pub fn heartbeat(payload: &serde_json::Value) -> Result<String, String> {
409    let url = format!("{}/api/telemetry/heartbeat", api_url());
410
411    let resp = ureq::post(&url)
412        .header("Content-Type", "application/json")
413        .send(&serde_json::to_vec(payload).map_err(|e| format!("JSON error: {e}"))?)
414        .map_err(|e| format!("Heartbeat failed: {e}"))?;
415
416    let resp_body = resp
417        .into_body()
418        .read_to_string()
419        .map_err(|e| format!("Failed to read response: {e}"))?;
420
421    let json: serde_json::Value =
422        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
423
424    Ok(json["message"].as_str().unwrap_or("OK").to_string())
425}
426
427/// Result of a successful Wrapped publish (`POST /api/wrapped`). The `edit_token` is returned
428/// (and must be stored to delete/claim later) only on a *fresh* insert; on a signed re-publish
429/// the server updates the existing card in place and omits it (the client keeps the stored one).
430#[derive(serde::Deserialize)]
431pub struct PublishedCard {
432    pub id: String,
433    #[serde(default)]
434    pub edit_token: Option<String>,
435    #[serde(default)]
436    pub edit_token_challenge: Option<String>,
437    #[serde(default)]
438    pub challenge_expires_in_secs: Option<i64>,
439    pub url: String,
440    #[serde(skip)]
441    pub account_claimed: bool,
442}
443
444/// Publish a whitelisted Wrapped payload. Accepts either a bare payload (legacy anonymous) or a
445/// signed envelope `{payload_json, public_key, signature}` (login-less identity → server upsert).
446/// If the user is logged in, attaches a Bearer token so the server can auto-claim
447/// the card (leaderboard consolidation).
448pub fn publish_wrapped(payload: &serde_json::Value) -> Result<PublishedCard, String> {
449    let url = format!("{}/api/wrapped", api_url());
450
451    let mut req = ureq::post(&url).header("Content-Type", "application/json");
452    if let Ok(token) = auth_bearer_token() {
453        req = req.header("Authorization", &format!("Bearer {token}"));
454    }
455    let resp = req
456        .send(&serde_json::to_vec(payload).map_err(|e| format!("JSON error: {e}"))?)
457        .map_err(|e| format!("Publish failed: {e}"))?;
458
459    let resp_body = resp
460        .into_body()
461        .read_to_string()
462        .map_err(|e| format!("Failed to read response: {e}"))?;
463
464    serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
465}
466
467#[derive(serde::Deserialize)]
468struct RecoveredEditToken {
469    edit_token: String,
470}
471
472/// Exchange a one-time server challenge for a rotated edit token after the
473/// caller proves possession of the card's persistent publisher key.
474pub fn recover_wrapped_edit_token(
475    id: &str,
476    nonce: &str,
477    public_key: &str,
478    signature: &str,
479) -> Result<String, String> {
480    let url = format!("{}/api/wrapped/{id}/edit-token/recover", api_url());
481    let body = serde_json::json!({
482        "nonce": nonce,
483        "public_key": public_key,
484        "signature": signature,
485    });
486    let resp = ureq::post(&url)
487        .header("Content-Type", "application/json")
488        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
489        .map_err(|e| format!("Edit-token recovery failed: {e}"))?;
490    let response = resp
491        .into_body()
492        .read_to_string()
493        .map_err(|e| format!("Failed to read response: {e}"))?;
494    let recovered: RecoveredEditToken =
495        serde_json::from_str(&response).map_err(|e| format!("Invalid recovery response: {e}"))?;
496    if recovered.edit_token.is_empty() {
497        return Err("Invalid recovery response: empty edit token".to_string());
498    }
499    Ok(recovered.edit_token)
500}
501
502/// Delete a previously published card using its one-time `edit_token` (sent as `X-Edit-Token`).
503pub fn unpublish_wrapped(id: &str, edit_token: &str) -> Result<(), String> {
504    let url = format!("{}/api/wrapped/{id}", api_url());
505
506    ureq::delete(&url)
507        .header("X-Edit-Token", edit_token)
508        .call()
509        .map_err(|e| format!("Unpublish failed: {e}"))?;
510    Ok(())
511}
512
513/// Bind a published card to the logged-in account so the leaderboard stacks all of the
514/// user's machines under one entry (#488). Auth: account Bearer + the card's `edit_token`
515/// (`X-Edit-Token`). Server: `POST /api/wrapped/:id/claim`. Requires being logged in.
516pub fn claim_wrapped(id: &str, edit_token: &str) -> Result<(), String> {
517    let bearer = auth_bearer_token()?;
518    let url = format!("{}/api/wrapped/{id}/claim", api_url());
519
520    ureq::post(&url)
521        .header("Authorization", &format!("Bearer {bearer}"))
522        .header("X-Edit-Token", edit_token)
523        .send_empty()
524        .map_err(|e| format!("Claim failed: {e}"))?;
525    Ok(())
526}
527
528/// A freshly minted pairing code for login-less machine linking (GH #736).
529#[derive(serde::Deserialize)]
530pub struct LinkCode {
531    pub code: String,
532    pub expires_in_secs: i64,
533}
534
535/// Start a login-less machine link: mint a short-lived pairing code for this card.
536/// Auth: the card's `edit_token` only — no account. Server: `POST /api/wrapped/:id/link/start`.
537pub fn link_wrapped_start(id: &str, edit_token: &str) -> Result<LinkCode, String> {
538    let url = format!("{}/api/wrapped/{id}/link/start", api_url());
539
540    let resp = ureq::post(&url)
541        .header("X-Edit-Token", edit_token)
542        .send_empty()
543        .map_err(|e| format!("Link start failed: {e}"))?;
544    let body = resp
545        .into_body()
546        .read_to_string()
547        .map_err(|e| format!("Failed to read response: {e}"))?;
548    serde_json::from_str(&body).map_err(|e| format!("Invalid response: {e}"))
549}
550
551/// Complete a login-less machine link on the second machine: join this card into
552/// the pairing code's group. Auth: this card's `edit_token` — no account.
553/// Server: `POST /api/wrapped/:id/link/complete`.
554pub fn link_wrapped_complete(id: &str, edit_token: &str, code: &str) -> Result<(), String> {
555    let url = format!("{}/api/wrapped/{id}/link/complete", api_url());
556    let body = serde_json::json!({ "code": code });
557
558    ureq::post(&url)
559        .header("X-Edit-Token", edit_token)
560        .header("Content-Type", "application/json")
561        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
562        .map_err(|e| match e {
563            ureq::Error::StatusCode(404) => {
564                "code invalid or expired — mint a fresh one with  lean-ctx gain --link".to_string()
565            }
566            other => format!("Link failed: {other}"),
567        })?;
568    Ok(())
569}
570
571/// Push the knowledge store as a zero-knowledge vault (GL #467): entries are
572/// sealed client-side (XChaCha20-Poly1305, domain-separated HKDF key) — the
573/// backend stores ciphertext and can never read them. The first vault push
574/// also purges the account's legacy plaintext rows server-side.
575pub fn push_knowledge(entries: &[serde_json::Value]) -> Result<String, String> {
576    let bearer = auth_bearer_token()?;
577    let key = knowledge_vault_key()?;
578    let blob = crate::core::knowledge_vault::seal(entries, &key).map_err(|e| e.to_string())?;
579    let url = format!("{}/api/sync/knowledge", api_url());
580
581    let resp = ureq::post(&url)
582        .header("Authorization", &format!("Bearer {bearer}"))
583        .header("Content-Type", "application/octet-stream")
584        .header("X-Entry-Count", &entries.len().to_string())
585        .header("X-Device-Label", &device_label())
586        .send(blob.as_slice())
587        .map_err(|e| format!("Push failed: {e}"))?;
588
589    let resp_body = resp
590        .into_body()
591        .read_to_string()
592        .map_err(|e| format!("Failed to read response: {e}"))?;
593
594    let json: serde_json::Value =
595        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
596
597    Ok(format!(
598        "{} entries synced (end-to-end encrypted)",
599        json["entry_count"].as_i64().unwrap_or(entries.len() as i64)
600    ))
601}
602
603/// The account's knowledge-vault key — same stable-API-key derivation rule as
604/// [`index_bundle_key`], different HKDF domain (`knowledge-vault-v1`).
605fn knowledge_vault_key() -> Result<[u8; 32], String> {
606    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
607    if api_key.trim().is_empty() {
608        return Err("Not logged in. Run: lean-ctx login".into());
609    }
610    Ok(crate::core::knowledge_vault::derive_vault_key(&api_key))
611}
612
613pub fn pull_cloud_models() -> Result<serde_json::Value, String> {
614    let bearer = auth_bearer_token()?;
615    let url = format!("{}/api/cloud/models", api_url());
616
617    let resp = ureq::get(&url)
618        .header("Authorization", &format!("Bearer {bearer}"))
619        .call()
620        .map_err(|e| {
621            let msg = e.to_string();
622            if msg.contains("403") {
623                "This feature is not available for your account.".to_string()
624            } else {
625                format!("Connection failed. Check your internet connection. ({e})")
626            }
627        })?;
628
629    let resp_body = resp
630        .into_body()
631        .read_to_string()
632        .map_err(|e| format!("Failed to read response: {e}"))?;
633
634    serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
635}
636
637pub fn save_cloud_models(data: &serde_json::Value) -> std::io::Result<()> {
638    let dir = config_dir();
639    std::fs::create_dir_all(&dir)?;
640    let json = serde_json::to_string_pretty(data).map_err(std::io::Error::other)?;
641    std::fs::write(dir.join("cloud_models.json"), json)
642}
643
644pub fn load_cloud_models() -> Option<serde_json::Value> {
645    let path = config_dir().join("cloud_models.json");
646    let data = std::fs::read_to_string(path).ok()?;
647    serde_json::from_str(&data).ok()
648}
649
650/// Fetch the public community leaderboard as JSON (`{ "entries": [ … ] }`).
651///
652/// Public, login-less endpoint (`GET /api/leaderboard`, contract:
653/// `docs/contracts/wrapped-permalink-v1.md`). The dashboard proxies it
654/// same-origin (#466) so the browser never reaches `api.leanctx.com` directly —
655/// the dashboard CSP pins `connect-src` to `'self'`. A 10s global timeout keeps
656/// a slow upstream from tying up a dashboard request thread.
657pub fn fetch_leaderboard() -> Result<serde_json::Value, String> {
658    let url = format!("{}/api/leaderboard", api_url());
659    let resp = ureq::get(&url)
660        .config()
661        .timeout_global(Some(std::time::Duration::from_secs(10)))
662        .build()
663        .call()
664        .map_err(|e| format!("Could not reach the leaderboard service: {e}"))?;
665    let body = resp
666        .into_body()
667        .read_to_string()
668        .map_err(|e| format!("Failed to read leaderboard response: {e}"))?;
669    serde_json::from_str(&body).map_err(|e| format!("Invalid leaderboard JSON: {e}"))
670}
671
672pub fn is_cloud_user() -> bool {
673    let path = config_dir().join("plan.txt");
674    std::fs::read_to_string(path).is_ok_and(|p| matches!(p.trim(), "cloud" | "pro"))
675}
676
677/// Days a cached plan keeps granting its hosted entitlements while the billing
678/// backend is unreachable. Generous on purpose: a network blip or a weekend
679/// offline must never silently demote a paying user to Free.
680pub const PLAN_GRACE_DAYS: i64 = 14;
681
682fn plan_cache_path() -> PathBuf {
683    config_dir().join("plan.json")
684}
685
686/// The locally cached plan plus *when* it was last confirmed against the billing
687/// backend. The timestamp is what powers offline grace.
688#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
689pub struct PlanCache {
690    pub plan: String,
691    /// Unix seconds of the last successful backend confirmation.
692    pub verified_at: i64,
693}
694
695pub fn save_plan(plan: &str) -> std::io::Result<()> {
696    let dir = config_dir();
697    std::fs::create_dir_all(&dir)?;
698    // Legacy flat file kept for back-compat (`is_cloud_user` still reads it).
699    std::fs::write(dir.join("plan.txt"), plan)?;
700    // Structured cache carrying the verification time for offline grace.
701    let cache = PlanCache {
702        plan: plan.to_string(),
703        verified_at: now_unix(),
704    };
705    let json = serde_json::to_string_pretty(&cache).map_err(std::io::Error::other)?;
706    std::fs::write(plan_cache_path(), json)
707}
708
709/// The cached plan, if any. Prefers the structured `plan.json`; falls back to a
710/// legacy `plan.txt` (no timestamp → `verified_at = 0`, i.e. immediately past
711/// grace until the next successful refresh re-stamps it).
712pub fn cached_plan() -> Option<PlanCache> {
713    if let Ok(data) = std::fs::read_to_string(plan_cache_path())
714        && let Ok(cache) = serde_json::from_str::<PlanCache>(&data)
715    {
716        return Some(cache);
717    }
718    let legacy = std::fs::read_to_string(config_dir().join("plan.txt")).ok()?;
719    Some(PlanCache {
720        plan: legacy.trim().to_string(),
721        verified_at: 0,
722    })
723}
724
725/// Where an effective plan came from — drives the wording in `billing status`
726/// and the dashboard badge.
727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
728pub enum PlanSource {
729    /// Just confirmed against the backend this run.
730    Live,
731    /// Served from the local cache and still within the grace window.
732    Cached,
733    /// Cached confirmation is past the grace window → demoted to Free.
734    Expired,
735    /// No cached plan at all (never logged in / never synced) → Free.
736    None,
737}
738
739/// A resolved plan plus its provenance. The plan here is only ever used for
740/// *display* and for gating **hosted** surfaces — it never gates a local
741/// capability (Local-Free Invariant; the local engine has no entitlement checks).
742#[derive(Debug, Clone)]
743pub struct EffectivePlan {
744    pub plan: crate::core::billing::Plan,
745    pub source: PlanSource,
746    pub verified_at: Option<i64>,
747    pub grace_days: i64,
748}
749
750/// Pure grace check (no clock/IO) so it is unit-testable: is a plan confirmed at
751/// `verified_at` still within `grace_days` of `now`? Returns the age in days too.
752#[must_use]
753pub fn plan_within_grace(verified_at: i64, now: i64, grace_days: i64) -> (bool, i64) {
754    let age_days = (now - verified_at).max(0) / 86_400;
755    (age_days <= grace_days, age_days)
756}
757
758/// Resolve the effective plan from the **local cache only** (no network),
759/// applying the offline-grace policy. Use this on hot paths (dashboard
760/// requests); use [`refresh_effective_plan`] when a live confirmation is
761/// acceptable.
762///
763/// Commercial entitlements (incl. any self-hosted offline Enterprise license)
764/// are resolved by the control-plane and reach this client as the cached/live
765/// plan — the open engine carries no licensing logic (oss-plane-separation-v1).
766#[must_use]
767pub fn resolve_effective_plan_cached() -> EffectivePlan {
768    let grace_days = PLAN_GRACE_DAYS;
769    let Some(cache) = cached_plan() else {
770        return EffectivePlan {
771            plan: crate::core::billing::Plan::Free,
772            source: PlanSource::None,
773            verified_at: None,
774            grace_days,
775        };
776    };
777    let (fresh, _age) = plan_within_grace(cache.verified_at, now_unix(), grace_days);
778    if fresh {
779        EffectivePlan {
780            plan: crate::core::billing::Plan::parse(&cache.plan),
781            source: PlanSource::Cached,
782            verified_at: Some(cache.verified_at),
783            grace_days,
784        }
785    } else {
786        // Fail closed for *hosted* entitlements once grace lapses. Local features
787        // remain unaffected — they are never gated.
788        EffectivePlan {
789            plan: crate::core::billing::Plan::Free,
790            source: PlanSource::Expired,
791            verified_at: Some(cache.verified_at),
792            grace_days,
793        }
794    }
795}
796
797/// Best-effort *live* resolve: try the backend (refreshing the cache on success),
798/// otherwise fall back to the cached-with-grace plan. Suitable for explicit
799/// commands like `lean-ctx billing status` where a network round-trip is fine.
800#[must_use]
801pub fn refresh_effective_plan() -> EffectivePlan {
802    if is_logged_in()
803        && let Ok(plan_str) = fetch_plan()
804    {
805        let _ = save_plan(&plan_str);
806        return EffectivePlan {
807            plan: crate::core::billing::Plan::parse(&plan_str),
808            source: PlanSource::Live,
809            verified_at: Some(now_unix()),
810            grace_days: PLAN_GRACE_DAYS,
811        };
812    }
813    resolve_effective_plan_cached()
814}
815
816pub fn fetch_plan() -> Result<String, String> {
817    let bearer = auth_bearer_token()?;
818    let url = format!("{}/api/auth/me", api_url());
819
820    let resp = ureq::get(&url)
821        .header("Authorization", &format!("Bearer {bearer}"))
822        .call()
823        .map_err(|e| format!("Failed to check plan: {e}"))?;
824
825    let resp_body = resp
826        .into_body()
827        .read_to_string()
828        .map_err(|e| format!("Failed to read response: {e}"))?;
829
830    let json: serde_json::Value =
831        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))?;
832
833    Ok(json["plan"].as_str().unwrap_or("free").to_string())
834}
835
836/// Start a Stripe Checkout session for the logged-in account and return the
837/// hosted URL to open. `plan` is e.g. `"pro"` or `"team"`; `interval` is
838/// `"monthly"` or `"yearly"`. The open backend proxies this to the private
839/// billing plane (which returns `503` when billing is not configured).
840pub fn start_checkout(plan: &str, interval: &str) -> Result<String, String> {
841    let bearer = auth_bearer_token()?;
842    let url = format!("{}/api/account/checkout", api_url());
843    let body = serde_json::json!({ "plan": plan, "interval": interval });
844
845    let resp = ureq::post(&url)
846        .header("Authorization", &format!("Bearer {bearer}"))
847        .header("Content-Type", "application/json")
848        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
849        .map_err(|e| format!("Checkout request failed: {e}"))?;
850
851    let resp_body = resp
852        .into_body()
853        .read_to_string()
854        .map_err(|e| format!("Failed to read response: {e}"))?;
855
856    let json: serde_json::Value =
857        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))?;
858
859    json["url"]
860        .as_str()
861        .map(str::to_string)
862        .ok_or_else(|| "Billing did not return a checkout URL.".to_string())
863}
864
865pub fn push_commands(entries: &[serde_json::Value]) -> Result<String, String> {
866    let bearer = auth_bearer_token()?;
867    let url = format!("{}/api/sync/commands", api_url());
868    let body = serde_json::json!({ "commands": entries });
869    let resp = ureq::post(&url)
870        .header("Authorization", &format!("Bearer {bearer}"))
871        .header("Content-Type", "application/json")
872        .header("X-Device-Label", &device_label())
873        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
874        .map_err(|e| format!("Push failed: {e}"))?;
875    let resp_body = resp
876        .into_body()
877        .read_to_string()
878        .map_err(|e| format!("Failed to read response: {e}"))?;
879    let json: serde_json::Value =
880        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
881    Ok(format!(
882        "{} commands synced",
883        json["synced"].as_i64().unwrap_or(0)
884    ))
885}
886
887pub fn push_cep(entries: &[serde_json::Value]) -> Result<String, String> {
888    let bearer = auth_bearer_token()?;
889    let url = format!("{}/api/sync/cep", api_url());
890    let body = serde_json::json!({ "scores": entries });
891    let resp = ureq::post(&url)
892        .header("Authorization", &format!("Bearer {bearer}"))
893        .header("Content-Type", "application/json")
894        .header("X-Device-Label", &device_label())
895        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
896        .map_err(|e| format!("Push failed: {e}"))?;
897    let resp_body = resp
898        .into_body()
899        .read_to_string()
900        .map_err(|e| format!("Failed to read response: {e}"))?;
901    let json: serde_json::Value =
902        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
903    Ok(format!(
904        "{} sessions synced",
905        json["synced"].as_i64().unwrap_or(0)
906    ))
907}
908
909pub fn push_gain(entries: &[serde_json::Value]) -> Result<String, String> {
910    let bearer = auth_bearer_token()?;
911    let url = format!("{}/api/sync/gain", api_url());
912    let body = serde_json::json!({ "scores": entries });
913    let resp = ureq::post(&url)
914        .header("Authorization", &format!("Bearer {bearer}"))
915        .header("Content-Type", "application/json")
916        .header("X-Device-Label", &device_label())
917        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
918        .map_err(|e| format!("Push failed: {e}"))?;
919    let resp_body = resp
920        .into_body()
921        .read_to_string()
922        .map_err(|e| format!("Failed to read response: {e}"))?;
923    let json: serde_json::Value =
924        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
925    Ok(format!(
926        "{} gain scores synced",
927        json["synced"].as_i64().unwrap_or(0)
928    ))
929}
930
931/// Push gotchas as a zero-knowledge vault (GL #467 follow-up): sealed
932/// client-side under the `gotcha-vault-v1` HKDF domain — the backend stores
933/// ciphertext only and purges the account's legacy plaintext rows on the
934/// first vault push.
935pub fn push_gotchas(entries: &[serde_json::Value]) -> Result<String, String> {
936    let bearer = auth_bearer_token()?;
937    let key = gotcha_vault_key()?;
938    let blob = crate::core::knowledge_vault::seal(entries, &key).map_err(|e| e.to_string())?;
939    let url = format!("{}/api/sync/gotchas", api_url());
940
941    let resp = ureq::post(&url)
942        .header("Authorization", &format!("Bearer {bearer}"))
943        .header("Content-Type", "application/octet-stream")
944        .header("X-Entry-Count", &entries.len().to_string())
945        .header("X-Device-Label", &device_label())
946        .send(blob.as_slice())
947        .map_err(|e| format!("Push failed: {e}"))?;
948    let resp_body = resp
949        .into_body()
950        .read_to_string()
951        .map_err(|e| format!("Failed to read response: {e}"))?;
952    let json: serde_json::Value =
953        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
954    Ok(format!(
955        "{} gotchas synced (end-to-end encrypted)",
956        json["entry_count"].as_i64().unwrap_or(entries.len() as i64)
957    ))
958}
959
960/// The account's gotcha-vault key — own HKDF domain (`gotcha-vault-v1`),
961/// derivation rule identical to [`knowledge_vault_key`].
962fn gotcha_vault_key() -> Result<[u8; 32], String> {
963    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
964    if api_key.trim().is_empty() {
965        return Err("Not logged in. Run: lean-ctx login".into());
966    }
967    Ok(crate::core::knowledge_vault::derive_gotcha_vault_key(
968        &api_key,
969    ))
970}
971
972pub fn push_buddy(data: &serde_json::Value) -> Result<String, String> {
973    let bearer = auth_bearer_token()?;
974    let url = format!("{}/api/sync/buddy", api_url());
975    let resp = ureq::post(&url)
976        .header("Authorization", &format!("Bearer {bearer}"))
977        .header("Content-Type", "application/json")
978        .header("X-Device-Label", &device_label())
979        .send(&serde_json::to_vec(data).map_err(|e| format!("JSON error: {e}"))?)
980        .map_err(|e| format!("Push failed: {e}"))?;
981    let resp_body = resp
982        .into_body()
983        .read_to_string()
984        .map_err(|e| format!("Failed to read response: {e}"))?;
985    let _json: serde_json::Value =
986        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
987    Ok("Buddy synced".to_string())
988}
989
990pub fn push_feedback(entries: &[serde_json::Value]) -> Result<String, String> {
991    let bearer = auth_bearer_token()?;
992    let url = format!("{}/api/sync/feedback", api_url());
993    let resp = ureq::post(&url)
994        .header("Authorization", &format!("Bearer {bearer}"))
995        .header("Content-Type", "application/json")
996        .header("X-Device-Label", &device_label())
997        .send(&serde_json::to_vec(entries).map_err(|e| format!("JSON error: {e}"))?)
998        .map_err(|e| format!("Push failed: {e}"))?;
999    let resp_body = resp
1000        .into_body()
1001        .read_to_string()
1002        .map_err(|e| format!("Failed to read response: {e}"))?;
1003    let json: serde_json::Value =
1004        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
1005    Ok(format!(
1006        "{} thresholds synced",
1007        json["synced"].as_i64().unwrap_or(0)
1008    ))
1009}
1010
1011/// The signed-in account's email, for status displays.
1012pub fn account_email() -> Option<String> {
1013    load_credentials().map(|c| c.email)
1014}
1015
1016/// `GET /api/account/cloud` — the Personal Cloud dashboard payload (entitlement
1017/// gate, per-bucket sync footprint, buddy, usage totals). Powers
1018/// `lean-ctx cloud status`, mirroring what leanctx.com/account/cloud shows.
1019pub fn fetch_account_cloud() -> Result<serde_json::Value, String> {
1020    let bearer = auth_bearer_token()?;
1021    let url = format!("{}/api/account/cloud", api_url());
1022
1023    let resp = ureq::get(&url)
1024        .header("Authorization", &format!("Bearer {bearer}"))
1025        .call()
1026        .map_err(|e| format!("Status fetch failed: {e}"))?;
1027
1028    let resp_body = resp
1029        .into_body()
1030        .read_to_string()
1031        .map_err(|e| format!("Failed to read response: {e}"))?;
1032
1033    serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))
1034}
1035
1036/// Pull the knowledge store: vault-first (encrypted blob, decrypted locally),
1037/// with a legacy plaintext fallback for accounts that never pushed a vault.
1038pub fn pull_knowledge() -> Result<Vec<serde_json::Value>, String> {
1039    let bearer = auth_bearer_token()?;
1040    let url = format!("{}/api/sync/knowledge", api_url());
1041
1042    // Vault path (GL #467).
1043    match ureq::get(&url)
1044        .header("Authorization", &format!("Bearer {bearer}"))
1045        .header("Accept", "application/octet-stream")
1046        .call()
1047    {
1048        Ok(resp) => {
1049            let is_blob = resp
1050                .headers()
1051                .get("content-type")
1052                .and_then(|v| v.to_str().ok())
1053                .is_some_and(|v| v.starts_with("application/octet-stream"));
1054            if is_blob {
1055                let mut blob = Vec::new();
1056                use std::io::Read;
1057                resp.into_body()
1058                    .into_reader()
1059                    .read_to_end(&mut blob)
1060                    .map_err(|e| format!("Failed to read vault: {e}"))?;
1061                let key = knowledge_vault_key()?;
1062                return crate::core::knowledge_vault::open(&blob, &key).map_err(|e| e.to_string());
1063            }
1064            // Pre-vault server ignored the Accept header and answered with
1065            // the legacy JSON listing — parse it directly.
1066            let body = resp
1067                .into_body()
1068                .read_to_string()
1069                .map_err(|e| format!("Failed to read response: {e}"))?;
1070            return serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"));
1071        }
1072        // No vault yet → fall through to the legacy listing.
1073        Err(ureq::Error::StatusCode(404)) => {}
1074        Err(e) => return Err(format!("Pull failed: {e}")),
1075    }
1076
1077    let resp = ureq::get(&url)
1078        .header("Authorization", &format!("Bearer {bearer}"))
1079        .call()
1080        .map_err(|e| format!("Pull failed: {e}"))?;
1081
1082    let resp_body = resp
1083        .into_body()
1084        .read_to_string()
1085        .map_err(|e| format!("Failed to read response: {e}"))?;
1086
1087    let entries: Vec<serde_json::Value> =
1088        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
1089
1090    Ok(entries)
1091}
1092
1093// ── Hosted Personal Index (GL #392) ──────────────────────────────────────────
1094// Contract: docs/contracts/hosted-personal-index-v1.md. Bundles are encrypted
1095// client-side (core::index_bundle); the backend only ever sees ciphertext.
1096
1097/// The account's bundle encryption key, HKDF-derived from the stable API key
1098/// (never from the rotating OAuth token — the key must be identical on every
1099/// logged-in device).
1100fn index_bundle_key() -> Result<[u8; 32], String> {
1101    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
1102    if api_key.trim().is_empty() {
1103        return Err("Not logged in. Run: lean-ctx login".into());
1104    }
1105    Ok(crate::core::index_bundle::derive_key(&api_key))
1106}
1107
1108/// Pack, encrypt and upload the project's index bundle.
1109/// Returns `(project_hash, encrypted_size_bytes)`.
1110pub fn push_index_bundle(project_root: &std::path::Path) -> Result<(String, u64), String> {
1111    let (container, manifest) =
1112        crate::core::index_bundle::pack(project_root).map_err(|e| e.to_string())?;
1113    let blob = crate::core::index_bundle::encrypt(&container, &index_bundle_key()?)
1114        .map_err(|e| e.to_string())?;
1115
1116    let bearer = auth_bearer_token()?;
1117    let url = format!("{}/api/sync/index/{}", api_url(), manifest.project_hash);
1118    let resp = ureq::put(&url)
1119        .header("Authorization", &format!("Bearer {bearer}"))
1120        .header("Content-Type", "application/octet-stream")
1121        .header("X-Device-Label", &device_label())
1122        .send(blob.as_slice())
1123        .map_err(|e| match e {
1124            ureq::Error::StatusCode(402) => "Hosted index requires lean-ctx Pro. \
1125                 Run: lean-ctx cloud upgrade --plan pro"
1126                .to_string(),
1127            ureq::Error::StatusCode(413) => {
1128                "Quota exceeded — the push was blocked (nothing is billed). \
1129                 Free space with `lean-ctx sync index status` / delete, then retry."
1130                    .to_string()
1131            }
1132            other => format!("Push failed: {other}"),
1133        })?;
1134
1135    let body = resp
1136        .into_body()
1137        .read_to_string()
1138        .map_err(|e| format!("Failed to read response: {e}"))?;
1139    let _ack: serde_json::Value =
1140        serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"))?;
1141    Ok((manifest.project_hash, blob.len() as u64))
1142}
1143
1144/// Download, decrypt and unpack the hosted bundle for this project.
1145/// Returns the bundle manifest on success.
1146pub fn pull_index_bundle(
1147    project_root: &std::path::Path,
1148) -> Result<crate::core::index_bundle::BundleManifest, String> {
1149    let project_hash = crate::core::index_namespace::namespace_hash(project_root);
1150    let bearer = auth_bearer_token()?;
1151    let url = format!("{}/api/sync/index/{project_hash}", api_url());
1152
1153    let resp = ureq::get(&url)
1154        .header("Authorization", &format!("Bearer {bearer}"))
1155        .call()
1156        .map_err(|e| match e {
1157            ureq::Error::StatusCode(404) => format!(
1158                "No hosted index for this project yet ({project_hash}). \
1159                 Push one from a device with a built index: lean-ctx sync index push"
1160            ),
1161            ureq::Error::StatusCode(402) => "Hosted index requires lean-ctx Pro. \
1162                 Run: lean-ctx cloud upgrade --plan pro"
1163                .to_string(),
1164            other => format!("Pull failed: {other}"),
1165        })?;
1166
1167    let mut blob = Vec::new();
1168    use std::io::Read;
1169    resp.into_body()
1170        .into_reader()
1171        .read_to_end(&mut blob)
1172        .map_err(|e| format!("Failed to read bundle: {e}"))?;
1173
1174    let container = crate::core::index_bundle::decrypt(&blob, &index_bundle_key()?)
1175        .map_err(|e| e.to_string())?;
1176    crate::core::index_bundle::unpack(project_root, &container).map_err(|e| e.to_string())
1177}
1178
1179/// `GET /api/sync/index` — hosted-bucket listing + quota usage for the account.
1180pub fn index_bundle_status() -> Result<serde_json::Value, String> {
1181    let bearer = auth_bearer_token()?;
1182    let url = format!("{}/api/sync/index", api_url());
1183    let resp = ureq::get(&url)
1184        .header("Authorization", &format!("Bearer {bearer}"))
1185        .call()
1186        .map_err(|e| format!("Status fetch failed: {e}"))?;
1187    let body = resp
1188        .into_body()
1189        .read_to_string()
1190        .map_err(|e| format!("Failed to read response: {e}"))?;
1191    serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"))
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197    use crate::core::billing::Plan;
1198    // Only the `#[cfg(unix)]` credential-permission tests still take the env lock
1199    // directly; the plan-resolver tests use `isolated_data_dir()` (which locks
1200    // internally). Gating the import keeps the Windows cross-compile warning-free.
1201    #[cfg(unix)]
1202    use crate::core::data_dir::test_env_lock;
1203
1204    #[test]
1205    fn existing_card_publish_response_carries_recovery_challenge() {
1206        let card: PublishedCard = serde_json::from_value(serde_json::json!({
1207            "id": "card-1",
1208            "url": "https://leanctx.com/w/card-1",
1209            "edit_token_challenge": "nonce-1",
1210            "challenge_expires_in_secs": 300
1211        }))
1212        .unwrap();
1213        assert!(card.edit_token.is_none());
1214        assert_eq!(card.edit_token_challenge.as_deref(), Some("nonce-1"));
1215        assert_eq!(card.challenge_expires_in_secs, Some(300));
1216        assert!(!card.account_claimed);
1217    }
1218
1219    #[test]
1220    fn grace_window_boundaries_are_inclusive_and_skew_safe() {
1221        let now = 1_000_000_000;
1222        let day = 86_400;
1223        assert_eq!(plan_within_grace(now, now, 14), (true, 0));
1224        // Exactly at the edge stays valid (inclusive).
1225        assert_eq!(plan_within_grace(now - 14 * day, now, 14), (true, 14));
1226        // One day past → expired.
1227        assert_eq!(plan_within_grace(now - 15 * day, now, 14), (false, 15));
1228        // Clock skew (future timestamp) is clamped to age 0, never negative.
1229        assert_eq!(plan_within_grace(now + day, now, 14), (true, 0));
1230    }
1231
1232    #[test]
1233    fn plan_cache_roundtrips_through_json() {
1234        let c = PlanCache {
1235            plan: "pro".into(),
1236            verified_at: 42,
1237        };
1238        let back: PlanCache = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
1239        assert_eq!(back.plan, "pro");
1240        assert_eq!(back.verified_at, 42);
1241    }
1242
1243    #[test]
1244    fn cached_resolve_grants_within_grace_then_expires_to_free() {
1245        // Isolate all dirs (config + cache) so the resolver reads only the cache
1246        // this test writes, not a developer's real plan cache.
1247        let _iso = crate::core::data_dir::isolated_data_dir();
1248
1249        // A fresh save is served from cache, within grace, at full plan.
1250        save_plan("pro").unwrap();
1251        let eff = resolve_effective_plan_cached();
1252        assert_eq!(eff.plan, Plan::Pro);
1253        assert_eq!(eff.source, PlanSource::Cached);
1254
1255        // Backdate beyond grace → hosted entitlements fail closed to Free.
1256        let stale = PlanCache {
1257            plan: "pro".into(),
1258            verified_at: now_unix() - (PLAN_GRACE_DAYS + 1) * 86_400,
1259        };
1260        std::fs::write(plan_cache_path(), serde_json::to_string(&stale).unwrap()).unwrap();
1261        let eff = resolve_effective_plan_cached();
1262        assert_eq!(eff.plan, Plan::Free);
1263        assert_eq!(eff.source, PlanSource::Expired);
1264    }
1265
1266    #[test]
1267    fn no_cache_resolves_to_free_none() {
1268        let _iso = crate::core::data_dir::isolated_data_dir();
1269        let eff = resolve_effective_plan_cached();
1270        assert_eq!(eff.plan, Plan::Free);
1271        assert_eq!(eff.source, PlanSource::None);
1272    }
1273
1274    // P0-2 (#414): credentials must be owner-only on disk.
1275    #[cfg(unix)]
1276    #[test]
1277    fn credentials_are_written_owner_only_and_atomic() {
1278        use std::os::unix::fs::PermissionsExt;
1279        let _env = test_env_lock();
1280        let tmp = tempfile::tempdir().unwrap();
1281        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1282
1283        save_credentials("sk-test-key", "user-1", "a@b.c").unwrap();
1284
1285        let path = credentials_path();
1286        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1287        assert_eq!(mode & 0o777, 0o600, "credentials.json must be 0o600");
1288
1289        let dir_mode = std::fs::metadata(config_dir())
1290            .unwrap()
1291            .permissions()
1292            .mode();
1293        assert_eq!(
1294            dir_mode & 0o077,
1295            0,
1296            "cloud dir must not be group/world accessible"
1297        );
1298
1299        // No tmp file leftovers from the atomic write.
1300        let leftovers: Vec<_> = std::fs::read_dir(config_dir())
1301            .unwrap()
1302            .filter_map(Result::ok)
1303            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
1304            .collect();
1305        assert!(leftovers.is_empty(), "atomic write must not leak tmp files");
1306
1307        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1308    }
1309
1310    // P0-2 (#414): pre-existing world-readable credentials are tightened on load.
1311    #[cfg(unix)]
1312    #[test]
1313    fn loose_credential_permissions_are_tightened_on_load() {
1314        use std::os::unix::fs::PermissionsExt;
1315        let _env = test_env_lock();
1316        let tmp = tempfile::tempdir().unwrap();
1317        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1318
1319        std::fs::create_dir_all(config_dir()).unwrap();
1320        let path = credentials_path();
1321        std::fs::write(&path, "{}").unwrap();
1322        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1323
1324        let _ = load_credentials();
1325
1326        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1327        assert_eq!(
1328            mode & 0o777,
1329            0o600,
1330            "legacy file must be tightened to 0o600"
1331        );
1332
1333        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1334    }
1335
1336    #[test]
1337    fn legacy_plan_txt_is_migrated_but_treated_as_stale() {
1338        let _iso = crate::core::data_dir::isolated_data_dir();
1339        // Only the legacy flat file exists (no timestamp) → past grace until refresh.
1340        std::fs::create_dir_all(config_dir()).unwrap();
1341        std::fs::write(config_dir().join("plan.txt"), "team").unwrap();
1342        let cache = cached_plan().unwrap();
1343        assert_eq!(cache.plan, "team");
1344        assert_eq!(cache.verified_at, 0);
1345        assert_eq!(resolve_effective_plan_cached().source, PlanSource::Expired);
1346    }
1347}