use std::path::PathBuf;
fn config_dir() -> PathBuf {
crate::core::paths::data_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("cloud")
}
fn credentials_path() -> PathBuf {
config_dir().join("credentials.json")
}
pub fn api_url() -> String {
std::env::var("LEAN_CTX_API_URL").unwrap_or_else(|_| "https://api.leanctx.com".to_string())
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Credentials {
api_key: String,
user_id: String,
email: String,
#[serde(default)]
oauth_client_id: Option<String>,
#[serde(default)]
oauth_client_secret: Option<String>,
#[serde(default)]
oauth_access_token: Option<String>,
#[serde(default)]
oauth_expires_at_unix: Option<i64>,
}
fn load_credentials() -> Option<Credentials> {
let path = credentials_path();
tighten_secret_permissions(&path);
let data = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&data).ok()
}
fn write_credentials(creds: &Credentials) -> std::io::Result<()> {
let dir = config_dir();
std::fs::create_dir_all(&dir)?;
restrict_dir_permissions(&dir);
let json = serde_json::to_string_pretty(creds).map_err(std::io::Error::other)?;
write_secret_file(&credentials_path(), json.as_bytes())
}
fn write_secret_file(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let parent = path
.parent()
.ok_or_else(|| std::io::Error::other("credentials path has no parent directory"))?;
let name = path
.file_name()
.ok_or_else(|| std::io::Error::other("credentials path has no file name"))?
.to_string_lossy();
let tmp = parent.join(format!(".{name}.tmp.{}", std::process::id()));
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let result = (|| {
let mut f = opts.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
drop(f);
#[cfg(windows)]
{
if path.exists() {
std::fs::remove_file(path)?;
}
}
std::fs::rename(&tmp, path)
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
#[cfg(unix)]
fn restrict_dir_permissions(dir: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
fn restrict_dir_permissions(_dir: &std::path::Path) {}
#[cfg(unix)]
fn tighten_secret_permissions(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path)
&& meta.permissions().mode() & 0o077 != 0
{
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
}
#[cfg(not(unix))]
fn tighten_secret_permissions(_path: &std::path::Path) {}
pub fn save_credentials(api_key: &str, user_id: &str, email: &str) -> std::io::Result<()> {
let mut creds = load_credentials().unwrap_or(Credentials {
api_key: api_key.to_string(),
user_id: user_id.to_string(),
email: email.to_string(),
oauth_client_id: None,
oauth_client_secret: None,
oauth_access_token: None,
oauth_expires_at_unix: None,
});
creds.api_key = api_key.to_string();
creds.user_id = user_id.to_string();
creds.email = email.to_string();
creds.oauth_access_token = None;
creds.oauth_expires_at_unix = None;
write_credentials(&creds)
}
pub fn load_api_key() -> Option<String> {
load_credentials().map(|c| c.api_key)
}
pub fn is_logged_in() -> bool {
load_credentials().is_some()
}
fn now_unix() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn device_label() -> String {
gethostname::gethostname().to_string_lossy().into_owned()
}
fn auth_bearer_token() -> Result<String, String> {
let mut creds = load_credentials().ok_or("Not logged in. Run: lean-ctx login")?;
if let (Some(client_id), Some(client_secret)) = (
creds.oauth_client_id.clone(),
creds.oauth_client_secret.clone(),
) {
let now = now_unix();
if let (Some(token), Some(exp)) = (
creds.oauth_access_token.clone(),
creds.oauth_expires_at_unix,
) && exp > now + 10
{
return Ok(token);
}
let url = format!("{}/oauth/token", api_url());
let resp = ureq::post(&url)
.header("Content-Type", "application/x-www-form-urlencoded")
.send_form([
("grant_type", "client_credentials"),
("client_id", client_id.as_str()),
("client_secret", client_secret.as_str()),
])
.map_err(|e| format!("OAuth token request failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read OAuth response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
let token = json["access_token"]
.as_str()
.ok_or("Missing access_token in response")?
.to_string();
let expires_in = json["expires_in"].as_i64().unwrap_or(3600);
let exp = now + expires_in.saturating_sub(30);
creds.oauth_access_token = Some(token.clone());
creds.oauth_expires_at_unix = Some(exp);
let _ = write_credentials(&creds);
return Ok(token);
}
Ok(creds.api_key)
}
pub fn oauth_register_client(client_name: Option<&str>) -> Result<String, String> {
let mut creds = load_credentials().ok_or("Not logged in. Run: lean-ctx login")?;
if creds.oauth_client_id.is_some() && creds.oauth_client_secret.is_some() {
return Ok("OAuth client already registered.".to_string());
}
let url = format!("{}/oauth/register", api_url());
let body = if let Some(name) = client_name {
serde_json::json!({ "client_name": name })
} else {
serde_json::json!({})
};
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {}", creds.api_key))
.header("Content-Type", "application/json")
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("OAuth register failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
creds.oauth_client_id = Some(
json["client_id"]
.as_str()
.ok_or("Missing client_id in response")?
.to_string(),
);
creds.oauth_client_secret = Some(
json["client_secret"]
.as_str()
.ok_or("Missing client_secret in response")?
.to_string(),
);
creds.oauth_access_token = None;
creds.oauth_expires_at_unix = None;
write_credentials(&creds).map_err(|e| format!("Failed to persist OAuth credentials: {e}"))?;
Ok("OAuth client registered. Cloud requests will use short-lived access tokens.".to_string())
}
pub struct RegisterResult {
pub api_key: String,
pub user_id: String,
pub email_verified: bool,
pub verification_sent: bool,
}
pub fn register(email: &str, password: Option<&str>) -> Result<RegisterResult, String> {
let url = format!("{}/api/auth/register", api_url());
let mut body = serde_json::json!({ "email": email });
if let Some(pw) = password {
body["password"] = serde_json::Value::String(pw.to_string());
}
let resp = ureq::post(&url)
.header("Content-Type", "application/json")
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Request failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(RegisterResult {
api_key: json["api_key"]
.as_str()
.ok_or("Missing api_key in response")?
.to_string(),
user_id: json["user_id"]
.as_str()
.ok_or("Missing user_id in response")?
.to_string(),
email_verified: json["email_verified"].as_bool().unwrap_or(false),
verification_sent: json["verification_sent"].as_bool().unwrap_or(false),
})
}
pub fn forgot_password(email: &str) -> Result<String, String> {
let url = format!("{}/api/auth/forgot-password", api_url());
let body = serde_json::json!({ "email": email });
let resp = ureq::post(&url)
.header("Content-Type", "application/json")
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Request failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(json["message"]
.as_str()
.unwrap_or("If an account exists, a reset email has been sent.")
.to_string())
}
pub fn login(email: &str, password: &str) -> Result<RegisterResult, String> {
let url = format!("{}/api/auth/login", api_url());
let body = serde_json::json!({ "email": email, "password": password });
let resp = ureq::post(&url)
.header("Content-Type", "application/json")
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| {
let msg = e.to_string();
if msg.contains("401") {
"Invalid email or password".to_string()
} else {
format!("Request failed: {e}")
}
})?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(RegisterResult {
api_key: json["api_key"]
.as_str()
.ok_or("Missing api_key in response")?
.to_string(),
user_id: json["user_id"]
.as_str()
.ok_or("Missing user_id in response")?
.to_string(),
email_verified: json["email_verified"].as_bool().unwrap_or(false),
verification_sent: false,
})
}
pub fn sync_stats(stats: &[serde_json::Value]) -> Result<String, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/stats", api_url());
let body = serde_json::json!({ "stats": stats });
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/json")
.header("X-Device-Label", &device_label())
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Sync failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(json["message"].as_str().unwrap_or("Synced").to_string())
}
pub fn contribute(entries: &[serde_json::Value]) -> Result<String, String> {
let url = format!("{}/api/contribute", api_url());
let body = serde_json::json!({ "entries": entries });
let resp = ureq::post(&url)
.header("Content-Type", "application/json")
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Contribute failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(json["message"]
.as_str()
.unwrap_or("Contributed")
.to_string())
}
#[derive(serde::Deserialize)]
pub struct PublishedCard {
pub id: String,
#[serde(default)]
pub edit_token: Option<String>,
#[serde(default)]
pub edit_token_challenge: Option<String>,
#[serde(default)]
pub challenge_expires_in_secs: Option<i64>,
pub url: String,
#[serde(skip)]
pub account_claimed: bool,
}
pub fn publish_wrapped(payload: &serde_json::Value) -> Result<PublishedCard, String> {
let url = format!("{}/api/wrapped", api_url());
let resp = ureq::post(&url)
.header("Content-Type", "application/json")
.send(&serde_json::to_vec(payload).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Publish failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
}
#[derive(serde::Deserialize)]
struct RecoveredEditToken {
edit_token: String,
}
pub fn recover_wrapped_edit_token(
id: &str,
nonce: &str,
public_key: &str,
signature: &str,
) -> Result<String, String> {
let url = format!("{}/api/wrapped/{id}/edit-token/recover", api_url());
let body = serde_json::json!({
"nonce": nonce,
"public_key": public_key,
"signature": signature,
});
let resp = ureq::post(&url)
.header("Content-Type", "application/json")
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Edit-token recovery failed: {e}"))?;
let response = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let recovered: RecoveredEditToken =
serde_json::from_str(&response).map_err(|e| format!("Invalid recovery response: {e}"))?;
if recovered.edit_token.is_empty() {
return Err("Invalid recovery response: empty edit token".to_string());
}
Ok(recovered.edit_token)
}
pub fn unpublish_wrapped(id: &str, edit_token: &str) -> Result<(), String> {
let url = format!("{}/api/wrapped/{id}", api_url());
ureq::delete(&url)
.header("X-Edit-Token", edit_token)
.call()
.map_err(|e| format!("Unpublish failed: {e}"))?;
Ok(())
}
pub fn claim_wrapped(id: &str, edit_token: &str) -> Result<(), String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/wrapped/{id}/claim", api_url());
ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("X-Edit-Token", edit_token)
.send_empty()
.map_err(|e| format!("Claim failed: {e}"))?;
Ok(())
}
#[derive(serde::Deserialize)]
pub struct LinkCode {
pub code: String,
pub expires_in_secs: i64,
}
pub fn link_wrapped_start(id: &str, edit_token: &str) -> Result<LinkCode, String> {
let url = format!("{}/api/wrapped/{id}/link/start", api_url());
let resp = ureq::post(&url)
.header("X-Edit-Token", edit_token)
.send_empty()
.map_err(|e| format!("Link start failed: {e}"))?;
let body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
serde_json::from_str(&body).map_err(|e| format!("Invalid response: {e}"))
}
pub fn link_wrapped_complete(id: &str, edit_token: &str, code: &str) -> Result<(), String> {
let url = format!("{}/api/wrapped/{id}/link/complete", api_url());
let body = serde_json::json!({ "code": code });
ureq::post(&url)
.header("X-Edit-Token", edit_token)
.header("Content-Type", "application/json")
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| match e {
ureq::Error::StatusCode(404) => {
"code invalid or expired — mint a fresh one with lean-ctx gain --link".to_string()
}
other => format!("Link failed: {other}"),
})?;
Ok(())
}
pub fn push_knowledge(entries: &[serde_json::Value]) -> Result<String, String> {
let bearer = auth_bearer_token()?;
let key = knowledge_vault_key()?;
let blob = crate::core::knowledge_vault::seal(entries, &key).map_err(|e| e.to_string())?;
let url = format!("{}/api/sync/knowledge", api_url());
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/octet-stream")
.header("X-Entry-Count", &entries.len().to_string())
.header("X-Device-Label", &device_label())
.send(blob.as_slice())
.map_err(|e| format!("Push failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(format!(
"{} entries synced (end-to-end encrypted)",
json["entry_count"].as_i64().unwrap_or(entries.len() as i64)
))
}
fn knowledge_vault_key() -> Result<[u8; 32], String> {
let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
if api_key.trim().is_empty() {
return Err("Not logged in. Run: lean-ctx login".into());
}
Ok(crate::core::knowledge_vault::derive_vault_key(&api_key))
}
pub fn pull_cloud_models() -> Result<serde_json::Value, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/cloud/models", api_url());
let resp = ureq::get(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.call()
.map_err(|e| {
let msg = e.to_string();
if msg.contains("403") {
"This feature is not available for your account.".to_string()
} else {
format!("Connection failed. Check your internet connection. ({e})")
}
})?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))
}
pub fn save_cloud_models(data: &serde_json::Value) -> std::io::Result<()> {
let dir = config_dir();
std::fs::create_dir_all(&dir)?;
let json = serde_json::to_string_pretty(data).map_err(std::io::Error::other)?;
std::fs::write(dir.join("cloud_models.json"), json)
}
pub fn load_cloud_models() -> Option<serde_json::Value> {
let path = config_dir().join("cloud_models.json");
let data = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
pub fn fetch_leaderboard() -> Result<serde_json::Value, String> {
let url = format!("{}/api/leaderboard", api_url());
let resp = ureq::get(&url)
.config()
.timeout_global(Some(std::time::Duration::from_secs(10)))
.build()
.call()
.map_err(|e| format!("Could not reach the leaderboard service: {e}"))?;
let body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read leaderboard response: {e}"))?;
serde_json::from_str(&body).map_err(|e| format!("Invalid leaderboard JSON: {e}"))
}
pub fn is_cloud_user() -> bool {
let path = config_dir().join("plan.txt");
std::fs::read_to_string(path).is_ok_and(|p| matches!(p.trim(), "cloud" | "pro"))
}
pub const PLAN_GRACE_DAYS: i64 = 14;
fn plan_cache_path() -> PathBuf {
config_dir().join("plan.json")
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PlanCache {
pub plan: String,
pub verified_at: i64,
}
pub fn save_plan(plan: &str) -> std::io::Result<()> {
let dir = config_dir();
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("plan.txt"), plan)?;
let cache = PlanCache {
plan: plan.to_string(),
verified_at: now_unix(),
};
let json = serde_json::to_string_pretty(&cache).map_err(std::io::Error::other)?;
std::fs::write(plan_cache_path(), json)
}
pub fn cached_plan() -> Option<PlanCache> {
if let Ok(data) = std::fs::read_to_string(plan_cache_path())
&& let Ok(cache) = serde_json::from_str::<PlanCache>(&data)
{
return Some(cache);
}
let legacy = std::fs::read_to_string(config_dir().join("plan.txt")).ok()?;
Some(PlanCache {
plan: legacy.trim().to_string(),
verified_at: 0,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanSource {
Live,
Cached,
Expired,
None,
}
#[derive(Debug, Clone)]
pub struct EffectivePlan {
pub plan: crate::core::billing::Plan,
pub source: PlanSource,
pub verified_at: Option<i64>,
pub grace_days: i64,
}
#[must_use]
pub fn plan_within_grace(verified_at: i64, now: i64, grace_days: i64) -> (bool, i64) {
let age_days = (now - verified_at).max(0) / 86_400;
(age_days <= grace_days, age_days)
}
#[must_use]
pub fn resolve_effective_plan_cached() -> EffectivePlan {
let grace_days = PLAN_GRACE_DAYS;
let Some(cache) = cached_plan() else {
return EffectivePlan {
plan: crate::core::billing::Plan::Free,
source: PlanSource::None,
verified_at: None,
grace_days,
};
};
let (fresh, _age) = plan_within_grace(cache.verified_at, now_unix(), grace_days);
if fresh {
EffectivePlan {
plan: crate::core::billing::Plan::parse(&cache.plan),
source: PlanSource::Cached,
verified_at: Some(cache.verified_at),
grace_days,
}
} else {
EffectivePlan {
plan: crate::core::billing::Plan::Free,
source: PlanSource::Expired,
verified_at: Some(cache.verified_at),
grace_days,
}
}
}
#[must_use]
pub fn refresh_effective_plan() -> EffectivePlan {
if is_logged_in()
&& let Ok(plan_str) = fetch_plan()
{
let _ = save_plan(&plan_str);
return EffectivePlan {
plan: crate::core::billing::Plan::parse(&plan_str),
source: PlanSource::Live,
verified_at: Some(now_unix()),
grace_days: PLAN_GRACE_DAYS,
};
}
resolve_effective_plan_cached()
}
pub fn fetch_plan() -> Result<String, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/auth/me", api_url());
let resp = ureq::get(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.call()
.map_err(|e| format!("Failed to check plan: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))?;
Ok(json["plan"].as_str().unwrap_or("free").to_string())
}
pub fn start_checkout(plan: &str, interval: &str) -> Result<String, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/account/checkout", api_url());
let body = serde_json::json!({ "plan": plan, "interval": interval });
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/json")
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Checkout request failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid response: {e}"))?;
json["url"]
.as_str()
.map(str::to_string)
.ok_or_else(|| "Billing did not return a checkout URL.".to_string())
}
pub fn push_commands(entries: &[serde_json::Value]) -> Result<String, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/sync/commands", api_url());
let body = serde_json::json!({ "commands": entries });
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/json")
.header("X-Device-Label", &device_label())
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Push failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(format!(
"{} commands synced",
json["synced"].as_i64().unwrap_or(0)
))
}
pub fn push_cep(entries: &[serde_json::Value]) -> Result<String, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/sync/cep", api_url());
let body = serde_json::json!({ "scores": entries });
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/json")
.header("X-Device-Label", &device_label())
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Push failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(format!(
"{} sessions synced",
json["synced"].as_i64().unwrap_or(0)
))
}
pub fn push_gain(entries: &[serde_json::Value]) -> Result<String, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/sync/gain", api_url());
let body = serde_json::json!({ "scores": entries });
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/json")
.header("X-Device-Label", &device_label())
.send(&serde_json::to_vec(&body).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Push failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(format!(
"{} gain scores synced",
json["synced"].as_i64().unwrap_or(0)
))
}
pub fn push_gotchas(entries: &[serde_json::Value]) -> Result<String, String> {
let bearer = auth_bearer_token()?;
let key = gotcha_vault_key()?;
let blob = crate::core::knowledge_vault::seal(entries, &key).map_err(|e| e.to_string())?;
let url = format!("{}/api/sync/gotchas", api_url());
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/octet-stream")
.header("X-Entry-Count", &entries.len().to_string())
.header("X-Device-Label", &device_label())
.send(blob.as_slice())
.map_err(|e| format!("Push failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(format!(
"{} gotchas synced (end-to-end encrypted)",
json["entry_count"].as_i64().unwrap_or(entries.len() as i64)
))
}
fn gotcha_vault_key() -> Result<[u8; 32], String> {
let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
if api_key.trim().is_empty() {
return Err("Not logged in. Run: lean-ctx login".into());
}
Ok(crate::core::knowledge_vault::derive_gotcha_vault_key(
&api_key,
))
}
pub fn push_buddy(data: &serde_json::Value) -> Result<String, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/sync/buddy", api_url());
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/json")
.header("X-Device-Label", &device_label())
.send(&serde_json::to_vec(data).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Push failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let _json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok("Buddy synced".to_string())
}
pub fn push_feedback(entries: &[serde_json::Value]) -> Result<String, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/sync/feedback", api_url());
let resp = ureq::post(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/json")
.header("X-Device-Label", &device_label())
.send(&serde_json::to_vec(entries).map_err(|e| format!("JSON error: {e}"))?)
.map_err(|e| format!("Push failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let json: serde_json::Value =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(format!(
"{} thresholds synced",
json["synced"].as_i64().unwrap_or(0)
))
}
pub fn account_email() -> Option<String> {
load_credentials().map(|c| c.email)
}
pub fn fetch_account_cloud() -> Result<serde_json::Value, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/account/cloud", api_url());
let resp = ureq::get(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.call()
.map_err(|e| format!("Status fetch failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))
}
pub fn pull_knowledge() -> Result<Vec<serde_json::Value>, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/sync/knowledge", api_url());
match ureq::get(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Accept", "application/octet-stream")
.call()
{
Ok(resp) => {
let is_blob = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("application/octet-stream"));
if is_blob {
let mut blob = Vec::new();
use std::io::Read;
resp.into_body()
.into_reader()
.read_to_end(&mut blob)
.map_err(|e| format!("Failed to read vault: {e}"))?;
let key = knowledge_vault_key()?;
return crate::core::knowledge_vault::open(&blob, &key).map_err(|e| e.to_string());
}
let body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
return serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"));
}
Err(ureq::Error::StatusCode(404)) => {}
Err(e) => return Err(format!("Pull failed: {e}")),
}
let resp = ureq::get(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.call()
.map_err(|e| format!("Pull failed: {e}"))?;
let resp_body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let entries: Vec<serde_json::Value> =
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok(entries)
}
fn index_bundle_key() -> Result<[u8; 32], String> {
let api_key = load_api_key().ok_or("Not logged in. Run: lean-ctx login")?;
if api_key.trim().is_empty() {
return Err("Not logged in. Run: lean-ctx login".into());
}
Ok(crate::core::index_bundle::derive_key(&api_key))
}
pub fn push_index_bundle(project_root: &std::path::Path) -> Result<(String, u64), String> {
let (container, manifest) =
crate::core::index_bundle::pack(project_root).map_err(|e| e.to_string())?;
let blob = crate::core::index_bundle::encrypt(&container, &index_bundle_key()?)
.map_err(|e| e.to_string())?;
let bearer = auth_bearer_token()?;
let url = format!("{}/api/sync/index/{}", api_url(), manifest.project_hash);
let resp = ureq::put(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.header("Content-Type", "application/octet-stream")
.header("X-Device-Label", &device_label())
.send(blob.as_slice())
.map_err(|e| match e {
ureq::Error::StatusCode(402) => {
"Hosted index requires lean-ctx Pro. Run: lean-ctx upgrade".to_string()
}
ureq::Error::StatusCode(413) => {
"Quota exceeded — the push was blocked (nothing is billed). \
Free space with `lean-ctx sync index status` / delete, then retry."
.to_string()
}
other => format!("Push failed: {other}"),
})?;
let body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
let _ack: serde_json::Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"))?;
Ok((manifest.project_hash, blob.len() as u64))
}
pub fn pull_index_bundle(
project_root: &std::path::Path,
) -> Result<crate::core::index_bundle::BundleManifest, String> {
let project_hash = crate::core::index_namespace::namespace_hash(project_root);
let bearer = auth_bearer_token()?;
let url = format!("{}/api/sync/index/{project_hash}", api_url());
let resp = ureq::get(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.call()
.map_err(|e| match e {
ureq::Error::StatusCode(404) => format!(
"No hosted index for this project yet ({project_hash}). \
Push one from a device with a built index: lean-ctx sync index push"
),
ureq::Error::StatusCode(402) => {
"Hosted index requires lean-ctx Pro. Run: lean-ctx upgrade".to_string()
}
other => format!("Pull failed: {other}"),
})?;
let mut blob = Vec::new();
use std::io::Read;
resp.into_body()
.into_reader()
.read_to_end(&mut blob)
.map_err(|e| format!("Failed to read bundle: {e}"))?;
let container = crate::core::index_bundle::decrypt(&blob, &index_bundle_key()?)
.map_err(|e| e.to_string())?;
crate::core::index_bundle::unpack(project_root, &container).map_err(|e| e.to_string())
}
pub fn index_bundle_status() -> Result<serde_json::Value, String> {
let bearer = auth_bearer_token()?;
let url = format!("{}/api/sync/index", api_url());
let resp = ureq::get(&url)
.header("Authorization", &format!("Bearer {bearer}"))
.call()
.map_err(|e| format!("Status fetch failed: {e}"))?;
let body = resp
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response: {e}"))?;
serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::billing::Plan;
#[cfg(unix)]
use crate::core::data_dir::test_env_lock;
#[test]
fn existing_card_publish_response_carries_recovery_challenge() {
let card: PublishedCard = serde_json::from_value(serde_json::json!({
"id": "card-1",
"url": "https://leanctx.com/w/card-1",
"edit_token_challenge": "nonce-1",
"challenge_expires_in_secs": 300
}))
.unwrap();
assert!(card.edit_token.is_none());
assert_eq!(card.edit_token_challenge.as_deref(), Some("nonce-1"));
assert_eq!(card.challenge_expires_in_secs, Some(300));
assert!(!card.account_claimed);
}
#[test]
fn grace_window_boundaries_are_inclusive_and_skew_safe() {
let now = 1_000_000_000;
let day = 86_400;
assert_eq!(plan_within_grace(now, now, 14), (true, 0));
assert_eq!(plan_within_grace(now - 14 * day, now, 14), (true, 14));
assert_eq!(plan_within_grace(now - 15 * day, now, 14), (false, 15));
assert_eq!(plan_within_grace(now + day, now, 14), (true, 0));
}
#[test]
fn plan_cache_roundtrips_through_json() {
let c = PlanCache {
plan: "pro".into(),
verified_at: 42,
};
let back: PlanCache = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(back.plan, "pro");
assert_eq!(back.verified_at, 42);
}
#[test]
fn cached_resolve_grants_within_grace_then_expires_to_free() {
let _iso = crate::core::data_dir::isolated_data_dir();
save_plan("pro").unwrap();
let eff = resolve_effective_plan_cached();
assert_eq!(eff.plan, Plan::Pro);
assert_eq!(eff.source, PlanSource::Cached);
let stale = PlanCache {
plan: "pro".into(),
verified_at: now_unix() - (PLAN_GRACE_DAYS + 1) * 86_400,
};
std::fs::write(plan_cache_path(), serde_json::to_string(&stale).unwrap()).unwrap();
let eff = resolve_effective_plan_cached();
assert_eq!(eff.plan, Plan::Free);
assert_eq!(eff.source, PlanSource::Expired);
}
#[test]
fn no_cache_resolves_to_free_none() {
let _iso = crate::core::data_dir::isolated_data_dir();
let eff = resolve_effective_plan_cached();
assert_eq!(eff.plan, Plan::Free);
assert_eq!(eff.source, PlanSource::None);
}
#[cfg(unix)]
#[test]
fn credentials_are_written_owner_only_and_atomic() {
use std::os::unix::fs::PermissionsExt;
let _env = test_env_lock();
let tmp = tempfile::tempdir().unwrap();
crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
save_credentials("sk-test-key", "user-1", "a@b.c").unwrap();
let path = credentials_path();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "credentials.json must be 0o600");
let dir_mode = std::fs::metadata(config_dir())
.unwrap()
.permissions()
.mode();
assert_eq!(
dir_mode & 0o077,
0,
"cloud dir must not be group/world accessible"
);
let leftovers: Vec<_> = std::fs::read_dir(config_dir())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.collect();
assert!(leftovers.is_empty(), "atomic write must not leak tmp files");
crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
}
#[cfg(unix)]
#[test]
fn loose_credential_permissions_are_tightened_on_load() {
use std::os::unix::fs::PermissionsExt;
let _env = test_env_lock();
let tmp = tempfile::tempdir().unwrap();
crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
std::fs::create_dir_all(config_dir()).unwrap();
let path = credentials_path();
std::fs::write(&path, "{}").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let _ = load_credentials();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"legacy file must be tightened to 0o600"
);
crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
}
#[test]
fn legacy_plan_txt_is_migrated_but_treated_as_stale() {
let _iso = crate::core::data_dir::isolated_data_dir();
std::fs::create_dir_all(config_dir()).unwrap();
std::fs::write(config_dir().join("plan.txt"), "team").unwrap();
let cache = cached_plan().unwrap();
assert_eq!(cache.plan, "team");
assert_eq!(cache.verified_at, 0);
assert_eq!(resolve_effective_plan_cached().source, PlanSource::Expired);
}
}