systemprompt_models/config/
mod.rs1use 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}