Skip to main content

systemprompt_cloud/
credentials_bootstrap.rs

1use anyhow::Result;
2use chrono::Utc;
3use std::path::Path;
4use std::sync::OnceLock;
5
6use crate::{CloudApiClient, CloudCredentials};
7
8static CREDENTIALS: OnceLock<Option<CloudCredentials>> = OnceLock::new();
9
10#[derive(Debug, Clone, Copy)]
11pub struct CredentialsBootstrap;
12
13#[derive(Debug, thiserror::Error)]
14pub enum CredentialsBootstrapError {
15    #[error(
16        "Credentials not initialized. Call CredentialsBootstrap::init() after \
17         ProfileBootstrap::init()"
18    )]
19    NotInitialized,
20
21    #[error("Credentials already initialized")]
22    AlreadyInitialized,
23
24    #[error("Cloud credentials not available")]
25    NotAvailable,
26
27    #[error("Cloud credentials file not found: {path}")]
28    FileNotFound { path: String },
29
30    #[error("Cloud credentials file invalid: {message}")]
31    InvalidCredentials { message: String },
32
33    #[error("Cloud token has expired. Run 'systemprompt cloud login' to refresh")]
34    TokenExpired,
35
36    #[error("Cloud API validation failed: {message}")]
37    ApiValidationFailed { message: String },
38}
39
40impl CredentialsBootstrap {
41    pub async fn init() -> Result<Option<&'static CloudCredentials>> {
42        if CREDENTIALS.get().is_some() {
43            anyhow::bail!(CredentialsBootstrapError::AlreadyInitialized);
44        }
45
46        if Self::is_fly_container() {
47            tracing::debug!("Fly.io container detected, loading credentials from environment");
48            let creds = Self::load_from_env();
49            if let Some(ref c) = creds {
50                if let Err(e) = Self::validate_with_api(c).await {
51                    if Self::allow_unvalidated() {
52                        tracing::warn!(
53                            error = %e,
54                            "cloud credentials unvalidated; proceeding under SYSTEMPROMPT_ALLOW_UNVALIDATED_CREDS=1"
55                        );
56                    } else {
57                        return Err(anyhow::anyhow!(
58                            CredentialsBootstrapError::ApiValidationFailed {
59                                message: e.to_string()
60                            }
61                        ));
62                    }
63                }
64            }
65            CREDENTIALS
66                .set(creds)
67                .map_err(|_| CredentialsBootstrapError::AlreadyInitialized)?;
68            return Ok(CREDENTIALS
69                .get()
70                .ok_or(CredentialsBootstrapError::NotInitialized)?
71                .as_ref());
72        }
73
74        let cloud_paths = crate::paths::get_cloud_paths();
75        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
76
77        let creds = Self::load_credentials_from_path(&credentials_path)?;
78        Self::validate_with_api(&creds).await?;
79
80        CREDENTIALS
81            .set(Some(creds))
82            .map_err(|_| CredentialsBootstrapError::AlreadyInitialized)?;
83        Ok(CREDENTIALS
84            .get()
85            .ok_or(CredentialsBootstrapError::NotInitialized)?
86            .as_ref())
87    }
88
89    async fn validate_with_api(creds: &CloudCredentials) -> Result<()> {
90        let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
91        client.get_user().await.map_err(|e| {
92            anyhow::anyhow!(CredentialsBootstrapError::ApiValidationFailed {
93                message: e.to_string()
94            })
95        })?;
96        tracing::debug!("Cloud credentials validated with API");
97        Ok(())
98    }
99
100    fn is_fly_container() -> bool {
101        std::env::var("FLY_APP_NAME").is_ok()
102    }
103
104    fn allow_unvalidated() -> bool {
105        std::env::var("SYSTEMPROMPT_ALLOW_UNVALIDATED_CREDS").as_deref() == Ok("1")
106    }
107
108    fn load_from_env() -> Option<CloudCredentials> {
109        let api_token = std::env::var("SYSTEMPROMPT_API_TOKEN")
110            .ok()
111            .filter(|s| !s.is_empty())?;
112
113        let user_email = std::env::var("SYSTEMPROMPT_USER_EMAIL")
114            .ok()
115            .filter(|s| !s.is_empty())?;
116
117        tracing::debug!("Loading cloud credentials from environment variables");
118
119        Some(CloudCredentials {
120            api_token,
121            api_url: std::env::var("SYSTEMPROMPT_API_URL")
122                .ok()
123                .filter(|s| !s.is_empty())
124                .unwrap_or_else(|| crate::constants::api::PRODUCTION_URL.into()),
125            authenticated_at: Utc::now(),
126            user_email,
127        })
128    }
129
130    pub fn get() -> Result<Option<&'static CloudCredentials>, CredentialsBootstrapError> {
131        CREDENTIALS
132            .get()
133            .map(|opt| opt.as_ref())
134            .ok_or(CredentialsBootstrapError::NotInitialized)
135    }
136
137    pub fn require() -> Result<&'static CloudCredentials, CredentialsBootstrapError> {
138        Self::get()?.ok_or(CredentialsBootstrapError::NotAvailable)
139    }
140
141    pub fn is_initialized() -> bool {
142        CREDENTIALS.get().is_some()
143    }
144
145    pub fn init_empty() {
146        let _ = CREDENTIALS.set(None);
147    }
148
149    pub async fn try_init() -> Result<Option<&'static CloudCredentials>> {
150        if CREDENTIALS.get().is_some() {
151            return Self::get().map_err(Into::into);
152        }
153        Self::init().await
154    }
155
156    pub fn expires_within(duration: chrono::Duration) -> bool {
157        match Self::get() {
158            Ok(Some(c)) => c.expires_within(duration),
159            Ok(None) => false,
160            Err(e) => {
161                tracing::debug!(error = %e, "Credentials not available for expiry check");
162                false
163            },
164        }
165    }
166
167    pub async fn reload() -> Result<CloudCredentials, CredentialsBootstrapError> {
168        let cloud_paths = crate::paths::get_cloud_paths();
169        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
170
171        let creds = Self::load_credentials_from_path(&credentials_path).map_err(|e| {
172            CredentialsBootstrapError::InvalidCredentials {
173                message: e.to_string(),
174            }
175        })?;
176
177        Self::validate_with_api(&creds).await.map_err(|e| {
178            CredentialsBootstrapError::ApiValidationFailed {
179                message: e.to_string(),
180            }
181        })?;
182
183        Ok(creds)
184    }
185
186    fn load_credentials_from_path(path: &Path) -> Result<CloudCredentials> {
187        let creds = CloudCredentials::load_from_path(path).map_err(|e| {
188            if path.exists() {
189                anyhow::anyhow!(CredentialsBootstrapError::InvalidCredentials {
190                    message: e.to_string(),
191                })
192            } else {
193                anyhow::anyhow!(CredentialsBootstrapError::FileNotFound {
194                    path: path.display().to_string()
195                })
196            }
197        })?;
198
199        if creds.is_token_expired() {
200            anyhow::bail!(CredentialsBootstrapError::TokenExpired);
201        }
202
203        if creds.expires_within(chrono::Duration::hours(1)) {
204            tracing::warn!(
205                "Cloud token will expire soon. Consider running 'systemprompt cloud login' to \
206                 refresh."
207            );
208        }
209
210        tracing::debug!(
211            "Loaded cloud credentials from {} (user: {:?})",
212            path.display(),
213            creds.user_email
214        );
215
216        Ok(creds)
217    }
218}