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