Skip to main content

systemprompt_models/
secrets.rs

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}