Skip to main content

greentic_deploy_spec/
environment.rs

1//! `greentic.environment.v1` (`§5.1`).
2//!
3//! Top-level Environment compose-view. Decomposes into three persistence units
4//! on disk (`environment.json`, `env-packs/<slot>/answers.json`, `runtime.json`)
5//! — the in-memory `Environment` is the union of those, owned by A2's
6//! `EnvironmentStore`.
7
8use crate::bundle_deployment::BundleDeployment;
9use crate::capability_slot::{CapabilitySlot, PackDescriptor};
10use crate::error::SpecError;
11use crate::ids::PackId;
12use crate::messaging_endpoint::MessagingEndpoint;
13use crate::refs::{ExtensionRef, SecretRef};
14use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
15use crate::revision::Revision;
16use crate::traffic_split::TrafficSplit;
17use crate::version::SchemaVersion;
18use greentic_types::EnvId;
19use serde::{Deserialize, Serialize};
20use std::collections::HashSet;
21use std::net::{IpAddr, Ipv4Addr, SocketAddr};
22use std::path::PathBuf;
23
24/// Default bind address for the runtime's local HTTP listener when
25/// [`EnvironmentHostConfig::listen_addr`] is unset and no runtime-level
26/// override applies. Loopback by design — exposing externally is an explicit
27/// opt-in via `op config set listen_addr 0.0.0.0:<port>`.
28pub const DEFAULT_LISTEN_ADDR: SocketAddr =
29    SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080);
30
31/// Host-level config moved out of `greentic-config-types::EnvironmentConfig`
32/// (`§5.1`). Identity-only — connectivity, region, and deployment ctx; nothing
33/// secret, nothing tenant-scoped.
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub struct EnvironmentHostConfig {
36    pub env_id: EnvId,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub region: Option<String>,
39    /// Tenant organization the env belongs to. `None` for `local`.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub tenant_org_id: Option<String>,
42    /// Bind address for the runtime's local HTTP listener. Set at `op env init`
43    /// to [`DEFAULT_LISTEN_ADDR`] so a freshly-initialized env can be started
44    /// with no bundles attached. The runtime (`gtc start`) may layer its own
45    /// env-var override on top — see the `greentic-start` docs for the
46    /// concrete name and precedence; this crate stays implementation-agnostic.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub listen_addr: Option<SocketAddr>,
49    /// Persistent public base URL the runtime exposes (e.g. via a static
50    /// tunnel or load balancer). Stored as origin only — `https://host[:port]`,
51    /// no path, no query, no fragment. Validated by [`Environment::validate`]
52    /// (so save AND load both reject invalid values via [`LocalFsStore`]).
53    /// Runtime precedence (env var override vs. tunnel-discovered vs. persisted)
54    /// is `greentic-start`'s concern; this crate persists the configured value
55    /// only.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub public_base_url: Option<String>,
58}
59
60impl EnvironmentHostConfig {
61    /// Resolves the bind address using `self.listen_addr` falling back to
62    /// [`DEFAULT_LISTEN_ADDR`]. Runtime-level env-var precedence (if any) is
63    /// the caller's responsibility — this helper is the persisted-state
64    /// resolution only.
65    pub fn resolved_listen_addr(&self) -> SocketAddr {
66        self.listen_addr.unwrap_or(DEFAULT_LISTEN_ADDR)
67    }
68}
69
70/// Normalize and validate a candidate `public_base_url`. Returns the canonical
71/// form (trimmed, trailing `/` removed) on success. Rules mirror
72/// `greentic-start::startup_contract::normalize_public_base_url` so a value
73/// accepted here passes the runtime's gate without reformatting.
74///
75/// - Scheme MUST be `http://` or `https://`.
76/// - MUST include a non-empty host.
77/// - MUST NOT contain whitespace.
78/// - MUST NOT include userinfo (`user:pass@`).
79/// - MUST NOT include a query string (`?...`).
80/// - MUST NOT include a fragment (`#...`).
81/// - Path MUST be empty or exactly `/`.
82pub fn validate_public_base_url(value: &str) -> Result<String, crate::error::SpecError> {
83    let trimmed = value.trim();
84    let invalid = |reason: &'static str| crate::error::SpecError::InvalidPublicBaseUrl {
85        value: trimmed.to_string(),
86        reason,
87    };
88    if trimmed.is_empty() {
89        return Err(invalid("must not be empty"));
90    }
91    if trimmed.chars().any(char::is_whitespace) {
92        return Err(invalid("must not contain whitespace"));
93    }
94    // Parse via http::Uri for robust authority/port/host validation.
95    let uri: http::Uri = trimmed.parse().map_err(|_| invalid("is not a valid URI"))?;
96    // Require http or https scheme.
97    match uri.scheme_str() {
98        Some("http") | Some("https") => {}
99        _ => return Err(invalid("must start with http:// or https://")),
100    }
101    // Require authority (host[:port]).
102    let authority = uri
103        .authority()
104        .ok_or_else(|| invalid("must include a host"))?;
105    // Reject userinfo.
106    if authority.as_str().contains('@') {
107        return Err(invalid("must not include userinfo"));
108    }
109    // Reject empty host.
110    if authority.host().is_empty() {
111        return Err(invalid("must include a host"));
112    }
113    // Reject non-numeric port: http::Uri accepts `host:bad` (port() → None)
114    // but we require a valid numeric port if `:` follows the host.
115    if authority.as_str().len() > authority.host().len() && authority.port_u16().is_none() {
116        // There's text after the host (a `:something`) but it's not a valid port.
117        return Err(invalid("port is not a valid number"));
118    }
119    // Reject query.
120    if uri.query().is_some() {
121        return Err(invalid("must not include a query string"));
122    }
123    // Reject fragment (http::Uri does not parse fragments, but guard anyway).
124    if trimmed.contains('#') {
125        return Err(invalid("must not include a fragment"));
126    }
127    // Path must be empty or exactly "/".
128    let path = uri.path();
129    if !path.is_empty() && path != "/" {
130        return Err(invalid("must be an origin without a path"));
131    }
132    Ok(trimmed.trim_end_matches('/').to_string())
133}
134
135/// Binding from a [`CapabilitySlot`] to a concrete pack (`§5.1`).
136#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
137pub struct EnvPackBinding {
138    pub slot: CapabilitySlot,
139    pub kind: PackDescriptor,
140    pub pack_ref: PackId,
141    /// `env-packs/<slot>/answers.json` (env-relative path).
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub answers_ref: Option<PathBuf>,
144    /// Bumped on attach/update/remove/rollback.
145    #[serde(default)]
146    pub generation: u64,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub previous_binding_ref: Option<PathBuf>,
149}
150
151/// An open-namespace capability binding (`§5.1`, Path 3).
152///
153/// Unlike [`EnvPackBinding`] it carries no `slot` field — its slot is always
154/// [`CapabilitySlot::Extension`](crate::CapabilitySlot::Extension). Its identity
155/// is `(kind.path(), instance_id)`: the descriptor path plus an optional
156/// instance selector distinguishing N instances of the same extension type.
157/// Bindings live in [`Environment::extensions`], never in
158/// [`Environment::packs`], so the 1-per-slot rule does not apply; a workload
159/// resolves one by name via `ext://<path>[/<instance>]`
160/// ([`ExtensionRef`](crate::ExtensionRef)) — no typed host interface is wired.
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct ExtensionBinding {
163    pub kind: PackDescriptor,
164    pub pack_ref: PackId,
165    /// Distinguishes N instances of the SAME extension type. `None` ⇒ the
166    /// descriptor path is the whole key (the single default instance). A
167    /// `None` binding and a `Some(..)` binding on the same path coexist; two
168    /// `None` bindings on the same path collide.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub instance_id: Option<String>,
171    /// `extensions/<path>[-<instance>]/answers.json` (env-relative path).
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub answers_ref: Option<PathBuf>,
174    /// Bumped on attach/update/remove/rollback.
175    #[serde(default)]
176    pub generation: u64,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub previous_binding_ref: Option<PathBuf>,
179}
180
181impl ExtensionBinding {
182    /// Per-document invariants. The `(path, instance_id)` uniqueness check is a
183    /// cross-document invariant on [`Environment::validate`] where the sibling
184    /// bindings are in scope.
185    pub fn validate(&self) -> Result<(), SpecError> {
186        if let Some(inst) = &self.instance_id {
187            crate::refs::validate_instance_id(inst).map_err(|e| {
188                SpecError::InvalidExtensionInstanceId {
189                    path: self.kind.path().to_string(),
190                    reason: e.to_string(),
191                }
192            })?;
193        }
194        Ok(())
195    }
196}
197
198/// `greentic.environment.v1` compose-view (`§5.1`).
199#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
200pub struct Environment {
201    pub schema: SchemaVersion,
202    pub environment_id: EnvId,
203    pub name: String,
204    pub host_config: EnvironmentHostConfig,
205    /// One entry per [`CapabilitySlot`]. Use [`Environment::validate`] to enforce.
206    pub packs: Vec<EnvPackBinding>,
207    /// `secret://<env>/credentials/...` reference into `packs[secrets]` (P5).
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub credentials_ref: Option<SecretRef>,
210    #[serde(default)]
211    pub bundles: Vec<BundleDeployment>,
212    #[serde(default)]
213    pub revisions: Vec<Revision>,
214    #[serde(default)]
215    pub traffic_splits: Vec<TrafficSplit>,
216    /// Per-environment messaging provider instances (`Phase M1`). N-per-env;
217    /// unique on `endpoint_id` and on `(provider_type, provider_id)`.
218    #[serde(default)]
219    pub messaging_endpoints: Vec<MessagingEndpoint>,
220    /// Open-namespace extension bindings (`Path 3`). N-per-env; unique on
221    /// `(kind.path(), instance_id)`. Resolved by workloads via
222    /// `ext://<path>[/<instance>]`, never linked as a typed host interface and
223    /// never reported in `doctor`'s `missing_slots` (the namespace is open).
224    #[serde(default)]
225    pub extensions: Vec<ExtensionBinding>,
226    #[serde(default)]
227    pub revocation: RevocationConfig,
228    #[serde(default)]
229    pub retention: RetentionPolicy,
230    #[serde(default)]
231    pub health: HealthStatus,
232}
233
234impl Environment {
235    pub fn schema_str() -> &'static str {
236        SchemaVersion::ENVIRONMENT_V1
237    }
238
239    /// Returns the binding for a slot, if any.
240    pub fn pack_for_slot(&self, slot: CapabilitySlot) -> Option<&EnvPackBinding> {
241        self.packs.iter().find(|b| b.slot == slot)
242    }
243
244    /// Resolve an [`ExtensionRef`] to its binding by `(path, instance_id)` —
245    /// the same key [`Environment::validate`] enforces uniqueness on. Returns
246    /// `None` when no extension matches both the path and the (absence of an)
247    /// instance selector.
248    pub fn extension_for_ref(&self, r: &ExtensionRef) -> Option<&ExtensionBinding> {
249        self.extensions
250            .iter()
251            .find(|b| b.kind.path() == r.path() && b.instance_id.as_deref() == r.instance_id())
252    }
253
254    /// Validates spec-level invariants:
255    /// - schema discriminator matches `greentic.environment.v1`,
256    /// - slot uniqueness across `packs`,
257    /// - extension binding uniqueness on `(kind.path(), instance_id)`,
258    /// - basis-points sums on contained `TrafficSplit` / `BundleDeployment`,
259    /// - `env_id` ownership across `host_config`, `revisions`, `bundles`, and
260    ///   `traffic_splits` (every nested doc carries the same env identifier),
261    /// - referential integrity: split entries reference a `Revision` in this
262    ///   env whose `deployment_id` + `bundle_id` match the split's, and every
263    ///   bundle's `current_revisions` references a `Revision` whose
264    ///   `deployment_id` matches the bundle's. Lifecycle-state checks (e.g.
265    ///   `lifecycle == Ready` for split entries per `§5.3`) stay at apply
266    ///   time — pure data invariants only here.
267    pub fn validate(&self) -> Result<(), SpecError> {
268        if self.schema.as_str() != SchemaVersion::ENVIRONMENT_V1 {
269            return Err(SpecError::SchemaMismatch {
270                expected: SchemaVersion::ENVIRONMENT_V1,
271                actual: self.schema.as_str().to_string(),
272            });
273        }
274
275        if self.host_config.env_id != self.environment_id {
276            return Err(SpecError::EnvIdMismatch {
277                context: "host_config",
278                expected: self.environment_id.clone(),
279                actual: self.host_config.env_id.clone(),
280            });
281        }
282
283        if let Some(url) = self.host_config.public_base_url.as_deref() {
284            validate_public_base_url(url)?;
285        }
286
287        // Sized to the `CapabilitySlot` enum cardinality. Bump in lock-step
288        // when the enum grows.
289        let mut seen = [false; CapabilitySlot::ALL.len()];
290        for binding in &self.packs {
291            let idx = binding.slot as usize;
292            if seen[idx] {
293                return Err(SpecError::DuplicateCapabilitySlot(binding.slot));
294            }
295            seen[idx] = true;
296        }
297
298        // `credentials_ref` is documented as `secret://<env>/credentials/...`.
299        // Without this scope check, a saved Environment could persist a
300        // pointer into a different env's secrets backend and bypass tenant
301        // isolation at resolve time.
302        if let Some(cred_ref) = &self.credentials_ref {
303            let actual = cred_ref.env_segment();
304            if actual != self.environment_id.as_str() {
305                return Err(SpecError::CrossEnvRef {
306                    context: "credentials_ref",
307                    uri: cred_ref.as_str().to_string(),
308                    expected_env: self.environment_id.clone(),
309                    actual_env: actual.to_string(),
310                });
311            }
312        }
313
314        for revision in &self.revisions {
315            revision.validate()?;
316            if revision.env_id != self.environment_id {
317                return Err(SpecError::EnvIdMismatch {
318                    context: "revision",
319                    expected: self.environment_id.clone(),
320                    actual: revision.env_id.clone(),
321                });
322            }
323        }
324
325        for bundle in &self.bundles {
326            if bundle.env_id != self.environment_id {
327                return Err(SpecError::EnvIdMismatch {
328                    context: "bundle_deployment",
329                    expected: self.environment_id.clone(),
330                    actual: bundle.env_id.clone(),
331                });
332            }
333            bundle.validate()?;
334            let mut revision_pack_ids: HashSet<&str> = HashSet::new();
335            for rev_id in &bundle.current_revisions {
336                let referenced = self
337                    .revisions
338                    .iter()
339                    .find(|r| r.revision_id == *rev_id)
340                    .ok_or(SpecError::UnknownRevision(*rev_id))?;
341                if referenced.deployment_id != bundle.deployment_id {
342                    return Err(SpecError::BundleRevisionWrongDeployment {
343                        deployment: bundle.deployment_id,
344                        revision: *rev_id,
345                        actual_deployment: referenced.deployment_id,
346                    });
347                }
348                // A `BundleDeployment` is `(deployment_id, bundle_id)`-shaped;
349                // a revision whose `bundle_id` does not match the deployment's
350                // would let the deployment route or bill a different bundle's
351                // revisions. Reject statically.
352                if referenced.bundle_id != bundle.bundle_id {
353                    return Err(SpecError::BundleRevisionWrongBundle {
354                        deployment: bundle.deployment_id,
355                        revision: *rev_id,
356                        expected_bundle: bundle.bundle_id.clone(),
357                        actual_bundle: referenced.bundle_id.clone(),
358                    });
359                }
360                revision_pack_ids.extend(referenced.pack_list.iter().map(|e| e.pack_id.as_str()));
361            }
362
363            // Cross-ref: every config_overrides pack_id must appear in a
364            // non-archived revision's pack_list for this deployment.
365            // Forward-accept when no such revisions yet exist OR when their
366            // pack_list is empty (the in-memory data the validator can see —
367            // disk lock is the source of truth). The override gets
368            // re-validated on the next env.validate() call once a revision
369            // lands with populated pack_list.
370            if !bundle.config_overrides.is_empty() {
371                let mut deployment_pack_ids: HashSet<&str> = HashSet::new();
372                for rev in self.revisions.iter().filter(|r| {
373                    r.deployment_id == bundle.deployment_id
374                        && r.lifecycle != crate::RevisionLifecycle::Archived
375                }) {
376                    deployment_pack_ids.extend(rev.pack_list.iter().map(|e| e.pack_id.as_str()));
377                }
378                if !deployment_pack_ids.is_empty() {
379                    for override_pack_id in bundle.config_overrides.keys() {
380                        if !deployment_pack_ids.contains(override_pack_id.as_str()) {
381                            return Err(SpecError::ConfigOverridePackNotInRevisions {
382                                deployment: bundle.deployment_id,
383                                pack_id: override_pack_id.clone(),
384                            });
385                        }
386                    }
387                }
388            }
389        }
390
391        for split in &self.traffic_splits {
392            if split.env_id != self.environment_id {
393                return Err(SpecError::EnvIdMismatch {
394                    context: "traffic_split",
395                    expected: self.environment_id.clone(),
396                    actual: split.env_id.clone(),
397                });
398            }
399            split.validate()?;
400            // Resolve the referenced BundleDeployment and assert that its
401            // bundle_id matches the split's. Without this, a split's
402            // (deployment_id, bundle_id) pair can diverge from the
403            // deployment's recorded bundle and cross-route traffic.
404            let referenced_bundle = self
405                .bundles
406                .iter()
407                .find(|b| b.deployment_id == split.deployment_id)
408                .ok_or(SpecError::UnknownDeployment(split.deployment_id))?;
409            if referenced_bundle.bundle_id != split.bundle_id {
410                return Err(SpecError::SplitDeploymentBundleMismatch {
411                    deployment: split.deployment_id,
412                    split_bundle: split.bundle_id.clone(),
413                    deployment_bundle: referenced_bundle.bundle_id.clone(),
414                });
415            }
416            for entry in &split.entries {
417                let referenced = self
418                    .revisions
419                    .iter()
420                    .find(|r| r.revision_id == entry.revision_id)
421                    .ok_or(SpecError::UnknownRevision(entry.revision_id))?;
422                if referenced.deployment_id != split.deployment_id {
423                    return Err(SpecError::SplitRevisionWrongDeployment {
424                        revision: entry.revision_id,
425                        expected_deployment: split.deployment_id,
426                        actual_deployment: referenced.deployment_id,
427                    });
428                }
429                if referenced.bundle_id != split.bundle_id {
430                    return Err(SpecError::SplitRevisionWrongBundle {
431                        revision: entry.revision_id,
432                        expected_bundle: split.bundle_id.clone(),
433                        actual_bundle: referenced.bundle_id.clone(),
434                    });
435                }
436            }
437        }
438
439        // Phase M1: messaging endpoint cross-document invariants. Per-document
440        // checks (schema discriminator, non-empty ids, secret-ref env scope)
441        // live on `MessagingEndpoint::validate`.
442        let mut seen_endpoint_ids = HashSet::with_capacity(self.messaging_endpoints.len());
443        let mut seen_provider_instances = HashSet::with_capacity(self.messaging_endpoints.len());
444        for endpoint in &self.messaging_endpoints {
445            endpoint.validate()?;
446            if endpoint.env_id != self.environment_id {
447                return Err(SpecError::EnvIdMismatch {
448                    context: "messaging_endpoint",
449                    expected: self.environment_id.clone(),
450                    actual: endpoint.env_id.clone(),
451                });
452            }
453            if !seen_endpoint_ids.insert(endpoint.endpoint_id) {
454                return Err(SpecError::DuplicateMessagingEndpoint(endpoint.endpoint_id));
455            }
456            let instance_key = (
457                endpoint.provider_type.as_str(),
458                endpoint.provider_id.as_str(),
459            );
460            if !seen_provider_instances.insert(instance_key) {
461                return Err(SpecError::DuplicateProviderInstance {
462                    provider_type: endpoint.provider_type.clone(),
463                    provider_id: endpoint.provider_id.clone(),
464                });
465            }
466            for bundle_id in &endpoint.linked_bundles {
467                if !self.bundles.iter().any(|b| b.bundle_id == *bundle_id) {
468                    return Err(SpecError::MessagingEndpointBundleNotLinked {
469                        endpoint: endpoint.endpoint_id,
470                        bundle: bundle_id.clone(),
471                    });
472                }
473            }
474            if let Some(welcome) = &endpoint.welcome_flow
475                && !endpoint.linked_bundles.contains(&welcome.bundle_id)
476            {
477                return Err(SpecError::WelcomeFlowBundleNotLinked {
478                    endpoint: endpoint.endpoint_id,
479                    bundle: welcome.bundle_id.clone(),
480                });
481            }
482        }
483
484        // Extension bindings (`Path 3`): open N-per-env namespace, unique on
485        // `(kind.path(), instance_id)`. A `None` instance and a `Some(..)`
486        // instance on the same path coexist; two identical keys collide.
487        let mut seen_extensions = HashSet::with_capacity(self.extensions.len());
488        for ext in &self.extensions {
489            ext.validate()?;
490            let key = (ext.kind.path(), ext.instance_id.as_deref());
491            if !seen_extensions.insert(key) {
492                return Err(SpecError::DuplicateExtension {
493                    path: ext.kind.path().to_string(),
494                    instance_id: ext.instance_id.clone(),
495                });
496            }
497        }
498
499        Ok(())
500    }
501}
502
503#[cfg(test)]
504mod public_base_url_tests {
505    use super::validate_public_base_url;
506
507    #[test]
508    fn accepts_https_origin() {
509        assert_eq!(
510            validate_public_base_url("https://chat.example.com").unwrap(),
511            "https://chat.example.com"
512        );
513    }
514
515    #[test]
516    fn accepts_http_origin() {
517        assert_eq!(
518            validate_public_base_url("http://localhost:8080").unwrap(),
519            "http://localhost:8080"
520        );
521    }
522
523    #[test]
524    fn trims_trailing_slash() {
525        // Match `greentic-start::startup_contract::normalize_public_base_url`
526        // so a value persisted here passes the runtime's gate unchanged.
527        assert_eq!(
528            validate_public_base_url("https://chat.example.com/").unwrap(),
529            "https://chat.example.com"
530        );
531    }
532
533    #[test]
534    fn rejects_path() {
535        let err = validate_public_base_url("https://chat.example.com/api").unwrap_err();
536        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
537    }
538
539    #[test]
540    fn rejects_query() {
541        let err = validate_public_base_url("https://chat.example.com?x=1").unwrap_err();
542        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
543    }
544
545    #[test]
546    fn rejects_fragment() {
547        let err = validate_public_base_url("https://chat.example.com#frag").unwrap_err();
548        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
549    }
550
551    #[test]
552    fn rejects_non_http_scheme() {
553        let err = validate_public_base_url("ftp://chat.example.com").unwrap_err();
554        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
555    }
556
557    #[test]
558    fn rejects_missing_scheme() {
559        let err = validate_public_base_url("chat.example.com").unwrap_err();
560        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
561    }
562
563    #[test]
564    fn rejects_empty_host() {
565        let err = validate_public_base_url("https:///path").unwrap_err();
566        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
567    }
568
569    #[test]
570    fn rejects_whitespace() {
571        let err = validate_public_base_url("https://chat .example.com").unwrap_err();
572        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
573    }
574
575    #[test]
576    fn trims_surrounding_whitespace_before_validation() {
577        // Mirrors `normalize_public_base_url`: trim outer whitespace, reject
578        // inner whitespace.
579        assert_eq!(
580            validate_public_base_url("  https://chat.example.com  ").unwrap(),
581            "https://chat.example.com"
582        );
583    }
584
585    #[test]
586    fn rejects_userinfo() {
587        let err = validate_public_base_url("https://user:pass@example.com").unwrap_err();
588        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
589    }
590
591    #[test]
592    fn rejects_empty_host_in_authority() {
593        // `https://:443` has an empty host but non-empty authority.
594        let err = validate_public_base_url("https://:443").unwrap_err();
595        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
596    }
597
598    #[test]
599    fn rejects_authority_with_bad_port() {
600        // `http::Uri` rejects a non-numeric port at parse time.
601        let err = validate_public_base_url("https://example.com:bad").unwrap_err();
602        assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
603    }
604
605    #[test]
606    fn accepts_ipv6_origin() {
607        // Parity with `greentic-start::normalize_public_base_url`.
608        assert_eq!(
609            validate_public_base_url("https://[::1]:8080").unwrap(),
610            "https://[::1]:8080"
611        );
612    }
613}