synth-ai-core 0.3.0

Rust core for Synth AI SDKs - API client, streaming, tracing, and tunnels
Documentation
use crate::auth;
use crate::errors::{CoreError, HttpErrorInfo};
use base64::{engine::general_purpose, Engine as _};
use serde_json::Value;
use sodiumoxide::crypto::box_::PublicKey;
use std::collections::{HashMap, HashSet};
use std::env;
use std::time::Duration;
use uuid::Uuid;

pub const ENVIRONMENT_API_KEY_NAME: &str = "ENVIRONMENT_API_KEY";
pub const DEV_ENVIRONMENT_API_KEY_NAME: &str = "DEV_ENVIRONMENT_API_KEY";
pub const ENVIRONMENT_API_KEY_ALIASES_NAME: &str = "ENVIRONMENT_API_KEY_ALIASES";
pub const MAX_ENVIRONMENT_API_KEY_BYTES: usize = 8 * 1024;
pub const SEALED_BOX_ALGORITHM: &str = "libsodium.sealedbox.v1";

const DEV_ENVIRONMENT_API_KEY_NAMES: [&str; 2] =
    ["dev_environment_api_key", "DEV_ENVIRONMENT_API_KEY"];

fn mask(value: &str, prefix: usize) -> String {
    if value.is_empty() {
        return "<empty>".to_string();
    }
    let visible: String = value.chars().take(prefix).collect();
    if value.len() > prefix {
        format!("{visible}...")
    } else {
        visible
    }
}

pub fn normalize_environment_api_key() -> Option<String> {
    if let Ok(value) = env::var(ENVIRONMENT_API_KEY_NAME) {
        if !value.is_empty() {
            return Some(value);
        }
    }
    for env_key in DEV_ENVIRONMENT_API_KEY_NAMES.iter() {
        if let Ok(value) = env::var(env_key) {
            if !value.is_empty() {
                env::set_var(ENVIRONMENT_API_KEY_NAME, &value);
                println!(
                    "[task:auth] {} set from {} (prefix={})",
                    ENVIRONMENT_API_KEY_NAME,
                    env_key,
                    mask(&value, 4)
                );
                return Some(value);
            }
        }
    }
    None
}

pub fn allowed_environment_api_keys() -> Vec<String> {
    let mut keys = HashSet::new();
    if let Some(primary) = normalize_environment_api_key() {
        keys.insert(primary);
    }
    if let Ok(aliases) = env::var(ENVIRONMENT_API_KEY_ALIASES_NAME) {
        for part in aliases.split(',') {
            let trimmed = part.trim();
            if !trimmed.is_empty() {
                keys.insert(trimmed.to_string());
            }
        }
    }
    keys.into_iter().collect()
}

fn extract_candidates(header_values: &[String]) -> Vec<String> {
    let mut candidates = Vec::new();
    for raw in header_values {
        let trimmed = raw.trim();
        if trimmed.is_empty() {
            continue;
        }
        let mut value = trimmed.to_string();
        if trimmed.len() >= 7 && trimmed[..7].eq_ignore_ascii_case("bearer ") {
            value = trimmed[7..].trim().to_string();
        }
        for part in value.split(',') {
            let token = part.trim();
            if !token.is_empty() {
                candidates.push(token.to_string());
            }
        }
    }
    candidates
}

pub fn is_api_key_header_authorized(header_values: &[String]) -> bool {
    let allowed = allowed_environment_api_keys();
    if allowed.is_empty() {
        return false;
    }
    let candidates = extract_candidates(header_values);
    if candidates.is_empty() {
        return false;
    }
    let allowed_set: HashSet<String> = allowed.into_iter().collect();
    candidates
        .into_iter()
        .any(|candidate| allowed_set.contains(&candidate))
}

pub fn mint_environment_api_key() -> String {
    format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}

