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}