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 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
51fn 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 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
151fn 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#[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
418pub 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
437pub 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
448pub 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
480fn 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
532pub const PLAN_GRACE_DAYS: i64 = 14;
536
537fn plan_cache_path() -> PathBuf {
538 config_dir().join("plan.json")
539}
540
541#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
544pub struct PlanCache {
545 pub plan: String,
546 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 std::fs::write(dir.join("plan.txt"), plan)?;
555 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
564pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum PlanSource {
584 Live,
586 Cached,
588 Expired,
590 None,
592}
593
594#[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#[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#[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 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#[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
686pub 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
781pub 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
810fn 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
861pub fn account_email() -> Option<String> {
863 load_credentials().map(|c| c.email)
864}
865
866pub 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
886pub 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 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 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 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
943fn 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
958pub 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
994pub 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
1029pub 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 assert_eq!(plan_within_grace(now - 14 * day, now, 14), (true, 14));
1057 assert_eq!(plan_within_grace(now - 15 * day, now, 14), (false, 15));
1059 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 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 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 #[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 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 #[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 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}