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