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