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