pub fn encrypt_for_backend(pubkey_b64: &str, secret: &[u8]) -> Result<String, CoreError> {
    if pubkey_b64.trim().is_empty() {
        return Err(CoreError::InvalidInput(
            "public key must be a non-empty base64 string".to_string(),
        ));
    }
    if secret.is_empty() {
        return Err(CoreError::InvalidInput(
            "secret must not be empty".to_string(),
        ));
    }

    let key_bytes = general_purpose::STANDARD
        .decode(pubkey_b64.trim())
        .map_err(|_| CoreError::InvalidInput("public key must be base64-encoded".to_string()))?;
    if key_bytes.len() != 32 {
        return Err(CoreError::InvalidInput(
            "public key must be 32 bytes for X25519".to_string(),
        ));
    }

    if sodiumoxide::init().is_err() {
        return Err(CoreError::Internal("failed to init libsodium".to_string()));
    }

    let pk = PublicKey::from_slice(&key_bytes)
        .ok_or_else(|| CoreError::InvalidInput("invalid public key bytes".to_string()))?;
    let cipher = sodiumoxide::crypto::sealedbox::seal(secret, &pk);
    Ok(general_purpose::STANDARD.encode(cipher))
}

fn normalize_backend_base(backend_base: &str) -> Result<String, CoreError> {
    let mut backend = backend_base.trim().trim_end_matches('/').to_string();
    if backend.ends_with("/api") {
        backend = backend.trim_end_matches("/api").to_string();
    }
    if backend.is_empty() {
        return Err(CoreError::InvalidInput(
            "backend_base must be provided".to_string(),
        ));
    }
    Ok(backend)
}

async fn raise_with_detail(resp: reqwest::Response) -> Result<reqwest::Response, CoreError> {
    if resp.status().is_success() {
        return Ok(resp);
    }
    let status = resp.status().as_u16();
    let url = resp.url().to_string();
    let text = resp.text().await.unwrap_or_default();
    let snippet = if text.is_empty() {
        None
    } else {
        Some(text.chars().take(200).collect::<String>())
    };
    Err(CoreError::HttpResponse(HttpErrorInfo {
        status,
        url,
        message: "request failed".to_string(),
        body_snippet: snippet,
    }))
}

