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::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}