1use crate::errors::CoreError;
10use crate::shared_client::build_pooled_client;
11use crate::utils;
12use crate::urls::backend_url_base;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::env;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21pub const CONFIG_DIR: &str = ".synth-ai";
23
24pub const CONFIG_FILE: &str = "user_config.json";
26pub const CONTAINER_CONFIG_FILE: &str = "container_config.json";
28
29pub const ENV_API_KEY: &str = "SYNTH_API_KEY";
31
32pub const ENV_ENVIRONMENT_API_KEY: &str = "ENVIRONMENT_API_KEY";
34
35pub const DEFAULT_FRONTEND_URL: &str = "https://usesynth.ai";
37
38pub const DEFAULT_BACKEND_URL: &str = crate::urls::DEFAULT_BACKEND_URL;
40
41pub fn get_config_dir() -> PathBuf {
43 dirs::home_dir()
44 .unwrap_or_else(|| PathBuf::from("."))
45 .join(CONFIG_DIR)
46}
47
48pub fn get_config_path() -> PathBuf {
50 get_config_dir().join(CONFIG_FILE)
51}
52
53pub fn get_container_config_path() -> PathBuf {
55 get_config_dir().join(CONTAINER_CONFIG_FILE)
56}
57
58pub fn load_user_config() -> Result<HashMap<String, Value>, CoreError> {
60 let path = get_config_path();
61 if !path.exists() {
62 return Ok(HashMap::new());
63 }
64 let content = fs::read_to_string(&path)
65 .map_err(|e| CoreError::Config(format!("failed to read config: {}", e)))?;
66 let value: Value = serde_json::from_str(&content)
67 .map_err(|e| CoreError::Config(format!("invalid config JSON: {}", e)))?;
68 let mut result = HashMap::new();
69 if let Value::Object(map) = value {
70 for (k, v) in map {
71 result.insert(k, v);
72 }
73 }
74 Ok(result)
75}
76
77pub fn save_user_config(config: &HashMap<String, Value>) -> Result<(), CoreError> {
79 let path = get_config_path();
80 let value = Value::Object(config.clone().into_iter().collect());
81 utils::write_private_json(&path, &value)
82 .map_err(|e| CoreError::Config(format!("failed to write config: {}", e)))?;
83 Ok(())
84}
85
86pub fn update_user_config(
88 updates: &HashMap<String, Value>,
89) -> Result<HashMap<String, Value>, CoreError> {
90 let mut current = load_user_config()?;
91 for (k, v) in updates {
92 current.insert(k.clone(), v.clone());
93 }
94 save_user_config(¤t)?;
95 Ok(current)
96}
97
98pub fn get_api_key_from_env(env_key: Option<&str>) -> Option<String> {
104 let key = env_key.unwrap_or(ENV_API_KEY);
105 env::var(key).ok().filter(|s| !s.trim().is_empty())
106}
107
108pub fn load_credentials(config_path: Option<&Path>) -> Result<HashMap<String, String>, CoreError> {
114 let path = config_path
115 .map(|p| p.to_path_buf())
116 .unwrap_or_else(get_config_path);
117
118 if !path.exists() {
119 return Ok(HashMap::new());
120 }
121
122 let content = fs::read_to_string(&path)
123 .map_err(|e| CoreError::Config(format!("failed to read config: {}", e)))?;
124
125 let value: Value = serde_json::from_str(&content)
126 .map_err(|e| CoreError::Config(format!("invalid config JSON: {}", e)))?;
127
128 let mut result = HashMap::new();
129 if let Value::Object(map) = value {
130 for (k, v) in map {
131 if let Value::String(s) = v {
132 result.insert(k, s);
133 }
134 }
135 }
136
137 Ok(result)
138}
139
140pub fn store_credentials(
150 credentials: &HashMap<String, String>,
151 config_path: Option<&Path>,
152) -> Result<(), CoreError> {
153 let path = config_path
154 .map(|p| p.to_path_buf())
155 .unwrap_or_else(get_config_path);
156
157 if let Some(parent) = path.parent() {
159 fs::create_dir_all(parent)
160 .map_err(|e| CoreError::Config(format!("failed to create config dir: {}", e)))?;
161 }
162
163 let mut existing = load_credentials(Some(&path)).unwrap_or_default();
165 for (k, v) in credentials {
166 existing.insert(k.clone(), v.clone());
167 }
168
169 let content = serde_json::to_string_pretty(&existing)
171 .map_err(|e| CoreError::Config(format!("failed to serialize config: {}", e)))?;
172
173 fs::write(&path, content)
174 .map_err(|e| CoreError::Config(format!("failed to write config: {}", e)))?;
175
176 #[cfg(unix)]
178 {
179 use std::os::unix::fs::PermissionsExt;
180 let perms = fs::Permissions::from_mode(0o600);
181 let _ = fs::set_permissions(&path, perms);
182 }
183
184 Ok(())
185}
186
187pub fn get_api_key(env_key: Option<&str>) -> Option<String> {
197 if let Some(key) = get_api_key_from_env(env_key) {
199 return Some(key);
200 }
201
202 if let Ok(creds) = load_credentials(None) {
204 let key_name = env_key.unwrap_or(ENV_API_KEY);
205 if let Some(key) = creds.get(key_name) {
206 if !key.trim().is_empty() {
207 return Some(key.clone());
208 }
209 }
210 }
211
212 None
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct DeviceAuthSession {
218 pub device_code: String,
220 pub verification_uri: String,
222 pub expires_at: f64,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct DeviceAuthResponse {
229 #[serde(default)]
231 pub synth_api_key: Option<String>,
232 #[serde(default)]
234 pub environment_api_key: Option<String>,
235 #[serde(default)]
237 pub keys: Option<HashMap<String, String>>,
238}
239
240pub async fn init_device_auth(frontend_url: Option<&str>) -> Result<DeviceAuthSession, CoreError> {
249 let base = frontend_url
250 .unwrap_or(DEFAULT_FRONTEND_URL)
251 .trim_end_matches('/');
252 let url = format!("{}/api/auth/device/init", base);
253
254 let client = build_pooled_client(Some(10));
255 let resp =
256 client.post(&url).send().await.map_err(|e| {
257 CoreError::Authentication(format!("failed to reach init endpoint: {}", e))
258 })?;
259
260 if !resp.status().is_success() {
261 let status = resp.status();
262 let body = resp.text().await.unwrap_or_default();
263 return Err(CoreError::Authentication(format!(
264 "init failed ({}): {}",
265 status,
266 body.trim()
267 )));
268 }
269
270 let data: Value = resp
271 .json()
272 .await
273 .map_err(|e| CoreError::Authentication(format!("invalid JSON response: {}", e)))?;
274
275 let device_code = data["device_code"]
276 .as_str()
277 .filter(|s| !s.is_empty())
278 .ok_or_else(|| CoreError::Authentication("missing device_code".to_string()))?
279 .to_string();
280
281 let verification_uri = data["verification_uri"]
282 .as_str()
283 .filter(|s| !s.is_empty())
284 .ok_or_else(|| CoreError::Authentication("missing verification_uri".to_string()))?
285 .to_string();
286
287 let expires_in = data["expires_in"].as_i64().unwrap_or(600);
288 let expires_at = now_timestamp() + expires_in as f64;
289
290 Ok(DeviceAuthSession {
291 device_code,
292 verification_uri,
293 expires_at,
294 })
295}
296
297pub async fn poll_device_token(
308 frontend_url: Option<&str>,
309 device_code: &str,
310 poll_interval_secs: Option<u64>,
311 timeout_secs: Option<u64>,
312) -> Result<HashMap<String, String>, CoreError> {
313 let base = frontend_url
314 .unwrap_or(DEFAULT_FRONTEND_URL)
315 .trim_end_matches('/');
316 let url = format!("{}/api/auth/device/token", base);
317 let poll_interval = Duration::from_secs(poll_interval_secs.unwrap_or(3));
318 let timeout = Duration::from_secs(timeout_secs.unwrap_or(600));
319 let start = std::time::Instant::now();
320
321 let client = build_pooled_client(Some(10));
322
323 loop {
324 if start.elapsed() >= timeout {
325 return Err(CoreError::Timeout(
326 "device auth timed out before credentials were returned".to_string(),
327 ));
328 }
329
330 let resp = client
331 .post(&url)
332 .json(&serde_json::json!({ "device_code": device_code }))
333 .send()
334 .await;
335
336 match resp {
337 Ok(r) if r.status().is_success() => {
338 let data: DeviceAuthResponse = r
339 .json()
340 .await
341 .map_err(|e| CoreError::Authentication(format!("invalid JSON: {}", e)))?;
342
343 return Ok(extract_credentials(data));
344 }
345 Ok(r) if r.status().as_u16() == 404 || r.status().as_u16() == 410 => {
346 return Err(CoreError::Authentication(
347 "device code expired or was revoked".to_string(),
348 ));
349 }
350 _ => {
351 tokio::time::sleep(poll_interval).await;
353 }
354 }
355 }
356}
357
358fn extract_credentials(data: DeviceAuthResponse) -> HashMap<String, String> {
360 let mut result = HashMap::new();
361
362 let synth_key = data.synth_api_key.filter(|s| !s.is_empty()).or_else(|| {
364 data.keys
365 .as_ref()
366 .and_then(|k| k.get("synth").cloned())
367 .filter(|s| !s.is_empty())
368 });
369
370 if let Some(key) = synth_key {
371 result.insert(ENV_API_KEY.to_string(), key);
372 }
373
374 let env_key = data
376 .environment_api_key
377 .filter(|s| !s.is_empty())
378 .or_else(|| {
379 data.keys.as_ref().and_then(|k| {
380 k.get("rl_env")
381 .or_else(|| k.get("environment_api_key"))
382 .cloned()
383 .filter(|s| !s.is_empty())
384 })
385 });
386
387 if let Some(key) = env_key {
388 result.insert(ENV_ENVIRONMENT_API_KEY.to_string(), key);
389 }
390
391 result
392}
393
394pub async fn mint_demo_key(
401 backend_url: Option<&str>,
402 ttl_hours: Option<u32>,
403) -> Result<String, CoreError> {
404 let base = backend_url
405 .map(|url| url.to_string())
406 .unwrap_or_else(backend_url_base)
407 .trim_end_matches('/')
408 .to_string();
409 let url = format!("{}/api/demo/keys", base);
410 let ttl = ttl_hours.unwrap_or(4);
411
412 let client = build_pooled_client(Some(30));
413 let resp = client
414 .post(&url)
415 .json(&serde_json::json!({ "ttl_hours": ttl }))
416 .send()
417 .await
418 .map_err(|e| CoreError::Authentication(format!("failed to mint demo key: {}", e)))?;
419
420 if !resp.status().is_success() {
421 let status = resp.status();
422 let body = resp.text().await.unwrap_or_default();
423 return Err(CoreError::Authentication(format!(
424 "demo key minting failed ({}): {}",
425 status,
426 body.trim()
427 )));
428 }
429
430 let data: Value = resp
431 .json()
432 .await
433 .map_err(|e| CoreError::Authentication(format!("invalid JSON: {}", e)))?;
434
435 data["api_key"]
436 .as_str()
437 .filter(|s| !s.is_empty())
438 .map(|s| s.to_string())
439 .ok_or_else(|| CoreError::Authentication("no api_key in response".to_string()))
440}
441
442pub async fn get_or_mint_api_key(
454 backend_url: Option<&str>,
455 allow_mint: bool,
456) -> Result<String, CoreError> {
457 if let Some(key) = get_api_key(None) {
459 return Ok(key);
460 }
461
462 if allow_mint {
464 return mint_demo_key(backend_url, None).await;
465 }
466
467 Err(CoreError::Authentication(
468 "SYNTH_API_KEY is required but missing".to_string(),
469 ))
470}
471
472pub fn mask_str(s: &str) -> String {
474 if s.len() <= 8 {
475 "*".repeat(s.len())
476 } else {
477 format!("{}...", &s[..8])
478 }
479}
480
481pub fn load_user_env() -> Result<HashMap<String, String>, CoreError> {
490 load_user_env_with(true)
491}
492
493pub fn load_user_env_with(override_env: bool) -> Result<HashMap<String, String>, CoreError> {
495 let mut applied: HashMap<String, String> = HashMap::new();
496
497 let mut apply = |mapping: &HashMap<String, Value>| {
498 for (k, v) in mapping {
499 if v.is_null() {
500 continue;
501 }
502 let value = if let Some(s) = v.as_str() {
503 s.to_string()
504 } else {
505 v.to_string()
506 };
507 if override_env || env::var(k).is_err() {
508 env::set_var(k, &value);
509 }
510 applied.insert(k.clone(), value);
511 }
512 };
513
514 let config = load_user_config()?;
515 apply(&config);
516
517 let container_path = get_container_config_path();
519 if container_path.exists() {
520 let raw = fs::read_to_string(&container_path)
521 .map_err(|e| CoreError::Config(format!("failed to read container config: {}", e)))?;
522 if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&raw) {
523 if let Some(Value::Object(apps)) = map.get("apps") {
524 if let Some(entry) = select_container_entry(apps) {
525 if let Some(Value::Object(modal)) = entry.get("modal") {
526 let mut modal_map = HashMap::new();
527 if let Some(v) = modal.get("base_url") {
528 modal_map.insert("CONTAINER_BASE_URL".to_string(), v.clone());
529 }
530 if let Some(v) = modal.get("app_name") {
531 modal_map.insert("CONTAINER_NAME".to_string(), v.clone());
532 }
533 if let Some(v) = modal.get("secret_name") {
534 modal_map.insert("CONTAINER_SECRET_NAME".to_string(), v.clone());
535 }
536 apply(&modal_map);
537 }
538 if let Some(Value::Object(secrets)) = entry.get("secrets") {
539 let mut secrets_map = HashMap::new();
540 if let Some(v) = secrets.get("environment_api_key") {
541 secrets_map.insert("ENVIRONMENT_API_KEY".to_string(), v.clone());
542 secrets_map.insert("DEV_ENVIRONMENT_API_KEY".to_string(), v.clone());
543 }
544 apply(&secrets_map);
545 }
546 }
547 }
548 }
549 }
550
551 Ok(applied)
552}
553
554fn select_container_entry(
555 apps: &serde_json::Map<String, Value>,
556) -> Option<&serde_json::Map<String, Value>> {
557 if apps.is_empty() {
558 return None;
559 }
560
561 if let Ok(cwd) = env::current_dir() {
562 let cwd_str = cwd.to_string_lossy().to_string();
563 if let Some(Value::Object(entry)) = apps.get(&cwd_str) {
564 return Some(entry);
565 }
566 }
567
568 let mut best: Option<&serde_json::Map<String, Value>> = None;
569 let mut best_ts = String::new();
570 for (_key, entry) in apps {
571 if let Value::Object(map) = entry {
572 let ts = map
573 .get("last_used")
574 .and_then(|v| v.as_str())
575 .unwrap_or("")
576 .to_string();
577 if ts > best_ts {
578 best_ts = ts;
579 best = Some(map);
580 }
581 }
582 }
583 best
584}
585
586fn write_atomic(path: &Path, content: &str) -> Result<(), CoreError> {
592 use std::io::Write;
593
594 let dir = path
595 .parent()
596 .ok_or_else(|| CoreError::Config("no parent directory".to_string()))?;
597
598 let mut temp = tempfile::NamedTempFile::new_in(dir)
600 .map_err(|e| CoreError::Config(format!("failed to create temp file: {}", e)))?;
601
602 #[cfg(unix)]
604 {
605 use std::os::unix::fs::PermissionsExt;
606 let perms = fs::Permissions::from_mode(0o600);
607 let _ = temp.as_file().set_permissions(perms);
608 }
609
610 temp.write_all(content.as_bytes())
612 .map_err(|e| CoreError::Config(format!("failed to write: {}", e)))?;
613
614 temp.as_file()
616 .sync_all()
617 .map_err(|e| CoreError::Config(format!("failed to sync: {}", e)))?;
618
619 temp.persist(path)
621 .map_err(|e| CoreError::Config(format!("failed to persist: {}", e)))?;
622
623 Ok(())
624}
625
626pub fn store_credentials_atomic(
631 credentials: &HashMap<String, String>,
632 config_path: Option<&Path>,
633) -> Result<(), CoreError> {
634 let path = config_path
635 .map(|p| p.to_path_buf())
636 .unwrap_or_else(get_config_path);
637
638 if let Some(parent) = path.parent() {
640 fs::create_dir_all(parent)
641 .map_err(|e| CoreError::Config(format!("failed to create config dir: {}", e)))?;
642
643 #[cfg(unix)]
644 {
645 use std::os::unix::fs::PermissionsExt;
646 let perms = fs::Permissions::from_mode(0o700);
647 let _ = fs::set_permissions(parent, perms);
648 }
649 }
650
651 let mut existing = load_credentials(Some(&path)).unwrap_or_default();
653 for (k, v) in credentials {
654 existing.insert(k.clone(), v.clone());
655 }
656
657 let content = serde_json::to_string_pretty(&existing)
659 .map_err(|e| CoreError::Config(format!("failed to serialize: {}", e)))?;
660
661 write_atomic(&path, &content)
663}
664
665pub async fn run_setup(open_browser: bool) -> Result<(), CoreError> {
681 let session = init_device_auth(None).await?;
682
683 println!("Please visit the following URL to authenticate:");
684 println!(" {}", session.verification_uri);
685
686 if open_browser {
687 if let Err(_) = open::that(&session.verification_uri) {
688 println!("(Could not open browser automatically)");
689 }
690 }
691
692 println!("\nWaiting for authentication...");
693
694 let creds = poll_device_token(None, &session.device_code, None, None).await?;
695
696 store_credentials_atomic(&creds, None)?;
698
699 let config_path = get_config_path();
700 println!("\nCredentials saved to: {}", config_path.display());
701 println!("You can now use the Synth API.");
702
703 Ok(())
704}
705
706fn now_timestamp() -> f64 {
708 SystemTime::now()
709 .duration_since(UNIX_EPOCH)
710 .map(|d| d.as_secs_f64())
711 .unwrap_or(0.0)
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 #[test]
719 fn test_get_config_path() {
720 let path = get_config_path();
721 assert!(path.ends_with("user_config.json"));
722 assert!(path.to_string_lossy().contains(".synth-ai"));
723 }
724
725 #[test]
726 fn test_mask_str() {
727 assert_eq!(mask_str("short"), "*****");
728 assert_eq!(mask_str("sk_live_1234567890"), "sk_live_...");
729 }
730
731 #[test]
732 fn test_extract_credentials() {
733 let data = DeviceAuthResponse {
735 synth_api_key: Some("sk_test_123".to_string()),
736 environment_api_key: Some("env_test_456".to_string()),
737 keys: None,
738 };
739 let creds = extract_credentials(data);
740 assert_eq!(creds.get("SYNTH_API_KEY"), Some(&"sk_test_123".to_string()));
741 assert_eq!(
742 creds.get("ENVIRONMENT_API_KEY"),
743 Some(&"env_test_456".to_string())
744 );
745
746 let mut legacy_keys = HashMap::new();
748 legacy_keys.insert("synth".to_string(), "sk_legacy".to_string());
749 legacy_keys.insert("rl_env".to_string(), "env_legacy".to_string());
750
751 let data = DeviceAuthResponse {
752 synth_api_key: None,
753 environment_api_key: None,
754 keys: Some(legacy_keys),
755 };
756 let creds = extract_credentials(data);
757 assert_eq!(creds.get("SYNTH_API_KEY"), Some(&"sk_legacy".to_string()));
758 assert_eq!(
759 creds.get("ENVIRONMENT_API_KEY"),
760 Some(&"env_legacy".to_string())
761 );
762 }
763}