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