Skip to main content

systemprompt_models/
secrets.rs

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