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