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
7pub(crate) static SECRETS: OnceLock<Secrets> = OnceLock::new();
8
9pub(crate) const JWT_SECRET_MIN_LENGTH: usize = 32;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Secrets {
13    pub jwt_secret: String,
14
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub manifest_signing_secret_seed: Option<String>,
17
18    pub database_url: String,
19
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub database_write_url: Option<String>,
22
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub external_database_url: Option<String>,
25
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub internal_database_url: Option<String>,
28
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub sync_token: Option<String>,
31
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub gemini: Option<String>,
34
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub anthropic: Option<String>,
37
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub openai: Option<String>,
40
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub github: Option<String>,
43
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub moonshot: Option<String>,
46
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub qwen: Option<String>,
49
50    #[serde(default, flatten)]
51    pub custom: HashMap<String, String>,
52}
53
54impl Secrets {
55    pub fn parse(content: &str) -> Result<Self> {
56        let secrets: Self =
57            serde_json::from_str(content).context("Failed to parse secrets JSON")?;
58        secrets.validate()?;
59        Ok(secrets)
60    }
61
62    pub fn load_from_path(secrets_path: &Path) -> Result<Self> {
63        if !secrets_path.exists() {
64            anyhow::bail!("Secrets file not found: {}", secrets_path.display());
65        }
66        let content = std::fs::read_to_string(secrets_path)
67            .with_context(|| format!("Failed to read secrets: {}", secrets_path.display()))?;
68        Self::parse(&content)
69    }
70
71    pub(crate) fn validate(&self) -> Result<()> {
72        if self.jwt_secret.len() < JWT_SECRET_MIN_LENGTH {
73            anyhow::bail!(
74                "jwt_secret must be at least {} characters (got {})",
75                JWT_SECRET_MIN_LENGTH,
76                self.jwt_secret.len()
77            );
78        }
79        Ok(())
80    }
81
82    pub fn effective_database_url(&self, external_db_access: bool) -> &str {
83        if external_db_access {
84            if let Some(url) = &self.external_database_url {
85                return url;
86            }
87        }
88        &self.database_url
89    }
90
91    pub const fn has_ai_provider(&self) -> bool {
92        self.gemini.is_some()
93            || self.anthropic.is_some()
94            || self.openai.is_some()
95            || self.moonshot.is_some()
96            || self.qwen.is_some()
97    }
98
99    pub fn get(&self, key: &str) -> Option<&String> {
100        match key {
101            "jwt_secret" | "JWT_SECRET" => Some(&self.jwt_secret),
102            "database_url" | "DATABASE_URL" => Some(&self.database_url),
103            "database_write_url" | "DATABASE_WRITE_URL" => self.database_write_url.as_ref(),
104            "external_database_url" | "EXTERNAL_DATABASE_URL" => {
105                self.external_database_url.as_ref()
106            },
107            "internal_database_url" | "INTERNAL_DATABASE_URL" => {
108                self.internal_database_url.as_ref()
109            },
110            "sync_token" | "SYNC_TOKEN" => self.sync_token.as_ref(),
111            "gemini" | "GEMINI_API_KEY" => self.gemini.as_ref(),
112            "anthropic" | "ANTHROPIC_API_KEY" => self.anthropic.as_ref(),
113            "openai" | "OPENAI_API_KEY" => self.openai.as_ref(),
114            "github" | "GITHUB_TOKEN" => self.github.as_ref(),
115            "moonshot" | "MOONSHOT_API_KEY" | "kimi" | "KIMI_API_KEY" => self.moonshot.as_ref(),
116            "qwen" | "QWEN_API_KEY" | "dashscope" | "DASHSCOPE_API_KEY" => self.qwen.as_ref(),
117            other => self.custom.get(other).or_else(|| {
118                let alternate = if other.chars().any(char::is_uppercase) {
119                    other.to_lowercase()
120                } else {
121                    other.to_uppercase()
122                };
123                self.custom.get(&alternate)
124            }),
125        }
126    }
127
128    pub fn log_configured_providers(&self) {
129        let configured: Vec<&str> = [
130            self.gemini.as_ref().map(|_| "gemini"),
131            self.anthropic.as_ref().map(|_| "anthropic"),
132            self.openai.as_ref().map(|_| "openai"),
133            self.github.as_ref().map(|_| "github"),
134            self.moonshot.as_ref().map(|_| "moonshot"),
135            self.qwen.as_ref().map(|_| "qwen"),
136        ]
137        .into_iter()
138        .flatten()
139        .collect();
140
141        tracing::info!(providers = ?configured, "Configured API providers");
142    }
143
144    pub fn custom_env_vars(&self) -> Vec<(String, &str)> {
145        self.custom
146            .iter()
147            .flat_map(|(key, value)| {
148                let upper_key = key.to_uppercase();
149                let value_str = value.as_str();
150                if upper_key == *key {
151                    vec![(key.clone(), value_str)]
152                } else {
153                    vec![(key.clone(), value_str), (upper_key, value_str)]
154                }
155            })
156            .collect()
157    }
158
159    pub fn custom_env_var_names(&self) -> Vec<String> {
160        self.custom.keys().map(|key| key.to_uppercase()).collect()
161    }
162}