systemprompt_cloud/
credentials_bootstrap.rs1use 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 tracing::debug!("Loading cloud credentials from environment variables");
102
103 Some(CloudCredentials {
104 api_token,
105 api_url: std::env::var("SYSTEMPROMPT_API_URL")
106 .ok()
107 .filter(|s| !s.is_empty())
108 .unwrap_or_else(|| "https://api.systemprompt.io".into()),
109 authenticated_at: Utc::now(),
110 user_email: std::env::var("SYSTEMPROMPT_USER_EMAIL")
111 .ok()
112 .filter(|s| !s.is_empty()),
113 })
114 }
115
116 pub fn get() -> Result<Option<&'static CloudCredentials>, CredentialsBootstrapError> {
117 CREDENTIALS
118 .get()
119 .map(|opt| opt.as_ref())
120 .ok_or(CredentialsBootstrapError::NotInitialized)
121 }
122
123 pub fn require() -> Result<&'static CloudCredentials, CredentialsBootstrapError> {
124 Self::get()?.ok_or(CredentialsBootstrapError::NotAvailable)
125 }
126
127 pub fn is_initialized() -> bool {
128 CREDENTIALS.get().is_some()
129 }
130
131 pub async fn try_init() -> Result<Option<&'static CloudCredentials>> {
132 if CREDENTIALS.get().is_some() {
133 return Self::get().map_err(Into::into);
134 }
135 Self::init().await
136 }
137
138 pub fn expires_within(duration: chrono::Duration) -> bool {
139 match Self::get() {
140 Ok(Some(c)) => c.expires_within(duration),
141 Ok(None) => false,
142 Err(e) => {
143 tracing::debug!(error = %e, "Credentials not available for expiry check");
144 false
145 },
146 }
147 }
148
149 pub async fn reload() -> Result<CloudCredentials, CredentialsBootstrapError> {
150 let cloud_paths =
151 crate::paths::get_cloud_paths().map_err(|_| CredentialsBootstrapError::NotAvailable)?;
152 let credentials_path = cloud_paths.resolve(crate::paths::CloudPath::Credentials);
153
154 let creds = Self::load_credentials_from_path(&credentials_path).map_err(|e| {
155 CredentialsBootstrapError::InvalidCredentials {
156 message: e.to_string(),
157 }
158 })?;
159
160 Self::validate_with_api(&creds).await.map_err(|e| {
161 CredentialsBootstrapError::ApiValidationFailed {
162 message: e.to_string(),
163 }
164 })?;
165
166 Ok(creds)
167 }
168
169 fn load_credentials_from_path(path: &Path) -> Result<CloudCredentials> {
170 let creds = CloudCredentials::load_from_path(path).map_err(|e| {
171 if path.exists() {
172 anyhow::anyhow!(CredentialsBootstrapError::InvalidCredentials {
173 message: e.to_string(),
174 })
175 } else {
176 anyhow::anyhow!(CredentialsBootstrapError::FileNotFound {
177 path: path.display().to_string()
178 })
179 }
180 })?;
181
182 if creds.is_token_expired() {
183 anyhow::bail!(CredentialsBootstrapError::TokenExpired);
184 }
185
186 if creds.expires_within(chrono::Duration::hours(1)) {
187 tracing::warn!(
188 "Cloud token will expire soon. Consider running 'systemprompt cloud login' to \
189 refresh."
190 );
191 }
192
193 tracing::debug!(
194 "Loaded cloud credentials from {} (user: {:?})",
195 path.display(),
196 creds.user_email
197 );
198
199 Ok(creds)
200 }
201}
202
203#[cfg(any(test, feature = "test-utils"))]
204pub mod test_helpers {
205 use chrono::{Duration, Utc};
206
207 use crate::CloudCredentials;
208
209 pub fn valid_credentials() -> CloudCredentials {
210 CloudCredentials {
211 api_token: "test_token_valid_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9".into(),
212 api_url: "https://api.test.systemprompt.io".into(),
213 authenticated_at: Utc::now(),
214 user_email: Some("test@example.com".into()),
215 }
216 }
217
218 pub fn expired_credentials() -> CloudCredentials {
219 let mut creds = valid_credentials();
220 creds.authenticated_at = Utc::now() - Duration::days(30);
221 creds
222 }
223
224 pub fn expiring_soon_credentials() -> CloudCredentials {
225 let mut creds = valid_credentials();
226 creds.authenticated_at = Utc::now() - Duration::hours(23);
227 creds
228 }
229
230 pub fn minimal_credentials() -> CloudCredentials {
231 CloudCredentials {
232 api_token: "minimal_test_token".into(),
233 api_url: "https://api.systemprompt.io".into(),
234 authenticated_at: Utc::now(),
235 user_email: None,
236 }
237 }
238}