cratefield_core/venture.rs
1//! The venture descriptor: who this backend belongs to (issue #2).
2
3use crate::config::ConfigError;
4
5/// Deployment environment. Mirrors the `ENV` config key; `Production`
6/// drives the mandatory-captcha rule (architecture section 11).
7///
8/// Ordered least to most protected, so [`VentureEnv::strictest`] can take
9/// the safer of two answers (issue #143).
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
11pub enum VentureEnv {
12 #[default]
13 Development,
14 Staging,
15 Production,
16}
17
18impl VentureEnv {
19 pub fn as_str(&self) -> &'static str {
20 match self {
21 VentureEnv::Development => "development",
22 VentureEnv::Staging => "staging",
23 VentureEnv::Production => "production",
24 }
25 }
26
27 /// The safer of two answers about one deployment (issue #143).
28 ///
29 /// A venture carries a **compiled** environment — a builder default
30 /// the operator cannot change without a rebuild — while the
31 /// deployment carries an `ENV` binding it can. They disagreed
32 /// silently, and the disagreement always resolved toward the weaker
33 /// answer: `ventures/cratefield-waitlist` ships `ENV = "production"`
34 /// and never calls [`Venture::env`], so every production-only rule
35 /// read `Development` and did not apply to the one venture actually
36 /// in production. Neither source may downgrade the other: if either
37 /// says `Production`, the deployment is production.
38 #[must_use]
39 pub fn strictest(self, other: Self) -> Self {
40 self.max(other)
41 }
42
43 /// Parses the `ENV` config value; anything unknown is `None`.
44 pub fn parse(value: &str) -> Option<Self> {
45 match value.trim().to_ascii_lowercase().as_str() {
46 "development" => Some(VentureEnv::Development),
47 "staging" => Some(VentureEnv::Staging),
48 "production" => Some(VentureEnv::Production),
49 _ => None,
50 }
51 }
52}
53
54/// Venture branding for mail templates (issue #12). Defaults are
55/// text-only: factory-zero orange accent, no logo, no footer line.
56#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
57pub struct Brand {
58 /// Accent colour as a `#rrggbb` string (links and rule lines).
59 pub accent: String,
60 /// Optional logo image URL; mails must read fine with images off.
61 pub logo_url: Option<String>,
62 /// Optional footer line (e.g. a company address).
63 pub footer: Option<String>,
64}
65
66impl Default for Brand {
67 fn default() -> Self {
68 Self {
69 accent: "#FF5A36".to_owned(),
70 logo_url: None,
71 footer: None,
72 }
73 }
74}
75
76/// Identity and CORS configuration for the venture this harness serves.
77///
78/// ```
79/// use cratefield_core::Venture;
80///
81/// let v = Venture::new("factory0", "factory0.ventures")
82/// .public_url("https://factory0.ventures")
83/// .cors_origins(["https://factory0.ventures"]);
84/// assert_eq!(v.name, "factory0");
85/// assert_eq!(v.problem_type_base(), "https://factory0.ventures/problems/");
86/// ```
87#[derive(Debug, Clone)]
88pub struct Venture {
89 /// Kebab-case venture name (`factory0`).
90 pub name: String,
91 /// Apex domain (`factory0.ventures`); the API serves `api.<domain>`.
92 pub domain: String,
93 /// Absolute public URL the API is linked from.
94 pub public_url: String,
95 /// CORS allowlist. Never `*` (architecture section 6).
96 pub cors_origins: Vec<String>,
97 /// Deployment environment.
98 pub env: VentureEnv,
99 /// Mail branding (issue #12).
100 pub brand: Brand,
101 /// Explicit [`Venture::problem_type_base`] override, for a venture that
102 /// serves its problems from somewhere other than `<public_url>/problems/`.
103 pub problem_base: Option<String>,
104}
105
106impl Venture {
107 pub fn new(name: impl Into<String>, domain: impl Into<String>) -> Self {
108 let domain = domain.into();
109 Self {
110 name: name.into(),
111 public_url: format!("https://{domain}"),
112 domain,
113 cors_origins: Vec::new(),
114 env: VentureEnv::default(),
115 brand: Brand::default(),
116 problem_base: None,
117 }
118 }
119
120 #[must_use]
121 pub fn public_url(mut self, url: impl Into<String>) -> Self {
122 self.public_url = url.into();
123 self
124 }
125
126 /// Overrides the base every problem `type` of this venture is served
127 /// under (RFC 9457): the full base URI, ending in `/`. Unset, the base
128 /// derives from [`Venture::public_url`] — see
129 /// [`Venture::problem_type_base`].
130 #[must_use]
131 pub fn problem_base(mut self, url: impl Into<String>) -> Self {
132 self.problem_base = Some(url.into());
133 self
134 }
135
136 /// The base URI every problem `type` of this venture is served under:
137 /// the [`Venture::problem_base`] override when set, else
138 /// `<public_url>/problems/`, else `about:blank`.
139 ///
140 /// A venture with no public URL has no URI of its own to name, and it
141 /// must never name another venture's domain: RFC 9457 §4.2.1 reserves
142 /// `about:blank` for exactly this, so that is the fallback. Callers
143 /// branch on the slug — the part after the base; under the default base
144 /// everything after `/problems/`, `auth/…` namespaces included — which
145 /// is the stable part
146 /// (docs/ERRORS.md).
147 ///
148 /// ```
149 /// use cratefield_core::Venture;
150 ///
151 /// // The default: under the venture's own public URL.
152 /// let v = Venture::new("acme", "acme.example").public_url("https://acme.example/");
153 /// assert_eq!(v.problem_type_base(), "https://acme.example/problems/");
154 ///
155 /// // An explicit override wins, normalized to one trailing slash.
156 /// let v = v.problem_base("https://errors.acme.example/types/");
157 /// assert_eq!(v.problem_type_base(), "https://errors.acme.example/types/");
158 /// let v = v.problem_base("https://errors.acme.example/types");
159 /// assert_eq!(v.problem_type_base(), "https://errors.acme.example/types/");
160 ///
161 /// // No public URL: the RFC 9457 default, never someone else's domain.
162 /// let v = Venture::new("acme", "acme.example").public_url(" ");
163 /// assert_eq!(v.problem_type_base(), "about:blank");
164 /// ```
165 #[must_use]
166 pub fn problem_type_base(&self) -> String {
167 if let Some(base) = self
168 .problem_base
169 .as_deref()
170 .map(str::trim)
171 .filter(|base| !base.is_empty())
172 {
173 if base == crate::problem::ABOUT_BLANK {
174 return base.to_owned();
175 }
176 // One trailing slash, so the slug is appended, not glued on.
177 return format!("{}/", base.trim_end_matches('/'));
178 }
179 let public = self.public_url.trim();
180 if public.is_empty() {
181 return crate::problem::ABOUT_BLANK.to_owned();
182 }
183 format!("{}/problems/", public.trim_end_matches('/'))
184 }
185
186 #[must_use]
187 pub fn cors_origins(mut self, origins: impl IntoIterator<Item = impl Into<String>>) -> Self {
188 self.cors_origins = origins.into_iter().map(Into::into).collect();
189 self
190 }
191
192 #[must_use]
193 pub fn env(mut self, env: VentureEnv) -> Self {
194 self.env = env;
195 self
196 }
197
198 /// Mail branding for templates (accent, logo, footer).
199 #[must_use]
200 pub fn brand(mut self, brand: Brand) -> Self {
201 self.brand = brand;
202 self
203 }
204
205 /// Appends every rule violation to `errors` (issue #2: collect all
206 /// problems, report them together).
207 pub(crate) fn validate(&self, errors: &mut ConfigError) {
208 if self.name.is_empty() {
209 errors.push("venture: name must not be empty");
210 } else if !is_kebab_case(&self.name) {
211 errors.push(format!(
212 "venture: name `{}` must be kebab-case ([a-z0-9]+ separated by '-')",
213 self.name
214 ));
215 }
216
217 if self.domain.trim().is_empty() {
218 errors.push("venture: domain must not be empty");
219 }
220
221 if self.cors_origins.is_empty() {
222 errors.push(format!(
223 "venture `{}`: at least one CORS origin is required",
224 self.name
225 ));
226 } else {
227 for origin in &self.cors_origins {
228 if origin == "*" {
229 errors.push(format!(
230 "venture `{}`: wildcard CORS origin `*` is not allowed",
231 self.name
232 ));
233 } else if !is_valid_origin(origin) {
234 errors.push(format!(
235 "venture `{}`: CORS origin `{origin}` must be scheme://host[:port]",
236 self.name
237 ));
238 }
239 }
240 }
241 }
242}
243
244fn is_kebab_case(name: &str) -> bool {
245 !name.is_empty()
246 && name.split('-').all(|part| {
247 !part.is_empty()
248 && part
249 .chars()
250 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
251 })
252}
253
254fn is_valid_origin(origin: &str) -> bool {
255 // scheme://host[:port] with no path, query or fragment.
256 let Some((scheme, rest)) = origin.split_once("://") else {
257 return false;
258 };
259 if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric()) {
260 return false;
261 }
262 if rest.contains(['/', '?', '#']) {
263 return false;
264 }
265 let hostport = rest;
266 let host = hostport.split_once(':').map_or(hostport, |(h, _)| h);
267 !host.is_empty()
268 && host
269 .chars()
270 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':')
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn errors(v: &Venture) -> Vec<String> {
278 let mut errs = ConfigError::default();
279 v.validate(&mut errs);
280 errs.problems
281 }
282
283 #[test]
284 fn valid_venture_has_no_errors() {
285 let v = Venture::new("factory0", "factory0.ventures")
286 .cors_origins(["https://factory0.ventures"]);
287 assert!(errors(&v).is_empty());
288 }
289
290 #[test]
291 fn name_must_be_kebab_case() {
292 let v = Venture::new("Factory0", "factory0.ventures")
293 .cors_origins(["https://factory0.ventures"]);
294 assert!(errors(&v).iter().any(|e| e.contains("kebab-case")));
295 }
296
297 #[test]
298 fn domain_must_be_non_empty() {
299 let v = Venture::new("factory0", " ").cors_origins(["https://x.dev"]);
300 assert!(errors(&v).iter().any(|e| e.contains("domain")));
301 }
302
303 #[test]
304 fn at_least_one_cors_origin() {
305 let v = Venture::new("factory0", "factory0.ventures");
306 assert!(errors(&v).iter().any(|e| e.contains("CORS origin")));
307 }
308
309 #[test]
310 fn wildcard_origin_rejected() {
311 let v = Venture::new("factory0", "factory0.ventures").cors_origins(["*"]);
312 assert!(errors(&v).iter().any(|e| e.contains("wildcard")));
313 }
314}