systemprompt_models/profile/
mod.rs1mod cloud;
4mod database;
5mod from_env;
6mod paths;
7mod rate_limits;
8mod runtime;
9mod secrets;
10mod security;
11mod server;
12mod site;
13mod style;
14mod validation;
15
16pub use cloud::{CloudConfig, CloudValidationMode};
17pub use database::DatabaseConfig;
18pub use paths::{expand_home, resolve_path, resolve_with_home, PathsConfig};
19pub use rate_limits::{
20 default_a2a_multiplier, default_admin_multiplier, default_agent_registry, default_agents,
21 default_anon_multiplier, default_artifacts, default_burst, default_content, default_contexts,
22 default_mcp, default_mcp_multiplier, default_mcp_registry, default_oauth_auth,
23 default_oauth_public, default_service_multiplier, default_stream, default_tasks,
24 default_user_multiplier, RateLimitsConfig, TierMultipliers,
25};
26pub use runtime::{Environment, LogLevel, OutputFormat, RuntimeConfig};
27pub use secrets::{SecretsConfig, SecretsSource, SecretsValidationMode};
28pub use security::SecurityConfig;
29pub use server::{ContentNegotiationConfig, ServerConfig};
30pub use site::SiteConfig;
31pub use style::ProfileStyle;
32
33use anyhow::{Context, Result};
34use regex::Regex;
35use serde::{Deserialize, Serialize};
36use std::path::Path;
37use std::sync::LazyLock;
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct ExtensionsConfig {
41 #[serde(default)]
42 pub disabled: Vec<String>,
43}
44
45impl ExtensionsConfig {
46 pub fn is_disabled(&self, extension_id: &str) -> bool {
47 self.disabled.iter().any(|id| id == extension_id)
48 }
49}
50
51#[allow(clippy::expect_used)]
52static ENV_VAR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
53 Regex::new(r"\$\{(\w+)\}")
54 .expect("ENV_VAR_REGEX is a valid regex - this is a compile-time constant")
55});
56
57fn env_var_regex() -> &'static Regex {
58 &ENV_VAR_REGEX
59}
60
61fn substitute_env_vars(content: &str) -> String {
62 env_var_regex()
63 .replace_all(content, |caps: ®ex::Captures| {
64 let var_name = &caps[1];
65 std::env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
66 })
67 .to_string()
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
71#[serde(rename_all = "lowercase")]
72pub enum ProfileType {
73 #[default]
74 Local,
75 Cloud,
76}
77
78impl ProfileType {
79 pub const fn is_cloud(&self) -> bool {
80 matches!(self, Self::Cloud)
81 }
82
83 pub const fn is_local(&self) -> bool {
84 matches!(self, Self::Local)
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Profile {
90 pub name: String,
91
92 pub display_name: String,
93
94 #[serde(default)]
95 pub target: ProfileType,
96
97 pub site: SiteConfig,
98
99 pub database: DatabaseConfig,
100
101 pub server: ServerConfig,
102
103 pub paths: PathsConfig,
104
105 pub security: SecurityConfig,
106
107 pub rate_limits: RateLimitsConfig,
108
109 #[serde(default)]
110 pub runtime: RuntimeConfig,
111
112 #[serde(default)]
113 pub cloud: Option<CloudConfig>,
114
115 #[serde(default)]
116 pub secrets: Option<SecretsConfig>,
117
118 #[serde(default)]
119 pub extensions: ExtensionsConfig,
120}
121
122impl Profile {
123 pub fn parse(content: &str, profile_path: &Path) -> Result<Self> {
124 let content = substitute_env_vars(content);
125
126 let mut profile: Self = serde_yaml::from_str(&content)
127 .with_context(|| format!("Failed to parse profile: {}", profile_path.display()))?;
128
129 let profile_dir = profile_path
130 .parent()
131 .with_context(|| format!("Invalid profile path: {}", profile_path.display()))?;
132
133 profile.paths.resolve_relative_to(profile_dir);
134
135 Ok(profile)
136 }
137
138 pub fn to_yaml(&self) -> Result<String> {
139 serde_yaml::to_string(self).context("Failed to serialize profile")
140 }
141
142 pub fn list_available(services_path: &Path) -> Vec<String> {
143 let profiles_dir = services_path.join("profiles");
144 if !profiles_dir.exists() {
145 return Vec::new();
146 }
147
148 std::fs::read_dir(&profiles_dir).map_or_else(
149 |_| Vec::new(),
150 |entries| {
151 entries
152 .filter_map(std::result::Result::ok)
153 .filter_map(|e| {
154 let name = e.file_name().to_string_lossy().to_string();
155 if name.ends_with(".secrets.profile.yaml") {
156 Some(name.trim_end_matches(".secrets.profile.yaml").to_string())
157 } else {
158 None
159 }
160 })
161 .collect()
162 },
163 )
164 }
165
166 pub fn save(&self, services_path: &Path) -> Result<()> {
167 let profiles_dir = services_path.join("profiles");
168 std::fs::create_dir_all(&profiles_dir).context("Failed to create profiles directory")?;
169
170 let profile_path = profiles_dir.join(format!("{}.secrets.profile.yaml", self.name));
171 let content = serde_yaml::to_string(self).context("Failed to serialize profile")?;
172
173 let content_with_header = format!(
174 "# systemprompt.io Profile: {}\n# \n# WARNING: This file contains secrets (API keys, \
175 JWT secrets, database credentials).\n# DO NOT commit this file to version \
176 control.\n# DO NOT share this file publicly.\n# \n# Generated from environment \
177 variables\n\n{}",
178 self.display_name, content
179 );
180
181 std::fs::write(&profile_path, content_with_header)
182 .with_context(|| format!("Failed to write profile file: {}", profile_path.display()))?;
183
184 Ok(())
185 }
186
187 pub fn profile_style(&self) -> ProfileStyle {
188 match self.name.to_lowercase().as_str() {
189 "dev" | "development" | "local" => ProfileStyle::Development,
190 "prod" | "production" => ProfileStyle::Production,
191 "staging" | "stage" => ProfileStyle::Staging,
192 "test" | "testing" => ProfileStyle::Test,
193 _ => ProfileStyle::Custom,
194 }
195 }
196
197 pub fn mask_secret(value: &str, visible_chars: usize) -> String {
198 if value.is_empty() {
199 return "(not set)".to_string();
200 }
201 if value.len() <= visible_chars {
202 return "***".to_string();
203 }
204 format!("{}...", &value[..visible_chars])
205 }
206
207 pub fn mask_database_url(url: &str) -> String {
208 if let Some(at_pos) = url.find('@') {
209 if let Some(colon_pos) = url[..at_pos].rfind(':') {
210 let prefix = &url[..=colon_pos];
211 let suffix = &url[at_pos..];
212 return format!("{}***{}", prefix, suffix);
213 }
214 }
215 url.to_string()
216 }
217
218 pub fn is_masked_database_url(url: &str) -> bool {
219 url.contains(":***@") || url.contains(":********@")
220 }
221}