1use std::path::PathBuf;
2
3fn config_dir() -> PathBuf {
4 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 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 && 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 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 ) && 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
406pub 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#[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
444pub 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
472pub 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
502pub 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
513pub 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#[derive(serde::Deserialize)]
530pub struct LinkCode {
531 pub code: String,
532 pub expires_in_secs: i64,
533}
534
535pub 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
551pub 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
571pub 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
603fn 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
650pub 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
677pub const PLAN_GRACE_DAYS: i64 = 14;
681
682fn plan_cache_path() -> PathBuf {
683 config_dir().join("plan.json")
684}
685
686#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
689pub struct PlanCache {
690 pub plan: String,
691 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 std::fs::write(dir.join("plan.txt"), plan)?;
700 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
709pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
728pub enum PlanSource {
729 Live,
731 Cached,
733 Expired,
735 None,
737}
738
739#[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#[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#[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 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#[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
836pub 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
931pub 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
960fn 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
1011pub fn account_email() -> Option<String> {
1013 load_credentials().map(|c| c.email)
1014}
1015
1016pub 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
1036pub 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 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 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 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
1093fn 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
1108pub 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
1144pub 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
1179pub 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 #[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 assert_eq!(plan_within_grace(now - 14 * day, now, 14), (true, 14));
1226 assert_eq!(plan_within_grace(now - 15 * day, now, 14), (false, 15));
1228 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 let _iso = crate::core::data_dir::isolated_data_dir();
1248
1249 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 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 #[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 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 #[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 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}