Skip to main content

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/// ```
86#[derive(Debug, Clone)]
87pub struct Venture {
88    /// Kebab-case venture name (`factory0`).
89    pub name: String,
90    /// Apex domain (`factory0.ventures`); the API serves `api.<domain>`.
91    pub domain: String,
92    /// Absolute public URL the API is linked from.
93    pub public_url: String,
94    /// CORS allowlist. Never `*` (architecture section 6).
95    pub cors_origins: Vec<String>,
96    /// Deployment environment.
97    pub env: VentureEnv,
98    /// Mail branding (issue #12).
99    pub brand: Brand,
100}
101
102impl Venture {
103    pub fn new(name: impl Into<String>, domain: impl Into<String>) -> Self {
104        let domain = domain.into();
105        Self {
106            name: name.into(),
107            public_url: format!("https://{domain}"),
108            domain,
109            cors_origins: Vec::new(),
110            env: VentureEnv::default(),
111            brand: Brand::default(),
112        }
113    }
114
115    #[must_use]
116    pub fn public_url(mut self, url: impl Into<String>) -> Self {
117        self.public_url = url.into();
118        self
119    }
120
121    #[must_use]
122    pub fn cors_origins(mut self, origins: impl IntoIterator<Item = impl Into<String>>) -> Self {
123        self.cors_origins = origins.into_iter().map(Into::into).collect();
124        self
125    }
126
127    #[must_use]
128    pub fn env(mut self, env: VentureEnv) -> Self {
129        self.env = env;
130        self
131    }
132
133    /// Mail branding for templates (accent, logo, footer).
134    #[must_use]
135    pub fn brand(mut self, brand: Brand) -> Self {
136        self.brand = brand;
137        self
138    }
139
140    /// Appends every rule violation to `errors` (issue #2: collect all
141    /// problems, report them together).
142    pub(crate) fn validate(&self, errors: &mut ConfigError) {
143        if self.name.is_empty() {
144            errors.push("venture: name must not be empty");
145        } else if !is_kebab_case(&self.name) {
146            errors.push(format!(
147                "venture: name `{}` must be kebab-case ([a-z0-9]+ separated by '-')",
148                self.name
149            ));
150        }
151
152        if self.domain.trim().is_empty() {
153            errors.push("venture: domain must not be empty");
154        }
155
156        if self.cors_origins.is_empty() {
157            errors.push(format!(
158                "venture `{}`: at least one CORS origin is required",
159                self.name
160            ));
161        } else {
162            for origin in &self.cors_origins {
163                if origin == "*" {
164                    errors.push(format!(
165                        "venture `{}`: wildcard CORS origin `*` is not allowed",
166                        self.name
167                    ));
168                } else if !is_valid_origin(origin) {
169                    errors.push(format!(
170                        "venture `{}`: CORS origin `{origin}` must be scheme://host[:port]",
171                        self.name
172                    ));
173                }
174            }
175        }
176    }
177}
178
179fn is_kebab_case(name: &str) -> bool {
180    !name.is_empty()
181        && name.split('-').all(|part| {
182            !part.is_empty()
183                && part
184                    .chars()
185                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
186        })
187}
188
189fn is_valid_origin(origin: &str) -> bool {
190    // scheme://host[:port] with no path, query or fragment.
191    let Some((scheme, rest)) = origin.split_once("://") else {
192        return false;
193    };
194    if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric()) {
195        return false;
196    }
197    if rest.contains(['/', '?', '#']) {
198        return false;
199    }
200    let hostport = rest;
201    let host = hostport.split_once(':').map_or(hostport, |(h, _)| h);
202    !host.is_empty()
203        && host
204            .chars()
205            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':')
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn errors(v: &Venture) -> Vec<String> {
213        let mut errs = ConfigError::default();
214        v.validate(&mut errs);
215        errs.problems
216    }
217
218    #[test]
219    fn valid_venture_has_no_errors() {
220        let v = Venture::new("factory0", "factory0.ventures")
221            .cors_origins(["https://factory0.ventures"]);
222        assert!(errors(&v).is_empty());
223    }
224
225    #[test]
226    fn name_must_be_kebab_case() {
227        let v = Venture::new("Factory0", "factory0.ventures")
228            .cors_origins(["https://factory0.ventures"]);
229        assert!(errors(&v).iter().any(|e| e.contains("kebab-case")));
230    }
231
232    #[test]
233    fn domain_must_be_non_empty() {
234        let v = Venture::new("factory0", " ").cors_origins(["https://x.dev"]);
235        assert!(errors(&v).iter().any(|e| e.contains("domain")));
236    }
237
238    #[test]
239    fn at_least_one_cors_origin() {
240        let v = Venture::new("factory0", "factory0.ventures");
241        assert!(errors(&v).iter().any(|e| e.contains("CORS origin")));
242    }
243
244    #[test]
245    fn wildcard_origin_rejected() {
246        let v = Venture::new("factory0", "factory0.ventures").cors_origins(["*"]);
247        assert!(errors(&v).iter().any(|e| e.contains("wildcard")));
248    }
249}