Skip to main content

systemprompt_models/
secrets.rs

1//! Secrets document model.
2//!
3//! [`Secrets`] is the deserialized on-disk secrets file: OAuth at-rest
4//! pepper, database URLs, and provider credentials.
5//! [`OAUTH_AT_REST_PEPPER_MIN_LENGTH`] is the enforced minimum.
6//! Validation returns [`crate::errors::SecretsError`].
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11use crate::errors::SecretsError;
12
13pub const OAUTH_AT_REST_PEPPER_MIN_LENGTH: usize = 32;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Secrets {
17    pub oauth_at_rest_pepper: String,
18
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub manifest_signing_secret_seed: Option<String>,
21
22    pub database_url: String,
23
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub database_write_url: Option<String>,
26
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub external_database_url: Option<String>,
29
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub internal_database_url: Option<String>,
32
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub gemini: Option<String>,
35
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub anthropic: Option<String>,
38
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub openai: Option<String>,
41
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub github: Option<String>,
44
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub moonshot: Option<String>,
47
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub qwen: Option<String>,
50
51    #[serde(default, flatten)]
52    pub custom: HashMap<String, String>,
53}
54
55impl Secrets {
56    pub fn parse(content: &str) -> Result<Self, SecretsError> {
57        let mut value: serde_json::Value =
58            serde_json::from_str(content).map_err(|source| SecretsError::Parse {
59                context: "Failed to parse secrets JSON",
60                source,
61            })?;
62        if let Some(obj) = value.as_object_mut() {
63            obj.retain(|_, v| !v.is_null());
64        }
65        let secrets: Self =
66            serde_json::from_value(value).map_err(|source| SecretsError::Parse {
67                context: "Failed to deserialize secrets after null stripping",
68                source,
69            })?;
70        secrets.validate()?;
71        Ok(secrets)
72    }
73
74    pub fn validate(&self) -> Result<(), SecretsError> {
75        if self.oauth_at_rest_pepper.len() < OAUTH_AT_REST_PEPPER_MIN_LENGTH {
76            return Err(SecretsError::Invalid(format!(
77                "oauth_at_rest_pepper must be at least {} characters (got {})",
78                OAUTH_AT_REST_PEPPER_MIN_LENGTH,
79                self.oauth_at_rest_pepper.len()
80            )));
81        }
82        Ok(())
83    }
84
85    pub fn effective_database_url(&self, external_db_access: bool) -> &str {
86        if external_db_access {
87            if let Some(url) = &self.external_database_url {
88                return url;
89            }
90        }
91        &self.database_url
92    }
93
94    pub const fn has_ai_provider(&self) -> bool {
95        self.gemini.is_some()
96            || self.anthropic.is_some()
97            || self.openai.is_some()
98            || self.moonshot.is_some()
99            || self.qwen.is_some()
100    }
101
102    pub fn get(&self, key: &str) -> Option<&String> {
103        match key {
104            "oauth_at_rest_pepper" | "OAUTH_AT_REST_PEPPER" => Some(&self.oauth_at_rest_pepper),
105            "database_url" | "DATABASE_URL" => Some(&self.database_url),
106            "database_write_url" | "DATABASE_WRITE_URL" => self.database_write_url.as_ref(),
107            "external_database_url" | "EXTERNAL_DATABASE_URL" => {
108                self.external_database_url.as_ref()
109            },
110            "internal_database_url" | "INTERNAL_DATABASE_URL" => {
111                self.internal_database_url.as_ref()
112            },
113            "gemini" | "GEMINI_API_KEY" => self.gemini.as_ref(),
114            "anthropic" | "ANTHROPIC_API_KEY" => self.anthropic.as_ref(),
115            "openai" | "OPENAI_API_KEY" => self.openai.as_ref(),
116            "github" | "GITHUB_TOKEN" => self.github.as_ref(),
117            "moonshot" | "MOONSHOT_API_KEY" | "kimi" | "KIMI_API_KEY" => self.moonshot.as_ref(),
118            "qwen" | "QWEN_API_KEY" | "dashscope" | "DASHSCOPE_API_KEY" => self.qwen.as_ref(),
119            other => self.custom.get(other).or_else(|| {
120                let alternate = if other.chars().any(char::is_uppercase) {
121                    other.to_lowercase()
122                } else {
123                    other.to_uppercase()
124                };
125                self.custom.get(&alternate)
126            }),
127        }
128    }
129
130    pub fn log_configured_providers(&self) {
131        let configured: Vec<&str> = [
132            self.gemini.as_ref().map(|_| "gemini"),
133            self.anthropic.as_ref().map(|_| "anthropic"),
134            self.openai.as_ref().map(|_| "openai"),
135            self.github.as_ref().map(|_| "github"),
136            self.moonshot.as_ref().map(|_| "moonshot"),
137            self.qwen.as_ref().map(|_| "qwen"),
138        ]
139        .into_iter()
140        .flatten()
141        .collect();
142
143        tracing::info!(providers = ?configured, "Configured API providers");
144    }
145
146    pub fn to_subprocess_env(&self) -> Vec<(String, String)> {
147        let mut pairs: Vec<(String, String)> = Vec::new();
148
149        pairs.push((
150            "OAUTH_AT_REST_PEPPER".to_owned(),
151            self.oauth_at_rest_pepper.clone(),
152        ));
153        pairs.push(("DATABASE_URL".to_owned(), self.database_url.clone()));
154
155        let optionals: &[(&str, &Option<String>)] = &[
156            (
157                "MANIFEST_SIGNING_SECRET_SEED",
158                &self.manifest_signing_secret_seed,
159            ),
160            ("DATABASE_WRITE_URL", &self.database_write_url),
161            ("EXTERNAL_DATABASE_URL", &self.external_database_url),
162            ("INTERNAL_DATABASE_URL", &self.internal_database_url),
163            ("GEMINI_API_KEY", &self.gemini),
164            ("ANTHROPIC_API_KEY", &self.anthropic),
165            ("OPENAI_API_KEY", &self.openai),
166            ("GITHUB_TOKEN", &self.github),
167            ("MOONSHOT_API_KEY", &self.moonshot),
168            ("QWEN_API_KEY", &self.qwen),
169        ];
170        for (name, value) in optionals {
171            if let Some(v) = value
172                && !v.is_empty()
173            {
174                pairs.push(((*name).to_owned(), v.clone()));
175            }
176        }
177
178        if !self.custom.is_empty() {
179            let mut names: Vec<String> = Vec::with_capacity(self.custom.len());
180            for (key, value) in &self.custom {
181                let upper = key.to_uppercase();
182                names.push(upper.clone());
183                pairs.push((upper.clone(), value.clone()));
184                if upper != *key {
185                    pairs.push((key.clone(), value.clone()));
186                }
187            }
188            pairs.push(("SYSTEMPROMPT_CUSTOM_SECRETS".to_owned(), names.join(",")));
189        }
190
191        pairs
192    }
193}