Skip to main content

cloud/
route_table.rs

1//! The compiled route table (R898-F1 / W348 §2.2) — ONE ordered artifact per
2//! domain, rendered to both front doors.
3//!
4//! ## What this is, and what it replaces
5//!
6//! `DomainConfig.routes` is the declaration; this module is the compilation of
7//! that declaration into the thing a door can execute: `{path, mode, resolved
8//! origin, headers, auth}` per entry, in manifest order. It is the widening of
9//! R746/R749-F3's `route_headers_json`, which compiled the same table down to
10//! its header column and is live on yah.dev today (`x-route-header-probe`).
11//!
12//! **One producer, two renderers** is the whole point. A per-path capability
13//! built into one door has to be built a second time the moment the domain
14//! flips `front_door`, and it does not merely cost twice — it *evaporates*, as
15//! `/api/releases` did when yah.dev went grey and `MESOFACT_BACKEND_ORIGIN`
16//! stopped executing with nothing anywhere reporting it (W348 §0.3, §2.3).
17//!
18//! ## The resolved origin is the only genuinely new datum
19//!
20//! Everything else an entry carries is already in the manifest. The origin is
21//! not, and it is **placement-time**:
22//!
23//! | mode | resolves to | by |
24//! |---|---|---|
25//! | `static` | the component's asset origin (`<cdn_base>/<service>/<env>`) | [`CdnPlacement::asset_origin`] |
26//! | `static` + `bucket` | the bucket's Worker R2 binding name — no placement | [`r2_binding_name`] |
27//! | `backend` | the deployed unit's address, keyed by its mesh identity | [`inner_door::component_workload_ident`](crate::inner_door::component_workload_ident) |
28//! | `redirect` | nothing — it carries its own target + status | — |
29//!
30//! The declared `origin` field on [`RouteMode::Backend`] is deliberately NOT
31//! the answer: it is the Worker arm's `fetch()` target and nothing reads it
32//! under passway (`.yah/domains/api-noisetable-com.toml` says so in its own
33//! words, in the noisetable camp). A table that echoed it would be wrong on the
34//! door that actually serves production.
35//!
36//! So placement arrives as a [`RoutePlacement`] — the same shape
37//! [`InnerDoorPlan::routes_file`](crate::inner_door::InnerDoorPlan::routes_file)
38//! already uses for upstream addresses, for the same reason: which node a unit
39//! landed on is not config. An unresolved origin is **refused**, never skipped
40//! — a dropped entry does not 503, it falls through to a shorter match (the
41//! catch-all, usually) and serves the wrong thing with a 200.
42//!
43//! ## Ordering and matching are pinned, not chosen here
44//!
45//! Manifest order, **first match wins, no merging across rules** — R746 pinned
46//! it in TS (`applyRouteHeaders`, `oss/mesofact/packages/mesofact-edge/src/router.ts`)
47//! and R749-F3 carried it into Rust (`mesofact::route_headers`). One path has
48//! one entry, decided where the route was decided. The manifests already
49//! document it as their contract: `noisetable-com.toml` puts `/app/*` above
50//! `/*` precisely because of it.
51//!
52//! [`matches_route_pattern`] is segment-aware for the same reason both of those
53//! are: a bare `starts_with` routes `/application` to the `/app` entry. It is a
54//! third copy of a two-sided wire format rather than a call into either — the
55//! Worker is TypeScript and `mesofact` is a separately-released crate in
56//! another workspace that `cloud` does not depend on — and it is handled the
57//! way this repo already handles that risk for passway's
58//! `path_route::mount_from_component`: one producer, and a test pinning the
59//! shared cases.
60//!
61//! ## Why the header projection is still the wire value
62//!
63//! [`RouteTable::headers_json`] is the header column of this same table, byte-
64//! identical to what `DomainConfig::route_headers_json` emits — and that string,
65//! not the full table, is still what `ROUTE_HEADERS` / `MESOFACT_ROUTE_HEADERS`
66//! carry. Widening those bindings is R898-F2 (passway) and R898-F3 (Worker).
67//! Shipping the wide table into them here would change deployed behaviour
68//! before either consumer can read it: every headerless route becomes a table
69//! entry, so `app.yah.dev`, `chat.yah.dev` and `scrabcake.net.yah.dev` — each a
70//! single route declaring no headers, each `"[]"` today — would start setting
71//! `MESOFACT_ROUTE_HEADERS` on their deploys.
72//!
73//! ## A backend entry carries its prefix rewrite (R898-F3)
74//!
75//! The two backend seams that exist today are **not** identity proxies:
76//! `/api/issues/42` reaches the issue tracker as `/issues/42`, and
77//! `/api/releases/v1.2.3` reaches the almanac as `/releases/v1.2.3`. Those
78//! rewrites lived inside the Worker's two hardcoded `if` blocks (R455-T4),
79//! which is precisely what R898-F3 deleted — so an origin-only entry would have
80//! silently started proxying `/api/issues` to `<origin>/api/issues` and changed
81//! the upstream contract with no diff naming it.
82//!
83//! So [`ResolvedRouteMode::Backend`] carries an optional [`RouteRewrite`]:
84//! `{from, to}`, the public prefix the matched path carries and what it becomes
85//! at the origin. It is route DATA, declared as `origin_path` on
86//! [`RouteMode::Backend`] — not an escape hatch, and not a per-door special
87//! case. The alternative was restating the two routes so the public path equals
88//! the origin path, which this repo cannot do: it does not own either upstream's
89//! path layout, and both are live contracts.
90//!
91//! ## R560-F13 folds in here
92//!
93//! "One Worker domain fronting several R2 buckets" is this table restricted to
94//! `static` entries whose sources differ. Same artifact, narrower slice — W279
95//! Gap C predicted the fold ("path-prefix -> bucket is just a route list").
96//!
97//! A bucket route does NOT resolve to an HTTP origin, and that is the one
98//! deliberate asymmetry: the buckets it exists for (noisetable-releases) have
99//! no public hostname to fetch, and a bucket root is not a placement fact
100//! anyway — the manifest names it outright. So [`RouteMode::StaticBucket`]
101//! compiles to [`ResolvedRouteMode::StaticBucket`], carrying the Worker binding
102//! it reads through ([`r2_binding_name`]), and the Worker's binding set gains
103//! one R2 bucket binding per distinct bucket ([`r2_bucket_bindings`]), derived
104//! from the same entries the table ships so the two cannot name different
105//! bindings. The key is the request path minus its leading slash, unchanged:
106//! xlb derives a blob's key from the URL path it serves at.
107
108use std::collections::BTreeMap;
109
110use anyhow::{bail, Result};
111use serde::Serialize;
112
113use crate::config::{
114    domain_serving_service, load_domains, split_component_ref, DomainConfig, DomainRoute, RouteMode,
115};
116use crate::inner_door::component_workload_ident;
117
118/// The placement-time facts a declared route cannot answer about itself.
119///
120/// Two separate methods rather than one origin lookup because the two
121/// resolutions have nothing in common: a static route's origin is a published
122/// *storage* prefix that exists before anything is deployed, and a backend
123/// route's is the address of a running unit. Collapsing them would force every
124/// implementor to re-derive the mode from the component ref.
125///
126/// `None` from either is a refused compile ([`DomainConfig::route_table`]), not
127/// a skipped entry.
128pub trait RoutePlacement {
129    /// Where a `static` route's component publishes its bytes.
130    ///
131    /// `path` is the route's own pattern, present for the multi-bucket case
132    /// (R560-F13) where the prefix, not the component, picks the origin.
133    fn asset_origin(&self, component: &str, path: &str) -> Option<String>;
134
135    /// Where a `backend` route's deployed unit is reachable.
136    fn backend_origin(&self, component: &str, path: &str) -> Option<String>;
137
138    /// Path prefixes the door fronting this domain requires a bearer on —
139    /// `PasswayAuth::require_prefixes`, verbatim. Default: an anonymous door,
140    /// which is every Worker-fronted domain (the Worker arm has no bearer auth
141    /// at all).
142    fn auth_required_prefixes(&self) -> &[String] {
143        &[]
144    }
145}
146
147/// The origin resolution that exists today: the CDN prefix for static routes,
148/// and a mesh-identity-keyed address map for backend ones.
149///
150/// `addresses` is keyed by **mesh identity**, not by component ref, so it takes
151/// [`InnerDoorPlan::resolve_addresses`](crate::inner_door::InnerDoorPlan::resolve_addresses)'
152/// output shape directly and the identity derivation stays in the one place
153/// that owns it ([`component_workload_ident`]).
154#[derive(Debug, Clone, Default)]
155pub struct CdnPlacement {
156    /// The tier's CDN origin, e.g. `https://cdn.yah.dev`. Trailing slash
157    /// tolerated.
158    pub cdn_base: String,
159    /// Mirror env the publisher wrote under, e.g. `prod`.
160    pub env: String,
161    /// `mesh ident -> host:port` for every deployed unit a backend route names.
162    pub addresses: BTreeMap<String, String>,
163    /// `PasswayAuth::require_prefixes` of the door fronting this domain.
164    pub auth_required_prefixes: Vec<String>,
165}
166
167impl RoutePlacement for CdnPlacement {
168    /// `<cdn_base>/<service>/<env>` — the same string
169    /// `reconciler::domain::plan_domain_worker` computes and the same one a
170    /// mirror's `providers.static.asset_origin` carries. The component's
171    /// `mount` is deliberately absent: the front door fetches
172    /// `${ASSET_ORIGIN}/<request path>`, so the mount is already in the path
173    /// (see `mesofact_static::publish_prefix`).
174    fn asset_origin(&self, component: &str, _path: &str) -> Option<String> {
175        cdn_asset_origin(&self.cdn_base, &self.env, component)
176    }
177
178    fn backend_origin(&self, component: &str, _path: &str) -> Option<String> {
179        let (service, id) = split_component_ref(component)?;
180        self.addresses
181            .get(&component_workload_ident(service, id))
182            .map(|addr| format!("http://{addr}"))
183    }
184
185    fn auth_required_prefixes(&self) -> &[String] {
186        &self.auth_required_prefixes
187    }
188}
189
190/// The Worker binding name a bucket route reads its bucket through:
191/// `noisetable-releases` → `R2_NOISETABLE_RELEASES`.
192///
193/// Deterministic, so the table entry and the binding list agree without either
194/// carrying a lookup, and collision-free: an R2 bucket name is lowercase
195/// letters, digits and hyphens only (refused otherwise at parse time), so
196/// upper-casing and `-` → `_` is injective. The `R2_` prefix keeps every such
197/// binding clear of the plain-text ones (`ASSET_ORIGIN`, `ROUTE_TABLE`, …).
198pub fn r2_binding_name(bucket: &str) -> String {
199    format!("R2_{}", bucket.to_ascii_uppercase().replace('-', "_"))
200}
201
202/// One R2 bucket binding per DISTINCT bucket the entries read, in first-use
203/// (manifest) order: `(binding name, bucket name)`.
204///
205/// Derived from compiled entries rather than from the declaration so the
206/// binding list a Worker deploys with is a function of the very table it ships
207/// — an entry naming a binding the upload did not create would 502 at the door.
208pub fn r2_bucket_bindings<'a>(
209    entries: impl IntoIterator<Item = &'a RouteTableEntry>,
210) -> Vec<(String, String)> {
211    let mut out: Vec<(String, String)> = Vec::new();
212    for entry in entries {
213        if let ResolvedRouteMode::StaticBucket { bucket, binding } = &entry.mode {
214            if !out.iter().any(|(b, _)| b == binding) {
215                out.push((binding.clone(), bucket.clone()));
216            }
217        }
218    }
219    out
220}
221
222/// `<cdn_base>/<service>/<env>` — where a static component's published bytes
223/// land, and the one spelling of that string.
224///
225/// A free function because it has two callers that must not drift:
226/// [`CdnPlacement`] (the production door) and [`WorkerAssets::PerComponent`]
227/// (the alias tier's Worker). `None` on a component ref that is not
228/// `<service>/<component-id>`, which [`DomainConfig::route_table`] turns into a
229/// refused compile naming the route.
230pub(crate) fn cdn_asset_origin(cdn_base: &str, env: &str, component: &str) -> Option<String> {
231    let (service, _) = split_component_ref(component)?;
232    Some(format!("{}/{}/{}", cdn_base.trim_end_matches('/'), service, env))
233}
234
235/// How the Worker arm resolves a **static** route's origin (R898-T4).
236///
237/// Two shapes because the Worker arm is configured from two sources that
238/// differ in exactly this: a mirror's static slot carries ONE deployed
239/// `asset_origin` field, while a domain manifest carries a component ref per
240/// route. Collapsing both to the deployed string is what made a second static
241/// route meaningless — every entry would resolve to the same origin, which is
242/// `plan_domain_worker`'s `find_map`-the-first defect wearing a table.
243#[derive(Debug, Clone, Copy)]
244pub enum WorkerAssets<'a> {
245    /// Every static route serves from this one origin — the mirror-driven
246    /// path, where the slot's `asset_origin` field IS the answer.
247    Deployed(&'a str),
248    /// Each static route serves from its own component's published prefix.
249    /// The alias tier (R561-F3), and the several-buckets-behind-one-domain
250    /// case R560-F13 asked for: the routes name different services, so they
251    /// resolve to different origins.
252    PerComponent {
253        /// The tier's CDN origin, e.g. `https://cdn.net.yah.dev`.
254        cdn_base: &'a str,
255        /// Mirror env the publisher wrote under, e.g. `prod`.
256        env: &'a str,
257    },
258    /// No CDN tier is known — a manifest-only Worker deployed by the domain
259    /// pass (R560-F13). A component static route has no origin under this and
260    /// the compile refuses it naming the route; bucket, backend and redirect
261    /// routes need no asset placement and compile as usual.
262    Unplaced,
263}
264
265impl WorkerAssets<'_> {
266    /// The `ASSET_ORIGIN` binding value: where a path that NO table entry
267    /// claims is fetched from.
268    ///
269    /// For [`Self::Deployed`] that is the deployed origin, domain or no domain.
270    /// For [`Self::PerComponent`] it is the FIRST static route's origin — not
271    /// because first-wins is back, but because `ASSET_ORIGIN` is a single
272    /// binding and a domain with a static catch-all resolves it to the same
273    /// string either way. Every static route still gets its own entry in the
274    /// table, which is what the door actually matches on.
275    ///
276    /// `None` means no path falls through to an HTTP asset origin: no domain,
277    /// no component static route (bucket routes read through R2 bindings, not
278    /// an origin), a static route whose component ref is malformed, or
279    /// [`Self::Unplaced`].
280    ///
281    /// [`Self::Deployed`] is returned VERBATIM, trailing slash and all, while
282    /// [`RoutePlacement::asset_origin`] trims it for the table. That asymmetry
283    /// is deliberate and load-bearing: the `ASSET_ORIGIN` binding is a live
284    /// deployed value read off a mirror's static slot, and R898-T4 is not the
285    /// change that quietly renormalizes it under every Worker on the fleet.
286    pub fn fallback_origin(&self, domain: Option<&DomainConfig>) -> Option<String> {
287        match self {
288            Self::Deployed(origin) => Some((*origin).to_string()),
289            Self::PerComponent { cdn_base, env } => {
290                domain?.routes.iter().find_map(|r| match &r.mode {
291                    RouteMode::Static { component } => cdn_asset_origin(cdn_base, env, component),
292                    _ => None,
293                })
294            }
295            Self::Unplaced => None,
296        }
297    }
298}
299
300/// The Worker arm's placement (R898-F3).
301///
302/// The Cloudflare Worker is the one door for which the manifest's **declared**
303/// `origin` is the right answer — `api-noisetable-com.toml`'s own words: "this
304/// field is the Worker arm's `fetch()` target and nothing else reads it". So
305/// `backend_origin` reads the declaration back off the route it is resolving,
306/// while [`CdnPlacement`] resolves the same entry to a mesh address for the
307/// door that serves production.
308///
309/// The static half is [`WorkerAssets`] — see there for why it is not simply the
310/// deployed `ASSET_ORIGIN` string.
311pub struct WorkerPlacement<'a> {
312    /// How this Worker's static routes resolve their origins.
313    pub assets: WorkerAssets<'a>,
314    /// The domain being compiled — read back for the declared backend origin.
315    pub domain: &'a DomainConfig,
316}
317
318impl RoutePlacement for WorkerPlacement<'_> {
319    fn asset_origin(&self, component: &str, _path: &str) -> Option<String> {
320        match self.assets {
321            WorkerAssets::Deployed(origin) => Some(origin.trim_end_matches('/').to_string()),
322            WorkerAssets::PerComponent { cdn_base, env } => {
323                cdn_asset_origin(cdn_base, env, component)
324            }
325            WorkerAssets::Unplaced => None,
326        }
327    }
328
329    fn backend_origin(&self, _component: &str, path: &str) -> Option<String> {
330        // Found by EXACT pattern, not by [`DomainConfig::route_for_path`]: the
331        // compiler hands us the route's own `path`, and a catch-all declared
332        // above it would match that string and answer for the wrong route.
333        let declared = self.domain.routes.iter().find(|r| r.path == path)?;
334        match &declared.mode {
335            RouteMode::Backend { origin, .. } if !origin.is_empty() => {
336                Some(origin.trim_end_matches('/').to_string())
337            }
338            _ => None,
339        }
340    }
341}
342
343/// Whether a path reaching this entry has to carry a bearer.
344///
345/// Not new vocabulary: it is `PasswayAuth::require_prefixes` — the door's
346/// allowlist of prefixes requiring a bearer — evaluated per route, so the UX
347/// and the doors read one answer instead of each re-deriving it (W348 §4.2,
348/// §4.3).
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
350#[serde(rename_all = "kebab-case")]
351pub enum RouteAuth {
352    /// No prefix the door protects covers this route.
353    Anonymous,
354    /// Every request this route matches requires a bearer.
355    Bearer,
356}
357
358/// The prefix swap a backend route performs on its way to the origin.
359///
360/// Both halves are explicit rather than "strip `from`, prepend `to`" implied by
361/// the route path alone: the door applying this is a TypeScript Worker that
362/// must not have to re-derive a prefix from a pattern, and a rewrite that only
363/// carried its replacement would be unreadable in the wire value a deploy logs.
364///
365/// `from` is the matched route's own prefix (`"/api/issues*"` → `"/api/issues"`);
366/// `to` is what the origin serves it at (`"/issues"`). Applied as
367/// `to + path[from.len()..]`, so `/api/issues/42` → `/issues/42` and the bare
368/// `/api/issues` → `/issues`.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
370pub struct RouteRewrite {
371    /// The public prefix, as matched.
372    pub from: String,
373    /// What that prefix becomes at the origin.
374    pub to: String,
375}
376
377/// Compile a declared `origin_path` into a [`RouteRewrite`] against the route's
378/// own path pattern.
379///
380/// `None` when nothing was declared, and also when the declaration is the
381/// identity — an entry saying "rewrite `/api` to `/api`" is noise on the wire
382/// and a needless branch at the door.
383fn route_rewrite(route_path: &str, origin_path: Option<&String>) -> Option<RouteRewrite> {
384    let to = origin_path?;
385    let from = route_path.strip_suffix('*').unwrap_or(route_path);
386    let from = from.trim_end_matches('/');
387    let to = to.trim_end_matches('/');
388    if from == to {
389        return None;
390    }
391    Some(RouteRewrite {
392        from: from.to_string(),
393        to: to.to_string(),
394    })
395}
396
397/// A compiled entry's body — the declared [`RouteMode`] with its origin
398/// **resolved**.
399///
400/// The origin lives inside the variant rather than beside it as an
401/// `Option<String>`, so "a static entry with no origin" has no spelling and
402/// "a redirect with one" has none either. Same move [`DeployTier`] makes for
403/// the deploy tiers: the contradictory state is unrepresentable rather than
404/// refused.
405///
406/// [`DeployTier`]: crate::config::DeployTier
407#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
408#[serde(tag = "mode", rename_all = "kebab-case")]
409pub enum ResolvedRouteMode {
410    Static {
411        /// Component reference `"<service>/<component-id>"`, as declared.
412        component: String,
413        /// Where the door fetches this route's bytes from.
414        origin: String,
415    },
416    /// A bucket-root static route (R560-F13). Same `mode = "static"` on the
417    /// wire — the door tells the two apart by `binding` — because both are
418    /// "serve the bytes at this key", and only the reader differs.
419    #[serde(rename = "static")]
420    StaticBucket {
421        /// The R2 bucket, as declared.
422        bucket: String,
423        /// The Worker binding the door reads it through,
424        /// [`r2_binding_name`]`(bucket)`. The key is the request path minus its
425        /// leading slash, unchanged.
426        binding: String,
427    },
428    Backend {
429        component: String,
430        /// Where the door proxies this route to — the *deployed unit's*
431        /// address, not the manifest's `origin` field.
432        origin: String,
433        /// How the matched path is rewritten before it reaches `origin`.
434        /// `None` is an identity proxy (the common case).
435        #[serde(skip_serializing_if = "Option::is_none")]
436        rewrite: Option<RouteRewrite>,
437    },
438    Redirect {
439        target: String,
440        status: u16,
441    },
442}
443
444/// One entry of the compiled table.
445#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
446pub struct RouteTableEntry {
447    /// URL pattern, as declared. A trailing `*` matches everything underneath;
448    /// anything else is an exact path ([`matches_route_pattern`]).
449    pub path: String,
450    #[serde(flatten)]
451    pub mode: ResolvedRouteMode,
452    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
453    pub headers: BTreeMap<String, String>,
454    pub auth: RouteAuth,
455}
456
457/// A domain's routes, compiled and resolved, in manifest order.
458///
459/// Constructed only by [`DomainConfig::route_table`], which refuses an
460/// unresolved origin — so there is no way to hold a half-filled table, and
461/// every renderer downstream can serialize without re-checking.
462#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
463pub struct RouteTable {
464    /// The manifest's `name` (its file stem), for error messages.
465    pub domain: String,
466    pub entries: Vec<RouteTableEntry>,
467}
468
469impl DomainConfig {
470    /// Compile this domain's declared routes into one resolved table.
471    ///
472    /// Manifest order is preserved because it is the matching order — see the
473    /// module doc. Fails, naming the route, when `placement` cannot resolve an
474    /// origin: the alternative is an entry silently missing from the table,
475    /// which serves that path from whichever shorter entry matches instead of
476    /// failing.
477    pub fn route_table(&self, placement: &dyn RoutePlacement) -> Result<RouteTable> {
478        let mut entries = Vec::with_capacity(self.routes.len());
479        for route in &self.routes {
480            entries.push(RouteTableEntry {
481                path: route.path.clone(),
482                mode: self.resolve_mode(route, placement)?,
483                headers: route.headers.clone(),
484                auth: route_auth(&route.path, placement.auth_required_prefixes()),
485            });
486        }
487        Ok(RouteTable {
488            domain: self.name.clone(),
489            entries,
490        })
491    }
492
493    fn resolve_mode(
494        &self,
495        route: &DomainRoute,
496        placement: &dyn RoutePlacement,
497    ) -> Result<ResolvedRouteMode> {
498        Ok(match &route.mode {
499            RouteMode::Static { component } => ResolvedRouteMode::Static {
500                component: component.clone(),
501                origin: self.require_origin(
502                    route,
503                    "static",
504                    component,
505                    placement.asset_origin(component, &route.path),
506                )?,
507            },
508            RouteMode::StaticBucket { bucket } => ResolvedRouteMode::StaticBucket {
509                bucket: bucket.clone(),
510                binding: r2_binding_name(bucket),
511            },
512            RouteMode::Backend {
513                component,
514                origin_path,
515                ..
516            } => ResolvedRouteMode::Backend {
517                component: component.clone(),
518                origin: self.require_origin(
519                    route,
520                    "backend",
521                    component,
522                    placement.backend_origin(component, &route.path),
523                )?,
524                rewrite: route_rewrite(&route.path, origin_path.as_ref()),
525            },
526            RouteMode::Redirect { target, status } => ResolvedRouteMode::Redirect {
527                target: target.clone(),
528                status: *status,
529            },
530        })
531    }
532
533    fn require_origin(
534        &self,
535        route: &DomainRoute,
536        mode: &str,
537        component: &str,
538        resolved: Option<String>,
539    ) -> Result<String> {
540        resolved.map(Ok).unwrap_or_else(|| {
541            bail!(
542                "domain {}: route {:?} ({mode}, component {component:?}) has no resolved origin \
543                 yet. Refusing to compile a partial route table — a missing entry does not 503, \
544                 it falls through to whichever shorter route matches (the catch-all, usually) \
545                 and serves the wrong thing with a 200.",
546                self.name,
547                route.path,
548            )
549        })
550    }
551
552    /// The `ROUTE_HEADERS` / `MESOFACT_ROUTE_HEADERS` wire value (R746) — the
553    /// header column of [`Self::route_table`], which is the only column those
554    /// two bindings carry until R898-F2/F3 widen them.
555    ///
556    /// Needs no [`RoutePlacement`], which is why it is here and not on
557    /// [`RouteTable`] alone: the reconcilers that set those bindings run before
558    /// anything is placed. Both spellings share [`headers_json`], so the
559    /// projection cannot drift from the table it projects.
560    pub fn route_headers_json(&self) -> String {
561        headers_json(self.routes.iter().map(|r| (r.path.as_str(), &r.headers)))
562    }
563
564    /// The declared route that governs `path` — the same walk
565    /// [`RouteTable::match_path`] makes, over the routes instead of over the
566    /// compiled entries.
567    ///
568    /// Placement-free, and here for exactly the reason
569    /// [`Self::route_headers_json`] is: a consumer that runs BEFORE anything is
570    /// placed cannot compile a table to ask. [`crate::inner_door::plan`] is that
571    /// consumer, and for it the dependency is not merely inconvenient but
572    /// **circular** — [`CdnPlacement::backend_origin`] resolves a route's origin
573    /// out of [`InnerDoorPlan::resolve_addresses`]' output, which is derived from
574    /// the very plan that would be asking (R898-F1's handoff states that keying).
575    ///
576    /// One rule, two walks, pinned by
577    /// `the_declared_walk_and_the_compiled_walk_pick_the_same_route` — the same
578    /// arrangement [`headers_json`] already has, and for the same reason.
579    ///
580    /// [`InnerDoorPlan::resolve_addresses`]: crate::inner_door::InnerDoorPlan::resolve_addresses
581    pub fn route_for_path(&self, path: &str) -> Option<&DomainRoute> {
582        first_match(self.routes.iter().map(|r| (r.path.as_str(), r)), path)
583    }
584}
585
586impl RouteTable {
587    /// The entry a request for `path` is served by — FIRST match, no merging.
588    pub fn match_path(&self, path: &str) -> Option<&RouteTableEntry> {
589        first_match(self.entries.iter().map(|e| (e.path.as_str(), e)), path)
590    }
591
592    /// The full table as the JSON both doors will consume (R898-F2/F3).
593    pub fn to_json(&self) -> String {
594        serde_json::to_string(&self.entries).unwrap_or_else(|_| "[]".to_string())
595    }
596
597    /// This table's header column, byte-identical to
598    /// [`DomainConfig::route_headers_json`] — the same routes, the same order,
599    /// the same dropped-when-headerless rule.
600    pub fn headers_json(&self) -> String {
601        headers_json(self.entries.iter().map(|e| (e.path.as_str(), &e.headers)))
602    }
603}
604
605/// The route-driven domain routing `service`, compiled against `placement`.
606///
607/// The widened twin of [`route_headers_for_service`](crate::config::route_headers_for_service):
608/// same lookup, same `.yah/domains/` read, the whole table instead of its
609/// header column. `None` when no route-driven domain routes the service.
610pub fn route_table_for_service(
611    workspace_root: &std::path::Path,
612    service: &str,
613    placement: &dyn RoutePlacement,
614) -> Result<Option<RouteTable>> {
615    let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
616    domain_serving_service(&domains, service)
617        .map(|d| d.route_table(placement))
618        .transpose()
619}
620
621/// The one serializer of the header wire format — `[{path, headers}]` in
622/// manifest order, headerless routes dropped.
623///
624/// A free function over `(path, headers)` pairs rather than a method, because
625/// it has two callers on purpose: the declared routes (pre-placement) and the
626/// compiled table. Two walks, one format, and a test pinning that they agree.
627pub(crate) fn headers_json<'a>(
628    rules: impl Iterator<Item = (&'a str, &'a BTreeMap<String, String>)>,
629) -> String {
630    #[derive(Serialize)]
631    struct Rule<'a> {
632        path: &'a str,
633        headers: &'a BTreeMap<String, String>,
634    }
635    let rules: Vec<Rule<'_>> = rules
636        .filter(|(_, headers)| !headers.is_empty())
637        .map(|(path, headers)| Rule { path, headers })
638        .collect();
639    serde_json::to_string(&rules).unwrap_or_else(|_| "[]".to_string())
640}
641
642/// The one implementation of *which* rule governs a path: manifest order,
643/// first match wins, no merging across rules.
644///
645/// Generic over the payload for the same reason [`headers_json`] is generic over
646/// the pair source — it has two callers on purpose, the declared routes
647/// (pre-placement) and the compiled table, and they must not be able to pick
648/// different rules. R746 pinned the semantics in TS with the reasoning at
649/// `router.ts:400-411`; R749-F3 carried them into Rust.
650pub(crate) fn first_match<'a, T>(
651    rules: impl Iterator<Item = (&'a str, T)>,
652    path: &str,
653) -> Option<T> {
654    rules
655        .into_iter()
656        .find(|(pattern, _)| matches_route_pattern(pattern, path))
657        .map(|(_, payload)| payload)
658}
659
660/// `true` when `path` is served by `pattern`.
661///
662/// A trailing `*` matches the bare prefix and every `/`-delimited descendant;
663/// anything else is an exact match. Never a raw `starts_with` — `/application`
664/// must not reach a `/app/*` route.
665///
666/// Mirrors `mesofact::route_headers::matches_route_pattern` and the Worker's
667/// `matchesRoutePattern` byte for byte, including the deliberate inclusion of
668/// the BARE prefix: `/app` is the URL a link points at, it resolves to
669/// `app/index.html` through the clean-URL rule, and an entry that skipped it
670/// would miss the very document it exists for.
671pub fn matches_route_pattern(pattern: &str, path: &str) -> bool {
672    let Some(head) = pattern.strip_suffix('*') else {
673        return path == pattern;
674    };
675    let prefix = head.trim_end_matches('/');
676    if prefix.is_empty() {
677        return true;
678    }
679    path == prefix || path.starts_with(&format!("{prefix}/"))
680}
681
682/// Whether a door protecting `required` prefixes protects every request
683/// `route_path` matches.
684///
685/// Conservative on purpose: a route is [`RouteAuth::Bearer`] only when a
686/// required prefix covers the WHOLE pattern. A `/*` catch-all against a door
687/// requiring `/admin` is anonymous here — most of what it serves is — and the
688/// `/admin` requirement is carried by whichever entry actually claims that
689/// prefix. Reporting the catch-all as protected would tell the topology view
690/// that a surface is authenticated when nearly all of it is not.
691fn route_auth(route_path: &str, required: &[String]) -> RouteAuth {
692    let route_prefix = route_path.strip_suffix('*').unwrap_or(route_path);
693    let route_prefix = route_prefix.trim_end_matches('/');
694    let covered = required.iter().any(|req| {
695        let req = req.trim_end_matches('/');
696        // `"/"` trims to `""` — the whole-surface requirement, which is the
697        // only value `PasswayAuth` has for "everything" (it is an allowlist
698        // with no exclusion form).
699        req.is_empty() || route_prefix == req || route_prefix.starts_with(&format!("{req}/"))
700    });
701    if covered {
702        RouteAuth::Bearer
703    } else {
704        RouteAuth::Anonymous
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use crate::config::FrontDoor;
712
713    /// A placement that resolves everything, so a test asserting on the table's
714    /// SHAPE is not also asserting on a resolver.
715    struct FixedPlacement {
716        auth: Vec<String>,
717    }
718
719    impl Default for FixedPlacement {
720        fn default() -> Self {
721            Self { auth: vec![] }
722        }
723    }
724
725    impl RoutePlacement for FixedPlacement {
726        fn asset_origin(&self, component: &str, _path: &str) -> Option<String> {
727            Some(format!("https://cdn.example/{component}"))
728        }
729        fn backend_origin(&self, component: &str, _path: &str) -> Option<String> {
730            Some(format!("http://unit.example/{component}"))
731        }
732        fn auth_required_prefixes(&self) -> &[String] {
733            &self.auth
734        }
735    }
736
737    /// Resolves nothing — the shape a caller has before placement runs.
738    struct NoPlacement;
739    impl RoutePlacement for NoPlacement {
740        fn asset_origin(&self, _component: &str, _path: &str) -> Option<String> {
741            None
742        }
743        fn backend_origin(&self, _component: &str, _path: &str) -> Option<String> {
744            None
745        }
746    }
747
748    fn domain(name: &str, routes: Vec<DomainRoute>) -> DomainConfig {
749        DomainConfig {
750            schema_version: 1,
751            name: name.to_string(),
752            domain: format!("{name}.example"),
753            front_door: FrontDoor::Worker,
754            cdn_bucket: "example".into(),
755            worker_bundle_path: None,
756            routes,
757        }
758    }
759
760    fn route(path: &str, mode: RouteMode, headers: &[(&str, &str)]) -> DomainRoute {
761        DomainRoute {
762            path: path.to_string(),
763            headers: headers
764                .iter()
765                .map(|(k, v)| (k.to_string(), v.to_string()))
766                .collect(),
767            mode,
768        }
769    }
770
771    /// R898-F3 — a backend route whose origin serves it at a different prefix
772    /// compiles that swap into the table, and the swap survives `to_json`.
773    ///
774    /// This is the datum the Worker's two hardcoded `if` blocks carried before
775    /// they were deleted: without it an entry proxies `/api/issues` to
776    /// `<origin>/api/issues` and quietly changes the upstream contract. The
777    /// identity case stays absent from the wire — a `{from: "/api", to: "/api"}`
778    /// is a branch at the door that can never do anything.
779    #[test]
780    fn a_backend_routes_prefix_rewrite_compiles_and_round_trips() {
781        let cfg = domain(
782            "acme",
783            vec![
784                route(
785                    "/api/issues*",
786                    RouteMode::Backend {
787                        component: "acme/issues".into(),
788                        origin: "https://declared.invalid".into(),
789                        origin_path: Some("/issues".into()),
790                    },
791                    &[],
792                ),
793                route(
794                    "/api/plain*",
795                    RouteMode::Backend {
796                        component: "acme/plain".into(),
797                        origin: "https://declared.invalid".into(),
798                        origin_path: None,
799                    },
800                    &[],
801                ),
802                route(
803                    "/api/same*",
804                    RouteMode::Backend {
805                        component: "acme/same".into(),
806                        origin: "https://declared.invalid".into(),
807                        origin_path: Some("/api/same".into()),
808                    },
809                    &[],
810                ),
811            ],
812        );
813        let table = cfg.route_table(&FixedPlacement::default()).unwrap();
814
815        assert_eq!(
816            table.entries[0].mode,
817            ResolvedRouteMode::Backend {
818                component: "acme/issues".into(),
819                origin: "http://unit.example/acme/issues".into(),
820                rewrite: Some(RouteRewrite {
821                    from: "/api/issues".into(),
822                    to: "/issues".into(),
823                }),
824            },
825            "the declared origin_path becomes the entry's {{from, to}} prefix swap",
826        );
827        assert!(
828            matches!(
829                table.entries[1].mode,
830                ResolvedRouteMode::Backend { rewrite: None, .. }
831            ),
832            "an undeclared origin_path is an identity proxy, not a rewrite",
833        );
834        assert!(
835            matches!(
836                table.entries[2].mode,
837                ResolvedRouteMode::Backend { rewrite: None, .. }
838            ),
839            "a declared origin_path equal to the route's own prefix is the identity too",
840        );
841
842        let json = table.to_json();
843        let wire: serde_json::Value = serde_json::from_str(&json).unwrap();
844        assert_eq!(
845            wire[0]["rewrite"],
846            serde_json::json!({"from": "/api/issues", "to": "/issues"}),
847            "the Worker reads both halves off the wire — it must not re-derive \
848             the public prefix from the pattern",
849        );
850        assert!(
851            wire[1].get("rewrite").is_none() && wire[2].get("rewrite").is_none(),
852            "identity entries carry no rewrite key at all: {json}",
853        );
854    }
855
856    /// yah.dev as it is declared today: one static catch-all carrying the
857    /// R749-F3 probe header. The degenerate case the widening must not move.
858    fn yah_dev() -> DomainConfig {
859        domain(
860            "yah-dev",
861            vec![route(
862                "/*",
863                RouteMode::Static {
864                    component: "yah-marketing/site".into(),
865                },
866                &[("X-Route-Header-Probe", "r749-f3")],
867            )],
868        )
869    }
870
871    // ── R560-F13: bucket-root static routes ─────────────────────────────────
872
873    #[test]
874    fn r2_binding_name_is_the_bucket_upcased_with_hyphens_mapped() {
875        assert_eq!(r2_binding_name("noisetable-releases"), "R2_NOISETABLE_RELEASES");
876        assert_eq!(r2_binding_name("nt-assets-2"), "R2_NT_ASSETS_2");
877    }
878
879    /// A bucket route needs no placement — it compiles under [`NoPlacement`] —
880    /// and its wire entry is `mode = "static"` plus bucket and binding, with no
881    /// origin: the door tells it from a component entry by `binding` alone.
882    #[test]
883    fn a_bucket_route_compiles_to_its_binding_with_no_placement() {
884        let dom = domain(
885            "cdn",
886            vec![
887                route(
888                    "/engine/*",
889                    RouteMode::StaticBucket {
890                        bucket: "noisetable-releases".into(),
891                    },
892                    &[],
893                ),
894                route(
895                    "/nt-cas/*",
896                    RouteMode::StaticBucket {
897                        bucket: "noisetable-assets".into(),
898                    },
899                    &[("Cache-Control", "immutable")],
900                ),
901                route(
902                    "/dev/*",
903                    RouteMode::StaticBucket {
904                        bucket: "noisetable-releases".into(),
905                    },
906                    &[],
907                ),
908            ],
909        );
910        let table = dom.route_table(&NoPlacement).unwrap();
911        assert_eq!(
912            table.entries[0].mode,
913            ResolvedRouteMode::StaticBucket {
914                bucket: "noisetable-releases".into(),
915                binding: "R2_NOISETABLE_RELEASES".into(),
916            }
917        );
918        let wire: serde_json::Value = serde_json::from_str(&table.to_json()).unwrap();
919        assert_eq!(
920            wire[0],
921            serde_json::json!({
922                "path": "/engine/*",
923                "mode": "static",
924                "bucket": "noisetable-releases",
925                "binding": "R2_NOISETABLE_RELEASES",
926                "auth": "anonymous",
927            })
928        );
929        assert_eq!(wire[1]["headers"]["Cache-Control"], "immutable");
930        assert_eq!(
931            r2_bucket_bindings(&table.entries),
932            vec![
933                (
934                    "R2_NOISETABLE_RELEASES".to_string(),
935                    "noisetable-releases".to_string()
936                ),
937                (
938                    "R2_NOISETABLE_ASSETS".to_string(),
939                    "noisetable-assets".to_string()
940                ),
941            ],
942            "one binding per DISTINCT bucket, first-use order"
943        );
944    }
945
946    /// A component entry keeps its exact wire shape, and a component-only
947    /// table binds no bucket.
948    #[test]
949    fn a_component_entry_keeps_its_origin_shape_and_binds_no_bucket() {
950        let table = yah_dev().route_table(&FixedPlacement::default()).unwrap();
951        assert_eq!(
952            table.to_json(),
953            r#"[{"path":"/*","mode":"static","component":"yah-marketing/site","origin":"https://cdn.example/yah-marketing/site","headers":{"X-Route-Header-Probe":"r749-f3"},"auth":"anonymous"}]"#
954        );
955        assert!(r2_bucket_bindings(&table.entries).is_empty());
956    }
957
958    // ── R898-F1 acceptance ──────────────────────────────────────────────────
959
960    /// The ticket's own criterion: an ordered mix of static, backend and
961    /// redirect routes compiles to ONE table whose entries carry path, mode,
962    /// resolved origin, headers and auth, in manifest order.
963    #[test]
964    fn a_mixed_domain_compiles_to_one_ordered_table_with_resolved_origins() {
965        let dom = domain(
966            "mixed",
967            vec![
968                route(
969                    "/app/*",
970                    RouteMode::Static {
971                        component: "acme/app".into(),
972                    },
973                    &[("Cross-Origin-Opener-Policy", "same-origin")],
974                ),
975                route(
976                    "/api/*",
977                    RouteMode::Backend {
978                        component: "acme/api".into(),
979                        // The DECLARED origin, which must not reach the table:
980                        // it is the Worker arm's fetch() target and nothing
981                        // reads it under passway.
982                        origin: "https://declared.invalid".into(),
983                        origin_path: None,
984                    },
985                    &[],
986                ),
987                route(
988                    "/old",
989                    RouteMode::Redirect {
990                        target: "https://acme.example/new".into(),
991                        status: 301,
992                    },
993                    &[],
994                ),
995                route(
996                    "/*",
997                    RouteMode::Static {
998                        component: "acme/site".into(),
999                    },
1000                    &[],
1001                ),
1002            ],
1003        );
1004
1005        let table = dom.route_table(&FixedPlacement::default()).unwrap();
1006
1007        assert_eq!(
1008            table
1009                .entries
1010                .iter()
1011                .map(|e| e.path.as_str())
1012                .collect::<Vec<_>>(),
1013            ["/app/*", "/api/*", "/old", "/*"],
1014            "manifest order is the matching order and must survive compilation",
1015        );
1016
1017        assert_eq!(
1018            table.entries[0].mode,
1019            ResolvedRouteMode::Static {
1020                component: "acme/app".into(),
1021                origin: "https://cdn.example/acme/app".into(),
1022            }
1023        );
1024        assert_eq!(
1025            table.entries[0]
1026                .headers
1027                .get("Cross-Origin-Opener-Policy")
1028                .map(String::as_str),
1029            Some("same-origin")
1030        );
1031        assert_eq!(table.entries[0].auth, RouteAuth::Anonymous);
1032
1033        assert_eq!(
1034            table.entries[1].mode,
1035            ResolvedRouteMode::Backend {
1036                component: "acme/api".into(),
1037                origin: "http://unit.example/acme/api".into(),
1038                rewrite: None,
1039            },
1040            "the RESOLVED unit address, never the manifest's declared `origin`",
1041        );
1042
1043        assert_eq!(
1044            table.entries[2].mode,
1045            ResolvedRouteMode::Redirect {
1046                target: "https://acme.example/new".into(),
1047                status: 301,
1048            },
1049            "a redirect carries its own target and needs no origin",
1050        );
1051    }
1052
1053    /// The wire shape F2/F3 parse: mode is the discriminator, the resolved
1054    /// origin rides beside it, a redirect has no `origin` key at all, and a
1055    /// headerless entry carries no `headers` key.
1056    #[test]
1057    fn the_table_json_carries_mode_origin_headers_and_auth_per_entry() {
1058        let dom = domain(
1059            "mixed",
1060            vec![
1061                route(
1062                    "/api/*",
1063                    RouteMode::Backend {
1064                        component: "acme/api".into(),
1065                        origin: "https://declared.invalid".into(),
1066                        origin_path: None,
1067                    },
1068                    &[],
1069                ),
1070                route(
1071                    "/old",
1072                    RouteMode::Redirect {
1073                        target: "https://acme.example/new".into(),
1074                        status: 308,
1075                    },
1076                    &[],
1077                ),
1078            ],
1079        );
1080        let json = dom
1081            .route_table(&FixedPlacement::default())
1082            .unwrap()
1083            .to_json();
1084        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1085        let entries = parsed.as_array().unwrap();
1086
1087        assert_eq!(entries[0]["mode"], "backend");
1088        assert_eq!(entries[0]["origin"], "http://unit.example/acme/api");
1089        assert_eq!(entries[0]["auth"], "anonymous");
1090        assert!(
1091            entries[0].get("headers").is_none(),
1092            "a headerless entry carries no headers key: {json}"
1093        );
1094
1095        assert_eq!(entries[1]["mode"], "redirect");
1096        assert_eq!(entries[1]["target"], "https://acme.example/new");
1097        assert_eq!(entries[1]["status"], 308);
1098        assert!(
1099            entries[1].get("origin").is_none(),
1100            "a redirect has no origin: {json}"
1101        );
1102    }
1103
1104    /// **The degenerate case must not move.** yah.dev's single catch-all plus
1105    /// its probe header compiles to a table whose header half is byte-identical
1106    /// to what `route_headers_json` emits today — so nothing deployed changes
1107    /// behaviour when F2/F3 land.
1108    #[test]
1109    fn yah_devs_header_half_is_byte_identical_to_route_headers_json() {
1110        let dom = yah_dev();
1111        let declared = dom.route_headers_json();
1112        assert_eq!(
1113            declared, r#"[{"path":"/*","headers":{"X-Route-Header-Probe":"r749-f3"}}]"#,
1114            "the live ROUTE_HEADERS / MESOFACT_ROUTE_HEADERS value for yah.dev",
1115        );
1116        assert_eq!(
1117            dom.route_table(&FixedPlacement::default())
1118                .unwrap()
1119                .headers_json(),
1120            declared,
1121            "the compiled table's header column IS the shipped binding value",
1122        );
1123    }
1124
1125    /// The same equality on a table that exercises both of the projection's
1126    /// rules — order preserved, headerless entries dropped.
1127    #[test]
1128    fn the_header_projection_agrees_with_the_declaration_on_a_mixed_table() {
1129        let dom = domain(
1130            "mixed",
1131            vec![
1132                route(
1133                    "/app/*",
1134                    RouteMode::Static {
1135                        component: "acme/app".into(),
1136                    },
1137                    &[("Cross-Origin-Embedder-Policy", "require-corp")],
1138                ),
1139                route(
1140                    "/*",
1141                    RouteMode::Static {
1142                        component: "acme/site".into(),
1143                    },
1144                    &[],
1145                ),
1146            ],
1147        );
1148        let table = dom.route_table(&FixedPlacement::default()).unwrap();
1149        assert_eq!(table.headers_json(), dom.route_headers_json());
1150        assert_eq!(
1151            table.headers_json(),
1152            r#"[{"path":"/app/*","headers":{"Cross-Origin-Embedder-Policy":"require-corp"}}]"#,
1153            "the headerless catch-all contributes no rule",
1154        );
1155    }
1156
1157    // ── refusal, matching, auth ─────────────────────────────────────────────
1158
1159    /// An unresolved origin fails the compile naming the route. The refusal is
1160    /// the point: the alternative is a dropped entry, which falls through to
1161    /// the catch-all and serves the wrong thing with a 200.
1162    #[test]
1163    fn an_unresolved_origin_refuses_the_table_rather_than_dropping_the_entry() {
1164        let err = yah_dev().route_table(&NoPlacement).unwrap_err();
1165        let msg = format!("{err:#}");
1166        assert!(msg.contains("yah-dev"), "{msg}");
1167        assert!(msg.contains("/*"), "{msg}");
1168        assert!(msg.contains("yah-marketing/site"), "{msg}");
1169    }
1170
1171    /// A redirect resolves with no placement at all — it carries its own
1172    /// target, so a domain of pure redirects compiles before anything deploys.
1173    #[test]
1174    fn a_redirect_needs_no_placement() {
1175        let dom = domain(
1176            "redirects",
1177            vec![route(
1178                "/old/*",
1179                RouteMode::Redirect {
1180                    target: "https://acme.example/new".into(),
1181                    status: 308,
1182                },
1183                &[],
1184            )],
1185        );
1186        assert!(dom.route_table(&NoPlacement).is_ok());
1187    }
1188
1189    /// FIRST match wins, and matching is segment-aware — `/application` is not
1190    /// under `/app`. Both halves of the rule this table inherits from R746.
1191    #[test]
1192    fn first_match_wins_and_app_does_not_capture_application() {
1193        let dom = domain(
1194            "mixed",
1195            vec![
1196                route(
1197                    "/app/*",
1198                    RouteMode::Static {
1199                        component: "acme/app".into(),
1200                    },
1201                    &[],
1202                ),
1203                route(
1204                    "/*",
1205                    RouteMode::Static {
1206                        component: "acme/site".into(),
1207                    },
1208                    &[],
1209                ),
1210            ],
1211        );
1212        let table = dom.route_table(&FixedPlacement::default()).unwrap();
1213
1214        assert_eq!(table.match_path("/app").unwrap().path, "/app/*");
1215        assert_eq!(table.match_path("/app/x").unwrap().path, "/app/*");
1216        assert_eq!(
1217            table.match_path("/application").unwrap().path,
1218            "/*",
1219            "a bare starts_with would have routed this to the app bundle",
1220        );
1221        assert_eq!(table.match_path("/").unwrap().path, "/*");
1222    }
1223
1224    /// R898-F2: the pre-placement walk and the compiled walk are one rule.
1225    /// `DomainConfig::route_for_path` exists because the inner-door planner runs
1226    /// before any placement and cannot compile a table to ask (the origin
1227    /// resolution would be circular) — so the thing that must be pinned is that
1228    /// it never picks a different route than the table would.
1229    #[test]
1230    fn the_declared_walk_and_the_compiled_walk_pick_the_same_route() {
1231        let dom = domain(
1232            "mixed",
1233            vec![
1234                route(
1235                    "/app/*",
1236                    RouteMode::Static {
1237                        component: "acme/app".into(),
1238                    },
1239                    &[("x-a", "1")],
1240                ),
1241                route(
1242                    "/api/thing",
1243                    RouteMode::Backend {
1244                        component: "acme/api".into(),
1245                        origin: "https://declared.invalid".into(),
1246                        origin_path: None,
1247                    },
1248                    &[],
1249                ),
1250                route(
1251                    "/*",
1252                    RouteMode::Static {
1253                        component: "acme/site".into(),
1254                    },
1255                    &[("x-b", "2")],
1256                ),
1257            ],
1258        );
1259        let table = dom.route_table(&FixedPlacement::default()).unwrap();
1260
1261        for path in [
1262            "/",
1263            "/app",
1264            "/app/x",
1265            "/application",
1266            "/api/thing",
1267            "/api/thing/more",
1268            "/anything",
1269        ] {
1270            let declared = dom.route_for_path(path);
1271            let compiled = table.match_path(path);
1272            assert_eq!(
1273                declared.map(|r| r.path.as_str()),
1274                compiled.map(|e| e.path.as_str()),
1275                "{path}"
1276            );
1277            assert_eq!(
1278                declared.map(|r| &r.headers),
1279                compiled.map(|e| &e.headers),
1280                "{path}"
1281            );
1282        }
1283    }
1284
1285    #[test]
1286    fn matches_route_pattern_mirrors_the_worker_and_mesofact_matchers() {
1287        assert!(matches_route_pattern("/*", "/anything/at/all"));
1288        assert!(matches_route_pattern("/app/*", "/app"));
1289        assert!(matches_route_pattern("/app/*", "/app/"));
1290        assert!(!matches_route_pattern("/app/*", "/application"));
1291        assert!(matches_route_pattern("/exact", "/exact"));
1292        assert!(!matches_route_pattern("/exact", "/exact/more"));
1293    }
1294
1295    /// Auth is the door's `require_prefixes`, read per route: the protected
1296    /// prefix's own entry is `bearer`, and a catch-all that mostly serves
1297    /// anonymous traffic is not reported as protected.
1298    #[test]
1299    fn auth_follows_the_doors_required_prefixes() {
1300        let dom = domain(
1301            "mixed",
1302            vec![
1303                route(
1304                    "/admin/*",
1305                    RouteMode::Static {
1306                        component: "acme/admin".into(),
1307                    },
1308                    &[],
1309                ),
1310                route(
1311                    "/*",
1312                    RouteMode::Static {
1313                        component: "acme/site".into(),
1314                    },
1315                    &[],
1316                ),
1317            ],
1318        );
1319        let placement = FixedPlacement {
1320            auth: vec!["/admin".to_string()],
1321        };
1322        let table = dom.route_table(&placement).unwrap();
1323        assert_eq!(table.entries[0].auth, RouteAuth::Bearer);
1324        assert_eq!(table.entries[1].auth, RouteAuth::Anonymous);
1325
1326        // `"/"` is `PasswayAuth`'s only whole-surface value — it covers both.
1327        let whole = FixedPlacement {
1328            auth: vec!["/".to_string()],
1329        };
1330        let table = dom.route_table(&whole).unwrap();
1331        assert!(table.entries.iter().all(|e| e.auth == RouteAuth::Bearer));
1332    }
1333
1334    /// `CdnPlacement` resolves through the identity derivation that already
1335    /// owns the mapping, rather than a second copy of it.
1336    #[test]
1337    fn cdn_placement_resolves_static_by_prefix_and_backend_by_mesh_ident() {
1338        let placement = CdnPlacement {
1339            cdn_base: "https://cdn.yah.dev/".into(),
1340            env: "prod".into(),
1341            addresses: [(
1342                component_workload_ident("yah-marketing", "api"),
1343                "100.64.0.3:8080".to_string(),
1344            )]
1345            .into_iter()
1346            .collect(),
1347            auth_required_prefixes: vec![],
1348        };
1349        assert_eq!(
1350            placement.asset_origin("yah-marketing/site", "/*").unwrap(),
1351            "https://cdn.yah.dev/yah-marketing/prod",
1352            "the same string plan_domain_worker computes today",
1353        );
1354        assert_eq!(
1355            placement
1356                .backend_origin("yah-marketing/api", "/api/*")
1357                .unwrap(),
1358            "http://100.64.0.3:8080",
1359        );
1360        assert_eq!(
1361            placement.backend_origin("yah-marketing/absent", "/x"),
1362            None,
1363            "an undeployed unit resolves to nothing, which refuses the compile",
1364        );
1365        assert_eq!(placement.asset_origin("malformed", "/*"), None);
1366    }
1367}