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                    tracing::warn!(
52                        error = %e,
53                        "Cloud credential validation failed on Fly.io, continuing with unvalidated credentials"
54                    );
55                }
56            }
57            CREDENTIALS
58                .set(creds)
59                .map_err(|_| CredentialsBootstrapError::AlreadyInitialized)?;
60            return Ok(CREDENTIALS
61                .get()
62                .ok_or(CredentialsBootstrapError::NotInitialized)?
63                .as_ref());
64        }
65
66        let cloud_paths = crate::paths::get_cloud_paths()?;
67        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
68
69        let creds = Self::load_credentials_from_path(&credentials_path)?;
70        Self::validate_with_api(&creds).await?;
71
72        CREDENTIALS
73            .set(Some(creds))
74            .map_err(|_| CredentialsBootstrapError::AlreadyInitialized)?;
75        Ok(CREDENTIALS
76            .get()
77            .ok_or(CredentialsBootstrapError::NotInitialized)?
78            .as_ref())
79    }
80
81    async fn validate_with_api(creds: &CloudCredentials) -> Result<()> {
82        let client = CloudApiClient::new(&creds.api_url, &creds.api_token);
83        client.get_user().await.map_err(|e| {
84            anyhow::anyhow!(CredentialsBootstrapError::ApiValidationFailed {
85                message: e.to_string()
86            })
87        })?;
88        tracing::debug!("Cloud credentials validated with API");
89        Ok(())
90    }
91
92    fn is_fly_container() -> bool {
93        std::env::var("FLY_APP_NAME").is_ok()
94    }
95
96    fn load_from_env() -> Option<CloudCredentials> {
97        let api_token = std::env::var("SYSTEMPROMPT_API_TOKEN")
98            .ok()
99            .filter(|s| !s.is_empty())?;
100
101        let user_email = std::env::var("SYSTEMPROMPT_USER_EMAIL")
102            .ok()
103            .filter(|s| !s.is_empty())?;
104
105        tracing::debug!("Loading cloud credentials from environment variables");
106
107        Some(CloudCredentials {
108            api_token,
109            api_url: std::env::var("SYSTEMPROMPT_API_URL")
110                .ok()
111                .filter(|s| !s.is_empty())
112                .unwrap_or_else(|| "https://api.systemprompt.io".into()),
113            authenticated_at: Utc::now(),
114            user_email,
115        })
116    }
117
118    pub fn get() -> Result<Option<&'static CloudCredentials>, CredentialsBootstrapError> {
119        CREDENTIALS
120            .get()
121            .map(|opt| opt.as_ref())
122            .ok_or(CredentialsBootstrapError::NotInitialized)
123    }
124
125    pub fn require() -> Result<&'static CloudCredentials, CredentialsBootstrapError> {
126        Self::get()?.ok_or(CredentialsBootstrapError::NotAvailable)
127    }
128
129    pub fn is_initialized() -> bool {
130        CREDENTIALS.get().is_some()
131    }
132
133    pub async fn try_init() -> Result<Option<&'static CloudCredentials>> {
134        if CREDENTIALS.get().is_some() {
135            return Self::get().map_err(Into::into);
136        }
137        Self::init().await
138    }
139
140    pub fn expires_within(duration: chrono::Duration) -> bool {
141        match Self::get() {
142            Ok(Some(c)) => c.expires_within(duration),
143            Ok(None) => false,
144            Err(e) => {
145                tracing::debug!(error = %e, "Credentials not available for expiry check");
146                false
147            },
148        }
149    }
150
151    pub async fn reload() -> Result<CloudCredentials, CredentialsBootstrapError> {
152        let cloud_paths =
153            crate::paths::get_cloud_paths().map_err(|_| CredentialsBootstrapError::NotAvailable)?;
154        let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
155
156        let creds = Self::load_credentials_from_path(&credentials_path).map_err(|e| {
157            CredentialsBootstrapError::InvalidCredentials {
158                message: e.to_string(),
159            }
160        })?;
161
162        Self::validate_with_api(&creds).await.map_err(|e| {
163            CredentialsBootstrapError::ApiValidationFailed {
164                message: e.to_string(),
165            }
166        })?;
167
168        Ok(creds)
169    }
170
171    fn load_credentials_from_path(path: &Path) -> Result<CloudCredentials> {
172        let creds = CloudCredentials::load_from_path(path).map_err(|e| {
173            if path.exists() {
174                anyhow::anyhow!(CredentialsBootstrapError::InvalidCredentials {
175                    message: e.to_string(),
176                })
177            } else {
178                anyhow::anyhow!(CredentialsBootstrapError::FileNotFound {
179                    path: path.display().to_string()
180                })
181            }
182        })?;
183
184        if creds.is_token_expired() {
185            anyhow::bail!(CredentialsBootstrapError::TokenExpired);
186        }
187
188        if creds.expires_within(chrono::Duration::hours(1)) {
189            tracing::warn!(
190                "Cloud token will expire soon. Consider running 'systemprompt cloud login' to \
191                 refresh."
192            );
193        }
194
195        tracing::debug!(
196            "Loaded cloud credentials from {} (user: {:?})",
197            path.display(),
198            creds.user_email
199        );
200
201        Ok(creds)
202    }
203}
204
205#[cfg(any(test, feature = "test-utils"))]
206pub mod test_helpers {
207    use chrono::{Duration, Utc};
208
209    use crate::CloudCredentials;
210
211    pub fn valid_credentials() -> CloudCredentials {
212        CloudCredentials {
213            api_token: "test_token_valid_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9".into(),
214            api_url: "https://api.test.systemprompt.io".into(),
215            authenticated_at: Utc::now(),
216            user_email: "test@example.com".into(),
217        }
218    }
219
220    pub fn expired_credentials() -> CloudCredentials {
221        let mut creds = valid_credentials();
222        creds.authenticated_at = Utc::now() - Duration::days(30);
223        creds
224    }
225
226    pub fn expiring_soon_credentials() -> CloudCredentials {
227        let mut creds = valid_credentials();
228        creds.authenticated_at = Utc::now() - Duration::hours(23);
229        creds
230    }
231
232    pub fn minimal_credentials() -> CloudCredentials {
233        CloudCredentials {
234            api_token: "minimal_test_token".into(),
235            api_url: "https://api.systemprompt.io".into(),
236            authenticated_at: Utc::now(),
237            user_email: "minimal@example.com".into(),
238        }
239    }
240}