1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5use std::sync::OnceLock;
6
7use crate::paths::constants::env_vars;
8use crate::profile::{resolve_with_home, SecretsSource, SecretsValidationMode};
9use crate::profile_bootstrap::ProfileBootstrap;
10
11static SECRETS: OnceLock<Secrets> = OnceLock::new();
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Secrets {
15 pub jwt_secret: String,
16
17 pub database_url: String,
18
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub database_write_url: Option<String>,
21
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub external_database_url: Option<String>,
24
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub internal_database_url: Option<String>,
27
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub sync_token: Option<String>,
30
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub gemini: Option<String>,
33
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub anthropic: Option<String>,
36
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub openai: Option<String>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub github: Option<String>,
42
43 #[serde(default, flatten)]
44 pub custom: HashMap<String, String>,
45}
46
47const JWT_SECRET_MIN_LENGTH: usize = 32;
48
49impl Secrets {
50 pub fn parse(content: &str) -> Result<Self> {
51 let secrets: Self =
52 serde_json::from_str(content).context("Failed to parse secrets JSON")?;
53 secrets.validate()?;
54 Ok(secrets)
55 }
56
57 pub fn load_from_path(secrets_path: &Path) -> Result<Self> {
58 if !secrets_path.exists() {
59 anyhow::bail!("Secrets file not found: {}", secrets_path.display());
60 }
61 let content = std::fs::read_to_string(secrets_path)
62 .with_context(|| format!("Failed to read secrets: {}", secrets_path.display()))?;
63 Self::parse(&content)
64 }
65
66 fn validate(&self) -> Result<()> {
67 if self.jwt_secret.len() < JWT_SECRET_MIN_LENGTH {
68 anyhow::bail!(
69 "jwt_secret must be at least {} characters (got {})",
70 JWT_SECRET_MIN_LENGTH,
71 self.jwt_secret.len()
72 );
73 }
74 Ok(())
75 }
76
77 pub fn effective_database_url(&self, external_db_access: bool) -> &str {
78 if external_db_access {
79 if let Some(url) = &self.external_database_url {
80 return url;
81 }
82 }
83 &self.database_url
84 }
85
86 pub const fn has_ai_provider(&self) -> bool {
87 self.gemini.is_some() || self.anthropic.is_some() || self.openai.is_some()
88 }
89
90 pub fn get(&self, key: &str) -> Option<&String> {
91 match key {
92 "jwt_secret" | "JWT_SECRET" => Some(&self.jwt_secret),
93 "database_url" | "DATABASE_URL" => Some(&self.database_url),
94 "database_write_url" | "DATABASE_WRITE_URL" => self.database_write_url.as_ref(),
95 "external_database_url" | "EXTERNAL_DATABASE_URL" => {
96 self.external_database_url.as_ref()
97 },
98 "internal_database_url" | "INTERNAL_DATABASE_URL" => {
99 self.internal_database_url.as_ref()
100 },
101 "sync_token" | "SYNC_TOKEN" => self.sync_token.as_ref(),
102 "gemini" | "GEMINI_API_KEY" => self.gemini.as_ref(),
103 "anthropic" | "ANTHROPIC_API_KEY" => self.anthropic.as_ref(),
104 "openai" | "OPENAI_API_KEY" => self.openai.as_ref(),
105 "github" | "GITHUB_TOKEN" => self.github.as_ref(),
106 other => self.custom.get(other).or_else(|| {
107 let alternate = if other.chars().any(char::is_uppercase) {
108 other.to_lowercase()
109 } else {
110 other.to_uppercase()
111 };
112 self.custom.get(&alternate)
113 }),
114 }
115 }
116
117 pub fn log_configured_providers(&self) {
118 let configured: Vec<&str> = [
119 self.gemini.as_ref().map(|_| "gemini"),
120 self.anthropic.as_ref().map(|_| "anthropic"),
121 self.openai.as_ref().map(|_| "openai"),
122 self.github.as_ref().map(|_| "github"),
123 ]
124 .into_iter()
125 .flatten()
126 .collect();
127
128 tracing::info!(providers = ?configured, "Configured API providers");
129 }
130
131 pub fn custom_env_vars(&self) -> Vec<(String, &str)> {
132 self.custom
133 .iter()
134 .flat_map(|(key, value)| {
135 let upper_key = key.to_uppercase();
136 let value_str = value.as_str();
137 if upper_key == *key {
138 vec![(key.clone(), value_str)]
139 } else {
140 vec![(key.clone(), value_str), (upper_key, value_str)]
141 }
142 })
143 .collect()
144 }
145
146 pub fn custom_env_var_names(&self) -> Vec<String> {
147 self.custom.keys().map(|key| key.to_uppercase()).collect()
148 }
149}
150
151#[derive(Debug, Clone, Copy)]
152pub struct SecretsBootstrap;
153
154#[derive(Debug, thiserror::Error)]
155pub enum SecretsBootstrapError {
156 #[error(
157 "Secrets not initialized. Call SecretsBootstrap::init() after ProfileBootstrap::init()"
158 )]
159 NotInitialized,
160
161 #[error("Secrets already initialized")]
162 AlreadyInitialized,
163
164 #[error("Profile not initialized. Call ProfileBootstrap::init() first")]
165 ProfileNotInitialized,
166
167 #[error("Secrets file not found: {path}")]
168 FileNotFound { path: String },
169
170 #[error("Invalid secrets file: {message}")]
171 InvalidSecretsFile { message: String },
172
173 #[error("No secrets configured. Create a secrets.json file.")]
174 NoSecretsConfigured,
175
176 #[error(
177 "JWT secret is required. Add 'jwt_secret' to your secrets file or set JWT_SECRET \
178 environment variable."
179 )]
180 JwtSecretRequired,
181
182 #[error(
183 "Database URL is required. Add 'database_url' to your secrets.json or set DATABASE_URL \
184 environment variable."
185 )]
186 DatabaseUrlRequired,
187}
188
189impl SecretsBootstrap {
190 pub fn init() -> Result<&'static Secrets> {
191 if SECRETS.get().is_some() {
192 anyhow::bail!(SecretsBootstrapError::AlreadyInitialized);
193 }
194
195 let secrets = Self::load_from_profile_config()?;
196
197 Self::log_loaded_secrets(&secrets);
198
199 SECRETS
200 .set(secrets)
201 .map_err(|_| anyhow::anyhow!(SecretsBootstrapError::AlreadyInitialized))?;
202
203 SECRETS
204 .get()
205 .ok_or_else(|| anyhow::anyhow!(SecretsBootstrapError::NotInitialized))
206 }
207
208 pub fn jwt_secret() -> Result<&'static str, SecretsBootstrapError> {
209 Ok(&Self::get()?.jwt_secret)
210 }
211
212 pub fn database_url() -> Result<&'static str, SecretsBootstrapError> {
213 Ok(&Self::get()?.database_url)
214 }
215
216 pub fn database_write_url() -> Result<Option<&'static str>, SecretsBootstrapError> {
217 Ok(Self::get()?.database_write_url.as_deref())
218 }
219
220 fn load_from_env() -> Result<Secrets> {
221 let jwt_secret = std::env::var("JWT_SECRET")
222 .ok()
223 .filter(|s| !s.is_empty())
224 .ok_or(SecretsBootstrapError::JwtSecretRequired)?;
225
226 let database_url = std::env::var("DATABASE_URL")
227 .ok()
228 .filter(|s| !s.is_empty())
229 .ok_or(SecretsBootstrapError::DatabaseUrlRequired)?;
230
231 let custom = std::env::var(env_vars::CUSTOM_SECRETS)
232 .ok()
233 .filter(|s| !s.is_empty())
234 .map_or_else(HashMap::new, |keys| {
235 keys.split(',')
236 .filter_map(|key| {
237 let key = key.trim();
238 std::env::var(key)
239 .ok()
240 .filter(|v| !v.is_empty())
241 .map(|v| (key.to_owned(), v))
242 })
243 .collect()
244 });
245
246 let secrets = Secrets {
247 jwt_secret,
248 database_url,
249 database_write_url: std::env::var("DATABASE_WRITE_URL")
250 .ok()
251 .filter(|s| !s.is_empty()),
252 external_database_url: std::env::var("EXTERNAL_DATABASE_URL")
253 .ok()
254 .filter(|s| !s.is_empty()),
255 internal_database_url: std::env::var("INTERNAL_DATABASE_URL")
256 .ok()
257 .filter(|s| !s.is_empty()),
258 sync_token: std::env::var("SYNC_TOKEN").ok().filter(|s| !s.is_empty()),
259 gemini: std::env::var("GEMINI_API_KEY")
260 .ok()
261 .filter(|s| !s.is_empty()),
262 anthropic: std::env::var("ANTHROPIC_API_KEY")
263 .ok()
264 .filter(|s| !s.is_empty()),
265 openai: std::env::var("OPENAI_API_KEY")
266 .ok()
267 .filter(|s| !s.is_empty()),
268 github: std::env::var("GITHUB_TOKEN").ok().filter(|s| !s.is_empty()),
269 custom,
270 };
271
272 secrets.validate()?;
273 Ok(secrets)
274 }
275
276 fn load_from_profile_config() -> Result<Secrets> {
277 let is_fly_environment = std::env::var("FLY_APP_NAME").is_ok();
278 let is_subprocess = std::env::var("SYSTEMPROMPT_SUBPROCESS").is_ok();
279
280 if is_subprocess || is_fly_environment {
281 if let Ok(jwt_secret) = std::env::var("JWT_SECRET") {
282 if jwt_secret.len() >= JWT_SECRET_MIN_LENGTH {
283 tracing::debug!(
284 "Using JWT_SECRET from environment (subprocess/container mode)"
285 );
286 return Self::load_from_env();
287 }
288 }
289 }
290
291 let profile =
292 ProfileBootstrap::get().map_err(|_| SecretsBootstrapError::ProfileNotInitialized)?;
293
294 let secrets_config = profile
295 .secrets
296 .as_ref()
297 .ok_or(SecretsBootstrapError::NoSecretsConfigured)?;
298
299 let is_fly_environment = std::env::var("FLY_APP_NAME").is_ok();
300
301 match secrets_config.source {
302 SecretsSource::Env if is_fly_environment => {
303 tracing::debug!("Loading secrets from environment (Fly.io container)");
304 Self::load_from_env()
305 },
306 SecretsSource::Env => {
307 tracing::debug!(
308 "Profile source is 'env' but running locally, trying file first..."
309 );
310 Self::resolve_and_load_file(&secrets_config.secrets_path).or_else(|_| {
311 tracing::debug!("File load failed, falling back to environment");
312 Self::load_from_env()
313 })
314 },
315 SecretsSource::File => {
316 tracing::debug!("Loading secrets from file (profile source: file)");
317 Self::resolve_and_load_file(&secrets_config.secrets_path)
318 .or_else(|e| Self::handle_load_error(e, secrets_config.validation))
319 },
320 }
321 }
322
323 fn handle_load_error(e: anyhow::Error, mode: SecretsValidationMode) -> Result<Secrets> {
324 log_secrets_issue(&e, mode);
325 Err(e)
326 }
327
328 pub fn get() -> Result<&'static Secrets, SecretsBootstrapError> {
329 SECRETS.get().ok_or(SecretsBootstrapError::NotInitialized)
330 }
331
332 pub fn require() -> Result<&'static Secrets, SecretsBootstrapError> {
333 Self::get()
334 }
335
336 pub fn is_initialized() -> bool {
337 SECRETS.get().is_some()
338 }
339
340 pub fn try_init() -> Result<&'static Secrets> {
341 if SECRETS.get().is_some() {
342 return Self::get().map_err(Into::into);
343 }
344 Self::init()
345 }
346
347 fn resolve_and_load_file(path_str: &str) -> Result<Secrets> {
348 let profile_path = ProfileBootstrap::get_path()
349 .context("SYSTEMPROMPT_PROFILE not set - cannot resolve secrets path")?;
350
351 let profile_dir = Path::new(profile_path)
352 .parent()
353 .context("Invalid profile path - no parent directory")?;
354
355 let resolved_path = resolve_with_home(profile_dir, path_str);
356 Self::load_from_file(&resolved_path)
357 }
358
359 fn load_from_file(path: &Path) -> Result<Secrets> {
360 if !path.exists() {
361 anyhow::bail!(SecretsBootstrapError::FileNotFound {
362 path: path.display().to_string()
363 });
364 }
365
366 let content = std::fs::read_to_string(path)
367 .with_context(|| format!("Failed to read secrets file: {}", path.display()))?;
368
369 let secrets = Secrets::parse(&content).map_err(|e| {
370 anyhow::anyhow!(SecretsBootstrapError::InvalidSecretsFile {
371 message: e.to_string(),
372 })
373 })?;
374
375 tracing::debug!("Loaded secrets from {}", path.display());
376
377 Ok(secrets)
378 }
379
380 fn log_loaded_secrets(secrets: &Secrets) {
381 let message = build_loaded_secrets_message(secrets);
382 tracing::debug!("{}", message);
383 }
384}
385
386fn log_secrets_issue(e: &anyhow::Error, mode: SecretsValidationMode) {
387 match mode {
388 SecretsValidationMode::Warn => log_secrets_warn(e),
389 SecretsValidationMode::Skip => log_secrets_skip(e),
390 SecretsValidationMode::Strict => {},
391 }
392}
393
394fn log_secrets_warn(e: &anyhow::Error) {
395 tracing::warn!("Secrets file issue: {}", e);
396}
397
398fn log_secrets_skip(e: &anyhow::Error) {
399 tracing::debug!("Skipping secrets file: {}", e);
400}
401
402fn build_loaded_secrets_message(secrets: &Secrets) -> String {
403 let base = ["jwt_secret", "database_url"];
404 let optional_providers = [
405 secrets
406 .database_write_url
407 .as_ref()
408 .map(|_| "database_write_url"),
409 secrets
410 .external_database_url
411 .as_ref()
412 .map(|_| "external_database_url"),
413 secrets
414 .internal_database_url
415 .as_ref()
416 .map(|_| "internal_database_url"),
417 secrets.gemini.as_ref().map(|_| "gemini"),
418 secrets.anthropic.as_ref().map(|_| "anthropic"),
419 secrets.openai.as_ref().map(|_| "openai"),
420 secrets.github.as_ref().map(|_| "github"),
421 ];
422
423 let loaded: Vec<&str> = base
424 .into_iter()
425 .chain(optional_providers.into_iter().flatten())
426 .collect();
427
428 if secrets.custom.is_empty() {
429 format!("Loaded secrets: {}", loaded.join(", "))
430 } else {
431 format!(
432 "Loaded secrets: {}, {} custom",
433 loaded.join(", "),
434 secrets.custom.len()
435 )
436 }
437}