Skip to main content

systemprompt_models/
secrets.rs

1//! `secrets` module — see crate-level docs for context.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use crate::errors::SecretsError;
7
8pub const JWT_SECRET_MIN_LENGTH: usize = 32;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Secrets {
12    pub jwt_secret: String,
13
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub manifest_signing_secret_seed: Option<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, skip_serializing_if = "Option::is_none")]
44    pub moonshot: Option<String>,
45
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub qwen: Option<String>,
48
49    #[serde(default, flatten)]
50    pub custom: HashMap<String, String>,
51}
52
53impl Secrets {
54    pub fn parse(content: &str) -> Result<Self, SecretsError> {
55        let mut value: serde_json::Value =
56            serde_json::from_str(content).map_err(|source| SecretsError::Parse {
57                context: "Failed to parse secrets JSON",
58                source,
59            })?;
60        if let Some(obj) = value.as_object_mut() {
61            obj.retain(|_, v| !v.is_null());
62        }
63        let secrets: Self =
64            serde_json::from_value(value).map_err(|source| SecretsError::Parse {
65                context: "Failed to deserialize secrets after null stripping",
66                source,
67            })?;
68        secrets.validate()?;
69        Ok(secrets)
70    }
71
72    pub fn validate(&self) -> Result<(), SecretsError> {
73        if self.jwt_secret.len() < JWT_SECRET_MIN_LENGTH {
74            return Err(SecretsError::Invalid(format!(
75                "jwt_secret must be at least {} characters (got {})",
76                JWT_SECRET_MIN_LENGTH,
77                self.jwt_secret.len()
78            )));
79        }
80        Ok(())
81    }
82
83    pub fn effective_database_url(&self, external_db_access: bool) -> &str {
84        if external_db_access {
85            if let Some(url) = &self.external_database_url {
86                return url;
87            }
88        }
89        &self.database_url
90    }
91
92    pub const fn has_ai_provider(&self) -> bool {
93        self.gemini.is_some()
94            || self.anthropic.is_some()
95            || self.openai.is_some()
96            || self.moonshot.is_some()
97            || self.qwen.is_some()
98    }
99
100    pub fn get(&self, key: &str) -> Option<&String> {
101        match key {
102            "jwt_secret" | "JWT_SECRET" => Some(&self.jwt_secret),
103            "database_url" | "DATABASE_URL" => Some(&self.database_url),
104            "database_write_url" | "DATABASE_WRITE_URL" => self.database_write_url.as_ref(),
105            "external_database_url" | "EXTERNAL_DATABASE_URL" => {
106                self.external_database_url.as_ref()
107            },
108            "internal_database_url" | "INTERNAL_DATABASE_URL" => {
109                self.internal_database_url.as_ref()
110            },
111            "sync_token" | "SYNC_TOKEN" => self.sync_token.as_ref(),
112            "gemini" | "GEMINI_API_KEY" => self.gemini.as_ref(),
113            "anthropic" | "ANTHROPIC_API_KEY" => self.anthropic.as_ref(),
114            "openai" | "OPENAI_API_KEY" => self.openai.as_ref(),
115            "github" | "GITHUB_TOKEN" => self.github.as_ref(),
116            "moonshot" | "MOONSHOT_API_KEY" | "kimi" | "KIMI_API_KEY" => self.moonshot.as_ref(),
117            "qwen" | "QWEN_API_KEY" | "dashscope" | "DASHSCOPE_API_KEY" => self.qwen.as_ref(),
118            other => self.custom.get(other).or_else(|| {
119                let alternate = if other.chars().any(char::is_uppercase) {
120                    other.to_lowercase()
121                } else {
122                    other.to_uppercase()
123                };
124                self.custom.get(&alternate)
125            }),
126        }
127    }
128
129    pub fn log_configured_providers(&self) {
130        let configured: Vec<&str> = [
131            self.gemini.as_ref().map(|_| "gemini"),
132            self.anthropic.as_ref().map(|_| "anthropic"),
133            self.openai.as_ref().map(|_| "openai"),
134            self.github.as_ref().map(|_| "github"),
135            self.moonshot.as_ref().map(|_| "moonshot"),
136            self.qwen.as_ref().map(|_| "qwen"),
137        ]
138        .into_iter()
139        .flatten()
140        .collect();
141
142        tracing::info!(providers = ?configured, "Configured API providers");
143    }
144
145    pub fn custom_env_vars(&self) -> Vec<(String, &str)> {
146        self.custom
147            .iter()
148            .flat_map(|(key, value)| {
149                let upper_key = key.to_uppercase();
150                let value_str = value.as_str();
151                if upper_key == *key {
152                    vec![(key.clone(), value_str)]
153                } else {
154                    vec![(key.clone(), value_str), (upper_key, value_str)]
155                }
156            })
157            .collect()
158    }
159
160    pub fn custom_env_var_names(&self) -> Vec<String> {
161        self.custom.keys().map(|key| key.to_uppercase()).collect()
162    }
163}