systemprompt-cloud 0.2.0

systemprompt.io Cloud infrastructure - API client, credentials, OAuth
Documentation
use anyhow::Result;
use chrono::Utc;
use std::path::Path;
use std::sync::OnceLock;

use crate::{CloudApiClient, CloudCredentials};

static CREDENTIALS: OnceLock<Option<CloudCredentials>> = OnceLock::new();

#[derive(Debug, Clone, Copy)]
pub struct CredentialsBootstrap;

#[derive(Debug, thiserror::Error)]
pub enum CredentialsBootstrapError {
    #[error(
        "Credentials not initialized. Call CredentialsBootstrap::init() after \
         ProfileBootstrap::init()"
    )]
    NotInitialized,

    #[error("Credentials already initialized")]
    AlreadyInitialized,

    #[error("Cloud credentials not available")]
    NotAvailable,

    #[error("Cloud credentials file not found: {path}")]
    FileNotFound { path: String },

    #[error("Cloud credentials file invalid: {message}")]
    InvalidCredentials { message: String },

    #[error("Cloud token has expired. Run 'systemprompt cloud login' to refresh")]
    TokenExpired,

    #[error("Cloud API validation failed: {message}")]
    ApiValidationFailed { message: String },
}

impl CredentialsBootstrap {
    pub async fn init() -> Result<Option<&'static CloudCredentials>> {
        if CREDENTIALS.get().is_some() {
            anyhow::bail!(CredentialsBootstrapError::AlreadyInitialized);
        }

        if Self::is_fly_container() {
            tracing::debug!("Fly.io container detected, loading credentials from environment");
            let creds = Self::load_from_env();
            if let Some(ref c) = creds {
                if let Err(e) = Self::validate_with_api(c).await {
                    tracing::warn!(
                        error = %e,
                        "Cloud credential validation failed on Fly.io, continuing with unvalidated credentials"
                    );
                }
            }
            CREDENTIALS
                .set(creds)
                .map_err(|_| CredentialsBootstrapError::AlreadyInitialized)?;
            return Ok(CREDENTIALS
                .get()
                .ok_or(CredentialsBootstrapError::NotInitialized)?
                .as_ref());
        }

        let cloud_paths = crate::paths::get_cloud_paths();
        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);

        let creds = Self::load_credentials_from_path(&credentials_path)?;
        Self::validate_with_api(&creds).await?;

        CREDENTIALS
            .set(Some(creds))
            .map_err(|_| CredentialsBootstrapError::AlreadyInitialized)?;
        Ok(CREDENTIALS
            .get()
            .ok_or(CredentialsBootstrapError::NotInitialized)?
            .as_ref())
    }

    async fn validate_with_api(creds: &CloudCredentials) -> Result<()> {
        let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
        client.get_user().await.map_err(|e| {
            anyhow::anyhow!(CredentialsBootstrapError::ApiValidationFailed {
                message: e.to_string()
            })
        })?;
        tracing::debug!("Cloud credentials validated with API");
        Ok(())
    }

    fn is_fly_container() -> bool {
        std::env::var("FLY_APP_NAME").is_ok()
    }

    fn load_from_env() -> Option<CloudCredentials> {
        let api_token = std::env::var("SYSTEMPROMPT_API_TOKEN")
            .ok()
            .filter(|s| !s.is_empty())?;

        let user_email = std::env::var("SYSTEMPROMPT_USER_EMAIL")
            .ok()
            .filter(|s| !s.is_empty())?;

        tracing::debug!("Loading cloud credentials from environment variables");

        Some(CloudCredentials {
            api_token,
            api_url: std::env::var("SYSTEMPROMPT_API_URL")
                .ok()
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| "https://api.systemprompt.io".into()),
            authenticated_at: Utc::now(),
            user_email,
        })
    }

    pub fn get() -> Result<Option<&'static CloudCredentials>, CredentialsBootstrapError> {
        CREDENTIALS
            .get()
            .map(|opt| opt.as_ref())
            .ok_or(CredentialsBootstrapError::NotInitialized)
    }

    pub fn require() -> Result<&'static CloudCredentials, CredentialsBootstrapError> {
        Self::get()?.ok_or(CredentialsBootstrapError::NotAvailable)
    }

    pub fn is_initialized() -> bool {
        CREDENTIALS.get().is_some()
    }

    pub fn init_empty() {
        let _ = CREDENTIALS.set(None);
    }

    pub async fn try_init() -> Result<Option<&'static CloudCredentials>> {
        if CREDENTIALS.get().is_some() {
            return Self::get().map_err(Into::into);
        }
        Self::init().await
    }

    pub fn expires_within(duration: chrono::Duration) -> bool {
        match Self::get() {
            Ok(Some(c)) => c.expires_within(duration),
            Ok(None) => false,
            Err(e) => {
                tracing::debug!(error = %e, "Credentials not available for expiry check");
                false
            },
        }
    }

    pub async fn reload() -> Result<CloudCredentials, CredentialsBootstrapError> {
        let cloud_paths = crate::paths::get_cloud_paths();
        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);

        let creds = Self::load_credentials_from_path(&credentials_path).map_err(|e| {
            CredentialsBootstrapError::InvalidCredentials {
                message: e.to_string(),
            }
        })?;

        Self::validate_with_api(&creds).await.map_err(|e| {
            CredentialsBootstrapError::ApiValidationFailed {
                message: e.to_string(),
            }
        })?;

        Ok(creds)
    }

    fn load_credentials_from_path(path: &Path) -> Result<CloudCredentials> {
        let creds = CloudCredentials::load_from_path(path).map_err(|e| {
            if path.exists() {
                anyhow::anyhow!(CredentialsBootstrapError::InvalidCredentials {
                    message: e.to_string(),
                })
            } else {
                anyhow::anyhow!(CredentialsBootstrapError::FileNotFound {
                    path: path.display().to_string()
                })
            }
        })?;

        if creds.is_token_expired() {
            anyhow::bail!(CredentialsBootstrapError::TokenExpired);
        }

        if creds.expires_within(chrono::Duration::hours(1)) {
            tracing::warn!(
                "Cloud token will expire soon. Consider running 'systemprompt cloud login' to \
                 refresh."
            );
        }

        tracing::debug!(
            "Loaded cloud credentials from {} (user: {:?})",
            path.display(),
            creds.user_email
        );

        Ok(creds)
    }
}

#[cfg(any(test, feature = "test-utils"))]
pub mod test_helpers {
    use chrono::{Duration, Utc};

    use crate::CloudCredentials;

    pub fn valid_credentials() -> CloudCredentials {
        CloudCredentials {
            api_token: "test_token_valid_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9".into(),
            api_url: "https://api.test.systemprompt.io".into(),
            authenticated_at: Utc::now(),
            user_email: "test@example.com".into(),
        }
    }

    pub fn expired_credentials() -> CloudCredentials {
        let mut creds = valid_credentials();
        creds.authenticated_at = Utc::now() - Duration::days(30);
        creds
    }

    pub fn expiring_soon_credentials() -> CloudCredentials {
        let mut creds = valid_credentials();
        creds.authenticated_at = Utc::now() - Duration::hours(23);
        creds
    }

    pub fn minimal_credentials() -> CloudCredentials {
        CloudCredentials {
            api_token: "minimal_test_token".into(),
            api_url: "https://api.systemprompt.io".into(),
            authenticated_at: Utc::now(),
            user_email: "minimal@example.com".into(),
        }
    }
}