Skip to main content

systemprompt_models/config/
mod.rs

1use anyhow::Result;
2use std::sync::OnceLock;
3use systemprompt_traits::ConfigProvider;
4
5use crate::auth::JwtAudience;
6use crate::profile::{ContentNegotiationConfig, Profile, SecurityHeadersConfig};
7use crate::profile_bootstrap::ProfileBootstrap;
8use crate::secrets::SecretsBootstrap;
9
10mod environment;
11mod paths;
12mod rate_limits;
13mod validation;
14mod verbosity;
15
16pub use environment::Environment;
17pub use paths::PathNotConfiguredError;
18pub use rate_limits::RateLimitConfig;
19pub use validation::{
20    format_path_errors, validate_optional_path, validate_postgres_url, validate_profile_paths,
21    validate_required_optional_path, validate_required_path,
22};
23pub use verbosity::VerbosityLevel;
24
25static CONFIG: OnceLock<Config> = OnceLock::new();
26
27#[allow(clippy::struct_field_names)]
28struct BuildConfigPaths {
29    system_path: String,
30    skills_path: String,
31    settings_path: String,
32    content_config_path: String,
33    web_path: String,
34    web_config_path: String,
35    web_metadata_path: String,
36}
37
38#[derive(Debug, Clone)]
39pub struct Config {
40    pub sitename: String,
41    pub database_type: String,
42    pub database_url: String,
43    pub database_write_url: Option<String>,
44    pub github_link: String,
45    pub github_token: Option<String>,
46    pub system_path: String,
47    pub services_path: String,
48    pub bin_path: String,
49    pub skills_path: String,
50    pub settings_path: String,
51    pub content_config_path: String,
52    pub geoip_database_path: Option<String>,
53    pub web_path: String,
54    pub web_config_path: String,
55    pub web_metadata_path: String,
56    pub host: String,
57    pub port: u16,
58    pub api_server_url: String,
59    pub api_internal_url: String,
60    pub api_external_url: String,
61    pub jwt_issuer: String,
62    pub jwt_access_token_expiration: i64,
63    pub jwt_refresh_token_expiration: i64,
64    pub jwt_audiences: Vec<JwtAudience>,
65    pub use_https: bool,
66    pub rate_limits: RateLimitConfig,
67    pub cors_allowed_origins: Vec<String>,
68    pub is_cloud: bool,
69    pub content_negotiation: ContentNegotiationConfig,
70    pub security_headers: SecurityHeadersConfig,
71    pub allow_registration: bool,
72}
73
74impl Config {
75    pub fn is_initialized() -> bool {
76        CONFIG.get().is_some()
77    }
78
79    pub fn init() -> Result<()> {
80        let profile = ProfileBootstrap::get()
81            .map_err(|e| anyhow::anyhow!("Profile not initialized: {}", e))?;
82
83        let config = Self::from_profile(profile)?;
84        CONFIG
85            .set(config)
86            .map_err(|_| anyhow::anyhow!("Config already initialized"))?;
87        Ok(())
88    }
89
90    pub fn try_init() -> Result<()> {
91        if Self::is_initialized() {
92            return Ok(());
93        }
94        Self::init()
95    }
96
97    pub fn get() -> Result<&'static Self> {
98        CONFIG
99            .get()
100            .ok_or_else(|| anyhow::anyhow!("Config not initialized. Call Config::init() first."))
101    }
102
103    pub fn from_profile(profile: &Profile) -> Result<Self> {
104        let profile_path = ProfileBootstrap::get_path()
105            .map_or_else(|_| "<not set>".to_string(), ToString::to_string);
106
107        let path_report = validate_profile_paths(profile, &profile_path);
108        if path_report.has_errors() {
109            return Err(anyhow::anyhow!(
110                "{}",
111                format_path_errors(&path_report, &profile_path)
112            ));
113        }
114
115        let system_path = Self::canonicalize_path(&profile.paths.system, "system")?;
116
117        let skills_path = profile.paths.skills();
118        let settings_path =
119            Self::require_yaml_path("config", Some(&profile.paths.config()), &profile_path)?;
120        let content_config_path = Self::require_yaml_path(
121            "content_config",
122            Some(&profile.paths.content_config()),
123            &profile_path,
124        )?;
125        let web_path = profile.paths.web_path_resolved();
126        let web_config_path = Self::require_yaml_path(
127            "web_config",
128            Some(&profile.paths.web_config()),
129            &profile_path,
130        )?;
131        let web_metadata_path = Self::require_yaml_path(
132            "web_metadata",
133            Some(&profile.paths.web_metadata()),
134            &profile_path,
135        )?;
136
137        let paths = BuildConfigPaths {
138            system_path,
139            skills_path,
140            settings_path,
141            content_config_path,
142            web_path,
143            web_config_path,
144            web_metadata_path,
145        };
146        let config = Self::build_config(profile, paths)?;
147
148        config.validate_database_config()?;
149        Ok(config)
150    }
151
152    fn canonicalize_path(path: &str, name: &str) -> Result<String> {
153        std::fs::canonicalize(path)
154            .map(|p| p.to_string_lossy().to_string())
155            .map_err(|e| anyhow::anyhow!("Failed to canonicalize {} path: {}", name, e))
156    }
157
158    fn require_yaml_path(field: &str, value: Option<&str>, profile_path: &str) -> Result<String> {
159        let path =
160            value.ok_or_else(|| anyhow::anyhow!("Missing required path: paths.{}", field))?;
161
162        let content = std::fs::read_to_string(path).map_err(|e| {
163            anyhow::anyhow!(
164                "Profile Error: Cannot read file\n\n  Field: paths.{}\n  Path: {}\n  Error: {}\n  \
165                 Profile: {}",
166                field,
167                path,
168                e,
169                profile_path
170            )
171        })?;
172
173        serde_yaml::from_str::<serde_yaml::Value>(&content).map_err(|e| {
174            anyhow::anyhow!(
175                "Profile Error: Invalid YAML syntax\n\n  Field: paths.{}\n  Path: {}\n  Error: \
176                 {}\n  Profile: {}",
177                field,
178                path,
179                e,
180                profile_path
181            )
182        })?;
183
184        Ok(path.to_string())
185    }
186
187    fn build_config(profile: &Profile, paths: BuildConfigPaths) -> Result<Self> {
188        let secrets = SecretsBootstrap::get().map_err(|_| {
189            anyhow::anyhow!(
190                "Secrets not initialized. Call SecretsBootstrap::init() before \
191                 Config::from_profile()"
192            )
193        })?;
194
195        Ok(Self {
196            sitename: profile.site.name.clone(),
197            database_type: profile.database.db_type.clone(),
198            database_url: secrets.database_url.clone(),
199            database_write_url: secrets.database_write_url.clone(),
200            github_link: profile
201                .site
202                .github_link
203                .clone()
204                .unwrap_or_else(|| "https://github.com/systemprompt/systemprompt-os".to_string()),
205            github_token: secrets.github.clone(),
206            system_path: paths.system_path,
207            services_path: profile.paths.services.clone(),
208            bin_path: profile.paths.bin.clone(),
209            skills_path: paths.skills_path,
210            settings_path: paths.settings_path,
211            content_config_path: paths.content_config_path,
212            geoip_database_path: profile.paths.geoip_database.clone(),
213            web_path: paths.web_path,
214            web_config_path: paths.web_config_path,
215            web_metadata_path: paths.web_metadata_path,
216            host: profile.server.host.clone(),
217            port: profile.server.port,
218            api_server_url: profile.server.api_server_url.clone(),
219            api_internal_url: profile.server.api_internal_url.clone(),
220            api_external_url: profile.server.api_external_url.clone(),
221            jwt_issuer: profile.security.issuer.clone(),
222            jwt_access_token_expiration: profile.security.access_token_expiration,
223            jwt_refresh_token_expiration: profile.security.refresh_token_expiration,
224            jwt_audiences: profile.security.audiences.clone(),
225            use_https: profile.server.use_https,
226            rate_limits: (&profile.rate_limits).into(),
227            cors_allowed_origins: profile.server.cors_allowed_origins.clone(),
228            is_cloud: profile.target.is_cloud(),
229            content_negotiation: profile.server.content_negotiation.clone(),
230            security_headers: profile.server.security_headers.clone(),
231            allow_registration: profile.security.allow_registration,
232        })
233    }
234
235    pub fn init_from_profile(profile: &Profile) -> Result<()> {
236        let config = Self::from_profile(profile)?;
237        CONFIG
238            .set(config)
239            .map_err(|_| anyhow::anyhow!("Config already initialized"))?;
240        Ok(())
241    }
242
243    pub fn validate_database_config(&self) -> Result<()> {
244        let db_type = self.database_type.to_lowercase();
245
246        if db_type != "postgres" && db_type != "postgresql" {
247            return Err(anyhow::anyhow!(
248                "Unsupported database type '{}'. Only 'postgres' is supported.",
249                self.database_type
250            ));
251        }
252
253        validate_postgres_url(&self.database_url)?;
254        if let Some(write_url) = &self.database_write_url {
255            validate_postgres_url(write_url)?;
256        }
257        Ok(())
258    }
259}
260
261impl ConfigProvider for Config {
262    fn get(&self, key: &str) -> Option<String> {
263        match key {
264            "database_type" => Some(self.database_type.clone()),
265            "database_url" => Some(self.database_url.clone()),
266            "database_write_url" => self.database_write_url.clone(),
267            "host" => Some(self.host.clone()),
268            "port" => Some(self.port.to_string()),
269            "system_path" => Some(self.system_path.clone()),
270            "services_path" => Some(self.services_path.clone()),
271            "bin_path" => Some(self.bin_path.clone()),
272            "skills_path" => Some(self.skills_path.clone()),
273            "settings_path" => Some(self.settings_path.clone()),
274            "content_config_path" => Some(self.content_config_path.clone()),
275            "web_path" => Some(self.web_path.clone()),
276            "web_config_path" => Some(self.web_config_path.clone()),
277            "web_metadata_path" => Some(self.web_metadata_path.clone()),
278            "sitename" => Some(self.sitename.clone()),
279            "github_link" => Some(self.github_link.clone()),
280            "github_token" => self.github_token.clone(),
281            "api_server_url" => Some(self.api_server_url.clone()),
282            "api_external_url" => Some(self.api_external_url.clone()),
283            "jwt_issuer" => Some(self.jwt_issuer.clone()),
284            "is_cloud" => Some(self.is_cloud.to_string()),
285            _ => None,
286        }
287    }
288
289    fn database_url(&self) -> &str {
290        &self.database_url
291    }
292
293    fn database_write_url(&self) -> Option<&str> {
294        self.database_write_url.as_deref()
295    }
296
297    fn system_path(&self) -> &str {
298        &self.system_path
299    }
300
301    fn api_port(&self) -> u16 {
302        self.port
303    }
304
305    fn as_any(&self) -> &dyn std::any::Any {
306        self
307    }
308}