systemprompt_cloud/credentials_bootstrap/
mod.rs1mod 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}