1use std::collections::{BTreeMap, BTreeSet};
2use std::io::Read;
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::Context;
6use greentic_types::{ExtensionInline, decode_pack_manifest};
7use serde::Deserialize;
8use zip::ZipArchive;
9
10use crate::domains::{self, Domain};
11
12pub const EXT_STATIC_ROUTES_V1: &str = "greentic.static-routes.v1";
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum RouteScopeSegment {
16 Literal(String),
17 Tenant,
18 Team,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum CacheStrategy {
23 None,
24 PublicMaxAge { max_age_seconds: u64 },
25}
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct StaticRouteDescriptor {
29 pub route_id: String,
30 pub pack_id: String,
31 pub pack_path: PathBuf,
32 pub public_path: String,
33 pub source_root: String,
34 pub index_file: Option<String>,
35 pub spa_fallback: Option<String>,
36 pub tenant_scoped: bool,
37 pub team_scoped: bool,
38 pub cache_strategy: CacheStrategy,
39 pub route_segments: Vec<RouteScopeSegment>,
40}
41
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
43pub struct StaticRoutePlan {
44 pub routes: Vec<StaticRouteDescriptor>,
45 pub warnings: Vec<String>,
46 pub blocking_failures: Vec<String>,
47}
48
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct ReservedRouteSet {
51 exact_paths: BTreeSet<String>,
52 prefix_paths: BTreeSet<String>,
53}
54
55impl ReservedRouteSet {
56 pub fn operator_defaults() -> Self {
57 let mut reserved = Self::default();
58 for path in [
59 "/healthz",
60 "/readyz",
61 "/status",
62 "/runtime/drain",
63 "/runtime/resume",
64 "/runtime/shutdown",
65 "/deployments/stage",
66 "/deployments/warm",
67 "/deployments/activate",
68 "/deployments/rollback",
69 "/deployments/complete-drain",
70 "/config/publish",
71 "/cache/invalidate",
72 "/observability/log-level",
73 ] {
74 reserved.insert_exact(path);
75 }
76 reserved.insert_prefix("/api/onboard");
77 reserved.insert_prefix("/runtime");
78 reserved.insert_prefix("/deployments");
79 reserved.insert_prefix("/config");
80 reserved.insert_prefix("/cache");
81 reserved.insert_prefix("/observability");
82 for domain in [
83 Domain::Messaging,
84 Domain::Events,
85 Domain::Secrets,
86 Domain::OAuth,
87 ] {
88 let name = domains::domain_name(domain);
89 reserved.insert_prefix(&format!("/v1/{name}/ingress"));
90 reserved.insert_prefix(&format!("/{name}/ingress"));
91 }
92 reserved
93 }
94
95 pub fn insert_exact(&mut self, path: &str) {
96 self.exact_paths.insert(normalize_public_path(path));
97 }
98
99 pub fn insert_prefix(&mut self, path: &str) {
100 self.prefix_paths.insert(normalize_public_path(path));
101 }
102
103 pub fn conflicts_with(&self, public_path: &str) -> bool {
104 let normalized = normalize_public_path(public_path);
105 self.exact_paths.contains(&normalized)
106 || self
107 .prefix_paths
108 .iter()
109 .any(|prefix| path_has_prefix(&normalized, prefix))
110 }
111}
112
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct StaticRouteMatch<'a> {
115 pub descriptor: &'a StaticRouteDescriptor,
116 pub asset_path: String,
117 pub request_is_directory: bool,
118}
119
120#[derive(Clone, Debug, Default, PartialEq, Eq)]
121pub struct ActiveRouteTable {
122 routes: Vec<StaticRouteDescriptor>,
123}
124
125impl ActiveRouteTable {
126 pub fn from_plan(plan: &StaticRoutePlan) -> Self {
127 let mut routes = plan.routes.clone();
128 routes.sort_by(|a, b| {
129 b.route_segments
130 .len()
131 .cmp(&a.route_segments.len())
132 .then_with(|| a.public_path.cmp(&b.public_path))
133 });
134 Self { routes }
135 }
136
137 pub fn routes(&self) -> &[StaticRouteDescriptor] {
138 &self.routes
139 }
140
141 pub fn match_request<'a>(&'a self, request_path: &str) -> Option<StaticRouteMatch<'a>> {
142 let normalized = request_path
143 .trim_start_matches('/')
144 .split('/')
145 .filter(|segment| !segment.is_empty())
146 .collect::<Vec<_>>();
147 let request_is_directory = request_path.ends_with('/');
148 for descriptor in &self.routes {
149 if normalized.len() < descriptor.route_segments.len() {
150 continue;
151 }
152 let mut matched = true;
153 for (route_segment, request_segment) in
154 descriptor.route_segments.iter().zip(normalized.iter())
155 {
156 match route_segment {
157 RouteScopeSegment::Literal(expected) if expected != request_segment => {
158 matched = false;
159 break;
160 }
161 RouteScopeSegment::Literal(_)
162 | RouteScopeSegment::Tenant
163 | RouteScopeSegment::Team => {}
164 }
165 }
166 if !matched {
167 continue;
168 }
169 let asset_path = normalized[descriptor.route_segments.len()..].join("/");
170 return Some(StaticRouteMatch {
171 descriptor,
172 asset_path,
173 request_is_directory,
174 });
175 }
176 None
177 }
178}
179
180#[derive(Debug, Deserialize)]
181struct StaticRoutesExtensionV1 {
182 #[serde(default = "default_schema_version")]
183 schema_version: u32,
184 #[serde(default)]
185 routes: Vec<StaticRouteRecord>,
186}
187
188#[derive(Clone, Debug, Deserialize)]
189struct StaticRouteRecord {
190 #[serde(default)]
191 id: Option<String>,
192 public_path: String,
193 source_root: String,
194 #[serde(default)]
195 index_file: Option<String>,
196 #[serde(default)]
197 spa_fallback: Option<String>,
198 #[serde(default)]
199 tenant: bool,
200 #[serde(default)]
201 team: bool,
202 #[serde(default)]
203 cache: Option<StaticRouteCacheRecord>,
204}
205
206#[derive(Clone, Debug, Deserialize)]
207struct StaticRouteCacheRecord {
208 strategy: String,
209 #[serde(default)]
210 max_age_seconds: Option<u64>,
211}
212
213fn default_schema_version() -> u32 {
214 1
215}
216
217pub fn discover_from_bundle(
218 bundle_root: &Path,
219 reserved_routes: &ReservedRouteSet,
220) -> anyhow::Result<StaticRoutePlan> {
221 let mut plan = StaticRoutePlan::default();
222 let pack_paths = collect_runtime_pack_paths(bundle_root)?;
223 for pack_path in pack_paths {
224 let descriptors = match read_pack_static_routes(&pack_path) {
225 Ok(Some(descriptors)) => descriptors,
226 Ok(None) => continue,
227 Err(err) => {
228 plan.blocking_failures.push(err.to_string());
229 continue;
230 }
231 };
232 plan.routes.extend(descriptors);
233 }
234 validate_plan(&mut plan, reserved_routes);
235 Ok(plan)
236}
237
238pub fn resolve_asset_path(route_match: &StaticRouteMatch<'_>) -> Option<String> {
239 if route_match.asset_path.is_empty() || route_match.request_is_directory {
240 return route_match.descriptor.index_file.clone();
241 }
242 Some(route_match.asset_path.clone())
243}
244
245pub fn fallback_asset_path(route_match: &StaticRouteMatch<'_>) -> Option<String> {
246 route_match.descriptor.spa_fallback.clone()
247}
248
249pub fn cache_control_value(strategy: &CacheStrategy) -> Option<String> {
250 match strategy {
251 CacheStrategy::None => None,
252 CacheStrategy::PublicMaxAge { max_age_seconds } => {
253 Some(format!("public, max-age={max_age_seconds}"))
254 }
255 }
256}
257
258fn collect_runtime_pack_paths(bundle_root: &Path) -> anyhow::Result<Vec<PathBuf>> {
259 let mut by_path = BTreeMap::new();
260 let discover = if bundle_root.join("greentic.demo.yaml").exists() {
261 domains::discover_provider_packs_cbor_only
262 } else {
263 domains::discover_provider_packs
264 };
265 for domain in [
266 Domain::Messaging,
267 Domain::Events,
268 Domain::Secrets,
269 Domain::OAuth,
270 ] {
271 for pack in discover(bundle_root, domain)? {
272 by_path.entry(pack.path.clone()).or_insert(pack.path);
273 }
274 }
275 Ok(by_path.into_values().collect())
276}
277
278fn read_pack_static_routes(pack_path: &Path) -> anyhow::Result<Option<Vec<StaticRouteDescriptor>>> {
279 let file = std::fs::File::open(pack_path)?;
280 let mut archive = ZipArchive::new(file)?;
281 let mut manifest_entry = archive.by_name("manifest.cbor").map_err(|err| {
282 anyhow::anyhow!(
283 "failed to open manifest.cbor in {}: {err}",
284 pack_path.display()
285 )
286 })?;
287 let mut bytes = Vec::new();
288 manifest_entry.read_to_end(&mut bytes)?;
289 let manifest = decode_pack_manifest(&bytes)
290 .with_context(|| format!("failed to decode pack manifest in {}", pack_path.display()))?;
291 let Some(extension) = manifest
292 .extensions
293 .as_ref()
294 .and_then(|extensions| extensions.get(EXT_STATIC_ROUTES_V1))
295 else {
296 return Ok(None);
297 };
298 let inline = extension
299 .inline
300 .as_ref()
301 .ok_or_else(|| anyhow::anyhow!("static-routes extension inline payload missing"))?;
302 let ExtensionInline::Other(value) = inline else {
303 anyhow::bail!("static-routes extension inline payload has unexpected type");
304 };
305 let decoded: StaticRoutesExtensionV1 = serde_json::from_value(value.clone())
306 .with_context(|| "failed to parse greentic.static-routes.v1 payload")?;
307 if decoded.schema_version != 1 {
308 anyhow::bail!(
309 "unsupported static-routes extension schema_version={} in {}",
310 decoded.schema_version,
311 pack_path.display()
312 );
313 }
314 let pack_id = manifest.pack_id.as_str().to_string();
315 let mut routes = Vec::new();
316 for (idx, route) in decoded.routes.into_iter().enumerate() {
317 routes.push(normalize_route_descriptor(&pack_id, pack_path, idx, route)?);
318 }
319 Ok(Some(routes))
320}
321
322fn normalize_route_descriptor(
323 pack_id: &str,
324 pack_path: &Path,
325 idx: usize,
326 route: StaticRouteRecord,
327) -> anyhow::Result<StaticRouteDescriptor> {
328 if route.team && !route.tenant {
329 anyhow::bail!(
330 "static route {} in {} sets team=true but tenant=false",
331 route.id.as_deref().unwrap_or("<unnamed>"),
332 pack_path.display()
333 );
334 }
335 let public_path = normalize_public_path(&route.public_path);
336 let route_segments = parse_route_segments(&public_path)?;
337 let uses_tenant = route_segments
338 .iter()
339 .any(|segment| matches!(segment, RouteScopeSegment::Tenant));
340 let uses_team = route_segments
341 .iter()
342 .any(|segment| matches!(segment, RouteScopeSegment::Team));
343 if route.tenant != uses_tenant {
344 anyhow::bail!(
345 "static route {} in {} has inconsistent tenant flag/public_path",
346 route.id.as_deref().unwrap_or("<unnamed>"),
347 pack_path.display()
348 );
349 }
350 if route.team != uses_team {
351 anyhow::bail!(
352 "static route {} in {} has inconsistent team flag/public_path",
353 route.id.as_deref().unwrap_or("<unnamed>"),
354 pack_path.display()
355 );
356 }
357
358 let source_root = normalize_relative_asset_path(&route.source_root).ok_or_else(|| {
359 anyhow::anyhow!(
360 "static route {} in {} has invalid source_root {}",
361 route.id.as_deref().unwrap_or("<unnamed>"),
362 pack_path.display(),
363 route.source_root
364 )
365 })?;
366 let index_file = normalize_optional_relative_asset_path(route.index_file)?;
367 let spa_fallback = normalize_optional_relative_asset_path(route.spa_fallback)?;
368 let cache_strategy =
369 normalize_cache_strategy(route.cache.as_ref(), pack_path, route.id.as_deref())?;
370
371 Ok(StaticRouteDescriptor {
372 route_id: route.id.unwrap_or_else(|| format!("{pack_id}::{idx}")),
373 pack_id: pack_id.to_string(),
374 pack_path: pack_path.to_path_buf(),
375 public_path,
376 source_root,
377 index_file,
378 spa_fallback,
379 tenant_scoped: route.tenant,
380 team_scoped: route.team,
381 cache_strategy,
382 route_segments,
383 })
384}
385
386fn normalize_cache_strategy(
387 cache: Option<&StaticRouteCacheRecord>,
388 pack_path: &Path,
389 route_id: Option<&str>,
390) -> anyhow::Result<CacheStrategy> {
391 let Some(cache) = cache else {
392 return Ok(CacheStrategy::None);
393 };
394 match cache.strategy.trim() {
395 "" | "none" => Ok(CacheStrategy::None),
396 "public-max-age" => Ok(CacheStrategy::PublicMaxAge {
397 max_age_seconds: cache.max_age_seconds.ok_or_else(|| {
398 anyhow::anyhow!(
399 "static route {} in {} uses public-max-age without max_age_seconds",
400 route_id.unwrap_or("<unnamed>"),
401 pack_path.display()
402 )
403 })?,
404 }),
405 other => anyhow::bail!(
406 "static route {} in {} uses unsupported cache.strategy {}",
407 route_id.unwrap_or("<unnamed>"),
408 pack_path.display(),
409 other
410 ),
411 }
412}
413
414fn normalize_optional_relative_asset_path(value: Option<String>) -> anyhow::Result<Option<String>> {
415 match value {
416 Some(value) => normalize_relative_asset_path(&value)
417 .map(Some)
418 .ok_or_else(|| anyhow::anyhow!("invalid asset path {}", value)),
419 None => Ok(None),
420 }
421}
422
423fn normalize_relative_asset_path(path: &str) -> Option<String> {
424 let mut segments = Vec::new();
425 for component in Path::new(path).components() {
426 match component {
427 Component::Normal(segment) => segments.push(segment.to_string_lossy().to_string()),
428 Component::CurDir => {}
429 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
430 }
431 }
432 if segments.is_empty() {
433 return None;
434 }
435 Some(segments.join("/"))
436}
437
438fn parse_route_segments(path: &str) -> anyhow::Result<Vec<RouteScopeSegment>> {
439 let segments = path
440 .trim_start_matches('/')
441 .split('/')
442 .filter(|segment| !segment.is_empty())
443 .collect::<Vec<_>>();
444 if segments.is_empty() {
445 anyhow::bail!("public_path must not be /");
446 }
447 let mut parsed = Vec::new();
448 for segment in segments {
449 match segment {
450 "{tenant}" => parsed.push(RouteScopeSegment::Tenant),
451 "{team}" => parsed.push(RouteScopeSegment::Team),
452 _ if segment.contains('{') || segment.contains('}') => {
453 anyhow::bail!("unsupported public_path segment {}", segment)
454 }
455 _ => parsed.push(RouteScopeSegment::Literal(segment.to_string())),
456 }
457 }
458 let team_pos = parsed
459 .iter()
460 .position(|segment| matches!(segment, RouteScopeSegment::Team));
461 let tenant_pos = parsed
462 .iter()
463 .position(|segment| matches!(segment, RouteScopeSegment::Tenant));
464 if let Some(team_pos) = team_pos {
465 let Some(tenant_pos) = tenant_pos else {
466 anyhow::bail!("public_path uses {{team}} without {{tenant}}");
467 };
468 if team_pos <= tenant_pos {
469 anyhow::bail!("public_path must place {{team}} after {{tenant}}");
470 }
471 }
472 Ok(parsed)
473}
474
475fn validate_plan(plan: &mut StaticRoutePlan, reserved_routes: &ReservedRouteSet) {
476 let mut seen_paths = BTreeMap::<String, String>::new();
477 for route in &plan.routes {
478 if reserved_routes.conflicts_with(&route.public_path) {
479 plan.blocking_failures.push(format!(
480 "static route {} conflicts with reserved operator path space at {}",
481 route.route_id, route.public_path
482 ));
483 }
484 if let Some(existing) = seen_paths.insert(route.public_path.clone(), route.route_id.clone())
485 {
486 plan.blocking_failures.push(format!(
487 "static route {} duplicates public_path {} already claimed by {}",
488 route.route_id, route.public_path, existing
489 ));
490 }
491 }
492 for i in 0..plan.routes.len() {
493 for j in (i + 1)..plan.routes.len() {
494 let left = &plan.routes[i];
495 let right = &plan.routes[j];
496 if paths_overlap(&left.public_path, &right.public_path) {
497 plan.blocking_failures.push(format!(
498 "static routes {} ({}) and {} ({}) overlap ambiguously",
499 left.route_id, left.public_path, right.route_id, right.public_path
500 ));
501 }
502 }
503 }
504}
505
506fn paths_overlap(left: &str, right: &str) -> bool {
507 path_has_prefix(left, right) || path_has_prefix(right, left)
508}
509
510fn path_has_prefix(path: &str, prefix: &str) -> bool {
511 if path == prefix {
512 return true;
513 }
514 let prefix = prefix.trim_end_matches('/');
515 path.strip_prefix(prefix)
516 .map(|rest| rest.starts_with('/'))
517 .unwrap_or(false)
518}
519
520fn normalize_public_path(path: &str) -> String {
521 let trimmed = path.trim();
522 let normalized = if trimmed.starts_with('/') {
523 trimmed.to_string()
524 } else {
525 format!("/{trimmed}")
526 };
527 if normalized.len() > 1 {
528 normalized.trim_end_matches('/').to_string()
529 } else {
530 normalized
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use std::collections::BTreeMap;
537 use std::io::Write;
538
539 use greentic_types::{ExtensionRef, PackId, PackKind, PackManifest, PackSignatures};
540 use semver::Version;
541 use serde_json::json;
542 use tempfile::tempdir;
543 use zip::write::FileOptions;
544
545 use super::*;
546
547 fn write_pack(path: &Path, extension_payload: serde_json::Value) {
548 let mut extensions = BTreeMap::new();
549 extensions.insert(
550 EXT_STATIC_ROUTES_V1.to_string(),
551 ExtensionRef {
552 kind: EXT_STATIC_ROUTES_V1.to_string(),
553 version: "v1".to_string(),
554 digest: None,
555 location: None,
556 inline: Some(greentic_types::ExtensionInline::Other(extension_payload)),
557 },
558 );
559 let manifest = PackManifest {
560 schema_version: "pack-v1".to_string(),
561 pack_id: PackId::new("web-pack").expect("pack id"),
562 name: None,
563 version: Version::parse("0.1.0").expect("version"),
564 kind: PackKind::Provider,
565 publisher: "demo".to_string(),
566 components: Vec::new(),
567 flows: Vec::new(),
568 dependencies: Vec::new(),
569 capabilities: Vec::new(),
570 secret_requirements: Vec::new(),
571 signatures: PackSignatures::default(),
572 bootstrap: None,
573 extensions: Some(extensions),
574 };
575 let file = std::fs::File::create(path).expect("create pack");
576 let mut zip = zip::ZipWriter::new(file);
577 zip.start_file("manifest.cbor", FileOptions::<()>::default())
578 .expect("start manifest");
579 let bytes = greentic_types::encode_pack_manifest(&manifest).expect("manifest bytes");
580 zip.write_all(&bytes).expect("write manifest");
581 zip.finish().expect("finish zip");
582 }
583
584 #[test]
585 fn discovers_static_routes_from_manifest_extension() {
586 let tmp = tempdir().expect("tempdir");
587 let providers = tmp.path().join("providers").join("messaging");
588 std::fs::create_dir_all(&providers).expect("providers dir");
589 let pack_path = providers.join("web.gtpack");
590 write_pack(
591 &pack_path,
592 json!({
593 "schema_version": 1,
594 "routes": [{
595 "id": "docs",
596 "public_path": "/v1/web/docs",
597 "source_root": "assets/site",
598 "index_file": "index.html",
599 "spa_fallback": "index.html",
600 "cache": {"strategy": "public-max-age", "max_age_seconds": 60}
601 }]
602 }),
603 );
604
605 let plan = discover_from_bundle(tmp.path(), &ReservedRouteSet::operator_defaults())
606 .expect("discover plan");
607 assert!(
608 plan.blocking_failures.is_empty(),
609 "{:?}",
610 plan.blocking_failures
611 );
612 assert_eq!(plan.routes.len(), 1);
613 assert_eq!(plan.routes[0].public_path, "/v1/web/docs");
614 assert_eq!(
615 plan.routes[0].cache_strategy,
616 CacheStrategy::PublicMaxAge {
617 max_age_seconds: 60
618 }
619 );
620 }
621
622 #[test]
623 fn rejects_reserved_and_overlapping_routes() {
624 let reserved = ReservedRouteSet::operator_defaults();
625 let mut plan = StaticRoutePlan {
626 routes: vec![
627 StaticRouteDescriptor {
628 route_id: "one".into(),
629 pack_id: "pack".into(),
630 pack_path: PathBuf::from("one.gtpack"),
631 public_path: "/api/onboard/docs".into(),
632 source_root: "assets".into(),
633 index_file: None,
634 spa_fallback: None,
635 tenant_scoped: false,
636 team_scoped: false,
637 cache_strategy: CacheStrategy::None,
638 route_segments: parse_route_segments("/api/onboard/docs").expect("segments"),
639 },
640 StaticRouteDescriptor {
641 route_id: "two".into(),
642 pack_id: "pack".into(),
643 pack_path: PathBuf::from("two.gtpack"),
644 public_path: "/v1/web/docs/admin".into(),
645 source_root: "assets".into(),
646 index_file: None,
647 spa_fallback: None,
648 tenant_scoped: false,
649 team_scoped: false,
650 cache_strategy: CacheStrategy::None,
651 route_segments: parse_route_segments("/v1/web/docs/admin").expect("segments"),
652 },
653 StaticRouteDescriptor {
654 route_id: "three".into(),
655 pack_id: "pack".into(),
656 pack_path: PathBuf::from("three.gtpack"),
657 public_path: "/v1/web/docs".into(),
658 source_root: "assets".into(),
659 index_file: None,
660 spa_fallback: None,
661 tenant_scoped: false,
662 team_scoped: false,
663 cache_strategy: CacheStrategy::None,
664 route_segments: parse_route_segments("/v1/web/docs").expect("segments"),
665 },
666 ],
667 warnings: Vec::new(),
668 blocking_failures: Vec::new(),
669 };
670 validate_plan(&mut plan, &reserved);
671 assert_eq!(plan.blocking_failures.len(), 2);
672 }
673
674 #[test]
675 fn active_route_table_matches_placeholders() {
676 let route = StaticRouteDescriptor {
677 route_id: "tenant-gui".into(),
678 pack_id: "web".into(),
679 pack_path: PathBuf::from("web.gtpack"),
680 public_path: "/v1/web/webchat/{tenant}/{team}".into(),
681 source_root: "assets/webchat".into(),
682 index_file: Some("index.html".into()),
683 spa_fallback: Some("index.html".into()),
684 tenant_scoped: true,
685 team_scoped: true,
686 cache_strategy: CacheStrategy::None,
687 route_segments: parse_route_segments("/v1/web/webchat/{tenant}/{team}")
688 .expect("segments"),
689 };
690 let table = ActiveRouteTable::from_plan(&StaticRoutePlan {
691 routes: vec![route],
692 warnings: Vec::new(),
693 blocking_failures: Vec::new(),
694 });
695 let matched = table
696 .match_request("/v1/web/webchat/demo/default/app.js")
697 .expect("route match");
698 assert_eq!(matched.asset_path, "app.js");
699 }
700}