systemprompt_models/profile/
validation.rs1use super::security::GATEWAY_REQUIRED_RESOURCE_AUDIENCES;
7use super::{Profile, ProfileError, ProfileResult};
8
9impl Profile {
10 pub fn validate(&self) -> ProfileResult<()> {
11 let mut errors: Vec<String> = Vec::new();
12 let is_cloud = self.target.is_cloud();
13
14 self.validate_required_fields(&mut errors);
15 self.validate_paths(&mut errors, is_cloud);
16 self.validate_security_settings(&mut errors);
17 self.validate_cors_origins(&mut errors);
18 self.validate_rate_limits(&mut errors);
19
20 if errors.is_empty() {
21 Ok(())
22 } else {
23 Err(ProfileError::Validation {
24 name: self.name.clone(),
25 errors,
26 })
27 }
28 }
29
30 pub(super) fn validate_paths(&self, errors: &mut Vec<String>, is_cloud: bool) {
31 if is_cloud {
32 self.validate_cloud_paths(errors);
33 } else {
34 self.validate_local_paths(errors);
35 }
36 }
37
38 pub(super) fn validate_cloud_paths(&self, errors: &mut Vec<String>) {
39 Self::require_non_empty(errors, &self.paths.system, "Paths system");
40 Self::require_non_empty(errors, &self.paths.services, "Paths services");
41 Self::require_non_empty(errors, &self.paths.bin, "Paths bin");
42
43 for (name, path) in [
44 ("system", self.paths.system.as_str()),
45 ("services", self.paths.services.as_str()),
46 ("bin", self.paths.bin.as_str()),
47 ] {
48 if !path.is_empty() && !path.starts_with("/app") {
49 errors.push(format!(
50 "Cloud profile {} path should start with /app, got: {}",
51 name, path
52 ));
53 }
54 }
55
56 if let Some(web_path) = &self.paths.web_path {
57 if !web_path.is_empty() {
58 if !web_path.starts_with("/app/web") {
59 errors.push(format!(
60 "Cloud profile web_path should start with /app/web, got: {}. Note: \
61 web_path points to the parent of dist/, e.g., /app/web for /app/web/dist",
62 web_path
63 ));
64 }
65 if web_path.contains("/services/web") {
66 errors.push(format!(
67 "Cloud profile web_path should be /app/web (for dist output), not \
68 /app/services/web (which is for templates/config). Got: {}",
69 web_path
70 ));
71 }
72 }
73 }
74 }
75
76 pub(super) fn validate_local_paths(&self, errors: &mut Vec<String>) {
77 Self::require_non_empty(errors, &self.paths.system, "Paths system");
78 Self::require_non_empty(errors, &self.paths.services, "Paths services");
79 Self::require_non_empty(errors, &self.paths.bin, "Paths bin");
80 }
81
82 pub(super) fn validate_required_fields(&self, errors: &mut Vec<String>) {
83 Self::require_non_empty(errors, &self.name, "Profile name");
84 Self::require_non_empty(errors, &self.display_name, "Profile display_name");
85 Self::require_non_empty(errors, &self.site.name, "Site name");
86 Self::require_non_empty(errors, &self.server.host, "Server host");
87 Self::require_non_empty(errors, &self.server.api_server_url, "Server api_server_url");
88 Self::require_non_empty(
89 errors,
90 &self.server.api_internal_url,
91 "Server api_internal_url",
92 );
93 Self::require_non_empty(
94 errors,
95 &self.server.api_external_url,
96 "Server api_external_url",
97 );
98
99 if self.server.port == 0 {
100 errors.push("Server port must be greater than 0".to_owned());
101 }
102 }
103
104 pub(super) fn require_non_empty(errors: &mut Vec<String>, value: &str, field_name: &str) {
105 if value.is_empty() {
106 errors.push(format!("{field_name} is required"));
107 }
108 }
109
110 pub(super) fn validate_security_settings(&self, errors: &mut Vec<String>) {
111 if self.security.access_token_expiration <= 0 {
112 errors.push("Security access_token_expiration must be positive".to_owned());
113 }
114
115 if self.security.refresh_token_expiration <= 0 {
116 errors.push("Security refresh_token_expiration must be positive".to_owned());
117 }
118
119 for required in GATEWAY_REQUIRED_RESOURCE_AUDIENCES {
120 if !self
121 .security
122 .allowed_resource_audiences
123 .iter()
124 .any(|allowed| allowed == required)
125 {
126 errors.push(format!(
127 "security.allowed_resource_audiences must include \"{required}\" — the \
128 gateway issues tokens bound to audience=\"{required}\" for internal protocol \
129 scopes (hook:govern, hook:track). Add it to the profile YAML and restart."
130 ));
131 }
132 }
133 }
134
135 pub(super) fn validate_cors_origins(&self, errors: &mut Vec<String>) {
136 for origin in &self.server.cors_allowed_origins {
137 if origin.is_empty() {
138 errors.push("CORS origin cannot be empty".to_owned());
139 continue;
140 }
141
142 if origin == "*" {
143 errors.push("CORS origin '*' is not permitted; list explicit origins".to_owned());
144 continue;
145 }
146
147 let is_https = origin.starts_with("https://");
148 let is_loopback_http = origin.starts_with("http://localhost")
149 || origin.starts_with("http://127.0.0.1")
150 || origin.starts_with("http://[::1]");
151 if !is_https && !is_loopback_http {
152 errors.push(format!(
153 "Invalid CORS origin (must be https:// or http://localhost): {origin}"
154 ));
155 }
156 }
157 }
158
159 pub(super) fn validate_rate_limits(&self, errors: &mut Vec<String>) {
160 if self.rate_limits.disabled {
161 return;
162 }
163
164 if self.rate_limits.burst_multiplier == 0 {
165 errors.push("rate_limits.burst_multiplier must be greater than 0".to_owned());
166 }
167
168 Self::validate_rate_limit(
169 errors,
170 "oauth_public",
171 self.rate_limits.oauth_public_per_second,
172 );
173 Self::validate_rate_limit(errors, "oauth_auth", self.rate_limits.oauth_auth_per_second);
174 Self::validate_rate_limit(errors, "contexts", self.rate_limits.contexts_per_second);
175 Self::validate_rate_limit(errors, "tasks", self.rate_limits.tasks_per_second);
176 Self::validate_rate_limit(errors, "artifacts", self.rate_limits.artifacts_per_second);
177 Self::validate_rate_limit(errors, "agents", self.rate_limits.agents_per_second);
178 Self::validate_rate_limit(errors, "mcp", self.rate_limits.mcp_per_second);
179 Self::validate_rate_limit(errors, "stream", self.rate_limits.stream_per_second);
180 Self::validate_rate_limit(errors, "content", self.rate_limits.content_per_second);
181 }
182
183 fn validate_rate_limit(errors: &mut Vec<String>, name: &str, value: u64) {
184 if value == 0 {
185 errors.push(format!(
186 "rate_limits.{}_per_second must be greater than 0",
187 name
188 ));
189 }
190 }
191}