Skip to main content

lean_ctx/
cloud_client.rs

1use std::path::PathBuf;
2
3fn config_dir() -> PathBuf {
4    if let Ok(dir) = std::env::var("LEAN_CTX_DATA_DIR") {
5        return PathBuf::from(dir).join("cloud");
6    }
7    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
8    home.join(".lean-ctx").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        if meta.permissions().mode() & 0o077 != 0 {
108            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
109        }
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        ) {
171            if exp > now + 10 {
172                return Ok(token);
173            }
174        }
175
176        let url = format!("{}/oauth/token", api_url());
177        let resp = ureq::post(&url)
178            .header("Content-Type", "application/x-www-form-urlencoded")
179            .send_form([
180                ("grant_type", "client_credentials"),
181                ("client_id", client_id.as_str()),
182                ("client_secret", client_secret.as_str()),
183            ])
184            .map_err(|e| format!("OAuth token request failed: {e}"))?;
185
186        let resp_body = resp
187            .into_body()
188            .read_to_string()
189            .map_err(|e| format!("Failed to read OAuth response: {e}"))?;
190
191        let json: serde_json::Value =
192            serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
193
194        let token = json["access_token"]
195            .as_str()
196            .ok_or("Missing access_token in response")?
197            .to_string();
198        let expires_in = json["expires_in"].as_i64().unwrap_or(3600);
199        let exp = now + expires_in.saturating_sub(30);
200
201        creds.oauth_access_token = Some(token.clone());
202        creds.oauth_expires_at_unix = Some(exp);
203        let _ = write_credentials(&creds);
204
205        return Ok(token);
206    }
207
208    Ok(creds.api_key)
209}
210
211pub fn oauth_register_client(client_name: Option<&str>) -> Result<String, String> {
212    let mut creds = load_credentials().ok_or("Not logged in. Run: lean-ctx login")?;
213    if creds.oauth_client_id.is_some() && creds.oauth_client_secret.is_some() {
214        return Ok("OAuth client already registered.".to_string());
215    }
216
217    let url = format!("{}/oauth/register", api_url());
218    let body = if let Some(name) = client_name {
219        serde_json::json!({ "client_name": name })
220    } else {
221        serde_json::json!({})
222    };
223
224    let resp = ureq::post(&url)
225        .header("Authorization", &format!("Bearer {}", creds.api_key))
226        .header("Content-Type", "application/json")
227        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
228        .map_err(|e| format!("OAuth register failed: {e}"))?;
229
230    let resp_body = resp
231        .into_body()
232        .read_to_string()
233        .map_err(|e| format!("Failed to read response: {e}"))?;
234
235    let json: serde_json::Value =
236        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
237
238    creds.oauth_client_id = Some(
239        json["client_id"]
240            .as_str()
241            .ok_or("Missing client_id in response")?
242            .to_string(),
243    );
244    creds.oauth_client_secret = Some(
245        json["client_secret"]
246            .as_str()
247            .ok_or("Missing client_secret in response")?
248            .to_string(),
249    );
250    creds.oauth_access_token = None;
251    creds.oauth_expires_at_unix = None;
252    write_credentials(&creds).map_err(|e| format!("Failed to persist OAuth credentials: {e}"))?;
253
254    Ok("OAuth client registered. Cloud requests will use short-lived access tokens.".to_string())
255}
256
257pub struct RegisterResult {
258    pub api_key: String,
259    pub user_id: String,
260    pub email_verified: bool,
261    pub verification_sent: bool,
262}
263
264pub fn register(email: &str, password: Option<&str>) -> Result<RegisterResult, String> {
265    let url = format!("{}/api/auth/register", api_url());
266    let mut body = serde_json::json!({ "email": email });
267    if let Some(pw) = password {
268        body["password"] = serde_json::Value::String(pw.to_string());
269    }
270
271    let resp = ureq::post(&url)
272        .header("Content-Type", "application/json")
273        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
274        .map_err(|e| format!("Request failed: {e}"))?;
275
276    let resp_body = resp
277        .into_body()
278        .read_to_string()
279        .map_err(|e| format!("Failed to read response: {e}"))?;
280
281    let json: serde_json::Value =
282        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
283
284    Ok(RegisterResult {
285        api_key: json["api_key"]
286            .as_str()
287            .ok_or("Missing api_key in response")?
288            .to_string(),
289        user_id: json["user_id"]
290            .as_str()
291            .ok_or("Missing user_id in response")?
292            .to_string(),
293        email_verified: json["email_verified"].as_bool().unwrap_or(false),
294        verification_sent: json["verification_sent"].as_bool().unwrap_or(false),
295    })
296}
297
298pub fn forgot_password(email: &str) -> Result<String, String> {
299    let url = format!("{}/api/auth/forgot-password", api_url());
300    let body = serde_json::json!({ "email": email });
301
302    let resp = ureq::post(&url)
303        .header("Content-Type", "application/json")
304        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
305        .map_err(|e| format!("Request failed: {e}"))?;
306
307    let resp_body = resp
308        .into_body()
309        .read_to_string()
310        .map_err(|e| format!("Failed to read response: {e}"))?;
311
312    let json: serde_json::Value =
313        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
314
315    Ok(json["message"]
316        .as_str()
317        .unwrap_or("If an account exists, a reset email has been sent.")
318        .to_string())
319}
320
321pub fn login(email: &str, password: &str) -> Result<RegisterResult, String> {
322    let url = format!("{}/api/auth/login", api_url());
323    let body = serde_json::json!({ "email": email, "password": password });
324
325    let resp = ureq::post(&url)
326        .header("Content-Type", "application/json")
327        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
328        .map_err(|e| {
329            let msg = e.to_string();
330            if msg.contains("401") {
331                "Invalid email or password".to_string()
332            } else {
333                format!("Request failed: {e}")
334            }
335        })?;
336
337    let resp_body = resp
338        .into_body()
339        .read_to_string()
340        .map_err(|e| format!("Failed to read response: {e}"))?;
341
342    let json: serde_json::Value =
343        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
344
345    Ok(RegisterResult {
346        api_key: json["api_key"]
347            .as_str()
348            .ok_or("Missing api_key in response")?
349            .to_string(),
350        user_id: json["user_id"]
351            .as_str()
352            .ok_or("Missing user_id in response")?
353            .to_string(),
354        email_verified: json["email_verified"].as_bool().unwrap_or(false),
355        verification_sent: false,
356    })
357}
358
359pub fn sync_stats(stats: &[serde_json::Value]) -> Result<String, String> {
360    let bearer = auth_bearer_token()?;
361    let url = format!("{}/api/stats", api_url());
362
363    let body = serde_json::json!({ "stats": stats });
364
365    let resp = ureq::post(&url)
366        .header("Authorization", &format!("Bearer {bearer}"))
367        .header("Content-Type", "application/json")
368        .header("X-Device-Label", &device_label())
369        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
370        .map_err(|e| format!("Sync failed: {e}"))?;
371
372    let resp_body = resp
373        .into_body()
374        .read_to_string()
375        .map_err(|e| format!("Failed to read response: {e}"))?;
376
377    let json: serde_json::Value =
378        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
379
380    Ok(json["message"].as_str().unwrap_or("Synced").to_string())
381}
382
383pub fn contribute(entries: &[serde_json::Value]) -> Result<String, String> {
384    let url = format!("{}/api/contribute", api_url());
385
386    let body = serde_json::json!({ "entries": entries });
387
388    let resp = ureq::post(&url)
389        .header("Content-Type", "application/json")
390        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
391        .map_err(|e| format!("Contribute failed: {e}"))?;
392
393    let resp_body = resp
394        .into_body()
395        .read_to_string()
396        .map_err(|e| format!("Failed to read response: {e}"))?;
397
398    let json: serde_json::Value =
399        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
400
401    Ok(json["message"]
402        .as_str()
403        .unwrap_or("Contributed")
404        .to_string())
405}
406
407/// Result of a successful Wrapped publish (`POST /api/wrapped`). The `edit_token` is returned
408/// (and must be stored to delete/claim later) only on a *fresh* insert; on a signed re-publish
409/// the server updates the existing card in place and omits it (the client keeps the stored one).
410#[derive(serde::Deserialize)]
411pub struct PublishedCard {
412    pub id: String,
413    #[serde(default)]
414    pub edit_token: Option<String>,
415    pub url: String,
416}
417
418/// Publish a whitelisted Wrapped payload. Accepts either a bare payload (legacy anonymous) or a
419/// signed envelope `{payload_json, public_key, signature}` (login-less identity → server upsert).
420/// No account auth; the server rate-limits per IP. Contract: `docs/contracts/wrapped-permalink-v1.md`.
421pub fn publish_wrapped(payload: &serde_json::Value) -> Result<PublishedCard, String> {
422    let url = format!("{}/api/wrapped", api_url());
423
424    let resp = ureq::post(&url)
425        .header("Content-Type", "application/json")
426        .send(&serde_json::to_vec(payload).map_err(|e| format!("JSON error: {e}"))?)
427        .map_err(|e| format!("Publish failed: {e}"))?;
428
429    let resp_body = resp
430        .into_body()
431        .read_to_string()
432        .map_err(|e| format!("Failed to read response: {e}"))?;
433
434    serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
435}
436
437/// Delete a previously published card using its one-time `edit_token` (sent as `X-Edit-Token`).
438pub fn unpublish_wrapped(id: &str, edit_token: &str) -> Result<(), String> {
439    let url = format!("{}/api/wrapped/{id}", api_url());
440
441    ureq::delete(&url)
442        .header("X-Edit-Token", edit_token)
443        .call()
444        .map_err(|e| format!("Unpublish failed: {e}"))?;
445    Ok(())
446}
447
448/// Push the knowledge store as a zero-knowledge vault (GL #467): entries are
449/// sealed client-side (XChaCha20-Poly1305, domain-separated HKDF key) — the
450/// backend stores ciphertext and can never read them. The first vault push
451/// also purges the account's legacy plaintext rows server-side.
452pub fn push_knowledge(entries: &[serde_json::Value]) -> Result<String, String> {
453    let bearer = auth_bearer_token()?;
454    let key = knowledge_vault_key()?;
455    let blob = crate::core::knowledge_vault::seal(entries, &key).map_err(|e| e.to_string())?;
456    let url = format!("{}/api/sync/knowledge", api_url());
457
458    let resp = ureq::post(&url)
459        .header("Authorization", &format!("Bearer {bearer}"))
460        .header("Content-Type", "application/octet-stream")
461        .header("X-Entry-Count", &entries.len().to_string())
462        .header("X-Device-Label", &device_label())
463        .send(blob.as_slice())
464        .map_err(|e| format!("Push failed: {e}"))?;
465
466    let resp_body = resp
467        .into_body()
468        .read_to_string()
469        .map_err(|e| format!("Failed to read response: {e}"))?;
470
471    let json: serde_json::Value =
472        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
473
474    Ok(format!(
475        "{} entries synced (end-to-end encrypted)",
476        json["entry_count"].as_i64().unwrap_or(entries.len() as i64)
477    ))
478}
479
480/// The account's knowledge-vault key — same stable-API-key derivation rule as
481/// [`index_bundle_key`], different HKDF domain (`knowledge-vault-v1`).
482fn knowledge_vault_key() -> Result<[u8; 32], String> {
483    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
484    if api_key.trim().is_empty() {
485        return Err("Not logged in. Run: lean-ctx login".into());
486    }
487    Ok(crate::core::knowledge_vault::derive_vault_key(&api_key))
488}
489
490pub fn pull_cloud_models() -> Result<serde_json::Value, String> {
491    let bearer = auth_bearer_token()?;
492    let url = format!("{}/api/cloud/models", api_url());
493
494    let resp = ureq::get(&url)
495        .header("Authorization", &format!("Bearer {bearer}"))
496        .call()
497        .map_err(|e| {
498            let msg = e.to_string();
499            if msg.contains("403") {
500                "This feature is not available for your account.".to_string()
501            } else {
502                format!("Connection failed. Check your internet connection. ({e})")
503            }
504        })?;
505
506    let resp_body = resp
507        .into_body()
508        .read_to_string()
509        .map_err(|e| format!("Failed to read response: {e}"))?;
510
511    serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
512}
513
514pub fn save_cloud_models(data: &serde_json::Value) -> std::io::Result<()> {
515    let dir = config_dir();
516    std::fs::create_dir_all(&dir)?;
517    let json = serde_json::to_string_pretty(data).map_err(std::io::Error::other)?;
518    std::fs::write(dir.join("cloud_models.json"), json)
519}
520
521pub fn load_cloud_models() -> Option<serde_json::Value> {
522    let path = config_dir().join("cloud_models.json");
523    let data = std::fs::read_to_string(path).ok()?;
524    serde_json::from_str(&data).ok()
525}
526
527pub fn is_cloud_user() -> bool {
528    let path = config_dir().join("plan.txt");
529    std::fs::read_to_string(path).is_ok_and(|p| matches!(p.trim(), "cloud" | "pro"))
530}
531
532/// Days a cached plan keeps granting its hosted entitlements while the billing
533/// backend is unreachable. Generous on purpose: a network blip or a weekend
534/// offline must never silently demote a paying user to Free.
535pub const PLAN_GRACE_DAYS: i64 = 14;
536
537fn plan_cache_path() -> PathBuf {
538    config_dir().join("plan.json")
539}
540
541/// The locally cached plan plus *when* it was last confirmed against the billing
542/// backend. The timestamp is what powers offline grace.
543#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
544pub struct PlanCache {
545    pub plan: String,
546    /// Unix seconds of the last successful backend confirmation.
547    pub verified_at: i64,
548}
549
550pub fn save_plan(plan: &str) -> std::io::Result<()> {
551    let dir = config_dir();
552    std::fs::create_dir_all(&dir)?;
553    // Legacy flat file kept for back-compat (`is_cloud_user` still reads it).
554    std::fs::write(dir.join("plan.txt"), plan)?;
555    // Structured cache carrying the verification time for offline grace.
556    let cache = PlanCache {
557        plan: plan.to_string(),
558        verified_at: now_unix(),
559    };
560    let json = serde_json::to_string_pretty(&cache).map_err(std::io::Error::other)?;
561    std::fs::write(plan_cache_path(), json)
562}
563
564/// The cached plan, if any. Prefers the structured `plan.json`; falls back to a
565/// legacy `plan.txt` (no timestamp → `verified_at = 0`, i.e. immediately past
566/// grace until the next successful refresh re-stamps it).
567pub fn cached_plan() -> Option<PlanCache> {
568    if let Ok(data) = std::fs::read_to_string(plan_cache_path()) {
569        if let Ok(cache) = serde_json::from_str::<PlanCache>(&data) {
570            return Some(cache);
571        }
572    }
573    let legacy = std::fs::read_to_string(config_dir().join("plan.txt")).ok()?;
574    Some(PlanCache {
575        plan: legacy.trim().to_string(),
576        verified_at: 0,
577    })
578}
579
580/// Where an effective plan came from — drives the wording in `billing status`
581/// and the dashboard badge.
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum PlanSource {
584    /// Just confirmed against the backend this run.
585    Live,
586    /// Served from the local cache and still within the grace window.
587    Cached,
588    /// Cached confirmation is past the grace window → demoted to Free.
589    Expired,
590    /// No cached plan at all (never logged in / never synced) → Free.
591    None,
592}
593
594/// A resolved plan plus its provenance. The plan here is only ever used for
595/// *display* and for gating **hosted** surfaces — it never gates a local
596/// capability (Local-Free Invariant; the local engine has no entitlement checks).
597#[derive(Debug, Clone)]
598pub struct EffectivePlan {
599    pub plan: crate::core::billing::Plan,
600    pub source: PlanSource,
601    pub verified_at: Option<i64>,
602    pub grace_days: i64,
603}
604
605/// Pure grace check (no clock/IO) so it is unit-testable: is a plan confirmed at
606/// `verified_at` still within `grace_days` of `now`? Returns the age in days too.
607#[must_use]
608pub fn plan_within_grace(verified_at: i64, now: i64, grace_days: i64) -> (bool, i64) {
609    let age_days = (now - verified_at).max(0) / 86_400;
610    (age_days <= grace_days, age_days)
611}
612
613/// Resolve the effective plan from the **local cache only** (no network), applying
614/// the offline-grace policy. Use this on hot paths (dashboard requests); use
615/// [`refresh_effective_plan`] when a live confirmation is acceptable.
616#[must_use]
617pub fn resolve_effective_plan_cached() -> EffectivePlan {
618    let grace_days = PLAN_GRACE_DAYS;
619    let Some(cache) = cached_plan() else {
620        return EffectivePlan {
621            plan: crate::core::billing::Plan::Free,
622            source: PlanSource::None,
623            verified_at: None,
624            grace_days,
625        };
626    };
627    let (fresh, _age) = plan_within_grace(cache.verified_at, now_unix(), grace_days);
628    if fresh {
629        EffectivePlan {
630            plan: crate::core::billing::Plan::parse(&cache.plan),
631            source: PlanSource::Cached,
632            verified_at: Some(cache.verified_at),
633            grace_days,
634        }
635    } else {
636        // Fail closed for *hosted* entitlements once grace lapses. Local features
637        // remain unaffected — they are never gated.
638        EffectivePlan {
639            plan: crate::core::billing::Plan::Free,
640            source: PlanSource::Expired,
641            verified_at: Some(cache.verified_at),
642            grace_days,
643        }
644    }
645}
646
647/// Best-effort *live* resolve: try the backend (refreshing the cache on success),
648/// otherwise fall back to the cached-with-grace plan. Suitable for explicit
649/// commands like `lean-ctx billing status` where a network round-trip is fine.
650#[must_use]
651pub fn refresh_effective_plan() -> EffectivePlan {
652    if is_logged_in() {
653        if let Ok(plan_str) = fetch_plan() {
654            let _ = save_plan(&plan_str);
655            return EffectivePlan {
656                plan: crate::core::billing::Plan::parse(&plan_str),
657                source: PlanSource::Live,
658                verified_at: Some(now_unix()),
659                grace_days: PLAN_GRACE_DAYS,
660            };
661        }
662    }
663    resolve_effective_plan_cached()
664}
665
666pub fn fetch_plan() -> Result<String, String> {
667    let bearer = auth_bearer_token()?;
668    let url = format!("{}/api/auth/me", api_url());
669
670    let resp = ureq::get(&url)
671        .header("Authorization", &format!("Bearer {bearer}"))
672        .call()
673        .map_err(|e| format!("Failed to check plan: {e}"))?;
674
675    let resp_body = resp
676        .into_body()
677        .read_to_string()
678        .map_err(|e| format!("Failed to read response: {e}"))?;
679
680    let json: serde_json::Value =
681        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))?;
682
683    Ok(json["plan"].as_str().unwrap_or("free").to_string())
684}
685
686/// Start a Stripe Checkout session for the logged-in account and return the
687/// hosted URL to open. `plan` is e.g. `"pro"` or `"team"`; `interval` is
688/// `"monthly"` or `"yearly"`. The open backend proxies this to the private
689/// billing plane (which returns `503` when billing is not configured).
690pub fn start_checkout(plan: &str, interval: &str) -> Result<String, String> {
691    let bearer = auth_bearer_token()?;
692    let url = format!("{}/api/account/checkout", api_url());
693    let body = serde_json::json!({ "plan": plan, "interval": interval });
694
695    let resp = ureq::post(&url)
696        .header("Authorization", &format!("Bearer {bearer}"))
697        .header("Content-Type", "application/json")
698        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
699        .map_err(|e| format!("Checkout request failed: {e}"))?;
700
701    let resp_body = resp
702        .into_body()
703        .read_to_string()
704        .map_err(|e| format!("Failed to read response: {e}"))?;
705
706    let json: serde_json::Value =
707        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))?;
708
709    json["url"]
710        .as_str()
711        .map(str::to_string)
712        .ok_or_else(|| "Billing did not return a checkout URL.".to_string())
713}
714
715pub fn push_commands(entries: &[serde_json::Value]) -> Result<String, String> {
716    let bearer = auth_bearer_token()?;
717    let url = format!("{}/api/sync/commands", api_url());
718    let body = serde_json::json!({ "commands": entries });
719    let resp = ureq::post(&url)
720        .header("Authorization", &format!("Bearer {bearer}"))
721        .header("Content-Type", "application/json")
722        .header("X-Device-Label", &device_label())
723        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
724        .map_err(|e| format!("Push failed: {e}"))?;
725    let resp_body = resp
726        .into_body()
727        .read_to_string()
728        .map_err(|e| format!("Failed to read response: {e}"))?;
729    let json: serde_json::Value =
730        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
731    Ok(format!(
732        "{} commands synced",
733        json["synced"].as_i64().unwrap_or(0)
734    ))
735}
736
737pub fn push_cep(entries: &[serde_json::Value]) -> Result<String, String> {
738    let bearer = auth_bearer_token()?;
739    let url = format!("{}/api/sync/cep", api_url());
740    let body = serde_json::json!({ "scores": entries });
741    let resp = ureq::post(&url)
742        .header("Authorization", &format!("Bearer {bearer}"))
743        .header("Content-Type", "application/json")
744        .header("X-Device-Label", &device_label())
745        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
746        .map_err(|e| format!("Push failed: {e}"))?;
747    let resp_body = resp
748        .into_body()
749        .read_to_string()
750        .map_err(|e| format!("Failed to read response: {e}"))?;
751    let json: serde_json::Value =
752        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
753    Ok(format!(
754        "{} sessions synced",
755        json["synced"].as_i64().unwrap_or(0)
756    ))
757}
758
759pub fn push_gain(entries: &[serde_json::Value]) -> Result<String, String> {
760    let bearer = auth_bearer_token()?;
761    let url = format!("{}/api/sync/gain", api_url());
762    let body = serde_json::json!({ "scores": entries });
763    let resp = ureq::post(&url)
764        .header("Authorization", &format!("Bearer {bearer}"))
765        .header("Content-Type", "application/json")
766        .header("X-Device-Label", &device_label())
767        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
768        .map_err(|e| format!("Push failed: {e}"))?;
769    let resp_body = resp
770        .into_body()
771        .read_to_string()
772        .map_err(|e| format!("Failed to read response: {e}"))?;
773    let json: serde_json::Value =
774        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
775    Ok(format!(
776        "{} gain scores synced",
777        json["synced"].as_i64().unwrap_or(0)
778    ))
779}
780
781/// Push gotchas as a zero-knowledge vault (GL #467 follow-up): sealed
782/// client-side under the `gotcha-vault-v1` HKDF domain — the backend stores
783/// ciphertext only and purges the account's legacy plaintext rows on the
784/// first vault push.
785pub fn push_gotchas(entries: &[serde_json::Value]) -> Result<String, String> {
786    let bearer = auth_bearer_token()?;
787    let key = gotcha_vault_key()?;
788    let blob = crate::core::knowledge_vault::seal(entries, &key).map_err(|e| e.to_string())?;
789    let url = format!("{}/api/sync/gotchas", api_url());
790
791    let resp = ureq::post(&url)
792        .header("Authorization", &format!("Bearer {bearer}"))
793        .header("Content-Type", "application/octet-stream")
794        .header("X-Entry-Count", &entries.len().to_string())
795        .header("X-Device-Label", &device_label())
796        .send(blob.as_slice())
797        .map_err(|e| format!("Push failed: {e}"))?;
798    let resp_body = resp
799        .into_body()
800        .read_to_string()
801        .map_err(|e| format!("Failed to read response: {e}"))?;
802    let json: serde_json::Value =
803        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
804    Ok(format!(
805        "{} gotchas synced (end-to-end encrypted)",
806        json["entry_count"].as_i64().unwrap_or(entries.len() as i64)
807    ))
808}
809
810/// The account's gotcha-vault key — own HKDF domain (`gotcha-vault-v1`),
811/// derivation rule identical to [`knowledge_vault_key`].
812fn gotcha_vault_key() -> Result<[u8; 32], String> {
813    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
814    if api_key.trim().is_empty() {
815        return Err("Not logged in. Run: lean-ctx login".into());
816    }
817    Ok(crate::core::knowledge_vault::derive_gotcha_vault_key(
818        &api_key,
819    ))
820}
821
822pub fn push_buddy(data: &serde_json::Value) -> Result<String, String> {
823    let bearer = auth_bearer_token()?;
824    let url = format!("{}/api/sync/buddy", api_url());
825    let resp = ureq::post(&url)
826        .header("Authorization", &format!("Bearer {bearer}"))
827        .header("Content-Type", "application/json")
828        .header("X-Device-Label", &device_label())
829        .send(&serde_json::to_vec(data).map_err(|e| format!("JSON error: {e}"))?)
830        .map_err(|e| format!("Push failed: {e}"))?;
831    let resp_body = resp
832        .into_body()
833        .read_to_string()
834        .map_err(|e| format!("Failed to read response: {e}"))?;
835    let _json: serde_json::Value =
836        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
837    Ok("Buddy synced".to_string())
838}
839
840pub fn push_feedback(entries: &[serde_json::Value]) -> Result<String, String> {
841    let bearer = auth_bearer_token()?;
842    let url = format!("{}/api/sync/feedback", api_url());
843    let resp = ureq::post(&url)
844        .header("Authorization", &format!("Bearer {bearer}"))
845        .header("Content-Type", "application/json")
846        .header("X-Device-Label", &device_label())
847        .send(&serde_json::to_vec(entries).map_err(|e| format!("JSON error: {e}"))?)
848        .map_err(|e| format!("Push failed: {e}"))?;
849    let resp_body = resp
850        .into_body()
851        .read_to_string()
852        .map_err(|e| format!("Failed to read response: {e}"))?;
853    let json: serde_json::Value =
854        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
855    Ok(format!(
856        "{} thresholds synced",
857        json["synced"].as_i64().unwrap_or(0)
858    ))
859}
860
861/// The signed-in account's email, for status displays.
862pub fn account_email() -> Option<String> {
863    load_credentials().map(|c| c.email)
864}
865
866/// `GET /api/account/cloud` — the Personal Cloud dashboard payload (entitlement
867/// gate, per-bucket sync footprint, buddy, usage totals). Powers
868/// `lean-ctx cloud status`, mirroring what leanctx.com/account/cloud shows.
869pub fn fetch_account_cloud() -> Result<serde_json::Value, String> {
870    let bearer = auth_bearer_token()?;
871    let url = format!("{}/api/account/cloud", api_url());
872
873    let resp = ureq::get(&url)
874        .header("Authorization", &format!("Bearer {bearer}"))
875        .call()
876        .map_err(|e| format!("Status fetch failed: {e}"))?;
877
878    let resp_body = resp
879        .into_body()
880        .read_to_string()
881        .map_err(|e| format!("Failed to read response: {e}"))?;
882
883    serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))
884}
885
886/// Pull the knowledge store: vault-first (encrypted blob, decrypted locally),
887/// with a legacy plaintext fallback for accounts that never pushed a vault.
888pub fn pull_knowledge() -> Result<Vec<serde_json::Value>, String> {
889    let bearer = auth_bearer_token()?;
890    let url = format!("{}/api/sync/knowledge", api_url());
891
892    // Vault path (GL #467).
893    match ureq::get(&url)
894        .header("Authorization", &format!("Bearer {bearer}"))
895        .header("Accept", "application/octet-stream")
896        .call()
897    {
898        Ok(resp) => {
899            let is_blob = resp
900                .headers()
901                .get("content-type")
902                .and_then(|v| v.to_str().ok())
903                .is_some_and(|v| v.starts_with("application/octet-stream"));
904            if is_blob {
905                let mut blob = Vec::new();
906                use std::io::Read;
907                resp.into_body()
908                    .into_reader()
909                    .read_to_end(&mut blob)
910                    .map_err(|e| format!("Failed to read vault: {e}"))?;
911                let key = knowledge_vault_key()?;
912                return crate::core::knowledge_vault::open(&blob, &key).map_err(|e| e.to_string());
913            }
914            // Pre-vault server ignored the Accept header and answered with
915            // the legacy JSON listing — parse it directly.
916            let body = resp
917                .into_body()
918                .read_to_string()
919                .map_err(|e| format!("Failed to read response: {e}"))?;
920            return serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"));
921        }
922        // No vault yet → fall through to the legacy listing.
923        Err(ureq::Error::StatusCode(404)) => {}
924        Err(e) => return Err(format!("Pull failed: {e}")),
925    }
926
927    let resp = ureq::get(&url)
928        .header("Authorization", &format!("Bearer {bearer}"))
929        .call()
930        .map_err(|e| format!("Pull failed: {e}"))?;
931
932    let resp_body = resp
933        .into_body()
934        .read_to_string()
935        .map_err(|e| format!("Failed to read response: {e}"))?;
936
937    let entries: Vec<serde_json::Value> =
938        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
939
940    Ok(entries)
941}
942
943// ── Hosted Personal Index (GL #392) ──────────────────────────────────────────
944// Contract: docs/contracts/hosted-personal-index-v1.md. Bundles are encrypted
945// client-side (core::index_bundle); the backend only ever sees ciphertext.
946
947/// The account's bundle encryption key, HKDF-derived from the stable API key
948/// (never from the rotating OAuth token — the key must be identical on every
949/// logged-in device).
950fn index_bundle_key() -> Result<[u8; 32], String> {
951    let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
952    if api_key.trim().is_empty() {
953        return Err("Not logged in. Run: lean-ctx login".into());
954    }
955    Ok(crate::core::index_bundle::derive_key(&api_key))
956}
957
958/// Pack, encrypt and upload the project's index bundle.
959/// Returns `(project_hash, encrypted_size_bytes)`.
960pub fn push_index_bundle(project_root: &std::path::Path) -> Result<(String, u64), String> {
961    let (container, manifest) =
962        crate::core::index_bundle::pack(project_root).map_err(|e| e.to_string())?;
963    let blob = crate::core::index_bundle::encrypt(&container, &index_bundle_key()?)
964        .map_err(|e| e.to_string())?;
965
966    let bearer = auth_bearer_token()?;
967    let url = format!("{}/api/sync/index/{}", api_url(), manifest.project_hash);
968    let resp = ureq::put(&url)
969        .header("Authorization", &format!("Bearer {bearer}"))
970        .header("Content-Type", "application/octet-stream")
971        .header("X-Device-Label", &device_label())
972        .send(blob.as_slice())
973        .map_err(|e| match e {
974            ureq::Error::StatusCode(402) => {
975                "Hosted index requires lean-ctx Pro. Run: lean-ctx upgrade".to_string()
976            }
977            ureq::Error::StatusCode(413) => {
978                "Quota exceeded — the push was blocked (nothing is billed). \
979                 Free space with `lean-ctx sync index status` / delete, then retry."
980                    .to_string()
981            }
982            other => format!("Push failed: {other}"),
983        })?;
984
985    let body = resp
986        .into_body()
987        .read_to_string()
988        .map_err(|e| format!("Failed to read response: {e}"))?;
989    let _ack: serde_json::Value =
990        serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"))?;
991    Ok((manifest.project_hash, blob.len() as u64))
992}
993
994/// Download, decrypt and unpack the hosted bundle for this project.
995/// Returns the bundle manifest on success.
996pub fn pull_index_bundle(
997    project_root: &std::path::Path,
998) -> Result<crate::core::index_bundle::BundleManifest, String> {
999    let project_hash = crate::core::index_namespace::namespace_hash(project_root);
1000    let bearer = auth_bearer_token()?;
1001    let url = format!("{}/api/sync/index/{project_hash}", api_url());
1002
1003    let resp = ureq::get(&url)
1004        .header("Authorization", &format!("Bearer {bearer}"))
1005        .call()
1006        .map_err(|e| match e {
1007            ureq::Error::StatusCode(404) => format!(
1008                "No hosted index for this project yet ({project_hash}). \
1009                 Push one from a device with a built index: lean-ctx sync index push"
1010            ),
1011            ureq::Error::StatusCode(402) => {
1012                "Hosted index requires lean-ctx Pro. Run: lean-ctx upgrade".to_string()
1013            }
1014            other => format!("Pull failed: {other}"),
1015        })?;
1016
1017    let mut blob = Vec::new();
1018    use std::io::Read;
1019    resp.into_body()
1020        .into_reader()
1021        .read_to_end(&mut blob)
1022        .map_err(|e| format!("Failed to read bundle: {e}"))?;
1023
1024    let container = crate::core::index_bundle::decrypt(&blob, &index_bundle_key()?)
1025        .map_err(|e| e.to_string())?;
1026    crate::core::index_bundle::unpack(project_root, &container).map_err(|e| e.to_string())
1027}
1028
1029/// `GET /api/sync/index` — hosted-bucket listing + quota usage for the account.
1030pub fn index_bundle_status() -> Result<serde_json::Value, String> {
1031    let bearer = auth_bearer_token()?;
1032    let url = format!("{}/api/sync/index", api_url());
1033    let resp = ureq::get(&url)
1034        .header("Authorization", &format!("Bearer {bearer}"))
1035        .call()
1036        .map_err(|e| format!("Status fetch failed: {e}"))?;
1037    let body = resp
1038        .into_body()
1039        .read_to_string()
1040        .map_err(|e| format!("Failed to read response: {e}"))?;
1041    serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"))
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047    use crate::core::billing::Plan;
1048    use crate::core::data_dir::test_env_lock;
1049
1050    #[test]
1051    fn grace_window_boundaries_are_inclusive_and_skew_safe() {
1052        let now = 1_000_000_000;
1053        let day = 86_400;
1054        assert_eq!(plan_within_grace(now, now, 14), (true, 0));
1055        // Exactly at the edge stays valid (inclusive).
1056        assert_eq!(plan_within_grace(now - 14 * day, now, 14), (true, 14));
1057        // One day past → expired.
1058        assert_eq!(plan_within_grace(now - 15 * day, now, 14), (false, 15));
1059        // Clock skew (future timestamp) is clamped to age 0, never negative.
1060        assert_eq!(plan_within_grace(now + day, now, 14), (true, 0));
1061    }
1062
1063    #[test]
1064    fn plan_cache_roundtrips_through_json() {
1065        let c = PlanCache {
1066            plan: "pro".into(),
1067            verified_at: 42,
1068        };
1069        let back: PlanCache = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
1070        assert_eq!(back.plan, "pro");
1071        assert_eq!(back.verified_at, 42);
1072    }
1073
1074    #[test]
1075    fn cached_resolve_grants_within_grace_then_expires_to_free() {
1076        let _env = test_env_lock();
1077        let tmp = tempfile::tempdir().unwrap();
1078        std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1079
1080        // A fresh save is served from cache, within grace, at full plan.
1081        save_plan("pro").unwrap();
1082        let eff = resolve_effective_plan_cached();
1083        assert_eq!(eff.plan, Plan::Pro);
1084        assert_eq!(eff.source, PlanSource::Cached);
1085
1086        // Backdate beyond grace → hosted entitlements fail closed to Free.
1087        let stale = PlanCache {
1088            plan: "pro".into(),
1089            verified_at: now_unix() - (PLAN_GRACE_DAYS + 1) * 86_400,
1090        };
1091        std::fs::write(plan_cache_path(), serde_json::to_string(&stale).unwrap()).unwrap();
1092        let eff = resolve_effective_plan_cached();
1093        assert_eq!(eff.plan, Plan::Free);
1094        assert_eq!(eff.source, PlanSource::Expired);
1095
1096        std::env::remove_var("LEAN_CTX_DATA_DIR");
1097    }
1098
1099    #[test]
1100    fn no_cache_resolves_to_free_none() {
1101        let _env = test_env_lock();
1102        let tmp = tempfile::tempdir().unwrap();
1103        std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1104        let eff = resolve_effective_plan_cached();
1105        assert_eq!(eff.plan, Plan::Free);
1106        assert_eq!(eff.source, PlanSource::None);
1107        std::env::remove_var("LEAN_CTX_DATA_DIR");
1108    }
1109
1110    // P0-2 (#414): credentials must be owner-only on disk.
1111    #[cfg(unix)]
1112    #[test]
1113    fn credentials_are_written_owner_only_and_atomic() {
1114        use std::os::unix::fs::PermissionsExt;
1115        let _env = test_env_lock();
1116        let tmp = tempfile::tempdir().unwrap();
1117        std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1118
1119        save_credentials("sk-test-key", "user-1", "a@b.c").unwrap();
1120
1121        let path = credentials_path();
1122        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1123        assert_eq!(mode & 0o777, 0o600, "credentials.json must be 0o600");
1124
1125        let dir_mode = std::fs::metadata(config_dir())
1126            .unwrap()
1127            .permissions()
1128            .mode();
1129        assert_eq!(
1130            dir_mode & 0o077,
1131            0,
1132            "cloud dir must not be group/world accessible"
1133        );
1134
1135        // No tmp file leftovers from the atomic write.
1136        let leftovers: Vec<_> = std::fs::read_dir(config_dir())
1137            .unwrap()
1138            .filter_map(Result::ok)
1139            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
1140            .collect();
1141        assert!(leftovers.is_empty(), "atomic write must not leak tmp files");
1142
1143        std::env::remove_var("LEAN_CTX_DATA_DIR");
1144    }
1145
1146    // P0-2 (#414): pre-existing world-readable credentials are tightened on load.
1147    #[cfg(unix)]
1148    #[test]
1149    fn loose_credential_permissions_are_tightened_on_load() {
1150        use std::os::unix::fs::PermissionsExt;
1151        let _env = test_env_lock();
1152        let tmp = tempfile::tempdir().unwrap();
1153        std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1154
1155        std::fs::create_dir_all(config_dir()).unwrap();
1156        let path = credentials_path();
1157        std::fs::write(&path, "{}").unwrap();
1158        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1159
1160        let _ = load_credentials();
1161
1162        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1163        assert_eq!(
1164            mode & 0o777,
1165            0o600,
1166            "legacy file must be tightened to 0o600"
1167        );
1168
1169        std::env::remove_var("LEAN_CTX_DATA_DIR");
1170    }
1171
1172    #[test]
1173    fn legacy_plan_txt_is_migrated_but_treated_as_stale() {
1174        let _env = test_env_lock();
1175        let tmp = tempfile::tempdir().unwrap();
1176        std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
1177        // Only the legacy flat file exists (no timestamp) → past grace until refresh.
1178        std::fs::create_dir_all(config_dir()).unwrap();
1179        std::fs::write(config_dir().join("plan.txt"), "team").unwrap();
1180        let cache = cached_plan().unwrap();
1181        assert_eq!(cache.plan, "team");
1182        assert_eq!(cache.verified_at, 0);
1183        assert_eq!(resolve_effective_plan_cached().source, PlanSource::Expired);
1184        std::env::remove_var("LEAN_CTX_DATA_DIR");
1185    }
1186}