pub async fn setup_environment_api_key(
    backend_base: &str,
    synth_api_key: &str,
    token: Option<&str>,
    timeout: f64,
) -> Result<Value, CoreError> {
    let backend = normalize_backend_base(backend_base)?;
    if synth_api_key.trim().is_empty() {
        return Err(CoreError::InvalidInput(
            "synth_api_key must be provided".to_string(),
        ));
    }

    let plaintext = token
        .map(|s| s.to_string())
        .or_else(|| env::var(ENVIRONMENT_API_KEY_NAME).ok())
        .unwrap_or_default();
    if plaintext.trim().is_empty() {
        return Err(CoreError::InvalidInput(
            "ENVIRONMENT_API_KEY must be set (or pass token=...) to upload".to_string(),
        ));
    }
    let token_bytes = plaintext.as_bytes();
    if token_bytes.is_empty() {
        return Err(CoreError::InvalidInput(
            "ENVIRONMENT_API_KEY token must not be empty".to_string(),
        ));
    }
    if token_bytes.len() > MAX_ENVIRONMENT_API_KEY_BYTES {
        return Err(CoreError::InvalidInput(
            "ENVIRONMENT_API_KEY token exceeds 8 KiB limit".to_string(),
        ));
    }

    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs_f64(timeout))
        .build()
        .map_err(CoreError::Http)?;
    let mut headers = reqwest::header::HeaderMap::new();
    headers.insert(
        reqwest::header::AUTHORIZATION,
        reqwest::header::HeaderValue::from_str(&format!("Bearer {}", synth_api_key))
            .map_err(|e| CoreError::InvalidInput(e.to_string()))?,
    );

    let pub_url = format!("{}/api/v1/crypto/public-key", backend);
    let resp = client.get(&pub_url).headers(headers.clone()).send().await?;
    let resp = raise_with_detail(resp).await?;
    let doc: Value = resp.json().await.map_err(CoreError::Http)?;
    let public_key = doc
        .get("public_key")
        .and_then(|v| v.as_str())
        .ok_or_else(|| {
            CoreError::InvalidInput("backend response missing public_key".to_string())
        })?;
    if let Some(alg) = doc.get("alg").and_then(|v| v.as_str()) {
        if alg != SEALED_BOX_ALGORITHM {
            return Err(CoreError::InvalidInput(format!(
                "unsupported sealed box algorithm: {alg}"
            )));
        }
    }

    let ciphertext_b64 = encrypt_for_backend(public_key, token_bytes)?;
    let body = serde_json::json!({
        "name": ENVIRONMENT_API_KEY_NAME,
        "ciphertext_b64": ciphertext_b64,
    });
    let post_url = format!("{}/api/v1/env-keys", backend);
    let resp2 = client
        .post(&post_url)
        .header(
            reqwest::header::AUTHORIZATION,
            format!("Bearer {}", synth_api_key),
        )
        .json(&body)
        .send()
        .await?;
    let resp2 = raise_with_detail(resp2).await?;
    let upload_doc: Value = resp2
        .json()
        .await
        .unwrap_or(Value::Object(Default::default()));

    let mut result = serde_json::Map::new();
    result.insert("stored".to_string(), Value::Bool(true));
    if let Some(id) = upload_doc.get("id").cloned() {
        result.insert("id".to_string(), id);
    }
    if let Some(name) = upload_doc.get("name").cloned() {
        result.insert("name".to_string(), name);
    }
    if let Some(updated_at) = upload_doc.get("updated_at").cloned() {
        result.insert("updated_at".to_string(), updated_at);
    }
    Ok(Value::Object(result))
}

pub async fn ensure_container_auth(
    backend_base: Option<&str>,
    synth_api_key: Option<&str>,
    upload: bool,
    persist: Option<bool>,
) -> Result<String, CoreError> {
    let _ = auth::load_user_env_with(false)?;

    let mut key = env::var(ENVIRONMENT_API_KEY_NAME).unwrap_or_default();
    if key.trim().is_empty() {
        let fallback = env::var(DEV_ENVIRONMENT_API_KEY_NAME).unwrap_or_default();
        if !fallback.trim().is_empty() {
            key = fallback;
            env::set_var(ENVIRONMENT_API_KEY_NAME, &key);
        }
    }

    let mut minted = false;
    if key.trim().is_empty() {
        key = mint_environment_api_key();
        env::set_var(ENVIRONMENT_API_KEY_NAME, &key);
        minted = true;
    }

    env::set_var(DEV_ENVIRONMENT_API_KEY_NAME, &key);

    let persist = persist.unwrap_or_else(|| {
        env::var("SYNTH_CONTAINER_AUTH_PERSIST")
            .map(|v| v != "0")
            .unwrap_or(true)
    });

    if minted && persist {
        let mut updates = HashMap::new();
        updates.insert(
            ENVIRONMENT_API_KEY_NAME.to_string(),
            Value::String(key.clone()),
        );
        updates.insert(
            DEV_ENVIRONMENT_API_KEY_NAME.to_string(),
            Value::String(key.clone()),
        );
        let _ = auth::update_user_config(&updates)?;
    }

    if upload {
        let synth_key = synth_api_key
            .map(|s| s.to_string())
            .or_else(|| env::var("SYNTH_API_KEY").ok());
        if let (Some(backend), Some(synth_key)) = (backend_base, synth_key.as_deref()) {
            let _ = setup_environment_api_key(backend, synth_key, Some(&key), 15.0).await;
        }
    }

    if key.trim().is_empty() {
        return Err(CoreError::InvalidInput(
            "ENVIRONMENT_API_KEY is required but missing".to_string(),
        ));
    }

    Ok(key)
}