Skip to main content

systemprompt_models/profile/
validation.rs

1//! Profile validation logic.
2//!
3//! This module contains all validation logic for Profile configurations,
4//! including path validation, security settings, CORS, and rate limits.
5
6use 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}