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 data = std::fs::read_to_string(credentials_path()).ok()?;
36    serde_json::from_str(&data).ok()
37}
38
39fn write_credentials(creds: &Credentials) -> std::io::Result<()> {
40    let dir = config_dir();
41    std::fs::create_dir_all(&dir)?;
42    let json = serde_json::to_string_pretty(creds).map_err(std::io::Error::other)?;
43    std::fs::write(credentials_path(), json)
44}
45
46pub fn save_credentials(api_key: &str, user_id: &str, email: &str) -> std::io::Result<()> {
47    let mut creds = load_credentials().unwrap_or(Credentials {
48        api_key: api_key.to_string(),
49        user_id: user_id.to_string(),
50        email: email.to_string(),
51        oauth_client_id: None,
52        oauth_client_secret: None,
53        oauth_access_token: None,
54        oauth_expires_at_unix: None,
55    });
56    creds.api_key = api_key.to_string();
57    creds.user_id = user_id.to_string();
58    creds.email = email.to_string();
59    // Access tokens are bound to a client and should be re-fetched after login changes.
60    creds.oauth_access_token = None;
61    creds.oauth_expires_at_unix = None;
62    write_credentials(&creds)
63}
64
65pub fn load_api_key() -> Option<String> {
66    load_credentials().map(|c| c.api_key)
67}
68
69pub fn is_logged_in() -> bool {
70    load_credentials().is_some()
71}
72
73fn now_unix() -> i64 {
74    use std::time::{SystemTime, UNIX_EPOCH};
75    SystemTime::now()
76        .duration_since(UNIX_EPOCH)
77        .unwrap_or_default()
78        .as_secs() as i64
79}
80
81fn auth_bearer_token() -> Result<String, String> {
82    let mut creds = load_credentials().ok_or("Not logged in. Run: lean-ctx login")?;
83
84    if let (Some(client_id), Some(client_secret)) = (
85        creds.oauth_client_id.clone(),
86        creds.oauth_client_secret.clone(),
87    ) {
88        let now = now_unix();
89        if let (Some(token), Some(exp)) = (
90            creds.oauth_access_token.clone(),
91            creds.oauth_expires_at_unix,
92        ) {
93            if exp > now + 10 {
94                return Ok(token);
95            }
96        }
97
98        let url = format!("{}/oauth/token", api_url());
99        let resp = ureq::post(&url)
100            .header("Content-Type", "application/x-www-form-urlencoded")
101            .send_form([
102                ("grant_type", "client_credentials"),
103                ("client_id", client_id.as_str()),
104                ("client_secret", client_secret.as_str()),
105            ])
106            .map_err(|e| format!("OAuth token request failed: {e}"))?;
107
108        let resp_body = resp
109            .into_body()
110            .read_to_string()
111            .map_err(|e| format!("Failed to read OAuth response: {e}"))?;
112
113        let json: serde_json::Value =
114            serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
115
116        let token = json["access_token"]
117            .as_str()
118            .ok_or("Missing access_token in response")?
119            .to_string();
120        let expires_in = json["expires_in"].as_i64().unwrap_or(3600);
121        let exp = now + expires_in.saturating_sub(30);
122
123        creds.oauth_access_token = Some(token.clone());
124        creds.oauth_expires_at_unix = Some(exp);
125        let _ = write_credentials(&creds);
126
127        return Ok(token);
128    }
129
130    Ok(creds.api_key)
131}
132
133pub fn oauth_register_client(client_name: Option<&str>) -> Result<String, String> {
134    let mut creds = load_credentials().ok_or("Not logged in. Run: lean-ctx login")?;
135    if creds.oauth_client_id.is_some() && creds.oauth_client_secret.is_some() {
136        return Ok("OAuth client already registered.".to_string());
137    }
138
139    let url = format!("{}/oauth/register", api_url());
140    let body = if let Some(name) = client_name {
141        serde_json::json!({ "client_name": name })
142    } else {
143        serde_json::json!({})
144    };
145
146    let resp = ureq::post(&url)
147        .header("Authorization", &format!("Bearer {}", creds.api_key))
148        .header("Content-Type", "application/json")
149        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
150        .map_err(|e| format!("OAuth register failed: {e}"))?;
151
152    let resp_body = resp
153        .into_body()
154        .read_to_string()
155        .map_err(|e| format!("Failed to read response: {e}"))?;
156
157    let json: serde_json::Value =
158        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
159
160    creds.oauth_client_id = Some(
161        json["client_id"]
162            .as_str()
163            .ok_or("Missing client_id in response")?
164            .to_string(),
165    );
166    creds.oauth_client_secret = Some(
167        json["client_secret"]
168            .as_str()
169            .ok_or("Missing client_secret in response")?
170            .to_string(),
171    );
172    creds.oauth_access_token = None;
173    creds.oauth_expires_at_unix = None;
174    write_credentials(&creds).map_err(|e| format!("Failed to persist OAuth credentials: {e}"))?;
175
176    Ok("OAuth client registered. Cloud requests will use short-lived access tokens.".to_string())
177}
178
179pub struct RegisterResult {
180    pub api_key: String,
181    pub user_id: String,
182    pub email_verified: bool,
183    pub verification_sent: bool,
184}
185
186pub fn register(email: &str, password: Option<&str>) -> Result<RegisterResult, String> {
187    let url = format!("{}/api/auth/register", api_url());
188    let mut body = serde_json::json!({ "email": email });
189    if let Some(pw) = password {
190        body["password"] = serde_json::Value::String(pw.to_string());
191    }
192
193    let resp = ureq::post(&url)
194        .header("Content-Type", "application/json")
195        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
196        .map_err(|e| format!("Request failed: {e}"))?;
197
198    let resp_body = resp
199        .into_body()
200        .read_to_string()
201        .map_err(|e| format!("Failed to read response: {e}"))?;
202
203    let json: serde_json::Value =
204        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
205
206    Ok(RegisterResult {
207        api_key: json["api_key"]
208            .as_str()
209            .ok_or("Missing api_key in response")?
210            .to_string(),
211        user_id: json["user_id"]
212            .as_str()
213            .ok_or("Missing user_id in response")?
214            .to_string(),
215        email_verified: json["email_verified"].as_bool().unwrap_or(false),
216        verification_sent: json["verification_sent"].as_bool().unwrap_or(false),
217    })
218}
219
220pub fn forgot_password(email: &str) -> Result<String, String> {
221    let url = format!("{}/api/auth/forgot-password", api_url());
222    let body = serde_json::json!({ "email": email });
223
224    let resp = ureq::post(&url)
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!("Request 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    Ok(json["message"]
238        .as_str()
239        .unwrap_or("If an account exists, a reset email has been sent.")
240        .to_string())
241}
242
243pub fn login(email: &str, password: &str) -> Result<RegisterResult, String> {
244    let url = format!("{}/api/auth/login", api_url());
245    let body = serde_json::json!({ "email": email, "password": password });
246
247    let resp = ureq::post(&url)
248        .header("Content-Type", "application/json")
249        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
250        .map_err(|e| {
251            let msg = e.to_string();
252            if msg.contains("401") {
253                "Invalid email or password".to_string()
254            } else {
255                format!("Request failed: {e}")
256            }
257        })?;
258
259    let resp_body = resp
260        .into_body()
261        .read_to_string()
262        .map_err(|e| format!("Failed to read response: {e}"))?;
263
264    let json: serde_json::Value =
265        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
266
267    Ok(RegisterResult {
268        api_key: json["api_key"]
269            .as_str()
270            .ok_or("Missing api_key in response")?
271            .to_string(),
272        user_id: json["user_id"]
273            .as_str()
274            .ok_or("Missing user_id in response")?
275            .to_string(),
276        email_verified: json["email_verified"].as_bool().unwrap_or(false),
277        verification_sent: false,
278    })
279}
280
281pub fn sync_stats(stats: &[serde_json::Value]) -> Result<String, String> {
282    let bearer = auth_bearer_token()?;
283    let url = format!("{}/api/stats", api_url());
284
285    let body = serde_json::json!({ "stats": stats });
286
287    let resp = ureq::post(&url)
288        .header("Authorization", &format!("Bearer {bearer}"))
289        .header("Content-Type", "application/json")
290        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
291        .map_err(|e| format!("Sync failed: {e}"))?;
292
293    let resp_body = resp
294        .into_body()
295        .read_to_string()
296        .map_err(|e| format!("Failed to read response: {e}"))?;
297
298    let json: serde_json::Value =
299        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
300
301    Ok(json["message"].as_str().unwrap_or("Synced").to_string())
302}
303
304pub fn contribute(entries: &[serde_json::Value]) -> Result<String, String> {
305    let url = format!("{}/api/contribute", api_url());
306
307    let body = serde_json::json!({ "entries": entries });
308
309    let resp = ureq::post(&url)
310        .header("Content-Type", "application/json")
311        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
312        .map_err(|e| format!("Contribute failed: {e}"))?;
313
314    let resp_body = resp
315        .into_body()
316        .read_to_string()
317        .map_err(|e| format!("Failed to read response: {e}"))?;
318
319    let json: serde_json::Value =
320        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
321
322    Ok(json["message"]
323        .as_str()
324        .unwrap_or("Contributed")
325        .to_string())
326}
327
328/// Result of a successful Wrapped publish (`POST /api/wrapped`). The `edit_token` is returned
329/// (and must be stored to delete/claim later) only on a *fresh* insert; on a signed re-publish
330/// the server updates the existing card in place and omits it (the client keeps the stored one).
331#[derive(serde::Deserialize)]
332pub struct PublishedCard {
333    pub id: String,
334    #[serde(default)]
335    pub edit_token: Option<String>,
336    pub url: String,
337}
338
339/// Publish a whitelisted Wrapped payload. Accepts either a bare payload (legacy anonymous) or a
340/// signed envelope `{payload_json, public_key, signature}` (login-less identity → server upsert).
341/// No account auth; the server rate-limits per IP. Contract: `docs/contracts/wrapped-permalink-v1.md`.
342pub fn publish_wrapped(payload: &serde_json::Value) -> Result<PublishedCard, String> {
343    let url = format!("{}/api/wrapped", api_url());
344
345    let resp = ureq::post(&url)
346        .header("Content-Type", "application/json")
347        .send(&serde_json::to_vec(payload).map_err(|e| format!("JSON error: {e}"))?)
348        .map_err(|e| format!("Publish failed: {e}"))?;
349
350    let resp_body = resp
351        .into_body()
352        .read_to_string()
353        .map_err(|e| format!("Failed to read response: {e}"))?;
354
355    serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
356}
357
358/// Delete a previously published card using its one-time `edit_token` (sent as `X-Edit-Token`).
359pub fn unpublish_wrapped(id: &str, edit_token: &str) -> Result<(), String> {
360    let url = format!("{}/api/wrapped/{id}", api_url());
361
362    ureq::delete(&url)
363        .header("X-Edit-Token", edit_token)
364        .call()
365        .map_err(|e| format!("Unpublish failed: {e}"))?;
366    Ok(())
367}
368
369pub fn push_knowledge(entries: &[serde_json::Value]) -> Result<String, String> {
370    let bearer = auth_bearer_token()?;
371    let url = format!("{}/api/sync/knowledge", api_url());
372
373    let body = serde_json::json!({ "entries": entries });
374
375    let resp = ureq::post(&url)
376        .header("Authorization", &format!("Bearer {bearer}"))
377        .header("Content-Type", "application/json")
378        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
379        .map_err(|e| format!("Push failed: {e}"))?;
380
381    let resp_body = resp
382        .into_body()
383        .read_to_string()
384        .map_err(|e| format!("Failed to read response: {e}"))?;
385
386    let json: serde_json::Value =
387        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
388
389    Ok(format!(
390        "{} entries synced",
391        json["synced"].as_i64().unwrap_or(0)
392    ))
393}
394
395pub fn pull_cloud_models() -> Result<serde_json::Value, String> {
396    let bearer = auth_bearer_token()?;
397    let url = format!("{}/api/cloud/models", api_url());
398
399    let resp = ureq::get(&url)
400        .header("Authorization", &format!("Bearer {bearer}"))
401        .call()
402        .map_err(|e| {
403            let msg = e.to_string();
404            if msg.contains("403") {
405                "This feature is not available for your account.".to_string()
406            } else {
407                format!("Connection failed. Check your internet connection. ({e})")
408            }
409        })?;
410
411    let resp_body = resp
412        .into_body()
413        .read_to_string()
414        .map_err(|e| format!("Failed to read response: {e}"))?;
415
416    serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
417}
418
419pub fn save_cloud_models(data: &serde_json::Value) -> std::io::Result<()> {
420    let dir = config_dir();
421    std::fs::create_dir_all(&dir)?;
422    let json = serde_json::to_string_pretty(data).map_err(std::io::Error::other)?;
423    std::fs::write(dir.join("cloud_models.json"), json)
424}
425
426pub fn load_cloud_models() -> Option<serde_json::Value> {
427    let path = config_dir().join("cloud_models.json");
428    let data = std::fs::read_to_string(path).ok()?;
429    serde_json::from_str(&data).ok()
430}
431
432pub fn is_cloud_user() -> bool {
433    let path = config_dir().join("plan.txt");
434    std::fs::read_to_string(path).is_ok_and(|p| matches!(p.trim(), "cloud" | "pro"))
435}
436
437pub fn save_plan(plan: &str) -> std::io::Result<()> {
438    let dir = config_dir();
439    std::fs::create_dir_all(&dir)?;
440    std::fs::write(dir.join("plan.txt"), plan)
441}
442
443pub fn fetch_plan() -> Result<String, String> {
444    let bearer = auth_bearer_token()?;
445    let url = format!("{}/api/auth/me", api_url());
446
447    let resp = ureq::get(&url)
448        .header("Authorization", &format!("Bearer {bearer}"))
449        .call()
450        .map_err(|e| format!("Failed to check plan: {e}"))?;
451
452    let resp_body = resp
453        .into_body()
454        .read_to_string()
455        .map_err(|e| format!("Failed to read response: {e}"))?;
456
457    let json: serde_json::Value =
458        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))?;
459
460    Ok(json["plan"].as_str().unwrap_or("free").to_string())
461}
462
463pub fn push_commands(entries: &[serde_json::Value]) -> Result<String, String> {
464    let bearer = auth_bearer_token()?;
465    let url = format!("{}/api/sync/commands", api_url());
466    let body = serde_json::json!({ "commands": entries });
467    let resp = ureq::post(&url)
468        .header("Authorization", &format!("Bearer {bearer}"))
469        .header("Content-Type", "application/json")
470        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
471        .map_err(|e| format!("Push failed: {e}"))?;
472    let resp_body = resp
473        .into_body()
474        .read_to_string()
475        .map_err(|e| format!("Failed to read response: {e}"))?;
476    let json: serde_json::Value =
477        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
478    Ok(format!(
479        "{} commands synced",
480        json["synced"].as_i64().unwrap_or(0)
481    ))
482}
483
484pub fn push_cep(entries: &[serde_json::Value]) -> Result<String, String> {
485    let bearer = auth_bearer_token()?;
486    let url = format!("{}/api/sync/cep", api_url());
487    let body = serde_json::json!({ "scores": entries });
488    let resp = ureq::post(&url)
489        .header("Authorization", &format!("Bearer {bearer}"))
490        .header("Content-Type", "application/json")
491        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
492        .map_err(|e| format!("Push failed: {e}"))?;
493    let resp_body = resp
494        .into_body()
495        .read_to_string()
496        .map_err(|e| format!("Failed to read response: {e}"))?;
497    let json: serde_json::Value =
498        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
499    Ok(format!(
500        "{} sessions synced",
501        json["synced"].as_i64().unwrap_or(0)
502    ))
503}
504
505pub fn push_gain(entries: &[serde_json::Value]) -> Result<String, String> {
506    let bearer = auth_bearer_token()?;
507    let url = format!("{}/api/sync/gain", api_url());
508    let body = serde_json::json!({ "scores": entries });
509    let resp = ureq::post(&url)
510        .header("Authorization", &format!("Bearer {bearer}"))
511        .header("Content-Type", "application/json")
512        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
513        .map_err(|e| format!("Push failed: {e}"))?;
514    let resp_body = resp
515        .into_body()
516        .read_to_string()
517        .map_err(|e| format!("Failed to read response: {e}"))?;
518    let json: serde_json::Value =
519        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
520    Ok(format!(
521        "{} gain scores synced",
522        json["synced"].as_i64().unwrap_or(0)
523    ))
524}
525
526pub fn push_gotchas(entries: &[serde_json::Value]) -> Result<String, String> {
527    let bearer = auth_bearer_token()?;
528    let url = format!("{}/api/sync/gotchas", api_url());
529    let body = serde_json::json!({ "gotchas": entries });
530    let resp = ureq::post(&url)
531        .header("Authorization", &format!("Bearer {bearer}"))
532        .header("Content-Type", "application/json")
533        .send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
534        .map_err(|e| format!("Push failed: {e}"))?;
535    let resp_body = resp
536        .into_body()
537        .read_to_string()
538        .map_err(|e| format!("Failed to read response: {e}"))?;
539    let json: serde_json::Value =
540        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
541    Ok(format!(
542        "{} gotchas synced",
543        json["synced"].as_i64().unwrap_or(0)
544    ))
545}
546
547pub fn push_buddy(data: &serde_json::Value) -> Result<String, String> {
548    let bearer = auth_bearer_token()?;
549    let url = format!("{}/api/sync/buddy", api_url());
550    let resp = ureq::post(&url)
551        .header("Authorization", &format!("Bearer {bearer}"))
552        .header("Content-Type", "application/json")
553        .send(&serde_json::to_vec(data).map_err(|e| format!("JSON error: {e}"))?)
554        .map_err(|e| format!("Push failed: {e}"))?;
555    let resp_body = resp
556        .into_body()
557        .read_to_string()
558        .map_err(|e| format!("Failed to read response: {e}"))?;
559    let _json: serde_json::Value =
560        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
561    Ok("Buddy synced".to_string())
562}
563
564pub fn push_feedback(entries: &[serde_json::Value]) -> Result<String, String> {
565    let bearer = auth_bearer_token()?;
566    let url = format!("{}/api/sync/feedback", api_url());
567    let resp = ureq::post(&url)
568        .header("Authorization", &format!("Bearer {bearer}"))
569        .header("Content-Type", "application/json")
570        .send(&serde_json::to_vec(entries).map_err(|e| format!("JSON error: {e}"))?)
571        .map_err(|e| format!("Push failed: {e}"))?;
572    let resp_body = resp
573        .into_body()
574        .read_to_string()
575        .map_err(|e| format!("Failed to read response: {e}"))?;
576    let json: serde_json::Value =
577        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
578    Ok(format!(
579        "{} thresholds synced",
580        json["synced"].as_i64().unwrap_or(0)
581    ))
582}
583
584pub fn pull_knowledge() -> Result<Vec<serde_json::Value>, String> {
585    let bearer = auth_bearer_token()?;
586    let url = format!("{}/api/sync/knowledge", api_url());
587
588    let resp = ureq::get(&url)
589        .header("Authorization", &format!("Bearer {bearer}"))
590        .call()
591        .map_err(|e| format!("Pull failed: {e}"))?;
592
593    let resp_body = resp
594        .into_body()
595        .read_to_string()
596        .map_err(|e| format!("Failed to read response: {e}"))?;
597
598    let entries: Vec<serde_json::Value> =
599        serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
600
601    Ok(entries)
602}