Skip to main content

systemprompt_cloud/credentials_bootstrap/
mod.rs

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