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 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}