Skip to main content

autumn_web/
router.rs

1//! Router construction and configuration.
2//!
3//! This module handles assembling the final [`axum::Router`] from the various
4//! components configured in [`AppBuilder`](crate::app::AppBuilder), including
5//! user routes, static files, middleware, error pages, and framework endpoints
6//! like actuators and probes.
7
8// autumn-determinism-gate: production code in this module must read time and
9// mint identifiers through the framework's injected seams (ClockSource /
10// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
11// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
12// (issue #1797). Justify exceptions with
13// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
14#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
15
16use std::sync::Arc;
17use std::time::Duration;
18
19use crate::app::ScopedGroup;
20use crate::config::AutumnConfig;
21#[cfg(feature = "maud")]
22use crate::error_pages::{self, SharedRenderer};
23use crate::idempotency::{IdempotencyLayer, IdempotencyStore, MemoryIdempotencyStore};
24use crate::middleware::RequestIdLayer;
25use crate::middleware::dev;
26use crate::middleware::exception_filter::{
27    ExceptionFilter, ExceptionFilterLayer, ProblemDetailsFilter,
28};
29use crate::route::Route;
30use crate::state::AppState;
31use axum::response::IntoResponse;
32use http::{Request, StatusCode};
33use thiserror::Error;
34
35pub const DEFAULT_FAVICON_PATH: &str = "/favicon.ico";
36
37/// Errors that can occur during the router build process.
38///
39/// These errors are typically fatal and represent configuration or routing
40/// definition issues that must be fixed before the application can start.
41#[derive(Debug, Error, PartialEq, Eq)]
42pub enum RouterBuildError {
43    /// The session backend configuration is invalid (e.g. Redis without a URL).
44    #[error("invalid session backend configuration: {0}")]
45    InvalidSessionBackend(#[from] crate::session::SessionBackendConfigError),
46    /// The idempotency backend configuration is invalid.
47    #[error("invalid idempotency backend configuration: {0}")]
48    #[allow(dead_code)] // constructed only in the `redis` feature path
49    InvalidIdempotencyBackend(String),
50    /// The submit-token backend configuration is invalid for production — an
51    /// explicit `[security.submit_token].backend = "memory"` cannot safely
52    /// deduplicate submits across replicas. Mirrors the idempotency
53    /// production-memory fail-fast.
54    #[error("invalid submit-token backend configuration: {0}")]
55    InvalidSubmitTokenBackend(String),
56    /// A user-defined route conflicts with a framework-provided route.
57    #[error("framework route overlap at {path}: {existing} conflicts with {incoming}")]
58    FrameworkRouteOverlap {
59        /// The HTTP path where the overlap occurred.
60        path: String,
61        /// The name of the existing framework route.
62        existing: &'static str,
63        /// The name of the incoming user route.
64        incoming: &'static str,
65    },
66    /// An `OpenApiConfig` path (e.g. `openapi_json_path` or
67    /// `swagger_ui_path`) is not a valid route path (must start with `/`
68    /// and be non-empty).
69    #[cfg(feature = "openapi")]
70    #[error("invalid OpenAPI {field} path: {value:?} (must start with '/' and be non-empty)")]
71    InvalidOpenApiPath {
72        /// Which config field carried the invalid path.
73        field: &'static str,
74        /// The offending value from the user's config.
75        value: String,
76    },
77    /// `openapi_json_path` and `swagger_ui_path` collide on the same
78    /// URL. Mounting both would cause axum to panic on overlapping
79    /// method routes at startup.
80    #[cfg(feature = "openapi")]
81    #[error(
82        "openapi_json_path and swagger_ui_path both resolve to {path:?}; they must differ or `swagger_ui_path` must be `None`"
83    )]
84    DuplicateOpenApiPath {
85        /// The path that both fields pointed at.
86        path: String,
87    },
88    /// An `OpenAPI` mount path overlaps with an existing `GET` handler,
89    /// which would panic at `axum::Router::merge` time.
90    #[cfg(feature = "openapi")]
91    #[error(
92        "OpenAPI {field} path {path:?} collides with an existing GET route; choose a different `OpenApiConfig::{field}`"
93    )]
94    OpenApiPathCollision {
95        /// Which config field carried the colliding path.
96        field: &'static str,
97        /// The colliding path.
98        path: String,
99    },
100    /// A route is annotated with an API version that is not registered.
101    #[error("route '{route_name}' uses unregistered API version '{version}'")]
102    UnregisteredApiVersion { route_name: String, version: String },
103    /// The MCP mount path (from [`AppBuilder::mount_mcp`](crate::app::AppBuilder::mount_mcp))
104    /// is not a valid route path. axum requires paths to start with `/`, so an
105    /// invalid path is surfaced here rather than panicking at mount time.
106    #[cfg(feature = "mcp")]
107    #[error("invalid MCP mount path: {value:?} (must start with '/' and be non-empty)")]
108    InvalidMcpPath {
109        /// The offending mount path.
110        value: String,
111    },
112    /// The MCP mount path collides with an existing application route at the
113    /// same path. Mounting the MCP endpoint there would panic at
114    /// `axum::Router::merge` time on overlapping method routes, so this is
115    /// surfaced as a recoverable error instead.
116    #[cfg(feature = "mcp")]
117    #[error(
118        "MCP mount path {path:?} collides with an existing {method} route; choose a different `mount_mcp` path"
119    )]
120    McpPathCollision {
121        /// The colliding mount path.
122        path: String,
123        /// The HTTP method of the existing route at that path.
124        method: String,
125    },
126    /// Two user- or plugin-registered routes resolve to the same
127    /// `(method, path)` after scope-prefix resolution. Mounting both would
128    /// panic inside `axum::routing::MethodRouter::merge` at startup on
129    /// overlapping method routes (issue #1012), so the collision preflight
130    /// surfaces it as a recoverable [`RouterBuildError`] BEFORE any router
131    /// is mounted and names both handlers so the offending call sites are
132    /// obvious in the log.
133    ///
134    /// Opaque routers registered via
135    /// [`AppBuilder::merge`](crate::app::AppBuilder::merge) or
136    /// [`AppBuilder::nest`](crate::app::AppBuilder::nest) are NOT introspectable
137    /// through axum's public API, so a collision that involves one of those
138    /// routers cannot be detected up front and will still surface as an axum
139    /// startup panic — the preflight emits a `tracing::warn!` in that case so
140    /// operators know the check was skipped (mirrors the existing OpenAPI/MCP
141    /// merge-router warnings).
142    #[error(
143        "duplicate user route: {existing:?} and {incoming:?} both resolve to {method} {path:?}; \
144         choose a different path for one of them or remove the duplicate registration"
145    )]
146    DuplicateUserRoute {
147        /// The HTTP method both handlers registered.
148        method: String,
149        /// The URL path both handlers registered (post scope-prefix resolution).
150        path: String,
151        /// The `route.name` of the first (already-seen) handler.
152        existing: String,
153        /// The `route.name` of the second (duplicate) handler that triggered
154        /// the collision.
155        incoming: String,
156    },
157    /// Two routers were registered at the SAME nest prefix via
158    /// [`AppBuilder::nest`](crate::app::AppBuilder::nest).
159    ///
160    /// Every nested router is given a fallback before it is mounted
161    /// (`mount_raw_routers`), and axum cannot merge two method routers that
162    /// both have one — nesting the second at a prefix the first already owns
163    /// panics with *"Cannot merge two `MethodRouter`s that both have a
164    /// fallback"* while the router is built.
165    ///
166    /// The declared-route preflight cannot catch this: two sandboxed plugins
167    /// sharing a prefix while declaring *disjoint* routes have no route
168    /// collision at all, and are accepted right up until the second nest
169    /// panics. A prefix is therefore owned by whoever nests at it first.
170    #[error(
171        "two routers are nested at {prefix:?} ({owners}); axum gives each nested router a fallback \
172         and cannot merge two that both have one, so the second panics at startup — give each \
173         router its own prefix"
174    )]
175    DuplicateNestPrefix {
176        /// The prefix both routers were nested at.
177        prefix: String,
178        /// Who claims the prefix, as far as the declared routes reveal.
179        owners: String,
180    },
181    /// Two user- or plugin-registered routes normalize to the SAME Axum path
182    /// shape but use DIFFERENT exact path templates — e.g. their capture names
183    /// differ (`/users/{id}` vs `/users/{slug}`) or a normal capture meets a
184    /// catch-all at the same position (`/u/{id}` vs `/u/{*rest}`).
185    ///
186    /// axum's matchit router rejects the second template as a route conflict
187    /// *before* method-router merging, so — unlike an exact-duplicate path,
188    /// which axum happily merges across distinct HTTP methods
189    /// ([`DuplicateUserRoute`](Self::DuplicateUserRoute)) — these two templates
190    /// can never coexist REGARDLESS of method. Issue #1012 surfaces the clash
191    /// here (naming both handlers and both original templates) instead of
192    /// letting the matchit conflict panic inside `Router::route` at startup.
193    ///
194    /// Opaque `AppBuilder::merge` / `AppBuilder::nest` routers are exempt for
195    /// the same reason as [`DuplicateUserRoute`](Self::DuplicateUserRoute).
196    #[error(
197        "conflicting route shapes: {existing:?} ({existing_path:?}) and {incoming:?} ({incoming_path:?}) \
198         resolve to the same Axum path shape but use different path templates; axum's matchit router \
199         rejects this as a route conflict regardless of HTTP method — rename the captures so both use the \
200         same template, or make their static paths distinct"
201    )]
202    ConflictingRouteShape {
203        /// The `route.name` of the first (already-seen) handler.
204        existing: String,
205        /// The original path template registered by the first handler.
206        existing_path: String,
207        /// The `route.name` of the second handler that triggered the conflict.
208        incoming: String,
209        /// The original path template registered by the second handler.
210        incoming_path: String,
211    },
212    /// Locale-prefix routing (issue #1251) would generate a path that
213    /// collides with another route already registered at that exact path —
214    /// e.g. an app defines both `/foo` and `/en/foo` while `en` is a
215    /// supported locale: the bare-path redirect (mounted at every
216    /// locale-prefix-eligible path, claiming every HTTP method) already owns
217    /// `/en/foo`, so nesting `/foo`'s content under `/en` collides and axum
218    /// panics on the overlapping route at router-construction time. Detected
219    /// and surfaced as a build error instead (Codex review).
220    #[cfg(feature = "i18n")]
221    #[error(
222        "locale-prefix routing generates {generated:?} (locale {locale:?} + route {path:?}), \
223         which collides with another route already registered at that path — rename one of the \
224         routes, or exclude it via `[i18n] locale_prefix_exclude`"
225    )]
226    LocalePrefixPathCollision {
227        /// The supported locale whose nest produced the collision.
228        locale: String,
229        /// The original, locale-prefix-eligible route path that got nested.
230        path: String,
231        /// The resulting generated path (`/{locale}{path}`) that collides.
232        generated: String,
233    },
234}
235
236/// Build the fully-configured Axum router from routes, config, and state.
237///
238/// Extracted from `AppBuilder::run` so the router construction logic is
239/// testable without binding a real TCP listener.
240///
241/// # Panics
242///
243/// Panics when framework router assembly encounters invalid configuration.
244/// Use [`try_build_router`] to handle configuration errors explicitly.
245#[allow(dead_code)]
246pub fn build_router(
247    route_list: Vec<Route>,
248    config: &AutumnConfig,
249    state: AppState,
250) -> axum::Router {
251    try_build_router(route_list, config, state)
252        .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
253}
254
255/// Checked variant of [`build_router`] that returns configuration errors
256/// instead of panicking.
257///
258/// # Errors
259///
260/// Returns [`RouterBuildError`] when router assembly encounters invalid
261/// framework configuration, such as an unusable session backend.
262pub struct RouterContext {
263    pub exception_filters: Vec<Arc<dyn ExceptionFilter>>,
264    pub scoped_groups: Vec<ScopedGroup>,
265    pub merge_routers: Vec<axum::Router<AppState>>,
266    pub nest_routers: Vec<(String, axum::Router<AppState>)>,
267    /// Route tables declared for otherwise-opaque `nest` mounts via
268    /// [`AppBuilder::declare_plugin_routes`](crate::app::AppBuilder::declare_plugin_routes).
269    ///
270    /// A nested router is normally opaque — axum exposes no way to enumerate
271    /// it — which is why the duplicate-route preflight has to skip it. A plugin
272    /// that declares its routes hands over that table anyway, and for a
273    /// sandboxed plugin the manifest *is* the table. Carrying it here lets
274    /// [`reject_duplicate_user_routes`] check a declared mount against the
275    /// application's own routes, so an artifact declaring a path the app
276    /// already serves is a typed startup error instead of a panic inside
277    /// `Router::nest`.
278    pub declared_routes: Vec<crate::route_listing::RouteInfo>,
279    /// Custom Tower layers registered via
280    /// [`AppBuilder::layer`](crate::app::AppBuilder::layer). Applied inside
281    /// [`RequestIdLayer`] and the session layer on the ingress path so user
282    /// middleware observes the generated request ID and session context.
283    ///
284    /// **SSG/ISG mode trade-off**: when `dist_dir` is active, layers are
285    /// moved outside the static-first middleware so they can process
286    /// pre-rendered responses (e.g. compression).  As a side effect they also
287    /// run *before* `RequestIdLayer`, session, `MetricsLayer`, and
288    /// `ExceptionFilterLayer` for all requests (static and dynamic).  Layers
289    /// that depend on extensions set by those framework layers — such as the
290    /// request ID or session data — will not find them in SSG mode.
291    pub custom_layers: Vec<crate::app::CustomLayerRegistration>,
292    /// Pre-static gate layers registered via
293    /// [`AppBuilder::static_gate`](crate::app::AppBuilder::static_gate).
294    /// Applied as the **outermost** middleware — outside the session layer and
295    /// ahead of the static-first middleware — so they can auth-gate / redirect
296    /// a request before a cached SSG/ISG page is served. Unlike
297    /// [`custom_layers`](Self::custom_layers), these always run in this
298    /// outermost position in both static and fully-dynamic modes, and never
299    /// see the session extension.
300    pub static_gate_layers: Vec<crate::app::CustomLayerRegistration>,
301    #[cfg(feature = "maud")]
302    pub error_page_renderer: Option<SharedRenderer>,
303    /// Custom session store installed via
304    /// [`AppBuilder::with_session_store`](crate::app::AppBuilder::with_session_store).
305    /// When `Some`, [`build_session_layer`](crate::session::build_session_layer)
306    /// uses it directly and skips the config-driven backend selection.
307    pub session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
308    /// `OpenAPI` generation configuration. When `Some`, the router mounts
309    /// an `openapi.json` endpoint and (optionally) a Swagger UI page
310    /// describing the application's routes.
311    ///
312    /// Gated behind the `openapi` feature.
313    #[cfg(feature = "openapi")]
314    pub openapi: Option<crate::openapi::OpenApiConfig>,
315    /// MCP (Model Context Protocol) runtime config. When `Some`, the router
316    /// mounts a Streamable-HTTP MCP endpoint that projects opted-in routes as
317    /// agent-callable tools and dispatches `tools/call` through the real
318    /// handler pipeline.
319    ///
320    /// Gated behind the `mcp` feature.
321    #[cfg(feature = "mcp")]
322    pub mcp: Option<crate::mcp::McpRuntime>,
323    /// `WebMCP` settings (from `AppBuilder::mount_webmcp`). With `Some`, the
324    /// router serves the tool list at `/_autumn/webmcp/tools.json`.
325    #[cfg(feature = "webmcp")]
326    pub webmcp: Option<crate::webmcp::WebMcpRuntime>,
327}
328
329/// Checked variant of [`build_router`] that returns configuration errors
330/// instead of panicking.
331///
332/// # Errors
333///
334/// Returns [`RouterBuildError`] when router assembly encounters invalid
335/// framework configuration, such as an unusable session backend.
336pub fn try_build_router(
337    route_list: Vec<Route>,
338    config: &AutumnConfig,
339    state: AppState,
340) -> Result<axum::Router, RouterBuildError> {
341    try_build_router_with_layers(route_list, config, state, Vec::new())
342}
343
344/// [`try_build_router`] plus caller-supplied app-wide Tower layers.
345///
346/// The layers are handed to the same [`RouterContext::custom_layers`] slot
347/// that [`AppBuilder::layer`](crate::app::AppBuilder::layer) uses, so they
348/// land in the identical stack position (inside `RequestId` and the session
349/// layer, outside CSRF/CORS) and compose with the identical ordering
350/// contract — the first registration is the outermost layer on ingress.
351///
352/// Exists for `SystemTest::layer` (feature `system-tests`, hence no intra-doc
353/// link from this always-compiled module): a browser test that registers its
354/// app's middleware must observe exactly the stack the real app serves,
355/// otherwise the harness lies about what production does.
356///
357/// # Errors
358///
359/// Returns [`RouterBuildError`] when router assembly encounters invalid
360/// framework configuration, such as an unusable session backend.
361pub fn try_build_router_with_layers(
362    route_list: Vec<Route>,
363    config: &AutumnConfig,
364    state: AppState,
365    custom_layers: Vec<crate::app::CustomLayerRegistration>,
366) -> Result<axum::Router, RouterBuildError> {
367    let startup_barrier_state = state.clone();
368    let router = try_build_router_inner(
369        route_list,
370        config,
371        state,
372        RouterContext {
373            exception_filters: Vec::new(),
374            scoped_groups: Vec::new(),
375            merge_routers: Vec::new(),
376            nest_routers: Vec::new(),
377            declared_routes: Vec::new(),
378            custom_layers,
379            static_gate_layers: Vec::new(),
380            #[cfg(feature = "maud")]
381            error_page_renderer: None,
382            session_store: None,
383            #[cfg(feature = "openapi")]
384            openapi: None,
385            #[cfg(feature = "mcp")]
386            mcp: None,
387            #[cfg(feature = "webmcp")]
388            webmcp: None,
389        },
390    )?;
391    Ok(apply_startup_barrier(
392        router,
393        config,
394        &startup_barrier_state,
395    ))
396}
397
398/// Build a router that includes user-supplied raw Axum routers.
399///
400/// Like [`build_router`], but also merges and nests additional raw
401/// Axum routers. This is primarily useful for integration testing;
402/// in production, use [`AppBuilder::merge`](crate::app::AppBuilder::merge) and [`AppBuilder::nest`](crate::app::AppBuilder::nest).
403///
404/// # Panics
405///
406/// Panics when framework router assembly encounters invalid configuration.
407/// Use [`try_build_router_merged`] to handle configuration errors explicitly.
408#[allow(dead_code)]
409pub fn build_router_merged(
410    route_list: Vec<Route>,
411    config: &AutumnConfig,
412    state: AppState,
413    merge_routers: Vec<axum::Router<AppState>>,
414    nest_routers: Vec<(String, axum::Router<AppState>)>,
415) -> axum::Router {
416    try_build_router_merged(route_list, config, state, merge_routers, nest_routers)
417        .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
418}
419
420/// Checked variant of [`build_router_merged`] that returns configuration
421/// errors instead of panicking.
422///
423/// # Errors
424///
425/// Returns [`RouterBuildError`] when router assembly encounters invalid
426/// framework configuration, such as an unusable session backend.
427#[allow(dead_code)]
428pub fn try_build_router_merged(
429    route_list: Vec<Route>,
430    config: &AutumnConfig,
431    state: AppState,
432    merge_routers: Vec<axum::Router<AppState>>,
433    nest_routers: Vec<(String, axum::Router<AppState>)>,
434) -> Result<axum::Router, RouterBuildError> {
435    let startup_barrier_state = state.clone();
436    let router = try_build_router_inner(
437        route_list,
438        config,
439        state,
440        RouterContext {
441            exception_filters: Vec::new(),
442            scoped_groups: Vec::new(),
443            merge_routers,
444            nest_routers,
445            declared_routes: Vec::new(),
446            custom_layers: Vec::new(),
447            static_gate_layers: Vec::new(),
448            #[cfg(feature = "maud")]
449            error_page_renderer: None,
450            session_store: None,
451            #[cfg(feature = "openapi")]
452            openapi: None,
453            #[cfg(feature = "mcp")]
454            mcp: None,
455            #[cfg(feature = "webmcp")]
456            webmcp: None,
457        },
458    )?;
459    Ok(apply_startup_barrier(
460        router,
461        config,
462        &startup_barrier_state,
463    ))
464}
465
466pub fn try_build_router_inner(
467    route_list: Vec<Route>,
468    config: &AutumnConfig,
469    state: AppState,
470    ctx: RouterContext,
471) -> Result<axum::Router, RouterBuildError> {
472    // Fully-dynamic path: no outer SecurityHeadersLayer is applied after this
473    // returns, so build_router_pre_state applies it (outermost, wrapping the
474    // gate). `ctx.custom_layers` was never drained, so it is already baked
475    // into the router the MCP dispatch clone is taken from — no extra layers
476    // needed for parity. `defer_security_headers = false` also means MCP (if
477    // configured) is merged in internally, so the second tuple element is
478    // always `None` here.
479    let (router, deferred_mcp) =
480        build_router_pre_state(route_list, config, &state, ctx, None, false, Vec::new())?;
481    debug_assert!(
482        deferred_mcp.is_none(),
483        "the fully-dynamic path always merges MCP internally"
484    );
485    Ok(router.with_state(state))
486}
487
488/// Build a probe-only router for the [`Worker`](crate::config::ProcessRole::Worker)
489/// process role.
490///
491/// A worker replica runs job workers and the cron scheduler but serves no user
492/// routes. It still binds the HTTP listener so orchestrators can supervise it,
493/// exposing **only** the framework liveness/readiness/startup/health probes
494/// (per `config.health.*`) and the actuator (`/actuator/*`, so `/actuator/jobs`
495/// works). This mirrors how [`try_build_router_with_static_inner`] finalizes the
496/// full router — same startup barrier and `with_state` — so probe/actuator
497/// behavior is identical, only the user route table is absent.
498///
499/// # Errors
500///
501/// Returns [`RouterBuildError`] when the actuator prefix collides with a probe
502/// path (the same guard the full build path applies).
503pub fn try_build_probe_only_router(
504    config: &AutumnConfig,
505    state: AppState,
506) -> Result<axum::Router, RouterBuildError> {
507    let barrier_state = state.clone();
508    // A worker replica serves no user routes, so nothing can shadow a probe.
509    let no_user_routes = std::collections::HashSet::new();
510    let (mounted_probe_paths, router) =
511        mount_probe_endpoints(axum::Router::<AppState>::new(), config, &no_user_routes);
512    let router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
513    let router = router.with_state(state);
514    let router = apply_startup_barrier(router, config, &barrier_state);
515    // A worker builds this router instead of the full one, and `run()` serves
516    // it over the same listener — the mTLS listener included. It never calls
517    // `apply_middleware`, so mirror the route-level certificate requirement
518    // (#1640) here, or a `required_paths` prefix covering `/actuator/` holds on
519    // a web replica and not on a worker.
520    if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
521        return Ok(router.layer(require_client_cert));
522    }
523    Ok(router)
524}
525
526/// Prepared MCP exposure carried through `build_router_pre_state`: the mount
527/// path, the derived tool catalog, and the optional whole-endpoint auth layer.
528#[cfg(feature = "mcp")]
529type McpPrepared = (
530    String,
531    Vec<crate::mcp::McpToolInfo>,
532    Option<crate::mcp::McpEndpointLayer>,
533    crate::mcp::McpTaskConfig,
534    Option<String>,
535    crate::mcp::McpCatalog,
536);
537
538/// Like [`try_build_router_inner`] but returns `Router<AppState>` before
539/// [`with_state`](axum::Router::with_state) is called.  Used by
540/// [`try_build_router_with_static_inner`] so that user layers and the static
541/// file middleware can be applied to the typed router before state is baked in.
542#[allow(clippy::too_many_lines)]
543// `mcp_dispatch_extra_layers` is always empty and unused when the `mcp`
544// feature is off (see its doc below) — `needless_pass_by_value` reasons about
545// the whole function body, so the allow has to sit here rather than on the
546// parameter itself.
547#[cfg_attr(not(feature = "mcp"), allow(clippy::needless_pass_by_value))]
548fn build_router_pre_state(
549    route_list: Vec<Route>,
550    config: &AutumnConfig,
551    state: &AppState,
552    #[cfg_attr(not(feature = "mcp"), allow(unused_mut))] mut ctx: RouterContext,
553    // When custom_layers are extracted from ctx before this call (SSG path),
554    // the caller pre-computes the flag so the idempotency selector still sees
555    // the real layer list even though ctx.custom_layers is empty.
556    opaque_app_layers_override: Option<bool>,
557    // When true (SSG/ISG path), the `SecurityHeadersLayer` is NOT applied here:
558    // `try_build_router_with_static_inner` applies a single one OUTSIDE the
559    // static-first middleware (wrapping cached pages, dynamic misses, and the
560    // gate), so applying it here too would double-apply it (which breaks CSP
561    // nonces). In the fully-dynamic path this is `false` and the layer is
562    // applied as the outermost framework layer below, wrapping the gate.
563    defer_security_headers: bool,
564    // A *clone* of the global custom layers (`AppBuilder::layer`), applied
565    // ONLY to the MCP dispatch clone (see the `mcp_prepared` comment below) —
566    // never to the router this function returns. In the fully-dynamic path
567    // `ctx.custom_layers` is not pre-drained, so it is already baked into
568    // `router` before the dispatch clone is taken and this is empty (a
569    // no-op). In static/ISR mode `try_build_router_with_static_inner` drains
570    // `ctx.custom_layers` before calling this function (so it can reapply
571    // the original outside the static-first middleware, for compression);
572    // this parameter carries a clone of that same set so the MCP dispatch
573    // clone still enforces it, restoring parity without touching the
574    // live-serving router's layer ordering.
575    #[cfg_attr(not(feature = "mcp"), allow(unused_variables))] mcp_dispatch_extra_layers: Vec<
576        crate::app::CustomLayerRegistration,
577    >,
578) -> Result<
579    (
580        axum::Router<AppState>,
581        // The mounted `/mcp` router, held back rather than merged in here
582        // when `defer_security_headers` is true (SSG/ISG path) — see the
583        // `mcp_prepared` comment below for why. `None` in the fully-dynamic
584        // path (merged internally, as before) and whenever MCP isn't
585        // configured.
586        Option<axum::Router<AppState>>,
587    ),
588    RouterBuildError,
589> {
590    // Verify registered API versions
591    let versions = state.extension::<crate::app::RegisteredApiVersions>();
592    let registered_versions: std::collections::HashSet<&str> = versions
593        .as_ref()
594        .map(|v| v.0.iter().map(|av| av.version.as_str()).collect())
595        .unwrap_or_default();
596
597    reject_unregistered_api_versions(&route_list, &ctx.scoped_groups, &registered_versions)?;
598
599    // Fail fast when two user- or plugin-registered routes share a `(method,
600    // path)`. `group_and_mount_routes` would hand the overlap to
601    // `MethodRouter::merge`, which panics inside `Router::route` at startup
602    // (#1012). Runs before the OpenAPI/MCP preflights so the error is always
603    // `DuplicateUserRoute`, and before any mount so the failure is structured
604    // rather than an axum panic.
605    reject_duplicate_user_routes(
606        &route_list,
607        &ctx.scoped_groups,
608        &ctx.merge_routers,
609        &ctx.nest_routers,
610        &ctx.declared_routes,
611        config,
612    )?;
613
614    // The OpenAPI and MCP mounts live on `ctx`, not on the config, so the
615    // framework check inside the call above cannot see them.
616    #[cfg(feature = "openapi")]
617    reject_declared_routes_on_context_mounts(&ctx.declared_routes, ctx.openapi.as_ref(), {
618        #[cfg(feature = "mcp")]
619        {
620            ctx.mcp.as_ref().map(|rt| rt.mount_path.as_str())
621        }
622        #[cfg(not(feature = "mcp"))]
623        {
624            None
625        }
626    })?;
627
628    // Fail-fast if an OpenAPI mount path collides with a user or
629    // framework GET route — axum panics on overlapping method routes,
630    // so surface this as a recoverable error before we start merging.
631    #[cfg(feature = "openapi")]
632    reject_openapi_path_collisions(
633        ctx.openapi.as_ref(),
634        &route_list,
635        &ctx.scoped_groups,
636        &ctx.merge_routers,
637        &ctx.nest_routers,
638        config,
639    )?;
640
641    // Build the OpenAPI spec BEFORE moving the routes into axum, because
642    // group_and_mount_routes consumes the Route list.
643    #[cfg(feature = "openapi")]
644    let openapi_router = build_openapi_router(
645        &route_list,
646        &ctx.scoped_groups,
647        ctx.openapi.as_ref(),
648        &config.session.cookie_name,
649        versions.as_ref().map_or(&[], |v| v.0.as_slice()),
650    )?;
651
652    // Prepare MCP exposure *before* `route_list` is moved into axum below.
653    // Validate the mount path up front (a typo like `"mcp"` surfaces as a
654    // recoverable error, mirroring the OpenAPI path validation, instead of an
655    // axum panic), derive the tool catalog, and carry the optional endpoint
656    // auth layer to be applied once the router is assembled.
657    #[cfg(feature = "mcp")]
658    let mcp_prepared: Option<McpPrepared> = if let Some(rt) = ctx.mcp.take() {
659        let path = rt.mount_path.as_str();
660        validate_mcp_mount_path(path)?;
661        // The MCP endpoint mounts GET+POST at `mount_path`. If a user, framework,
662        // or OpenAPI route already owns that exact path, the later `merge` would
663        // panic on overlapping method routes; surface it as a recoverable error
664        // first (mirroring the OpenAPI collision preflight).
665        reject_mcp_path_collisions(
666            path,
667            &route_list,
668            &ctx.scoped_groups,
669            config,
670            ctx.openapi.as_ref(),
671            &ctx.merge_routers,
672            &ctx.nest_routers,
673        )?;
674        let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
675        // Pass the app's OpenAPI config (if any) so MCP tool `inputSchema`s
676        // reuse the same registered component schemas as the served spec.
677        let tools = crate::mcp::derive_tools(&docs, rt.expose_all, ctx.openapi.as_ref());
678        // Drop a tool with a meta-tool name before the AEO documents read
679        // the list, so they match the served catalog.
680        let tools = crate::mcp::drop_reserved_tools(tools, rt.catalog);
681        Some((
682            rt.mount_path,
683            tools,
684            rt.endpoint_layer,
685            rt.tasks,
686            rt.instructions,
687            rt.catalog,
688        ))
689    } else {
690        None
691    };
692
693    // WebMCP (#2619): derive the page tools with the `/mcp` derivation and keep
694    // the list in state. The fallback serves it, so a user route wins. The AEO
695    // site below reads the names so its script does not register them twice.
696    #[cfg(feature = "webmcp")]
697    if let Some(rt) = ctx.webmcp.take() {
698        let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
699        crate::webmcp::install(state, &docs, rt, ctx.openapi.as_ref(), config);
700    } else {
701        crate::webmcp::install_empty(state, config);
702    }
703
704    // Agent readiness (AEO): record what the app has before `route_list` is
705    // consumed. The documents are served from the router fallback and the
706    // response layer reads the same snapshot (see `crate::aeo`).
707    #[cfg(feature = "openapi")]
708    let aeo_openapi = ctx.openapi.as_ref();
709    #[cfg(not(feature = "openapi"))]
710    let aeo_openapi: Option<&()> = None;
711    #[cfg(feature = "mcp")]
712    let aeo_mcp = mcp_prepared.as_ref();
713    #[cfg(not(feature = "mcp"))]
714    let aeo_mcp: Option<&()> = None;
715    if config.aeo.enabled
716        && ctx.nest_routers.iter().any(|(prefix, _)| {
717            let prefix = prefix.trim_end_matches('/');
718            prefix.is_empty() || prefix == "/.well-known"
719        })
720    {
721        tracing::warn!(
722            "aeo: a router nested at /.well-known answers every path under it; the \
723             generated /.well-known documents (agent skills, ARD, server card) are hidden"
724        );
725    }
726    state.insert_extension(build_aeo_site(
727        config,
728        state,
729        &route_list,
730        &ctx.scoped_groups,
731        !ctx.merge_routers.is_empty(),
732        aeo_openapi,
733        aeo_mcp,
734    ));
735    if config.aeo.enabled && !config.aeo.paid_routes.is_empty() {
736        state.insert_extension(crate::aeo::commerce::RoutedTemplates {
737            mounted: routed_templates(&route_list, &ctx.scoped_groups, config),
738            nested: route_list
739                .iter()
740                .filter(|r| mounted_paths(r.path, config) != [r.path])
741                .map(|r| r.path.to_owned())
742                .collect(),
743        });
744    }
745
746    // Build the per-route timeout override table before `route_list` and the
747    // scoped groups are consumed by the mounting steps below.
748    let route_timeouts = build_route_timeout_table(&route_list, &ctx.scoped_groups, config);
749    let criticality_layer = crate::middleware::criticality::CriticalityLayer::from_table(
750        build_route_criticality_table(&route_list, &ctx.scoped_groups, config),
751        config.server.admission.trust_criticality_header,
752    );
753
754    let idempotency_layers = build_idempotency_layers(config, state)?;
755    // Both `.layer(..)` custom layers and `.static_gate(..)` gate layers are
756    // opaque app layers for idempotency: an auth/tenant layer in either slot
757    // must force fail-closed replay so a cached mutation can't be served to a
758    // different principal carrying the same Idempotency-Key.
759    let opaque_app_layers_present = opaque_app_layers_override.unwrap_or_else(|| {
760        custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
761            || custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers)
762    });
763    // Capture the paths a user handler already owns BEFORE `route_list` is
764    // consumed below, so the auto-mounted probes can yield to a user route at
765    // the same path instead of panicking on an overlapping `GET` (issue #1971).
766    let user_get_paths = collect_user_get_paths(&route_list, &ctx.scoped_groups);
767
768    let mut router = mount_user_routes(
769        route_list,
770        &ctx.scoped_groups,
771        &ctx.declared_routes,
772        idempotency_layers.as_ref(),
773        opaque_app_layers_present,
774        state,
775        config,
776    )?;
777
778    let dev_reload_enabled = dev::is_enabled_with_env(&crate::config::OsEnv);
779
780    router = mount_framework_routes(router, config, dev_reload_enabled);
781
782    let (mounted_probe_paths, router_with_probes) =
783        mount_probe_endpoints(router, config, &user_get_paths);
784    router = router_with_probes;
785
786    router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
787
788    #[cfg(feature = "openapi")]
789    if let Some(openapi_router) = openapi_router {
790        router = router.merge(openapi_router);
791    }
792
793    // Static file serving. Fingerprinted assets (e.g. `autumn.a1b2c3d4.css`)
794    // get `Cache-Control: public, max-age=31536000, immutable`; other files use
795    // the default browser policy. With an embedded `static/` tree (feature
796    // `embed-assets` plus a registered dir), serve `/static/*` from the binary.
797    // Otherwise serve from the project's `static/` directory, which keeps
798    // hot-reload working in dev.
799    #[cfg(feature = "embed-assets")]
800    let embedded_static = crate::assets::embedded_static_dir().is_some();
801    #[cfg(not(feature = "embed-assets"))]
802    let embedded_static = false;
803
804    if embedded_static {
805        #[cfg(feature = "embed-assets")]
806        {
807            router = router.route(
808                "/static/{*path}",
809                axum::routing::get(crate::assets::serve_embedded),
810            );
811        }
812    } else {
813        let env = crate::config::OsEnv;
814        let static_dir = crate::app::project_dir("static", &env);
815        router = router.nest_service("/static", tower_http::services::ServeDir::new(&static_dir));
816    }
817    router = router.layer(crate::assets::AssetCacheControlLayer);
818
819    router = mount_scoped_groups(
820        router,
821        ctx.scoped_groups,
822        idempotency_layers.as_ref(),
823        state,
824    );
825
826    router = mount_raw_routers(
827        router,
828        ctx.merge_routers,
829        ctx.nest_routers,
830        idempotency_layers.as_ref(),
831    );
832
833    // Extract the pre-static gate layers (AppBuilder::static_gate) before
834    // applying the rest of the middleware. They are applied LAST — after the MCP
835    // dispatch clone is taken below — so a `tools/call` replay never traverses
836    // the page-cache gate. In the SSG/ISG path the caller already drained these
837    // into `try_build_router_with_static_inner`, so this take yields an empty
838    // list there.
839    let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
840
841    // Built once and shared (by clone — it wraps an `Arc` in-flight counter)
842    // between the direct-route stack below and the late-mounted `/mcp`
843    // envelope further down, so both ingress surfaces admit against the same
844    // ceiling instead of each getting its own independent (never-shared)
845    // counter. See `apply_middleware`'s `load_shed_layer` parameter doc.
846    let load_shed_layer = build_load_shed_layer(config, state);
847    #[cfg(feature = "mcp")]
848    let mcp_load_shed_layer = load_shed_layer.clone();
849
850    router = apply_middleware(
851        router,
852        config,
853        state,
854        ctx.exception_filters,
855        ctx.custom_layers,
856        #[cfg(feature = "maud")]
857        ctx.error_page_renderer,
858        ctx.session_store,
859        route_timeouts,
860        criticality_layer,
861        load_shed_layer,
862        defer_security_headers,
863    )?;
864
865    if dev_reload_enabled {
866        // One `Router::layer` call, not two: tuple order is OUTERMOST FIRST, so
867        // `inject_live_reload` stays outer to `disable_static_cache` exactly as
868        // the two chained `.layer()` calls used to leave it (issue #2193).
869        router = router.layer((
870            axum::middleware::from_fn(dev::inject_live_reload),
871            axum::middleware::from_fn(dev::disable_static_cache),
872        ));
873    }
874
875    // Dev request inspector: mount UI and apply recording middleware.
876    // Only active when profile = "dev"; returns 404 for all other profiles.
877    let is_dev_profile = crate::config::profile_is_dev(config.profile.as_deref());
878    if is_dev_profile {
879        // Capture the matched route pattern for the dev error overlay.
880        // Applied as a route_layer so MatchedPath is already set when this runs.
881        router = router.route_layer(axum::middleware::from_fn(
882            crate::middleware::dev::capture_matched_path_middleware,
883        ));
884    }
885    if is_dev_profile {
886        let buf = crate::inspector::InspectorBuffer::new(config.dev.inspector_capacity);
887        let inspector_path = config.dev.inspector_path.clone();
888        let threshold = config.dev.inspector_n_plus_one_threshold;
889
890        // Mount the inspector UI routes. They merge after `apply_middleware`,
891        // so they do not inherit its layers. Mirror the mTLS route requirement
892        // (#1640) onto them, or a `required_paths` prefix that covers the
893        // inspector path promises a 403 it does not deliver.
894        let mut inspector = crate::inspector::inspector_router(buf.clone(), &inspector_path);
895        if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
896            inspector = inspector.layer(require_client_cert);
897        }
898        router = router.merge(inspector);
899        tracing::debug!(
900            path = %inspector_path,
901            "Mounted dev request inspector"
902        );
903
904        // Apply the recording middleware (outermost layer so it captures
905        // all routes). Self-excludes inspector's own path prefix.
906        let layer = crate::inspector::InspectorLayer::new(buf, threshold, inspector_path)
907            .with_session_cookie_name(config.session.cookie_name.clone());
908        router = router.layer(layer);
909    }
910
911    #[cfg(feature = "oauth2")]
912    let http_interceptor = HttpInterceptorLayer::new(state.clone());
913    #[cfg(not(feature = "oauth2"))]
914    let http_interceptor = tower::layer::util::Identity::new();
915
916    // Install the request's app as the ambient event-bus context, so a free
917    // `events::publish` call in a handler or service dispatches against this app
918    // and not the process-global bus. This keeps parallel in-process apps
919    // (notably tests) isolated. One `layer` call for both: tuple order is
920    // outermost first, so the event-bus context stays outer to the oauth2
921    // interceptor, exactly as the two separate calls used to leave it (#2193).
922    let router = router.layer((
923        crate::events::EventAppContextLayer::new(state.clone()),
924        http_interceptor,
925    ));
926
927    // Mount MCP last so its dispatch target — a clone of the fully-assembled
928    // router with state applied — traverses the same routes, layers, and
929    // middleware as an HTTP request. The clone is taken before the MCP route is
930    // added, so `tools/call` never recurses into the MCP endpoint.
931    //
932    // `static_gate` is deliberately absent from the clone in both modes: the
933    // gate layers are applied after the clone is taken. A page-cache gate only
934    // redirects or rejects a browser, which is meaningless for a JSON-RPC
935    // `tools/call`. MCP and API auth belong in route guards, `#[secured]`, or
936    // the session, all of which do traverse the clone.
937    //
938    // In static/ISR mode, `try_build_router_with_static_inner` drains the
939    // global custom layers (`AppBuilder::layer`) out of `ctx.custom_layers`
940    // and applies them to the *live-serving* router only after this clone is
941    // taken (so they can process pre-rendered responses too — see
942    // `RouterContext::custom_layers`'s doc). Left alone, that would mean a
943    // `tools/call` replay skips them entirely (🛡 Warden,
944    // docs/security/2026-09-07-mcp-custom-layer-static-mode/): unlike
945    // `static_gate`, `AppBuilder::layer` is a documented, unrestricted way to
946    // add a real per-path auth check ("wrap every request... cross-cutting
947    // concerns that genuinely apply everywhere", `docs/guide/middleware.md`),
948    // and `docs/guide/mcp.md` promises `tools/call` "runs through the real
949    // handler pipeline... the same in-process path" with no static-mode
950    // carve-out for it. So `try_build_router_with_static_inner` also hands
951    // this function a *clone* of that same drained set
952    // (`mcp_dispatch_extra_layers`) for the dispatch clone alone — applied
953    // here, never to the live-serving router, so the live-serving stack's
954    // ordering/compression trade-off is unchanged. In fully-dynamic mode
955    // `ctx.custom_layers` was never drained, so it is already baked into
956    // `router` above (via `apply_middleware`) and `mcp_dispatch_extra_layers`
957    // is empty — this call is then a no-op, and parity holds exactly as
958    // before.
959    //
960    // That alone isn't sufficient, though: in the SSG/ISG path `mcp_router`
961    // below would otherwise still get merged into `router` *before* the
962    // caller's own `custom_layers` reapplication runs (see
963    // `RouterContext::custom_layers`'s doc), which would additionally wrap
964    // the *live* `/mcp` envelope in `custom_layers` — on top of the dispatch
965    // clone above, doubling every custom layer's side effects per
966    // `tools/call` (a real regression a reviewer caught, see the
967    // `defer_security_headers` branch below). So `mcp_router` is instead
968    // handed back to the caller unmerged in that mode, and the caller merges
969    // it in *after* its own `custom_layers` reapplication.
970    #[cfg(feature = "mcp")]
971    let (router, deferred_mcp_router) =
972        if let Some((mount_path, tools, endpoint_layer, tasks, instructions, catalog)) =
973            mcp_prepared
974        {
975            // The outermost `SecurityHeadersLayer` is applied after this clone, so
976            // the dispatch snapshot would otherwise miss it. That layer also injects
977            // `CspNonce` into request extensions, so a `tools/call` replay of a
978            // handler using the `CspNonce` extractor would 500 when `csp_nonce` is
979            // on. Re-attach it to the dispatch clone only: a direct request gets it
980            // from the outer application, and `serve_mcp` discards the replay's
981            // response headers, so no header is duplicated live. The gate stays off
982            // the clone — a browser redirect is meaningless for JSON-RPC.
983            let dispatch = apply_layers_in_registration_order(
984                router.clone(),
985                mcp_dispatch_extra_layers,
986                "Custom (MCP dispatch parity, static/ISR mode)",
987            )
988            .layer(crate::security::SecurityHeadersLayer::from_config(
989                &config.security.headers,
990            ))
991            .with_state(state.clone());
992            // For header-based tenancy, forward the configured tenant header on
993            // dispatch so tenant-scoped tools resolve the same tenant a direct HTTP
994            // call would. Other sources key off already-forwarded headers/Host.
995            let tenant_header = (config.tenancy.enabled && config.tenancy.source == "header")
996                .then(|| config.tenancy.header_name.clone());
997            let wiring = crate::mcp::McpWiring {
998                // The CORS config drives the cross-origin Origin allowlist and the
999                // endpoint's own OPTIONS preflight responses.
1000                cors: config.cors.clone(),
1001                // The same-origin shortcut is gated on the app's trusted-Host
1002                // policy so it can't be abused for DNS rebinding.
1003                trusted_hosts: TrustedHostPolicy::from_config_with_state(config, state),
1004                tenant_header,
1005                // Forward the configured CSRF header (default `x-csrf-token`) so
1006                // customized CsrfConfig::token_header deployments work via MCP.
1007                csrf_header: config.security.csrf.token_header.to_ascii_lowercase(),
1008                // The envelope is rate-limited below iff rate limiting is enabled;
1009                // when so, a tools/call is counted there and its replay is exempted
1010                // from the dispatch pipeline's limiter (avoiding double-counting).
1011                envelope_rate_limited: config.security.rate_limit.enabled,
1012                // `dispatch` above is cloned from `router`, which already carries
1013                // `load_shed_layer` (applied inside `apply_middleware`) — so when
1014                // the envelope below is ALSO wrapped with that same shared layer,
1015                // a tools/call must mark its replay exempt (avoiding double-
1016                // counting against the same in-flight counter).
1017                envelope_load_shed: mcp_load_shed_layer.is_some(),
1018                // The agent-authority audit path (#1691) writes through the app's
1019                // installed `AuditLogger` and mints its correlation id from the
1020                // injected entropy seam, both reached from state.
1021                state: state.clone(),
1022                // Task requests are bound to the caller. The session cookie is
1023                // one of the credentials in that binding.
1024                session_cookie: config.session.cookie_name.clone(),
1025                tasks,
1026                instructions,
1027                catalog,
1028            };
1029            let mut mcp_router =
1030                crate::mcp::build_mcp_router(&mount_path, tools, dispatch, wiring, endpoint_layer);
1031            // NOTE: this envelope's inbound request-timeout layer is applied further
1032            // down, outer to the rate-limit layer (see
1033            // `apply_request_timeout_middleware` below). It must wrap the limiter so
1034            // a stalled Redis rate-limit decision is bounded by `request_timeout_ms`,
1035            // matching the main stack.
1036            // Gate the envelope under maintenance mode, mirroring the layer
1037            // `apply_middleware` installs for direct routes. The `/mcp` router merges
1038            // after that layer, so without this `initialize`/`tools/list` would keep
1039            // serving the tool catalog during maintenance; the `tools/call` replay is
1040            // already gated through the dispatch clone. Applied before
1041            // `TrustedProxiesLayer` so it is inner to it, letting the maintenance IP
1042            // allow-list read the proxy-resolved identity instead of a spoofable raw
1043            // `X-Forwarded-For`.
1044            mcp_router = mcp_router.layer(build_maintenance_layer(config, state));
1045            // mTLS route requirement (#1640), mirroring the layer
1046            // `apply_middleware` installs for direct routes. The `/mcp` router
1047            // merges after that layer, so an operator who puts the MCP mount
1048            // itself under `required_paths` would otherwise find `initialize`,
1049            // `tools/list` and `tools/call` answering an uncertified client
1050            // while the prefix promised a 403. The `tools/call` replay is
1051            // guarded separately, through the dispatch clone.
1052            if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
1053                mcp_router = mcp_router.layer(require_client_cert);
1054            }
1055            // Admission control / load shedding (#1006), mirroring the layer
1056            // `apply_middleware` installs for direct routes (see the comment
1057            // there). The `/mcp` router is merged after that layer, so without
1058            // this, `initialize`/`tools/list`/`tools/call` would bypass
1059            // `server.max_concurrent_requests` entirely. Reuses the SAME
1060            // `load_shed_layer` instance passed to `apply_middleware` above
1061            // (cloned, sharing its `Arc` in-flight counter) rather than building
1062            // a second, independently-counting layer — see that call site's
1063            // comment. `None` (the default) is a no-op, matching direct routes.
1064            if let Some(load_shed) = mcp_load_shed_layer {
1065                // The envelope does not know the tool yet, so it admits as
1066                // `critical` (up to the full limit). The `tools/call` replay
1067                // then claims a slot at the tool route's own class (#3068,
1068                // #3186), so a `default` or `sheddable` tool is still shed at
1069                // its share. The marker gives the replay a handle to the
1070                // envelope's admission.
1071                mcp_router = mcp_router
1072                    .layer(load_shed)
1073                    .layer(axum::Extension(crate::middleware::LoadShedEnvelope))
1074                    .layer(axum::Extension(crate::admission::Criticality::Critical));
1075            }
1076            // Stamp `ResolvedClientIdentity` on the *outer* `/mcp` request too. The
1077            // MCP route is merged after `apply_middleware`, so the centralized
1078            // `TrustedProxiesLayer` above does not wrap it; without this, the
1079            // endpoint's own DNS-rebinding / same-origin check would fall back to
1080            // the raw (possibly proxy-rewritten) `Host` and wrongly 403 a
1081            // same-origin browser client behind a TLS-terminating proxy. The
1082            // dispatch clone already carries its own copy of this layer.
1083            mcp_router = apply_trusted_proxies_middleware(mcp_router, config);
1084            // The MCP route is merged after the ingress upload guards
1085            // (`build_upload_layers`), so axum's
1086            // built-in 2 MiB `DefaultBodyLimit` — not the app's configured limit —
1087            // would otherwise govern the `tools/call` envelope's `Bytes` body. Apply
1088            // the same cap a direct JSON endpoint gets so larger-but-valid tool
1089            // payloads aren't rejected before dispatch.
1090            mcp_router = mcp_router.layer(axum::extract::DefaultBodyLimit::max(
1091                config.security.upload.max_request_size_bytes,
1092            ));
1093            // Rate-limit the envelope so `secure_mcp` auth rejections are throttled;
1094            // they never reach the dispatch clone's limiter, so credential guessing
1095            // would otherwise consume no per-client bucket. A successful tools/call
1096            // is counted once here and replayed with `RateLimitExempt`, so the
1097            // dispatch pipeline's limiter does not count it twice. No-op when rate
1098            // limiting is off, matching `envelope_rate_limited`.
1099            //
1100            // Known limitation with `key_strategy = AuthenticatedPrincipal` plus
1101            // session auth: the envelope keys on the IP fallback, because the session
1102            // layer that `populate_rate_limit_principal` reads runs inside
1103            // `apply_middleware` and does not wrap this late-merged router. The
1104            // tools/call replay is then exempt, so the dispatch clone's
1105            // principal-aware limiter is skipped too. A session-authenticated MCP
1106            // call therefore misses the per-user bucket a direct request would use.
1107            mcp_router = apply_rate_limit_middleware(mcp_router, config, state);
1108            // Bound the whole envelope by the global inbound deadline: the rate-limit
1109            // decision (a stalled Redis limiter would tie up `/mcp` indefinitely), the
1110            // metadata and auth work (initialize, tools/list, and `secure_mcp`
1111            // rejections that never reach the dispatch clone), and the in-process
1112            // `tools/call` dispatch. The `/mcp` router merges after `apply_middleware`,
1113            // so the timeout layer installed there does not wrap it. Applied outer to
1114            // the rate-limit layer above, matching the main stack, but inner to the
1115            // security-header and CORS layers below, so a timeout 503 still flows out
1116            // through them and stays CORS-readable. The mount path is fixed, so
1117            // route-level overrides cannot apply and an empty override table is passed.
1118            // The layer no-ops when the global timeout is disabled.
1119            //
1120            // Known limitation for tools/call: this timer wraps the whole POST,
1121            // including the dispatch replay, with the global default deadline. The
1122            // dispatch clone's own per-route timeout layer is inner to this one, so a
1123            // tool whose route declares `timeout = "off"` or a longer `timeout_ms` is
1124            // still capped at the global default over MCP. Honoring the per-route
1125            // policy would mean propagating the dispatched route's timeout out to this
1126            // single fixed-path endpoint, which has no per-route distinction at the
1127            // layer level. `mirror_cors = false`: the 503 already exits through this
1128            // router's outer `CorsLayer` from `apply_mcp_cors_layer`.
1129            mcp_router = apply_request_timeout_middleware(
1130                mcp_router,
1131                config,
1132                state,
1133                std::sync::Arc::new(std::collections::HashMap::new()),
1134                false,
1135            );
1136            // Security headers (HSTS/CSP/etc.), mirroring the `SecurityHeadersLayer`
1137            // `apply_middleware` installs for direct routes. The `/mcp` router merges
1138            // after that layer, so without this the envelope's `initialize`,
1139            // `tools/list`, auth 401/403, and rate-limit 429 responses would ship
1140            // without the configured `security.headers`. The `tools/call` replay's
1141            // headers are produced on the dispatch clone and discarded when
1142            // `serve_mcp` rebuilds the JSON-RPC response, so the envelope needs its own.
1143            mcp_router = mcp_router.layer(crate::security::SecurityHeadersLayer::from_config(
1144                &config.security.headers,
1145            ));
1146            // CORS grant outermost so every response — including auth 401/403, the
1147            // 413 body-limit rejection, and a 429 from the limiter above, all
1148            // produced before `serve_mcp` runs — is readable by an allowlisted
1149            // browser client instead of being masked as a CORS failure.
1150            mcp_router = crate::mcp::apply_mcp_cors_layer(mcp_router, &config.cors);
1151            // In the SSG/ISG path, merging `mcp_router` in here (as the
1152            // fully-dynamic path always does) would put it inside the caller's
1153            // later `custom_layers` reapplication (`try_build_router_with_static_inner`,
1154            // "Custom (outside static middleware)") — which, combined with
1155            // `mcp_dispatch_extra_layers` above, would run every custom layer
1156            // *twice* per `tools/call`: once for the live `/mcp` POST, once for
1157            // the replay. A stateful layer (a counter, a rate limiter, an audit
1158            // log) would then be charged twice per call — a real regression a
1159            // reviewer caught (🛡 Warden PR #2608). So in this mode `mcp_router`
1160            // is handed back to the caller instead of merged here; the caller
1161            // merges it in *after* that reapplication, so the live envelope
1162            // never sees `custom_layers` at all — matching the fully-dynamic
1163            // path, where `/mcp` structurally never traverses them either
1164            // (see `docs/guide/mcp.md`'s "why `/mcp` sits outside the global
1165            // middleware stack"). It still gets everything merged in *before*
1166            // this point (nothing — `mcp_router` above is fully self-contained)
1167            // and everything the caller wraps *after* the custom-layers
1168            // reapplication (static_gate, compression, security headers),
1169            // exactly as it already does today.
1170            if defer_security_headers {
1171                (router, Some(mcp_router))
1172            } else {
1173                (router.merge(mcp_router), None)
1174            }
1175        } else {
1176            (router, None)
1177        };
1178    #[cfg(not(feature = "mcp"))]
1179    let deferred_mcp_router: Option<axum::Router<AppState>> = None;
1180
1181    // Apply the pre-static gate and the outermost `SecurityHeadersLayer` last,
1182    // after the MCP dispatch clone above. This keeps the gate out of the
1183    // `tools/call` path in fully-dynamic mode, matching the SSG/ISG path, while
1184    // still running it before session and the static cache for ordinary HTTP
1185    // requests. `SecurityHeadersLayer` goes outermost so a gate redirect or 401
1186    // still carries HSTS/CSP/nosniff; one application keeps CSP nonces consistent.
1187    //
1188    // In the SSG/ISG path `defer_security_headers` is true and
1189    // `try_build_router_with_static_inner` already drained the gate layers and
1190    // applied both outside the static-first middleware, so this block no-ops.
1191    let router = if defer_security_headers {
1192        router
1193    } else if static_gate_layers.is_empty() {
1194        router.layer(crate::security::SecurityHeadersLayer::from_config(
1195            &config.security.headers,
1196        ))
1197    } else {
1198        // ONE application for both: a `tower-layer` tuple puts its FIRST
1199        // element OUTERMOST, so `SecurityHeadersLayer` stays outside the gates
1200        // — the same order the two separate `.layer()` calls produced (the
1201        // gates were applied first, then security headers wrapped them). A
1202        // registered gate therefore costs the framework no extra nesting level
1203        // at all.
1204        tracing::debug!(
1205            count = static_gate_layers.len(),
1206            "Pre-static gate Tower layers applied"
1207        );
1208        router.layer((
1209            crate::security::SecurityHeadersLayer::from_config(&config.security.headers),
1210            ComposedRegisteredLayers::new(static_gate_layers),
1211        ))
1212    };
1213
1214    Ok((router, deferred_mcp_router))
1215}
1216
1217/// Parse `{name}` captures from a route path.
1218///
1219/// Mirrors the compile-time extractor in `autumn_macros::api_doc` so
1220/// runtime spec assembly (which sees scope prefixes that the macro
1221/// never does) produces consistent parameter lists.
1222#[cfg(feature = "openapi")]
1223pub fn extract_path_params(path: &str) -> Vec<String> {
1224    let mut out = Vec::new();
1225    let mut remaining = path;
1226
1227    while let Some(start) = remaining.find('{') {
1228        let after_brace = &remaining[start + 1..];
1229        // `{{` is an escaped literal brace (matchit renders `{{`/`}}` as literal
1230        // `{`/`}`), not a parameter. Skip the escaped brace and continue,
1231        // mirroring `autumn_macros::api_doc::extract_path_params`. The prior
1232        // `rfind`-based variant dropped this branch and so injected a phantom
1233        // param for valid escaped-brace routes (`{{hello}}` -> `hello`).
1234        if let Some(rest) = after_brace.strip_prefix('{') {
1235            remaining = rest;
1236            continue;
1237        }
1238        let Some(end_rel) = after_brace.find('}') else {
1239            break;
1240        };
1241
1242        let inner = &after_brace[..end_rel];
1243        // Isolate the parameter name from any `:constraint` suffix
1244        // (`{id:[0-9]+}` -> `id`).
1245        let name = inner.split(':').next().unwrap_or(inner).trim();
1246        // Brace-free guard: only emit a name that is non-empty and contains no
1247        // stray brace. On nested/unbalanced input the inner segment may still
1248        // hold a `{` (e.g. `"{a{b}"` -> inner `"a{b"`); dropping such names
1249        // keeps the emitted list brace-free, which the macro algorithm alone
1250        // would not (#1721).
1251        if !name.is_empty() && !name.contains('{') && !name.contains('}') {
1252            out.push(name.to_owned());
1253        }
1254
1255        remaining = &after_brace[end_rel + 1..];
1256    }
1257
1258    out
1259}
1260
1261/// Handler that dynamically constructs the `OpenAPI` specification document per request
1262/// so deprecation and sunset statuses do not go stale.
1263#[cfg(feature = "openapi")]
1264async fn serve_openapi_spec(
1265    state: axum::extract::State<AppState>,
1266    axum::extract::Extension(config): axum::extract::Extension<
1267        std::sync::Arc<crate::openapi::OpenApiConfig>,
1268    >,
1269    axum::extract::Extension(docs): axum::extract::Extension<
1270        std::sync::Arc<Vec<crate::openapi::ApiDoc>>,
1271    >,
1272) -> impl axum::response::IntoResponse {
1273    use axum::response::IntoResponse;
1274    let refs: Vec<&crate::openapi::ApiDoc> = docs.iter().collect();
1275    let now = state.clock().now();
1276    let spec = crate::openapi::generate_spec_at(&config, &refs, now);
1277    // MPP discovery: `x-payment-info` on priced operations.
1278    let spec_json = state.extension::<AutumnConfig>().map_or_else(
1279        || serde_json::to_string_pretty(&spec),
1280        |app| crate::aeo::commerce::spec_json_with_mpp(&spec, &app),
1281    );
1282    let spec_json =
1283        spec_json.unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize spec: {e}\"}}"));
1284    (
1285        [(http::header::CONTENT_TYPE, "application/json")],
1286        spec_json,
1287    )
1288        .into_response()
1289}
1290
1291/// Build an Axum sub-router that serves the generated `OpenAPI` document
1292/// and (optionally) a Swagger UI HTML page.
1293///
1294/// Returns `None` when `OpenAPI` generation is disabled, i.e. the user
1295/// never called [`AppBuilder::openapi`](crate::app::AppBuilder::openapi).
1296///
1297/// The spec is dynamically generated on request to prevent lifecycle status from going stale.
1298#[cfg(feature = "openapi")]
1299fn build_openapi_router(
1300    route_list: &[Route],
1301    scoped_groups: &[ScopedGroup],
1302    openapi_config: Option<&crate::openapi::OpenApiConfig>,
1303    session_cookie_name: &str,
1304    api_versions: &[crate::app::ApiVersion],
1305) -> Result<Option<axum::Router<AppState>>, RouterBuildError> {
1306    let Some(config) = openapi_config else {
1307        return Ok(None);
1308    };
1309    let mut config = config.clone();
1310    session_cookie_name.clone_into(&mut config.session_cookie_name);
1311    config.api_versions = api_versions.to_vec();
1312
1313    // Validate user-provided paths up front so a typo like
1314    // `"openapi.json"` surfaces as a recoverable RouterBuildError
1315    // rather than an axum panic (`Paths must start with a '/'`).
1316    validate_openapi_mount_paths(&config)?;
1317
1318    let docs = collect_openapi_docs(route_list, scoped_groups);
1319
1320    let json_path = config.openapi_json_path.clone();
1321    let swagger_path = config.swagger_ui_path.clone();
1322    let title = config.title.clone();
1323
1324    let mut router = axum::Router::<AppState>::new()
1325        .route(&json_path, axum::routing::get(serve_openapi_spec))
1326        .layer(axum::extract::Extension(std::sync::Arc::new(
1327            config.clone(),
1328        )))
1329        .layer(axum::extract::Extension(std::sync::Arc::new(docs)));
1330
1331    if let Some(path) = swagger_path {
1332        router = mount_swagger_ui_routes(router, &path, &title, &json_path);
1333    }
1334
1335    tracing::debug!(
1336        openapi_json = %json_path,
1337        swagger_ui = ?config.swagger_ui_path,
1338        swagger_ui_version = crate::openapi::SWAGGER_UI_VERSION,
1339        "Mounted OpenAPI endpoints"
1340    );
1341
1342    Ok(Some(router))
1343}
1344
1345/// Join a nest/scope prefix with a child route path, matching
1346/// `axum::Router::nest` normalization.
1347///
1348/// `nest("/api", r)` mounts r's `/` at `/api` (not `/api/`), and any
1349/// other child path `/foo` at `/api/foo`. The collision check and the
1350/// path emitted into the `OpenAPI` spec must use the same shape or we
1351/// end up either missing real collisions (the reviewer's case:
1352/// `/api` + `/` recorded as `/api/` but axum routes it at `/api`) or
1353/// generating a spec whose URLs don't match what axum serves.
1354#[allow(dead_code)]
1355pub fn join_nested_path(prefix: &str, child: &str) -> String {
1356    if child == "/" || child.is_empty() {
1357        // axum mounts the root child at the prefix *verbatim*, keeping any
1358        // trailing slash: `nest("/api", route("/"))` is served at "/api" while
1359        // `nest("/api/", route("/"))` is served at "/api/" — and `MatchedPath`
1360        // reports the same string. Preserve the prefix as-is so the per-route
1361        // timeout table keys by exactly what the runtime looks up; only the
1362        // empty (root) prefix collapses to "/".
1363        if prefix.is_empty() {
1364            "/".to_owned()
1365        } else {
1366            prefix.to_owned()
1367        }
1368    } else {
1369        // Non-root children always join on a single slash, matching axum (e.g.
1370        // `nest("/api/", route("/users"))` resolves to "/api/users").
1371        let prefix_trimmed = prefix.trim_end_matches('/');
1372        if child.starts_with('/') {
1373            format!("{prefix_trimmed}{child}")
1374        } else {
1375            format!("{prefix_trimmed}/{child}")
1376        }
1377    }
1378}
1379
1380/// Shared validator for user-supplied `OpenAPI` mount paths.
1381///
1382/// Catches the common typos that would otherwise manifest as axum
1383/// panics inside `Router::route` at startup:
1384///
1385/// * empty or missing leading slash,
1386/// * unbalanced `{` / `}` pairs,
1387/// * any `{…}` / `{*…}` capture or wildcard syntax (the mount points
1388///   are static endpoints — a user that needs templated paths shouldn't
1389///   be using this field), and
1390/// * any `*` wildcard character (axum treats these as catch-alls).
1391///
1392/// The check intentionally stays conservative: rejecting a few valid-
1393/// but-weird paths is far better than letting a typo like
1394/// `"openapi.json"` or `"/docs/{id}"` crash boot.
1395#[cfg(feature = "openapi")]
1396fn validate_route_path(field: &'static str, value: &str) -> Result<(), RouterBuildError> {
1397    let reject = |reason_fragment: &str| {
1398        Err(RouterBuildError::InvalidOpenApiPath {
1399            field,
1400            value: format!("{value:?} {reason_fragment}"),
1401        })
1402    };
1403
1404    if value.is_empty() {
1405        return reject("(must be non-empty)");
1406    }
1407    if !value.starts_with('/') {
1408        return reject("(must start with '/')");
1409    }
1410    // Double-slash inside the path is almost always a typo (e.g.
1411    // `//v3/api-docs`) and axum normalizes it away on match, so
1412    // treating it as invalid avoids surprising "route can't be hit"
1413    // reports in the field.
1414    if value.contains("//") {
1415        return reject("(must not contain '//')");
1416    }
1417
1418    let mut depth: i32 = 0;
1419    for ch in value.chars() {
1420        match ch {
1421            '{' => depth += 1,
1422            '}' => {
1423                depth -= 1;
1424                if depth < 0 {
1425                    return reject("(unbalanced '}')");
1426                }
1427            }
1428            '*' => return reject("(wildcard '*' is not allowed in an OpenAPI mount path)"),
1429            _ => {}
1430        }
1431    }
1432    if depth != 0 {
1433        return reject("(unbalanced '{')");
1434    }
1435    if value.contains('{') {
1436        return reject("(OpenAPI mount paths must be static; `{…}` captures are not allowed)");
1437    }
1438    Ok(())
1439}
1440
1441/// The x402 layer, or a no-op when no route is priced.
1442#[cfg(feature = "http-client")]
1443fn aeo_x402_layer(
1444    config: &AutumnConfig,
1445    state: &AppState,
1446    static_paths: crate::aeo::commerce::StaticPaths,
1447) -> tower::util::Either<crate::aeo::commerce::X402Layer, tower::layer::util::Identity> {
1448    tower::util::option_layer(
1449        crate::aeo::commerce::X402Layer::shared(config, state).map(|layer| match static_paths {
1450            Some(paths) => layer.for_static_pages(paths),
1451            None => layer,
1452        }),
1453    )
1454}
1455
1456/// Without `http-client`, priced routes fail closed (`503`).
1457#[cfg(not(feature = "http-client"))]
1458fn aeo_x402_layer(
1459    config: &AutumnConfig,
1460    state: &AppState,
1461    static_paths: crate::aeo::commerce::StaticPaths,
1462) -> tower::util::Either<crate::aeo::commerce::X402Unavailable, tower::layer::util::Identity> {
1463    tower::util::option_layer(
1464        crate::aeo::commerce::X402Unavailable::from_config(config, state).map(|layer| {
1465            match static_paths {
1466                Some(paths) => layer.for_static_pages(paths),
1467                None => layer,
1468            }
1469        }),
1470    )
1471}
1472
1473/// The `GET /` routes the route table and scoped groups declare.
1474/// Each is `Some(noindex)` when that home exists.
1475struct HomeRoutes {
1476    /// A `routes![...]` home, mounted inside the locale nests.
1477    routed: Option<bool>,
1478    /// A scoped-group home, mounted outside them.
1479    scoped: Option<bool>,
1480}
1481
1482impl HomeRoutes {
1483    fn find(route_list: &[Route], scoped_groups: &[ScopedGroup]) -> Self {
1484        let routed: Vec<&Route> = route_list
1485            .iter()
1486            .filter(|r| r.method == http::Method::GET && r.path == "/")
1487            .collect();
1488        let scoped: Vec<&Route> = scoped_groups
1489            .iter()
1490            .flat_map(|g| {
1491                g.routes.iter().filter(|r| {
1492                    r.method == http::Method::GET && join_nested_path(&g.prefix, r.path) == "/"
1493                })
1494            })
1495            .collect();
1496        let home = |routes: &[&Route]| {
1497            (!routes.is_empty()).then(|| {
1498                routes.iter().any(|r| {
1499                    r.seo
1500                        .robots
1501                        .is_some_and(crate::seo::robots_directive_is_noindex)
1502                })
1503            })
1504        };
1505        Self {
1506            routed: home(&routed),
1507            scoped: home(&scoped),
1508        }
1509    }
1510}
1511
1512/// Build the AEO site snapshot from the route registry, the `OpenAPI` config,
1513/// and the MCP tool catalog.
1514#[cfg_attr(
1515    not(all(feature = "openapi", feature = "mcp")),
1516    allow(clippy::needless_pass_by_value, unused_variables)
1517)]
1518fn build_aeo_site<O: AeoOpenApiFacts, M: AeoMcpFacts>(
1519    config: &AutumnConfig,
1520    state: &AppState,
1521    route_list: &[Route],
1522    scoped_groups: &[ScopedGroup],
1523    merged_routers: bool,
1524    openapi: Option<&O>,
1525    mcp: Option<&M>,
1526) -> crate::aeo::AeoSite {
1527    use crate::aeo::documents::{PageFacts, SiteFacts};
1528
1529    let page = |path: String, seo: crate::seo::SeoRouteDefaults| {
1530        let title = seo.title?;
1531        let indexable = !seo
1532            .robots
1533            .is_some_and(crate::seo::robots_directive_is_noindex);
1534        (indexable && !path.contains('{') && !path.contains('*')).then(|| PageFacts {
1535            path,
1536            title: title.to_owned(),
1537            description: seo.description.map(str::to_owned),
1538        })
1539    };
1540    let pages: Vec<PageFacts> = route_list
1541        .iter()
1542        .filter(|r| r.method == http::Method::GET)
1543        .filter_map(|r| page(r.path.to_owned(), r.seo))
1544        .collect();
1545    // With locale-prefixed routing the top-level routes are served under
1546    // `/{locale}` and the bare paths only redirect: list the localized pages,
1547    // as the `[seo]` sitemap does. Scoped groups mount outside the locale
1548    // nests, so their pages keep their paths.
1549    #[cfg(feature = "i18n")]
1550    let locales = if config.i18n.locale_prefix_enabled {
1551        // The locales that get a nest, so discovery lists reachable URLs.
1552        validated_locale_prefix_locales(&config.i18n)
1553    } else {
1554        Vec::new()
1555    };
1556    #[cfg(feature = "i18n")]
1557    let localize = |pages: Vec<PageFacts>| {
1558        crate::aeo::localize_pages(
1559            pages,
1560            &locales,
1561            &config.i18n.locale_prefix_exclude,
1562            &config.i18n.locale_prefix_exclude_exact,
1563        )
1564    };
1565    #[cfg(not(feature = "i18n"))]
1566    let localize = |pages: Vec<PageFacts>| pages;
1567    let mut pages = localize(pages);
1568    // The home page paths: `/`, or `/{locale}` for each locale when `/` is
1569    // localized.
1570    let home_paths: Vec<String> = localize(vec![PageFacts {
1571        path: "/".to_owned(),
1572        ..PageFacts::default()
1573    }])
1574    .into_iter()
1575    .map(|p| p.path)
1576    .collect();
1577    for group in scoped_groups {
1578        pages.extend(
1579            group
1580                .routes
1581                .iter()
1582                .filter(|r| r.method == http::Method::GET)
1583                .filter_map(|r| page(join_nested_path(&group.prefix, r.path), r.seo)),
1584        );
1585    }
1586    pages.sort_by(|a, b| a.path.cmp(&b.path));
1587    pages.dedup_by(|a, b| a.path == b.path);
1588
1589    let home = HomeRoutes::find(route_list, scoped_groups);
1590    // An API-only app has no home page: list none rather than a `404`. A
1591    // merged router is opaque and may serve `/`, so with one the home page
1592    // is assumed to exist.
1593    let has_home = merged_routers || home.routed.is_some() || home.scoped.is_some();
1594    // Only a `routes![...]` home is localized. A home from a scoped group or a
1595    // merged router mounts outside the locale nests and answers at `/`, next
1596    // to any localized ones.
1597    // Each home's own `noindex` leaves only that page out of the sitemap.
1598    let mut noindex_homes = Vec::new();
1599    if home.routed == Some(true) {
1600        noindex_homes.clone_from(&home_paths);
1601    }
1602    if home.scoped == Some(true) {
1603        noindex_homes.push("/".to_owned());
1604    }
1605    let mut home_paths = if home.routed.is_some() {
1606        home_paths
1607    } else {
1608        Vec::new()
1609    };
1610    if home.routed.is_none() || home.scoped.is_some() {
1611        home_paths.push("/".to_owned());
1612    }
1613    home_paths.dedup();
1614    let mut facts = SiteFacts {
1615        pages,
1616        home_paths,
1617        noindex_homes,
1618        no_home: !has_home,
1619        health_path: config.health.enabled.then(|| config.health.path.clone()),
1620        skills: state
1621            .extension::<crate::aeo::RegisteredAgentSkills>()
1622            .map(|s| s.0.clone())
1623            .unwrap_or_default(),
1624        commerce: state
1625            .extension::<crate::aeo::RegisteredCommerceDocs>()
1626            .map(|c| (*c).clone())
1627            .unwrap_or_default(),
1628        ..SiteFacts::default()
1629    };
1630    #[cfg(feature = "webmcp")]
1631    if let Some(manifest) = state.extension::<crate::webmcp::WebMcpManifest>() {
1632        facts.webmcp_projected = manifest.names().to_vec();
1633    }
1634    if let Some(api) = openapi {
1635        api.fill(&mut facts);
1636    }
1637    if let Some(mcp) = mcp {
1638        mcp.fill(&mut facts);
1639    }
1640    crate::aeo::AeoSite::new(config, facts)
1641}
1642
1643/// Copies what AEO needs from the `OpenAPI` config. A trait so
1644/// [`build_aeo_site`] compiles without the `openapi` feature.
1645trait AeoOpenApiFacts {
1646    fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts);
1647}
1648
1649impl AeoOpenApiFacts for () {
1650    fn fill(&self, _: &mut crate::aeo::documents::SiteFacts) {}
1651}
1652
1653#[cfg(feature = "openapi")]
1654impl AeoOpenApiFacts for crate::openapi::OpenApiConfig {
1655    fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts) {
1656        facts.name = Some(self.title.clone()).filter(|t| !t.trim().is_empty());
1657        facts.description.clone_from(&self.description);
1658        facts.openapi = Some(crate::aeo::documents::OpenApiFacts {
1659            json_path: self.openapi_json_path.clone(),
1660            docs_path: self.swagger_ui_path.clone(),
1661            version: self.version.clone(),
1662        });
1663    }
1664}
1665
1666/// Copies what AEO needs from the prepared MCP endpoint.
1667trait AeoMcpFacts {
1668    fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts);
1669}
1670
1671impl AeoMcpFacts for () {
1672    fn fill(&self, _: &mut crate::aeo::documents::SiteFacts) {}
1673}
1674
1675#[cfg(feature = "mcp")]
1676impl AeoMcpFacts for McpPrepared {
1677    fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts) {
1678        let (path, tools, endpoint_layer, ..) = self;
1679        // A gated endpoint (`secure_mcp`) keeps its tool list private.
1680        let public_tools = endpoint_layer.is_none();
1681        let tools = if public_tools {
1682            tools
1683                .iter()
1684                .map(|t| crate::aeo::documents::ToolFacts {
1685                    name: t.name().to_owned(),
1686                    description: t.description().map(str::to_owned),
1687                    input_schema: t.input_schema().clone(),
1688                    annotations: t.annotations().clone(),
1689                })
1690                .collect()
1691        } else {
1692            Vec::new()
1693        };
1694        facts.mcp = Some(crate::aeo::documents::McpFacts {
1695            path: path.clone(),
1696            tools,
1697            public_tools,
1698        });
1699    }
1700}
1701
1702/// Collect the exact `GET`/`WS` paths owned by the user's *typed* route table
1703/// (top-level routes plus scoped-group routes, after scope-prefix resolution).
1704///
1705/// Unlike [`collect_claimed_get_paths`], this deliberately excludes every
1706/// framework-mounted path: its sole purpose is to let the auto-mounted probe
1707/// endpoints ([`mount_probe_endpoints`]) detect when a *user* handler already
1708/// owns a probe path and yield to it, rather than panicking inside
1709/// `axum::Router::route` on an overlapping `GET` (issue #1971). A `WS` route is
1710/// a `GET` under the hood, so it claims the path too (mirroring
1711/// [`collect_claimed_get_paths`]). Opaque routers registered via
1712/// [`AppBuilder::merge`](crate::app::AppBuilder::merge) /
1713/// [`AppBuilder::nest`](crate::app::AppBuilder::nest) are not introspectable
1714/// and so are not covered here — the same limitation the OpenAPI/MCP collision
1715/// preflights carry.
1716fn collect_user_get_paths(
1717    route_list: &[Route],
1718    scoped_groups: &[ScopedGroup],
1719) -> std::collections::HashSet<String> {
1720    let mut owned: std::collections::HashSet<String> = std::collections::HashSet::new();
1721    for route in route_list {
1722        if route.method == http::Method::GET || route.method.as_str() == "WS" {
1723            owned.insert(route.path.to_owned());
1724        }
1725    }
1726    for group in scoped_groups {
1727        for route in &group.routes {
1728            if route.method == http::Method::GET || route.method.as_str() == "WS" {
1729                owned.insert(join_nested_path(&group.prefix, route.path));
1730            }
1731        }
1732    }
1733    owned
1734}
1735
1736/// Path namespaces the framework owns wholesale, rather than as individual
1737/// routes.
1738///
1739/// `/static` is always mounted — as `nest_service` over `ServeDir`, or as
1740/// `GET /static/{*path}` under `embed-assets` — and `/_autumn` holds the
1741/// inspector, job status, mail previews and the unsubscribe endpoint.
1742///
1743/// These need namespace treatment rather than an exact-path claim because the
1744/// paths under them are not enumerable route-by-route: `ServeDir` serves
1745/// whatever is on disk. A plugin nested here is not only a startup panic (a
1746/// declared route AT the prefix, or a catch-all, makes `Router::nest` refuse
1747/// the overlap) — a declared SUB-path mounts cleanly and then **shadows** the
1748/// framework, so an artifact declaring `/static/app.js` would serve script
1749/// from the host's own origin. That is the outcome this lane's response
1750/// content-type allowlist exists to prevent, arriving by a different door.
1751const fn framework_namespaces() -> &'static [&'static str] {
1752    &["/static", "/_autumn"]
1753}
1754
1755/// Whether `path` is inside `namespace` — the namespace itself, or anything
1756/// below it on a segment boundary. `/staticfoo` is NOT inside `/static`.
1757fn path_is_under_namespace(path: &str, namespace: &str) -> bool {
1758    path == namespace
1759        || path
1760            .strip_prefix(namespace)
1761            .is_some_and(|rest| rest.starts_with('/'))
1762}
1763
1764/// Every path a framework-mounted `GET` handler owns: probes, actuator, htmx
1765/// assets, framework CSS, dev live-reload and inspector, mail previews and
1766/// unsubscribe, the story gallery, and the tracked-job status route.
1767///
1768/// Split out of [`collect_claimed_get_paths`] so the declared-plugin-route
1769/// preflight can use it without the `openapi` feature and without duplicating
1770/// this list — the two must not drift, or a path one knows about becomes a
1771/// startup panic the other never sees.
1772fn collect_framework_get_paths(config: &AutumnConfig) -> std::collections::HashSet<String> {
1773    let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
1774    // Framework-mounted GETs. The probes are behind the same `health.enabled`
1775    // off-switch `mount_probe_endpoints` checks (#1971): claiming them while
1776    // nothing mounts them would refuse a plugin — or an OpenAPI/MCP mount path
1777    // — over a collision that cannot happen.
1778    if config.health.enabled {
1779        claimed.insert(config.health.path.clone());
1780        claimed.insert(config.health.live_path.clone());
1781        claimed.insert(config.health.ready_path.clone());
1782        claimed.insert(config.health.startup_path.clone());
1783    }
1784    // Some entries in `actuator_endpoint_paths` exist only to seed the runtime
1785    // startup-barrier allow-list (#1627) and are actually mounted with a
1786    // mutating method — `/webhooks/replay` is a POST. Claiming those as GETs
1787    // refuses a plugin's GET at a path where GET and the framework's POST
1788    // merge cleanly. `route_listing` already subtracts them for the same
1789    // reason; the two lists must agree or one refuses what the other permits.
1790    let actuator_mutating = crate::actuator::actuator_mutating_routes(
1791        &config.actuator.prefix,
1792        config.actuator.sensitive,
1793    );
1794    let mutating_paths: std::collections::HashSet<&str> = actuator_mutating
1795        .iter()
1796        .map(|(_, path)| path.as_str())
1797        .collect();
1798    for path in crate::actuator::actuator_endpoint_paths(
1799        &config.actuator.prefix,
1800        config.actuator.sensitive,
1801        config.actuator.prometheus,
1802    ) {
1803        if mutating_paths.contains(path.as_str()) {
1804            continue;
1805        }
1806        claimed.insert(path);
1807    }
1808    #[cfg(feature = "htmx")]
1809    {
1810        // Both URLs of every framework script. `framework_scripts` leaves htmx
1811        // out when it is vendored via `autumn assets`: ServeDir serves the
1812        // file then, and the path must not appear in the claimed-routes set.
1813        for asset in crate::htmx::framework_scripts() {
1814            claimed.insert(asset.plain_url().to_owned());
1815            claimed.insert(asset.url().to_owned());
1816        }
1817    }
1818    // Framework CSS routes (flash/widget stylesheets) merge a GET
1819    // unconditionally whenever their feature is on, before the late-merged
1820    // OpenAPI/MCP routers — reserve them so a colliding configured path
1821    // surfaces the typed collision error instead of panicking in
1822    // `router.merge`.
1823    #[cfg(feature = "flash")]
1824    claimed.insert(crate::flash::FLASH_CSS_PATH.to_owned());
1825    #[cfg(feature = "maud")]
1826    claimed.insert(crate::ui::WIDGETS_CSS_PATH.to_owned());
1827    // Dev live-reload endpoints are only mounted when the env vars
1828    // that enable them are set, but reserving the paths regardless
1829    // makes the error message deterministic across dev/prod.
1830    if dev::is_enabled_with_env(&crate::config::OsEnv) {
1831        claimed.insert(dev::LIVE_RELOAD_PATH.to_owned());
1832        claimed.insert(dev::LIVE_RELOAD_SCRIPT_PATH.to_owned());
1833    }
1834    // The dev request inspector merges a GET at `config.dev.inspector_path`
1835    // (only under the dev profile), before the late-merged OpenAPI/MCP routers.
1836    // Reserve it so a mount path colliding with the inspector surfaces a
1837    // recoverable error instead of panicking in `router.merge`.
1838    if matches!(config.profile.as_deref(), Some("dev" | "development")) {
1839        // Both of them: the inspector mounts a detail template alongside the
1840        // index, and claiming only the configured path left
1841        // `{inspector_path}/requests/{id}` open whenever the inspector sits
1842        // outside the reserved `/_autumn` namespace.
1843        claimed.extend(crate::inspector::inspector_endpoint_paths(
1844            &config.dev.inspector_path,
1845        ));
1846    }
1847    #[cfg(feature = "mail")]
1848    if config
1849        .mail
1850        .preview_routes_enabled(config.profile.as_deref())
1851    {
1852        claimed.insert(crate::mail::MAIL_PREVIEW_PATH.to_owned());
1853        claimed.insert("/_autumn/mail/messages/{message_id}".to_owned());
1854        claimed.insert("/_autumn/mail/previews/{mailer}/{method}".to_owned());
1855    }
1856    // The widget story gallery merges GETs at `/_stories` and
1857    // `/_stories/{slug}` when `stories.enabled` resolves true, before the
1858    // late-merged OpenAPI/MCP routers — reserve them so a colliding
1859    // configured mount path surfaces the typed collision error instead of
1860    // panicking in `router.merge`.
1861    #[cfg(feature = "maud")]
1862    if config.stories.enabled {
1863        claimed.insert(crate::stories::STORIES_PATH.to_owned());
1864        claimed.insert("/_stories/{slug}".to_owned());
1865        // The Active search / Autocomplete / Infinite feed stories' live
1866        // demo backends (review follow-up — these three were missing from
1867        // the preflight, so a colliding OpenAPI/MCP mount here would panic
1868        // in `router.merge` instead of surfacing the typed collision error).
1869        claimed.insert("/_stories/demo/search".to_owned());
1870        claimed.insert("/_stories/demo/tags/search".to_owned());
1871        claimed.insert("/_stories/demo/posts/feed".to_owned());
1872    }
1873    // The default unsubscribe endpoint merges a GET (+POST) at `UNSUBSCRIBE_PATH`
1874    // before the late-merged OpenAPI/MCP routers, so reserve it too — otherwise an
1875    // OpenAPI/MCP mount configured at `/_autumn/unsubscribe` passes this preflight
1876    // and then panics in `router.merge` instead of surfacing the typed collision.
1877    #[cfg(feature = "mail")]
1878    if config.mail.should_mount_unsubscribe_endpoint() {
1879        claimed.insert(crate::mail::UNSUBSCRIBE_PATH.to_owned());
1880    }
1881    // The tracked-job status endpoint merges a GET before the late-merged
1882    // OpenAPI/MCP routers, so reserve it too (same rationale as unsubscribe
1883    // above): an OpenAPI/MCP mount at this path should surface the typed
1884    // collision instead of panicking in `router.merge`.
1885    if config.jobs.tracking.route_enabled {
1886        claimed.insert(crate::job_tracking::JOB_STATUS_ROUTE_PATH.to_owned());
1887    }
1888    claimed
1889}
1890
1891/// Gather every path that a `GET` (or `WS`, which mounts as a `GET`) handler
1892/// will already own by the time a late-merged sub-router (`OpenAPI` or MCP) is
1893/// added: user routes (top-level + scoped groups) plus framework-mounted `GET`s
1894/// (probes, actuator, htmx assets, dev live-reload, mail previews). Shared by
1895/// the `OpenAPI` and MCP mount-collision preflights so they stay in lockstep.
1896#[cfg(feature = "openapi")]
1897fn collect_claimed_get_paths(
1898    route_list: &[Route],
1899    scoped_groups: &[ScopedGroup],
1900    config: &AutumnConfig,
1901) -> std::collections::HashSet<String> {
1902    let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
1903    for route in route_list {
1904        if route.method == http::Method::GET || route.method.as_str() == "WS" {
1905            claimed.insert(route.path.to_owned());
1906        }
1907    }
1908    for group in scoped_groups {
1909        for route in &group.routes {
1910            if route.method == http::Method::GET || route.method.as_str() == "WS" {
1911                claimed.insert(join_nested_path(&group.prefix, route.path));
1912            }
1913        }
1914    }
1915    claimed.extend(collect_framework_get_paths(config));
1916    claimed
1917}
1918
1919/// Reject an MCP mount path that overlaps with a route already owning that
1920/// path. The MCP endpoint mounts `GET`+`POST` at `mount_path`; merging it would
1921/// panic in axum if a `GET` (any user/framework route) or `POST` (a user route)
1922/// already lives there. We surface a recoverable
1923/// [`RouterBuildError::McpPathCollision`] instead, reusing the same claimed-GET
1924/// gathering as the `OpenAPI` preflight so framework routes (health/probe,
1925/// actuator, htmx, dev) are covered too — e.g. `mount_mcp(config.health.path)`.
1926/// The configured `OpenAPI` JSON/UI/asset paths (which merge as `GET`s before
1927/// the MCP router) are checked as well.
1928#[cfg(feature = "mcp")]
1929pub fn reject_mcp_path_collisions(
1930    mount_path: &str,
1931    route_list: &[Route],
1932    scoped_groups: &[ScopedGroup],
1933    config: &AutumnConfig,
1934    openapi: Option<&crate::openapi::OpenApiConfig>,
1935    merge_routers: &[axum::Router<AppState>],
1936    nest_routers: &[(String, axum::Router<AppState>)],
1937) -> Result<(), RouterBuildError> {
1938    let mut claimed_get = collect_claimed_get_paths(route_list, scoped_groups, config);
1939    // The OpenAPI JSON/Swagger-UI endpoints (and UI assets) merge as GETs
1940    // before the MCP router, so a mount path colliding with them would panic.
1941    if let Some(openapi) = openapi {
1942        claimed_get.insert(openapi.openapi_json_path.clone());
1943        if let Some(ui_path) = &openapi.swagger_ui_path {
1944            claimed_get.insert(ui_path.clone());
1945            claimed_get.extend(crate::openapi::swagger_ui_asset_paths(ui_path));
1946        }
1947    }
1948    if claimed_get.contains(mount_path) {
1949        return Err(RouterBuildError::McpPathCollision {
1950            path: mount_path.to_owned(),
1951            method: "GET".to_owned(),
1952        });
1953    }
1954    // POST handlers come from user routes (framework routes are GETs).
1955    let post_owns_path = route_list
1956        .iter()
1957        .any(|route| route.method == http::Method::POST && route.path == mount_path)
1958        || scoped_groups.iter().any(|group| {
1959            group.routes.iter().any(|route| {
1960                route.method == http::Method::POST
1961                    && join_nested_path(&group.prefix, route.path) == mount_path
1962            })
1963        });
1964    if post_owns_path {
1965        return Err(RouterBuildError::McpPathCollision {
1966            path: mount_path.to_owned(),
1967            method: "POST".to_owned(),
1968        });
1969    }
1970    // A nest prefix P owns every route under P (`/P/...`), and those raw routers
1971    // are mounted before the MCP router. A mount path equal to P or falling
1972    // under `P/` would be shadowed by (or panic against) the nested router, so
1973    // reject it up front — mirroring the OpenAPI nest-collision preflight. The
1974    // framework unconditionally nests the static-file service at `/static`, so
1975    // reserve that prefix too.
1976    let nest_prefixes = nest_routers
1977        .iter()
1978        .map(|(prefix, _)| prefix.as_str())
1979        .chain(std::iter::once("/static"));
1980    for prefix in nest_prefixes {
1981        let prefix_slash = format!("{prefix}/");
1982        if mount_path == prefix || mount_path.starts_with(&prefix_slash) {
1983            return Err(RouterBuildError::McpPathCollision {
1984                path: mount_path.to_owned(),
1985                method: "nested router".to_owned(),
1986            });
1987        }
1988    }
1989    // Raw merged routers are opaque — axum does not expose their route table —
1990    // so an overlapping handler there would still panic at merge time. Warn so
1991    // operators know the check can't cover this case (mirrors the OpenAPI one).
1992    if !merge_routers.is_empty() {
1993        tracing::warn!(
1994            mcp_mount_path = %mount_path,
1995            merged_routers = merge_routers.len(),
1996            "MCP mount collision check skipped for AppBuilder::merge routers: \
1997             axum does not expose their route table, so an overlapping handler \
1998             will still panic at startup. Choose an MCP mount path that doesn't \
1999             overlap with any merged router's handlers."
2000        );
2001    }
2002    Ok(())
2003}
2004
2005/// Reject `OpenAPI` mount paths that overlap with an existing `GET`
2006/// handler.
2007///
2008/// `axum::Router::merge` panics when the merged routers have method
2009/// handlers on the same path (e.g. two `GET` handlers on
2010/// `/openapi.json`). We surface that as a recoverable
2011/// [`RouterBuildError::OpenApiPathCollision`] so misconfiguration
2012/// produces an actionable error instead of a crash on startup.
2013///
2014/// We check against:
2015/// * user routes (top-level + scoped groups) that will be mounted
2016///   before the `OpenAPI` sub-router merges in,
2017/// * framework `GET`s: probes, actuator, htmx assets, and dev
2018///   live-reload when enabled,
2019/// * nest prefixes from [`AppBuilder::nest`](crate::app::AppBuilder::nest)
2020///   when the `OpenAPI` path falls under one.
2021///
2022/// Raw routers passed to [`AppBuilder::merge`](crate::app::AppBuilder::merge)
2023/// cannot be introspected — axum does not expose their route table.
2024/// We emit a `tracing::warn!` so operators know the check is
2025/// incomplete in that case.
2026#[cfg(feature = "openapi")]
2027pub fn reject_openapi_path_collisions(
2028    openapi_config: Option<&crate::openapi::OpenApiConfig>,
2029    route_list: &[Route],
2030    scoped_groups: &[ScopedGroup],
2031    merge_routers: &[axum::Router<AppState>],
2032    nest_routers: &[(String, axum::Router<AppState>)],
2033    config: &AutumnConfig,
2034) -> Result<(), RouterBuildError> {
2035    let Some(openapi) = openapi_config else {
2036        return Ok(());
2037    };
2038
2039    // Gather every path a GET (or WS, which mounts as GET) will already
2040    // own by the time we merge.
2041    let claimed = collect_claimed_get_paths(route_list, scoped_groups, config);
2042
2043    check_openapi_path_against(
2044        "openapi_json_path",
2045        &openapi.openapi_json_path,
2046        &claimed,
2047        nest_routers,
2048    )?;
2049    if let Some(path) = &openapi.swagger_ui_path {
2050        check_openapi_path_against("swagger_ui_path", path, &claimed, nest_routers)?;
2051        let mut claimed_with_openapi = claimed;
2052        claimed_with_openapi.insert(openapi.openapi_json_path.clone());
2053        for asset_path in crate::openapi::swagger_ui_asset_paths(path) {
2054            check_openapi_path_against(
2055                "swagger_ui_path",
2056                &asset_path,
2057                &claimed_with_openapi,
2058                nest_routers,
2059            )?;
2060        }
2061    }
2062
2063    // Raw merged routers are opaque — we can't inspect their route
2064    // tables through the axum API. Warn instead of failing so users
2065    // know the check doesn't cover this code path.
2066    if !merge_routers.is_empty() {
2067        tracing::warn!(
2068            openapi_json_path = %openapi.openapi_json_path,
2069            swagger_ui_path = ?openapi.swagger_ui_path,
2070            merged_routers = merge_routers.len(),
2071            "OpenAPI mount collision check skipped for AppBuilder::merge routers: \
2072             axum does not expose their route table, so overlapping GET handlers \
2073             will still panic at startup. Choose OpenAPI paths that don't overlap \
2074             with any merged router's handlers."
2075        );
2076    }
2077
2078    Ok(())
2079}
2080
2081/// Evaluate a single `OpenAPI` path against the claimed-path set plus
2082/// any nest prefixes. Returns an `OpenApiPathCollision` error on
2083/// collision.
2084#[cfg(feature = "openapi")]
2085fn check_openapi_path_against(
2086    field: &'static str,
2087    path: &str,
2088    claimed: &std::collections::HashSet<String>,
2089    nest_routers: &[(String, axum::Router<AppState>)],
2090) -> Result<(), RouterBuildError> {
2091    if claimed.contains(path) {
2092        return Err(RouterBuildError::OpenApiPathCollision {
2093            field,
2094            path: path.to_owned(),
2095        });
2096    }
2097    // A nest prefix P owns every route under P (`/P/...`), so any
2098    // OpenAPI path that equals P or starts with `P/` will either
2099    // panic on merge (exact match) or nest inside the user's router
2100    // (where axum routing semantics decide which handler wins).
2101    // Reject both cases so the spec endpoint can't silently vanish.
2102    for (prefix, _) in nest_routers {
2103        let prefix_slash = format!("{prefix}/");
2104        if path == prefix || path.starts_with(&prefix_slash) {
2105            return Err(RouterBuildError::OpenApiPathCollision {
2106                field,
2107                path: path.to_owned(),
2108            });
2109        }
2110    }
2111    Ok(())
2112}
2113
2114/// The HTTP method axum actually mounts a handler under — the effective verb the
2115/// duplicate preflight and the request-timeout table must both key on so the two
2116/// never drift.
2117///
2118/// `#[ws]` records the synthetic `WS` method, but the macro builds its handler
2119/// with `axum::routing::get` (plus `.connect` for HTTP/2) and
2120/// [`group_and_mount_routes`] merges it as a `GET` `MethodRouter`. So a `#[ws("/p")]` and a `#[get("/p")]` are the SAME mount as
2121/// far as axum is concerned and would panic on merge. Every other method mounts
2122/// under itself.
2123fn effective_mount_method(method: &http::Method) -> http::Method {
2124    if method.as_str() == "WS" {
2125        http::Method::GET
2126    } else {
2127        method.clone()
2128    }
2129}
2130
2131/// Probe whether two path templates conflict under matchit — the SAME engine
2132/// axum 0.8 routes through — by inserting both into a throwaway router. axum's
2133/// `Router::route` forwards each template to matchit verbatim (brace syntax:
2134/// `{param}` / `{*wild}`), so a matchit `Conflict` here is exactly the mount
2135/// panic `reject_duplicate_user_routes` is preventing. Used only on the error
2136/// path to name the specific prior template a conflicting insert collided with.
2137fn paths_conflict_under_matchit(existing: &str, incoming: &str) -> bool {
2138    let mut probe: matchit::Router<()> = matchit::Router::new();
2139    // If `existing` is itself malformed its insert fails; then it isn't in the
2140    // tree and can't be the conflict partner — return false so the caller keeps
2141    // scanning earlier templates.
2142    if probe.insert(existing, ()).is_err() {
2143        return false;
2144    }
2145    matches!(
2146        probe.insert(incoming, ()),
2147        Err(matchit::InsertError::Conflict { .. })
2148    )
2149}
2150
2151/// Refuse two routers nested at the same prefix.
2152///
2153/// `mount_raw_routers` gives every nested router a fallback before mounting it,
2154/// and axum cannot merge two method routers that both have one — so the second
2155/// nest at a prefix the first already owns panics with "Cannot merge two
2156/// `MethodRouter`s that both have a fallback" while the router is built.
2157///
2158/// No route-level check can see this: two sandboxed plugins sharing a prefix
2159/// while declaring *disjoint* routes collide nowhere in the route table. The
2160/// collision is between the mounts, so a prefix belongs to whoever nests first.
2161fn reject_duplicate_nest_prefixes(
2162    nest_routers: &[(String, axum::Router<AppState>)],
2163    declared_routes: &[crate::route_listing::RouteInfo],
2164) -> Result<(), RouterBuildError> {
2165    let mut nested_at: std::collections::HashSet<&str> = std::collections::HashSet::new();
2166    for (prefix, _) in nest_routers {
2167        if nested_at.insert(prefix.as_str()) {
2168            continue;
2169        }
2170        // Declared routes are the only attribution available — a raw nested
2171        // router is anonymous — so name whoever we can, and say so when we
2172        // cannot.
2173        let mut owners: Vec<&str> = declared_routes
2174            .iter()
2175            .filter(|declared| declared.path.starts_with(prefix.as_str()))
2176            .map(|declared| declared.handler.as_str())
2177            .collect();
2178        owners.sort_unstable();
2179        owners.dedup();
2180        let owners = if owners.is_empty() {
2181            "two undeclared nested routers".to_owned()
2182        } else {
2183            owners.join(", ")
2184        };
2185        return Err(RouterBuildError::DuplicateNestPrefix {
2186            prefix: prefix.clone(),
2187            owners,
2188        });
2189    }
2190    Ok(())
2191}
2192
2193/// Does a declared route clash with a framework mount, as axum would see it?
2194///
2195/// The two halves are not symmetric, and conflating them is what this check
2196/// kept getting wrong in both directions:
2197///
2198/// * **The same exact path** merges across methods. `GET /health` and a
2199///   declared `POST /health` land in one `MethodRouter` and coexist, so this is
2200///   a clash only when the methods are the same.
2201/// * **A different template at the same shape** — `/_stories/{slug}` against a
2202///   declared `/_stories/{id}` — is a *matchit* conflict, and matchit sits
2203///   above method routing: axum refuses the second template whatever method it
2204///   carries. Gating this on GET let a declared `POST /_stories/{id}` through
2205///   to a startup panic.
2206fn framework_route_clashes(
2207    framework_path: &str,
2208    framework_method: &str,
2209    declared_path: &str,
2210    declared_method: &str,
2211) -> bool {
2212    if framework_path == declared_path {
2213        return declared_method.eq_ignore_ascii_case(framework_method);
2214    }
2215    paths_conflict_under_matchit(framework_path, declared_path)
2216}
2217
2218/// Fail-fast preflight for issue #1012: reject two user- or plugin-registered
2219/// routes that resolve to the same `(method, path)` before
2220/// [`group_and_mount_routes`] hands overlapping method routes to
2221/// [`axum::routing::MethodRouter::merge`] (which panics inside
2222/// `Router::route` at startup).
2223///
2224/// **Coverage** — mirrors `collect_route_infos`'s scope-prefix resolution so
2225/// duplicates across the same source, across sources (top-level +
2226/// scoped/plugin, plugin + plugin), and across `.scoped(...)` groups are
2227/// caught uniformly. `#[repository]`-generated API routes land in
2228/// `route_list` like any other route macro output, so they are covered
2229/// for free.
2230///
2231/// **Not covered — opaque routers**:
2232/// * [`AppBuilder::merge`](crate::app::AppBuilder::merge) — axum does not
2233///   expose the merged router's route table.
2234/// * [`AppBuilder::nest`](crate::app::AppBuilder::nest) — same limitation.
2235///
2236/// A non-empty opaque table emits a `tracing::warn!` (same pattern as the
2237/// existing `OpenAPI` and MCP merge-router warnings) so operators know the
2238/// preflight cannot see inside — an overlap involving one of those routers
2239/// will still surface as an axum startup panic.
2240///
2241/// The first pairwise collision wins: `existing` names the handler that
2242/// registered the path first (in the iteration order used by the actual
2243/// mount step), `incoming` names the duplicate that triggered the error.
2244/// Fail when a route declares an API version that was never registered.
2245///
2246/// Extracted from `build_router_pre_state`, which used to inline this as a
2247/// closure, so the no-boot dump modes can run the SAME rule. `autumn openapi
2248/// export` otherwise emitted a document for a versioned app that cannot start
2249/// (issue #802). One definition, both callers — a second copy would drift.
2250/// Validate the configured `OpenAPI` JSON and Swagger-UI mount paths.
2251///
2252/// Shared by `build_openapi_router` and the no-boot dump modes: a malformed
2253/// path (`"openapi.json"` with no leading slash) or two endpoints on the same
2254/// path make the serving router unbuildable, and an export that ignored that
2255/// would let `--check` pass for an app that cannot start (issue #802).
2256///
2257/// Gated on `openapi` like every item it touches: `OpenApiConfig`,
2258/// `validate_route_path` and `RouterBuildError::DuplicateOpenApiPath` are all
2259/// behind that feature, and extracting this out of `build_openapi_router` (which
2260/// sits inside the gated region) moved it out from under the gate.
2261#[cfg(feature = "openapi")]
2262pub fn validate_openapi_mount_paths(
2263    config: &crate::openapi::OpenApiConfig,
2264) -> Result<(), RouterBuildError> {
2265    validate_route_path("openapi_json_path", &config.openapi_json_path)?;
2266    if let Some(path) = &config.swagger_ui_path {
2267        validate_route_path("swagger_ui_path", path)?;
2268        // Registering two GET handlers on the same path would cause an
2269        // axum `Route::route` panic, so reject collisions as a
2270        // configuration error instead.
2271        if path == &config.openapi_json_path {
2272            return Err(RouterBuildError::DuplicateOpenApiPath { path: path.clone() });
2273        }
2274    }
2275    Ok(())
2276}
2277
2278/// Validate the MCP mount path.
2279///
2280/// It must be one static endpoint: reject empty, non-absolute, doubled-slash and
2281/// dynamic (`{capture}` / `{*rest}`) paths, so MCP cannot shadow a path class and
2282/// the collision preflight reserves the exact URL it matches. Colon-prefixed
2283/// segments (`/:mcp`, axum 0.7 syntax) panic in axum 0.8's `Router::route`;
2284/// rejecting them here yields `InvalidMcpPath` instead of a crash.
2285///
2286/// Extracted from `build_router_pre_state`, which used to inline it, so the
2287/// no-boot dump modes can run the SAME rule rather than a second copy that would
2288/// drift (issue #802).
2289///
2290/// Gated on `mcp` like `RouterBuildError::InvalidMcpPath` itself.
2291#[cfg(feature = "mcp")]
2292pub fn validate_mcp_mount_path(path: &str) -> Result<(), RouterBuildError> {
2293    if path.is_empty()
2294        || !path.starts_with('/')
2295        || path.contains("//")
2296        || path.contains('{')
2297        || path.contains('*')
2298        || path.split('/').any(|segment| segment.starts_with(':'))
2299    {
2300        return Err(RouterBuildError::InvalidMcpPath {
2301            value: path.to_owned(),
2302        });
2303    }
2304    Ok(())
2305}
2306
2307pub fn reject_unregistered_api_versions(
2308    route_list: &[Route],
2309    scoped_groups: &[ScopedGroup],
2310    registered_versions: &std::collections::HashSet<&str>,
2311) -> Result<(), RouterBuildError> {
2312    let check = |route: &Route| -> Result<(), RouterBuildError> {
2313        if let Some(version) = route
2314            .api_version
2315            .filter(|ver| !registered_versions.contains(*ver))
2316        {
2317            return Err(RouterBuildError::UnregisteredApiVersion {
2318                route_name: route.name.to_string(),
2319                version: version.to_string(),
2320            });
2321        }
2322        Ok(())
2323    };
2324
2325    for route in route_list {
2326        check(route)?;
2327    }
2328    for group in scoped_groups {
2329        for route in &group.routes {
2330            check(route)?;
2331        }
2332    }
2333    Ok(())
2334}
2335
2336/// Fail when two user- or plugin-registered routes share a `(method, path)`.
2337///
2338/// `pub` so the no-boot dump modes can run the SAME check the serving path
2339/// runs. `autumn openapi export` otherwise emitted a document in which the
2340/// later of two colliding operations silently overwrote the earlier — so
2341/// `--check` could pass on a contract for an app that cannot start at all
2342/// (issue #802). One function, both callers, no second copy to drift.
2343pub fn reject_duplicate_user_routes(
2344    route_list: &[Route],
2345    scoped_groups: &[ScopedGroup],
2346    merge_routers: &[axum::Router<AppState>],
2347    nest_routers: &[(String, axum::Router<AppState>)],
2348    declared_routes: &[crate::route_listing::RouteInfo],
2349    config: &AutumnConfig,
2350) -> Result<(), RouterBuildError> {
2351    // `claimed` keys on `(effective_method, exact_path)`. The value is the
2352    // first-seen handler name, so an exact-duplicate error can name both sides
2353    // (AC #2). Iterate in mount order: top-level routes first
2354    // (`group_and_mount_routes`), then scoped groups (`mount_scoped_groups`).
2355    //
2356    // The key is the exact path string, not the normalized shape. axum merges the
2357    // same exact path across distinct methods (AC #4: `GET /admin` + `POST
2358    // /admin`), so a same-shape clash is a duplicate only when the exact path and
2359    // effective method both match. Cross-method shape conflicts are handled below.
2360    let mut claimed: std::collections::HashMap<(String, String), String> =
2361        std::collections::HashMap::new();
2362
2363    // Method-independent path-shape conflicts are delegated to matchit, the same
2364    // engine axum 0.8 routes through, rather than a hand-rolled shape normalizer.
2365    // Every distinct exact template is inserted into a throwaway
2366    // `matchit::Router`. An `InsertError::Conflict` means the two templates overlap
2367    // and axum's `Router::route` would reject them with a mount panic before any
2368    // method merging (`/users/{id}` vs `/users/{slug}`, `/u/{id}` vs `/u/{*rest}`,
2369    // `/cmd/{tool}/{sub}` vs `/cmd/{*path}`, `/file.{ext}` vs `/file.{kind}`). This
2370    // converges every capture-name, escaped-brace, and catch-all edge case on
2371    // axum's own semantics. See `matchit_agrees_with_axum_route_conflicts` for the
2372    // parity guard that fails loudly if matchit ever drifts from axum.
2373    //
2374    // Exact-duplicate templates legitimately merge across distinct methods (AC
2375    // #4), so identical strings are deduplicated before insertion — re-inserting
2376    // one would falsely self-conflict. They fall through to the method-keyed
2377    // `claimed` check, which alone tells a real duplicate from a legal
2378    // cross-method registration.
2379    let mut shape_router: matchit::Router<String> = matchit::Router::new();
2380    // DISTINCT exact templates inserted into `shape_router`, in insertion order,
2381    // paired with their handler name. matchit's `InsertError::Conflict { with }`
2382    // reports the conflicting route as an unescaped/merged node string that need
2383    // not equal any template we registered, so we recover the conflict partner
2384    // ourselves by re-probing this list (first prior template that conflicts
2385    // under matchit wins, matching the "first-seen is `existing`" convention).
2386    let mut inserted_shapes: Vec<(String, String)> = Vec::new();
2387
2388    let mut record =
2389        |method: &http::Method, path: String, name: &str| -> Result<(), RouterBuildError> {
2390            let effective_method = effective_mount_method(method).to_string();
2391
2392            // Shape conflict (method-independent) via the matchit oracle. Skip
2393            // templates whose EXACT string was already inserted: an identical
2394            // string is a legal cross-method merge, not a shape conflict, and
2395            // re-inserting it would self-conflict.
2396            let already_inserted = inserted_shapes.iter().any(|(p, _)| p == &path);
2397            if !already_inserted {
2398                match shape_router.insert(&path, name.to_owned()) {
2399                    Ok(()) => inserted_shapes.push((path.clone(), name.to_owned())),
2400                    Err(matchit::InsertError::Conflict { .. }) => {
2401                        // Name the specific prior template this one collides with.
2402                        let (existing_path, existing_name) = inserted_shapes
2403                            .iter()
2404                            .find(|(prior, _)| paths_conflict_under_matchit(prior, &path))
2405                            .cloned()
2406                            // Defensive fallback (a full-tree conflict with no
2407                            // single pairwise partner is not expected for real
2408                            // route templates): attribute to the first insert.
2409                            .unwrap_or_else(|| inserted_shapes[0].clone());
2410                        return Err(RouterBuildError::ConflictingRouteShape {
2411                            existing: existing_name,
2412                            existing_path,
2413                            incoming: name.to_owned(),
2414                            incoming_path: path,
2415                        });
2416                    }
2417                    // Any other `InsertError` (malformed param/catch-all syntax)
2418                    // is a single-template validity problem, not a cross-route
2419                    // conflict; leave it to the existing path-validation seams
2420                    // and axum itself rather than mislabeling it a shape clash.
2421                    Err(_) => {}
2422                }
2423            }
2424
2425            // Exact-duplicate check: same effective method AND same exact path
2426            // → axum's `MethodRouter::merge` would panic. Distinct methods on
2427            // the same exact path are legal (axum merges them) and fall through.
2428            let key = (effective_method.clone(), path.clone());
2429            if let Some(existing) = claimed.get(&key) {
2430                return Err(RouterBuildError::DuplicateUserRoute {
2431                    method: effective_method,
2432                    path,
2433                    existing: existing.clone(),
2434                    incoming: name.to_owned(),
2435                });
2436            }
2437            claimed.insert(key, name.to_owned());
2438            Ok(())
2439        };
2440
2441    for route in route_list {
2442        record(&route.method, route.path.to_owned(), route.name)?;
2443    }
2444    for group in scoped_groups {
2445        for route in &group.routes {
2446            record(
2447                &route.method,
2448                join_nested_path(&group.prefix, route.path),
2449                route.name,
2450            )?;
2451        }
2452    }
2453
2454    // A `nest` mount is opaque to axum's API, but a plugin that declared its
2455    // routes handed us the table anyway — and for a sandboxed plugin the manifest
2456    // is the mount, so that table is what `Router::nest` registers. Run those
2457    // declarations through the same oracle as the app's own routes. An artifact
2458    // declaring `GET /hello/greet` that the application already serves would
2459    // otherwise panic inside `Router::nest` ("Overlapping method route") and take
2460    // the process down at boot — containment failing open for the one input class
2461    // this lane exists to distrust. Shape clashes panic identically (`/hello/{id}`
2462    // against a nest declaring `/{slug}`, and `/hello/{*rest}` likewise), which is
2463    // why these go through `record` rather than an exact-path compare. Disjoint
2464    // paths under one prefix do not conflict, nor does a route at the prefix, so
2465    // this rejects only what axum would refuse.
2466    //
2467    // Declared routes are recorded last, so an application route always wins the
2468    // "first-seen is `existing`" convention and the error names the plugin as the
2469    // incoming side.
2470    for declared in declared_routes {
2471        // A method string that is not a valid HTTP token could not have been
2472        // mounted by axum either; leave it to the declaring seam rather than
2473        // inventing a collision for it here.
2474        let Ok(method) = http::Method::from_bytes(declared.method.as_bytes()) else {
2475            continue;
2476        };
2477        record(&method, declared.path.clone(), &declared.handler)?;
2478    }
2479
2480    reject_declared_framework_collisions(declared_routes, config)?;
2481
2482    // Raw merged / nested routers are opaque — axum does not expose their
2483    // route tables. Warn so operators know the check does not cover those
2484    // code paths (mirrors the OpenAPI and MCP merge-router warnings).
2485    if !merge_routers.is_empty() {
2486        tracing::warn!(
2487            merged_routers = merge_routers.len(),
2488            "duplicate-route preflight (#1012) skipped for AppBuilder::merge routers: \
2489             axum does not expose their route table, so an overlapping handler on a \
2490             method+path Autumn already owns will still panic at startup. Keep merged \
2491             routers on disjoint paths from your `.routes()`/`.scoped()` registrations."
2492        );
2493    }
2494    reject_duplicate_nest_prefixes(nest_routers, declared_routes)?;
2495
2496    if !nest_routers.is_empty() {
2497        tracing::warn!(
2498            nested_routers = nest_routers.len(),
2499            declared_routes = declared_routes.len(),
2500            "duplicate-route preflight (#1012) covers AppBuilder::nest routers only \
2501             through declare_plugin_routes: axum does not expose a nested router's \
2502             route table, so any handler it serves that was NOT declared can still \
2503             panic at startup if it overlaps a method+path Autumn already owns. \
2504             Declared routes — a sandboxed plugin declares its whole manifest — ARE \
2505             checked; for anything else keep nested routers on disjoint prefixes \
2506             from your `.routes()`/`.scoped()` registrations."
2507        );
2508    }
2509
2510    Ok(())
2511}
2512
2513/// Refuse a declared plugin route on a path the context mounts: the `OpenAPI`
2514/// JSON, Swagger UI and its assets (all GET), and the MCP endpoint (GET, POST
2515/// and OPTIONS). Same refusal as [`reject_declared_framework_collisions`].
2516///
2517/// Takes the mounts as inputs, so the router build and the no-boot export
2518/// preflight apply one rule.
2519#[cfg(feature = "openapi")]
2520pub fn reject_declared_routes_on_context_mounts(
2521    declared_routes: &[crate::route_listing::RouteInfo],
2522    openapi: Option<&crate::openapi::OpenApiConfig>,
2523    mcp_mount_path: Option<&str>,
2524) -> Result<(), RouterBuildError> {
2525    if declared_routes.is_empty() {
2526        return Ok(());
2527    }
2528    // `(method, path)`: the verbs each mount really owns.
2529    let mut mounts: Vec<(&'static str, String)> = Vec::new();
2530    if let Some(openapi) = openapi {
2531        mounts.push(("GET", openapi.openapi_json_path.clone()));
2532        if let Some(ui_path) = &openapi.swagger_ui_path {
2533            mounts.push(("GET", ui_path.clone()));
2534            mounts.extend(
2535                crate::openapi::swagger_ui_asset_paths(ui_path)
2536                    .into_iter()
2537                    .map(|path| ("GET", path)),
2538            );
2539        }
2540    }
2541    if let Some(path) = mcp_mount_path {
2542        for method in ["GET", "POST", "OPTIONS"] {
2543            mounts.push((method, path.to_owned()));
2544        }
2545    }
2546    for declared in declared_routes {
2547        // A `WS` upgrade is a GET as far as axum's method router is concerned.
2548        let method = if declared.method.eq_ignore_ascii_case("WS") {
2549            "GET"
2550        } else {
2551            declared.method.as_str()
2552        };
2553        if let Some((_, path)) = mounts
2554            .iter()
2555            .find(|(owned, path)| framework_route_clashes(path, owned, &declared.path, method))
2556        {
2557            return Err(RouterBuildError::DuplicateUserRoute {
2558                method: declared.method.clone(),
2559                path: declared.path.clone(),
2560                existing: format!("autumn framework route {path}"),
2561                incoming: declared.handler.clone(),
2562            });
2563        }
2564    }
2565    Ok(())
2566}
2567
2568/// Refuse a declared plugin route that lands on something the framework
2569/// mounts.
2570///
2571/// Split out of [`reject_duplicate_user_routes`] because the framework's own
2572/// mounts are not in `route_list` — they are installed separately — so the
2573/// `record` oracle there cannot see them at all.
2574fn reject_declared_framework_collisions(
2575    declared_routes: &[crate::route_listing::RouteInfo],
2576    config: &AutumnConfig,
2577) -> Result<(), RouterBuildError> {
2578    // The framework's own GETs (probes, actuator, htmx assets, mail previews, …)
2579    // are mounted separately and are not in `route_list`, so the `record` oracle
2580    // in `reject_duplicate_user_routes` cannot see them: a manifest declaring
2581    // `GET /health` sailed past that check and still panicked at `Router::nest`.
2582    // Verified against axum 0.8.9 that only GET actually clashes there. A declared
2583    // HEAD or POST at a framework GET path merges cleanly into the same
2584    // `MethodRouter`, so refusing those would reject mounts axum accepts.
2585    //
2586    // Refuse rather than let the framework yield. A user route at a probe path
2587    // legitimately takes it over (#1971) — the developer owns their app. An
2588    // artifact the operator was told is sandboxed is a different principal:
2589    // silently handing it `/health`, which orchestrators read to decide whether
2590    // the process is alive, is worse than a loud refusal. Any path a user route
2591    // already owns is caught by the caller's `record` pass, so this fires only
2592    // where the framework really mounts.
2593    let framework_get_paths = collect_framework_get_paths(config);
2594    // The framework's mutating mounts, carrying their real methods. The GET
2595    // claim set deliberately excludes these paths, so without this pass a
2596    // declared PUT or POST at one of them is compared against nothing.
2597    let actuator_mutating_routes = crate::actuator::actuator_mutating_routes(
2598        &config.actuator.prefix,
2599        config.actuator.sensitive,
2600    );
2601    for declared in declared_routes {
2602        // Namespaces first, and for EVERY method: `/static` and `/_autumn` are
2603        // owned wholesale, so a declared route anywhere beneath them is refused
2604        // whether it would panic (at the prefix, or a catch-all) or mount
2605        // quietly and shadow what the framework serves there.
2606        //
2607        // The one exception is a `PluginAssets` file, which the framework
2608        // itself mounts under `/static/_plugins/<namespace>/`: a `GET` there
2609        // carrying the asset marker. Only `AppBuilder::plugin_assets` attaches
2610        // that marker (`declare_plugin_routes`, and so every sandbox
2611        // manifest, strips it), so any other declared route under `/static`
2612        // is still refused. The exempt entries still reach the duplicate-route
2613        // pass, so an app route at a bundle URL is a typed collision too.
2614        if let Some(namespace) = framework_namespaces()
2615            .iter()
2616            .find(|namespace| path_is_under_namespace(&declared.path, namespace))
2617            && !crate::assets::plugin::is_framework_asset_route(declared)
2618        {
2619            return Err(RouterBuildError::DuplicateUserRoute {
2620                method: declared.method.clone(),
2621                path: declared.path.clone(),
2622                existing: format!("autumn framework namespace {namespace}"),
2623                incoming: declared.handler.clone(),
2624            });
2625        }
2626        // A `WS` upgrade is a GET as far as axum's method router is concerned,
2627        // so it clashes wherever a declared GET would.
2628        let effective_method = if declared.method.eq_ignore_ascii_case("GET")
2629            || declared.method.eq_ignore_ascii_case("WS")
2630        {
2631            "GET"
2632        } else {
2633            declared.method.as_str()
2634        };
2635        // The framework mounts non-GET routes too, and only GET was ever
2636        // compared — so a manifest declaring `PUT {prefix}/loggers/{name}`
2637        // passed this check and panicked at the nest, because the actuator
2638        // mounts exactly that. These carry their real method, so the
2639        // comparison is method-aware rather than GET-shaped.
2640        for (framework_method, framework_path) in &actuator_mutating_routes {
2641            if framework_route_clashes(
2642                framework_path,
2643                framework_method,
2644                &declared.path,
2645                effective_method,
2646            ) {
2647                return Err(RouterBuildError::DuplicateUserRoute {
2648                    method: declared.method.clone(),
2649                    path: declared.path.clone(),
2650                    existing: format!("autumn framework route {framework_path}"),
2651                    incoming: declared.handler.clone(),
2652                });
2653            }
2654        }
2655        // Compare through matchit, not by string equality: a framework path
2656        // that carries a capture conflicts with a DIFFERENTLY-NAMED capture at
2657        // the same position, and axum refuses that shape regardless of method.
2658        // `/_stories/{slug}` is the live example — a manifest declaring
2659        // `/_stories/{id}` is an exact-string miss and a startup panic. The
2660        // configurable paths (probes, actuator prefix, dev inspector, job
2661        // status) can carry captures too, since an operator sets them.
2662        let collision = framework_get_paths.iter().find(|framework_path| {
2663            framework_route_clashes(framework_path, "GET", &declared.path, effective_method)
2664        });
2665        if let Some(framework_path) = collision {
2666            // `FrameworkRouteOverlap` would read better, but its `existing` and
2667            // `incoming` are `&'static str` and a plugin's handler name is
2668            // built at runtime; widening them is a breaking change to a public
2669            // enum. `DuplicateUserRoute` carries `String`s and still names the
2670            // method, the path and both sides, which is what an operator needs
2671            // to act.
2672            return Err(RouterBuildError::DuplicateUserRoute {
2673                method: declared.method.clone(),
2674                path: declared.path.clone(),
2675                // Name the framework template, not just the label: when the
2676                // clash is a shape conflict the two paths are not the same
2677                // string, and the operator needs to see what it collided with.
2678                existing: format!("autumn framework route {framework_path}"),
2679                incoming: declared.handler.clone(),
2680            });
2681        }
2682    }
2683    Ok(())
2684}
2685
2686/// Attach a route's declared SEO defaults (#1182) as a request extension so the
2687/// [`SeoMeta`](crate::seo::SeoMeta) extractor can hand the handler a
2688/// pre-populated builder.
2689///
2690/// Attaching them here — rather than inside the route macro — keeps the layer
2691/// clear of the macro-ordering dance with the signature-rewriting guards
2692/// (`#[secured]`, `#[throttle]`, `#[step_up]`, `#[authorize]`), and means
2693/// static pre-rendering picks them up for free, since it drives this same
2694/// router.
2695///
2696/// Called from **both** mounting paths — [`group_and_mount_routes`] and
2697/// [`mount_scoped_groups`] — because a scoped route is just as entitled to its
2698/// declared defaults, and the extractor's infallibility means a miss would show
2699/// up as silently absent meta tags rather than an error.
2700///
2701/// Routes that never declared `seo(...)` skip the layer entirely and pay
2702/// nothing per request. The layer is applied to the route's own
2703/// `MethodRouter`, so sibling verbs mounted on the same path do not inherit it.
2704fn attach_seo_defaults(
2705    handler: axum::routing::MethodRouter<AppState>,
2706    seo: crate::seo::SeoRouteDefaults,
2707) -> axum::routing::MethodRouter<AppState> {
2708    if seo.is_empty() {
2709        return handler;
2710    }
2711    handler.layer(axum::Extension(seo))
2712}
2713
2714fn group_and_mount_routes(
2715    route_list: Vec<Route>,
2716    idempotency_layers: Option<&BuiltIdempotencyLayers>,
2717    opaque_app_layers_present: bool,
2718    state: &AppState,
2719) -> axum::Router<AppState> {
2720    // Group routes by path so multiple methods on the same path
2721    // (e.g. GET /admin + POST /admin) are merged into a single
2722    // MethodRouter. Axum 0.7+ panics if .route() is called twice
2723    // with the same path — merging avoids this.
2724    let mut grouped: indexmap::IndexMap<&str, axum::routing::MethodRouter<AppState>> =
2725        indexmap::IndexMap::new();
2726    for route in &route_list {
2727        tracing::debug!(
2728            method = %route.method,
2729            path = route.path,
2730            name = route.name,
2731            "Mounted route"
2732        );
2733    }
2734    for route in route_list {
2735        let selected_layer = idempotency_layers.and_then(|layers| {
2736            idempotency_layer_for_route(&route, layers, opaque_app_layers_present)
2737        });
2738        let mut handler = route.handler;
2739        if let Some(layer) = selected_layer {
2740            handler = handler.layer(layer.clone());
2741        }
2742        handler = attach_seo_defaults(handler, route.seo);
2743        if let Some(version) = route.api_version {
2744            handler = handler.layer(axum::middleware::from_fn_with_state(
2745                state.clone(),
2746                api_versioning_middleware,
2747            ));
2748            handler = handler.layer(axum::Extension(RouteVersionMetadata {
2749                version: version.to_string(),
2750                sunset_opt_out: route.sunset_opt_out,
2751                secured: route.api_doc.secured,
2752                required_roles: route.api_doc.required_roles,
2753                has_policy: route.api_doc.has_policy,
2754            }));
2755        }
2756        grouped
2757            .entry(route.path)
2758            .and_modify(|existing| {
2759                *existing = std::mem::take(existing).merge(handler.clone());
2760            })
2761            .or_insert(handler);
2762    }
2763
2764    let mut router = axum::Router::new();
2765    for (path, method_router) in grouped {
2766        router = router.route(path, method_router);
2767    }
2768    router
2769}
2770
2771/// Mount the user route table, applying locale-prefixed routing (issue
2772/// #1251) when [`I18nConfig::locale_prefix_enabled`](crate::i18n::I18nConfig::locale_prefix_enabled)
2773/// is enabled. Delegates straight to [`group_and_mount_routes`] otherwise, so
2774/// apps that don't opt in see no behavior change.
2775#[cfg(feature = "i18n")]
2776fn mount_user_routes(
2777    route_list: Vec<Route>,
2778    scoped_groups: &[ScopedGroup],
2779    declared_routes: &[crate::route_listing::RouteInfo],
2780    idempotency_layers: Option<&BuiltIdempotencyLayers>,
2781    opaque_app_layers_present: bool,
2782    state: &AppState,
2783    config: &AutumnConfig,
2784) -> Result<axum::Router<AppState>, RouterBuildError> {
2785    if !config.i18n.locale_prefix_enabled {
2786        return Ok(group_and_mount_routes(
2787            route_list,
2788            idempotency_layers,
2789            opaque_app_layers_present,
2790            state,
2791        ));
2792    }
2793
2794    let (included, excluded) = partition_routes_for_locale_prefix(
2795        route_list,
2796        &config.i18n.locale_prefix_exclude,
2797        &config.i18n.locale_prefix_exclude_exact,
2798    );
2799
2800    let included_path_methods = route_list_path_methods(&included);
2801    let excluded_path_methods = route_list_path_methods(&excluded);
2802    let scoped_path_methods = scoped_group_path_methods(scoped_groups);
2803    let framework_path_methods = framework_probe_path_methods(config);
2804    let declared_path_methods = declared_path_methods(declared_routes);
2805
2806    let valid_locales = validated_locale_prefix_locales(&config.i18n);
2807
2808    if let Some(err) = detect_locale_prefix_path_collision(
2809        &included_path_methods,
2810        &excluded_path_methods,
2811        &scoped_path_methods,
2812        &framework_path_methods,
2813        &declared_path_methods,
2814        &valid_locales,
2815    ) {
2816        return Err(err);
2817    }
2818
2819    let path_method_filters = path_method_filters(&included_path_methods);
2820
2821    let content_router = group_and_mount_routes(
2822        included,
2823        idempotency_layers,
2824        opaque_app_layers_present,
2825        state,
2826    );
2827    let excluded_router = group_and_mount_routes(
2828        excluded,
2829        idempotency_layers,
2830        opaque_app_layers_present,
2831        state,
2832    );
2833    Ok(apply_locale_prefix_routing(
2834        excluded_router,
2835        &content_router,
2836        &path_method_filters,
2837        &config.i18n,
2838        &valid_locales,
2839    ))
2840}
2841
2842// This variant never actually returns `Err` (only the `i18n`-enabled sibling
2843// above can, on a locale-prefix path collision) — but it must keep the same
2844// `Result` signature so the single call site doesn't need its own
2845// `#[cfg(feature = "i18n")]` branch.
2846#[cfg(not(feature = "i18n"))]
2847#[allow(clippy::unnecessary_wraps)]
2848fn mount_user_routes(
2849    route_list: Vec<Route>,
2850    _scoped_groups: &[ScopedGroup],
2851    _declared_routes: &[crate::route_listing::RouteInfo],
2852    idempotency_layers: Option<&BuiltIdempotencyLayers>,
2853    opaque_app_layers_present: bool,
2854    state: &AppState,
2855    _config: &AutumnConfig,
2856) -> Result<axum::Router<AppState>, RouterBuildError> {
2857    Ok(group_and_mount_routes(
2858        route_list,
2859        idempotency_layers,
2860        opaque_app_layers_present,
2861        state,
2862    ))
2863}
2864
2865/// Splits `route_list` into `(included, excluded)` for locale-prefix
2866/// mounting: `excluded` routes either match a configured
2867/// [`I18nConfig::locale_prefix_exclude`](crate::i18n::I18nConfig::locale_prefix_exclude)
2868/// prefix (e.g. hand-written `/api/*` routes) or a literal
2869/// [`I18nConfig::locale_prefix_exclude_exact`](crate::i18n::I18nConfig::locale_prefix_exclude_exact)
2870/// path (auto-populated from `#[static_get]` routes), and mount unprefixed,
2871/// exactly as they would with locale-prefix routing off. `included` routes
2872/// get nested under every supported locale plus a bare-path redirect.
2873///
2874/// The two lists are matched differently on purpose: a prefix exclusion like
2875/// `/api` also swallows `/api/users`, which is the point for a hand-excluded
2876/// namespace, but the exact list must NOT do that — excluding a static route
2877/// like `/posts` must not also swallow an unrelated dynamic sibling route
2878/// like `/posts/{slug}` (Codex review).
2879#[cfg(feature = "i18n")]
2880fn partition_routes_for_locale_prefix(
2881    route_list: Vec<Route>,
2882    exclude_prefixes: &[String],
2883    exclude_exact: &[String],
2884) -> (Vec<Route>, Vec<Route>) {
2885    let mut included = Vec::new();
2886    let mut excluded = Vec::new();
2887    for route in route_list {
2888        if exclude_exact.iter().any(|p| p == route.path)
2889            || matches_locale_exclude_prefix(route.path, exclude_prefixes)
2890        {
2891            excluded.push(route);
2892        } else {
2893            included.push(route);
2894        }
2895    }
2896    (included, excluded)
2897}
2898
2899/// `true` when `path` equals one of `prefixes` or starts with `{prefix}/`.
2900/// A trailing `/*` (or `/`) on a configured prefix is stripped before
2901/// comparing, so `"/api"` and `"/api/*"` are equivalent — except a bare `"/"`
2902/// (e.g. a `#[static_get("/")]` route added via
2903/// `exclude_static_routes_from_locale_prefix`), which is kept as-is and
2904/// matched exactly: stripping its trailing slash would normalize it to an
2905/// empty prefix, which the empty-prefix guard below then silently rejects,
2906/// so `"/"` would never actually get excluded (Codex review).
2907///
2908/// `pub` (the `router` module itself is `pub(crate)`, so this is already
2909/// crate-private) so `tenancy::strip_locale_prefix_for_tenancy` can reuse the
2910/// same exclusion semantics rather than a third divergent copy (`seo.rs`
2911/// keeps its own, feature-independent copy to avoid a hard dependency on
2912/// `i18n`-gated router internals; `tenancy.rs`'s caller is already
2913/// `i18n`-gated, so reuse here doesn't add one).
2914#[cfg(feature = "i18n")]
2915pub fn matches_locale_exclude_prefix(path: &str, prefixes: &[String]) -> bool {
2916    prefixes.iter().any(|raw| {
2917        let prefix = raw.strip_suffix("/*").unwrap_or(raw.as_str());
2918        let prefix = if prefix == "/" {
2919            prefix
2920        } else {
2921            prefix.strip_suffix('/').unwrap_or(prefix)
2922        };
2923        !prefix.is_empty() && (path == prefix || path.starts_with(&format!("{prefix}/")))
2924    })
2925}
2926
2927/// `true` when `locale` is safe to use as a literal [`Router::nest`](axum::Router::nest)
2928/// segment AND as a literal path segment in the generated redirect target
2929/// (`/{locale}{path}`): non-empty (an empty string would nest at `"/"`,
2930/// which axum panics on — nesting at the root isn't supported) and free of
2931/// characters axum's route syntax interprets specially — `/` (would
2932/// silently nest an extra sub-path instead of one opaque segment), `{`/`}`
2933/// (path-parameter capture syntax), `*` (wildcard capture), and a leading
2934/// `:` (axum 0.7 capture syntax — axum 0.8's `Router::route` panics on it
2935/// during assembly via `validate_v07_paths`, the same restriction
2936/// `InvalidMcpPath` above already guards against) — plus `?`/`#` and
2937/// whitespace, which axum accepts as a literal nest string but which a
2938/// client parses as a query/fragment delimiter, silently truncating the
2939/// redirect target (`/en?x/foo` parses as path `/en` + query `x/foo`)
2940/// (Codex review).
2941#[cfg(feature = "i18n")]
2942fn is_valid_locale_segment(locale: &str) -> bool {
2943    !locale.is_empty()
2944        && !locale.starts_with(':')
2945        && !locale.contains(['/', '{', '}', '*', '?', '#'])
2946        && !locale.chars().any(char::is_whitespace)
2947}
2948
2949/// The subset of `i18n.supported_locales` that's actually valid and unique —
2950/// i.e. exactly the locales [`apply_locale_prefix_routing`] nests (invalid
2951/// entries are dropped by [`is_valid_locale_segment`], duplicates by
2952/// order-preserving dedup). Negotiation data (`LocaleRoutingConfig`, the
2953/// default-locale fallback) must be built from this validated list rather
2954/// than the raw config — otherwise a request could negotiate to a locale
2955/// that was silently skipped and has no nest, trading a config typo's
2956/// build-time no-op for a runtime 404 (Codex review).
2957#[cfg(feature = "i18n")]
2958pub fn validated_locale_prefix_locales(i18n: &crate::i18n::I18nConfig) -> Vec<String> {
2959    let mut seen = std::collections::HashSet::new();
2960    i18n.supported_locales
2961        .iter()
2962        .filter(|locale| is_valid_locale_segment(locale) && seen.insert(locale.as_str()))
2963        .cloned()
2964        .collect()
2965}
2966
2967/// Maps an HTTP method to the [`axum::routing::MethodFilter`] bit it
2968/// corresponds to. Falls back to `GET` for anything unrecognized — in
2969/// practice only the synthetic `WS` method, which callers must run through
2970/// [`effective_mount_method`] first (converting it to `GET`) anyway; this
2971/// catch-all just avoids ever silently dropping a method were that
2972/// invariant somehow violated.
2973#[cfg(feature = "i18n")]
2974fn method_filter_for(method: &http::Method) -> axum::routing::MethodFilter {
2975    use axum::routing::MethodFilter;
2976    match method.as_str() {
2977        "POST" => MethodFilter::POST,
2978        "PUT" => MethodFilter::PUT,
2979        "DELETE" => MethodFilter::DELETE,
2980        "PATCH" => MethodFilter::PATCH,
2981        "HEAD" => MethodFilter::HEAD,
2982        "OPTIONS" => MethodFilter::OPTIONS,
2983        "TRACE" => MethodFilter::TRACE,
2984        "CONNECT" => MethodFilter::CONNECT,
2985        _ => MethodFilter::GET,
2986    }
2987}
2988
2989/// The same map as [`route_list_path_methods`], for routes a plugin *declared*
2990/// rather than routes the application built.
2991///
2992/// The locale-prefix check compares generated paths against everything already
2993/// mounted, and a sandboxed plugin's manifest is a mount like any other — it
2994/// was simply not among the things being compared.
2995#[cfg(feature = "i18n")]
2996fn declared_path_methods(
2997    declared: &[crate::route_listing::RouteInfo],
2998) -> std::collections::HashMap<String, Vec<http::Method>> {
2999    let mut map: std::collections::HashMap<String, Vec<http::Method>> =
3000        std::collections::HashMap::new();
3001    for route in declared {
3002        let Ok(parsed) = http::Method::from_bytes(route.method.as_bytes()) else {
3003            // `WS` is not an HTTP method; it mounts as a GET.
3004            if route.method.eq_ignore_ascii_case("WS") {
3005                let methods = map.entry(route.path.clone()).or_default();
3006                if !methods.contains(&http::Method::GET) {
3007                    methods.push(http::Method::GET);
3008                }
3009                if !methods.contains(&http::Method::HEAD) {
3010                    methods.push(http::Method::HEAD);
3011                }
3012            }
3013            continue;
3014        };
3015        let effective = effective_mount_method(&parsed);
3016        let methods = map.entry(route.path.clone()).or_default();
3017        if !methods.contains(&effective) {
3018            methods.push(effective.clone());
3019        }
3020        if effective == http::Method::GET && !methods.contains(&http::Method::HEAD) {
3021            methods.push(http::Method::HEAD);
3022        }
3023    }
3024    map
3025}
3026
3027/// For each DISTINCT path in `routes`, the effective HTTP methods actually
3028/// registered there — `WS`→`GET` via [`effective_mount_method`], and
3029/// `GET`→`+HEAD` (axum also serves `HEAD` through a `#[get]` handler),
3030/// mirroring exactly what [`build_route_timeout_table`] already does for
3031/// the same reason.
3032#[cfg(feature = "i18n")]
3033fn route_list_path_methods(
3034    routes: &[Route],
3035) -> std::collections::HashMap<String, Vec<http::Method>> {
3036    let mut map: std::collections::HashMap<String, Vec<http::Method>> =
3037        std::collections::HashMap::new();
3038    for route in routes {
3039        let effective = effective_mount_method(&route.method);
3040        let methods = map.entry(route.path.to_owned()).or_default();
3041        if !methods.contains(&effective) {
3042            methods.push(effective.clone());
3043        }
3044        if effective == http::Method::GET && !methods.contains(&http::Method::HEAD) {
3045            methods.push(http::Method::HEAD);
3046        }
3047        if route.method.as_str() == "WS" && !methods.contains(&http::Method::CONNECT) {
3048            methods.push(http::Method::CONNECT);
3049        }
3050    }
3051    map
3052}
3053
3054/// The same per-path method collection as [`route_list_path_methods`], but
3055/// for routes mounted via `AppBuilder::scoped()` — resolved to their final
3056/// `{prefix}{path}` mount point via [`join_nested_path`] (the same helper
3057/// [`build_route_timeout_table`] uses), since a scoped group's routes are
3058/// never part of `route_list` and so are otherwise invisible to locale-prefix
3059/// collision detection even though they mount onto the SAME router
3060/// (`mount_scoped_groups`, after this module returns) and can collide with a
3061/// generated locale path just as easily as a top-level route (Codex review).
3062#[cfg(feature = "i18n")]
3063fn scoped_group_path_methods(
3064    scoped_groups: &[ScopedGroup],
3065) -> std::collections::HashMap<String, Vec<http::Method>> {
3066    let mut map: std::collections::HashMap<String, Vec<http::Method>> =
3067        std::collections::HashMap::new();
3068    for group in scoped_groups {
3069        for route in &group.routes {
3070            let resolved = join_nested_path(&group.prefix, route.path);
3071            let effective = effective_mount_method(&route.method);
3072            let methods = map.entry(resolved).or_default();
3073            if !methods.contains(&effective) {
3074                methods.push(effective.clone());
3075            }
3076            if effective == http::Method::GET && !methods.contains(&http::Method::HEAD) {
3077                methods.push(http::Method::HEAD);
3078            }
3079            if route.method.as_str() == "WS" && !methods.contains(&http::Method::CONNECT) {
3080                methods.push(http::Method::CONNECT);
3081            }
3082        }
3083    }
3084    map
3085}
3086
3087/// Health/liveness/readiness/startup probe paths reserved by the framework
3088/// (`mount_probe_endpoints`, mounted well after this module returns) — all
3089/// `GET` (+`HEAD`), and only when `config.health.enabled` (matching
3090/// `mount_probe_endpoints`'s own off-switch: when probes are disabled, none
3091/// of these paths are reserved). Included in locale-prefix collision
3092/// detection so a probe path that happens to equal a generated locale path
3093/// is caught before axum panics on the later overlapping mount (Codex
3094/// review).
3095///
3096/// Actuator/OpenAPI/MCP mount paths are a similar, currently-undetected risk
3097/// (see the reply on this finding), but aren't included here: unlike the
3098/// probe paths, threading `OpenApiConfig`/the MCP mount path into this
3099/// function requires plumbing well beyond `AutumnConfig` alone.
3100#[cfg(feature = "i18n")]
3101fn framework_probe_path_methods(
3102    config: &AutumnConfig,
3103) -> std::collections::HashMap<String, Vec<http::Method>> {
3104    let mut map = std::collections::HashMap::new();
3105    if !config.health.enabled {
3106        return map;
3107    }
3108    for path in [
3109        &config.health.path,
3110        &config.health.live_path,
3111        &config.health.ready_path,
3112        &config.health.startup_path,
3113    ] {
3114        if path.is_empty() {
3115            continue;
3116        }
3117        map.entry(path.clone())
3118            .or_insert_with(|| vec![http::Method::GET, http::Method::HEAD]);
3119    }
3120    map
3121}
3122
3123/// Combines a path's registered methods into the single [`axum::routing::MethodFilter`]
3124/// the bare-path redirect must claim there.
3125#[cfg(feature = "i18n")]
3126fn path_method_filter(methods: &[http::Method]) -> axum::routing::MethodFilter {
3127    methods
3128        .iter()
3129        .map(method_filter_for)
3130        .reduce(axum::routing::MethodFilter::or)
3131        .unwrap_or(axum::routing::MethodFilter::GET)
3132}
3133
3134/// For each locale-prefix-included path, the [`axum::routing::MethodFilter`]
3135/// the bare-path redirect must claim there — so it claims precisely what the
3136/// nested content will serve.
3137///
3138/// Claiming every method via `axum::routing::any` (the prior behavior)
3139/// would otherwise reserve, say, GET at `/health` for the redirect even
3140/// when only `POST /health` is a real included route — colliding with the
3141/// framework's own auto-mounted `GET /health` probe, which mounts later
3142/// (`mount_probe_endpoints`, well after this function returns) and has no
3143/// visibility into methods the redirect already claimed (Codex review).
3144#[cfg(feature = "i18n")]
3145fn path_method_filters(
3146    included_path_methods: &std::collections::HashMap<String, Vec<http::Method>>,
3147) -> Vec<(String, axum::routing::MethodFilter)> {
3148    let mut result: Vec<_> = included_path_methods
3149        .iter()
3150        .map(|(path, methods)| (path.clone(), path_method_filter(methods)))
3151        .collect();
3152    result.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
3153    result
3154}
3155
3156/// Detects a route path that collides with a path locale-prefix nesting will
3157/// itself generate — e.g. an app defines both `/foo` and `/en/foo` while
3158/// `en` is a supported locale: the bare-path redirect (mounted at every
3159/// locale-prefix-eligible path, claiming only the methods actually
3160/// registered there — see [`path_method_filters`]) already owns some methods
3161/// at `/en/foo`, so nesting `/foo`'s content under `/en` collides if those
3162/// methods overlap, and axum panics on the overlapping route at
3163/// router-construction time. Checked against `included`, `excluded`
3164/// (mounted verbatim at the top level), `scoped` (mounted later via
3165/// `mount_scoped_groups`, but onto the same router), and `framework` (the
3166/// health/live/ready/startup probes, mounted later still via
3167/// `mount_probe_endpoints`) so any pre-existing route is caught regardless
3168/// of how it was registered (Codex review).
3169///
3170/// Two DIFFERENT kinds of collision are distinguished, matching
3171/// `reject_duplicate_user_routes`'s established model:
3172///   - An EXACT path match is only a real conflict if the methods overlap —
3173///     axum legally merges the SAME path template across DIFFERENT methods
3174///     (`GET /foo` generating `GET /en/foo` must coexist with an existing
3175///     `POST /en/foo`).
3176///   - A DIFFERENT template that [`paths_conflict_under_matchit`] flags is a
3177///     conflict regardless of method: two templates with different capture
3178///     *names* at the same position (e.g. `/en/users/{id}` generated from
3179///     `/users/{id}`, vs. an existing `/en/users/{slug}`) can never coexist,
3180///     because matchit's tree rejects the shape clash before axum even gets
3181///     to method-router merging.
3182#[cfg(feature = "i18n")]
3183fn detect_locale_prefix_path_collision(
3184    included: &std::collections::HashMap<String, Vec<http::Method>>,
3185    excluded: &std::collections::HashMap<String, Vec<http::Method>>,
3186    scoped: &std::collections::HashMap<String, Vec<http::Method>>,
3187    framework: &std::collections::HashMap<String, Vec<http::Method>>,
3188    declared: &std::collections::HashMap<String, Vec<http::Method>>,
3189    valid_locales: &[String],
3190) -> Option<RouterBuildError> {
3191    let all_other_paths: Vec<&String> = excluded
3192        .keys()
3193        .chain(scoped.keys())
3194        .chain(framework.keys())
3195        .chain(declared.keys())
3196        .chain(included.keys())
3197        .collect();
3198
3199    for locale in valid_locales {
3200        for (path, methods) in included {
3201            let generated = if path == "/" {
3202                format!("/{locale}")
3203            } else {
3204                format!("/{locale}{path}")
3205            };
3206
3207            let exact_conflict = excluded
3208                .get(&generated)
3209                .into_iter()
3210                .chain(scoped.get(&generated))
3211                .chain(framework.get(&generated))
3212                // A sandboxed plugin's declared mount is a mount: without it
3213                // here, a manifest claiming `/en/foo` sailed past this check and
3214                // axum panicked at boot on the locale clone of `/foo`.
3215                .chain(declared.get(&generated))
3216                .chain(included.get(&generated))
3217                .any(|other_methods| methods.iter().any(|m| other_methods.contains(m)));
3218
3219            let shape_conflict = all_other_paths
3220                .iter()
3221                .any(|p| **p != generated && paths_conflict_under_matchit(p, &generated));
3222
3223            if exact_conflict || shape_conflict {
3224                return Some(RouterBuildError::LocalePrefixPathCollision {
3225                    locale: locale.clone(),
3226                    path: path.clone(),
3227                    generated,
3228                });
3229            }
3230        }
3231    }
3232    None
3233}
3234
3235/// Builds the locale-prefixed router: `excluded_router` (and a bare-path
3236/// redirect for every `path_method_filters` entry, claiming only the exact
3237/// methods registered there — see [`path_method_filters`](fn@path_method_filters))
3238/// mount at the top level; `content_router` is cloned and nested once per entry in
3239/// `valid_locales` (see [`validated_locale_prefix_locales`] — already
3240/// deduped and filtered to safe `Router::nest` segments).
3241///
3242/// Cloning `content_router` — a cheap, `Arc`-backed `axum::Router` — rather
3243/// than the underlying [`Route`] list is what lets every locale share one
3244/// router build: no route definition is duplicated by hand or in code.
3245///
3246/// When `valid_locales` is empty (a degenerate config — locale-prefix
3247/// routing on with nothing valid to prefix with), no nest is created and the
3248/// bare-path redirect is skipped too: redirecting to an unnested
3249/// `/{default_locale}/...` target that structurally can't exist would just
3250/// swap a direct 404 for a 308-then-404 round trip.
3251#[cfg(feature = "i18n")]
3252fn apply_locale_prefix_routing(
3253    excluded_router: axum::Router<AppState>,
3254    content_router: &axum::Router<AppState>,
3255    path_method_filters: &[(String, axum::routing::MethodFilter)],
3256    i18n: &crate::i18n::I18nConfig,
3257    valid_locales: &[String],
3258) -> axum::Router<AppState> {
3259    let mut router = excluded_router;
3260
3261    if !path_method_filters.is_empty() && !valid_locales.is_empty() {
3262        let mut redirect_router = axum::Router::<AppState>::new();
3263        for (path, filter) in path_method_filters {
3264            redirect_router = redirect_router.route(
3265                path,
3266                axum::routing::on(*filter, locale_prefix_redirect_handler),
3267            );
3268        }
3269        router = router.merge(redirect_router);
3270    }
3271
3272    // Content resolution (#1384) must see the URL prefix, which is visible only
3273    // inside this nest. An app-wide ambient-locale layer sits outside it and
3274    // would negotiate `/es/posts` from `Accept-Language` alone, so the nest gets
3275    // its own scope layer.
3276    //
3277    // It refines the app-wide scope rather than building a fresh one. That
3278    // layer's chain comes from the loaded `Bundle`, which an app may have built
3279    // from a different `I18nConfig` than the router's. Rebuilding the chain here
3280    // would shadow it, letting `/es/...` resolve `#[translatable]` content down
3281    // one chain while `Locale::t` on the same request walked another. The config
3282    // chain is the fallback for one shape only: locale-prefix routing without
3283    // `.i18n()`/`.i18n_auto()`, where no bundle exists.
3284    let prefix_chain = i18n.resolved_fallback_chain();
3285    for locale in valid_locales {
3286        let nested = content_router
3287            .clone()
3288            .fallback(crate::middleware::error_page_filter::fallback_404_handler)
3289            .layer(crate::i18n::LocalePrefixScopeLayer::new(
3290                locale.clone(),
3291                prefix_chain.clone(),
3292                &i18n.default_locale,
3293            ))
3294            .layer(axum::Extension(crate::i18n::UriPrefixedLocale(
3295                locale.clone(),
3296            )));
3297        router = router.nest(&format!("/{locale}"), nested);
3298    }
3299
3300    // Negotiation data for the `Locale` extractor (#1251). Covers apps that set
3301    // `locale_prefix_enabled` without calling `.i18n()`/`.i18n_auto()`, so the
3302    // bare-path redirect — and any handler under an excluded prefix that takes a
3303    // `Locale` — negotiates against the configured
3304    // `supported_locales`/`default_locale` instead of an empty list and a
3305    // hard-coded `"en"`. This stays authoritative for negotiation even with a
3306    // `Bundle` installed, because the router's reachable locale segments come
3307    // from `I18nConfig`, not the bundle; see `Locale::from_request_parts`. A
3308    // `Bundle` stays authoritative for `t()`/`t_with()` lookups only.
3309    //
3310    // `default_locale` is used here only as the negotiation fallback, so it must
3311    // name a locale nested above. One absent from the validated set would
3312    // negotiate to a locale with no `/{locale}` nest, and the bare-path redirect
3313    // would 308 straight into a 404. Fall back to the first mounted locale.
3314    let effective_default_locale = if valid_locales.contains(&i18n.default_locale) {
3315        i18n.default_locale.clone()
3316    } else {
3317        valid_locales
3318            .first()
3319            .cloned()
3320            .unwrap_or_else(|| i18n.default_locale.clone())
3321    };
3322    router.layer(axum::Extension(crate::i18n::LocaleRoutingConfig {
3323        supported_locales: valid_locales.to_vec(),
3324        default_locale: effective_default_locale,
3325    }))
3326}
3327
3328/// Fallback handler mounted at every locale-prefix-eligible bare path:
3329/// 308-redirects to the negotiated locale's prefixed path, preserving the
3330/// query string. Reuses the unmodified [`Locale`](crate::i18n::Locale)
3331/// extractor (no [`UriPrefixedLocale`](crate::i18n::UriPrefixedLocale) is set
3332/// this far outside any locale nest) so the redirect target matches exactly
3333/// what the request would have resolved to anyway.
3334///
3335/// **Known limitation** (Codex review): a mutating form `POSTing` directly to
3336/// its bare, unprefixed path with a `[SubmitTokenLayer](crate::security::SubmitTokenLayer)`-protected
3337/// `_submit_token` hits this 308 *before* reaching the real handler.
3338/// `SubmitTokenLayer` caches 3xx responses so a replayed submit returns the
3339/// first response verbatim — it has no way to distinguish this redirect
3340/// from a handler-issued one it's deliberately designed to cache, so it
3341/// records this 308 against the token. The browser then re-POSTs the same
3342/// body (with the same token) to the now-current, already-prefixed URL,
3343/// where the token replays the *cached 308* instead of reaching the
3344/// handler. Closing this fully would mean teaching `SubmitTokenLayer` to
3345/// recognize and skip caching this specific redirect — out of scope here.
3346/// Forms should POST to the current (already locale-prefixed) path — e.g.
3347/// via [`widgets::localized_path`](crate::widgets::localized_path) or a
3348/// relative `action` — rather than a hardcoded bare path, which this
3349/// framework's own `locale_switcher` already does for links.
3350#[cfg(feature = "i18n")]
3351async fn locale_prefix_redirect_handler(
3352    locale: crate::i18n::Locale,
3353    uri: axum::http::Uri,
3354) -> axum::response::Redirect {
3355    let path = uri.path();
3356    // Axum's `nest(prefix, router)` makes the *bare* `prefix` (no trailing
3357    // slash) match the inner router's own `"/"` route — `prefix/` 404s. So
3358    // the root path's redirect target is `/{locale}`, not `/{locale}/`,
3359    // unlike every other path, which is a plain concatenation.
3360    let mut target = if path == "/" {
3361        format!("/{}", locale.tag())
3362    } else {
3363        format!("/{}{path}", locale.tag())
3364    };
3365    if let Some(query) = uri.query() {
3366        target.push('?');
3367        target.push_str(query);
3368    }
3369    axum::response::Redirect::permanent(&target)
3370}
3371
3372/// The idempotency layer to mount on `route`, or `None` for a route that
3373/// opted out with `idempotent = false` ([`RouteIdempotency::Off`]).
3374///
3375/// [`RouteIdempotency::Off`]: crate::route::RouteIdempotency::Off
3376const fn idempotency_layer_for_route<'a>(
3377    route: &Route,
3378    layers: &'a BuiltIdempotencyLayers,
3379    opaque_app_layers_present: bool,
3380) -> Option<&'a IdempotencyLayer> {
3381    if matches!(route.idempotency, crate::route::RouteIdempotency::Off) {
3382        None
3383    } else if opaque_app_layers_present {
3384        Some(&layers.manual)
3385    } else if route_uses_generated_replay_stop(route) {
3386        Some(&layers.route)
3387    } else {
3388        Some(&layers.manual)
3389    }
3390}
3391
3392const fn route_uses_generated_replay_stop(route: &Route) -> bool {
3393    matches!(
3394        route.idempotency,
3395        crate::route::RouteIdempotency::ReplayThroughInner
3396    )
3397}
3398
3399fn custom_layers_require_fail_closed_idempotency(
3400    custom_layers: &[crate::app::CustomLayerRegistration],
3401) -> bool {
3402    custom_layers
3403        .iter()
3404        .any(|registered| !is_idempotency_transparent_app_layer(registered))
3405}
3406
3407fn is_idempotency_transparent_app_layer(registered: &crate::app::CustomLayerRegistration) -> bool {
3408    registered
3409        .type_name
3410        .starts_with("autumn_web::session::SessionLayer<")
3411        || registered
3412            .type_name
3413            .starts_with("autumn::session::SessionLayer<")
3414        || registered.type_id
3415            == std::any::TypeId::of::<crate::session::SessionLayer<crate::session::MemoryStore>>()
3416        || is_i18n_bundle_extension_layer(registered.type_id)
3417        || is_edge_fallthrough_sentinel_strip_layer(registered.type_id)
3418        || is_require_api_token_layer(registered.type_id)
3419}
3420
3421/// [`RequireApiToken`](crate::auth::RequireApiToken) registered directly via
3422/// `AppBuilder::layer` or `static_gate`.
3423///
3424/// It rejects a missing or revoked token before the idempotency layer runs,
3425/// and the principal it verifies is part of the idempotency storage key (the
3426/// `bearer-principal` component), so two token principals never share a cache
3427/// slot. That is the property the fail-closed path exists to provide, so
3428/// forcing it here only turned every same-client retry into a `409`. Matched
3429/// by `TypeId`: a `ServiceBuilder` stack that wraps it is a different type and
3430/// stays opaque.
3431fn is_require_api_token_layer(type_id: std::any::TypeId) -> bool {
3432    type_id == std::any::TypeId::of::<crate::auth::RequireApiToken>()
3433}
3434
3435/// The origin-only layer that strips the edge lane's internal fallthrough
3436/// sentinel header (issue #2244, `autumn::app::StripEdgeFallthroughSentinelLayer`).
3437///
3438/// It only ever removes one framework-owned response header — it never reads
3439/// or branches on caller identity, session, or tenant — so it cannot change
3440/// which cached response an idempotency replay serves. `app()` registers it
3441/// through the ordinary `AppBuilder::layer` path (like any other custom
3442/// layer), which is why it needs this same-shaped allowance as
3443/// `SessionLayer` and the i18n bundle extension: otherwise every app built
3444/// with the `edge` feature on would force fail-closed idempotency, whether
3445/// or not it ever calls `with_edge_kv`.
3446///
3447/// Matched by `TypeId`, not a name: a bespoke crate-private type, unlike a
3448/// name (even a function's), cannot collide with a user's own middleware.
3449#[cfg(feature = "edge")]
3450fn is_edge_fallthrough_sentinel_strip_layer(type_id: std::any::TypeId) -> bool {
3451    type_id == std::any::TypeId::of::<crate::app::StripEdgeFallthroughSentinelLayer>()
3452}
3453
3454#[cfg(not(feature = "edge"))]
3455const fn is_edge_fallthrough_sentinel_strip_layer(_type_id: std::any::TypeId) -> bool {
3456    false
3457}
3458
3459#[cfg(feature = "i18n")]
3460fn is_i18n_bundle_extension_layer(type_id: std::any::TypeId) -> bool {
3461    type_id == std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>()
3462}
3463
3464#[cfg(not(feature = "i18n"))]
3465const fn is_i18n_bundle_extension_layer(_type_id: std::any::TypeId) -> bool {
3466    false
3467}
3468
3469#[cfg_attr(not(feature = "mail"), allow(unused_variables))]
3470#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
3471fn mount_framework_routes(
3472    mut router: axum::Router<AppState>,
3473    config: &AutumnConfig,
3474    dev_reload_enabled: bool,
3475) -> axum::Router<AppState> {
3476    #[cfg(not(feature = "mail"))]
3477    let _ = config;
3478
3479    // Framework-provided scripts (htmx, its SSE extension, idiomorph, the
3480    // CSRF helper and the widget runtime), each at its plain path and at a
3481    // content-hashed path. The global `AssetCacheControlLayer` applied after
3482    // this recognises the hashed paths as fingerprinted, so they are cached
3483    // `immutable` and the plain paths revalidate.
3484    #[cfg(feature = "htmx")]
3485    {
3486        // When htmx is vendored via `autumn assets add htmx@…`,
3487        // `framework_scripts` leaves it out so ServeDir serves the pinned file.
3488        // Axum explicit routes beat `nest_service`, so without this the
3489        // embedded bytes would shadow the vendored version.
3490        if crate::assets::htmx_is_vendored() {
3491            tracing::debug!(
3492                path = crate::htmx::HTMX_JS_PATH,
3493                "htmx vendored via `autumn assets`; built-in handler skipped, ServeDir serves it"
3494            );
3495        }
3496        router = router.merge(framework_scripts_router());
3497        for asset in crate::htmx::framework_scripts() {
3498            tracing::debug!(
3499                method = "GET",
3500                path = asset.plain_url(),
3501                fingerprinted = asset.url(),
3502                "Mounted route"
3503            );
3504        }
3505    }
3506
3507    // Framework-provided flash-message stylesheet. Served as a same-origin
3508    // asset (rather than inline styles) so the `.flash` classes emitted by
3509    // `Flash::render` stay compatible with a strict `style-src 'self'` CSP.
3510    #[cfg(feature = "flash")]
3511    {
3512        router = router.route(
3513            crate::flash::FLASH_CSS_PATH,
3514            axum::routing::get(flash_css_handler),
3515        );
3516        tracing::debug!(
3517            method = "GET",
3518            path = crate::flash::FLASH_CSS_PATH,
3519            name = "autumn flash stylesheet",
3520            "Mounted route"
3521        );
3522    }
3523
3524    // Framework-provided widget stylesheet (#1215). Backs every `autumn-*`
3525    // class emitted by form/widgets/wizard/pagination/storage/job-tracking so
3526    // widgets render styled without an app-authored copy — Tailwind or not.
3527    #[cfg(feature = "maud")]
3528    {
3529        router = router.route(
3530            crate::ui::WIDGETS_CSS_PATH,
3531            axum::routing::get(widgets_css_handler),
3532        );
3533        tracing::debug!(
3534            method = "GET",
3535            path = crate::ui::WIDGETS_CSS_PATH,
3536            name = "autumn widget stylesheet",
3537            "Mounted route"
3538        );
3539    }
3540
3541    if dev_reload_enabled {
3542        router = router.route(
3543            dev::LIVE_RELOAD_PATH,
3544            axum::routing::get(dev::live_reload_state_handler),
3545        );
3546        router = router.route(
3547            dev::LIVE_RELOAD_SCRIPT_PATH,
3548            axum::routing::get(dev::live_reload_script_handler),
3549        );
3550        tracing::debug!(
3551            state_path = dev::LIVE_RELOAD_PATH,
3552            script_path = dev::LIVE_RELOAD_SCRIPT_PATH,
3553            "Mounted dev live reload endpoints"
3554        );
3555    }
3556
3557    #[cfg(feature = "mail")]
3558    if config
3559        .mail
3560        .preview_routes_enabled(config.profile.as_deref())
3561    {
3562        router = router.merge(crate::mail::mail_preview_router(
3563            config.mail.file_dir.clone(),
3564        ));
3565        tracing::debug!(
3566            path = crate::mail::MAIL_PREVIEW_PATH,
3567            "Mounted dev mail preview endpoints"
3568        );
3569    }
3570
3571    // Widget story gallery (#1526) — off by default, opt-in in ANY profile
3572    // via `[stories] enabled = true` (profile-layered). Handlers read the
3573    // StoryRegistry from the AppState extension installed by
3574    // `AppBuilder::with_story_gallery`.
3575    #[cfg(feature = "maud")]
3576    if config.stories.enabled {
3577        router = router.merge(crate::stories::story_router());
3578        tracing::debug!(
3579            path = crate::stories::STORIES_PATH,
3580            "Mounted story gallery endpoints"
3581        );
3582    }
3583
3584    // RFC 8058 one-click unsubscribe endpoint — opt-in via
3585    // `mail.mount_unsubscribe_endpoint` / `AppBuilder::mount_unsubscribe_endpoint`
3586    // so JSON-only apps never get an HTML endpoint they didn't request.
3587    #[cfg(feature = "mail")]
3588    if config.mail.should_mount_unsubscribe_endpoint() {
3589        router = router.merge(crate::mail::unsubscribe_router());
3590        tracing::debug!(
3591            path = crate::mail::UNSUBSCRIBE_PATH,
3592            "Mounted default unsubscribe endpoint"
3593        );
3594    }
3595
3596    // Tracked-job status endpoint (enqueue_tracked / #[job] JobContext) — on
3597    // by default; opt out via `jobs.tracking.route_enabled = false`.
3598    if config.jobs.tracking.route_enabled {
3599        router = router.merge(crate::job_tracking::status_router());
3600        tracing::debug!(
3601            path = crate::job_tracking::JOB_STATUS_ROUTE_PATH,
3602            "Mounted tracked-job status endpoint"
3603        );
3604    }
3605
3606    router
3607}
3608
3609fn mount_probe_endpoints<S>(
3610    mut router: axum::Router<S>,
3611    config: &AutumnConfig,
3612    user_get_paths: &std::collections::HashSet<String>,
3613) -> (std::collections::HashSet<String>, axum::Router<S>)
3614where
3615    S: Clone + Send + Sync + 'static,
3616    AppState: axum::extract::FromRef<S>,
3617{
3618    // Probe endpoints (auto-mounted). Each probe is a `GET`; when a user route
3619    // already owns that exact path, yield to the user handler instead of
3620    // handing axum a second `GET` for the same path — which panics at startup
3621    // with a raw "Overlapping method route" message that names none of the
3622    // user's code (issue #1971). A user who hand-writes `GET /health` clearly
3623    // wants their handler, so the built-in steps aside and logs the override.
3624    let mut mounted_probe_paths = std::collections::HashSet::new();
3625
3626    // Global off-switch (issue #1971): when `health.enabled = false`, the
3627    // framework mounts none of the built-in probes (health/live/ready/startup),
3628    // leaving those paths free for an app to own — or to expose nothing. The
3629    // returned `mounted_probe_paths` set stays empty, so the actuator overlap
3630    // guard treats every probe path as unclaimed by the framework.
3631    if !config.health.enabled {
3632        tracing::info!("health.enabled = false; built-in probe endpoints are not auto-mounted");
3633        return (mounted_probe_paths, router);
3634    }
3635
3636    let mut mount_probe = |mut router: axum::Router<S>,
3637                           path: &str,
3638                           label: &'static str,
3639                           handler: axum::routing::MethodRouter<S>|
3640     -> axum::Router<S> {
3641        if user_get_paths.contains(path) {
3642            tracing::info!(
3643                probe = label,
3644                path,
3645                "a user route already owns this path; the built-in probe was \
3646                 not auto-mounted (the user handler wins)"
3647            );
3648            // Still record the ceded path: `mount_actuator_endpoints` keys its
3649            // overlap guard off this set, and a configured probe path stays a
3650            // collision hazard for the actuator even when a user route (not the
3651            // built-in probe) owns it. Dropping it here would let an actuator at
3652            // prefix "/" merge its own `GET /health` onto the user's `GET
3653            // /health` and axum would panic during construction instead of
3654            // returning a checked `FrameworkRouteOverlap` (issue #1971 P2).
3655            mounted_probe_paths.insert(path.to_owned());
3656            return router;
3657        }
3658        if mounted_probe_paths.insert(path.to_owned()) {
3659            router = router.route(path, handler);
3660        }
3661        router
3662    };
3663
3664    router = mount_probe(
3665        router,
3666        &config.health.live_path,
3667        "liveness",
3668        axum::routing::get(crate::probe::live_handler::<AppState>),
3669    );
3670    router = mount_probe(
3671        router,
3672        &config.health.ready_path,
3673        "readiness",
3674        axum::routing::get(crate::probe::ready_handler::<AppState>),
3675    );
3676    router = mount_probe(
3677        router,
3678        &config.health.startup_path,
3679        "startup",
3680        axum::routing::get(crate::probe::startup_handler::<AppState>),
3681    );
3682    router = mount_probe(
3683        router,
3684        &config.health.path,
3685        "health",
3686        axum::routing::get(crate::health::handler::<AppState>),
3687    );
3688    tracing::debug!(
3689        health = %config.health.path,
3690        live = %config.health.live_path,
3691        ready = %config.health.ready_path,
3692        startup = %config.health.startup_path,
3693        "Mounted probe endpoints"
3694    );
3695
3696    (mounted_probe_paths, router)
3697}
3698
3699fn mount_actuator_endpoints(
3700    mut router: axum::Router<AppState>,
3701    config: &AutumnConfig,
3702    mounted_probe_paths: &std::collections::HashSet<String>,
3703) -> Result<axum::Router<AppState>, RouterBuildError> {
3704    // Actuator endpoints
3705    let actuator_sensitive = config.actuator.sensitive;
3706    let actuator_prometheus = config.actuator.prometheus;
3707    let actuator_paths = crate::actuator::actuator_endpoint_paths(
3708        &config.actuator.prefix,
3709        actuator_sensitive,
3710        actuator_prometheus,
3711    );
3712    if let Some(path) = actuator_paths
3713        .iter()
3714        .find(|path| mounted_probe_paths.contains(path.as_str()))
3715    {
3716        return Err(RouterBuildError::FrameworkRouteOverlap {
3717            path: path.clone(),
3718            existing: "probe endpoint",
3719            incoming: "actuator endpoint",
3720        });
3721    }
3722    router = router.merge(crate::actuator::actuator_router_with_prefix(
3723        &config.actuator.prefix,
3724        actuator_sensitive,
3725        actuator_prometheus,
3726    ));
3727    tracing::debug!(
3728        sensitive = actuator_sensitive,
3729        prometheus = actuator_prometheus,
3730        prefix = %config.actuator.prefix,
3731        "Mounted actuator endpoints"
3732    );
3733    if !actuator_prometheus {
3734        tracing::info!(
3735            config_key = "actuator.prometheus",
3736            "Prometheus scrape endpoint is disabled; app metrics recorded through autumn_web::metrics are still collected, and still visible as JSON under the `app` key of the actuator's metrics endpoint — only the Prometheus scrape format is gated. Set actuator.prometheus = true to expose it"
3737        );
3738    }
3739    Ok(router)
3740}
3741
3742fn mount_scoped_groups(
3743    mut router: axum::Router<AppState>,
3744    scoped_groups: Vec<ScopedGroup>,
3745    idempotency_layers: Option<&BuiltIdempotencyLayers>,
3746    state: &AppState,
3747) -> axum::Router<AppState> {
3748    // Mount scoped route groups (each with its own middleware layer).
3749    for group in scoped_groups {
3750        let mut sub_router = axum::Router::new();
3751        for route in group.routes {
3752            tracing::debug!(
3753                method = %route.method,
3754                path = route.path,
3755                name = route.name,
3756                scope = %group.prefix,
3757                "Mounted scoped route"
3758            );
3759            // Scoped groups are wrapped by an opaque user-provided layer after
3760            // the route handlers are built. The idempotency storage key cannot
3761            // know whether that layer authorizes, audits, or resolves tenant
3762            // state from non-whitelisted headers/extensions, so cached hits
3763            // fail closed instead of replaying through a generated stop inside
3764            // the scoped route. A route that opted out with `idempotent =
3765            // false` gets no layer here either.
3766            let selected_layer = idempotency_layers
3767                .filter(|_| !matches!(route.idempotency, crate::route::RouteIdempotency::Off))
3768                .map(|layers| &layers.manual);
3769            let seo = route.seo;
3770            let mut handler = route.handler;
3771            if let Some(layer) = selected_layer {
3772                handler = handler.layer(layer.clone());
3773            }
3774            handler = attach_seo_defaults(handler, seo);
3775            if let Some(version) = route.api_version {
3776                handler = handler.layer(axum::middleware::from_fn_with_state(
3777                    state.clone(),
3778                    api_versioning_middleware,
3779                ));
3780                handler = handler.layer(axum::Extension(RouteVersionMetadata {
3781                    version: version.to_string(),
3782                    sunset_opt_out: route.sunset_opt_out,
3783                    secured: route.api_doc.secured,
3784                    required_roles: route.api_doc.required_roles,
3785                    has_policy: route.api_doc.has_policy,
3786                }));
3787            }
3788            sub_router = sub_router.route(route.path, handler);
3789        }
3790        sub_router = (group.apply_layer)(sub_router);
3791        router = router.nest(&group.prefix, sub_router);
3792    }
3793    router
3794}
3795
3796fn mount_raw_routers(
3797    mut router: axum::Router<AppState>,
3798    merge_routers: Vec<axum::Router<AppState>>,
3799    nest_routers: Vec<(String, axum::Router<AppState>)>,
3800    idempotency_layers: Option<&BuiltIdempotencyLayers>,
3801) -> axum::Router<AppState> {
3802    // Merge user-supplied raw Axum routers (escape hatch).
3803    // Merged after annotated routes so annotated routes take precedence.
3804    for raw_router in merge_routers {
3805        tracing::debug!("Merged raw Axum router");
3806        let raw_router = if let Some(layers) = idempotency_layers {
3807            raw_router.layer(layers.manual.clone())
3808        } else {
3809            raw_router
3810        };
3811        router = router.merge(raw_router);
3812    }
3813
3814    // Nest user-supplied raw Axum routers under path prefixes.
3815    for (prefix, raw_router) in nest_routers {
3816        tracing::debug!(prefix = %prefix, "Nested raw Axum router");
3817        // We explicitly apply the fallback to the nested router before nesting,
3818        // so that unmatched routes within this prefix are protected by global middleware.
3819        let nested_router =
3820            raw_router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
3821        let nested_router = if let Some(layers) = idempotency_layers {
3822            nested_router.layer(layers.manual.clone())
3823        } else {
3824            nested_router
3825        };
3826        router = router.nest(&prefix, nested_router);
3827    }
3828    router
3829}
3830
3831/// Content-type prefixes `CompressionPredicate` skips beyond what
3832/// `tower_http`'s own `DefaultPredicate` already excludes (images, gRPC,
3833/// SSE, small bodies): binary media and already-compressed formats waste CPU
3834/// to (re)compress, inflate archive transfer size, or can confuse media
3835/// players.
3836const COMPRESSION_EXCLUDED_CONTENT_TYPES: &[&str] = &[
3837    // Binary media — already-encoded by codec, not compressible by gzip/br.
3838    "audio/",
3839    "video/",
3840    "application/octet-stream",
3841    // Compressed archive formats — re-compressing wastes CPU.
3842    "application/zip",
3843    "application/gzip",
3844    "application/x-gzip",
3845    "application/zstd",
3846    "application/x-bzip2",
3847    "application/x-bzip",
3848    "application/x-rar-compressed",
3849    "application/vnd.rar",
3850    "application/x-7z-compressed",
3851    // Pre-compressed web fonts — WOFF/WOFF2 embed their own compression, so
3852    // gzip/br only wastes CPU and can inflate them. Raw fonts (`font/ttf`,
3853    // `font/otf`) are NOT excluded: they are uncompressed SFNT data that
3854    // genuinely benefits from transfer compression.
3855    "font/woff",
3856    "font/woff2",
3857];
3858
3859/// `compress_when` predicate shared by both compression call sites (this
3860/// function's SSG/ISG path and `apply_middleware`'s inline fully-dynamic
3861/// path, #2371).
3862///
3863/// A hand-rolled `Predicate` wrapping `DefaultPredicate`, rather than
3864/// `DefaultPredicate` extended with 13 chained
3865/// `.and(NotForContentType::const_new(...))` calls (the form this replaces):
3866/// each `NotForContentType` embeds a ~24-byte `Str` enum, so 13 of them
3867/// nested via `tower_http`'s `And<Lhs, Rhs>` inflated `CompressionLayer`'s
3868/// own static SIZE by several hundred bytes — harmless while that size only
3869/// affected `apply_compression_middleware`'s own local, but #2371 also made
3870/// this predicate a member of `apply_middleware`'s `option_layer` `Either`,
3871/// whose size is fixed by its larger (`Some`) branch even in the `None`
3872/// (compression-off, the default) case. That inflated the per-request byte
3873/// count `config_alloc_gate.rs` gates for every app, not just ones with
3874/// compression on — caught by CI, not by this repo's `middleware_stack_depth.rs`
3875/// probe, which only counts clone *events*, not their size (see that file's
3876/// own "What this gate is blind to" section). A `&'static [&'static str]`
3877/// slice checked in a loop costs 16 bytes regardless of how many entries it
3878/// lists.
3879#[derive(Clone)]
3880struct CompressionPredicate {
3881    default: tower_http::compression::predicate::DefaultPredicate,
3882}
3883
3884impl tower_http::compression::predicate::Predicate for CompressionPredicate {
3885    fn should_compress<B>(&self, response: &http::Response<B>) -> bool
3886    where
3887        B: http_body::Body,
3888    {
3889        // A digest covers the bytes the handler sent; encoding them after
3890        // would break it, and any signature made over it (Web Bot Auth).
3891        let headers = response.headers();
3892        self.default.should_compress(response)
3893            && !headers.contains_key("content-digest")
3894            && !headers.contains_key("repr-digest")
3895            && response
3896                .headers()
3897                .get(http::header::CONTENT_TYPE)
3898                .and_then(|value| value.to_str().ok())
3899                .is_none_or(|content_type| !is_compression_excluded(content_type))
3900    }
3901}
3902
3903/// Returns true if `content_type` starts with an excluded prefix. The match
3904/// ignores ASCII case (RFC 9110 §8.3.1). The header is not changed.
3905fn is_compression_excluded(content_type: &str) -> bool {
3906    COMPRESSION_EXCLUDED_CONTENT_TYPES.iter().any(|excluded| {
3907        content_type
3908            .as_bytes()
3909            .get(..excluded.len())
3910            .is_some_and(|head| head.eq_ignore_ascii_case(excluded.as_bytes()))
3911    })
3912}
3913
3914fn compression_predicate() -> CompressionPredicate {
3915    CompressionPredicate {
3916        default: tower_http::compression::predicate::DefaultPredicate::new(),
3917    }
3918}
3919
3920/// Apply response compression, when enabled.
3921///
3922/// Used only by the SSG/ISG static path (`try_build_router_with_static_inner`),
3923/// which keeps this as its own standalone `Router::layer` call. The
3924/// fully-dynamic path (`apply_middleware`) instead folds the equivalent
3925/// `(NormalizeBodyLayer, CompressionLayer)` pair directly into its single
3926/// merged tuple via `option_layer` (#2371) — see the `compression_layer`
3927/// local there for why the pairing is required: `CompressionLayer`'s service
3928/// changes the response BODY type, which `Route::layer` absorbs via
3929/// `IntoResponse` but `option_layer`'s `Either` cannot on its own — both of
3930/// its branches must share one `Response` type.
3931fn apply_compression_middleware<S>(
3932    mut router: axum::Router<S>,
3933    config: &AutumnConfig,
3934) -> axum::Router<S>
3935where
3936    S: Clone + Send + Sync + 'static,
3937{
3938    if config.compression.enabled {
3939        router = router.layer(
3940            tower_http::compression::CompressionLayer::new().compress_when(compression_predicate()),
3941        );
3942        tracing::info!("Response compression enabled (gzip/brotli)");
3943    }
3944    router
3945}
3946
3947/// Kept as a router-level wrapper for this module's unit tests; the main
3948/// ingress stack composes the layer directly (see `apply_middleware`).
3949#[cfg(test)]
3950fn apply_cors_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
3951where
3952    S: Clone + Send + Sync + 'static,
3953{
3954    match build_ingress_cors_layer(config) {
3955        Some(layer) => router.layer(layer),
3956        None => router,
3957    }
3958}
3959
3960/// Build the ingress CORS layer, or `None` when no origins are configured.
3961///
3962/// Split out of [`apply_cors_middleware`] so the layer can join the composed
3963/// ingress stack rather than costing its own nesting level (issue #2193).
3964fn build_ingress_cors_layer(config: &AutumnConfig) -> Option<tower_http::cors::CorsLayer> {
3965    // CORS middleware (only applied when allowed_origins is non-empty)
3966    if config.cors.allowed_origins.is_empty() {
3967        return None;
3968    }
3969    // A browser's paid x402 retry carries `PAYMENT-SIGNATURE`, which its
3970    // preflight must allow, or the payment never reaches the priced route.
3971    let pays = config.aeo.enabled
3972        && config
3973            .aeo
3974            .paid_routes
3975            .iter()
3976            .any(crate::aeo::commerce::PaidRoute::is_x402)
3977        // `*` is a wildcard only without credentials (Fetch §3.3.4).
3978        && !config.cors.allowed_headers.iter().any(|h| {
3979            (h == "*" && !config.cors.allow_credentials)
3980                || h.eq_ignore_ascii_case("payment-signature")
3981        });
3982    let cors = if pays {
3983        let mut cors = config.cors.clone();
3984        cors.allowed_headers.push("payment-signature".to_owned());
3985        build_cors_layer(&cors)
3986    } else {
3987        build_cors_layer(&config.cors)
3988    };
3989    tracing::info!(
3990        origins = ?config.cors.allowed_origins,
3991        credentials = config.cors.allow_credentials,
3992        "CORS enabled"
3993    );
3994    Some(cors)
3995}
3996
3997/// Kept as a router-level wrapper for this module's unit tests; the main
3998/// ingress stack composes the layer directly (see `apply_middleware`).
3999#[cfg(test)]
4000fn apply_csrf_middleware<S>(
4001    router: axum::Router<S>,
4002    config: &AutumnConfig,
4003    signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
4004) -> axum::Router<S>
4005where
4006    S: Clone + Send + Sync + 'static,
4007{
4008    match build_csrf_layer(config, signing_keys) {
4009        Some(layer) => router.layer(layer),
4010        None => router,
4011    }
4012}
4013
4014/// Build the CSRF layer, or `None` when CSRF is disabled.
4015///
4016/// The mTLS route-requirement layer (#1640), or `None` when no route declares
4017/// one.
4018///
4019/// `None` whenever `[server.tls.client_auth]` is absent, its mode is `off`, or
4020/// it lists no `required_paths` — the three cases in which the layer would have
4021/// nothing to enforce. Built here (rather than at the serve boundary) so it
4022/// sits inside the router; see the call site for why that matters.
4023#[cfg(feature = "tls")]
4024fn build_client_cert_requirement_layer(
4025    config: &crate::config::AutumnConfig,
4026) -> Option<crate::tls::client_auth::RequireClientCertLayer> {
4027    let client_auth = config.server.tls.as_ref()?.client_auth.as_ref()?;
4028    if !client_auth.mode.requests_certificate() || client_auth.required_paths.is_empty() {
4029        return None;
4030    }
4031    Some(crate::tls::client_auth::RequireClientCertLayer::for_paths(
4032        client_auth.required_paths.clone(),
4033    ))
4034}
4035
4036/// The `tls`-less build has no client certificates to require, so the slot is
4037/// always empty. `Identity` only names a type for `option_layer`'s `None` arm.
4038#[cfg(not(feature = "tls"))]
4039const fn build_client_cert_requirement_layer(
4040    _config: &crate::config::AutumnConfig,
4041) -> Option<tower::layer::util::Identity> {
4042    None
4043}
4044
4045/// Split out of [`apply_csrf_middleware`] so the layer can join the composed
4046/// ingress stack rather than costing its own nesting level (issue #2193).
4047fn build_csrf_layer(
4048    config: &AutumnConfig,
4049    signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
4050) -> Option<crate::security::CsrfLayer> {
4051    // CSRF middleware (only applied when enabled)
4052    if config.security.csrf.enabled {
4053        // The CSRF token scan reads only a bounded body prefix
4054        // (`security.csrf.token_scan_bytes`, 2 MiB default) and streams the rest
4055        // through, so the cap comes from CSRF config, not from
4056        // `upload.max_request_size_bytes` — that would force whole uploads into
4057        // memory and defeat the streaming upload path.
4058        //
4059        // Clamp the prefix to the global body limit so the CSRF layer never
4060        // buffers more than `upload.max_request_size_bytes`. Normally the small
4061        // `token_scan_bytes` prefix wins; the `min` keeps it at 2 MiB and never
4062        // raises it to the upload limit. Only an operator who lowers the global
4063        // limit below the prefix cap clamps the scan down, and then the whole
4064        // body is within that limit anyway, so an early `_csrf` token is still in
4065        // range and anything larger is rejected by `DefaultBodyLimit`.
4066        let effective_scan_bytes = config
4067            .security
4068            .csrf
4069            .token_scan_bytes
4070            .min(config.security.upload.max_request_size_bytes);
4071        let mut csrf_layer = crate::security::CsrfLayer::from_config(&config.security.csrf)
4072            .with_max_scan_bytes(effective_scan_bytes);
4073        if let Some(keys) = signing_keys {
4074            csrf_layer = csrf_layer.with_signing_keys(keys);
4075        }
4076        for endpoint in &config.security.webhooks.endpoints {
4077            csrf_layer = csrf_layer.with_exempt_path(&endpoint.path);
4078        }
4079        // RFC 8058 one-click unsubscribe POSTs arrive from mailbox providers
4080        // with no Autumn CSRF cookie/header; exempt the endpoint only when the
4081        // framework owns it (opt-in), so a custom override keeps its own CSRF.
4082        #[cfg(feature = "mail")]
4083        if config.mail.should_mount_unsubscribe_endpoint() {
4084            csrf_layer = csrf_layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
4085        }
4086        tracing::info!("CSRF protection enabled");
4087        Some(csrf_layer)
4088    } else {
4089        None
4090    }
4091}
4092
4093/// Apply the one-time submit-token guard (issue #1360).
4094///
4095/// Enabled by default. The layer is applied *inner* to the CSRF layer (it is
4096/// registered before `apply_csrf_middleware`, so on the request path CSRF is
4097/// validated first): a request bearing a valid `_csrf` but an already-consumed
4098/// `_submit_token` is still short-circuited by this guard. The store backend
4099/// mirrors [`build_idempotency_layers`]; the `redis` backend reuses the
4100/// `[idempotency.redis]` connection settings.
4101///
4102/// Kept as a router-level wrapper for this module's unit tests; the main
4103/// ingress stack composes the layer directly (see `apply_middleware`).
4104#[cfg(test)]
4105fn apply_submit_token_middleware<S>(
4106    router: axum::Router<S>,
4107    config: &AutumnConfig,
4108    is_production: bool,
4109) -> Result<axum::Router<S>, RouterBuildError>
4110where
4111    S: Clone + Send + Sync + 'static,
4112{
4113    Ok(
4114        match build_submit_token_layer(config, &AppState::for_test(), is_production)? {
4115            Some(layer) => router.layer(layer),
4116            None => router,
4117        },
4118    )
4119}
4120
4121/// Build the one-time submit-token layer, or `None` when it is disabled.
4122///
4123/// Split out of [`apply_submit_token_middleware`] so the layer can join the
4124/// composed ingress stack rather than costing its own nesting level
4125/// (issue #2193). The production memory-backend guard still surfaces as an
4126/// `Err`, before any layer is applied.
4127fn build_submit_token_layer(
4128    config: &AutumnConfig,
4129    state: &AppState,
4130    is_production: bool,
4131) -> Result<Option<crate::security::SubmitTokenLayer>, RouterBuildError> {
4132    let cfg = &config.security.submit_token;
4133    if !cfg.enabled {
4134        return Ok(None);
4135    }
4136
4137    // Production guard for the resolved consumed-token backend. Submit tokens are
4138    // on by default, so the backend can land on the per-process memory store in
4139    // production, which cannot deduplicate submits across replicas. Mirrors
4140    // `fail_fast_on_invalid_idempotency_config`: an explicit
4141    // `[security.submit_token].backend = "memory"` in prod fails fast, while an
4142    // inherited default only warns, so upgrading Autumn never means "prod won't
4143    // boot without Redis".
4144    match cfg.production_memory_guard(config.idempotency.backend, is_production) {
4145        crate::security::config::SubmitTokenMemoryGuard::Ok => {}
4146        crate::security::config::SubmitTokenMemoryGuard::WarnInherited => {
4147            tracing::warn!(
4148                "[security.submit_token].backend resolved to the in-memory store in production \
4149                 (inherited from [idempotency].backend, which is unset or memory). \
4150                 Single-replica deployments are fine, but multi-replica deployments need a shared \
4151                 backend: configure [idempotency] with backend = \"redis\" (or set \
4152                 [security.submit_token].backend = \"redis\") so consumed tokens are shared across \
4153                 replicas — otherwise a duplicate submit can slip through on a different replica."
4154            );
4155        }
4156        crate::security::config::SubmitTokenMemoryGuard::FailExplicit => {
4157            return Err(RouterBuildError::InvalidSubmitTokenBackend(
4158                "the in-memory submit-token backend is not safe for multi-replica production use. \
4159                 Set `[security.submit_token].backend = \"redis\"` in autumn.toml (it reuses the \
4160                 [idempotency.redis] connection settings), or remove the explicit `backend` \
4161                 override to inherit `[idempotency].backend`."
4162                    .to_owned(),
4163            ));
4164        }
4165    }
4166
4167    let ttl = Duration::from_secs(cfg.ttl_secs);
4168    // Backend selection: an explicit `[security.submit_token].backend` wins;
4169    // otherwise inherit `[idempotency].backend` so a Redis-configured app shares
4170    // one consumed-token store across replicas by default (issue #1360), while a
4171    // dev app on the default memory idempotency backend keeps memory tokens.
4172    // `resolved_backend` is the single source of truth so this cannot drift from
4173    // `build_idempotency_layers`.
4174    let backend = cfg.resolved_backend(config.idempotency.backend);
4175    let store = build_idempotency_store(backend, config, state, ttl, "submit_token")?;
4176
4177    let mut layer = crate::security::SubmitTokenLayer::new(store, cfg)
4178        .with_max_scan_bytes(config.security.upload.max_request_size_bytes);
4179    for endpoint in &config.security.webhooks.endpoints {
4180        layer = layer.with_exempt_path(&endpoint.path);
4181    }
4182    #[cfg(feature = "mail")]
4183    if config.mail.should_mount_unsubscribe_endpoint() {
4184        layer = layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
4185    }
4186    tracing::info!(
4187        backend = ?backend,
4188        inherited = cfg.backend.is_none(),
4189        ttl_secs = cfg.ttl_secs,
4190        "One-time submit-token protection enabled"
4191    );
4192    Ok(Some(layer))
4193}
4194
4195/// Build the CAPTCHA/bot-protection layer, or `None` when it is disabled.
4196///
4197/// Called only by [`apply_middleware`], which places it in the composed ingress
4198/// stack rather than spending its own `Router::layer` call — and therefore its
4199/// own nesting level — on it (issue #2193). The former
4200/// `apply_bot_protection_middleware` router wrapper was removed with its last
4201/// caller.
4202fn build_bot_protection_layer(
4203    config: &AutumnConfig,
4204) -> Option<crate::security::captcha::BotProtectionLayer> {
4205    if config.bot_protection.enabled {
4206        // Use the dedicated captcha_exempt_paths list — NOT csrf.exempt_paths —
4207        // so that a route exempt from CSRF for non-cookie auth reasons does not
4208        // automatically bypass bot-protection as well.
4209        let mut exempt = config.security.captcha_exempt_paths.clone();
4210        for endpoint in &config.security.webhooks.endpoints {
4211            exempt.push(endpoint.path.clone());
4212        }
4213        // One-click unsubscribe POSTs carry no CAPTCHA token; exempt the
4214        // framework-owned endpoint when mounted.
4215        #[cfg(feature = "mail")]
4216        if config.mail.should_mount_unsubscribe_endpoint() {
4217            exempt.push(crate::mail::UNSUBSCRIBE_PATH.to_owned());
4218        }
4219        let layer =
4220            crate::security::captcha::BotProtectionLayer::from_config(&config.bot_protection)
4221                .with_max_scan_bytes(config.security.upload.max_request_size_bytes)
4222                .with_exempt_paths(exempt);
4223        tracing::info!(
4224            provider = ?config.bot_protection.provider,
4225            dev_bypass = config.bot_protection.dev_bypass,
4226            "Bot protection (CAPTCHA) enabled"
4227        );
4228        Some(layer)
4229    } else {
4230        None
4231    }
4232}
4233
4234async fn populate_rate_limit_principal(
4235    axum::extract::State(state): axum::extract::State<AppState>,
4236    mut req: axum::extract::Request,
4237    next: axum::middleware::Next,
4238) -> axum::response::Response {
4239    // Populate `RateLimitPrincipal` from the verified session identity only.
4240    //
4241    // Deliberately no fallback to a raw `Authorization` header: this shim runs as
4242    // a global layer outer to route-scoped auth (`RequireApiToken`), so any
4243    // bearer token visible here is unverified and attacker-controlled. Keying the
4244    // limiter on it would let a caller rotate the token to mint unlimited buckets
4245    // or forge another user's principal to exhaust theirs. With no verified
4246    // principal, the limiter's `extract_key` falls back to IP keying, the safe
4247    // default. API-token routes that want per-principal limiting should place a
4248    // `RateLimitLayer` inner to `RequireApiToken`, which sets the verified
4249    // principal id (see `RequireApiTokenService::call`).
4250    if let Some(session) = req.extensions().get::<crate::session::Session>() {
4251        let auth_session_key = state.auth_session_key();
4252        // The operator's id while impersonating, so their throttle events and
4253        // budget stay their own.
4254        if let Some(user_id) =
4255            crate::auth::impersonation::throttle_principal_id(session, auth_session_key).await
4256        {
4257            req.extensions_mut()
4258                .insert(crate::security::RateLimitPrincipal(user_id));
4259        }
4260    }
4261    next.run(req).await
4262}
4263
4264/// Kept as a router-level wrapper for the `/mcp` envelope; the main ingress
4265/// stack composes the layer directly (see `apply_middleware`).
4266#[cfg(feature = "mcp")]
4267fn apply_trusted_proxies_middleware<S>(
4268    router: axum::Router<S>,
4269    config: &AutumnConfig,
4270) -> axum::Router<S>
4271where
4272    S: Clone + Send + Sync + 'static,
4273{
4274    router.layer(build_trusted_proxies_layer(config))
4275}
4276
4277/// Build the trusted-proxy resolution layer.
4278///
4279/// **Unconditional** — the `if` below gates only the log line; the layer itself
4280/// is always installed, because `ResolvedClientIdentity` must be stamped for
4281/// every request whether or not any proxy ranges are configured. Split out of
4282/// [`apply_trusted_proxies_middleware`] so the layer can join the composed
4283/// ingress stack rather than costing its own nesting level (issue #2193).
4284fn build_trusted_proxies_layer(config: &AutumnConfig) -> crate::security::TrustedProxiesLayer {
4285    let tp = &config.security.trusted_proxies;
4286    if tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some() {
4287        tracing::info!(
4288            ranges = ?tp.ranges,
4289            trusted_hops = ?tp.trusted_hops,
4290            "Centralized trusted-proxy resolution enabled"
4291        );
4292    }
4293    crate::security::TrustedProxiesLayer::from_config(tp)
4294}
4295
4296/// Kept as a router-level wrapper for the `/mcp` envelope and this module's
4297/// unit tests; the main ingress stack composes the layer directly (see
4298/// `apply_middleware`).
4299#[cfg(any(test, feature = "mcp"))]
4300fn apply_rate_limit_middleware(
4301    mut router: axum::Router<AppState>,
4302    config: &AutumnConfig,
4303    state: &AppState,
4304) -> axum::Router<AppState> {
4305    let (limiter, principal_keying) = build_rate_limit_layers(config, state);
4306    if let Some(limiter) = limiter {
4307        router = router.layer(limiter);
4308    }
4309    // Applied second, so it is OUTER to the limiter on ingress: the principal
4310    // must be populated before `extract_key` runs.
4311    if principal_keying {
4312        router = router.layer(axum::middleware::from_fn_with_state(
4313            state.clone(),
4314            populate_rate_limit_principal,
4315        ));
4316    }
4317    router
4318}
4319
4320/// Build the rate-limit layer, plus a flag for whether the
4321/// authenticated-principal keying shim is needed.
4322///
4323/// Returns `(None, false)` when rate limiting is disabled. Split out of
4324/// [`apply_rate_limit_middleware`] so both layers can join the composed ingress
4325/// stack rather than costing two more nesting levels (issue #2193). The shim is
4326/// returned as a flag rather than a layer because it is an
4327/// `axum::middleware::from_fn_with_state` closure whose type cannot be named
4328/// across a function boundary; the caller constructs it in place.
4329fn build_rate_limit_layers(
4330    config: &AutumnConfig,
4331    state: &AppState,
4332) -> (Option<crate::security::RateLimitLayer>, bool) {
4333    if config.security.rate_limit.enabled {
4334        let tp = &config.security.trusted_proxies;
4335        let rl = &config.security.rate_limit;
4336        let has_top_level_proxy_config =
4337            tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some();
4338        // Preserve explicit rate-limit proxy config (legacy fields). The shared
4339        // top-level resolver is only injected when the rate-limit section carries
4340        // no proxy config of its own, preventing dev defaults from silently
4341        // overriding an operator's explicit security.rate_limit.trusted_proxies.
4342        let has_rate_limit_proxy_config =
4343            rl.trust_forwarded_headers || !rl.trusted_proxies.is_empty();
4344        // The framework default limiter shares its bucket with the MCP `/mcp`
4345        // envelope limiter (both built here), so it honors `RateLimitEnvelopeCounted`
4346        // to avoid double-counting an already-charged `tools/call`. User-installed
4347        // limiters don't, so MCP replays still consume their per-route buckets.
4348        let mut layer = crate::security::RateLimitLayer::from_config(rl)
4349            .honoring_mcp_exempt()
4350            .with_clock(state.clock.clone());
4351        if has_top_level_proxy_config && !has_rate_limit_proxy_config {
4352            let resolver = crate::security::ProxyResolver::from_config(tp);
4353            layer = layer.with_proxy_resolver(resolver);
4354        }
4355        tracing::info!(
4356            rps = config.security.rate_limit.requests_per_second,
4357            burst = config.security.rate_limit.burst,
4358            "Rate limiting enabled"
4359        );
4360        let principal_keying = config.security.rate_limit.key_strategy
4361            == crate::security::KeyStrategy::AuthenticatedPrincipal;
4362        (Some(layer), principal_keying)
4363    } else {
4364        (None, false)
4365    }
4366}
4367
4368/// Kept as a router-level wrapper for this module's unit tests; the main
4369/// ingress stack composes the layer directly (see `apply_middleware`).
4370#[cfg(test)]
4371fn apply_upload_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
4372where
4373    S: Clone + Send + Sync + 'static,
4374{
4375    let (body_limit, upload_config) = build_upload_layers(config);
4376    // NOTE: this REPRODUCES the relative order `apply_middleware`'s `inner_stack`
4377    // encodes (extension inserter OUTER to the body limit); it does not share it.
4378    // Keep the two in sync. Applied inner-first here, because consecutive
4379    // `Router::layer` calls put the LAST one outermost — the opposite of a tuple.
4380    router
4381        .layer(body_limit)
4382        .layer(axum::Extension(upload_config))
4383}
4384
4385/// Resolve the two always-applied upload guards: the global body-size cap and
4386/// the [`UploadConfig`](crate::security::config::UploadConfig) the `Multipart`
4387/// extractor reads per-file limits from.
4388///
4389/// Split out of [`apply_upload_middleware`] so [`apply_middleware`] can place
4390/// both in the composed ingress stack (issue #2193).
4391fn build_upload_layers(
4392    config: &AutumnConfig,
4393) -> (
4394    axum::extract::DefaultBodyLimit,
4395    crate::security::config::UploadConfig,
4396) {
4397    let upload_config = config.security.upload.clone();
4398    let max_request_size = upload_config.max_request_size_bytes;
4399    tracing::info!(
4400        max_request_size_bytes = max_request_size,
4401        max_file_size_bytes = upload_config.max_file_size_bytes,
4402        allowed_mime_types = ?upload_config.allowed_mime_types,
4403        "Request body size limits enabled (applies to all content types)"
4404    );
4405    // The global cap covers JSON, form, raw bytes, and multipart; the Multipart
4406    // extractor further refines it per the UploadConfig extension. The config is
4407    // handed back as a plain value: callers install it with `axum::Extension`,
4408    // whose `AddExtension` service inserts it into request extensions directly —
4409    // the same effect as the `axum::middleware::from_fn` that used to do it, but
4410    // without that wrapper's per-request boxed future and boxed `Next`
4411    // (issue #2193).
4412    (
4413        axum::extract::DefaultBodyLimit::max(max_request_size),
4414        upload_config,
4415    )
4416}
4417
4418/// Exact-match health/probe paths that must always bypass admission-style
4419/// gates (maintenance mode, the startup barrier, load shedding): the
4420/// compat health endpoint plus the `/live`, `/ready`, `/startup` lifecycle
4421/// probes and the actuator's own `/health` alias. Callers additionally
4422/// exempt the whole actuator prefix (`with_health_prefix`), since these
4423/// gates are keyed on exact paths, not prefixes.
4424fn probe_bypass_paths(config: &AutumnConfig) -> Vec<String> {
4425    vec![
4426        config.health.path.clone(),
4427        config.health.live_path.clone(),
4428        config.health.ready_path.clone(),
4429        config.health.startup_path.clone(),
4430        crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
4431    ]
4432}
4433
4434/// Build the [`MaintenanceLayer`](crate::middleware::maintenance::MaintenanceLayer)
4435/// from config + state, with the health/probe paths that always bypass the gate.
4436///
4437/// Shared by [`apply_middleware`] (direct routes) and the late-mounted `/mcp`
4438/// envelope so both return the documented `503` identically when maintenance
4439/// mode is active — the `/mcp` router is merged after `apply_middleware`, so
4440/// without an explicit layer its `initialize`/`tools/list` would keep serving
4441/// the catalog during maintenance.
4442fn build_maintenance_layer(
4443    config: &AutumnConfig,
4444    state: &AppState,
4445) -> crate::middleware::maintenance::MaintenanceLayer {
4446    let maintenance_state = state
4447        .extension::<crate::maintenance::MaintenanceState>()
4448        .map(|s| (*s).clone())
4449        .unwrap_or_default();
4450    crate::middleware::maintenance::MaintenanceLayer::new(maintenance_state)
4451        .with_health_prefix(config.actuator.prefix.clone())
4452        .with_probe_paths(probe_bypass_paths(config))
4453}
4454
4455/// Build the admission-control ([`LoadShedLayer`](crate::middleware::LoadShedLayer))
4456/// layer from config, or `None` when `server.max_concurrent_requests` is unset
4457/// or `0` — the default, preserving today's unlimited behavior with effectively
4458/// zero overhead. In [`apply_middleware`] the `None` case goes through
4459/// `tower::util::option_layer`, contributing an `Either` branch that forwards
4460/// straight to the inner service: no allocation, no `Route` box, no nesting
4461/// level. The `/mcp` envelope still installs nothing at all.
4462///
4463/// Reuses the same probe/actuator bypass list as [`build_maintenance_layer`]
4464/// so health/liveness/readiness probes are never shed under load (#1006).
4465///
4466/// With `server.admission.mode = "adaptive"` the layer is always installed.
4467/// The static ceiling, if any, is the default `max_limit` (#3068).
4468fn build_load_shed_layer(
4469    config: &AutumnConfig,
4470    state: &AppState,
4471) -> Option<crate::middleware::LoadShedLayer> {
4472    // The ceiling is either hand-set, sourced from the committed capacity
4473    // contract (#1733), or the profile default (`prod`, #3057).
4474    // `resolve_admission_limit_with_default` owns that precedence — and owns
4475    // falling back to the profile default (never to a lower ceiling) on every
4476    // contract problem, so a stale or missing lockfile can never shed every
4477    // request on the way up.
4478    let resolved = crate::capacity::resolve_configured_admission_limit_for_profile(
4479        config.server.max_concurrent_requests,
4480        config.server.capacity_contract.as_deref(),
4481        config.profile_admission_default(),
4482        config.profile.as_deref(),
4483    );
4484    let admission = &config.server.admission;
4485    let shares = admission.partition_shares().unwrap_or_else(|error| {
4486        // `AutumnConfig::validate` rejects this at boot; keep a router built
4487        // without it working.
4488        tracing::warn!(%error, "invalid server.admission.partitions; using the defaults");
4489        crate::admission::PartitionShares::default()
4490    });
4491    if admission.mode == crate::config::AdmissionMode::Adaptive {
4492        match crate::admission::AdaptiveLimiter::from_config(admission, resolved.limit()) {
4493            Ok(limiter) => {
4494                tracing::info!(
4495                    algorithm = ?admission.algorithm,
4496                    initial_limit = limiter.limit(),
4497                    "admission control uses an adaptive limit"
4498                );
4499                return Some(finish_load_shed_layer(
4500                    crate::middleware::LoadShedLayer::adaptive(limiter, state.metrics.clone()),
4501                    shares,
4502                    config,
4503                ));
4504            }
4505            Err(error) => tracing::warn!(
4506                %error,
4507                "invalid server.admission limits; using the static ceiling"
4508            ),
4509        }
4510    }
4511    let limit = resolved.limit()?;
4512    match resolved {
4513        crate::capacity::AdmissionLimit::Contract(_) => tracing::info!(
4514            limit,
4515            source = resolved.source(),
4516            contract = config
4517                .server
4518                .capacity_contract
4519                .as_deref()
4520                .unwrap_or_default(),
4521            "admission control sourced from the committed capacity contract"
4522        ),
4523        crate::capacity::AdmissionLimit::ProfileDefault(_) => tracing::info!(
4524            limit,
4525            source = resolved.source(),
4526            "admission control uses the profile default ceiling; set \
4527             server.max_concurrent_requests to tune it, or 0 to turn it off"
4528        ),
4529        _ => {}
4530    }
4531    // Mirror CORS headers onto a shed 503 the same way the timeout middleware
4532    // does for the main stack (`mirror_cors = true` there): this layer sits
4533    // outside `CorsLayer` on direct routes, so without mirroring a
4534    // cross-origin browser client sees an opaque CORS failure instead of a
4535    // readable 503. Harmless (but redundant) at the `/mcp` mount point, since
4536    // that shares this same layer instance yet sits *inside* its own
4537    // `CorsLayer`, which overwrites these headers with its own regardless.
4538    Some(finish_load_shed_layer(
4539        crate::middleware::LoadShedLayer::new(limit, state.metrics.clone()),
4540        shares,
4541        config,
4542    ))
4543}
4544
4545/// Add the partitions, probe exemptions and CORS mirroring that both
4546/// admission modes share.
4547fn finish_load_shed_layer(
4548    layer: crate::middleware::LoadShedLayer,
4549    shares: crate::admission::PartitionShares,
4550    config: &AutumnConfig,
4551) -> crate::middleware::LoadShedLayer {
4552    let cors =
4553        (!config.cors.allowed_origins.is_empty()).then(|| std::sync::Arc::new(config.cors.clone()));
4554    layer
4555        .with_partitions(shares)
4556        .with_health_prefix(config.actuator.prefix.clone())
4557        .with_probe_paths(probe_bypass_paths(config))
4558        .with_cors(cors)
4559}
4560
4561/// Build the shadow-mirroring layer, or `None` when `[shadow]` is off.
4562///
4563/// Also installs the [`ShadowHandle`](crate::shadow::ShadowHandle) into the
4564/// app state's runtime extension map, which is what
4565/// `{actuator-prefix}/shadow` reads. A replica with mirroring off installs
4566/// nothing and that endpoint reports a disabled mirror.
4567///
4568/// The clock and entropy source are taken from the app state rather than read
4569/// ambiently, so a [`#[sim_test]`](crate::sim_test) controls both the sampling
4570/// decision and the recorded timestamps.
4571/// Paths that fault injection never faults: the probes and the actuator.
4572fn fault_injection_exempt_paths(config: &AutumnConfig) -> Vec<String> {
4573    let mut paths = probe_bypass_paths(config);
4574    paths.extend(crate::actuator::actuator_endpoint_paths(
4575        &config.actuator.prefix,
4576        config.actuator.sensitive,
4577        config.actuator.prometheus,
4578    ));
4579    paths
4580}
4581
4582/// Install both fault injection layers again outside the static-first
4583/// middleware (the SSG/ISG path), on the injector that `apply_middleware`
4584/// built. A cached page is outside the request timeout, so the route layer
4585/// caps injected latency at it.
4586fn install_outer_fault_injection(
4587    router: axum::Router<AppState>,
4588    config: &AutumnConfig,
4589    state: &AppState,
4590) -> axum::Router<AppState> {
4591    let Some(handle) = state.extension::<crate::fault_injection::FaultInjection>() else {
4592        return router;
4593    };
4594    let deadline = config
4595        .server
4596        .timeouts
4597        .request_timeout_ms
4598        .filter(|ms| *ms > 0)
4599        .map(std::time::Duration::from_millis);
4600    router.layer(handle.layers(deadline))
4601}
4602
4603fn build_shadow_layer(
4604    config: &AutumnConfig,
4605    state: &AppState,
4606) -> Option<crate::shadow::ShadowMirrorLayer> {
4607    if !config.shadow.is_active() {
4608        return None;
4609    }
4610    let target_base = config.shadow.target_base()?.to_owned();
4611
4612    // The real transport needs an HTTP client. Without the `http-client`
4613    // feature there is nothing to dial the candidate with, so say so once and
4614    // leave the ingress stack untouched rather than pretending to mirror.
4615    #[cfg(not(feature = "http-client"))]
4616    {
4617        tracing::warn!(
4618            "[shadow] enabled but this build has no `http-client` feature; \
4619             traffic mirroring is inactive"
4620        );
4621        // Install an inactive handle anyway, so the actuator can say "this
4622        // replica has a target configured but cannot mirror" rather than
4623        // reporting the same thing as a replica that never configured
4624        // `[shadow]` at all.
4625        state.insert_extension(crate::shadow::ShadowHandle::inactive(target_base));
4626        return None;
4627    }
4628
4629    #[cfg(feature = "http-client")]
4630    {
4631        let timeout = std::time::Duration::from_millis(config.shadow.timeout_ms);
4632        let transport = match crate::shadow::transport::HttpShadowTransport::new(
4633            timeout,
4634            config.shadow.max_body_bytes,
4635        ) {
4636            Ok(transport) => std::sync::Arc::new(transport),
4637            Err(error) => {
4638                tracing::warn!(
4639                    %error,
4640                    "[shadow] could not build the mirroring HTTP client; traffic mirroring \
4641                     is inactive"
4642                );
4643                return None;
4644            }
4645        };
4646
4647        let registry = crate::shadow::ShadowRegistry::new(config.shadow.max_records);
4648        state.insert_extension(crate::shadow::ShadowHandle {
4649            registry: registry.clone(),
4650            enabled: true,
4651            target: Some(target_base.clone()),
4652        });
4653
4654        // One `[log] filter_parameters` list governs the access log, error
4655        // pages, failure capsules, and now the recorded divergence samples.
4656        let mut filter_parameters = config.log.filter_parameters.clone();
4657        filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
4658        filter_parameters.extend(crate::confidential::registered_confidential_column_names());
4659        let filter = Arc::new(crate::log::filter::ParameterFilter::new(
4660            &filter_parameters,
4661            &config.log.unfilter_parameters,
4662        ));
4663
4664        // Exempt the actuator's actual mounted paths as well as its prefix. With
4665        // `[actuator] prefix = "/"` the endpoints mount at the root, where no
4666        // prefix test can tell them from application routes; mirroring an
4667        // operator's `/metrics` poll would add candidate load and permanent false
4668        // divergences from a per-replica payload. `actuator_endpoint_paths` is the
4669        // same source the startup barrier seeds its allow-list from, so this
4670        // cannot drift from what is mounted.
4671        let mut exempt_paths = probe_bypass_paths(config);
4672        exempt_paths.extend(crate::actuator::actuator_endpoint_paths(
4673            &config.actuator.prefix,
4674            config.actuator.sensitive,
4675            config.actuator.prometheus,
4676        ));
4677
4678        let selector = crate::shadow::MirrorSelector::new(
4679            config.shadow.sample_rate,
4680            &config.shadow.routes,
4681            &config.actuator.prefix,
4682            &exempt_paths,
4683        );
4684
4685        tracing::info!(
4686            target = %target_base,
4687            sample_rate = config.shadow.sample_rate,
4688            routes = ?config.shadow.routes,
4689            "Shadow traffic mirroring enabled (GET/HEAD only)"
4690        );
4691
4692        Some(crate::shadow::ShadowMirrorLayer::new(
4693            crate::shadow::MirrorSettings {
4694                target_base,
4695                timeout,
4696                max_in_flight: config.shadow.max_in_flight,
4697                max_body_bytes: config.shadow.max_body_bytes,
4698                max_sample_bytes: config.shadow.max_sample_bytes,
4699            },
4700            selector,
4701            registry,
4702            transport,
4703            filter,
4704            state.entropy_arc(),
4705            state.clock_arc(),
4706        ))
4707    }
4708}
4709
4710/// Per-route timeout lookup table, keyed by the fully-qualified route template
4711/// (matching [`axum::extract::MatchedPath`]) and then by HTTP method, so an
4712/// override on one handler never bleeds onto sibling methods sharing the path
4713/// (e.g. `GET /items` vs `POST /items`). The nested layout also lets the
4714/// middleware resolve the deadline from a borrowed `&str` + `&Method`, avoiding
4715/// any allocation on exempt/disabled routes. Built once at router-assembly time
4716/// from each [`Route`]'s `timeout` field and shared (cheaply cloned) into the
4717/// global timeout middleware.
4718type RouteTimeoutTable = RouteAttrTable<crate::route::RouteTimeout>;
4719
4720/// A per-route attribute lookup table, keyed like [`RouteTimeoutTable`]:
4721/// route template (as [`axum::extract::MatchedPath`]), then HTTP method.
4722pub type RouteAttrTable<T> =
4723    std::sync::Arc<std::collections::HashMap<String, std::collections::HashMap<http::Method, T>>>;
4724
4725/// Error surfaced as the cause of the `503` when an inbound request exceeds its
4726/// wall-clock deadline. Carried into [`crate::error::AutumnError::service_unavailable`]
4727/// so the response flows through the standard Problem Details / error-page stack
4728/// (JSON for API clients, HTML for browsers) instead of a raw tower `BoxError`.
4729#[derive(Debug)]
4730struct RequestDeadlineExceeded {
4731    timeout_ms: u64,
4732}
4733
4734impl std::fmt::Display for RequestDeadlineExceeded {
4735    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4736        write!(
4737            f,
4738            "the server did not produce a response within the configured {}ms deadline",
4739            self.timeout_ms
4740        )
4741    }
4742}
4743
4744impl std::error::Error for RequestDeadlineExceeded {}
4745
4746/// Response-extension marker stamped on the `503` produced when the inbound
4747/// request-timeout deadline cancels the handler future.
4748///
4749/// The session layer is applied *outer* to the timeout layer, so when the
4750/// deadline fires it observes the (still-shared) `Session` handle as dirty even
4751/// though the handler was cancelled mid-flight. Persisting that partial mutation
4752/// would commit half-finished state — e.g. a login that set the user id but
4753/// never finished — so `SessionService` checks for this marker and skips the
4754/// dirty save/destroy when it is present. Only the timeout handler sets it, so
4755/// ordinary handler-produced `503`s still persist session changes as before.
4756#[derive(Clone, Copy, Debug)]
4757pub struct RequestDeadlineCancelled;
4758
4759/// Build the per-route timeout override table from the top-level routes and any
4760/// scoped (prefixed) groups. Group routes are keyed by their nested template so
4761/// the runtime lookup matches [`axum::extract::MatchedPath`].
4762///
4763/// `config` is only consulted (behind the `i18n` feature) to expand each
4764/// locale-prefix-eligible entry under `/{locale}{path}` too — see
4765/// [`expand_route_timeout_table_for_locale_prefix`].
4766fn build_route_timeout_table(
4767    route_list: &[Route],
4768    scoped_groups: &[ScopedGroup],
4769    config: &AutumnConfig,
4770) -> RouteTimeoutTable {
4771    // `Inherit` carries no override, so it never needs a table entry.
4772    build_route_attr_table(route_list, scoped_groups, config, |route| {
4773        (!matches!(route.timeout, crate::route::RouteTimeout::Inherit)).then_some(route.timeout)
4774    })
4775}
4776
4777/// Build the per-route criticality table (issue #3068). Routes with the
4778/// default criticality get no entry, so an app without `criticality = ...`
4779/// gets an empty table and no `CriticalityLayer`.
4780fn build_route_criticality_table(
4781    route_list: &[Route],
4782    scoped_groups: &[ScopedGroup],
4783    config: &AutumnConfig,
4784) -> RouteAttrTable<crate::admission::Criticality> {
4785    build_route_attr_table(route_list, scoped_groups, config, |route| {
4786        (route.criticality != crate::admission::Criticality::Default).then_some(route.criticality)
4787    })
4788}
4789
4790/// Build a [`RouteAttrTable`] from the values `value` returns. A route for
4791/// which `value` returns `None` gets no entry.
4792fn build_route_attr_table<T: Copy>(
4793    route_list: &[Route],
4794    scoped_groups: &[ScopedGroup],
4795    #[cfg_attr(not(feature = "i18n"), allow(unused_variables))] config: &AutumnConfig,
4796    value: impl Fn(&Route) -> Option<T>,
4797) -> RouteAttrTable<T> {
4798    let mut table: std::collections::HashMap<String, std::collections::HashMap<http::Method, T>> =
4799        std::collections::HashMap::new();
4800    let mut insert = |path: String, method: &http::Method, timeout: T| {
4801        // Key by (path, effective request method) so an override on one handler
4802        // never bleeds onto sibling methods sharing the template, while still
4803        // resolving through a method alias. `RequestTimeoutService` looks up
4804        // `req.method()`, which differs from the declared method twice:
4805        //   - axum serves `HEAD` through a `#[get]` handler, so a GET override
4806        //     must also cover HEAD.
4807        //   - `#[ws]` records the synthetic `WS` method but mounts a `GET`
4808        //     handler, so the upgrade arrives as GET.
4809        // Each (effective method, path) pair is unique across the router, so
4810        // `insert` cannot lose a competing entry.
4811        let by_method = table.entry(path).or_default();
4812        // Key under the same effective verb the router mounts the handler as, so
4813        // a `#[ws]` override lands on the GET the upgrade actually arrives as
4814        // (shared with the duplicate-route preflight via `effective_mount_method`
4815        // so the two mappings can never drift).
4816        by_method.insert(effective_mount_method(method), timeout);
4817        // A real `#[get]` is also served for HEAD in axum; a WS upgrade is not,
4818        // so only expand HEAD for a genuine GET (not the WS→GET alias).
4819        if *method == http::Method::GET {
4820            by_method.insert(http::Method::HEAD, timeout);
4821        }
4822        // A WS route also takes the HTTP/2 upgrade, which arrives as `CONNECT`.
4823        if method.as_str() == "WS" {
4824            by_method.insert(http::Method::CONNECT, timeout);
4825        }
4826    };
4827    for route in route_list {
4828        if let Some(v) = value(route) {
4829            insert(route.path.to_owned(), &route.method, v);
4830        }
4831    }
4832    for group in scoped_groups {
4833        for route in &group.routes {
4834            if let Some(v) = value(route) {
4835                insert(
4836                    join_nested_path(&group.prefix, route.path),
4837                    &route.method,
4838                    v,
4839                );
4840            }
4841        }
4842    }
4843    #[cfg(feature = "i18n")]
4844    expand_route_timeout_table_for_locale_prefix(&mut table, route_list, &config.i18n);
4845    std::sync::Arc::new(table)
4846}
4847
4848/// `Router::nest("/{locale}", ...)` mounts each locale under a *literal*
4849/// segment (`"/en"`, `"/es"`, ...), not an axum path parameter, so axum
4850/// reports `MatchedPath` for a locale-prefixed request as `/{locale}{path}`
4851/// verbatim (see `join_nested_path_matches_axum_matched_path`, which pins the
4852/// same literal-concatenation behavior for scoped groups). The base timeout
4853/// table above is built from `route_list`'s bare, unprefixed paths, so a
4854/// request that actually matched through a locale nest would never find its
4855/// override there — this duplicates every locale-prefix-eligible entry under
4856/// each supported locale's segment too (Codex review). Scoped-group routes are
4857/// deliberately excluded: they mount after locale-prefix nesting and are never
4858/// locale-prefixed themselves (see `scoped_group_routes_are_not_locale_prefixed`).
4859#[cfg(feature = "i18n")]
4860fn expand_route_timeout_table_for_locale_prefix<T: Clone>(
4861    table: &mut std::collections::HashMap<String, std::collections::HashMap<http::Method, T>>,
4862    route_list: &[Route],
4863    i18n: &crate::i18n::I18nConfig,
4864) {
4865    if !i18n.locale_prefix_enabled || i18n.supported_locales.is_empty() {
4866        return;
4867    }
4868    for route in route_list {
4869        if i18n
4870            .locale_prefix_exclude_exact
4871            .iter()
4872            .any(|p| p == route.path)
4873            || matches_locale_exclude_prefix(route.path, &i18n.locale_prefix_exclude)
4874        {
4875            continue;
4876        }
4877        let Some(by_method) = table.get(route.path).cloned() else {
4878            continue;
4879        };
4880        for locale in &i18n.supported_locales {
4881            let prefixed_path = if route.path == "/" {
4882                format!("/{locale}")
4883            } else {
4884                format!("/{locale}{}", route.path)
4885            };
4886            table
4887                .entry(prefixed_path)
4888                .or_default()
4889                .extend(by_method.clone());
4890        }
4891    }
4892}
4893
4894/// Apply the built-in inbound request timeout.
4895///
4896/// A single global layer enforces `config.server.timeouts.request_timeout_ms`
4897/// (the `prod` profile smart-defaults this to 30s) as a per-request wall-clock
4898/// deadline, with per-route overrides resolved from `route_timeouts` via the
4899/// matched route template. On expiry the handler returns a framework-standard
4900/// `503 Service Unavailable` (Problem Details JSON for API clients, the error
4901/// page for browsers — never a raw tower `BoxError`).
4902///
4903/// Streaming responses are exempt by construction: the deadline bounds the time
4904/// to produce the response head, not the duration of body streaming, so SSE and
4905/// chunked responses are never interrupted once the head is sent. Long-poll
4906/// handlers, which block *before* returning the head, are bound by the deadline
4907/// and must opt out via `timeout = "off"`. WebSocket routes inherit the deadline
4908/// ([`RouteTimeout::Inherit`](crate::route::RouteTimeout), emitted by `#[ws]`),
4909/// so it bounds a hung pre-upgrade handshake but never the established socket —
4910/// that future runs on a separate task via `on_upgrade` and is unbounded by
4911/// design.
4912///
4913/// The layer is a no-op (zero overhead) when the global timeout is disabled and
4914/// no route declares an `Override`.
4915///
4916/// `mirror_cors` makes a synthesized 503 carry the CORS response headers a
4917/// normal response would. Set it for the main ingress stack, where this layer
4918/// sits *outside* `CorsLayer` (see the order in `apply_middleware`) so the 503
4919/// never flows back through it; leave it off for the `/mcp` envelope, whose
4920/// timeout is applied *inner* to its `CorsLayer` and whose 503 is therefore
4921/// already CORS-readable.
4922///
4923/// Kept as a router-level wrapper for the `/mcp` envelope and this module's
4924/// unit tests; the main ingress stack composes the layer directly (see
4925/// `apply_middleware`).
4926#[cfg(any(test, feature = "mcp"))]
4927fn apply_request_timeout_middleware(
4928    router: axum::Router<AppState>,
4929    config: &AutumnConfig,
4930    state: &AppState,
4931    route_timeouts: RouteTimeoutTable,
4932    mirror_cors: bool,
4933) -> axum::Router<AppState> {
4934    let Some(settings) = build_request_timeout_settings(config, state, route_timeouts, mirror_cors)
4935    else {
4936        return router;
4937    };
4938    // Both this envelope and `apply_middleware` install the SAME layer type now,
4939    // so there is nothing left to keep in sync: before #2214 each site had to
4940    // build its own `axum::middleware::from_fn` closure, because the closure's
4941    // type (and the opaque future it returned) could not be named across a
4942    // function boundary, and the two copies could silently drift.
4943    router.layer(RequestTimeoutLayer::new(settings))
4944}
4945
4946/// Everything [`RequestTimeoutService`] needs, resolved once at
4947/// router-assembly time.
4948///
4949/// Held behind an `Arc` by [`RequestTimeoutLayer`] because the produced service
4950/// is cloned on the request path: every field is individually cheap to clone
4951/// (`RouteTimeoutTable` and the CORS snapshot are already `Arc`s), but one
4952/// refcount bump for the whole struct beats four.
4953struct RequestTimeoutSettings {
4954    global: Option<Duration>,
4955    route_timeouts: RouteTimeoutTable,
4956    metrics: crate::middleware::MetricsCollector,
4957    cors: Option<std::sync::Arc<crate::config::CorsConfig>>,
4958    /// Read the caller's [`crate::deadline::DEADLINE_HEADER`] (issue #3058).
4959    accept_deadline_header: bool,
4960    /// The jitter source for the timeout `Retry-After` (seeded in a sim).
4961    entropy: Arc<dyn crate::entropy::Entropy>,
4962}
4963
4964/// Resolve the request-timeout settings, or `None` when no global timeout is
4965/// configured and no route declares an override.
4966///
4967/// In that case [`apply_request_timeout_middleware`] (the `/mcp` envelope)
4968/// installs no layer at all, and [`apply_middleware`] contributes an
4969/// `option_layer` `Either` branch that forwards straight to the inner service —
4970/// no allocation, no `Route` box, no nesting level. Either way the documented
4971/// zero-overhead default holds.
4972///
4973/// Split out of [`apply_request_timeout_middleware`] so [`apply_middleware`] can
4974/// place the layer in the composed ingress stack (issue #2193).
4975fn build_request_timeout_settings(
4976    config: &AutumnConfig,
4977    state: &AppState,
4978    route_timeouts: RouteTimeoutTable,
4979    mirror_cors: bool,
4980) -> Option<RequestTimeoutSettings> {
4981    let global = config
4982        .server
4983        .timeouts
4984        .request_timeout_ms
4985        .filter(|ms| *ms > 0)
4986        .map(std::time::Duration::from_millis);
4987    let has_override = route_timeouts
4988        .values()
4989        .flat_map(std::collections::HashMap::values)
4990        .any(|t| matches!(t, crate::route::RouteTimeout::Override(_)));
4991    // Adaptive admission must see `timeout = "off"` routes, to skip their
4992    // latency samples (#3068).
4993    let has_off_for_admission = config.server.admission.mode
4994        == crate::config::AdmissionMode::Adaptive
4995        && route_timeouts
4996            .values()
4997            .flat_map(std::collections::HashMap::values)
4998            .any(|t| matches!(t, crate::route::RouteTimeout::Disabled));
4999    if global.is_none() && !has_override && !has_off_for_admission {
5000        return None;
5001    }
5002    if let Some(duration) = global {
5003        tracing::info!(
5004            timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
5005            "Inbound request timeout enabled"
5006        );
5007    }
5008    // Snapshot the CORS config once iff we must mirror it onto timeout 503s and
5009    // any origin is configured (otherwise `CorsLayer` itself is absent).
5010    let cors = (mirror_cors && !config.cors.allowed_origins.is_empty())
5011        .then(|| std::sync::Arc::new(config.cors.clone()));
5012    Some(RequestTimeoutSettings {
5013        global,
5014        route_timeouts,
5015        metrics: state.metrics.clone(),
5016        cors,
5017        accept_deadline_header: config.server.timeouts.accept_deadline_header,
5018        entropy: state.entropy_arc(),
5019    })
5020}
5021
5022/// Tower [`Layer`](tower::Layer) applying the framework's per-request deadline.
5023///
5024/// Replaces the `axum::middleware::from_fn` closure both call sites used to
5025/// build. `from_fn` had to `Box::pin` the async block it wrapped — one heap
5026/// allocation per request, sized by the whole downstream continuation the block
5027/// captured across its `.await` — and clone its inner service to move it in
5028/// there. [`RequestTimeoutFuture`] holds `tokio::time::Timeout<S::Future>`
5029/// (itself a named type) in place instead, so a deadline costs no allocation
5030/// and an exempt route costs not even a timer (issue #2214).
5031#[derive(Clone)]
5032pub struct RequestTimeoutLayer {
5033    settings: Arc<RequestTimeoutSettings>,
5034}
5035
5036impl RequestTimeoutLayer {
5037    fn new(settings: RequestTimeoutSettings) -> Self {
5038        Self {
5039            settings: Arc::new(settings),
5040        }
5041    }
5042}
5043
5044impl<S> tower::Layer<S> for RequestTimeoutLayer {
5045    type Service = RequestTimeoutService<S>;
5046
5047    fn layer(&self, inner: S) -> Self::Service {
5048        RequestTimeoutService {
5049            inner,
5050            settings: Arc::clone(&self.settings),
5051        }
5052    }
5053}
5054
5055/// Tower [`Service`](tower::Service) produced by [`RequestTimeoutLayer`].
5056#[derive(Clone)]
5057pub struct RequestTimeoutService<S> {
5058    inner: S,
5059    settings: Arc<RequestTimeoutSettings>,
5060}
5061
5062impl<S> RequestTimeoutService<S> {
5063    /// The deadline that applies to `req`, or `None` when it is exempt.
5064    ///
5065    /// Uses borrowed lookups throughout so an exempt or deadline-free route
5066    /// allocates nothing.
5067    fn deadline_for<B>(&self, req: &Request<B>) -> Option<Duration> {
5068        // Internal `autumn build` / ISR regeneration renders drive a
5069        // `#[static_get]` route directly via `oneshot` and tag the request with
5070        // `RenderDeadlineExempt` (there is no client connection whose deadline
5071        // should apply). Skip the deadline for these; live inbound requests to
5072        // the same route do not carry the marker and are bounded normally.
5073        if req
5074            .extensions()
5075            .get::<crate::static_gen::RenderDeadlineExempt>()
5076            .is_some()
5077        {
5078            return None;
5079        }
5080
5081        // Resolve the effective deadline from the matched route template +
5082        // method.
5083        let route_timeout = req
5084            .extensions()
5085            .get::<axum::extract::MatchedPath>()
5086            .map(axum::extract::MatchedPath::as_str)
5087            .and_then(|p| self.settings.route_timeouts.get(p))
5088            .and_then(|by_method| by_method.get(req.method()))
5089            .copied()
5090            .unwrap_or(crate::route::RouteTimeout::Inherit);
5091        let route_deadline = match route_timeout {
5092            crate::route::RouteTimeout::Disabled => None,
5093            crate::route::RouteTimeout::Override(d) => Some(d),
5094            crate::route::RouteTimeout::Inherit => self.settings.global,
5095        }?;
5096        // The caller's deadline header can only make the deadline shorter.
5097        let caller_deadline = self
5098            .settings
5099            .accept_deadline_header
5100            .then(|| req.headers().get(crate::deadline::DEADLINE_HEADER))
5101            .flatten()
5102            .and_then(crate::deadline::parse_header);
5103        Some(caller_deadline.map_or(route_deadline, |caller| caller.min(route_deadline)))
5104    }
5105
5106    /// `true` when the matched route has `timeout = "off"`.
5107    fn route_timeout_is_off<B>(&self, req: &Request<B>) -> bool {
5108        req.extensions()
5109            .get::<axum::extract::MatchedPath>()
5110            .and_then(|p| self.settings.route_timeouts.get(p.as_str()))
5111            .and_then(|by_method| by_method.get(req.method()))
5112            .is_some_and(|t| matches!(t, crate::route::RouteTimeout::Disabled))
5113    }
5114}
5115
5116impl<S> tower::Service<Request<axum::body::Body>> for RequestTimeoutService<S>
5117where
5118    S: tower::Service<Request<axum::body::Body>, Response = axum::response::Response>,
5119{
5120    type Response = S::Response;
5121    type Error = S::Error;
5122    type Future = RequestTimeoutFuture<S::Future>;
5123
5124    fn poll_ready(
5125        &mut self,
5126        cx: &mut std::task::Context<'_>,
5127    ) -> std::task::Poll<Result<(), Self::Error>> {
5128        self.inner.poll_ready(cx)
5129    }
5130
5131    fn call(&mut self, mut req: Request<axum::body::Body>) -> Self::Future {
5132        let Some(duration) = self.deadline_for(&req) else {
5133            // Exempt (disabled route, or global off with a non-Override route)
5134            // — no timer and no allocation on this hot path.
5135            if self.route_timeout_is_off(&req) {
5136                // Tell adaptive admission that this route's latency is not a
5137                // capacity signal (#3068).
5138                req.extensions_mut()
5139                    .insert(crate::admission::InboundDeadline::Off);
5140            }
5141            return RequestTimeoutFuture::Unbounded {
5142                inner: self.inner.call(req),
5143            };
5144        };
5145        // In a fault scope, take only the time left: a fault wait before this
5146        // layer used part of the request timeout (#3071).
5147        let duration =
5148            crate::fault_injection::time_left().map_or(duration, |left| duration.min(left));
5149        // Adaptive admission tells a cancel at this deadline (overload) from a
5150        // client that goes away (#3068).
5151        if let Some(at) = tokio::time::Instant::now().checked_add(duration) {
5152            req.extensions_mut()
5153                .insert(crate::admission::InboundDeadline::At(at));
5154        }
5155
5156        // A deadline is active: keep the matched route for the warn log. It is
5157        // the `Arc<str>` axum already holds, so this is a refcount bump, not a
5158        // copy of the template.
5159        let matched_path = req
5160            .extensions()
5161            .get::<axum::extract::MatchedPath>()
5162            .cloned();
5163        let request_id = req
5164            .extensions()
5165            .get::<crate::middleware::RequestId>()
5166            .cloned();
5167        // Capture the request Origin before `req` is consumed so a timeout 503
5168        // can mirror the CORS headers `CorsLayer` would have added (only when
5169        // mirroring is enabled — see `apply_request_timeout_middleware`).
5170        let cors_origin = self
5171            .settings
5172            .cors
5173            .as_ref()
5174            .and_then(|_| req.headers().get(http::header::ORIGIN).cloned());
5175
5176        // Start the clock and the deadline together, before the downstream
5177        // `call` chain runs, so `start` and the deadline measure the same
5178        // interval. The handler sees the deadline during `self.inner.call(req)`
5179        // (issue #3058), so synchronous work there counts against it, as it
5180        // did in the `from_fn` form, which armed both inside an async block
5181        // before that chain ran.
5182        //
5183        // `tokio::time::timeout` needs a runtime handle, so this `call` must run
5184        // inside a Tokio runtime. `tower::timeout::Timeout::call` has the same
5185        // requirement, and every driver in this crate reaches it through
5186        // `ServiceExt::oneshot`, which calls `call` only from inside a poll.
5187        //
5188        // The handler sees the deadline through `Deadline::current()` (issue
5189        // #3058). The task-local scope costs no allocation.
5190        let start = crate::time::ambient_instant();
5191        // A deadline of 0 (from the caller's header) leaves no time: answer
5192        // with the timeout response without calling the handler at all.
5193        if duration.is_zero() {
5194            return RequestTimeoutFuture::Elapsed {
5195                response: Some(deadline_exceeded_response(
5196                    &self.settings,
5197                    duration,
5198                    matched_path
5199                        .as_ref()
5200                        .map(axum::extract::MatchedPath::as_str),
5201                    request_id.as_ref(),
5202                    cors_origin.as_ref(),
5203                    start,
5204                )),
5205            };
5206        }
5207        let deadline = crate::deadline::Deadline::after(duration);
5208        let inner = deadline.sync_scope(|| self.inner.call(req));
5209
5210        RequestTimeoutFuture::Bounded {
5211            inner: crate::deadline::Bounded::until(deadline, inner),
5212            settings: Arc::clone(&self.settings),
5213            duration,
5214            matched_path,
5215            request_id,
5216            cors_origin,
5217            start,
5218        }
5219    }
5220}
5221
5222pin_project_lite::pin_project! {
5223    /// Future returned by [`RequestTimeoutService`].
5224    ///
5225    /// `Unbounded` is the exempt path and is literally the inner service's own
5226    /// future; `Bounded` wraps it in [`crate::deadline::Bounded`], which is a
5227    /// named type, so neither variant is heap-allocated. It checks the
5228    /// deadline before every poll of the handler, so a handler woken at or
5229    /// after the deadline does not run on (issue #3058).
5230    ///
5231    /// `Elapsed` exists to make the deadline actually *cancel*.
5232    /// `Bounded::poll` does not drop the future it wraps when the deadline
5233    /// passes — it just reports `Err(DeadlineExceeded)` — so a `Bounded` variant
5234    /// that returned the `503` in place would keep the whole cancelled handler
5235    /// tree (its database connection guards, its load-shed slot, its webhook
5236    /// [`ReplayKeyGuard`](crate::webhook)) alive until whatever owns *this*
5237    /// future is itself dropped, several response layers later. The `from_fn`
5238    /// form dropped it at the deadline, because its `tokio::time::timeout(..)`
5239    /// was a `match` scrutinee temporary. Transitioning to `Elapsed` restores
5240    /// that: `Pin::set` drops the old variant in place, so the handler tree is
5241    /// released before the `503` starts travelling back out.
5242    #[project = RequestTimeoutFutureProj]
5243    pub enum RequestTimeoutFuture<F> {
5244        Unbounded {
5245            #[pin]
5246            inner: F,
5247        },
5248        Bounded {
5249            #[pin]
5250            inner: crate::deadline::Bounded<F>,
5251            settings: Arc<RequestTimeoutSettings>,
5252            duration: Duration,
5253            matched_path: Option<axum::extract::MatchedPath>,
5254            request_id: Option<crate::middleware::RequestId>,
5255            cors_origin: Option<http::HeaderValue>,
5256            start: std::time::Instant,
5257        },
5258        Elapsed {
5259            response: Option<axum::response::Response>,
5260        },
5261    }
5262}
5263
5264impl<F, E> std::future::Future for RequestTimeoutFuture<F>
5265where
5266    F: std::future::Future<Output = Result<axum::response::Response, E>>,
5267{
5268    type Output = Result<axum::response::Response, E>;
5269
5270    #[allow(
5271        clippy::expect_used,
5272        reason = "unreachable: future not polled after Ready"
5273    )]
5274    fn poll(
5275        mut self: std::pin::Pin<&mut Self>,
5276        cx: &mut std::task::Context<'_>,
5277    ) -> std::task::Poll<Self::Output> {
5278        loop {
5279            let elapsed = match self.as_mut().project() {
5280                RequestTimeoutFutureProj::Unbounded { inner } => return inner.poll(cx),
5281                RequestTimeoutFutureProj::Bounded {
5282                    inner,
5283                    settings,
5284                    duration,
5285                    matched_path,
5286                    request_id,
5287                    cors_origin,
5288                    start,
5289                } => match std::task::ready!(inner.poll(cx)) {
5290                    Ok(response) => return std::task::Poll::Ready(response),
5291                    Err(crate::deadline::DeadlineExceeded) => Self::Elapsed {
5292                        response: Some(deadline_exceeded_response(
5293                            settings,
5294                            *duration,
5295                            matched_path
5296                                .as_ref()
5297                                .map(axum::extract::MatchedPath::as_str),
5298                            request_id.as_ref(),
5299                            cors_origin.as_ref(),
5300                            *start,
5301                        )),
5302                    },
5303                },
5304                RequestTimeoutFutureProj::Elapsed { response } => {
5305                    return std::task::Poll::Ready(Ok(response
5306                        .take()
5307                        .expect("RequestTimeoutFuture polled after completion")));
5308                }
5309            };
5310            // Drops the `Bounded` variant — and with it the cancelled handler
5311            // future the elapsed `Timeout` is still holding — before the `503`
5312            // leaves this layer.
5313            self.as_mut().set(elapsed);
5314        }
5315    }
5316}
5317
5318/// Build the `503` a request that blew its deadline receives, recording the
5319/// timeout metric and emitting the structured `autumn::timeout` warn on the way.
5320///
5321/// Split out of [`RequestTimeoutFuture::poll`] so that hot method stays a
5322/// dispatch and nothing else.
5323fn deadline_exceeded_response(
5324    settings: &RequestTimeoutSettings,
5325    duration: Duration,
5326    matched_path: Option<&str>,
5327    request_id: Option<&crate::middleware::RequestId>,
5328    cors_origin: Option<&http::HeaderValue>,
5329    start: std::time::Instant,
5330) -> axum::response::Response {
5331    let elapsed_ms = u64::try_from(
5332        crate::time::ambient_instant()
5333            .saturating_duration_since(start)
5334            .as_millis(),
5335    )
5336    .unwrap_or(u64::MAX);
5337    let route = matched_path.unwrap_or("<unmatched>");
5338    // Structured telemetry: route template + elapsed time so operators
5339    // can alert on the (already-counted) timeout event.
5340    tracing::warn!(
5341        target: "autumn::timeout",
5342        route = route,
5343        elapsed_ms = elapsed_ms,
5344        timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
5345        request_id = request_id.map(ToString::to_string),
5346        "inbound request exceeded deadline"
5347    );
5348    settings.metrics.record_request_timeout();
5349    // Return a 503 via the standard error type so the exception-filter
5350    // and error-page stack negotiate JSON vs HTML and enrich with the
5351    // request id — no manual Problem Details assembly, no raw BoxError.
5352    let mut response = crate::error::AutumnError::service_unavailable(RequestDeadlineExceeded {
5353        timeout_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
5354    })
5355    .into_response();
5356    // Tag the 503 so the outer session layer skips persisting any partial
5357    // session mutation the cancelled handler made before the deadline.
5358    response.extensions_mut().insert(RequestDeadlineCancelled);
5359    // Tell the client when to retry. Jitter spreads the retries (issue #3058).
5360    response.headers_mut().insert(
5361        http::header::RETRY_AFTER,
5362        http::HeaderValue::from(timeout_retry_after_secs(settings.entropy.as_ref())),
5363    );
5364    // This layer is outside `CorsLayer` in the main stack, so the 503
5365    // never passes back through it; mirror the CORS headers ourselves so
5366    // cross-origin browser clients can read the Problem Details body
5367    // instead of seeing an opaque CORS failure.
5368    if let Some(cors) = settings.cors.as_deref() {
5369        mirror_cors_headers(cors, cors_origin, &mut response);
5370    }
5371    response
5372}
5373
5374/// Build the store for `backend`. `label` names the config section in errors.
5375fn build_idempotency_store(
5376    backend: crate::config::IdempotencyBackend,
5377    config: &AutumnConfig,
5378    state: &AppState,
5379    ttl: Duration,
5380    label: &str,
5381) -> Result<std::sync::Arc<dyn IdempotencyStore>, RouterBuildError> {
5382    #[cfg(not(feature = "db"))]
5383    let _ = state;
5384    match backend {
5385        crate::config::IdempotencyBackend::Memory => {
5386            Ok(std::sync::Arc::new(MemoryIdempotencyStore::new(ttl)))
5387        }
5388        #[cfg(feature = "redis")]
5389        crate::config::IdempotencyBackend::Redis => {
5390            crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency)
5391                .map(|store| std::sync::Arc::new(store) as std::sync::Arc<dyn IdempotencyStore>)
5392                .map_err(RouterBuildError::InvalidIdempotencyBackend)
5393        }
5394        #[cfg(not(feature = "redis"))]
5395        crate::config::IdempotencyBackend::Redis => {
5396            let _ = config;
5397            Err(RouterBuildError::InvalidIdempotencyBackend(format!(
5398                "{label} backend 'redis' requires the autumn-web 'redis' feature \
5399                 flag; rebuild with --features redis or switch to backend = \"memory\""
5400            )))
5401        }
5402        #[cfg(feature = "db")]
5403        crate::config::IdempotencyBackend::Database => state.pool().map_or_else(
5404            || {
5405                Err(RouterBuildError::InvalidIdempotencyBackend(format!(
5406                    "{label} backend 'database' requires a configured database; \
5407                     set [database] url or switch to backend = \"memory\""
5408                )))
5409            },
5410            |pool| {
5411                Ok(std::sync::Arc::new(
5412                    crate::idempotency::DbIdempotencyStore::new(pool.clone(), ttl)
5413                        .with_legal_holds(state),
5414                ) as std::sync::Arc<dyn IdempotencyStore>)
5415            },
5416        ),
5417        #[cfg(not(feature = "db"))]
5418        crate::config::IdempotencyBackend::Database => {
5419            Err(RouterBuildError::InvalidIdempotencyBackend(format!(
5420                "{label} backend 'database' requires the autumn-web 'db' feature"
5421            )))
5422        }
5423    }
5424}
5425
5426/// Tell the operator when a request can outlive its in-flight lock. Then a
5427/// retry can run the handler while the first request still runs. Only a
5428/// handler that uses `IdempotencyTx::commit` on the database store is fenced.
5429fn warn_on_short_in_flight_ttl(config: &AutumnConfig) {
5430    let in_flight_secs = config.idempotency.in_flight_ttl_secs;
5431    match config.server.timeouts.request_timeout_ms {
5432        None => tracing::info!(
5433            in_flight_ttl_secs = in_flight_secs,
5434            "no request timeout is set; an idempotent request that runs longer than \
5435             idempotency.in_flight_ttl_secs can run twice"
5436        ),
5437        Some(timeout_ms) if in_flight_secs.saturating_mul(1000) < timeout_ms => tracing::warn!(
5438            in_flight_ttl_secs = in_flight_secs,
5439            request_timeout_ms = timeout_ms,
5440            "idempotency.in_flight_ttl_secs is shorter than the request timeout; \
5441             a retry can run while the first request still runs"
5442        ),
5443        Some(_) => {}
5444    }
5445}
5446
5447/// The lowest and highest `Retry-After` seconds on a timeout `503`.
5448const TIMEOUT_RETRY_AFTER_SECS: std::ops::RangeInclusive<u64> = 1..=3;
5449
5450/// A `Retry-After` value for a timeout `503`, from `entropy`. Each value in
5451/// [`TIMEOUT_RETRY_AFTER_SECS`] has about the same chance.
5452fn timeout_retry_after_secs(entropy: &dyn crate::entropy::Entropy) -> u64 {
5453    let (low, high) = TIMEOUT_RETRY_AFTER_SECS.into_inner();
5454    low + entropy.next_u64() % (high - low + 1)
5455}
5456
5457struct BuiltIdempotencyLayers {
5458    route: crate::idempotency::IdempotencyLayer,
5459    manual: crate::idempotency::IdempotencyLayer,
5460}
5461
5462fn build_idempotency_layers(
5463    config: &AutumnConfig,
5464    state: &AppState,
5465) -> Result<Option<BuiltIdempotencyLayers>, RouterBuildError> {
5466    if !config.idempotency.enabled.unwrap_or(false) {
5467        return Ok(None);
5468    }
5469
5470    let ttl = Duration::from_secs(config.idempotency.ttl_secs);
5471    let in_flight_ttl = Duration::from_secs(config.idempotency.in_flight_ttl_secs);
5472    let store = build_idempotency_store(
5473        config.idempotency.backend,
5474        config,
5475        state,
5476        ttl,
5477        "idempotency",
5478    )?;
5479    warn_on_short_in_flight_ttl(config);
5480
5481    tracing::debug!(
5482        backend = ?config.idempotency.backend,
5483        ttl_secs = config.idempotency.ttl_secs,
5484        in_flight_ttl_secs = config.idempotency.in_flight_ttl_secs,
5485        "Idempotency-key middleware enabled"
5486    );
5487
5488    let base = IdempotencyLayer::new(store)
5489        .with_ttl(ttl)
5490        .with_in_flight_ttl(in_flight_ttl)
5491        .with_metrics(state.metrics.clone())
5492        .with_entropy(state.entropy_arc());
5493
5494    Ok(Some(BuiltIdempotencyLayers {
5495        route: base.clone().replay_through_inner(),
5496        manual: base.fail_closed_on_replay(),
5497    }))
5498}
5499
5500/// A run of user-registered layers ([`AppBuilder::layer`](crate::app::AppBuilder::layer),
5501/// [`AppBuilder::static_gate`](crate::app::AppBuilder::static_gate), plugin
5502/// layers) composed into a SINGLE `tower::Layer`, so an arbitrary number of
5503/// registrations costs one application instead of one per registration.
5504///
5505/// # Why this type exists
5506///
5507/// A `tower-layer` tuple needs every member's type at compile time; a `Vec` of
5508/// registrations does not have that. Erasing each registration to
5509/// [`ErasedAppLayer`](crate::app::ErasedAppLayer) at registration time makes
5510/// them homogeneous, and this type folds the homogeneous run by hand. The
5511/// result is one `Layer` that can sit inside `apply_middleware`'s single merged
5512/// tuple, so operator layers no longer deepen the framework's per-request
5513/// clone cascade (#2198) — the framework's overhead becomes a constant instead
5514/// of a function of how many layers an operator or plugin attached.
5515///
5516/// The fold costs exactly one boxing adapter for the whole run (the
5517/// `ErasedAppService::new` seed), regardless of how many layers it contains.
5518/// That box does NOT clone on call — `BoxCloneSyncService::call` forwards to
5519/// the inner service — so it adds no traversal to the cascade either; its
5520/// runtime cost is one `Box::pin` per request.
5521///
5522/// # Why an EMPTY run is still composed in `apply_middleware`
5523///
5524/// It is not dead weight there: it is the type boundary that makes the single
5525/// merged application compile in reasonable time. `tower::util::option_layer`
5526/// yields `Either<L::Service, S>`, in which the inner service type `S` appears
5527/// TWICE — so a chain of *n* conditional layers with no erasure between them
5528/// expands to a type of size `O(2ⁿ)`, and rustc proves `Router::layer`'s
5529/// `Send`/`Sync`/`Clone` obligations over that expansion. The ingress stack has
5530/// twelve `option_layer`s. Split at this slot they are 5 above and 7 below
5531/// (`2⁵ + 2⁷`); merged into one un-erased chain they are `2¹²`, which took
5532/// rustc over twenty minutes to check `apply_middleware` alone. Dropping this
5533/// boundary "to save a box when no layer is registered" brings that back.
5534///
5535/// `apply_layers_in_registration_order` (the SSG/ISG path) does the opposite
5536/// and skips an empty run: there the run is applied on its own `Router::layer`
5537/// call, so there is no long chain to break and the box would buy nothing.
5538#[derive(Clone)]
5539struct ComposedRegisteredLayers(Vec<crate::app::ErasedAppLayer>);
5540
5541impl ComposedRegisteredLayers {
5542    /// Compose a run of registrations, preserving their registration order.
5543    fn new(registrations: Vec<crate::app::CustomLayerRegistration>) -> Self {
5544        Self(registrations.into_iter().map(|reg| reg.layer).collect())
5545    }
5546}
5547
5548impl<S> tower::Layer<S> for ComposedRegisteredLayers
5549where
5550    S: tower::Service<
5551            axum::extract::Request,
5552            Response = axum::response::Response,
5553            Error = std::convert::Infallible,
5554        > + Clone
5555        + Send
5556        + Sync
5557        + 'static,
5558    S::Future: Send + 'static,
5559{
5560    type Service = crate::app::ErasedAppService;
5561
5562    fn layer(&self, inner: S) -> Self::Service {
5563        // Fold direction. The contract is "first registered ends up outermost on
5564        // ingress", matching `tower::ServiceBuilder` and the pre-#2198 loop.
5565        //
5566        // `Layer::layer(inner)` returns a service that wraps `inner`, so each
5567        // successive call in this fold produces something strictly more outer,
5568        // and the last registration visited ends up outermost. Visiting the vec
5569        // in reverse therefore visits `registrations[0]` last and puts the
5570        // first-registered layer outermost.
5571        //
5572        // The old loop used the same `.rev()` for a different reason: there,
5573        // `router = reg.apply(router)` made the last `Router::layer` call
5574        // outermost. Both forms accumulate outward, so both reverse. A
5575        // `tower-layer` tuple is the form that does not — its first element is
5576        // outermost. Getting this backwards still compiles, because every layer
5577        // here is `Request -> Response` with `Error = Infallible`, so only
5578        // behavioural tests catch it.
5579        let mut svc = crate::app::ErasedAppService::new(inner);
5580        for registered in self.0.iter().rev() {
5581            svc = registered.layer(svc);
5582        }
5583        svc
5584    }
5585}
5586
5587/// Re-normalize a group's response body back to `axum::body::Body`.
5588///
5589/// Every `Router::layer` call ends in `Route::new`, which maps the produced
5590/// service's response through `IntoResponse::into_response` — so each of the
5591/// separate `.layer()` calls this file used to make silently converted a
5592/// group's exotic response body (e.g. `LogContextLayer`'s `LogContextBody`)
5593/// back to `axum::body::Body` at the group boundary. Collapsing those calls
5594/// into one merged tuple removes those implicit conversions, so a boundary
5595/// between a body-rewrapping group and a member that demands
5596/// `Response<axum::body::Body>` needs this explicit equivalent.
5597///
5598/// It costs nothing measurable: no box, no service clone on call, and the
5599/// mapping is a fn pointer applied to the response future's output.
5600///
5601/// Deliberately a UNIT struct rather than a generic constructor returning
5602/// `tower::util::MapResponseLayer<fn(Response<B>) -> Response>`: an inference
5603/// variable for `B` sitting in the middle of the merged tuple makes rustc
5604/// re-normalize the whole nested `Layer`/`Service` projection chain and pushes
5605/// this function's type-check into the tens of minutes. With a unit struct,
5606/// `B` is a projection out of the inner service and never an inference
5607/// variable at the call site.
5608#[derive(Clone, Copy)]
5609struct NormalizeBodyLayer;
5610
5611impl<S> tower::Layer<S> for NormalizeBodyLayer {
5612    type Service = NormalizeBody<S>;
5613
5614    fn layer(&self, inner: S) -> Self::Service {
5615        NormalizeBody(inner)
5616    }
5617}
5618
5619/// Service half of [`NormalizeBodyLayer`].
5620#[derive(Clone, Copy)]
5621struct NormalizeBody<S>(S);
5622
5623/// Free function (not a closure) so it can be named as a `fn` pointer in
5624/// `NormalizeBody::Future`, keeping the future un-boxed.
5625fn into_response_result<B, E>(
5626    result: Result<http::Response<B>, E>,
5627) -> Result<axum::response::Response, E>
5628where
5629    http::Response<B>: axum::response::IntoResponse,
5630{
5631    result.map(axum::response::IntoResponse::into_response)
5632}
5633
5634impl<S, B> tower::Service<axum::extract::Request> for NormalizeBody<S>
5635where
5636    S: tower::Service<axum::extract::Request, Response = http::Response<B>>,
5637    http::Response<B>: axum::response::IntoResponse,
5638{
5639    type Response = axum::response::Response;
5640    type Error = S::Error;
5641    type Future = futures::future::Map<
5642        S::Future,
5643        fn(Result<http::Response<B>, S::Error>) -> Result<axum::response::Response, S::Error>,
5644    >;
5645
5646    fn poll_ready(
5647        &mut self,
5648        cx: &mut std::task::Context<'_>,
5649    ) -> std::task::Poll<Result<(), Self::Error>> {
5650        self.0.poll_ready(cx)
5651    }
5652
5653    fn call(&mut self, req: axum::extract::Request) -> Self::Future {
5654        futures::FutureExt::map(
5655            self.0.call(req),
5656            into_response_result::<B, S::Error> as fn(_) -> _,
5657        )
5658    }
5659}
5660
5661#[allow(
5662    clippy::cognitive_complexity,
5663    clippy::too_many_lines,
5664    clippy::too_many_arguments
5665)]
5666fn apply_middleware(
5667    mut router: axum::Router<AppState>,
5668    config: &AutumnConfig,
5669    state: &AppState,
5670    exception_filters: Vec<Arc<dyn ExceptionFilter>>,
5671    custom_layers: Vec<crate::app::CustomLayerRegistration>,
5672    #[cfg(feature = "maud")] error_page_renderer: Option<SharedRenderer>,
5673    session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
5674    route_timeouts: RouteTimeoutTable,
5675    // Sets the `Criticality` extension and task-local (#3068). Passive when no
5676    // route sets a criticality and the header is not trusted.
5677    criticality_layer: crate::middleware::criticality::CriticalityLayer,
5678    // Built once by the caller (`build_router_pre_state`) and cloned into the
5679    // late-mounted `/mcp` envelope too, so both ingress surfaces admit
5680    // against the SAME shared in-flight counter — constructing a second
5681    // `LoadShedLayer` here would give `/mcp` its own independent (always-zero)
5682    // counter that never sheds. See `build_load_shed_layer`.
5683    load_shed_layer: Option<crate::middleware::LoadShedLayer>,
5684    // When true (SSG/ISG path), the shadow-mirroring layer is NOT installed
5685    // here. `try_build_router_with_static_inner` installs a single one OUTSIDE
5686    // the static-first middleware, so that a request served from the static
5687    // cache is mirrored too — installing one here as well would double-mirror
5688    // every dynamic miss against a second, unpublished registry.
5689    defer_shadow: bool,
5690) -> Result<axum::Router<AppState>, RouterBuildError> {
5691    // 404 fallback handler for unmatched routes must be registered BEFORE global middleware
5692    // so that unmatched routes are still protected by rate limiting, CSRF, CORS, etc.
5693    // The fallback also serves the AEO documents (`/llms.txt`, the
5694    // `/.well-known/*` agent files): an application route at the same path
5695    // always wins, and a generated path cannot collide with one. With
5696    // `plugin-sandbox` it serves plugins mounted at runtime too (issue #2433);
5697    // like an application route, a mounted plugin wins over a document.
5698    let aeo_site = state.extension::<crate::aeo::AeoSite>();
5699    let fallback_site = aeo_site.clone();
5700    router = router.fallback(
5701        move |axum::extract::State(app): axum::extract::State<AppState>,
5702              req: axum::extract::Request| {
5703            let site = fallback_site.clone();
5704            async move {
5705                #[cfg(feature = "plugin-sandbox")]
5706                if app
5707                    .extension::<crate::plugin_sandbox::PluginMounts>()
5708                    .is_some_and(|mounts| mounts.owns(req.uri().path()))
5709                {
5710                    return crate::plugin_sandbox::mounts_fallback(axum::extract::State(app), req)
5711                        .await;
5712                }
5713                #[cfg(feature = "webmcp")]
5714                if let Some(res) = crate::webmcp::serve_manifest(
5715                    app.extension::<crate::webmcp::WebMcpManifest>(),
5716                    &req,
5717                ) {
5718                    return res;
5719                }
5720                #[cfg(not(feature = "plugin-sandbox"))]
5721                let _ = app;
5722                match crate::aeo::serve_document(site, &req) {
5723                    Some(res) => res,
5724                    None => {
5725                        crate::middleware::error_page_filter::fallback_404_handler(
5726                            req.method().clone(),
5727                            req.uri().clone(),
5728                        )
5729                        .await
5730                    }
5731                }
5732            }
5733        },
5734    );
5735
5736    // Resolve signing keys once; shared across session and CSRF layers.
5737    let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
5738    let signing_keys = std::sync::Arc::new(crate::security::config::resolve_signing_keys(
5739        &config.security.signing_secret,
5740    ));
5741    // Only thread signing keys when a secret is configured (or in production where
5742    // fail_fast already ensures one is present). In dev without a configured secret
5743    // the ephemeral key is generated per-process — useful but not required.
5744    let signing_keys_opt: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>> =
5745        if config.security.signing_secret.secret.is_some() || is_production {
5746            Some(signing_keys)
5747        } else {
5748            None
5749        };
5750
5751    // ── How the ingress stack is assembled ──────────────────────────────────
5752    //
5753    // Each `Router::layer` call re-boxes the whole downstream stack: axum's
5754    // `Route::layer` ends in `Route::new(..)`, which is
5755    // `BoxCloneSyncService::new(..)`. N sequential `.layer()` calls build N
5756    // nested boxes, and `Route::call` deep-clones everything beneath it, so a
5757    // request descending N levels pays `N + (N-1) + … + 1` heap allocations.
5758    // Measured against axum 0.8.9 the fit is `13 + N(N+1)/2 + 2N` per request
5759    // (263 at N = 20, 1388 at N = 50); the same layers in ONE `Router::layer`
5760    // call cost a flat 16 for any N. See #2193.
5761    //
5762    // The layers below are therefore composed into tuples and applied in one
5763    // call instead of ~16 (#2198 collapsed the last four: the inner group, the
5764    // user layers, the middle group, and the session). `tower-layer` implements
5765    // `Layer` for tuples with the FIRST element outermost, so each tuple reads
5766    // top-to-bottom in ingress order.
5767    //
5768    // ⚠ This is the OPPOSITE of repeated `Router::layer` calls, where the LAST
5769    // call ends up outermost. Reverse the order when moving a layer between the
5770    // two forms. `tower::ServiceBuilder` matches the tuple form — first-added is
5771    // outermost — which is why `ComposedRegisteredLayers` folds its run in
5772    // reverse. Getting this backwards still compiles and type-checks; only
5773    // behavioural tests catch it.
5774    //
5775    // `tower-layer` implements `Layer` for tuples up to 16 elements; the largest
5776    // group below has 13. Past 16, nest a sub-tuple as one element —
5777    // `(a, b, (c, d, e), f)` composes identically and still costs one box.
5778    //
5779    // Conditional members use `tower::util::option_layer`, which maps `None` to
5780    // `Identity`: its `Service` is the inner service, wrapped in an `Either` that
5781    // forwards to it. A disabled layer costs one enum branch per call — no
5782    // allocation, no `Route` box, no nesting level.
5783
5784    // Innermost group: everything from the handler out to, but not including, the
5785    // user-registered layers. Listed OUTERMOST FIRST.
5786    // Built first because it is one of the two builders that can fail the router
5787    // build (here the production memory-backend guard for submit tokens; the
5788    // other is `build_session_layer` below). The infallible builders have side
5789    // effects — `tracing::info!` lines, and a lazy Redis connection manager for a
5790    // Redis-backed rate limiter — that must not run on the way to a fail-fast `Err`.
5791    let submit_token_layer = build_submit_token_layer(config, state, is_production)?;
5792    let (body_limit, upload_config) = build_upload_layers(config);
5793    let trusted_host_policy = TrustedHostPolicy::from_config_with_state(config, state);
5794    let (rate_limit_layer, rate_limit_principal_keying) = build_rate_limit_layers(config, state);
5795    // Staging fault injection (#3071). The scope layer goes outside the
5796    // exception filters and the session layer, so the Redis session store is
5797    // in the request scope and the stop condition sees the final status. The route layer is the
5798    // innermost member of `inner_stack`: it runs after rate limiting and load
5799    // shedding, and the timeout, the access log and error reporting see an
5800    // injected fault as a real one. Probe and actuator paths are exempt, as
5801    // for `[shadow]`. `build` has no side effects.
5802    // On the SSG/ISG path, `install_outer_fault_injection` adds the layers
5803    // again outside the static-first middleware, on the same injector. The
5804    // inner ones stay for the MCP dispatch clone; a request keeps the first
5805    // scope, and route faults roll once.
5806    let fault_injection = crate::fault_injection::build(
5807        config,
5808        state,
5809        fault_injection_exempt_paths(config),
5810        &route_timeouts,
5811    );
5812    let (fault_scope_layer, fault_route_layer, fault_handle) = match fault_injection {
5813        Some((scope, route, handle)) => (Some(scope), Some(route), Some(handle)),
5814        None => (None, None, None),
5815    };
5816
5817    let inner_stack = (
5818        // Insert UploadConfig into extensions so the Multipart extractor can
5819        // read per-file limits and the allowed MIME-type list.
5820        axum::Extension(upload_config),
5821        // Global body-size cap covering JSON, form, raw bytes, and multipart.
5822        body_limit,
5823        crate::webhook::WebhookReplayCleanupLayer,
5824        // Admission control / load shedding (#1006). Outer to MaintenanceLayer so
5825        // the cheap in-flight-count check runs before maintenance mode's
5826        // bypass-header/IP-allowlist evaluation. `None` (the default — no
5827        // `server.max_concurrent_requests` configured) contributes an `Either`
5828        // branch that forwards straight to the inner service: no allocation and
5829        // no extra nesting level.
5830        // Request criticality (#3068). Outer to load shedding, which reads the
5831        // `Criticality` extension that this layer sets. Always installed: an
5832        // outer user layer can set the class. Passive when nothing does.
5833        criticality_layer,
5834        tower::util::option_layer(load_shed_layer),
5835        // Maintenance mode (shared construction with the late-mounted `/mcp`
5836        // envelope — see `build_maintenance_layer`).
5837        build_maintenance_layer(config, state),
5838        // Populates RateLimitPrincipal from the verified session identity, so it
5839        // must run BEFORE (outside) the limiter that keys on it.
5840        tower::util::option_layer(rate_limit_principal_keying.then(|| {
5841            axum::middleware::from_fn_with_state(state.clone(), populate_rate_limit_principal)
5842        })),
5843        tower::util::option_layer(rate_limit_layer),
5844        // Method-override rejection filter. The outer `MethodOverrideLayer`,
5845        // applied at the `axum::serve` boundary so it can rewrite the method
5846        // before route matching, stamps a [`MethodOverrideRejection`] extension
5847        // when the override value is invalid or the body was too large to scan.
5848        // This inner middleware turns that extension into the matching `400`/`413`
5849        // response, so the rejection flows through the rest of the response stack
5850        // (security headers, request ids, metrics, error-page filter) instead of
5851        // bypassing it. Placed outside CSRF so a `BodyTooLarge` on an empty body
5852        // is not masked by CSRF's missing-token `403`, and a clear
5853        // `400 invalid _method` outranks "missing CSRF".
5854        crate::middleware::method_override::MethodOverrideRejectionLayer,
5855        // mTLS route requirement (#1640). INSIDE the router, not at the
5856        // `axum::serve` boundary beside `ClientIdentityLayer`, for two reasons:
5857        // the MCP dispatch clone is taken from the finished router, so a
5858        // `tools/call` replaying into an mTLS-only route must traverse this
5859        // check; and a rejection here flows through the rest of the response
5860        // stack (access log, request id, security headers, Problem Details)
5861        // instead of bypassing it. Inner to rate limiting and load shedding, so
5862        // an uncertified prober is throttled like any other client, and outer
5863        // to CSRF, so a connection with no certificate never reaches a token
5864        // check it cannot pass anyway. `None` — and so free — for every app
5865        // that declares no `required_paths`.
5866        tower::util::option_layer(build_client_cert_requirement_layer(config)),
5867        tower::util::option_layer(build_bot_protection_layer(config)),
5868        (
5869            // Outer to CSRF: a request to an x402-priced route with no
5870            // cookie, or with a `PAYMENT-SIGNATURE` header, cannot be forged
5871            // cross-site and is gated by x402 itself, so a payment client's
5872            // `POST` reaches x402 instead of a CSRF `403`.
5873            tower::util::option_layer(
5874                config
5875                    .security
5876                    .csrf
5877                    .enabled
5878                    .then(|| crate::aeo::commerce::X402CsrfLayer::from_config(config, state))
5879                    .flatten(),
5880            ),
5881            tower::util::option_layer(build_csrf_layer(config, signing_keys_opt.clone())),
5882        ),
5883        // Inner to the CSRF layer so CSRF is validated first on the request
5884        // path; a replayed `_submit_token` is still short-circuited even when
5885        // the request carries a valid `_csrf` (issue #1360, AC #4).
5886        tower::util::option_layer(submit_token_layer),
5887        TrustedHostLayer::new(trusted_host_policy),
5888        tower::util::option_layer(build_ingress_cors_layer(config)),
5889        (
5890            tower::util::option_layer(fault_route_layer),
5891            // x402 payments for `[[aeo.paid_routes]]`: innermost, so rate
5892            // limits, the trusted-host check, CSRF, the request timeout, and
5893            // CORS all apply before a facilitator call. Also present on the
5894            // SSG/ISG path, where the MCP dispatch clone needs it.
5895            aeo_x402_layer(config, state, None),
5896        ),
5897    );
5898
5899    // User-registered Tower layers (`AppBuilder::layer`) wrap the group above.
5900    // They are erased at registration time and folded into
5901    // `ComposedRegisteredLayers`, so however many an operator or plugin attaches
5902    // (`Plugin::build` receives the same `AppBuilder`), they occupy ONE slot in
5903    // the single merged application below rather than one `Router::layer` call
5904    // each. The `TypeId`/`type_name` that `AppBuilder::has_layer` and
5905    // `get_layer_types` expose ride along on the registration and survive erasure.
5906    //
5907    // With a static dist dir active (SSG/ISG build) these layers are not passed
5908    // here. `try_build_router_with_static_inner` extracts them and applies them
5909    // outside the static-first middleware, so they can process pre-rendered
5910    // responses without creating a session dependency.
5911    let custom_layer_count = custom_layers.len();
5912    if custom_layer_count > 0 {
5913        tracing::debug!(count = custom_layer_count, "Custom Tower layers applied");
5914    }
5915
5916    // ── Middle group: outer to the user layers, inner to the session ────────
5917    //
5918    // Per-request timeout, inner to RequestId so the request id set by that layer
5919    // is available when the timeout fires (see `RequestTimeoutService`).
5920    //
5921    // Full ingress layer order (outermost → innermost):
5922    //   TraceContext → AccessLog-fallback (applied in apply_startup_barrier) →
5923    //   StartupBarrier → Compression → Metrics → ExceptionFilter → ErrorPageContext →
5924    //   Session → SecurityHeaders → RequestId → LogContext → ServerTiming →
5925    //   AccessLog-primary → FailureCapture → Reporting → Timeout → Tenancy →
5926    //   TrustedProxies → [user layers] → BodyLimit/UploadConfig → MethodOverride →
5927    //   RateLimit → CSRF → CORS → AeoX402 → handler
5928    // `mirror_cors = true`: this layer is outside `CorsLayer`, so its timeout 503
5929    // must carry CORS headers itself.
5930    //
5931    // Known limitation — session store I/O is unbounded. `Session` sits outside
5932    // this layer, so `store.load` runs before the timer starts and
5933    // `store.save`/`destroy` after it completes; a stalled session backend can tie
5934    // up a worker despite `request_timeout_ms`. The placement is deliberate: the
5935    // timer stays inner to `RequestId` so a timeout 503 and its warn log carry
5936    // `X-Request-Id`. Bound session-store I/O with a store-level deadline (for
5937    // example the Redis command timeout); a cancelled inbound request cannot abort
5938    // an already-issued store call at any layer order.
5939    //
5940    // The same applies to the edge layers `App::run` wraps around the finished
5941    // router at the `axum::serve` boundary (`MethodOverrideLayer`,
5942    // `TrustedProxiesLayer`): they sit outside `RequestId` and so outside this
5943    // timer. `MethodOverrideLayer` in particular buffers an HTML form body
5944    // (`axum::body::to_bytes`, capped at `upload.max_request_size_bytes`) before
5945    // the inner router runs, so a slow `_method` upload is not bounded by
5946    // `request_timeout_ms`. Use a server or proxy read timeout for that.
5947    //
5948    // `apply_request_timeout_middleware` installs the same layer type for the
5949    // `/mcp` envelope, so the two cannot drift.
5950    let timeout_layer = build_request_timeout_settings(config, state, route_timeouts, true)
5951        .map(RequestTimeoutLayer::new);
5952
5953    // Failure-capsule capture (#1598). Outer to the reporting layer, because a
5954    // request's capture scope must exist before that layer snapshots its context;
5955    // the scope is what the reporting layer seals into a capsule for a failed
5956    // request. Off unless `[failure_capture] enabled = true`, since capsules hold
5957    // real request data. This layer is also the sole arming point for database
5958    // attribution: the connection-checkout marker fires only under a capture
5959    // scope, so two apps with different capture settings in one process cannot
5960    // disturb each other.
5961    #[cfg(feature = "reporting")]
5962    let capture_layer = tower::util::option_layer(config.failure_capture.enabled.then(|| {
5963        // Same filter composition as the log context below, so one
5964        // `[log] filter_parameters` list governs both.
5965        let mut capture_filter_parameters = config.log.filter_parameters.clone();
5966        capture_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
5967        capture_filter_parameters
5968            .extend(crate::confidential::registered_confidential_column_names());
5969        let capture_filter = Arc::new(crate::log::filter::ParameterFilter::new(
5970            &capture_filter_parameters,
5971            &config.log.unfilter_parameters,
5972        ));
5973        // `mut` only on builds that fill in the roles below; a sqlite (or
5974        // no-`db`) build compiles that block out and records none.
5975        #[cfg_attr(
5976            any(not(feature = "db"), feature = "sqlite"),
5977            allow(
5978                unused_mut,
5979                reason = "the role assignment below is compiled out on these builds"
5980            )
5981        )]
5982        let mut capture_settings = crate::capsule::settings_from_config(config);
5983        // The roles come from the pools the application actually built, not
5984        // from the configured URLs: a custom `DatabasePoolProvider` may return
5985        // no pool despite a `primary_url`, or ignore a configured replica (the
5986        // managed-Postgres provider does exactly that). Recording a role the
5987        // app does not have would have replay rebuild a shape production never
5988        // ran. PostgreSQL-only, because that is all replay can reconstruct.
5989        #[cfg(all(feature = "db", not(feature = "sqlite")))]
5990        {
5991            capture_settings.db_roles = crate::capsule::observed_db_roles(
5992                state.pool().is_some(),
5993                state.replica_pool().is_some(),
5994            );
5995        }
5996        let probe_state = state.clone();
5997        crate::capsule::CaptureLayer::new(capture_settings, capture_filter)
5998            .with_state_cache_probe(Arc::new(move || probe_state.has_cache()))
5999    }));
6000    #[cfg(not(feature = "reporting"))]
6001    let capture_layer = tower::layer::util::Identity::new();
6002
6003    // Request-scoped log context (#1169). Established for every request, inner
6004    // to `RequestIdLayer` (so the request id is available to seed it) and outer
6005    // to tenancy, user layers, and the handler (so all of them, and every
6006    // `tracing` event they emit, inherit the same correlating context). The
6007    // filter mirrors the error-page scrubber so sensitive custom fields never
6008    // enter the context output.
6009    let mut log_context_filter_parameters = config.log.filter_parameters.clone();
6010    log_context_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
6011    log_context_filter_parameters
6012        .extend(crate::confidential::registered_confidential_column_names());
6013    let log_context_filter = Arc::new(crate::log::filter::ParameterFilter::new(
6014        &log_context_filter_parameters,
6015        &config.log.unfilter_parameters,
6016    ));
6017
6018    // Error-reporting and panic-catch layer. Inner to `RequestIdLayer` so the
6019    // request id is available when a handler panics, and outer to the timeout,
6020    // user layers, and handler so their panics become a clean 500 instead of
6021    // aborting the worker task. That 500 still flows out through the
6022    // exception-filter chain for HTML negotiation. `config.reporting.enabled` is
6023    // a constructor argument, not a gate: the layer is installed whenever the
6024    // `reporting` feature is on, so the panic catch cannot be configured away.
6025    #[cfg(feature = "reporting")]
6026    let reporting_layer = crate::reporting::ReportingLayer::new(
6027        state.error_reporters(),
6028        config.reporting.enabled,
6029        config.reporting.sample_rate,
6030    );
6031    #[cfg(not(feature = "reporting"))]
6032    let reporting_layer = tower::layer::util::Identity::new();
6033
6034    // Structured per-request access log (#999), primary emitter: one INFO event
6035    // (target `autumn::access`) per served request at the response boundary. Inner
6036    // to RequestId and LogContext, so the request id is available and the event is
6037    // emitted inside the request span. Outer to the reporting and timeout layers,
6038    // so panics-turned-500s and timeout responses are logged with the status the
6039    // client receives. Emitted responses are marked so the outermost fallback in
6040    // `apply_startup_barrier` does not double-log; that fallback covers requests
6041    // which short-circuit before this layer runs.
6042    let access_log_layer = config
6043        .log
6044        .access_log
6045        .then(|| crate::middleware::AccessLogLayer::new(config.log.access_log_exclude.clone()));
6046
6047    // Server-Timing response header (#1348). Outer to AccessLogLayer — its
6048    // `total` metric is therefore the outermost wall-clock measure and is `>=`
6049    // the access-log `duration_ms` by a few microseconds; both share the same
6050    // `Instant`-based formula. Opt-in via `[observability] server_timing`;
6051    // defaults on in dev, off in prod so timings never leak to anonymous prod
6052    // clients without explicit opt-in.
6053    let server_timing_layer = crate::config::server_timing_enabled(config)
6054        .then(|| crate::middleware::ServerTimingLayer::new(true));
6055
6056    // Cost metering (#1720). Inner to ServerTiming and LogContext, so the
6057    // tenant that Tenancy resolves is readable when the request completes.
6058    // Outer to everything that does request work. Its `Server-Timing` metrics
6059    // follow the same setting as the ServerTiming layer.
6060    let cost_layer = config
6061        .cost
6062        .enabled
6063        .then(|| state.extension::<crate::cost::CostAccountant>())
6064        .flatten()
6065        .map(|accountant| {
6066            crate::middleware::CostLayer::new(
6067                (*accountant).clone(),
6068                crate::config::server_timing_enabled(config),
6069            )
6070        });
6071
6072    let tenancy_layer = config.tenancy.enabled.then(|| {
6073        tracing::debug!("Multi-tenancy middleware enabled");
6074        axum::middleware::from_fn_with_state(state.clone(), crate::tenancy::tenancy_middleware)
6075    });
6076
6077    // `SET LOCAL` timeouts for transactions a handler opens on a connection of
6078    // its own, such as a generated repository (#3057). `Db::tx` scopes its own
6079    // values. A route's `StatementTimeout` reaches this scope through the
6080    // extractors (`db::note_route_statement_timeout`), so the layer is on even
6081    // when no global timeout is set.
6082    #[cfg(all(feature = "db", not(feature = "sqlite")))]
6083    let tx_timeouts_layer = {
6084        let timeouts = crate::db::TxTimeouts::from_config(&config.database);
6085        Some(axum::middleware::from_fn(
6086            move |req: axum::extract::Request, next: axum::middleware::Next| {
6087                timeouts.scope_request(next.run(req))
6088            },
6089        ))
6090    };
6091    #[cfg(not(all(feature = "db", not(feature = "sqlite"))))]
6092    let tx_timeouts_layer: Option<tower::layer::util::Identity> = None;
6093
6094    // `security_headers` is applied later as the framework's outermost layer, by
6095    // `build_router_pre_state` after the gate, so a gate short-circuit still
6096    // carries HSTS/CSP/nosniff. RequestId stays here, inner to session, so the
6097    // request id seeds the session, logs, and trace context.
6098    //
6099    // TrustedProxiesLayer is the innermost member of this group — immediately
6100    // outside the user layers — so `ResolvedClientIdentity` is stamped before any
6101    // middleware reads ClientAddr, ClientHost, or ClientScheme.
6102    // Listed OUTERMOST FIRST — see the warning at the top of this function.
6103    // One proxy resolver for both layers: the trust decision for
6104    // `X-Request-Id` and for `X-Forwarded-*` has one source.
6105    let trusted_proxies_layer = build_trusted_proxies_layer(config);
6106    let middle_stack = (
6107        RequestIdLayer::with_entropy(state.entropy_arc())
6108            .with_inbound_trust(trusted_proxies_layer.resolver()),
6109        crate::middleware::LogContextLayer::new(log_context_filter),
6110        tower::util::option_layer(server_timing_layer),
6111        tower::util::option_layer(cost_layer),
6112        tower::util::option_layer(access_log_layer),
6113        capture_layer,
6114        reporting_layer,
6115        tower::util::option_layer(timeout_layer),
6116        tower::util::option_layer(tenancy_layer),
6117        tower::util::option_layer(tx_timeouts_layer),
6118        trusted_proxies_layer,
6119    );
6120
6121    // Pre-clone signing keys for the RYWW middleware (session mode needs to
6122    // sign/verify the `autumn.ryw` cookie; `signing_keys_opt` is consumed below).
6123    #[cfg(feature = "db")]
6124    let signing_keys_for_ryw = signing_keys_opt.clone();
6125
6126    // The session used to need its own `Router::layer` call: each backend produces
6127    // a differently-typed `SessionLayer<Store>`, which no fixed tuple member can
6128    // be. `build_session_layer` monomorphizes it to `SessionLayer<ArcSessionStore>`
6129    // so it joins the merged tuple below; see that function for the
6130    // boxed-future-per-store-op cost that buys the nesting level back.
6131    // Set only when tenancy resolves the tenant from the session: a handler that
6132    // mutates that session key (an org switch, a tenant-scoped login) needs its
6133    // deferred idempotency alias keyed by the finalized tenant, not the one
6134    // resolved before the handler ran.
6135    let tenancy_session_key = (config.tenancy.enabled && config.tenancy.source == "session")
6136        .then(|| Arc::<str>::from(config.tenancy.session_key.as_str()));
6137    let session_layer = crate::session::build_session_layer(
6138        &config.session,
6139        config.profile.as_deref(),
6140        session_store,
6141        signing_keys_opt,
6142        &state.entropy_arc(),
6143        tenancy_session_key,
6144    )?
6145    .with_rotation_hooks(state.session_rotation_hooks());
6146    tracing::debug!(backend = ?config.session.backend, "Session management enabled");
6147
6148    // Read-your-own-writes middleware: installed only when the mode is not
6149    // `off`. When active, it scopes a per-request task-local `RequestPin`
6150    // that generated repository read methods consult at acquire time.
6151    // Outer to Session, so the task-local also wraps the session store's own
6152    // reads; the `autumn.ryw` cookie is parsed from raw `Cookie` headers and
6153    // does not require the Session extractor to have run first.
6154    #[cfg(feature = "db")]
6155    let ryw_layer = tower::util::option_layer(
6156        (config.database.read_your_writes != crate::config::ReadYourWrites::Off).then(|| {
6157            let ryw_mode = config.database.read_your_writes;
6158            let window_secs = config.database.pin_after_write_secs;
6159            let keys = signing_keys_for_ryw;
6160            if ryw_mode == crate::config::ReadYourWrites::Session && keys.is_none() {
6161                tracing::warn!(
6162                    "read_your_writes = \"session\" requires a configured \
6163                     security.signing_secret to sign the autumn.ryw cookie; \
6164                     cross-request pinning is disabled until a secret is set"
6165                );
6166            }
6167            let metrics = state.metrics().clone();
6168            crate::read_your_writes::ReadYourWritesLayer::new(ryw_mode, window_secs, keys, metrics)
6169        }),
6170    );
6171    #[cfg(not(feature = "db"))]
6172    let ryw_layer = tower::layer::util::Identity::new();
6173
6174    // Must agree with `is_dev_profile` above (the request inspector's own
6175    // gate) on what "dev" means: both derive it from
6176    // `crate::config::profile_is_dev`, which fails closed on an unset
6177    // profile rather than falling back to `cfg!(debug_assertions)` — see its
6178    // doc comment for why that fallback was a dev-overlay disclosure bug.
6179    let is_dev = crate::config::profile_is_dev(config.profile.as_deref());
6180
6181    // Error page filter: renders HTML error pages for browser requests.
6182    // Always registered (uses default renderer if no custom one is provided).
6183
6184    // When the `maud` feature is enabled, an ErrorPageFilter renders styled HTML
6185    // error pages for browser requests. Without `maud`, only the
6186    // ProblemDetailsFilter (JSON error normalization) is installed.
6187    let mut all_filters: Vec<Arc<dyn ExceptionFilter>> =
6188        vec![Arc::new(ProblemDetailsFilter { is_dev })];
6189    #[cfg(feature = "maud")]
6190    {
6191        // Encrypted columns (#805) compose into log scrubbing (#697): their names are
6192        // always scrubbed from trace/error parameter output so ciphertext-backed
6193        // values never leak through logs even if an app forgets to list them.
6194        let mut filter_parameters = config.log.filter_parameters.clone();
6195        filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
6196        filter_parameters.extend(crate::confidential::registered_confidential_column_names());
6197        let renderer = error_page_renderer.unwrap_or_else(error_pages::default_renderer);
6198        let error_page_filter = crate::middleware::error_page_filter::ErrorPageFilter {
6199            renderer,
6200            is_dev,
6201            parameter_filter: crate::log::filter::ParameterFilter::new(
6202                &filter_parameters,
6203                &config.log.unfilter_parameters,
6204            ),
6205        };
6206        all_filters.push(Arc::new(error_page_filter));
6207    }
6208    all_filters.extend(exception_filters);
6209
6210    let count = all_filters.len();
6211    tracing::debug!(
6212        count,
6213        "Registered exception filters (including error page filter)"
6214    );
6215
6216    // ── Outermost group: outer to the session ───────────────────────────────
6217    //
6218    // Response compression is outermost, outside ExceptionFilter, so filters that
6219    // rebuild the response body (e.g. `ProblemDetailsFilter` normalising
6220    // `AutumnError`s to JSON Problem Details) run before the body is encoded.
6221    // Inner to ExceptionFilter, the filter would inherit a `Content-Encoding:
6222    // gzip` header on its rebuilt uncompressed body and clients would receive
6223    // uncompressed bytes labeled as gzip. User layers (EtagLayer etc.) stay inner
6224    // to Compression, so ETags are computed on the uncompressed body.
6225    //
6226    // The error page context layer must sit directly inner to the exception
6227    // filter (adjacent in the tuple below), so `WantsHtml` is set on the
6228    // response before the filter inspects it. It attaches its context only to
6229    // responses that already carry `AutumnErrorInfo`, which is correct only
6230    // while no layer between the two could turn a plain response into an
6231    // error one.
6232    //
6233    // Full ingress layer order (outermost → innermost). The framework's outermost
6234    // `SecurityHeadersLayer` and the `static_gate` layers are applied by
6235    // `build_router_pre_state` after this function returns — and after the MCP
6236    // dispatch clone is taken — so they are not in this list:
6237    //   [MethodOverride, TrustedProxies, loopback ConnectInfo — wrapped around
6238    //   the finished Router by `App::run` at the `axum::serve` boundary, so
6239    //   they are outside even the startup barrier] ->
6240    //   TraceContext -> ServerTiming-fallback -> AccessLog-fallback ->
6241    //   StartupBarrier   (all four applied by `apply_startup_barrier`, which
6242    //   every entry point calls LAST on the finished router — so this group is
6243    //   the outermost thing inside the Router) ->
6244    //   SecurityHeaders (framework outermost within build_router_pre_state) ->
6245    //   [static_gate layers — applied just inside SecurityHeaders and after the
6246    //   MCP dispatch clone, outside session and the static cache] ->
6247    //   [event-bus context, oauth2 interceptor] -> Inspector (dev) ->
6248    //   dev live-reload (dev)   (all applied in build_router_pre_state) ->
6249    //   Compression -> ShadowMirror -> AeoNegotiate -> Metrics -> ExceptionFilter ->
6250    //   ErrorPageContext ->
6251    //   ReadYourWrites -> Session -> NormalizeBody ->
6252    //   RequestId -> LogContext -> ServerTiming -> Cost -> AccessLog-primary ->
6253    //   Reporting -> Timeout -> Tenancy -> TrustedProxies ->
6254    //   [user layers, non-static build — ONE slot however many are registered] ->
6255    //   UploadConfig -> BodyLimit -> WebhookReplayCleanup -> LoadShed ->
6256    //   Maintenance -> RateLimitPrincipal -> RateLimit ->
6257    //   MethodOverrideRejection -> RequireClientCert (mTLS, #1640) ->
6258    //   BotProtection -> CSRF -> SubmitToken ->
6259    //   TrustedHost -> CORS -> FaultRoute -> AeoX402 -> [asset cache-control] -> handler
6260    // Everything from `Compression` through `CORS` is ONE `Router::layer` call:
6261    // the merged tuple below. `NormalizeBody` is a body-type adapter with no
6262    // request-path behaviour, listed only so this order reads against that tuple
6263    // member-for-member; `Compression` carries its own private one (#2371). In the
6264    // SSG/ISG path the user layers and a second compression layer are applied
6265    // outside the static-first middleware instead — see
6266    // `try_build_router_with_static_inner`.
6267    //
6268    // Response compression, when `[compression] enabled = true` (off by default),
6269    // kept outer to `outer_stack` so it stays outside ExceptionFilter. It is
6270    // wrapped in its own private `(NormalizeBodyLayer, CompressionLayer)` pair
6271    // rather than an `option_layer` on `CompressionLayer` alone: `option_layer`'s
6272    // `Either` needs both arms to share one `Response` type, and
6273    // `CompressionLayer`'s service changes the response BODY type.
6274    // `NormalizeBodyLayer` folds compression's output back to
6275    // `axum::response::Response` before `option_layer` compares the arms. This
6276    // closes the one case #2371 found — a `[compression]`-enabled app paid a full
6277    // extra `Router::layer` box level, and the quadratic per-request re-clone with
6278    // it, that every other config-gated member of this tuple already avoided.
6279    let compression_layer = tower::util::option_layer(config.compression.enabled.then(|| {
6280        tracing::info!("Response compression enabled (gzip/brotli)");
6281        (
6282            NormalizeBodyLayer,
6283            tower_http::compression::CompressionLayer::new().compress_when(compression_predicate()),
6284        )
6285    }));
6286
6287    // Listed OUTERMOST FIRST — see the warning at the top of this function.
6288    let outer_stack = (
6289        // Shadow mirroring (#1653) is the outermost member of this group and
6290        // therefore INNER to compression: the primary body it tees is the
6291        // handler's own bytes, which is what the candidate build returns too.
6292        // Teeing a gzip-encoded body would diff against the candidate's plain
6293        // one and report every route as divergent.
6294        //
6295        // `None` in the SSG/ISG path — see `defer_shadow`.
6296        tower::util::option_layer(
6297            (!defer_shadow)
6298                .then(|| build_shadow_layer(config, state))
6299                .flatten(),
6300        ),
6301        // AEO response layer: Markdown negotiation, `Vary: Accept`, and the
6302        // homepage `Link` header. It is inside compression, so it reads plain
6303        // bytes. It is outside the exception filter, so it sees the final
6304        // page. The SSG/ISG path adds it later, outside the static-first
6305        // layer (same reason as `defer_shadow`).
6306        tower::util::option_layer(
6307            (!defer_shadow)
6308                .then(|| aeo_site.as_ref().and_then(|site| site.layer()))
6309                .flatten(),
6310        ),
6311        crate::middleware::MetricsLayer::new(state.metrics.clone()),
6312        // Outside the exception filters, so the stop condition counts the
6313        // final status that a filter may set (#3071).
6314        tower::util::option_layer(fault_scope_layer),
6315        ExceptionFilterLayer::new(all_filters),
6316        crate::middleware::error_page_filter::ErrorPageContextLayer { is_dev },
6317        ryw_layer,
6318    );
6319
6320    // ── The single merged application ───────────────────────────────────────
6321    //
6322    // One `Router::layer` call for the whole ingress stack. A `tower-layer` tuple
6323    // puts its FIRST element OUTERMOST, so this tuple reads in ingress order:
6324    // outer group, session, middle group, the operator's own layers, then the
6325    // inner group. That is the order the four separate `.layer()` calls this
6326    // replaces produced — they ran inner-group-first precisely because repeated
6327    // calls accumulate outward. Collapsing a run reverses it; see the warning at
6328    // the top of this function.
6329    //
6330    // Each `.layer()` call removed here is a whole `BoxCloneSyncService` nesting
6331    // level that every request above it deep-clones per call, so the four-to-one
6332    // collapse is a quadratic-to-linear change, not a constant-factor one (#2193,
6333    // #2198).
6334    //
6335    // `ComposedRegisteredLayers` occupies the user-layer slot unconditionally,
6336    // even with no layer registered — see its docs for why an empty run still
6337    // earns its boxing adapter.
6338    let router = router.layer((
6339        compression_layer,
6340        outer_stack,
6341        session_layer,
6342        NormalizeBodyLayer,
6343        middle_stack,
6344        ComposedRegisteredLayers::new(custom_layers),
6345        inner_stack,
6346    ));
6347
6348    // NOTE: the `static_gate` layers and the framework's outermost
6349    // `SecurityHeadersLayer` are intentionally NOT applied here. They are applied
6350    // by `build_router_pre_state` after this function returns and after the MCP
6351    // dispatch clone is taken, so a `tools/call` replay never traverses the
6352    // page-cache gate (matching the SSG/ISG path and the documented intent).
6353    if let Some(handle) = fault_handle {
6354        crate::fault_injection::announce(&handle);
6355        state.insert_extension(handle);
6356    }
6357    Ok(router)
6358}
6359
6360/// Apply a set of user-registered layer registrations in ONE `Router::layer`
6361/// call, so that the first-registered layer ends up outermost on ingress —
6362/// matching [`tower::ServiceBuilder`] ordering. Returns the wrapped router.
6363///
6364/// An empty run returns the router untouched: no application, and therefore no
6365/// `BoxCloneSyncService` nesting level and no boxing adapter.
6366///
6367/// Used by the SSG/ISG path, which drains the custom layers and the static
6368/// gates out of `apply_middleware` and applies them outside the static-first
6369/// middleware instead. The fully-dynamic path composes both runs into larger
6370/// merged applications (`apply_middleware` and `build_router_pre_state`).
6371fn apply_layers_in_registration_order(
6372    router: axum::Router<AppState>,
6373    layers: Vec<crate::app::CustomLayerRegistration>,
6374    what: &str,
6375) -> axum::Router<AppState> {
6376    let count = layers.len();
6377    if count == 0 {
6378        return router;
6379    }
6380    tracing::debug!(count, "{what} Tower layers applied");
6381    router.layer(ComposedRegisteredLayers::new(layers))
6382}
6383
6384/// Decide whether `req` clears the trusted-host policy, returning the rejection
6385/// response when it does not.
6386///
6387/// Shared by [`TrustedHostService`] and — through it — every ingress path, so
6388/// the decision lives in exactly one place. Returns `None` for "let it
6389/// through", which is the overwhelmingly common answer and costs no allocation
6390/// at all: the host is borrowed from the request unless it carries an ASCII
6391/// uppercase letter (see [`normalize_trusted_host`]).
6392fn trusted_host_rejection<B>(
6393    req: &Request<B>,
6394    policy: &TrustedHostPolicy,
6395) -> Option<axum::response::Response> {
6396    let path = req.uri().path();
6397    if (req.method() == http::Method::GET || req.method() == http::Method::HEAD)
6398        && policy.probe_bypass_paths.contains(path)
6399    {
6400        return None;
6401    }
6402    let authority = req.uri().authority().map(http::uri::Authority::as_str);
6403    let host_header = req
6404        .headers()
6405        .get(http::header::HOST)
6406        .and_then(|v| v.to_str().ok());
6407    let raw_host = authority.or(host_header);
6408    let parsed_host = raw_host.and_then(extract_host_without_port);
6409    let host = parsed_host.and_then(normalize_trusted_host);
6410    let host_source_present = raw_host.is_some();
6411    if host.is_none() && !host_source_present && policy.allow_missing_host {
6412        return None;
6413    }
6414    // A build render (`autumn build`, an ISR refresh) is made in process and
6415    // carries no `Host`. Only server code can attach the marker, so a client
6416    // request can never take this branch.
6417    if !host_source_present
6418        && req
6419            .extensions()
6420            .get::<crate::static_gen::RenderDeadlineExempt>()
6421            .is_some()
6422    {
6423        return None;
6424    }
6425    if host.as_deref().is_some_and(|host| policy.allows_host(host)) {
6426        return None;
6427    }
6428    tracing::warn!(host = ?host, "trusted host rejected request");
6429    let body = crate::error::problem_details_json_string(
6430        StatusCode::BAD_REQUEST,
6431        "Invalid Host header",
6432        None,
6433        None,
6434        None,
6435        None,
6436        true,
6437    );
6438    Some(
6439        (
6440            StatusCode::BAD_REQUEST,
6441            [(http::header::CONTENT_TYPE, "application/problem+json")],
6442            body,
6443        )
6444            .into_response(),
6445    )
6446}
6447
6448/// Normalise a parsed host for trusted-host matching: strip trailing dots,
6449/// then ASCII-lowercase. `None` when nothing is left.
6450///
6451/// Borrows when the host is already in that form — browsers send a lowercase,
6452/// dot-free host, so that is the per-request case — and copies only a host
6453/// that actually carries an ASCII uppercase letter. Lowercasing neither adds
6454/// nor removes a `.`, so trimming first yields exactly the string that
6455/// lowercasing first would, which is how the rules in
6456/// [`TrustedHostPolicy::from_config`] are normalised.
6457fn normalize_trusted_host(host: &str) -> Option<std::borrow::Cow<'_, str>> {
6458    let host = host.trim_end_matches('.');
6459    if host.is_empty() {
6460        return None;
6461    }
6462    Some(if host.bytes().any(|b| b.is_ascii_uppercase()) {
6463        std::borrow::Cow::Owned(host.to_ascii_lowercase())
6464    } else {
6465        std::borrow::Cow::Borrowed(host)
6466    })
6467}
6468
6469/// Tower [`Layer`](tower::Layer) enforcing [`TrustedHostPolicy`] on the ingress
6470/// path.
6471///
6472/// This used to be an `axum::middleware::from_fn` closure. It is a hand-rolled
6473/// service now because `from_fn` `Box::pin`s the future of whatever it wraps —
6474/// one heap allocation per request, sized by everything the wrapped async block
6475/// captures across its `.await`, which for a layer this far out is the whole
6476/// downstream continuation — plus a `self.inner.clone()` that deep-clones the
6477/// erased stack beneath it. Neither cost depended on whether a request was
6478/// actually rejected (issue #2214).
6479#[derive(Clone, Debug)]
6480pub struct TrustedHostLayer {
6481    policy: TrustedHostPolicy,
6482}
6483
6484impl TrustedHostLayer {
6485    pub(crate) const fn new(policy: TrustedHostPolicy) -> Self {
6486        Self { policy }
6487    }
6488}
6489
6490impl<S> tower::Layer<S> for TrustedHostLayer {
6491    type Service = TrustedHostService<S>;
6492
6493    fn layer(&self, inner: S) -> Self::Service {
6494        TrustedHostService {
6495            inner,
6496            policy: self.policy.clone(),
6497        }
6498    }
6499}
6500
6501/// Tower [`Service`](tower::Service) produced by [`TrustedHostLayer`].
6502#[derive(Clone, Debug)]
6503pub struct TrustedHostService<S> {
6504    inner: S,
6505    policy: TrustedHostPolicy,
6506}
6507
6508impl<S, ReqBody> tower::Service<Request<ReqBody>> for TrustedHostService<S>
6509where
6510    S: tower::Service<Request<ReqBody>, Response = axum::response::Response>,
6511{
6512    type Response = S::Response;
6513    type Error = S::Error;
6514    type Future = crate::middleware::short_circuit::ShortCircuitFuture<S::Future>;
6515
6516    fn poll_ready(
6517        &mut self,
6518        cx: &mut std::task::Context<'_>,
6519    ) -> std::task::Poll<Result<(), Self::Error>> {
6520        self.inner.poll_ready(cx)
6521    }
6522
6523    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
6524        use crate::middleware::short_circuit::ShortCircuitFuture;
6525        trusted_host_rejection(&req, &self.policy).map_or_else(
6526            || ShortCircuitFuture::forward(self.inner.call(req)),
6527            ShortCircuitFuture::short_circuit,
6528        )
6529    }
6530}
6531
6532pub fn extract_host_without_port(header: &str) -> Option<&str> {
6533    let host = header.trim();
6534    if host.is_empty() {
6535        return None;
6536    }
6537    if host.starts_with('[') {
6538        let end = host.find(']')?;
6539        let literal = host.get(1..end)?;
6540        if literal.is_empty() || literal.parse::<std::net::IpAddr>().is_err() {
6541            return None;
6542        }
6543
6544        let remainder = host.get(end + 1..)?;
6545        if remainder.is_empty() {
6546            return Some(literal);
6547        }
6548
6549        let maybe_port = remainder.strip_prefix(':')?;
6550        if !maybe_port.is_empty() && maybe_port.chars().all(|c| c.is_ascii_digit()) {
6551            return Some(literal);
6552        }
6553
6554        return None;
6555    }
6556    let Some((candidate, maybe_port)) = host.rsplit_once(':') else {
6557        return Some(host);
6558    };
6559    if candidate.contains(':') {
6560        // unbracketed IPv6 literal; keep host verbatim
6561        return Some(host);
6562    }
6563    if !maybe_port.is_empty()
6564        && maybe_port.chars().all(|c| c.is_ascii_digit())
6565        && !candidate.is_empty()
6566    {
6567        Some(candidate)
6568    } else {
6569        None
6570    }
6571}
6572
6573/// Build the router with optional static-file-first serving.
6574///
6575/// If `dist_dir` is `Some` and contains a valid `manifest.json`, the
6576/// returned router intercepts GET/HEAD requests whose path appears in
6577/// the manifest and serves pre-built HTML directly — before the dynamic
6578/// router runs.  This matches Next.js SSG/ISR semantics where static
6579/// pages always win over dynamic handlers.
6580///
6581/// Requests not in the manifest (including non-GET/HEAD methods) fall
6582/// through to the dynamic router unchanged.
6583///
6584/// When `dist_dir` is `None` or the manifest is missing, the returned
6585/// router is identical to [`build_router`].
6586///
6587/// This function is public primarily for integration testing.
6588///
6589/// # Panics
6590///
6591/// Panics when framework router assembly encounters invalid configuration.
6592/// Use [`try_build_router_with_static`] to handle configuration errors
6593/// explicitly.
6594#[allow(dead_code)]
6595pub fn build_router_with_static(
6596    route_list: Vec<Route>,
6597    config: &AutumnConfig,
6598    state: AppState,
6599    dist_dir: Option<&std::path::Path>,
6600) -> axum::Router {
6601    try_build_router_with_static(route_list, config, state, dist_dir)
6602        .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
6603}
6604
6605/// Checked variant of [`build_router_with_static`] that returns configuration
6606/// errors instead of panicking.
6607///
6608/// # Errors
6609///
6610/// Returns [`RouterBuildError`] when router assembly encounters invalid
6611/// framework configuration, such as an unusable session backend.
6612#[allow(dead_code)]
6613pub fn try_build_router_with_static(
6614    route_list: Vec<Route>,
6615    config: &AutumnConfig,
6616    state: AppState,
6617    dist_dir: Option<&std::path::Path>,
6618) -> Result<axum::Router, RouterBuildError> {
6619    try_build_router_with_static_inner(
6620        route_list,
6621        config,
6622        state,
6623        dist_dir,
6624        RouterContext {
6625            exception_filters: Vec::new(),
6626            scoped_groups: Vec::new(),
6627            merge_routers: Vec::new(),
6628            nest_routers: Vec::new(),
6629            declared_routes: Vec::new(),
6630            custom_layers: Vec::new(),
6631            static_gate_layers: Vec::new(),
6632            #[cfg(feature = "maud")]
6633            error_page_renderer: None,
6634            session_store: None,
6635            #[cfg(feature = "openapi")]
6636            openapi: None,
6637            #[cfg(feature = "mcp")]
6638            mcp: None,
6639            #[cfg(feature = "webmcp")]
6640            webmcp: None,
6641        },
6642    )
6643}
6644
6645/// Partition `custom_layers` for the static render path (#2405).
6646///
6647/// Returns `(session_scoped, drained)`:
6648/// - `session_scoped`: layers that must stay on the inner (pre-layer) router —
6649///   the i18n ambient-locale layer, which reads the session and therefore
6650///   cannot run outside the static-first middleware (see #1384), and the i18n
6651///   bundle `Extension`, which `Locale::from_request_parts` reads the bundle
6652///   from exclusively. The build drops the drained set outright, so draining
6653///   the bundle would make translated `#[static_get]` handlers write
6654///   translation keys into `dist`. The extension inserts no headers and
6655///   rewrites nothing, so keeping it does not disturb the recorded
6656///   `Content-Type`.
6657/// - `drained`: everything else — the user layers the SSG serve path applies
6658///   outside the static-first middleware, to the cached response, at request
6659///   time.
6660///
6661/// Both the static build (`App::run_build_mode`) and ISR regeneration render
6662/// through the pre-layer router, so the recorded `Content-Type` and the body
6663/// on disk are the handler's own. Recording the post-layer output instead
6664/// double-applies the layers — once at generation, once per request — and,
6665/// because ISR's type guard sees the pre-layer response, refuses every
6666/// regeneration for an app with a `Content-Type`-rewriting layer, freezing
6667/// the route until the next build.
6668#[cfg(feature = "i18n")]
6669pub fn partition_custom_layers_for_static_render(
6670    custom_layers: Vec<crate::app::CustomLayerRegistration>,
6671) -> (
6672    Vec<crate::app::CustomLayerRegistration>,
6673    Vec<crate::app::CustomLayerRegistration>,
6674) {
6675    // #1384: the ambient-locale layer must not drain out with the rest. It runs
6676    // `Locale::from_request_parts`, whose session step reads the signed session,
6677    // and everything drained here is applied outside the static-first middleware
6678    // — that is, outside `SessionLayer`. Out there the session extension does not
6679    // exist yet, so a locale persisted by the documented `set_locale_in_session`
6680    // switcher would be invisible and content would resolve from
6681    // `Accept-Language` instead, disagreeing with the UI chrome on the same page.
6682    // A handler that deliberately takes no `Locale` argument — the point of the
6683    // feature — never runs an extractor later to correct it.
6684    //
6685    // The i18n bundle `Extension` stays with it. `Locale::from_request_parts`
6686    // obtains the bundle exclusively from the request extension that
6687    // `install_i18n_bundle_layer` installs as a custom layer; the build drops
6688    // the drained set, and without the extension the locale would carry no
6689    // bundle, so `t()` would return the raw translation keys into the
6690    // pre-rendered output. Registration order (Extension outermost) is
6691    // preserved by the stable `partition`, so the ambient layer still reads
6692    // the bundle exactly as on the fully-dynamic path.
6693    let keep_type_ids = [
6694        std::any::TypeId::of::<crate::i18n::AmbientLocaleLayer>(),
6695        std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
6696    ];
6697    custom_layers
6698        .into_iter()
6699        .partition(|r| keep_type_ids.contains(&r.type_id))
6700}
6701
6702/// The same partition with the `i18n` feature off: nothing is session-scoped,
6703/// so every custom layer drains.
6704#[cfg(not(feature = "i18n"))]
6705pub const fn partition_custom_layers_for_static_render(
6706    custom_layers: Vec<crate::app::CustomLayerRegistration>,
6707) -> (
6708    Vec<crate::app::CustomLayerRegistration>,
6709    Vec<crate::app::CustomLayerRegistration>,
6710) {
6711    (Vec::new(), custom_layers)
6712}
6713
6714/// For each pre-rendered page, the `GET` route template axum would match
6715/// it to, located with the router's own matcher. A page inside a locale nest
6716/// gets its nest prefix, as axum's `MatchedPath` does. `None` when no route
6717/// matches (a hand-written manifest entry).
6718fn static_page_routes<'a>(
6719    pages: impl Iterator<Item = &'a String>,
6720    route_list: &[Route],
6721    scoped_groups: &[ScopedGroup],
6722    config: &AutumnConfig,
6723) -> std::collections::HashMap<String, Option<String>> {
6724    // Every `GET` route at the path it is mounted at, so the matcher picks
6725    // the route axum would: a localized route under each locale nest, an
6726    // excluded one and a scoped group's at their bare path.
6727    let mut mounted: Vec<String> = Vec::new();
6728    for route in route_list.iter().filter(|r| r.method == http::Method::GET) {
6729        mounted.extend(mounted_paths(route.path, config));
6730    }
6731    mounted.extend(scoped_groups.iter().flat_map(|g| {
6732        g.routes
6733            .iter()
6734            .filter(|r| r.method == http::Method::GET)
6735            .map(|r| join_nested_path(&g.prefix, r.path))
6736    }));
6737    let mut matcher: matchit::Router<String> = matchit::Router::new();
6738    for path in mounted {
6739        // A conflict is the router's to report; the first one stays.
6740        let _ = matcher.insert(path.clone(), path);
6741    }
6742    pages
6743        .map(|page| {
6744            let route = matcher.at(page).ok().map(|hit| hit.value.clone());
6745            (page.clone(), route)
6746        })
6747        .collect()
6748}
6749
6750/// Every route template the app mounts, for any method, as `MatchedPath`
6751/// names it. x402 reads it to tell `/report` from `/report/`.
6752fn routed_templates(
6753    route_list: &[Route],
6754    scoped_groups: &[ScopedGroup],
6755    config: &AutumnConfig,
6756) -> Vec<String> {
6757    let mut mounted: Vec<String> = route_list
6758        .iter()
6759        .flat_map(|r| mounted_paths(r.path, config))
6760        .collect();
6761    mounted.extend(
6762        scoped_groups
6763            .iter()
6764            .flat_map(|g| g.routes.iter().map(|r| join_nested_path(&g.prefix, r.path))),
6765    );
6766    mounted.sort_unstable();
6767    mounted.dedup();
6768    mounted
6769}
6770
6771/// Where a top-level route is mounted, as `MatchedPath` reports it: under
6772/// each locale nest when locale-prefix routing nests it, else as written.
6773#[cfg_attr(not(feature = "i18n"), allow(clippy::unnecessary_wraps))]
6774fn mounted_paths(path: &str, config: &AutumnConfig) -> Vec<String> {
6775    #[cfg(feature = "i18n")]
6776    {
6777        let i18n = &config.i18n;
6778        let excluded = i18n.locale_prefix_exclude_exact.iter().any(|p| p == path)
6779            || matches_locale_exclude_prefix(path, &i18n.locale_prefix_exclude);
6780        if i18n.locale_prefix_enabled && !excluded {
6781            return validated_locale_prefix_locales(i18n)
6782                .iter()
6783                .map(|locale| join_nested_path(&format!("/{locale}"), path))
6784                .collect();
6785        }
6786    }
6787    #[cfg(not(feature = "i18n"))]
6788    let _ = config;
6789    vec![path.to_owned()]
6790}
6791
6792#[allow(clippy::too_many_lines)]
6793pub fn try_build_router_with_static_inner(
6794    route_list: Vec<Route>,
6795    config: &AutumnConfig,
6796    state: AppState,
6797    dist_dir: Option<&std::path::Path>,
6798    mut ctx: RouterContext,
6799) -> Result<axum::Router, RouterBuildError> {
6800    let startup_barrier_state = state.clone();
6801
6802    let Some(dist) = dist_dir else {
6803        let app_router = try_build_router_inner(route_list, config, state, ctx)?;
6804        return Ok(apply_startup_barrier(
6805            app_router,
6806            config,
6807            &startup_barrier_state,
6808        ));
6809    };
6810
6811    let Some(layer) = crate::static_gen::StaticFileLayer::new(dist) else {
6812        tracing::debug!(
6813            dist = %dist.display(),
6814            "No valid manifest.json in dist dir; skipping static file layer"
6815        );
6816        let app_router = try_build_router_inner(route_list, config, state, ctx)?;
6817        return Ok(apply_startup_barrier(
6818            app_router,
6819            config,
6820            &startup_barrier_state,
6821        ));
6822    };
6823
6824    for (route, entry) in &layer.manifest().routes {
6825        tracing::debug!(
6826            route = %route,
6827            file = %entry.file,
6828            revalidate = ?entry.revalidate,
6829            "Static route"
6830        );
6831    }
6832
6833    // Extract user layers before building the inner router. They are applied
6834    // outside the static-first middleware, and outside session, so that:
6835    //   • User layers (e.g. compression) can process pre-rendered responses.
6836    //   • Static serving stays available when the session backend is down.
6837    //   • ISR regeneration uses the inner router (no user layers), so re-rendered
6838    //     pages are saved as raw HTML rather than pre-transformed.
6839    //
6840    // Known limitation — `request_timeout_ms` does not bound these outer layers.
6841    // The per-request timeout lives inside `inner_router`, applied by
6842    // `apply_middleware` inner to `RequestId`. `custom_layers` and
6843    // `static_gate_layers` are reapplied outside the static-first middleware
6844    // below, so they, and the static cache lookup itself, run before the timer
6845    // starts. With a `dist` manifest active, a hung async `static_gate` (e.g.
6846    // remote JWT/IdP validation) or custom layer is therefore unbounded, unlike
6847    // the non-static path. This is the same trade-off as the session-store and
6848    // edge-layer limitations documented in `apply_middleware`: pulling the timer
6849    // out here would place it outside `RequestId` and lose `X-Request-Id` on the
6850    // timeout 503, double-time dynamic misses, and apply a global deadline to
6851    // cached hits with no route-table entry. Bound auth/tenant work in a
6852    // `static_gate` with a layer-level or server/proxy read timeout instead.
6853    //
6854    // Compute the idempotency flag now, while `custom_layers` is still populated,
6855    // then drain it. `build_router_pre_state` would otherwise see an empty list
6856    // and treat opaque layers as absent when selecting per-route idempotency
6857    // behaviour. Pre-static gate layers count too: a `static_gate` used as a
6858    // JWT/stateless auth layer is an opaque app layer for idempotency purposes,
6859    // because idempotency keys exclude `Authorization`, so without fail-closed
6860    // replay a second principal with the same key and body could receive the
6861    // first principal's cached mutation. Include both lists before either drains.
6862    let opaque_present = Some(
6863        custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
6864            || custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers),
6865    );
6866    let custom_layers = std::mem::take(&mut ctx.custom_layers);
6867
6868    // The ambient-locale layer stays on the inner router's context, which
6869    // lands it in `apply_middleware`'s merged tuple — inside `session_layer`
6870    // on both this path and the fully-dynamic one. The i18n bundle
6871    // `Extension` stays on the inner router too: the build drops the drained
6872    // set outright, and `Locale::from_request_parts` reads the bundle from
6873    // that extension exclusively, so draining it would leave translated
6874    // `#[static_get]` handlers writing translation keys into `dist`. The
6875    // partition is stable, so registration order (Extension outermost) is
6876    // preserved and the ambient layer still reads the bundle. Shared with
6877    // the static build (`App::run_build_mode`), which renders through the
6878    // same pre-layer composition — see
6879    // [`partition_custom_layers_for_static_render`].
6880    let (session_scoped, custom_layers) = partition_custom_layers_for_static_render(custom_layers);
6881    ctx.custom_layers = session_scoped;
6882
6883    // Pre-static gate layers (AppBuilder::static_gate) are likewise extracted
6884    // and applied OUTSIDE the static-first middleware (the outermost layer of
6885    // all), so they run before the static cache lookup serves a pre-rendered
6886    // page. Draining them here keeps build_router_pre_state from applying them
6887    // to the inner router (which would place them inside the static middleware
6888    // and defeat the gate for cached hits).
6889    let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
6890
6891    // SSG/ISG path: a single SecurityHeadersLayer is applied OUTSIDE the
6892    // static-first middleware below (wrapping cached pages, dynamic misses, and
6893    // the gate), so the inner router must NOT apply its own — hence `true`.
6894    //
6895    // `custom_layers` was just drained out of `ctx` above so it can be
6896    // reapplied to the *live-serving* router outside the static-first
6897    // middleware (below). Left at that, a `tools/call` MCP replay — dispatched
6898    // against a clone taken inside `build_router_pre_state`, before this
6899    // point — would never see it at all: unlike `static_gate` (deliberately
6900    // excluded from MCP dispatch everywhere, see the `mcp_prepared` comment),
6901    // `AppBuilder::layer` is a documented, unrestricted way to add a real
6902    // per-path check, and MCP is documented to dispatch through the same
6903    // pipeline a direct call gets. Hand `build_router_pre_state` a *clone* of
6904    // the same drained set for its dispatch clone alone, so replay parity
6905    // holds without changing anything about how these layers wrap the
6906    // live-serving router below (🛡 Warden,
6907    // docs/security/2026-09-07-mcp-custom-layer-static-mode/).
6908    //
6909    // Known limitation: this calls `Layer::layer()` on the registered
6910    // layer(s) a *second* time (once here for the dispatch clone, once below
6911    // for the live-serving router) — unlike every other mode/path, which
6912    // calls it exactly once and shares the resulting *service* by cloning
6913    // the already-built router. A layer that allocates its enforcement state
6914    // inside `layer()` itself, rather than constructing it once and sharing
6915    // it via `Arc` — e.g. `tower::limit::ConcurrencyLimitLayer`, which Tower
6916    // documents as a *per-service* limit for exactly this reason, contrasted
6917    // with `GlobalConcurrencyLimitLayer`'s shared `Arc<Semaphore>` — gets two
6918    // independent instances of that state, so direct and MCP traffic are
6919    // capped separately instead of against one shared app-wide budget.
6920    // Unavoidable without either of two worse regressions: taking dispatch
6921    // from a service that already carries this application would need it
6922    // downstream of the static-first middleware (whose whole point is
6923    // deciding whether the real handler runs at all — a `tools/call` must
6924    // never be answered from a stale cached page), or applying custom_layers
6925    // before that middleware would stop them from processing cached-page
6926    // responses, the reason they sit outside it in the first place. Use a
6927    // layer that owns its shared state behind an `Arc` (constructed once,
6928    // cloned into the `Layer` value) rather than allocating inside
6929    // `Layer::layer()`, and this is a non-issue — the same discipline the
6930    // framework's own mirrored layers on `mcp_router` below already follow.
6931    #[cfg(feature = "mcp")]
6932    let mcp_dispatch_extra_layers = custom_layers.clone();
6933    #[cfg(not(feature = "mcp"))]
6934    let mcp_dispatch_extra_layers = Vec::new();
6935    let static_paths = Arc::new(static_page_routes(
6936        layer.manifest().routes.keys(),
6937        &route_list,
6938        &ctx.scoped_groups,
6939        config,
6940    ));
6941    let (inner_router, deferred_mcp_router) = build_router_pre_state(
6942        route_list,
6943        config,
6944        &state,
6945        ctx,
6946        opaque_present,
6947        true,
6948        mcp_dispatch_extra_layers,
6949    )?;
6950    // After `build_router_pre_state`, which records the routes x402 reads.
6951    let (priced_static, unrouted_static) =
6952        crate::aeo::commerce::priced_static_pages(config, &state, &static_paths);
6953    if !unrouted_static.is_empty() {
6954        tracing::warn!(
6955            pages = ?unrouted_static.keys().collect::<Vec<_>>(),
6956            "aeo: x402 prices pre-rendered pages no route renders; they are served from \
6957             the manifest, before rate limiting, bot protection, load shedding and CORS"
6958        );
6959    }
6960    let priced_static = Arc::new(priced_static);
6961
6962    // Attach the inner router for ISR background regeneration. Because user
6963    // layers are excluded, re-renders produce raw HTML (no compression, etc.)
6964    // that is then saved to disk and served with user-layer processing applied
6965    // at request time.
6966    let has_isr = layer
6967        .manifest()
6968        .routes
6969        .values()
6970        .any(|e| e.revalidate.is_some());
6971    let layer = if has_isr {
6972        // The inner router defers `SecurityHeadersLayer` to the single outer
6973        // application (see `defer_security_headers`), but ISR background
6974        // regeneration drives this router directly and never reaches that layer.
6975        // `SecurityHeadersLayer` also injects `CspNonce` into request extensions,
6976        // so without it a handler using the `CspNonce` extractor would 500 during
6977        // regeneration and the stale file would never refresh. Re-attach it on
6978        // the regeneration router only. Its response headers are discarded — only
6979        // the rendered HTML body is persisted — so live-request headers are
6980        // unaffected and no duplicate-header or nonce conflict arises.
6981        let regen_router = inner_router
6982            .clone()
6983            .layer(crate::security::SecurityHeadersLayer::from_config(
6984                &config.security.headers,
6985            ))
6986            .with_state(state.clone());
6987        layer.with_router(regen_router)
6988    } else {
6989        layer
6990    };
6991    let layer = Arc::new(layer);
6992
6993    // Static-first serving: intercept GET/HEAD requests whose path is in the
6994    // manifest and serve pre-built HTML directly, before the dynamic router and
6995    // session layer run. Static pages stay available when the session backend is
6996    // down. Requests not in the manifest, including other methods, fall through
6997    // to the dynamic router unchanged. `resolve()` checks ISR staleness: a stale
6998    // page is served immediately while regeneration runs in the background
6999    // (stale-while-revalidate).
7000    let static_layer = layer;
7001    let mut router: axum::Router<AppState> = inner_router.layer(axum::middleware::from_fn(
7002        move |req: axum::extract::Request, next: axum::middleware::Next| {
7003            let static_layer = static_layer.clone();
7004            let priced_static = Arc::clone(&priced_static);
7005            async move {
7006                let is_get = req.method() == http::Method::GET;
7007                let is_head = req.method() == http::Method::HEAD;
7008                // A Markdown `HEAD` keeps the page bytes, already read here,
7009                // so the negotiation layer converts them as for `GET` and
7010                // sets the Markdown's length and token count. The server
7011                // sends no `HEAD` body either way.
7012                let head_for_markdown =
7013                    is_head && crate::aeo::negotiate::prefers_markdown(req.headers());
7014                if is_get || is_head {
7015                    let path = req.uri().path();
7016                    // Normalize trailing slash: /about/ → /about (but keep / as /)
7017                    let normalized = if path.len() > 1 && path.ends_with('/') {
7018                        &path[..path.len() - 1]
7019                    } else {
7020                        path
7021                    };
7022                    // A page x402 prices renders through the router, so the
7023                    // ingress limits and CORS run before the facilitator call.
7024                    if priced_static.contains(normalized) {
7025                        return next.run(req).await;
7026                    }
7027                    if let Some(hit) = static_layer.resolve_entry(normalized)
7028                        && let Ok(contents) = tokio::fs::read(&hit.file_path).await
7029                    {
7030                        // #1832: `resolve_entry` returns the Content-Type the
7031                        // manifest recorded at generation time (see
7032                        // `static_gen::resolved_content_type` for the ordering
7033                        // and the legacy fallback). It is a `HeaderValue`, so
7034                        // the builder below cannot fail on manifest content.
7035                        // An accurate type matters here because the compression
7036                        // layer is applied outside this middleware and
7037                        // negotiates gzip/brotli by content type: it encodes
7038                        // compressible SSG pages and leaves binary assets alone.
7039                        // A `HEAD` carries the length the `GET` would send, so
7040                        // a layer outside (Markdown negotiation) can tell
7041                        // whether that body fits.
7042                        let len = contents.len();
7043                        let empty_head = is_head && !head_for_markdown;
7044                        let body = if empty_head {
7045                            axum::body::Body::empty()
7046                        } else {
7047                            axum::body::Body::from(contents)
7048                        };
7049                        let mut response = http::Response::builder()
7050                            .status(http::StatusCode::OK)
7051                            .header(http::header::CONTENT_TYPE, hit.content_type);
7052                        if empty_head {
7053                            response = response.header(http::header::CONTENT_LENGTH, len);
7054                        }
7055                        return response.body(body).expect("infallible response builder");
7056                    }
7057                }
7058                next.run(req).await
7059            }
7060        },
7061    ));
7062
7063    // Apply user layers OUTSIDE the static middleware so they wrap it and can
7064    // process both static and dynamic responses (e.g. compress the HTML on
7065    // the way out). The first registered layer ends up outermost — matching
7066    // `tower::ServiceBuilder` ordering; the fold that gets it there lives in
7067    // `apply_layers_in_registration_order` / `ComposedRegisteredLayers`.
7068    router = apply_layers_in_registration_order(
7069        router,
7070        custom_layers,
7071        "Custom (outside static middleware)",
7072    );
7073
7074    // Merge the `/mcp` router in now — right after `custom_layers`, not
7075    // before. `build_router_pre_state` held it back (see the `mcp_prepared`
7076    // comment there) specifically so the live `/mcp` envelope never
7077    // traverses `custom_layers`: it already got its own copy applied to the
7078    // *dispatch clone* (`mcp_dispatch_extra_layers` above), and merging
7079    // `mcp_router` in before this point — inside `custom_layers`, as the
7080    // fully-dynamic path never does — would mean every `tools/call` runs
7081    // each custom layer twice (once for the envelope, once for the replay).
7082    // `mcp_router` is fully self-contained (its own security headers, rate
7083    // limit, timeout, CORS — see the comments in `build_router_pre_state`),
7084    // so where exactly it merges relative to the shadow/compression/
7085    // static_gate/security-headers wraps below doesn't matter; only staying
7086    // outside `custom_layers` does.
7087    // Fault injection (#3071), for the same reason as shadow mirroring below:
7088    // a pre-rendered page never reaches the inner router, so the layers also
7089    // go here. Before the MCP merge, so they do not wrap the `/mcp` envelope;
7090    // its replays go through the inner layers, as on the dynamic path.
7091    router = install_outer_fault_injection(router, config, &state);
7092
7093    if let Some(mcp_router) = deferred_mcp_router {
7094        router = router.merge(mcp_router);
7095    }
7096
7097    // Shadow mirroring (#1653) sits here — outside the static-first middleware
7098    // and inside compression — rather than in `apply_middleware`, which is why
7099    // that call passed `defer_shadow = true`. A request the static cache answers
7100    // never reaches the inner router, so a layer installed there would see only
7101    // dynamic misses: a shadow run over an SSG/ISG app would report clean while
7102    // every pre-rendered page the candidate generated differently went
7103    // uncompared. Outer to the user layers and inner to compression, matching its
7104    // position in the dynamic path.
7105    //
7106    // The AEO response layer and x402 sit here too, for the same reason: a
7107    // pre-rendered page must be negotiated and charged. One `Router::layer`
7108    // call for all three keeps the SSG path one box level deep (#2193). The
7109    // inner x402 copy skips a request this one already handled.
7110    router = router.layer((
7111        tower::util::option_layer(build_shadow_layer(config, &state)),
7112        tower::util::option_layer(
7113            state
7114                .extension::<crate::aeo::AeoSite>()
7115                .and_then(|site| site.layer()),
7116        ),
7117        // Only for the priced pages no route renders: the static-first
7118        // middleware leaves every routed priced page to the router, whose copy
7119        // prices the route axum matched behind the ingress limits.
7120        aeo_x402_layer(config, &state, Some(Arc::new(unrouted_static))),
7121    ));
7122
7123    // Compression is applied outside the static-first middleware too, so
7124    // pre-rendered HTML that `StaticFileLayer` serves without reaching
7125    // `inner_router` is still compressed. This mirrors the placement in
7126    // `apply_middleware` for the dynamic-only path.
7127    router = apply_compression_middleware(router, config);
7128
7129    // Pre-static gate layers run before the static cache lookup (they wrap the
7130    // static-first middleware) so they can redirect / reject a request before a
7131    // cached SSG/ISG page is served. They are applied INNER to the
7132    // SecurityHeadersLayer below so that a gate's short-circuit response
7133    // (redirect / 401) still carries the framework security headers (HSTS/CSP,
7134    // etc.) — matching the headers a normal cached or dynamic response gets.
7135    router = apply_layers_in_registration_order(
7136        router,
7137        static_gate_layers,
7138        "Pre-static gate (outside static middleware)",
7139    );
7140
7141    // mTLS route requirement (#1640), a SECOND time. The copy inside
7142    // `inner_router` covers dynamic routes and the MCP dispatch clone, but the
7143    // static-first middleware above answers a manifest hit from disk without
7144    // ever calling that router — so a pre-rendered page under a
7145    // `required_paths` prefix would be served to an uncertified client while
7146    // the posture manifest reported `mtls_required: true`. Applied here, beside
7147    // the pre-static gates and for the same reason they are: a cached page must
7148    // not outrank the check that guards it.
7149    //
7150    // Applying it twice is harmless: on a rejection this outer copy
7151    // short-circuits, so the inner one never runs and the counter moves once;
7152    // on a pass both are no-ops. Inner to `SecurityHeadersLayer` below, like the
7153    // gates, so the 403 carries the same headers every other response does.
7154    if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
7155        router = router.layer(require_client_cert);
7156    }
7157
7158    // Security headers are applied OUTERMOST so they wrap both cached pages and
7159    // any gate short-circuit response. This is the SINGLE application for the
7160    // SSG/ISG path: the inner router skips it (build_router_pre_state is called
7161    // with `defer_security_headers = true`), so dynamic misses are not
7162    // double-wrapped (which would break CSP nonces).
7163    let router = router.layer(crate::security::SecurityHeadersLayer::from_config(
7164        &config.security.headers,
7165    ));
7166
7167    Ok(apply_startup_barrier(
7168        router.with_state(state),
7169        config,
7170        &startup_barrier_state,
7171    ))
7172}
7173
7174#[derive(Clone)]
7175pub struct StartupBarrierState {
7176    app_state: AppState,
7177    // Canonical exact-match probe/health paths (`probe_bypass_paths`), the
7178    // single source of truth shared with `TrustedHostPolicy` and the
7179    // maintenance/load-shed gates — see that function's doc comment.
7180    probe_paths: Vec<String>,
7181    actuator_paths: Vec<String>,
7182    actuator_subtree_paths: Vec<String>,
7183}
7184
7185impl StartupBarrierState {
7186    fn from_config(config: &AutumnConfig, app_state: &AppState) -> Self {
7187        let actuator_subtree_paths = if config.actuator.sensitive {
7188            vec![crate::actuator::actuator_route_path(
7189                &config.actuator.prefix,
7190                "/loggers",
7191            )]
7192        } else {
7193            Vec::new()
7194        };
7195
7196        Self {
7197            app_state: app_state.clone(),
7198            probe_paths: probe_bypass_paths(config),
7199            actuator_paths: crate::actuator::actuator_endpoint_paths(
7200                &config.actuator.prefix,
7201                config.actuator.sensitive,
7202                config.actuator.prometheus,
7203            ),
7204            actuator_subtree_paths,
7205        }
7206    }
7207
7208    fn allows_path(&self, path: &str) -> bool {
7209        self.probe_paths.iter().any(|allowed| path == allowed)
7210            || self.actuator_paths.iter().any(|allowed| path == allowed)
7211            || self
7212                .actuator_subtree_paths
7213                .iter()
7214                .any(|allowed| path_matches_route_prefix(path, allowed))
7215    }
7216}
7217
7218fn apply_startup_barrier(
7219    router: axum::Router,
7220    config: &AutumnConfig,
7221    state: &AppState,
7222) -> axum::Router {
7223    let barrier_state = StartupBarrierState::from_config(config, state);
7224
7225    // These four are the outermost layers on every production build path, so they
7226    // are composed into one tuple and applied with a single `Router::layer` call.
7227    // Four separate calls would nest four `BoxCloneSyncService` levels around
7228    // every route, and axum deep-clones that nest on each request (#2193).
7229    //
7230    // ⚠ Tuple order is OUTERMOST FIRST — the opposite of consecutive
7231    // `Router::layer` calls, where the last call ends up outermost.
7232    //
7233    // W3C Trace Context propagation wraps the startup barrier and the
7234    // static-first middleware above it, so short-circuit responses — startup 503s
7235    // and pre-built static hits — still extract the incoming `traceparent` and
7236    // inject the current context into the response. It is applied here rather
7237    // than inside `apply_middleware` because those outer wrappers can return
7238    // without invoking the inner router. Outer to AccessLog, so the access event
7239    // is emitted while the trace context is current.
7240    #[cfg(feature = "telemetry-otlp")]
7241    let trace_context = crate::middleware::TraceContextLayer;
7242    #[cfg(not(feature = "telemetry-otlp"))]
7243    let trace_context = tower::layer::util::Identity::new();
7244
7245    // Server-Timing fallback (#1348), applied outside the startup barrier, the
7246    // static-first (SSG/ISR) middleware, the session layer, and the late MCP
7247    // merge — the short-circuit paths the primary `ServerTimingLayer` in
7248    // `apply_middleware` never sees. Gated on the same `server_timing_enabled`
7249    // resolver as the primary. It appends only for responses missing the
7250    // `ServerTimingEmitted` marker, so a request reaching the primary carries one
7251    // `total` and a short-circuit gets its `total` here.
7252    let server_timing_fallback = crate::config::server_timing_enabled(config)
7253        .then(|| crate::middleware::ServerTimingLayer::fallback(true));
7254
7255    // Access-log fallback (#999), applied outside the startup barrier, the
7256    // static-first (SSG/ISR) middleware, the session layer, and the
7257    // exception-filter chain. Every production build path funnels through this
7258    // function, including after the late MCP merge. It emits only for responses
7259    // the primary in-stack layer never saw, checking the `AccessLogEmitted`
7260    // marker, so startup 503s, pre-built static hits, session-store outage 503s,
7261    // and MCP requests get an access line too. Those short-circuits never ran
7262    // `RequestIdLayer`, so the fallback reads `x-request-id` from the response
7263    // when present and logs without a request id otherwise.
7264    let access_log_fallback = config.log.access_log.then(|| {
7265        crate::middleware::AccessLogLayer::fallback(config.log.access_log_exclude.clone())
7266    });
7267
7268    // Cost fallback (#1720), for the same short-circuit paths. It records only
7269    // a response without the primary `CostLayer`'s marker, so a request counts
7270    // one time. Inside the Server-Timing fallback, as the primary is.
7271    let cost_fallback = config
7272        .cost
7273        .enabled
7274        .then(|| state.extension::<crate::cost::CostAccountant>())
7275        .flatten()
7276        .map(|accountant| {
7277            crate::middleware::CostLayer::fallback(
7278                (*accountant).clone(),
7279                crate::config::server_timing_enabled(config),
7280            )
7281        });
7282
7283    router.layer((
7284        trace_context,
7285        tower::util::option_layer(server_timing_fallback),
7286        tower::util::option_layer(cost_fallback),
7287        tower::util::option_layer(access_log_fallback),
7288        StartupBarrierLayer::new(barrier_state),
7289    ))
7290}
7291
7292/// Tower [`Layer`](tower::Layer) for the startup readiness barrier: requests are refused with
7293/// `503 Service is still starting up` until the app reports startup complete,
7294/// except on the paths the barrier lets through (probes, actuator).
7295///
7296/// A hand-rolled service rather than an `axum::middleware::from_fn`: this is the
7297/// outermost layer inside the `Router`, so `from_fn`'s per-request `Box::pin`
7298/// captured the entire downstream continuation and its `self.inner.clone()`
7299/// deep-cloned the whole erased stack — on every request, for a check that
7300/// passes on every request after the first few seconds of process life
7301/// (issue #2214).
7302///
7303/// The state is held behind an `Arc` because the produced service is cloned on
7304/// the request path — once per traversal of the stack above it — and
7305/// `StartupBarrierState` owns an `AppState` plus three `Vec<String>` path lists.
7306/// Holding it by value would deep-copy all three on every one of those clones,
7307/// which is the cost #2193 removed elsewhere in this stack.
7308#[derive(Clone)]
7309pub struct StartupBarrierLayer {
7310    state: Arc<StartupBarrierState>,
7311}
7312
7313impl StartupBarrierLayer {
7314    pub(crate) fn new(state: StartupBarrierState) -> Self {
7315        Self {
7316            state: Arc::new(state),
7317        }
7318    }
7319}
7320
7321impl<S> tower::Layer<S> for StartupBarrierLayer {
7322    type Service = StartupBarrierService<S>;
7323
7324    fn layer(&self, inner: S) -> Self::Service {
7325        StartupBarrierService {
7326            inner,
7327            state: Arc::clone(&self.state),
7328        }
7329    }
7330}
7331
7332/// Tower [`Service`](tower::Service) produced by [`StartupBarrierLayer`].
7333#[derive(Clone)]
7334pub struct StartupBarrierService<S> {
7335    inner: S,
7336    state: Arc<StartupBarrierState>,
7337}
7338
7339impl<S, ReqBody> tower::Service<Request<ReqBody>> for StartupBarrierService<S>
7340where
7341    S: tower::Service<Request<ReqBody>, Response = axum::response::Response>,
7342{
7343    type Response = S::Response;
7344    type Error = S::Error;
7345    type Future = crate::middleware::short_circuit::ShortCircuitFuture<S::Future>;
7346
7347    fn poll_ready(
7348        &mut self,
7349        cx: &mut std::task::Context<'_>,
7350    ) -> std::task::Poll<Result<(), Self::Error>> {
7351        self.inner.poll_ready(cx)
7352    }
7353
7354    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
7355        use crate::middleware::short_circuit::ShortCircuitFuture;
7356        if crate::app::is_static_build_mode()
7357            || self.state.app_state.probes().is_startup_complete()
7358            || self.state.allows_path(req.uri().path())
7359        {
7360            ShortCircuitFuture::forward(self.inner.call(req))
7361        } else {
7362            ShortCircuitFuture::short_circuit(
7363                (
7364                    StatusCode::SERVICE_UNAVAILABLE,
7365                    "Service is still starting up",
7366                )
7367                    .into_response(),
7368            )
7369        }
7370    }
7371}
7372
7373pub fn path_matches_route_prefix(path: &str, prefix: &str) -> bool {
7374    path == prefix
7375        || path
7376            .strip_prefix(prefix)
7377            .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
7378}
7379
7380/// Build a `tower_http::cors::CorsLayer` from the framework's [`crate::config::CorsConfig`].
7381///
7382/// Called only when `config.cors.allowed_origins` is non-empty.
7383pub fn build_cors_layer(cors: &crate::config::CorsConfig) -> tower_http::cors::CorsLayer {
7384    use http::header::HeaderName;
7385    use tower_http::cors::{AllowOrigin, CorsLayer};
7386
7387    let layer = if cors.allowed_origins.iter().any(|o| o == "*") {
7388        CorsLayer::new().allow_origin(AllowOrigin::any())
7389    } else {
7390        let origins: Vec<http::HeaderValue> = cors
7391            .allowed_origins
7392            .iter()
7393            .filter_map(|o| match o.parse() {
7394                Ok(v) => Some(v),
7395                Err(e) => {
7396                    tracing::warn!(origin = %o, error = %e, "CORS: ignoring malformed allowed_origin");
7397                    None
7398                }
7399            })
7400            .collect();
7401        CorsLayer::new().allow_origin(origins)
7402    };
7403
7404    let methods: Vec<http::Method> = cors
7405        .allowed_methods
7406        .iter()
7407        .filter_map(|m| match m.parse() {
7408            Ok(v) => Some(v),
7409            Err(e) => {
7410                tracing::warn!(method = %m, error = %e, "CORS: ignoring malformed allowed_method");
7411                None
7412            }
7413        })
7414        .collect();
7415
7416    let headers: Vec<HeaderName> = cors
7417        .allowed_headers
7418        .iter()
7419        .filter_map(|h| match h.parse() {
7420            Ok(v) => Some(v),
7421            Err(e) => {
7422                tracing::warn!(header = %h, error = %e, "CORS: ignoring malformed allowed_header");
7423                None
7424            }
7425        })
7426        .collect();
7427
7428    layer
7429        .allow_methods(methods)
7430        .allow_headers(headers)
7431        .allow_credentials(cors.allow_credentials)
7432        .max_age(std::time::Duration::from_secs(cors.max_age_secs))
7433}
7434
7435/// Mirror onto a timeout-generated 503 the CORS response headers `CorsLayer`
7436/// would add to a normal (non-preflight) response.
7437///
7438/// In the main ingress stack the per-request timeout layer sits *outside*
7439/// `CorsLayer` (see the layer order in `apply_middleware`), so a 503 it
7440/// synthesizes on expiry never flows back through `CorsLayer`. Without this a
7441/// cross-origin browser client sees an opaque CORS failure instead of the
7442/// documented Problem Details 503. Only the simple-response subset is needed:
7443/// the resolved `Access-Control-Allow-Origin` (with `Vary: origin` when it is
7444/// reflected) and `Access-Control-Allow-Credentials`. Preflight (OPTIONS)
7445/// requests are answered by `CorsLayer` directly and never reach the timer.
7446/// Mirror the `Access-Control-*` response headers a real `CorsLayer` would
7447/// have added, onto a `response` synthesized by a layer that sits outside
7448/// (outer to) `CorsLayer` in the ingress stack — so its 503 is CORS-readable
7449/// instead of the client seeing an opaque CORS failure. Shared by the
7450/// per-request timeout middleware and [`crate::middleware::LoadShedLayer`],
7451/// the two admission-style gates that can short-circuit before `CorsLayer`
7452/// runs.
7453pub fn mirror_cors_headers(
7454    cors: &crate::config::CorsConfig,
7455    origin: Option<&http::HeaderValue>,
7456    response: &mut axum::response::Response,
7457) {
7458    use http::header;
7459    let allow_any = cors.allowed_origins.iter().any(|o| o == "*");
7460    let allow_origin = if allow_any {
7461        Some(http::HeaderValue::from_static("*"))
7462    } else {
7463        // Echo the request Origin iff it is in the configured allowlist, exactly
7464        // as `CorsLayer` does for a reflected origin.
7465        origin.and_then(|value| {
7466            let value_str = value.to_str().ok()?;
7467            cors.allowed_origins
7468                .iter()
7469                .any(|allowed| allowed == value_str)
7470                .then(|| value.clone())
7471        })
7472    };
7473    let Some(allow_origin) = allow_origin else {
7474        // Origin missing or not allowed: a real `CorsLayer` would add nothing.
7475        return;
7476    };
7477    let headers = response.headers_mut();
7478    headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin);
7479    if !allow_any {
7480        // A reflected origin makes the response origin-dependent; mirror the
7481        // `Vary: origin` `CorsLayer` adds so shared caches don't serve it to a
7482        // different origin.
7483        headers.insert(header::VARY, http::HeaderValue::from_static("origin"));
7484    }
7485    if cors.allow_credentials {
7486        headers.insert(
7487            header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
7488            http::HeaderValue::from_static("true"),
7489        );
7490    }
7491}
7492
7493/// Router for the framework's own scripts: each served script at its plain
7494/// path and its content-hashed path. See [`crate::htmx::FRAMEWORK_SCRIPTS`].
7495#[cfg(feature = "htmx")]
7496fn framework_scripts_router() -> axum::Router<AppState> {
7497    crate::assets::plugin::routes_for(crate::htmx::framework_scripts(), "/static", "/static")
7498}
7499
7500/// Gzip/brotli encodings of a compile-time-constant CSS body, computed once
7501/// per process (via a call-site-owned [`std::sync::OnceLock`], see
7502/// [`flash_css_handler`]/[`widgets_css_handler`]) rather than redone on every
7503/// request — the bytes never change, so recompressing them per-request would
7504/// burn CPU for a byte-identical result each time.
7505#[cfg(any(feature = "flash", feature = "maud"))]
7506struct PrecompressedCss {
7507    gzip: bytes::Bytes,
7508    brotli: bytes::Bytes,
7509}
7510
7511#[cfg(any(feature = "flash", feature = "maud"))]
7512impl PrecompressedCss {
7513    fn compute(body: &'static str) -> Self {
7514        use std::io::Write as _;
7515
7516        let mut gzip_encoder =
7517            flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
7518        gzip_encoder
7519            .write_all(body.as_bytes())
7520            .expect("in-memory gzip encoding cannot fail");
7521        let gzip = gzip_encoder
7522            .finish()
7523            .expect("in-memory gzip encoding cannot fail");
7524
7525        let mut brotli_writer = brotli::CompressorWriter::new(Vec::new(), 4096, 11, 22);
7526        brotli_writer
7527            .write_all(body.as_bytes())
7528            .expect("in-memory brotli encoding cannot fail");
7529        let brotli = brotli_writer.into_inner();
7530
7531        Self {
7532            gzip: gzip.into(),
7533            brotli: brotli.into(),
7534        }
7535    }
7536}
7537
7538/// `true` when the request's `Accept-Encoding` header accepts `coding`
7539/// (case-insensitive, comma-separated, honoring an explicit `q=0` opt-out
7540/// per RFC 7231 §5.3.4). A minimal parser rather than a full content-
7541/// negotiation crate, since only `gzip`/`br` ever need checking here.
7542#[cfg(any(feature = "flash", feature = "maud"))]
7543fn accepts_encoding(headers: &http::HeaderMap, coding: &str) -> bool {
7544    let Some(value) = headers
7545        .get(http::header::ACCEPT_ENCODING)
7546        .and_then(|v| v.to_str().ok())
7547    else {
7548        return false;
7549    };
7550    value.split(',').any(|part| {
7551        let mut segments = part.split(';');
7552        let name = segments.next().unwrap_or("").trim();
7553        name.eq_ignore_ascii_case(coding)
7554            && segments
7555                .find_map(|q| q.trim().strip_prefix("q="))
7556                .and_then(|q| q.parse::<f32>().ok())
7557                .is_none_or(|q| q > 0.0)
7558    })
7559}
7560
7561/// Serves a framework-owned, compile-time-constant CSS asset: same-origin,
7562/// immutably cached, conditional-GET aware (a strong `ETag` hashed from
7563/// `body`, so a revalidating client gets a bodyless `304` instead of the
7564/// full asset), and served pre-compressed from `precompressed` when the
7565/// client's `Accept-Encoding` allows it — computed once per process, not
7566/// per request. Shared by every framework CSS route ([`flash_css_handler`],
7567/// [`widgets_css_handler`]) so the caching/content-type/compression policy
7568/// lives in one place.
7569#[cfg(any(feature = "flash", feature = "maud"))]
7570fn static_css_response(
7571    headers: &http::HeaderMap,
7572    body: &'static str,
7573    precompressed: &'static PrecompressedCss,
7574) -> axum::response::Response {
7575    use crate::etag::IntoETag as _;
7576    use axum::response::IntoResponse;
7577
7578    let (encoded_body, content_encoding): (axum::body::Body, Option<&'static str>) =
7579        if accepts_encoding(headers, "br") {
7580            (precompressed.brotli.clone().into(), Some("br"))
7581        } else if accepts_encoding(headers, "gzip") {
7582            (precompressed.gzip.clone().into(), Some("gzip"))
7583        } else {
7584            (body.into(), None)
7585        };
7586
7587    let mut response_headers = http::HeaderMap::new();
7588    response_headers.insert(
7589        http::header::CONTENT_TYPE,
7590        http::HeaderValue::from_static("text/css; charset=utf-8"),
7591    );
7592    response_headers.insert(
7593        http::header::CACHE_CONTROL,
7594        http::HeaderValue::from_static("public, max-age=31536000, immutable"),
7595    );
7596    // Cache intermediaries must key on the request's Accept-Encoding since
7597    // the body served for this same URL differs (plain/gzip/br).
7598    response_headers.insert(
7599        http::header::VARY,
7600        http::HeaderValue::from_static("Accept-Encoding"),
7601    );
7602    if let Some(encoding) = content_encoding {
7603        response_headers.insert(
7604            http::header::CONTENT_ENCODING,
7605            http::HeaderValue::from_static(encoding),
7606        );
7607    }
7608
7609    // A weak validator: the identity/gzip/br byte streams served for this
7610    // one logical resource are not byte-identical, so a strong ETag (which
7611    // asserts byte-for-byte equivalence — see `ETag::strong`) would be
7612    // incorrect here, even though `Vary: Accept-Encoding` already keeps
7613    // cache entries for different encodings distinct.
7614    let etag = crate::etag::ETag::weak(body.into_etag().tag().to_owned());
7615
7616    crate::etag::fresh_when(headers, etag)
7617        .or((response_headers, encoded_body))
7618        .into_response()
7619}
7620
7621/// Serves the framework's default flash-message stylesheet
7622/// ([`crate::flash::FLASH_CSS`]) at [`crate::flash::FLASH_CSS_PATH`].
7623#[cfg(feature = "flash")]
7624pub async fn flash_css_handler(headers: http::HeaderMap) -> axum::response::Response {
7625    static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
7626    static_css_response(
7627        &headers,
7628        crate::flash::FLASH_CSS,
7629        PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::flash::FLASH_CSS)),
7630    )
7631}
7632
7633/// Serves the framework's widget stylesheet ([`crate::ui::WIDGETS_CSS`]) at
7634/// [`crate::ui::WIDGETS_CSS_PATH`] (#1215).
7635#[cfg(feature = "maud")]
7636pub async fn widgets_css_handler(headers: http::HeaderMap) -> axum::response::Response {
7637    static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
7638    static_css_response(
7639        &headers,
7640        crate::ui::WIDGETS_CSS,
7641        PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::ui::WIDGETS_CSS)),
7642    )
7643}
7644
7645#[cfg(feature = "openapi")]
7646pub fn collect_openapi_docs(
7647    route_list: &[Route],
7648    scoped_groups: &[ScopedGroup],
7649) -> Vec<crate::openapi::ApiDoc> {
7650    // Walk both top-level routes and scoped groups. For scoped groups the
7651    // effective path is `prefix + route.path`; we materialize these into
7652    // fresh `ApiDoc`s so the rendered spec reflects the actual URL the
7653    // user will call.
7654    let mut docs: Vec<crate::openapi::ApiDoc> = Vec::new();
7655    for route in route_list {
7656        let mut doc = route.api_doc.clone();
7657        doc.api_version = route.api_version;
7658        doc.sunset_opt_out = route.sunset_opt_out;
7659        docs.push(doc);
7660    }
7661    for group in scoped_groups {
7662        // Extract `{name}` captures from the scope prefix so parameters
7663        // declared in the prefix (e.g. `/orgs/{org_id}`) show up on the
7664        // generated operation alongside the child route's own params.
7665        let prefix_params = extract_path_params(&group.prefix);
7666        for route in &group.routes {
7667            let mut doc = route.api_doc.clone();
7668            doc.api_version = route.api_version;
7669            doc.sunset_opt_out = route.sunset_opt_out;
7670            // Leak the combined path so it fits the `&'static str` shape of
7671            // ApiDoc. The spec is built once per process; the leak is
7672            // bounded by the route table size. Using the same
7673            // normalization as `join_nested_path` keeps the spec's
7674            // paths aligned with the URLs axum actually routes.
7675            let full = join_nested_path(&group.prefix, route.api_doc.path);
7676            doc.path = Box::leak(full.into_boxed_str());
7677
7678            if !prefix_params.is_empty() {
7679                let mut merged: Vec<&'static str> = prefix_params
7680                    .iter()
7681                    .map(|p| &*Box::leak(p.clone().into_boxed_str()))
7682                    .collect();
7683                for existing in route.api_doc.path_params {
7684                    if !merged.iter().any(|n| n == existing) {
7685                        merged.push(existing);
7686                    }
7687                }
7688                doc.path_params = Box::leak(merged.into_boxed_slice());
7689            }
7690
7691            docs.push(doc);
7692        }
7693    }
7694    docs
7695}
7696
7697#[cfg(feature = "openapi")]
7698fn mount_swagger_ui_routes(
7699    mut router: axum::Router<AppState>,
7700    path: &str,
7701    title: &str,
7702    json_path: &str,
7703) -> axum::Router<AppState> {
7704    let [css_path, bundle_path, initializer_path] = crate::openapi::swagger_ui_asset_paths(path);
7705    let html_body = Arc::new(crate::openapi::swagger_ui_html(
7706        title,
7707        &css_path,
7708        &bundle_path,
7709        &initializer_path,
7710    ));
7711    let initializer_body = Arc::new(crate::openapi::swagger_ui_initializer_js(json_path));
7712    router = router.route(
7713        path,
7714        axum::routing::get(move || {
7715            let html = html_body.clone();
7716            async move {
7717                use axum::response::IntoResponse;
7718                (
7719                    [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
7720                    (*html).clone(),
7721                )
7722                    .into_response()
7723            }
7724        }),
7725    );
7726    router = router.route(
7727        &css_path,
7728        axum::routing::get(|| async move {
7729            use axum::response::IntoResponse;
7730            (
7731                [(http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
7732                crate::openapi::SWAGGER_UI_CSS,
7733            )
7734                .into_response()
7735        }),
7736    );
7737    router = router.route(
7738        &bundle_path,
7739        axum::routing::get(|| async move {
7740            use axum::body::Bytes;
7741            use axum::response::IntoResponse;
7742            (
7743                [(
7744                    http::header::CONTENT_TYPE,
7745                    "application/javascript; charset=utf-8",
7746                )],
7747                Bytes::from_static(crate::openapi::SWAGGER_UI_BUNDLE),
7748            )
7749                .into_response()
7750        }),
7751    );
7752    router = router.route(
7753        &initializer_path,
7754        axum::routing::get(move || {
7755            let js = initializer_body.clone();
7756            async move {
7757                use axum::response::IntoResponse;
7758                (
7759                    [(
7760                        http::header::CONTENT_TYPE,
7761                        "application/javascript; charset=utf-8",
7762                    )],
7763                    (*js).clone(),
7764                )
7765                    .into_response()
7766            }
7767        }),
7768    );
7769    router
7770}
7771
7772/// Tower [`Layer`](tower::Layer) installing the app's registered
7773/// [`HttpInterceptor`](crate::interceptor::HttpInterceptor) as the ambient
7774/// interceptor chain for outbound `reqwest` calls made during this request.
7775///
7776/// Hand-rolled rather than an `axum::middleware::from_fn_with_state` for the
7777/// reason #2214 documents: `from_fn` `Box::pin`s the async block it generates
7778/// on every request. `tokio::task_local!`'s `scope` returns a named
7779/// `TaskLocalFuture`, so the scoped branch needs no box either — and an app
7780/// with no interceptor registered (the common case) forwards the inner
7781/// service's future completely untouched.
7782///
7783/// Like [`crate::events::EventAppContextLayer`], the inner future is built
7784/// inside a `sync_scope` as well as polled inside a `scope`, so the synchronous
7785/// `Service::call` chain beneath this layer also sees the interceptors.
7786#[cfg(feature = "oauth2")]
7787#[derive(Clone)]
7788pub struct HttpInterceptorLayer {
7789    state: AppState,
7790}
7791
7792#[cfg(feature = "oauth2")]
7793impl HttpInterceptorLayer {
7794    pub(crate) const fn new(state: AppState) -> Self {
7795        Self { state }
7796    }
7797}
7798
7799#[cfg(feature = "oauth2")]
7800impl<S> tower::Layer<S> for HttpInterceptorLayer {
7801    type Service = HttpInterceptorService<S>;
7802
7803    fn layer(&self, inner: S) -> Self::Service {
7804        HttpInterceptorService {
7805            inner,
7806            state: self.state.clone(),
7807        }
7808    }
7809}
7810
7811/// Tower [`Service`](tower::Service) produced by [`HttpInterceptorLayer`].
7812#[cfg(feature = "oauth2")]
7813#[derive(Clone)]
7814pub struct HttpInterceptorService<S> {
7815    inner: S,
7816    state: AppState,
7817}
7818
7819#[cfg(feature = "oauth2")]
7820pin_project_lite::pin_project! {
7821    /// Future returned by [`HttpInterceptorService`].
7822    #[project = HttpInterceptorFutureProj]
7823    pub enum HttpInterceptorScopeFuture<F> {
7824        /// No interceptor registered: the inner service's own future.
7825        Plain {
7826            #[pin]
7827            inner: F,
7828        },
7829        /// The inner future, polled inside the interceptor task-local scope.
7830        Scoped {
7831            #[pin]
7832            inner: tokio::task::futures::TaskLocalFuture<
7833                Vec<Arc<dyn crate::interceptor::HttpInterceptor>>,
7834                F,
7835            >,
7836        },
7837    }
7838}
7839
7840#[cfg(feature = "oauth2")]
7841impl<F: std::future::Future> std::future::Future for HttpInterceptorScopeFuture<F> {
7842    type Output = F::Output;
7843
7844    fn poll(
7845        self: std::pin::Pin<&mut Self>,
7846        cx: &mut std::task::Context<'_>,
7847    ) -> std::task::Poll<Self::Output> {
7848        match self.project() {
7849            HttpInterceptorFutureProj::Plain { inner } => inner.poll(cx),
7850            HttpInterceptorFutureProj::Scoped { inner } => inner.poll(cx),
7851        }
7852    }
7853}
7854
7855#[cfg(feature = "oauth2")]
7856impl<S, ReqBody> tower::Service<Request<ReqBody>> for HttpInterceptorService<S>
7857where
7858    S: tower::Service<Request<ReqBody>>,
7859{
7860    type Response = S::Response;
7861    type Error = S::Error;
7862    type Future = HttpInterceptorScopeFuture<S::Future>;
7863
7864    fn poll_ready(
7865        &mut self,
7866        cx: &mut std::task::Context<'_>,
7867    ) -> std::task::Poll<Result<(), Self::Error>> {
7868        self.inner.poll_ready(cx)
7869    }
7870
7871    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
7872        use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
7873        let Some(interceptor_arc) = self.state.extension::<Arc<dyn HttpInterceptor>>() else {
7874            return HttpInterceptorScopeFuture::Plain {
7875                inner: self.inner.call(req),
7876            };
7877        };
7878        let interceptors = vec![(*interceptor_arc).clone()];
7879        let inner =
7880            ACTIVE_HTTP_INTERCEPTORS.sync_scope(interceptors.clone(), || self.inner.call(req));
7881        HttpInterceptorScopeFuture::Scoped {
7882            inner: ACTIVE_HTTP_INTERCEPTORS.scope(interceptors, inner),
7883        }
7884    }
7885}
7886
7887#[cfg(test)]
7888mod tests {
7889    use super::*;
7890    use axum::body::Body;
7891    use axum::http::{Request, StatusCode};
7892    use tower::ServiceExt;
7893
7894    fn test_state() -> AppState {
7895        AppState {
7896            profile: Some("test".into()),
7897            ..AppState::test_default()
7898        }
7899    }
7900
7901    // ── #3057: the prod profile turns load shedding on ───────────────────────
7902
7903    #[test]
7904    fn adaptive_mode_installs_the_load_shed_layer_without_a_ceiling() {
7905        let mut config = AutumnConfig::default();
7906        assert!(build_load_shed_layer(&config, &test_state()).is_none());
7907        config.server.admission.mode = crate::config::AdmissionMode::Adaptive;
7908        let state = test_state();
7909        assert!(build_load_shed_layer(&config, &state).is_some());
7910        assert_eq!(
7911            state.metrics.snapshot().http.admission.limit,
7912            20,
7913            "the gauge shows the initial adaptive limit"
7914        );
7915    }
7916
7917    #[test]
7918    fn adaptive_mode_with_bad_bounds_falls_back_to_the_static_ceiling() {
7919        let mut config = AutumnConfig::default();
7920        config.server.admission.mode = crate::config::AdmissionMode::Adaptive;
7921        config.server.admission.min_limit = 0;
7922        assert!(build_load_shed_layer(&config, &test_state()).is_none());
7923        config.server.max_concurrent_requests = Some(7);
7924        let state = test_state();
7925        assert!(build_load_shed_layer(&config, &state).is_some());
7926        assert_eq!(state.metrics.snapshot().http.admission.limit, 7);
7927    }
7928
7929    #[test]
7930    fn prod_profile_installs_the_load_shed_layer() {
7931        let mut config = AutumnConfig {
7932            profile: Some("prod".into()),
7933            ..AutumnConfig::default()
7934        };
7935        assert!(build_load_shed_layer(&config, &test_state()).is_some());
7936
7937        // `0` turns it off.
7938        config.server.max_concurrent_requests = Some(0);
7939        assert!(build_load_shed_layer(&config, &test_state()).is_none());
7940    }
7941
7942    #[test]
7943    fn other_profiles_leave_load_shedding_off() {
7944        for profile in ["dev", "staging"] {
7945            let config = AutumnConfig {
7946                profile: Some(profile.into()),
7947                ..AutumnConfig::default()
7948            };
7949            assert!(
7950                build_load_shed_layer(&config, &test_state()).is_none(),
7951                "{profile}"
7952            );
7953        }
7954    }
7955
7956    // ── #2418: the router passes the running profile to the contract check ──
7957
7958    fn contract_file_under(profile: &str) -> (tempfile::TempDir, String) {
7959        use crate::capacity::{
7960            CONTRACT_VERSION, Calibration, CapacityContract, Envelope, HostProfile, Provenance,
7961        };
7962        let contract = CapacityContract {
7963            version: CONTRACT_VERSION,
7964            provenance: Provenance {
7965                autumn_version: "0.0.0".to_owned(),
7966                calibrated_at: "2026-09-01T12:00:00Z".to_owned(),
7967                git_commit: None,
7968                git_dirty: false,
7969                route_graph_digest: String::new(),
7970            },
7971            host: HostProfile::detect(),
7972            envelope: Envelope {
7973                sustained_rps: 1000.0,
7974                p99_latency_ms: 10.0,
7975                saturation_concurrency: 40,
7976                admission_limit: 48,
7977            },
7978            calibration: Calibration {
7979                profile: profile.to_owned(),
7980                ..Calibration::default()
7981            },
7982            routes: Vec::new(),
7983        };
7984        let dir = tempfile::tempdir().expect("tempdir");
7985        let path = dir.path().join(crate::capacity::CONTRACT_FILE_NAME);
7986        std::fs::write(&path, contract.to_toml().expect("serialize")).expect("write contract");
7987        (dir, path.to_str().expect("utf-8 path").to_owned())
7988    }
7989
7990    #[test]
7991    fn the_load_shed_layer_enforces_only_a_contract_for_the_running_profile() {
7992        let (_dir, path) = contract_file_under("staging");
7993        let mut config = AutumnConfig {
7994            profile: Some("prod".into()),
7995            ..AutumnConfig::default()
7996        };
7997        config.server.capacity_contract = Some(path);
7998
7999        let state = test_state();
8000        assert!(build_load_shed_layer(&config, &state).is_some());
8001        let mismatched = state.metrics.snapshot().http.admission.limit;
8002        assert_ne!(mismatched, 48, "a staging contract must not bind prod");
8003
8004        config.profile = Some("staging".into());
8005        let state = test_state();
8006        assert!(build_load_shed_layer(&config, &state).is_some());
8007        assert_eq!(state.metrics.snapshot().http.admission.limit, 48);
8008    }
8009
8010    // ── submit-token production memory guard wiring (Finding O) ─────────────
8011
8012    #[test]
8013    fn submit_token_explicit_memory_in_production_fails_router_build() {
8014        // EXPLICIT `[security.submit_token].backend = "memory"` + production →
8015        // hard fail at router build, mirroring the idempotency prod-memory guard.
8016        let mut config = AutumnConfig::default();
8017        config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
8018        let err = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
8019            .expect_err("explicit memory submit-token backend in prod must fail router build");
8020        assert!(
8021            matches!(err, RouterBuildError::InvalidSubmitTokenBackend(_)),
8022            "expected InvalidSubmitTokenBackend, got {err:?}"
8023        );
8024    }
8025
8026    #[test]
8027    fn submit_token_inherited_memory_in_production_builds() {
8028        // INHERITED default (`backend = None`) resolving to Memory in prod must
8029        // NOT fail — it only warns. Router build succeeds.
8030        let mut config = AutumnConfig::default();
8031        config.security.submit_token.backend = None;
8032        config.idempotency.backend = crate::config::IdempotencyBackend::Memory;
8033        let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
8034            .expect("inherited memory submit-token backend in prod must still build (warn only)");
8035    }
8036
8037    #[test]
8038    fn submit_token_memory_outside_production_builds() {
8039        // Non-production → no fail regardless of explicit memory.
8040        let mut config = AutumnConfig::default();
8041        config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
8042        let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, false)
8043            .expect("memory submit-token backend outside production must build");
8044    }
8045
8046    #[tokio::test]
8047    async fn build_router_mounts_actuator_at_configured_prefix() {
8048        let mut config = AutumnConfig::default();
8049        config.actuator.prefix = "/ops".to_owned();
8050        config.actuator.sensitive = true;
8051
8052        let app = build_router(Vec::new(), &config, test_state());
8053
8054        let prefixed = app
8055            .clone()
8056            .oneshot(
8057                Request::builder()
8058                    .uri("/ops/health")
8059                    .body(Body::empty())
8060                    .unwrap(),
8061            )
8062            .await
8063            .unwrap();
8064        assert_eq!(prefixed.status(), StatusCode::OK);
8065
8066        let legacy = app
8067            .oneshot(
8068                Request::builder()
8069                    .uri("/actuator/health")
8070                    .body(Body::empty())
8071                    .unwrap(),
8072            )
8073            .await
8074            .unwrap();
8075        assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
8076    }
8077
8078    /// A worker builds the probe-only router and serves it over the same
8079    /// listener, so a `required_paths` prefix must hold there too. Without the
8080    /// requirement layer, `/actuator/jobs` answered an uncertified client on a
8081    /// worker while a web replica returned 403 for the same prefix.
8082    #[cfg(feature = "tls")]
8083    #[tokio::test]
8084    async fn a_worker_actuator_under_a_required_path_still_demands_a_certificate() {
8085        let mut config = AutumnConfig::default();
8086        config.server.tls = Some(crate::config::TlsConfig {
8087            cert_path: Some("cert.pem".into()),
8088            key_path: Some("key.pem".into()),
8089            reload_interval_secs: 60,
8090            handshake_timeout_secs: 10,
8091            acme: None,
8092            client_auth: Some(crate::config::ClientAuthConfig {
8093                mode: crate::config::ClientAuthMode::Optional,
8094                ca_bundle_path: Some("client-ca.pem".into()),
8095                crl_path: None,
8096                required_paths: vec!["/actuator/".to_owned()],
8097                reload_interval_secs: 60,
8098            }),
8099        });
8100
8101        let app = try_build_probe_only_router(&config, test_state())
8102            .expect("probe-only router should build");
8103
8104        // No `Arc<ClientIdentity>` extension: no verified certificate.
8105        let response = app
8106            .clone()
8107            .oneshot(
8108                Request::builder()
8109                    .uri("/actuator/info")
8110                    .body(Body::empty())
8111                    .unwrap(),
8112            )
8113            .await
8114            .unwrap();
8115        assert_eq!(
8116            response.status(),
8117            StatusCode::FORBIDDEN,
8118            "a worker actuator under a required_paths prefix must demand a certificate"
8119        );
8120
8121        // A verified client still reaches it, so the guard has not simply
8122        // broken the worker actuator.
8123        let identity = std::sync::Arc::new(crate::tls::client_auth::ClientIdentity::new_for_test(
8124            "svc-ops",
8125            Vec::new(),
8126        ));
8127        let mut request = Request::builder()
8128            .uri("/actuator/info")
8129            .body(Body::empty())
8130            .unwrap();
8131        request.extensions_mut().insert(identity);
8132        let response = app.clone().oneshot(request).await.unwrap();
8133        assert_ne!(response.status(), StatusCode::FORBIDDEN);
8134
8135        // A probe outside the prefix is untouched: an orchestrator that cannot
8136        // present a certificate still supervises the process.
8137        let response = app
8138            .oneshot(
8139                Request::builder()
8140                    .uri(config.health.live_path.as_str())
8141                    .body(Body::empty())
8142                    .unwrap(),
8143            )
8144            .await
8145            .unwrap();
8146        assert_ne!(response.status(), StatusCode::FORBIDDEN);
8147    }
8148
8149    /// Worker-role (#1613) probe-only router: exposes the framework probes and
8150    /// the actuator, but no user routes. A sample user path 404s.
8151    #[tokio::test]
8152    async fn probe_only_router_mounts_probes_and_actuator_but_no_user_routes() {
8153        let config = AutumnConfig::default();
8154        let app = try_build_probe_only_router(&config, test_state())
8155            .expect("probe-only router should build");
8156
8157        // Probe + actuator paths respond.
8158        for path in [
8159            config.health.live_path.as_str(),
8160            config.health.ready_path.as_str(),
8161            config.health.startup_path.as_str(),
8162            config.health.path.as_str(),
8163            "/actuator/health",
8164            "/actuator/info",
8165        ] {
8166            let response = app
8167                .clone()
8168                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8169                .await
8170                .unwrap();
8171            assert_ne!(
8172                response.status(),
8173                StatusCode::NOT_FOUND,
8174                "probe-only router should serve {path}"
8175            );
8176        }
8177
8178        // A made-up user route is absent (probe-only router has no user table).
8179        let missing = app
8180            .oneshot(
8181                Request::builder()
8182                    .uri("/definitely-not-a-user-route")
8183                    .body(Body::empty())
8184                    .unwrap(),
8185            )
8186            .await
8187            .unwrap();
8188        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
8189    }
8190
8191    /// Issue #1971: a user route registered at the auto-mounted health path
8192    /// must WIN. The router build succeeds — no raw axum "Overlapping method
8193    /// route. Handler for GET /health already exists" panic — the user's own
8194    /// handler serves `/health`, and the remaining built-in probes (`/live`,
8195    /// `/ready`, `/startup`) still mount and respond.
8196    #[tokio::test]
8197    async fn user_route_at_health_path_overrides_builtin_probe() {
8198        async fn user_health() -> &'static str {
8199            "user-health-handler"
8200        }
8201
8202        let config = AutumnConfig::default();
8203        // Precondition: the default health alias is exactly the path we shadow.
8204        assert_eq!(config.health.path, "/health");
8205
8206        let route = Route {
8207            method: http::Method::GET,
8208            path: "/health",
8209            handler: axum::routing::get(user_health),
8210            name: "user_health",
8211            api_doc: crate::openapi::ApiDoc {
8212                method: "GET",
8213                path: "/health",
8214                operation_id: "user_health",
8215                success_status: 200,
8216                ..Default::default()
8217            },
8218            repository: None,
8219            idempotency: crate::route::RouteIdempotency::Direct,
8220            timeout: crate::route::RouteTimeout::Inherit,
8221            criticality: crate::admission::Criticality::Default,
8222            seo: crate::seo::SeoRouteDefaults::EMPTY,
8223            api_version: None,
8224            sunset_opt_out: false,
8225        };
8226
8227        // Before the fix this panicked inside `axum::Router::route`; now it
8228        // builds cleanly (`build_router` panics on any RouterBuildError, so a
8229        // successful return also proves no structured error is raised).
8230        let app = build_router(vec![route], &config, test_state());
8231
8232        // `/health` is served by the USER handler, not the framework probe.
8233        let response = app
8234            .clone()
8235            .oneshot(
8236                Request::builder()
8237                    .uri("/health")
8238                    .body(Body::empty())
8239                    .unwrap(),
8240            )
8241            .await
8242            .unwrap();
8243        assert_eq!(response.status(), StatusCode::OK);
8244        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8245            .await
8246            .unwrap();
8247        assert_eq!(
8248            &body[..],
8249            b"user-health-handler",
8250            "user route must win at the health path"
8251        );
8252
8253        // The other built-in probes are untouched and still respond.
8254        for path in ["/live", "/ready", "/startup"] {
8255            let resp = app
8256                .clone()
8257                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8258                .await
8259                .unwrap();
8260            assert_ne!(
8261                resp.status(),
8262                StatusCode::NOT_FOUND,
8263                "built-in probe {path} should still be mounted"
8264            );
8265        }
8266    }
8267
8268    /// Issue #1971: `health.enabled = false` is a global off-switch — the
8269    /// framework auto-mounts NONE of the built-in probes (health/live/ready/
8270    /// startup). The router still builds cleanly (`build_router` panics on any
8271    /// `RouterBuildError`, so a successful return proves no structured error is
8272    /// raised), and every probe path resolves to `404` because nothing owns it.
8273    #[tokio::test]
8274    async fn health_enabled_false_mounts_no_builtin_probes() {
8275        let mut config = AutumnConfig::default();
8276        config.health.enabled = false;
8277
8278        let app = build_router(vec![], &config, test_state());
8279
8280        for path in [
8281            config.health.path.as_str(),
8282            config.health.live_path.as_str(),
8283            config.health.ready_path.as_str(),
8284            config.health.startup_path.as_str(),
8285        ] {
8286            let response = app
8287                .clone()
8288                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8289                .await
8290                .unwrap();
8291            assert_eq!(
8292                response.status(),
8293                StatusCode::NOT_FOUND,
8294                "probe path {path} must not be auto-mounted when health.enabled = false"
8295            );
8296        }
8297    }
8298
8299    /// The framework-owned widget stylesheet (#1215) is served the same way
8300    /// as the flash stylesheet: a same-origin, immutably-cached asset — not
8301    /// inline styles — so a strict `style-src 'self'` CSP still works and the
8302    /// asset is embeddable in the single binary (#1004) with no loose files.
8303    #[cfg(feature = "maud")]
8304    #[tokio::test]
8305    async fn widgets_css_route_serves_the_shared_stylesheet() {
8306        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8307
8308        let response = app
8309            .oneshot(
8310                Request::builder()
8311                    .uri(crate::ui::WIDGETS_CSS_PATH)
8312                    .body(Body::empty())
8313                    .unwrap(),
8314            )
8315            .await
8316            .unwrap();
8317
8318        assert_eq!(response.status(), StatusCode::OK);
8319        let content_type = response
8320            .headers()
8321            .get(http::header::CONTENT_TYPE)
8322            .unwrap()
8323            .to_str()
8324            .unwrap();
8325        assert!(content_type.contains("text/css"), "{content_type}");
8326
8327        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8328            .await
8329            .unwrap();
8330        let body = String::from_utf8(body.to_vec()).unwrap();
8331        assert!(body.contains(".autumn-field"), "{body}");
8332        assert!(body.contains(":root"), "{body}");
8333    }
8334
8335    /// The widget stylesheet is conditional-GET aware (shared `static_css_response`
8336    /// helper): a revalidating client sends back the `ETag` it was given and gets
8337    /// a bodyless `304`, instead of re-downloading the full asset every time the
8338    /// far-future `Cache-Control` gets bypassed (hard refresh, a CDN stripping
8339    /// cache headers, etc.).
8340    #[cfg(feature = "maud")]
8341    #[tokio::test]
8342    async fn widgets_css_route_supports_conditional_get() {
8343        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8344
8345        let first = app
8346            .clone()
8347            .oneshot(
8348                Request::builder()
8349                    .uri(crate::ui::WIDGETS_CSS_PATH)
8350                    .body(Body::empty())
8351                    .unwrap(),
8352            )
8353            .await
8354            .unwrap();
8355        assert_eq!(first.status(), StatusCode::OK);
8356        let etag = first
8357            .headers()
8358            .get(http::header::ETAG)
8359            .expect("widget stylesheet response should carry an ETag")
8360            .clone();
8361
8362        let revalidated = app
8363            .oneshot(
8364                Request::builder()
8365                    .uri(crate::ui::WIDGETS_CSS_PATH)
8366                    .header(http::header::IF_NONE_MATCH, etag)
8367                    .body(Body::empty())
8368                    .unwrap(),
8369            )
8370            .await
8371            .unwrap();
8372        assert_eq!(revalidated.status(), StatusCode::NOT_MODIFIED);
8373        let revalidated_body = axum::body::to_bytes(revalidated.into_body(), usize::MAX)
8374            .await
8375            .unwrap();
8376        assert!(revalidated_body.is_empty());
8377    }
8378
8379    /// The widget stylesheet's `ETag` must be weak (`W/"..."`), not strong:
8380    /// the identity/gzip/br byte streams served under it are not
8381    /// byte-identical, and a strong `ETag` asserts exactly that (RFC 7232
8382    /// §2.1).
8383    #[cfg(feature = "maud")]
8384    #[tokio::test]
8385    async fn widgets_css_route_etag_is_weak_not_strong() {
8386        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8387
8388        let response = app
8389            .oneshot(
8390                Request::builder()
8391                    .uri(crate::ui::WIDGETS_CSS_PATH)
8392                    .body(Body::empty())
8393                    .unwrap(),
8394            )
8395            .await
8396            .unwrap();
8397
8398        let etag = response
8399            .headers()
8400            .get(http::header::ETAG)
8401            .expect("widget stylesheet response should carry an ETag")
8402            .to_str()
8403            .unwrap()
8404            .to_owned();
8405        assert!(
8406            etag.starts_with("W/\""),
8407            "ETag must be weak since encoded variants aren't byte-identical: {etag}"
8408        );
8409    }
8410
8411    /// A client that sends `Accept-Encoding: br` gets the pre-computed brotli
8412    /// encoding straight back (`Content-Encoding: br`), not a plain body that
8413    /// the outer `CompressionLayer` then has to compress on the fly.
8414    #[cfg(feature = "maud")]
8415    #[tokio::test]
8416    async fn widgets_css_route_serves_precompressed_brotli_when_accepted() {
8417        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8418
8419        let response = app
8420            .oneshot(
8421                Request::builder()
8422                    .uri(crate::ui::WIDGETS_CSS_PATH)
8423                    .header(http::header::ACCEPT_ENCODING, "br")
8424                    .body(Body::empty())
8425                    .unwrap(),
8426            )
8427            .await
8428            .unwrap();
8429
8430        assert_eq!(response.status(), StatusCode::OK);
8431        assert_eq!(
8432            response
8433                .headers()
8434                .get(http::header::CONTENT_ENCODING)
8435                .unwrap(),
8436            "br"
8437        );
8438        assert_eq!(
8439            response.headers().get(http::header::VARY).unwrap(),
8440            "Accept-Encoding"
8441        );
8442
8443        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8444            .await
8445            .unwrap();
8446        let mut decoded = Vec::new();
8447        brotli::BrotliDecompress(&mut std::io::Cursor::new(body.as_ref()), &mut decoded)
8448            .expect("response body must be valid brotli");
8449        assert_eq!(String::from_utf8(decoded).unwrap(), crate::ui::WIDGETS_CSS);
8450    }
8451
8452    /// Same as the brotli case, for a `gzip`-only client.
8453    #[cfg(feature = "maud")]
8454    #[tokio::test]
8455    async fn widgets_css_route_serves_precompressed_gzip_when_accepted() {
8456        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8457
8458        let response = app
8459            .oneshot(
8460                Request::builder()
8461                    .uri(crate::ui::WIDGETS_CSS_PATH)
8462                    .header(http::header::ACCEPT_ENCODING, "gzip")
8463                    .body(Body::empty())
8464                    .unwrap(),
8465            )
8466            .await
8467            .unwrap();
8468
8469        assert_eq!(response.status(), StatusCode::OK);
8470        assert_eq!(
8471            response
8472                .headers()
8473                .get(http::header::CONTENT_ENCODING)
8474                .unwrap(),
8475            "gzip"
8476        );
8477
8478        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8479            .await
8480            .unwrap();
8481        let mut gz = flate2::read::GzDecoder::new(body.as_ref());
8482        let mut output = String::new();
8483        std::io::Read::read_to_string(&mut gz, &mut output)
8484            .expect("response body must be valid gzip");
8485        assert_eq!(output, crate::ui::WIDGETS_CSS);
8486    }
8487
8488    /// `q=0` is an explicit opt-out (RFC 7231 §5.3.4): a client that lists
8489    /// `br` but disqualifies it must fall back to the identity encoding
8490    /// rather than being served brotli anyway.
8491    #[cfg(feature = "maud")]
8492    #[tokio::test]
8493    async fn widgets_css_route_honors_q_zero_opt_out() {
8494        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8495
8496        let response = app
8497            .oneshot(
8498                Request::builder()
8499                    .uri(crate::ui::WIDGETS_CSS_PATH)
8500                    .header(http::header::ACCEPT_ENCODING, "br;q=0, gzip")
8501                    .body(Body::empty())
8502                    .unwrap(),
8503            )
8504            .await
8505            .unwrap();
8506
8507        assert_eq!(response.status(), StatusCode::OK);
8508        assert_eq!(
8509            response
8510                .headers()
8511                .get(http::header::CONTENT_ENCODING)
8512                .unwrap(),
8513            "gzip"
8514        );
8515    }
8516
8517    /// No `Accept-Encoding` header at all means identity — no
8518    /// `Content-Encoding` header, plain-text body (matches the existing
8519    /// `widgets_css_route_serves_the_shared_stylesheet` assertions).
8520    #[cfg(feature = "maud")]
8521    #[tokio::test]
8522    async fn widgets_css_route_serves_identity_with_no_accept_encoding() {
8523        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8524
8525        let response = app
8526            .oneshot(
8527                Request::builder()
8528                    .uri(crate::ui::WIDGETS_CSS_PATH)
8529                    .body(Body::empty())
8530                    .unwrap(),
8531            )
8532            .await
8533            .unwrap();
8534
8535        assert_eq!(response.status(), StatusCode::OK);
8536        assert!(
8537            !response
8538                .headers()
8539                .contains_key(http::header::CONTENT_ENCODING)
8540        );
8541    }
8542
8543    /// Pins the production access-log wiring (#999): the layer is applied in
8544    /// `apply_startup_barrier`, outside the barrier itself, so even requests
8545    /// rejected with 503 before the app router runs emit one access event
8546    /// carrying the status the client receives.
8547    #[test]
8548    fn startup_barrier_503s_are_access_logged() {
8549        use tracing_subscriber::layer::SubscriberExt as _;
8550
8551        #[derive(Clone, Default)]
8552        struct Capture {
8553            events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
8554        }
8555        struct Visitor<'a>(&'a mut std::collections::BTreeMap<String, String>);
8556        impl tracing::field::Visit for Visitor<'_> {
8557            fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
8558                self.0.insert(field.name().to_owned(), format!("{value:?}"));
8559            }
8560            fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
8561                self.0.insert(field.name().to_owned(), value.to_string());
8562            }
8563        }
8564        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
8565            fn on_event(
8566                &self,
8567                event: &tracing::Event<'_>,
8568                _ctx: tracing_subscriber::layer::Context<'_, S>,
8569            ) {
8570                if event.metadata().target() != crate::middleware::ACCESS_LOG_TARGET {
8571                    return;
8572                }
8573                let mut fields = std::collections::BTreeMap::new();
8574                event.record(&mut Visitor(&mut fields));
8575                self.events.lock().unwrap().push(fields);
8576            }
8577        }
8578
8579        let capture = Capture::default();
8580        let events = Arc::clone(&capture.events);
8581        let subscriber = tracing_subscriber::registry().with(capture);
8582
8583        tracing::subscriber::with_default(subscriber, || {
8584            // With startup incomplete, the barrier rejects non-probe requests
8585            // with 503 before the app router runs.
8586            let state = AppState::for_test()
8587                .with_profile("test")
8588                .with_startup_complete(false);
8589            let app = build_router(Vec::new(), &AutumnConfig::default(), state);
8590            let rt = tokio::runtime::Builder::new_current_thread()
8591                .enable_all()
8592                .build()
8593                .unwrap();
8594
8595            // A `tracing` callsite `Interest` is one value cached per callsite
8596            // for the whole process, combined from every active dispatcher.
8597            // `cargo test` runs this alongside thousands of unit tests in the
8598            // same binary, many touching the `autumn::access` callsite with no
8599            // capturing subscriber, so the combined interest can be re-cached as
8600            // "not interested" in the narrow window between rebuilding it and
8601            // firing the request below. Rebuilding and re-firing converges almost
8602            // immediately, so retry a few times rather than flake.
8603            let mut response = None;
8604            for attempt in 1..=5 {
8605                tracing::callsite::rebuild_interest_cache();
8606                let resp = rt.block_on(async {
8607                    app.clone()
8608                        .oneshot(
8609                            Request::builder()
8610                                .uri("/not-a-probe")
8611                                .body(Body::empty())
8612                                .unwrap(),
8613                        )
8614                        .await
8615                        .unwrap()
8616                });
8617                let captured = !events.lock().unwrap().is_empty();
8618                response = Some(resp);
8619                if captured {
8620                    break;
8621                }
8622                assert!(
8623                    attempt < 5,
8624                    "access-log event was not captured after {attempt} attempts \
8625                     (tracing interest-cache race with a concurrent test)"
8626                );
8627            }
8628            assert_eq!(response.unwrap().status(), StatusCode::SERVICE_UNAVAILABLE);
8629        });
8630
8631        let events = events.lock().unwrap().clone();
8632        assert_eq!(
8633            events.len(),
8634            1,
8635            "a barrier-rejected request should emit one access event: {events:?}"
8636        );
8637        assert_eq!(events[0].get("status").map(String::as_str), Some("503"));
8638        assert!(
8639            !events[0].contains_key("request_id"),
8640            "barrier short-circuits before RequestIdLayer, so no request id"
8641        );
8642    }
8643
8644    /// Pins the Server-Timing fallback wiring (#1348): the fallback layer is
8645    /// applied in `apply_startup_barrier`, outside the barrier itself, so a
8646    /// request rejected with 503 before the app router (and its primary
8647    /// `ServerTimingLayer`) runs still carries a `Server-Timing` header. Without
8648    /// the fallback the header is silently dropped on these short-circuits.
8649    #[tokio::test]
8650    async fn startup_barrier_503s_carry_server_timing_header() {
8651        // Startup incomplete → the barrier 503s non-probe requests before the
8652        // app router (and the primary ServerTimingLayer) ever run.
8653        let state = AppState::for_test()
8654            .with_profile("test")
8655            .with_startup_complete(false);
8656        let mut config = AutumnConfig::default();
8657        config.observability.server_timing = Some(true);
8658
8659        let app = build_router(Vec::new(), &config, state);
8660        let response = app
8661            .oneshot(
8662                Request::builder()
8663                    .uri("/not-a-probe")
8664                    .body(Body::empty())
8665                    .unwrap(),
8666            )
8667            .await
8668            .unwrap();
8669
8670        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8671        let header = response
8672            .headers()
8673            .get("server-timing")
8674            .expect("startup 503 short-circuit should still carry Server-Timing via the fallback")
8675            .to_str()
8676            .expect("server-timing header should be valid ASCII");
8677        assert!(
8678            header.starts_with("total;dur="),
8679            "fallback should emit a `total` metric, got {header:?}"
8680        );
8681        // Exactly one metric on the short-circuit path — the primary never ran,
8682        // so there is no second `total`.
8683        assert_eq!(
8684            header.matches("total;dur=").count(),
8685            1,
8686            "short-circuit response must carry a single total metric: {header:?}"
8687        );
8688    }
8689
8690    #[test]
8691    fn try_build_router_rejects_invalid_session_backend_config() {
8692        let mut config = AutumnConfig::default();
8693        config.session.backend = crate::session::SessionBackend::Redis;
8694
8695        let error = try_build_router(Vec::new(), &config, test_state())
8696            .expect_err("missing redis config should fail checked router build");
8697
8698        assert!(matches!(
8699            error,
8700            RouterBuildError::InvalidSessionBackend(
8701                crate::session::SessionBackendConfigError::MissingRedisUrl
8702            )
8703        ));
8704    }
8705
8706    #[test]
8707    fn try_build_router_with_static_rejects_invalid_session_backend_config() {
8708        let mut config = AutumnConfig::default();
8709        config.session.backend = crate::session::SessionBackend::Redis;
8710
8711        let error = try_build_router_with_static(Vec::new(), &config, test_state(), None)
8712            .expect_err("missing redis config should fail checked static router build");
8713
8714        assert!(matches!(
8715            error,
8716            RouterBuildError::InvalidSessionBackend(
8717                crate::session::SessionBackendConfigError::MissingRedisUrl
8718            )
8719        ));
8720    }
8721
8722    #[test]
8723    fn try_build_router_returns_error_for_probe_actuator_path_overlap() {
8724        let mut config = AutumnConfig::default();
8725        config.actuator.prefix = "/".to_owned();
8726
8727        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8728            try_build_router(Vec::new(), &config, test_state())
8729        }));
8730
8731        assert!(result.is_ok(), "try_build_router panicked on route overlap");
8732        assert!(
8733            result.unwrap().is_err(),
8734            "route overlap should be reported as a checked router build error"
8735        );
8736    }
8737
8738    /// Regression for issue #1971 P2: when a user route already owns `/health`,
8739    /// the built-in probe cedes that path (#1971) — but a root-prefix actuator
8740    /// still normalizes its own `GET /health` onto it. The ceded probe path must
8741    /// remain visible to the actuator overlap guard so this surfaces as a checked
8742    /// `FrameworkRouteOverlap` rather than an axum construction panic (matching
8743    /// the no-user-route case in
8744    /// `try_build_router_returns_error_for_probe_actuator_path_overlap`).
8745    #[test]
8746    fn probe_actuator_overlap_detected_when_user_route_owns_probe_path() {
8747        async fn user_health() -> &'static str {
8748            "user-health-handler"
8749        }
8750
8751        let mut config = AutumnConfig::default();
8752        config.actuator.prefix = "/".to_owned();
8753        // Precondition: the user route, the ceded probe, and the actuator all
8754        // land on exactly `/health`.
8755        assert_eq!(config.health.path, "/health");
8756
8757        let route = Route {
8758            method: http::Method::GET,
8759            path: "/health",
8760            handler: axum::routing::get(user_health),
8761            name: "user_health",
8762            api_doc: crate::openapi::ApiDoc {
8763                method: "GET",
8764                path: "/health",
8765                operation_id: "user_health",
8766                success_status: 200,
8767                ..Default::default()
8768            },
8769            repository: None,
8770            idempotency: crate::route::RouteIdempotency::Direct,
8771            timeout: crate::route::RouteTimeout::Inherit,
8772            criticality: crate::admission::Criticality::Default,
8773            seo: crate::seo::SeoRouteDefaults::EMPTY,
8774            api_version: None,
8775            sunset_opt_out: false,
8776        };
8777
8778        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8779            try_build_router(vec![route], &config, test_state())
8780        }));
8781
8782        assert!(
8783            result.is_ok(),
8784            "try_build_router panicked instead of returning a checked overlap error"
8785        );
8786        let build = result.unwrap();
8787        assert!(
8788            matches!(
8789                &build,
8790                Err(RouterBuildError::FrameworkRouteOverlap {
8791                    path,
8792                    incoming: "actuator endpoint",
8793                    ..
8794                }) if path == "/health"
8795            ),
8796            "root-prefix actuator over a user-owned probe path must yield a checked \
8797             FrameworkRouteOverlap for /health, got: {:?}",
8798            build.as_ref().map(|_| "Ok(router)"),
8799        );
8800    }
8801
8802    #[tokio::test]
8803    async fn apply_cors_middleware_skipped_when_no_origins() {
8804        let config = AutumnConfig::default();
8805        assert!(config.cors.allowed_origins.is_empty());
8806
8807        let base: axum::Router<AppState> =
8808            axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
8809        let router = apply_cors_middleware(base, &config).with_state(test_state());
8810
8811        let response = router
8812            .oneshot(
8813                Request::builder()
8814                    .uri("/test")
8815                    .header("Origin", "https://example.com")
8816                    .body(Body::empty())
8817                    .unwrap(),
8818            )
8819            .await
8820            .unwrap();
8821
8822        assert_eq!(response.status(), StatusCode::OK);
8823        assert!(
8824            response
8825                .headers()
8826                .get("access-control-allow-origin")
8827                .is_none(),
8828            "CORS header must be absent when no origins are configured"
8829        );
8830    }
8831
8832    #[tokio::test]
8833    async fn apply_cors_middleware_present_when_origins_configured() {
8834        let mut config = AutumnConfig::default();
8835        config.cors.allowed_origins = vec!["https://example.com".to_owned()];
8836
8837        let base: axum::Router<AppState> =
8838            axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
8839        let router = apply_cors_middleware(base, &config).with_state(test_state());
8840
8841        let response = router
8842            .oneshot(
8843                Request::builder()
8844                    .uri("/test")
8845                    .header("Origin", "https://example.com")
8846                    .body(Body::empty())
8847                    .unwrap(),
8848            )
8849            .await
8850            .unwrap();
8851
8852        assert_eq!(response.status(), StatusCode::OK);
8853        assert!(
8854            response
8855                .headers()
8856                .get("access-control-allow-origin")
8857                .is_some(),
8858            "CORS header must be present when origins are configured"
8859        );
8860    }
8861
8862    #[tokio::test]
8863    async fn apply_cors_middleware_handles_preflight_request() {
8864        let mut config = AutumnConfig::default();
8865        config.cors.allowed_origins = vec!["https://example.com".to_owned()];
8866
8867        let base: axum::Router<AppState> =
8868            axum::Router::new().route("/api/widgets", axum::routing::post(|| async { "ok" }));
8869        let router = apply_cors_middleware(base, &config).with_state(test_state());
8870
8871        let response = router
8872            .oneshot(
8873                Request::builder()
8874                    .method("OPTIONS")
8875                    .uri("/api/widgets")
8876                    .header("Origin", "https://example.com")
8877                    .header("Access-Control-Request-Method", "POST")
8878                    .header("Access-Control-Request-Headers", "Content-Type")
8879                    .body(Body::empty())
8880                    .unwrap(),
8881            )
8882            .await
8883            .unwrap();
8884
8885        let headers = response.headers();
8886        assert_eq!(
8887            headers
8888                .get("access-control-allow-origin")
8889                .and_then(|v| v.to_str().ok()),
8890            Some("https://example.com"),
8891            "preflight must echo the allowed origin"
8892        );
8893        assert!(
8894            headers.get("access-control-allow-methods").is_some(),
8895            "preflight must advertise allowed methods"
8896        );
8897        assert!(
8898            headers.get("access-control-allow-headers").is_some(),
8899            "preflight must advertise allowed headers"
8900        );
8901        assert!(
8902            headers.get("access-control-max-age").is_some(),
8903            "preflight must advertise max-age so browsers can cache it"
8904        );
8905    }
8906
8907    #[tokio::test]
8908    async fn apply_csrf_middleware_skipped_when_disabled() {
8909        let config = AutumnConfig::default();
8910        assert!(!config.security.csrf.enabled);
8911
8912        let base: axum::Router<AppState> =
8913            axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
8914        let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
8915
8916        // Without CSRF the POST should pass through with no CSRF-specific response
8917        let response = router
8918            .oneshot(
8919                Request::builder()
8920                    .method("POST")
8921                    .uri("/form")
8922                    .body(Body::empty())
8923                    .unwrap(),
8924            )
8925            .await
8926            .unwrap();
8927
8928        assert_eq!(response.status(), StatusCode::OK);
8929    }
8930
8931    #[tokio::test]
8932    async fn apply_rate_limit_middleware_skipped_when_disabled() {
8933        let config = AutumnConfig::default();
8934        assert!(!config.security.rate_limit.enabled);
8935
8936        let base: axum::Router<AppState> =
8937            axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
8938        let state = test_state();
8939        let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
8940
8941        // Fire several rapid requests; none should be throttled.
8942        for _ in 0..5 {
8943            let response = router
8944                .clone()
8945                .oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
8946                .await
8947                .unwrap();
8948            assert_eq!(response.status(), StatusCode::OK);
8949        }
8950    }
8951
8952    #[tokio::test]
8953    async fn apply_rate_limit_middleware_returns_429_when_exhausted() {
8954        let mut config = AutumnConfig::default();
8955        config.security.rate_limit.enabled = true;
8956        config.security.rate_limit.requests_per_second = 0.1;
8957        config.security.rate_limit.burst = 1;
8958        config.security.rate_limit.trust_forwarded_headers = true;
8959
8960        let base: axum::Router<AppState> =
8961            axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
8962        let state = test_state();
8963        let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
8964
8965        let ok = router
8966            .clone()
8967            .oneshot(
8968                Request::builder()
8969                    .uri("/ping")
8970                    .header("X-Forwarded-For", "203.0.113.9")
8971                    .body(Body::empty())
8972                    .unwrap(),
8973            )
8974            .await
8975            .unwrap();
8976        assert_eq!(ok.status(), StatusCode::OK);
8977
8978        let blocked = router
8979            .oneshot(
8980                Request::builder()
8981                    .uri("/ping")
8982                    .header("X-Forwarded-For", "203.0.113.9")
8983                    .body(Body::empty())
8984                    .unwrap(),
8985            )
8986            .await
8987            .unwrap();
8988        assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
8989        assert!(blocked.headers().get("retry-after").is_some());
8990    }
8991
8992    #[cfg(feature = "mcp")]
8993    #[tokio::test]
8994    async fn mcp_envelope_is_gated_during_maintenance() {
8995        use crate::maintenance::{MaintenanceConfig, MaintenanceState};
8996
8997        // Trust the host the control request sends so that, with maintenance
8998        // off, the envelope's host guard lets `initialize` through.
8999        let mut config = AutumnConfig::default();
9000        config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
9001
9002        let wiring = crate::mcp::McpWiring {
9003            cors: crate::config::CorsConfig::default(),
9004            trusted_hosts: TrustedHostPolicy::from_config(&config),
9005            tenant_header: None,
9006            csrf_header: "x-csrf-token".to_owned(),
9007            envelope_rate_limited: false,
9008            envelope_load_shed: false,
9009            state: test_state(),
9010            session_cookie: "autumn.sid".to_owned(),
9011            tasks: crate::mcp::McpTaskConfig::default(),
9012            instructions: None,
9013            catalog: crate::mcp::McpCatalog::Full,
9014        };
9015        let mcp_router =
9016            crate::mcp::build_mcp_router("/mcp", Vec::new(), axum::Router::new(), wiring, None);
9017
9018        let initialize = || {
9019            Request::builder()
9020                .method("POST")
9021                .uri("/mcp")
9022                .header("host", "app.example")
9023                .header("content-type", "application/json")
9024                .body(Body::from(
9025                    serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize"}).to_string(),
9026                ))
9027                .unwrap()
9028        };
9029
9030        // Maintenance ON: the late-mounted envelope returns the documented 503
9031        // instead of serving the catalog — the gap this layer closes.
9032        let state = test_state();
9033        let maintenance = MaintenanceState::new();
9034        maintenance.enable(MaintenanceConfig::default());
9035        state.insert_extension(maintenance);
9036        let gated = mcp_router
9037            .clone()
9038            .layer(build_maintenance_layer(&config, &state))
9039            .with_state(state);
9040        let resp = gated.oneshot(initialize()).await.unwrap();
9041        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
9042
9043        // Maintenance OFF (no enabled state): the same envelope serves
9044        // `initialize` normally, confirming the gate is the only difference.
9045        let state = test_state();
9046        let open = mcp_router
9047            .layer(build_maintenance_layer(&config, &state))
9048            .with_state(state);
9049        let resp = open.oneshot(initialize()).await.unwrap();
9050        assert_eq!(resp.status(), StatusCode::OK);
9051    }
9052
9053    #[cfg(feature = "mail")]
9054    fn dev_mail_preview_config(dir: &std::path::Path) -> AutumnConfig {
9055        let mut config = AutumnConfig {
9056            profile: Some("dev".to_owned()),
9057            mail: crate::mail::MailConfig {
9058                transport: crate::mail::Transport::File,
9059                file_dir: dir.to_path_buf(),
9060                ..Default::default()
9061            },
9062            ..Default::default()
9063        };
9064        config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
9065        config
9066    }
9067
9068    #[cfg(any(feature = "mail", feature = "maud"))]
9069    async fn response_text(response: axum::response::Response) -> String {
9070        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9071            .await
9072            .expect("body should collect");
9073        String::from_utf8(body.to_vec()).expect("body should be utf8")
9074    }
9075
9076    #[cfg(feature = "mail")]
9077    #[tokio::test]
9078    async fn build_router_mounts_dev_mail_preview_empty_state_for_file_transport() {
9079        let dir = tempfile::tempdir().expect("tempdir");
9080        let config = dev_mail_preview_config(dir.path());
9081        let router = build_router(Vec::new(), &config, test_state());
9082
9083        let response = router
9084            .oneshot(
9085                Request::builder()
9086                    .uri("/_autumn/mail")
9087                    .header("host", "example.com")
9088                    .body(Body::empty())
9089                    .unwrap(),
9090            )
9091            .await
9092            .unwrap();
9093
9094        assert_eq!(response.status(), StatusCode::OK);
9095        let body = response_text(response).await;
9096        assert!(
9097            body.contains("No captured emails"),
9098            "missing empty state: {body}"
9099        );
9100        assert!(
9101            body.contains("mail.transport = &quot;file&quot;"),
9102            "empty state should explain capture setup: {body}"
9103        );
9104    }
9105
9106    #[cfg(feature = "mail")]
9107    #[tokio::test]
9108    async fn build_router_lists_captured_mail_newest_first() {
9109        let dir = tempfile::tempdir().expect("tempdir");
9110        let older = dir.path().join("older.eml");
9111        let newer = dir.path().join("newer.eml");
9112        std::fs::write(
9113            &older,
9114            "To: first@example.com\nSubject: First\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <first@example.com>\n\nfirst body\n",
9115        )
9116        .expect("write older eml");
9117        std::fs::write(
9118            &newer,
9119            "To: second@example.com\nSubject: Second\nDate: Tue, 05 May 2026 10:01:00 +0000\nMessage-Id: <second@example.com>\n\nsecond body\n",
9120        )
9121        .expect("write newer eml");
9122        filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(100, 0))
9123            .expect("set older mtime");
9124        filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(200, 0))
9125            .expect("set newer mtime");
9126
9127        let config = dev_mail_preview_config(dir.path());
9128        let router = build_router(Vec::new(), &config, test_state());
9129        let response = router
9130            .oneshot(
9131                Request::builder()
9132                    .uri("/_autumn/mail")
9133                    .header("host", "example.com")
9134                    .body(Body::empty())
9135                    .unwrap(),
9136            )
9137            .await
9138            .unwrap();
9139
9140        assert_eq!(response.status(), StatusCode::OK);
9141        let body = response_text(response).await;
9142        let second = body.find("Second").expect("newer subject should render");
9143        let first = body.find("First").expect("older subject should render");
9144        assert!(second < first, "newest message should render first: {body}");
9145        assert!(
9146            body.contains("second@example.com"),
9147            "missing To column: {body}"
9148        );
9149        assert!(
9150            body.contains("Timestamp"),
9151            "missing timestamp column: {body}"
9152        );
9153    }
9154
9155    #[cfg(feature = "mail")]
9156    #[tokio::test]
9157    async fn build_router_mail_preview_detail_renders_html_in_sandboxed_iframe() {
9158        let dir = tempfile::tempdir().expect("tempdir");
9159        std::fs::write(
9160            dir.path().join("detail.eml"),
9161            "From: Autumn <noreply@example.com>\nTo: ada@example.com\nReply-To: support@example.com\nSubject: Reset\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <reset@example.com>\nMIME-Version: 1.0\nContent-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\nPlain reset\n--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n<h1>Hello iframe</h1>\n--autumn-mail--\n",
9162        )
9163        .expect("write detail eml");
9164
9165        let config = dev_mail_preview_config(dir.path());
9166        let router = build_router(Vec::new(), &config, test_state());
9167        let response = router
9168            .oneshot(
9169                Request::builder()
9170                    .uri("/_autumn/mail/messages/detail.eml")
9171                    .header("host", "example.com")
9172                    .body(Body::empty())
9173                    .unwrap(),
9174            )
9175            .await
9176            .unwrap();
9177
9178        assert_eq!(response.status(), StatusCode::OK);
9179        let body = response_text(response).await;
9180        assert!(body.contains("<iframe"), "missing iframe: {body}");
9181        assert!(body.contains("sandbox"), "iframe must be sandboxed: {body}");
9182        assert!(body.contains("Hello iframe"), "missing html body: {body}");
9183        assert!(body.contains("Plain text"), "missing text toggle: {body}");
9184        assert!(body.contains("Headers"), "missing headers toggle: {body}");
9185        assert!(
9186            body.contains("Raw .eml"),
9187            "missing raw source toggle: {body}"
9188        );
9189        assert!(
9190            body.contains("Message-Id"),
9191            "missing message id header: {body}"
9192        );
9193    }
9194
9195    #[cfg(feature = "mail")]
9196    #[tokio::test]
9197    async fn build_router_does_not_mount_mail_preview_outside_dev() {
9198        let dir = tempfile::tempdir().expect("tempdir");
9199        let mut config = dev_mail_preview_config(dir.path());
9200        config.profile = Some("prod".to_owned());
9201        let router = build_router(Vec::new(), &config, test_state());
9202
9203        let response = router
9204            .oneshot(
9205                Request::builder()
9206                    .uri("/_autumn/mail")
9207                    .header("host", "example.com")
9208                    .body(Body::empty())
9209                    .unwrap(),
9210            )
9211            .await
9212            .unwrap();
9213
9214        assert_eq!(response.status(), StatusCode::NOT_FOUND);
9215    }
9216
9217    // ── Widget story gallery mount gating (issue #1526) ─────────────────────
9218    //
9219    // Unlike the dev-only mail preview, `/_stories` is opt-in in ANY profile
9220    // via `[stories] enabled = true` (default false): mounting is gated only
9221    // on the resolved config flag, while handlers read the `StoryRegistry`
9222    // from the AppState extension installed by `with_story_gallery`.
9223
9224    #[cfg(feature = "maud")]
9225    fn story_gallery_config() -> AutumnConfig {
9226        let mut config = AutumnConfig::default();
9227        config.stories.enabled = true;
9228        config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
9229        config
9230    }
9231
9232    #[cfg(feature = "maud")]
9233    fn stories_state_with_builtin() -> AppState {
9234        let state = test_state();
9235        state.insert_extension(crate::stories::builtin());
9236        state
9237    }
9238
9239    #[cfg(feature = "maud")]
9240    async fn get_with_host(router: axum::Router, uri: &str) -> axum::response::Response {
9241        router
9242            .oneshot(
9243                Request::builder()
9244                    .uri(uri)
9245                    .header("host", "example.com")
9246                    .body(Body::empty())
9247                    .unwrap(),
9248            )
9249            .await
9250            .unwrap()
9251    }
9252
9253    /// T1 (AC4/AC5): with `[stories] enabled = true` and the builtin registry
9254    /// installed, the grouped index is served at `/_stories` and pulls in the
9255    /// framework widget stylesheet.
9256    #[cfg(feature = "maud")]
9257    #[tokio::test]
9258    async fn build_router_mounts_story_gallery_when_enabled() {
9259        let router = build_router(
9260            Vec::new(),
9261            &story_gallery_config(),
9262            stories_state_with_builtin(),
9263        );
9264
9265        let response = get_with_host(router, crate::stories::STORIES_PATH).await;
9266        assert_eq!(response.status(), StatusCode::OK);
9267        let body = response_text(response).await;
9268        assert!(
9269            body.contains("Data table"),
9270            "index should list builtin story names: {body}"
9271        );
9272        assert!(
9273            body.contains("autumn-widgets.css"),
9274            "index should link the framework widget stylesheet: {body}"
9275        );
9276    }
9277
9278    /// T2 (AC4): the detail route serves the live render plus Source and
9279    /// Rendered HTML tabs.
9280    #[cfg(feature = "maud")]
9281    #[tokio::test]
9282    async fn story_detail_route_serves_render_source_and_html() {
9283        let router = build_router(
9284            Vec::new(),
9285            &story_gallery_config(),
9286            stories_state_with_builtin(),
9287        );
9288
9289        let response = get_with_host(router, "/_stories/data-table").await;
9290        assert_eq!(response.status(), StatusCode::OK);
9291        let body = response_text(response).await;
9292        assert!(
9293            body.contains("<table"),
9294            "detail page must contain the live data_table render: {body}"
9295        );
9296        assert!(
9297            body.contains("data_table("),
9298            "detail page must show the source snippet that produced the render: {body}"
9299        );
9300        assert!(
9301            body.contains("Rendered HTML"),
9302            "detail page must offer the rendered-HTML tab: {body}"
9303        );
9304        assert!(
9305            body.contains("Source"),
9306            "detail page must offer the source tab: {body}"
9307        );
9308    }
9309
9310    /// #2353: an `hx-*` story page loads htmx, and loads it before the
9311    /// widget runtime.
9312    #[cfg(all(feature = "maud", feature = "htmx"))]
9313    #[tokio::test]
9314    async fn story_detail_route_loads_htmx_for_hx_stories() {
9315        let router = build_router(
9316            Vec::new(),
9317            &story_gallery_config(),
9318            stories_state_with_builtin(),
9319        );
9320
9321        let response = get_with_host(router, "/_stories/active-search").await;
9322        assert_eq!(response.status(), StatusCode::OK);
9323        let body = response_text(response).await;
9324        assert!(body.contains("hx-get"), "story must use htmx: {body}");
9325        let htmx_at = body
9326            .find(crate::htmx::HTMX_JS_PATH)
9327            .unwrap_or_else(|| panic!("page must load htmx.min.js: {body}"));
9328        let widgets_at = body
9329            .find(crate::htmx::AUTUMN_WIDGETS_JS_PATH)
9330            .unwrap_or_else(|| panic!("page must load autumn-widgets.js: {body}"));
9331        assert!(htmx_at < widgets_at, "htmx must load first: {body}");
9332    }
9333
9334    /// T3 (AC4): a mounted gallery 404s unknown slugs while the index stays up.
9335    #[cfg(feature = "maud")]
9336    #[tokio::test]
9337    async fn story_detail_unknown_slug_is_404() {
9338        let router = build_router(
9339            Vec::new(),
9340            &story_gallery_config(),
9341            stories_state_with_builtin(),
9342        );
9343
9344        let missing = get_with_host(router.clone(), "/_stories/nope").await;
9345        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
9346
9347        let index = get_with_host(router, "/_stories").await;
9348        assert_eq!(
9349            index.status(),
9350            StatusCode::OK,
9351            "index route must exist even when a slug misses"
9352        );
9353    }
9354
9355    /// T4 (AC5/AC6): off by default — no `[stories] enabled = true`, no
9356    /// routes, even when a registry extension is installed.
9357    #[cfg(feature = "maud")]
9358    #[tokio::test]
9359    async fn build_router_omits_story_gallery_by_default() {
9360        let mut config = AutumnConfig::default();
9361        assert!(
9362            !config.stories.enabled,
9363            "stories gallery must be off by default"
9364        );
9365        config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
9366
9367        let router = build_router(Vec::new(), &config, stories_state_with_builtin());
9368        let response = get_with_host(router, "/_stories").await;
9369        assert_eq!(response.status(), StatusCode::NOT_FOUND);
9370    }
9371
9372    // ── Locale-prefixed routing (issue #1251) ───────────────────────────────
9373
9374    #[cfg(feature = "i18n")]
9375    mod locale_prefix_routing {
9376        use super::*;
9377        use crate::i18n::{Bundle, I18nConfig, Locale};
9378
9379        async fn locale_probe(locale: Locale) -> String {
9380            locale.tag().to_owned()
9381        }
9382
9383        async fn plain_ok() -> &'static str {
9384            "ok"
9385        }
9386
9387        fn simple_route(path: &'static str, name: &'static str, probe: bool) -> Route {
9388            Route {
9389                method: http::Method::GET,
9390                path,
9391                handler: if probe {
9392                    axum::routing::get(locale_probe)
9393                } else {
9394                    axum::routing::get(plain_ok)
9395                },
9396                name,
9397                api_doc: crate::openapi::ApiDoc {
9398                    method: "GET",
9399                    path,
9400                    operation_id: name,
9401                    success_status: 200,
9402                    ..Default::default()
9403                },
9404                repository: None,
9405                idempotency: crate::route::RouteIdempotency::Direct,
9406                timeout: crate::route::RouteTimeout::Inherit,
9407                criticality: crate::admission::Criticality::Default,
9408                seo: crate::seo::SeoRouteDefaults::EMPTY,
9409                api_version: None,
9410                sunset_opt_out: false,
9411            }
9412        }
9413
9414        fn config(supported: &[&str], exclude: &[&str]) -> AutumnConfig {
9415            let mut config = AutumnConfig::default();
9416            config.i18n.locale_prefix_enabled = true;
9417            config.i18n.default_locale = supported.first().copied().unwrap_or("en").to_owned();
9418            config.i18n.supported_locales = supported.iter().map(|s| (*s).to_owned()).collect();
9419            config.i18n.locale_prefix_exclude = exclude.iter().map(|s| (*s).to_owned()).collect();
9420            config
9421        }
9422
9423        fn bundle(supported: &[&str]) -> Arc<Bundle> {
9424            let cfg = I18nConfig {
9425                default_locale: supported.first().copied().unwrap_or("en").to_owned(),
9426                supported_locales: supported.iter().map(|s| (*s).to_owned()).collect(),
9427                fallback_chain: vec![],
9428                dir: "i18n".to_owned(),
9429                locale_prefix_enabled: false,
9430                locale_prefix_exclude: vec![],
9431                locale_prefix_exclude_exact: vec![],
9432            };
9433            Arc::new(Bundle::from_messages(
9434                std::collections::HashMap::new(),
9435                &cfg,
9436            ))
9437        }
9438
9439        async fn request(
9440            router: &axum::Router,
9441            uri: &str,
9442            headers: &[(&str, &str)],
9443        ) -> axum::response::Response {
9444            request_method(router, http::Method::GET, uri, headers).await
9445        }
9446
9447        async fn request_method(
9448            router: &axum::Router,
9449            method: http::Method,
9450            uri: &str,
9451            headers: &[(&str, &str)],
9452        ) -> axum::response::Response {
9453            let mut req = Request::builder().method(method).uri(uri);
9454            for (k, v) in headers {
9455                req = req.header(*k, *v);
9456            }
9457            router
9458                .clone()
9459                .oneshot(req.body(Body::empty()).unwrap())
9460                .await
9461                .unwrap()
9462        }
9463
9464        async fn body_string(resp: axum::response::Response) -> String {
9465            let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
9466            String::from_utf8(bytes.to_vec()).unwrap()
9467        }
9468
9469        async fn uri_probe(uri: axum::http::Uri) -> String {
9470            uri.path().to_owned()
9471        }
9472
9473        async fn slug_probe(
9474            locale: Locale,
9475            axum::extract::Path(slug): axum::extract::Path<String>,
9476        ) -> String {
9477            format!("{}:{slug}", locale.tag())
9478        }
9479
9480        fn uri_probe_route(path: &'static str, name: &'static str) -> Route {
9481            Route {
9482                method: http::Method::GET,
9483                path,
9484                handler: axum::routing::get(uri_probe),
9485                name,
9486                api_doc: crate::openapi::ApiDoc {
9487                    method: "GET",
9488                    path,
9489                    operation_id: name,
9490                    success_status: 200,
9491                    ..Default::default()
9492                },
9493                repository: None,
9494                idempotency: crate::route::RouteIdempotency::Direct,
9495                timeout: crate::route::RouteTimeout::Inherit,
9496                criticality: crate::admission::Criticality::Default,
9497                seo: crate::seo::SeoRouteDefaults::EMPTY,
9498                api_version: None,
9499                sunset_opt_out: false,
9500            }
9501        }
9502
9503        fn slug_route(path: &'static str, name: &'static str) -> Route {
9504            Route {
9505                method: http::Method::GET,
9506                path,
9507                handler: axum::routing::get(slug_probe),
9508                name,
9509                api_doc: crate::openapi::ApiDoc {
9510                    method: "GET",
9511                    path,
9512                    operation_id: name,
9513                    success_status: 200,
9514                    ..Default::default()
9515                },
9516                repository: None,
9517                idempotency: crate::route::RouteIdempotency::Direct,
9518                timeout: crate::route::RouteTimeout::Inherit,
9519                criticality: crate::admission::Criticality::Default,
9520                seo: crate::seo::SeoRouteDefaults::EMPTY,
9521                api_version: None,
9522                sunset_opt_out: false,
9523            }
9524        }
9525
9526        fn make_post_route(path: &'static str, name: &'static str) -> Route {
9527            Route {
9528                method: http::Method::POST,
9529                path,
9530                handler: axum::routing::post(plain_ok),
9531                name,
9532                api_doc: crate::openapi::ApiDoc {
9533                    method: "POST",
9534                    path,
9535                    operation_id: name,
9536                    success_status: 200,
9537                    ..Default::default()
9538                },
9539                repository: None,
9540                idempotency: crate::route::RouteIdempotency::Direct,
9541                timeout: crate::route::RouteTimeout::Inherit,
9542                criticality: crate::admission::Criticality::Default,
9543                seo: crate::seo::SeoRouteDefaults::EMPTY,
9544                api_version: None,
9545                sunset_opt_out: false,
9546            }
9547        }
9548
9549        /// Codex review (P1): an app that enables `locale_prefix_enabled`
9550        /// without also calling `.i18n()`/`.i18n_auto()` (no `Bundle`
9551        /// installed) must still redirect to — and correctly serve — its
9552        /// *configured* locale, not a hard-coded `"en"` that may not even be
9553        /// in `supported_locales`.
9554        #[tokio::test]
9555        async fn locale_prefix_redirect_works_without_an_i18n_bundle() {
9556            let config = config(&["fr"], &[]);
9557            let route = simple_route("/posts", "posts", true);
9558            // No `.layer(axum::Extension(bundle(...)))` — deliberately no
9559            // Bundle, unlike every other test in this module.
9560            let app = build_router(vec![route], &config, test_state());
9561
9562            let redirected = request(&app, "/posts", &[]).await;
9563            assert_eq!(redirected.status(), StatusCode::PERMANENT_REDIRECT);
9564            assert_eq!(
9565                redirected
9566                    .headers()
9567                    .get(axum::http::header::LOCATION)
9568                    .and_then(|v| v.to_str().ok()),
9569                Some("/fr/posts"),
9570                "must redirect to the configured locale, not a hard-coded \"en\""
9571            );
9572
9573            let target = request(&app, "/fr/posts", &[]).await;
9574            assert_eq!(
9575                target.status(),
9576                StatusCode::OK,
9577                "the redirect target must actually resolve"
9578            );
9579            assert_eq!(body_string(target).await, "fr");
9580        }
9581
9582        /// AC: default off — no behavior change for existing apps. The bare
9583        /// route serves directly; no locale nest exists.
9584        #[tokio::test]
9585        async fn locale_prefix_routing_off_by_default() {
9586            let mut config = AutumnConfig::default();
9587            assert!(!config.i18n.locale_prefix_enabled);
9588            config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
9589
9590            let route = simple_route("/posts", "posts", false);
9591            let app = build_router(vec![route], &config, test_state())
9592                .layer(axum::Extension(bundle(&["en", "es"])));
9593
9594            let bare = request(&app, "/posts", &[]).await;
9595            assert_eq!(bare.status(), StatusCode::OK);
9596
9597            let prefixed = request(&app, "/en/posts", &[]).await;
9598            assert_eq!(
9599                prefixed.status(),
9600                StatusCode::NOT_FOUND,
9601                "no locale nest should exist when the flag is off"
9602            );
9603        }
9604
9605        /// The root path (`/`) is axum's nest-plus-root special case: `nest(
9606        /// "/en", router_with_route_at("/"))` makes bare `/en` (no trailing
9607        /// slash) match, while `/en/` 404s — the opposite of every other
9608        /// path, where the locale segment is a plain concatenation. Both the
9609        /// redirect target and direct nested access must account for this.
9610        #[tokio::test]
9611        async fn root_path_redirects_to_bare_locale_prefix_without_trailing_slash() {
9612            let config = config(&["en", "es"], &[]);
9613            let route = simple_route("/", "root", true);
9614            let app = build_router(vec![route], &config, test_state())
9615                .layer(axum::Extension(bundle(&["en", "es"])));
9616
9617            let bare = request(&app, "/", &[]).await;
9618            assert_eq!(bare.status(), StatusCode::PERMANENT_REDIRECT);
9619            assert_eq!(
9620                bare.headers()
9621                    .get(axum::http::header::LOCATION)
9622                    .and_then(|v| v.to_str().ok()),
9623                Some("/en"),
9624                "root redirect target must have no trailing slash"
9625            );
9626
9627            let nested = request(&app, "/en", &[]).await;
9628            assert_eq!(nested.status(), StatusCode::OK);
9629            assert_eq!(body_string(nested).await, "en");
9630
9631            let nested_with_slash = request(&app, "/en/", &[]).await;
9632            assert_eq!(
9633                nested_with_slash.status(),
9634                StatusCode::NOT_FOUND,
9635                "axum's nest-plus-root case does not also match the trailing-slash form"
9636            );
9637        }
9638
9639        /// The root path with a query string preserves the query and still
9640        /// omits the trailing slash before it.
9641        #[tokio::test]
9642        async fn root_path_redirect_preserves_query_without_trailing_slash() {
9643            let config = config(&["en", "es"], &[]);
9644            let route = simple_route("/", "root", true);
9645            let app = build_router(vec![route], &config, test_state())
9646                .layer(axum::Extension(bundle(&["en", "es"])));
9647
9648            let response = request(&app, "/?ref=newsletter", &[]).await;
9649            assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
9650            assert_eq!(
9651                response
9652                    .headers()
9653                    .get(axum::http::header::LOCATION)
9654                    .and_then(|v| v.to_str().ok()),
9655                Some("/en?ref=newsletter")
9656            );
9657        }
9658
9659        /// AC: every route is reachable under `/{locale}` for each supported
9660        /// locale, with zero hand-duplicated route definitions — one `Route`
9661        /// serves both `/en/posts` and `/es/posts`.
9662        #[tokio::test]
9663        async fn route_reachable_under_every_supported_locale() {
9664            let config = config(&["en", "es"], &[]);
9665            let route = simple_route("/posts", "posts", true);
9666            let app = build_router(vec![route], &config, test_state())
9667                .layer(axum::Extension(bundle(&["en", "es"])));
9668
9669            let en = request(&app, "/en/posts", &[]).await;
9670            assert_eq!(en.status(), StatusCode::OK);
9671            assert_eq!(body_string(en).await, "en");
9672
9673            let es = request(&app, "/es/posts", &[]).await;
9674            assert_eq!(es.status(), StatusCode::OK);
9675            assert_eq!(body_string(es).await, "es");
9676        }
9677
9678        /// Codex review (P1): a duplicate entry in `supported_locales` must
9679        /// not panic at router-construction time (axum rejects nesting the
9680        /// same path twice as an overlapping route) — it should simply be
9681        /// deduped, and the locale should still route normally.
9682        #[tokio::test]
9683        async fn duplicate_supported_locale_does_not_panic_and_still_routes() {
9684            let config = config(&["en", "es", "en"], &[]);
9685            let route = simple_route("/posts", "posts", true);
9686            let app = build_router(vec![route], &config, test_state())
9687                .layer(axum::Extension(bundle(&["en", "es"])));
9688
9689            let en = request(&app, "/en/posts", &[]).await;
9690            assert_eq!(en.status(), StatusCode::OK);
9691            assert_eq!(body_string(en).await, "en");
9692
9693            let es = request(&app, "/es/posts", &[]).await;
9694            assert_eq!(es.status(), StatusCode::OK);
9695            assert_eq!(body_string(es).await, "es");
9696        }
9697
9698        /// Codex review (P2): an empty-string entry in `supported_locales`
9699        /// must not panic at router-construction time — `.nest("/", ...)`
9700        /// (an empty locale segment) is a root nest, which axum rejects.
9701        /// Skip the malformed entry instead; the other, valid locale must
9702        /// still route normally.
9703        #[tokio::test]
9704        async fn malformed_locale_segment_does_not_panic_and_valid_locale_still_routes() {
9705            let config = config(&["en", "", "es"], &[]);
9706            let route = simple_route("/posts", "posts", true);
9707            let app = build_router(vec![route], &config, test_state())
9708                .layer(axum::Extension(bundle(&["en", "es"])));
9709
9710            let en = request(&app, "/en/posts", &[]).await;
9711            assert_eq!(en.status(), StatusCode::OK);
9712            assert_eq!(body_string(en).await, "en");
9713
9714            let es = request(&app, "/es/posts", &[]).await;
9715            assert_eq!(es.status(), StatusCode::OK);
9716            assert_eq!(body_string(es).await, "es");
9717        }
9718
9719        /// Codex review (P1): an app that defines both `/foo` and the exact
9720        /// path its own locale-prefix nesting would generate for it
9721        /// (`/en/foo`, when `en` is supported) must not panic at
9722        /// router-construction time — the bare-path redirect (mounted at
9723        /// every locale-prefix-eligible path via `any()`, which claims every
9724        /// HTTP method) already owns `/en/foo`, so nesting `/foo`'s content
9725        /// under `/en` collides. Surfaced as a structured build error.
9726        #[tokio::test]
9727        async fn generated_locale_path_collision_is_a_build_error_not_a_panic() {
9728            let config = config(&["en", "es"], &[]);
9729            let foo = simple_route("/foo", "foo", false);
9730            let en_foo = simple_route("/en/foo", "en_foo", false);
9731            let err = try_build_router(vec![foo, en_foo], &config, test_state())
9732                .expect_err("a generated-path collision must be a build error, not a panic");
9733            match err {
9734                RouterBuildError::LocalePrefixPathCollision {
9735                    ref locale,
9736                    ref path,
9737                    ref generated,
9738                } => {
9739                    assert_eq!(locale, "en");
9740                    assert_eq!(path, "/foo");
9741                    assert_eq!(generated, "/en/foo");
9742                }
9743                other => panic!("expected LocalePrefixPathCollision, got {other:?}"),
9744            }
9745        }
9746
9747        /// Codex review (P1): a generated locale path can collide with an
9748        /// existing route that has the SAME matchit shape but a DIFFERENT
9749        /// capture name (`/en/users/{id}` generated from `/users/{id}`, vs.
9750        /// an existing `/en/users/{slug}`) — axum's `Router::route` rejects
9751        /// this as a conflict regardless of the capture name, exactly like
9752        /// the exact-duplicate-path case, so it must be caught the same way.
9753        #[tokio::test]
9754        async fn generated_locale_path_collision_via_capture_name_mismatch_is_a_build_error() {
9755            let config = config(&["en"], &[]);
9756            let users_id = slug_route("/users/{id}", "users_id");
9757            let en_users_slug = slug_route("/en/users/{slug}", "en_users_slug");
9758            let err = try_build_router(vec![users_id, en_users_slug], &config, test_state())
9759                .expect_err("a capture-name-mismatched shape collision must be a build error");
9760            assert!(
9761                matches!(err, RouterBuildError::LocalePrefixPathCollision { .. }),
9762                "expected LocalePrefixPathCollision, got {err:?}"
9763            );
9764        }
9765
9766        /// Codex review (P2): a legal cross-method exact-path match must
9767        /// NOT be flagged as a collision — axum merges the SAME path
9768        /// template across DIFFERENT methods. `GET /foo` generates
9769        /// `GET /en/foo`, which must coexist with a separately registered
9770        /// `POST /en/foo`.
9771        #[tokio::test]
9772        async fn exact_generated_path_with_different_method_is_not_a_collision() {
9773            let config = config(&["en"], &[]);
9774            let get_foo = simple_route("/foo", "foo_get", false);
9775            let post_en_foo = duplicate_test_route(http::Method::POST, "/en/foo", "post_en_foo");
9776
9777            // Must build without panicking or erroring.
9778            let app = build_router(vec![get_foo, post_en_foo], &config, test_state());
9779
9780            let get_resp = request(&app, "/en/foo", &[]).await;
9781            assert_eq!(
9782                get_resp.status(),
9783                StatusCode::OK,
9784                "GET /en/foo must resolve to the nested /foo content"
9785            );
9786        }
9787
9788        /// Codex review (P1): a route mounted via `AppBuilder::scoped()`
9789        /// lives outside `route_list` entirely, but still mounts onto the
9790        /// same router (`mount_scoped_groups`, after this module returns) —
9791        /// a scoped route whose resolved path collides with a generated
9792        /// locale path must be caught here too, not just top-level routes.
9793        #[tokio::test]
9794        async fn generated_locale_path_colliding_with_a_scoped_route_is_a_build_error() {
9795            let config = config(&["en"], &[]);
9796            let foo = simple_route("/foo", "foo", false);
9797            let scoped_route = duplicate_test_route(http::Method::GET, "/foo", "scoped_foo");
9798            let group = crate::app::ScopedGroup {
9799                prefix: "/en".to_owned(),
9800                routes: vec![scoped_route],
9801                source: crate::route_listing::RouteSource::User,
9802                layer_guarded: false,
9803                apply_layer: Box::new(|r| r),
9804            };
9805            let mut ctx = duplicate_test_ctx();
9806            ctx.scoped_groups.push(group);
9807
9808            let err = super::try_build_router_inner(vec![foo], &config, test_state(), ctx)
9809                .expect_err(
9810                    "a scoped route colliding with a generated locale path must be a build error",
9811                );
9812            assert!(
9813                matches!(err, RouterBuildError::LocalePrefixPathCollision { .. }),
9814                "expected LocalePrefixPathCollision, got {err:?}"
9815            );
9816        }
9817
9818        /// Codex review (P1): a generated locale path can collide with a
9819        /// framework-owned path — the health probes, mounted later via
9820        /// `mount_probe_endpoints`, are invisible to `collect_user_get_paths`
9821        /// (which only sees the raw, pre-locale-prefix `route_list`) and so
9822        /// weren't previously checked here either.
9823        #[tokio::test]
9824        async fn generated_locale_path_colliding_with_a_health_probe_is_a_build_error() {
9825            let mut config = config(&["en"], &[]);
9826            config.health.path = "/en/foo".to_owned();
9827            let foo = simple_route("/foo", "foo", false);
9828
9829            let err = try_build_router(vec![foo], &config, test_state()).expect_err(
9830                "a health probe colliding with a generated locale path must be a build error",
9831            );
9832            assert!(
9833                matches!(err, RouterBuildError::LocalePrefixPathCollision { .. }),
9834                "expected LocalePrefixPathCollision, got {err:?}"
9835            );
9836        }
9837
9838        /// Codex review (P2): a locale segment beginning with `:` (axum 0.7
9839        /// capture syntax) must not panic at router-construction time — axum
9840        /// 0.8's `Router::route` rejects it during assembly (the same
9841        /// restriction `InvalidMcpPath` guards for MCP mount paths).
9842        #[tokio::test]
9843        async fn colon_prefixed_locale_segment_does_not_panic() {
9844            let config = config(&["en", ":en"], &[]);
9845            let route = simple_route("/posts", "posts", true);
9846            let app = build_router(vec![route], &config, test_state())
9847                .layer(axum::Extension(bundle(&["en"])));
9848
9849            let en = request(&app, "/en/posts", &[]).await;
9850            assert_eq!(en.status(), StatusCode::OK);
9851            assert_eq!(body_string(en).await, "en");
9852        }
9853
9854        /// Codex review (P2): a locale segment containing a query/fragment
9855        /// delimiter or whitespace (e.g. `"en?x"`, `"en#x"`, `"en y"`) must
9856        /// be skipped rather than nested — axum accepts it as a literal
9857        /// nest string, but a client parses `/en?x/foo` as path `/en` plus
9858        /// query `x/foo`, silently truncating the redirect target. Router
9859        /// construction must not panic, and the other, valid locale must
9860        /// still route normally.
9861        #[tokio::test]
9862        async fn locale_segment_with_uri_delimiter_does_not_panic_and_valid_locale_still_routes() {
9863            let config = config(&["en", "en?x", "en#x", "en y"], &[]);
9864            let route = simple_route("/posts", "posts", true);
9865            let app = build_router(vec![route], &config, test_state())
9866                .layer(axum::Extension(bundle(&["en"])));
9867
9868            let en = request(&app, "/en/posts", &[]).await;
9869            assert_eq!(en.status(), StatusCode::OK);
9870            assert_eq!(body_string(en).await, "en");
9871        }
9872
9873        /// Codex review (P1): the bare-path redirect must claim only the
9874        /// methods actually registered at that path, not every method via
9875        /// `any()`. A user route at `POST /health` (a different method than
9876        /// the framework's own auto-mounted `GET /health` probe) must not
9877        /// have its redirect swallow GET too — that would collide with the
9878        /// probe's later auto-mount, which has no visibility into methods
9879        /// the redirect already claimed.
9880        #[tokio::test]
9881        async fn bare_path_redirect_claims_only_registered_methods_not_every_method() {
9882            async fn user_health_post() -> &'static str {
9883                "posted"
9884            }
9885
9886            let config = config(&["en"], &[]);
9887            let route = Route {
9888                method: http::Method::POST,
9889                path: "/health",
9890                handler: axum::routing::post(user_health_post),
9891                name: "user_health_post",
9892                api_doc: crate::openapi::ApiDoc {
9893                    method: "POST",
9894                    path: "/health",
9895                    operation_id: "user_health_post",
9896                    success_status: 200,
9897                    ..Default::default()
9898                },
9899                repository: None,
9900                idempotency: crate::route::RouteIdempotency::Direct,
9901                timeout: crate::route::RouteTimeout::Inherit,
9902                criticality: crate::admission::Criticality::Default,
9903                seo: crate::seo::SeoRouteDefaults::EMPTY,
9904                api_version: None,
9905                sunset_opt_out: false,
9906            };
9907
9908            // Before the fix, the redirect's `any()` claimed GET at /health
9909            // too, colliding with the framework's own auto-mounted probe and
9910            // panicking inside `axum::Router::route`.
9911            let app = build_router(vec![route], &config, test_state());
9912
9913            let health = request(&app, "/health", &[]).await;
9914            assert_eq!(
9915                health.status(),
9916                StatusCode::OK,
9917                "the framework's own health probe must still respond at GET /health"
9918            );
9919
9920            let redirected = request_method(&app, http::Method::POST, "/health", &[]).await;
9921            assert_eq!(redirected.status(), StatusCode::PERMANENT_REDIRECT);
9922            assert_eq!(
9923                redirected
9924                    .headers()
9925                    .get(axum::http::header::LOCATION)
9926                    .and_then(|v| v.to_str().ok()),
9927                Some("/en/health")
9928            );
9929        }
9930
9931        /// Codex review (P2): `LocaleRoutingConfig` (and the default-locale
9932        /// negotiation fallback) must be built from the validated,
9933        /// deduplicated locale set — the same one actually nested — not the
9934        /// raw `supported_locales`. Otherwise a skipped invalid entry could
9935        /// still be selected as the negotiated locale (or as the fallback
9936        /// default) and 404, since no `/{locale}` nest exists for it.
9937        #[tokio::test]
9938        async fn skipped_invalid_locale_is_never_selected_by_negotiation() {
9939            let mut config = config(&["", "en"], &[]);
9940            config.i18n.default_locale = String::new();
9941            let route = simple_route("/posts", "posts", true);
9942            let app = build_router(vec![route], &config, test_state());
9943
9944            // The empty "default" is invalid and skipped, so the bare-path
9945            // redirect must fall back to "en" — the only validly-nested
9946            // locale — not attempt an unreachable "//posts".
9947            let response = request(&app, "/posts", &[]).await;
9948            assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
9949            assert_eq!(
9950                response
9951                    .headers()
9952                    .get(axum::http::header::LOCATION)
9953                    .and_then(|v| v.to_str().ok()),
9954                Some("/en/posts"),
9955                "must redirect to the validly-nested locale, not the skipped empty default"
9956            );
9957        }
9958
9959        /// AC: an unknown `{locale}` prefix 404s — no panic.
9960        #[tokio::test]
9961        async fn unknown_locale_prefix_is_404_not_panic() {
9962            let config = config(&["en", "es"], &[]);
9963            let route = simple_route("/posts", "posts", true);
9964            let app = build_router(vec![route], &config, test_state())
9965                .layer(axum::Extension(bundle(&["en", "es"])));
9966
9967            let response = request(&app, "/zz/posts", &[]).await;
9968            assert_eq!(response.status(), StatusCode::NOT_FOUND);
9969        }
9970
9971        /// AC: a bare (non-prefixed) path 308-redirects to the negotiated
9972        /// locale's prefixed path, preserving the query string.
9973        #[tokio::test]
9974        async fn bare_path_redirects_preserving_query() {
9975            let config = config(&["en", "es"], &[]);
9976            let route = simple_route("/posts", "posts", true);
9977            let app = build_router(vec![route], &config, test_state())
9978                .layer(axum::Extension(bundle(&["en", "es"])));
9979
9980            let response = request(&app, "/posts?sort=asc", &[]).await;
9981            assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
9982            assert_eq!(
9983                response
9984                    .headers()
9985                    .get(axum::http::header::LOCATION)
9986                    .and_then(|v| v.to_str().ok()),
9987                Some("/en/posts?sort=asc")
9988            );
9989
9990            let response = request(
9991                &app,
9992                "/posts?sort=asc",
9993                &[(axum::http::header::ACCEPT_LANGUAGE.as_str(), "es")],
9994            )
9995            .await;
9996            assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
9997            assert_eq!(
9998                response
9999                    .headers()
10000                    .get(axum::http::header::LOCATION)
10001                    .and_then(|v| v.to_str().ok()),
10002                Some("/es/posts?sort=asc")
10003            );
10004        }
10005
10006        /// AC: the URL prefix takes precedence over cookie/session/
10007        /// `Accept-Language`, with zero handler changes (the handler above
10008        /// takes a plain `Locale` parameter).
10009        #[tokio::test]
10010        async fn url_prefix_locale_wins_over_cookie_and_accept_language() {
10011            let config = config(&["en", "es"], &[]);
10012            let route = simple_route("/posts", "posts", true);
10013            let app = build_router(vec![route], &config, test_state())
10014                .layer(axum::Extension(bundle(&["en", "es"])));
10015
10016            let response = request(
10017                &app,
10018                "/es/posts",
10019                &[
10020                    (axum::http::header::COOKIE.as_str(), "autumn_locale=en"),
10021                    (axum::http::header::ACCEPT_LANGUAGE.as_str(), "en"),
10022                ],
10023            )
10024            .await;
10025            assert_eq!(response.status(), StatusCode::OK);
10026            assert_eq!(body_string(response).await, "es");
10027        }
10028
10029        /// AC: configured prefixes are excluded from localization and from
10030        /// the bare-path redirect, so machine endpoints stay unprefixed.
10031        #[tokio::test]
10032        async fn excluded_prefix_stays_unprefixed_and_unredirected() {
10033            let config = config(&["en", "es"], &["/api"]);
10034            let route = simple_route("/api/status", "api_status", false);
10035            let app = build_router(vec![route], &config, test_state())
10036                .layer(axum::Extension(bundle(&["en", "es"])));
10037
10038            let direct = request(&app, "/api/status", &[]).await;
10039            assert_eq!(
10040                direct.status(),
10041                StatusCode::OK,
10042                "excluded route must serve directly, not redirect"
10043            );
10044
10045            let nested = request(&app, "/en/api/status", &[]).await;
10046            assert_eq!(
10047                nested.status(),
10048                StatusCode::NOT_FOUND,
10049                "excluded route must not be nested under a locale prefix"
10050            );
10051        }
10052
10053        /// A trailing `/*` on a configured exclude prefix is equivalent to
10054        /// the bare prefix — `"/api"` and `"/api/*"` behave identically.
10055        #[tokio::test]
10056        async fn exclude_prefix_accepts_trailing_glob_suffix() {
10057            let config = config(&["en", "es"], &["/api/*"]);
10058            let route = simple_route("/api/status", "api_status", false);
10059            let app = build_router(vec![route], &config, test_state())
10060                .layer(axum::Extension(bundle(&["en", "es"])));
10061
10062            let direct = request(&app, "/api/status", &[]).await;
10063            assert_eq!(direct.status(), StatusCode::OK);
10064
10065            let nested = request(&app, "/en/api/status", &[]).await;
10066            assert_eq!(nested.status(), StatusCode::NOT_FOUND);
10067        }
10068
10069        /// Codex review (P1): a bare `"/"` exclude entry (as
10070        /// `exclude_static_routes_from_locale_prefix` adds for a
10071        /// `#[static_get("/")]` route) must exclude exactly the root path,
10072        /// not be normalized away to an empty, always-non-matching prefix.
10073        #[tokio::test]
10074        async fn root_path_exclude_prefix_excludes_exactly_the_root() {
10075            let config = config(&["en", "es"], &["/"]);
10076            let root = simple_route("/", "root", false);
10077            let posts = simple_route("/posts", "posts", true);
10078            let app = build_router(vec![root, posts], &config, test_state())
10079                .layer(axum::Extension(bundle(&["en", "es"])));
10080
10081            let bare_root = request(&app, "/", &[]).await;
10082            assert_eq!(
10083                bare_root.status(),
10084                StatusCode::OK,
10085                "excluded root route must serve directly, not redirect"
10086            );
10087
10088            let nested_root = request(&app, "/en", &[]).await;
10089            assert_eq!(
10090                nested_root.status(),
10091                StatusCode::NOT_FOUND,
10092                "excluded root route must not be nested under a locale prefix"
10093            );
10094
10095            // A "/" exclude entry must not swallow every other path — only
10096            // the exact root.
10097            let bare_posts = request(&app, "/posts", &[]).await;
10098            assert_eq!(
10099                bare_posts.status(),
10100                StatusCode::PERMANENT_REDIRECT,
10101                "\"/\" in the exclude list must not exclude unrelated routes"
10102            );
10103        }
10104
10105        /// A path that merely starts with an exclude prefix's characters
10106        /// (`/apikeys` vs the configured `/api`) is NOT a prefix match — only
10107        /// an exact path or a `/`-delimited sub-path counts. Otherwise
10108        /// `/apikeys` would be wrongly swept into the excluded/unprefixed
10109        /// group alongside genuine `/api/*` routes.
10110        #[tokio::test]
10111        async fn exclude_prefix_does_not_match_unrelated_path_sharing_a_string_prefix() {
10112            let config = config(&["en", "es"], &["/api"]);
10113            let route = simple_route("/apikeys", "apikeys", true);
10114            let app = build_router(vec![route], &config, test_state())
10115                .layer(axum::Extension(bundle(&["en", "es"])));
10116
10117            let bare = request(&app, "/apikeys", &[]).await;
10118            assert_eq!(
10119                bare.status(),
10120                StatusCode::PERMANENT_REDIRECT,
10121                "/apikeys must be treated as included (locale-prefixed), not excluded"
10122            );
10123
10124            let nested = request(&app, "/en/apikeys", &[]).await;
10125            assert_eq!(
10126                nested.status(),
10127                StatusCode::OK,
10128                "/apikeys must be nested under the locale prefix like any other included route"
10129            );
10130        }
10131
10132        /// A parameterized route (`/posts/{slug}`) is reachable through a
10133        /// locale nest exactly like a static path — dynamic-segment matching
10134        /// works the same whether or not the route is nested.
10135        #[tokio::test]
10136        async fn parameterized_route_reachable_under_locale_prefix() {
10137            let config = config(&["en", "es"], &[]);
10138            let route = slug_route("/posts/{slug}", "posts_show");
10139            let app = build_router(vec![route], &config, test_state())
10140                .layer(axum::Extension(bundle(&["en", "es"])));
10141
10142            let response = request(&app, "/es/posts/hello-world", &[]).await;
10143            assert_eq!(response.status(), StatusCode::OK);
10144            assert_eq!(body_string(response).await, "es:hello-world");
10145        }
10146
10147        /// Codex review (P1): an exact-match exclusion (as populated by
10148        /// `exclude_static_routes_from_locale_prefix` for a `#[static_get]`
10149        /// route) must not behave like a *prefix* exclusion — excluding the
10150        /// literal static route `/posts` must not also swallow an unrelated
10151        /// dynamic sibling like `/posts/{slug}`, which shares only a string
10152        /// prefix, not a namespace.
10153        #[tokio::test]
10154        async fn exact_exclude_does_not_swallow_a_dynamic_sibling_route() {
10155            let mut config = config(&["en", "es"], &[]);
10156            config.i18n.locale_prefix_exclude_exact = vec!["/posts".to_owned()];
10157            let static_index = simple_route("/posts", "posts_index", false);
10158            let dynamic_child = slug_route("/posts/{slug}", "posts_show");
10159            let app = build_router(vec![static_index, dynamic_child], &config, test_state())
10160                .layer(axum::Extension(bundle(&["en", "es"])));
10161
10162            let excluded = request(&app, "/posts", &[]).await;
10163            assert_eq!(
10164                excluded.status(),
10165                StatusCode::OK,
10166                "the exactly-excluded static route must stay unprefixed and unredirected"
10167            );
10168
10169            let excluded_prefixed = request(&app, "/en/posts", &[]).await;
10170            assert_eq!(
10171                excluded_prefixed.status(),
10172                StatusCode::NOT_FOUND,
10173                "the exactly-excluded static route must not be nested under a locale prefix"
10174            );
10175
10176            let sibling_prefixed = request(&app, "/en/posts/hello-world", &[]).await;
10177            assert_eq!(
10178                sibling_prefixed.status(),
10179                StatusCode::OK,
10180                "a dynamic sibling sharing the excluded path as a string prefix must \
10181                 still be locale-prefixed normally — an exact exclusion must not act \
10182                 like a prefix exclusion"
10183            );
10184            assert_eq!(body_string(sibling_prefixed).await, "en:hello-world");
10185
10186            let sibling_bare = request(&app, "/posts/hello-world", &[]).await;
10187            assert_eq!(
10188                sibling_bare.status(),
10189                StatusCode::PERMANENT_REDIRECT,
10190                "the dynamic sibling's bare path must still redirect, since only the \
10191                 exact literal `/posts` is excluded"
10192            );
10193        }
10194
10195        /// Core assumption underpinning `locale_switcher`/hreflang
10196        /// (documented on `widgets::localized_path` and
10197        /// `seo::locale_alternates`): axum's `nest()` strips the matched
10198        /// locale segment before extraction, so a handler's plain `Uri`
10199        /// extractor inside the nest sees the locale-stripped path — not the
10200        /// `/es/posts` the client actually requested.
10201        #[tokio::test]
10202        async fn uri_extractor_inside_locale_nest_sees_locale_stripped_path() {
10203            let config = config(&["en", "es"], &[]);
10204            let route = uri_probe_route("/posts", "posts_uri_probe");
10205            let app = build_router(vec![route], &config, test_state())
10206                .layer(axum::Extension(bundle(&["en", "es"])));
10207
10208            let response = request(&app, "/es/posts", &[]).await;
10209            assert_eq!(response.status(), StatusCode::OK);
10210            assert_eq!(
10211                body_string(response).await,
10212                "/posts",
10213                "Uri extractor inside a locale nest must see the prefix-stripped path"
10214            );
10215        }
10216
10217        /// The redirect handler is mounted via `any()`, so it must redirect
10218        /// non-GET methods too, not just the GET case every other test uses.
10219        #[tokio::test]
10220        async fn bare_path_redirect_applies_to_non_get_methods() {
10221            let config = config(&["en", "es"], &[]);
10222            let route = make_post_route("/posts", "posts_create");
10223            let app = build_router(vec![route], &config, test_state())
10224                .layer(axum::Extension(bundle(&["en", "es"])));
10225
10226            let response = request_method(&app, http::Method::POST, "/posts", &[]).await;
10227            assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
10228            assert_eq!(
10229                response
10230                    .headers()
10231                    .get(axum::http::header::LOCATION)
10232                    .and_then(|v| v.to_str().ok()),
10233                Some("/en/posts")
10234            );
10235        }
10236
10237        /// Two routes sharing one path under different HTTP methods must
10238        /// still dedup to a single bare-path redirect registration — the
10239        /// `included_paths.sort_unstable(); included_paths.dedup();` step in
10240        /// `mount_user_routes` exists precisely to prevent `Router::route`
10241        /// panicking on the same path registered twice.
10242        #[tokio::test]
10243        async fn same_path_different_methods_dedups_to_one_redirect_route() {
10244            let config = config(&["en", "es"], &[]);
10245            let get_route = simple_route("/posts", "posts_list", true);
10246            let post_route = make_post_route("/posts", "posts_create");
10247            let app = build_router(vec![get_route, post_route], &config, test_state())
10248                .layer(axum::Extension(bundle(&["en", "es"])));
10249
10250            let get_redirect = request(&app, "/posts", &[]).await;
10251            assert_eq!(get_redirect.status(), StatusCode::PERMANENT_REDIRECT);
10252            let post_redirect = request_method(&app, http::Method::POST, "/posts", &[]).await;
10253            assert_eq!(post_redirect.status(), StatusCode::PERMANENT_REDIRECT);
10254
10255            let en_get = request(&app, "/en/posts", &[]).await;
10256            assert_eq!(en_get.status(), StatusCode::OK);
10257            let en_post = request_method(&app, http::Method::POST, "/en/posts", &[]).await;
10258            assert_eq!(en_post.status(), StatusCode::OK);
10259        }
10260
10261        /// A region-subtagged locale code (`es-MX`) works as a literal nest
10262        /// prefix like any other configured locale, and an uppercase variant
10263        /// of a configured code is treated as unknown (404) — the nest
10264        /// prefix is a literal path segment, not case-negotiated.
10265        #[tokio::test]
10266        async fn region_subtag_locale_nests_and_prefix_is_case_sensitive() {
10267            let config = config(&["en", "es-MX"], &[]);
10268            let route = simple_route("/posts", "posts", true);
10269            let app = build_router(vec![route], &config, test_state())
10270                .layer(axum::Extension(bundle(&["en", "es-MX"])));
10271
10272            let response = request(&app, "/es-MX/posts", &[]).await;
10273            assert_eq!(response.status(), StatusCode::OK);
10274            assert_eq!(body_string(response).await, "es-MX");
10275
10276            let wrong_case = request(&app, "/ES-MX/posts", &[]).await;
10277            assert_eq!(
10278                wrong_case.status(),
10279                StatusCode::NOT_FOUND,
10280                "the locale nest prefix is a literal path segment, not case-negotiated"
10281            );
10282        }
10283
10284        /// `supported_locales = []` is a degenerate config (locale-prefix
10285        /// routing on, nothing to prefix with): no nest is created, and the
10286        /// bare-path redirect is skipped rather than 308-ing to an
10287        /// unreachable `/{default_locale}/...` target — a clean 404 beats a
10288        /// redirect-then-404 round trip.
10289        #[tokio::test]
10290        async fn empty_supported_locales_serves_clean_404_not_redirect_to_nowhere() {
10291            let mut config = AutumnConfig::default();
10292            config.i18n.locale_prefix_enabled = true;
10293            config.i18n.supported_locales = vec![];
10294
10295            let route = simple_route("/posts", "posts", false);
10296            let app = build_router(vec![route], &config, test_state());
10297
10298            let response = request(&app, "/posts", &[]).await;
10299            assert_eq!(response.status(), StatusCode::NOT_FOUND);
10300        }
10301
10302        /// Codex review (P2): a misconfigured `default_locale` absent from
10303        /// `supported_locales` (e.g. `default_locale = "fr"`,
10304        /// `supported_locales = ["en"]`) must not negotiate a bare request to
10305        /// an unreachable `/fr/...` target — only `en` is actually nested.
10306        /// The negotiation fallback clamps to the first supported (i.e.
10307        /// actually mounted) locale instead.
10308        #[tokio::test]
10309        async fn misconfigured_default_locale_falls_back_to_a_mounted_locale() {
10310            let mut config = config(&["en"], &[]);
10311            config.i18n.default_locale = "fr".to_owned();
10312
10313            let route = simple_route("/posts", "posts", true);
10314            // No bundle — exercises the router's own `LocaleRoutingConfig`
10315            // fallback, same as `locale_prefix_redirect_works_without_an_i18n_bundle`.
10316            let app = build_router(vec![route], &config, test_state());
10317
10318            let redirected = request(&app, "/posts", &[]).await;
10319            assert_eq!(redirected.status(), StatusCode::PERMANENT_REDIRECT);
10320            assert_eq!(
10321                redirected
10322                    .headers()
10323                    .get(axum::http::header::LOCATION)
10324                    .and_then(|v| v.to_str().ok()),
10325                Some("/en/posts"),
10326                "must redirect to a locale that's actually mounted, not the \
10327                 misconfigured default_locale"
10328            );
10329
10330            let target = request(&app, "/en/posts", &[]).await;
10331            assert_eq!(target.status(), StatusCode::OK);
10332        }
10333
10334        /// A pre-rendered page inside a locale nest resolves to the nested
10335        /// route, as axum's `MatchedPath` reports it, not to a top-level
10336        /// wildcard.
10337        #[test]
10338        fn a_localized_static_page_resolves_inside_its_nest() {
10339            let config = config(&["en", "fr"], &[]);
10340            let routes = vec![
10341                simple_route("/files/public", "public", false),
10342                simple_route("/{*path}", "any", false),
10343            ];
10344            let pages = ["/en/files/public".to_owned(), "/fr/other".to_owned()];
10345            let map = super::super::static_page_routes(pages.iter(), &routes, &[], &config);
10346            assert_eq!(map["/en/files/public"].as_deref(), Some("/en/files/public"));
10347            assert_eq!(map["/fr/other"].as_deref(), Some("/fr/{*path}"));
10348        }
10349
10350        /// Codex review (P1): an excluded route mounts bare, so a page under
10351        /// a locale-looking segment goes to the route axum mounts there, not
10352        /// to an excluded route re-read inside a locale nest.
10353        #[test]
10354        fn an_excluded_route_is_matched_where_it_is_mounted() {
10355            let config = config(&["en", "fr"], &["/api", "/en"]);
10356            let routes = vec![
10357                simple_route("/api/{id}", "api", false),
10358                simple_route("/en/{*path}", "english", false),
10359                simple_route("/posts", "posts", false),
10360            ];
10361            let pages = [
10362                "/en/api/42".to_owned(),
10363                "/api/42".to_owned(),
10364                "/fr/posts".to_owned(),
10365                "/fr/api/42".to_owned(),
10366            ];
10367            let map = super::super::static_page_routes(pages.iter(), &routes, &[], &config);
10368            assert_eq!(map["/en/api/42"].as_deref(), Some("/en/{*path}"));
10369            assert_eq!(map["/api/42"].as_deref(), Some("/api/{id}"));
10370            assert_eq!(map["/fr/posts"].as_deref(), Some("/fr/posts"));
10371            // `/api/{id}` is not mounted in the `fr` nest.
10372            assert_eq!(map["/fr/api/42"], None);
10373        }
10374
10375        /// Codex review (P2): a home page served only by a merged router
10376        /// mounts outside the locale nests, so discovery lists `/`, not a
10377        /// `/{locale}` home that does not exist.
10378        #[test]
10379        fn a_merged_router_home_stays_unprefixed() {
10380            let config = config(&["en", "fr"], &[]);
10381            let site = super::build_aeo_site::<(), ()>(
10382                &config,
10383                &test_state(),
10384                &[simple_route("/posts", "posts", false)],
10385                &[],
10386                true,
10387                None,
10388                None,
10389            );
10390            assert_eq!(site.facts.home_paths, vec!["/".to_owned()]);
10391            assert!(!site.facts.no_home);
10392
10393            let routed = super::build_aeo_site::<(), ()>(
10394                &config,
10395                &test_state(),
10396                &[simple_route("/", "home", false)],
10397                &[],
10398                true,
10399                None,
10400                None,
10401            );
10402            assert_eq!(
10403                routed.facts.home_paths,
10404                vec!["/en".to_owned(), "/fr".to_owned()]
10405            );
10406
10407            // Codex review (P2): a scoped `/` home is reachable at `/` next to
10408            // the localized ones, and is listed with them.
10409            let scoped = crate::app::ScopedGroup {
10410                prefix: "/".to_owned(),
10411                routes: vec![simple_route("/", "scoped_home", false)],
10412                source: crate::route_listing::RouteSource::User,
10413                layer_guarded: false,
10414                apply_layer: Box::new(|r| r),
10415            };
10416            let both = super::build_aeo_site::<(), ()>(
10417                &config,
10418                &test_state(),
10419                &[simple_route("/", "home", false)],
10420                &[scoped],
10421                false,
10422                None,
10423                None,
10424            );
10425            assert_eq!(
10426                both.facts.home_paths,
10427                vec!["/en".to_owned(), "/fr".to_owned(), "/".to_owned()]
10428            );
10429            assert!(both.facts.noindex_homes.is_empty());
10430
10431            // Codex review (P2): a `noindex` scoped `/` leaves only `/` out;
10432            // the indexable localized homes stay listed.
10433            let mut scoped_home = simple_route("/", "scoped_home", false);
10434            scoped_home.seo = crate::seo::SeoRouteDefaults {
10435                robots: Some("noindex"),
10436                ..crate::seo::SeoRouteDefaults::EMPTY
10437            };
10438            let scoped = crate::app::ScopedGroup {
10439                prefix: "/".to_owned(),
10440                routes: vec![scoped_home],
10441                source: crate::route_listing::RouteSource::User,
10442                layer_guarded: false,
10443                apply_layer: Box::new(|r| r),
10444            };
10445            let mixed = super::build_aeo_site::<(), ()>(
10446                &config,
10447                &test_state(),
10448                &[simple_route("/", "home", false)],
10449                &[scoped],
10450                false,
10451                None,
10452                None,
10453            );
10454            assert_eq!(mixed.facts.noindex_homes, vec!["/".to_owned()]);
10455        }
10456
10457        /// Routes registered via `AppBuilder::scoped()` are mounted by a
10458        /// separate pipeline (`mount_scoped_groups`, which runs after
10459        /// locale-prefix mounting) and are therefore untouched by
10460        /// locale-prefix routing — no locale nest, no bare-path redirect —
10461        /// exactly as if the flag were off. This guards the framework's
10462        /// documented boundary: only the top-level `routes![...]` table is
10463        /// locale-prefixed.
10464        #[tokio::test]
10465        async fn scoped_group_routes_are_not_locale_prefixed() {
10466            let config = config(&["en", "es"], &[]);
10467            let scoped_route = duplicate_test_route(http::Method::GET, "/status", "scoped_status");
10468            let group = crate::app::ScopedGroup {
10469                prefix: "/admin".to_owned(),
10470                routes: vec![scoped_route],
10471                source: crate::route_listing::RouteSource::User,
10472                layer_guarded: false,
10473                apply_layer: Box::new(|r| r),
10474            };
10475            let mut ctx = duplicate_test_ctx();
10476            ctx.scoped_groups.push(group);
10477
10478            let router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
10479                .expect("router with a scoped group under locale-prefix routing should build")
10480                .layer(axum::Extension(bundle(&["en", "es"])));
10481
10482            let direct = request(&router, "/admin/status", &[]).await;
10483            assert_eq!(
10484                direct.status(),
10485                StatusCode::OK,
10486                "scoped route must serve directly, unprefixed"
10487            );
10488
10489            let nested = request(&router, "/en/admin/status", &[]).await;
10490            assert_eq!(
10491                nested.status(),
10492                StatusCode::NOT_FOUND,
10493                "scoped routes are mounted after locale-prefix nesting, so no nest exists for them"
10494            );
10495        }
10496    }
10497
10498    /// Loads a layered `autumn.toml` for `profile` via `MockEnv` (no process
10499    /// env, no `set_current_dir`) and reports the status `/_stories` returns.
10500    #[cfg(feature = "maud")]
10501    async fn stories_status_for_layered_profile(toml: &str, profile: &str) -> StatusCode {
10502        let dir = tempfile::tempdir().expect("tempdir");
10503        std::fs::write(dir.path().join("autumn.toml"), toml).expect("write autumn.toml");
10504        let env = crate::config::MockEnv::new()
10505            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap())
10506            .with("AUTUMN_ENV", profile);
10507        let mut config = AutumnConfig::load_with_env(&env).expect("layered config should load");
10508        config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
10509
10510        let router = build_router(Vec::new(), &config, stories_state_with_builtin());
10511        get_with_host(router, "/_stories").await.status()
10512    }
10513
10514    /// T5 (AC6): profile-scoped gating works both ways through the existing
10515    /// config layering — routes mount iff the resolved flag is true.
10516    #[cfg(feature = "maud")]
10517    #[tokio::test]
10518    async fn story_routes_mount_iff_resolved_profile_flag() {
10519        // Private app: dev-only gallery, absent in prod.
10520        let dev_only = r"
10521[stories]
10522enabled = false
10523
10524[profile.dev.stories]
10525enabled = true
10526";
10527        assert_eq!(
10528            stories_status_for_layered_profile(dev_only, "dev").await,
10529            StatusCode::OK,
10530            "dev profile override must mount the gallery"
10531        );
10532        assert_eq!(
10533            stories_status_for_layered_profile(dev_only, "prod").await,
10534            StatusCode::NOT_FOUND,
10535            "prod must not mount the gallery when only dev enables it"
10536        );
10537
10538        // Public showcase: enabled in prod, absent in dev.
10539        let public_showcase = r"
10540[stories]
10541enabled = false
10542
10543[profile.prod.stories]
10544enabled = true
10545";
10546        assert_eq!(
10547            stories_status_for_layered_profile(public_showcase, "prod").await,
10548            StatusCode::OK,
10549            "prod profile override must mount the gallery for a public showcase"
10550        );
10551        assert_eq!(
10552            stories_status_for_layered_profile(public_showcase, "dev").await,
10553            StatusCode::NOT_FOUND,
10554            "dev must not mount the gallery when only prod enables it"
10555        );
10556    }
10557
10558    /// T6 (AC7): a custom app story registered via
10559    /// `StoryGallery::builtin().extend(...)` is served alongside builtins.
10560    #[cfg(feature = "maud")]
10561    #[tokio::test]
10562    async fn custom_story_served_alongside_builtins() {
10563        let custom = crate::stories::story! {
10564            "App",
10565            "Greeting",
10566            {
10567                maud::html! { span class="app-greeting" { "hi from the app" } }
10568            }
10569        };
10570        let state = test_state();
10571        state.insert_extension(
10572            crate::stories::StoryGallery::builtin()
10573                .extend([custom])
10574                .into_registry(),
10575        );
10576
10577        let router = build_router(Vec::new(), &story_gallery_config(), state);
10578
10579        let detail = get_with_host(router.clone(), "/_stories/greeting").await;
10580        assert_eq!(detail.status(), StatusCode::OK);
10581        let body = response_text(detail).await;
10582        assert!(
10583            body.contains("hi from the app"),
10584            "custom story must render at its slug: {body}"
10585        );
10586
10587        let index = get_with_host(router, "/_stories").await;
10588        let body = response_text(index).await;
10589        assert!(
10590            body.contains("Greeting"),
10591            "index must list the custom story: {body}"
10592        );
10593        assert!(
10594            body.contains("App"),
10595            "index must show the custom story's group: {body}"
10596        );
10597        assert!(
10598            body.contains("Data table"),
10599            "builtins must still be listed alongside the custom story: {body}"
10600        );
10601    }
10602
10603    /// Review follow-up (#1526): with `security.headers.csp_nonce.enabled =
10604    /// true` the default CSP's `style-src` drops `'unsafe-inline'` in favor of
10605    /// a per-request nonce, so the gallery's inline `<style>` must carry the
10606    /// exact nonce the CSP header advertises or browsers block all of the
10607    /// gallery chrome CSS.
10608    #[cfg(feature = "maud")]
10609    #[tokio::test]
10610    async fn story_pages_inline_style_carries_csp_header_nonce() {
10611        let mut config = story_gallery_config();
10612        config.security.headers.csp_nonce.enabled = true;
10613
10614        let router = build_router(Vec::new(), &config, stories_state_with_builtin());
10615
10616        for uri in ["/_stories", "/_stories/data-table"] {
10617            let response = get_with_host(router.clone(), uri).await;
10618            assert_eq!(response.status(), StatusCode::OK);
10619
10620            let csp = response
10621                .headers()
10622                .get("content-security-policy")
10623                .expect("CSP header must be present")
10624                .to_str()
10625                .unwrap()
10626                .to_owned();
10627            let nonce = csp
10628                .split("'nonce-")
10629                .nth(1)
10630                .and_then(|rest| rest.split('\'').next())
10631                .unwrap_or_else(|| panic!("CSP header must advertise a nonce: {csp}"))
10632                .to_owned();
10633            assert!(!nonce.is_empty(), "advertised nonce must be non-empty");
10634
10635            let body = response_text(response).await;
10636            assert!(
10637                body.contains(&format!(r#"<style nonce="{nonce}">"#)),
10638                "{uri} inline style must carry the CSP header nonce {nonce}: {body}"
10639            );
10640        }
10641    }
10642
10643    /// T17 (AC5, R12): enabled config but no registry extension (the user
10644    /// forgot `with_story_gallery`) serves a friendly empty state, not a 500.
10645    #[cfg(feature = "maud")]
10646    #[tokio::test]
10647    async fn enabled_without_registry_shows_empty_state() {
10648        let router = build_router(Vec::new(), &story_gallery_config(), test_state());
10649
10650        let response = get_with_host(router, "/_stories").await;
10651        assert_eq!(response.status(), StatusCode::OK);
10652        let body = response_text(response).await;
10653        assert!(
10654            body.contains("with_story_gallery"),
10655            "empty state should point at AppBuilder::with_story_gallery: {body}"
10656        );
10657    }
10658
10659    #[tokio::test]
10660    async fn apply_csrf_middleware_blocks_without_token_when_enabled() {
10661        let mut config = AutumnConfig::default();
10662        config.security.csrf.enabled = true;
10663
10664        let base: axum::Router<AppState> =
10665            axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
10666        let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
10667
10668        // POST without CSRF token should be rejected
10669        let response = router
10670            .oneshot(
10671                Request::builder()
10672                    .method("POST")
10673                    .uri("/form")
10674                    .body(Body::empty())
10675                    .unwrap(),
10676            )
10677            .await
10678            .unwrap();
10679
10680        assert_ne!(
10681            response.status(),
10682            StatusCode::OK,
10683            "POST without CSRF token should be rejected when CSRF is enabled"
10684        );
10685    }
10686
10687    #[test]
10688    fn join_nested_path_normalizes_like_axum() {
10689        // Reviewer's reported case: scope "/api" + child "/" must
10690        // produce "/api", not "/api/" — otherwise a user-configured
10691        // openapi_json_path("/api") won't match the effective mount
10692        // point and the collision check is unreliable.
10693        assert_eq!(super::join_nested_path("/api", "/"), "/api");
10694        // Trailing slash on the prefix is preserved for the root child:
10695        // axum mounts `nest("/api/", route("/"))` at "/api/" and reports
10696        // `MatchedPath` as "/api/" (verified by
10697        // `join_nested_path_matches_axum_matched_path`), so the joined key
10698        // must keep the slash or the runtime lookup misses.
10699        assert_eq!(super::join_nested_path("/api/", "/"), "/api/");
10700        // Normal case: prefix + child.
10701        assert_eq!(super::join_nested_path("/api", "/users"), "/api/users");
10702        // Trailing slash on prefix + child starting with slash doesn't
10703        // produce doubled slashes.
10704        assert_eq!(super::join_nested_path("/api/", "/users"), "/api/users");
10705        // Root prefix handles sensibly.
10706        assert_eq!(super::join_nested_path("", "/"), "/");
10707        assert_eq!(super::join_nested_path("", "/users"), "/users");
10708    }
10709
10710    /// Pins `join_nested_path` to axum's real `MatchedPath` so the per-route
10711    /// timeout table (and the `OpenAPI` collision check) key by exactly the
10712    /// string the runtime looks up. The trailing-slash root child is the
10713    /// subtle case: `nest("/api/", route("/"))` is served at "/api/", not
10714    /// "/api".
10715    #[tokio::test]
10716    async fn join_nested_path_matches_axum_matched_path() {
10717        use axum::routing::get;
10718        async fn matched(mp: Option<axum::extract::MatchedPath>) -> String {
10719            mp.map(|m| m.as_str().to_owned()).unwrap_or_default()
10720        }
10721        // (nest prefix, child route, request path that reaches the child)
10722        for (prefix, child, req) in [
10723            ("/api", "/", "/api"),
10724            ("/api/", "/", "/api/"),
10725            ("/api", "/users", "/api/users"),
10726            ("/api/", "/users", "/api/users"),
10727        ] {
10728            let sub = axum::Router::new().route(child, get(matched));
10729            let app: axum::Router = axum::Router::new().nest(prefix, sub);
10730            let resp = tower::ServiceExt::oneshot(
10731                app,
10732                axum::http::Request::builder()
10733                    .uri(req)
10734                    .body(axum::body::Body::empty())
10735                    .unwrap(),
10736            )
10737            .await
10738            .unwrap();
10739            assert_eq!(resp.status(), http::StatusCode::OK, "{prefix} + {child}");
10740            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10741                .await
10742                .unwrap();
10743            let axum_matched = String::from_utf8(body.to_vec()).unwrap();
10744            assert_eq!(
10745                super::join_nested_path(prefix, child),
10746                axum_matched,
10747                "join_nested_path must equal axum MatchedPath for nest({prefix:?}, {child:?})"
10748            );
10749        }
10750    }
10751
10752    #[cfg(feature = "openapi")]
10753    #[tokio::test]
10754    async fn try_build_router_detects_scoped_root_collision() {
10755        // Scope "/api" + child "/" mounts axum's handler at "/api"
10756        // (not "/api/"). The collision check must use the same
10757        // normalization or we'd miss this overlap.
10758        use crate::openapi::{ApiDoc, OpenApiConfig};
10759        async fn child() -> &'static str {
10760            "inner"
10761        }
10762        let group = crate::app::ScopedGroup {
10763            prefix: "/api".to_owned(),
10764            routes: vec![Route {
10765                method: http::Method::GET,
10766                path: "/",
10767                handler: axum::routing::get(child),
10768                name: "root",
10769                api_doc: ApiDoc {
10770                    method: "GET",
10771                    path: "/",
10772                    operation_id: "root",
10773                    success_status: 200,
10774                    ..Default::default()
10775                },
10776                repository: None,
10777                idempotency: crate::route::RouteIdempotency::Direct,
10778                timeout: crate::route::RouteTimeout::Inherit,
10779                criticality: crate::admission::Criticality::Default,
10780                seo: crate::seo::SeoRouteDefaults::EMPTY,
10781                api_version: None,
10782                sunset_opt_out: false,
10783            }],
10784            source: crate::route_listing::RouteSource::User,
10785            layer_guarded: false,
10786            apply_layer: Box::new(|r| r),
10787        };
10788
10789        let openapi = OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api");
10790        let config = AutumnConfig::default();
10791        let ctx = RouterContext {
10792            exception_filters: Vec::new(),
10793            scoped_groups: vec![group],
10794            merge_routers: Vec::new(),
10795            nest_routers: Vec::new(),
10796            declared_routes: Vec::new(),
10797            custom_layers: Vec::new(),
10798            static_gate_layers: Vec::new(),
10799            #[cfg(feature = "maud")]
10800            error_page_renderer: None,
10801            session_store: None,
10802            openapi: Some(openapi),
10803            #[cfg(feature = "mcp")]
10804            mcp: None,
10805            #[cfg(feature = "webmcp")]
10806            webmcp: None,
10807        };
10808        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
10809            .expect_err("scope '/api' + child '/' should collide with openapi path '/api'");
10810        assert!(matches!(
10811            err,
10812            RouterBuildError::OpenApiPathCollision {
10813                field: "openapi_json_path",
10814                ..
10815            }
10816        ));
10817    }
10818
10819    /// The widget stylesheet route merges a GET unconditionally whenever
10820    /// `maud` is on, before the late-merged `OpenAPI` router — an
10821    /// `openapi_json_path` configured to the same path must be rejected by
10822    /// the preflight, not panic in `router.merge`.
10823    #[cfg(all(feature = "openapi", feature = "maud"))]
10824    #[test]
10825    fn try_build_router_detects_widgets_css_path_collision() {
10826        use crate::openapi::OpenApiConfig;
10827
10828        let openapi =
10829            OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::ui::WIDGETS_CSS_PATH);
10830        let config = AutumnConfig::default();
10831        let ctx = RouterContext {
10832            exception_filters: Vec::new(),
10833            scoped_groups: Vec::new(),
10834            merge_routers: Vec::new(),
10835            nest_routers: Vec::new(),
10836            declared_routes: Vec::new(),
10837            custom_layers: Vec::new(),
10838            static_gate_layers: Vec::new(),
10839            #[cfg(feature = "maud")]
10840            error_page_renderer: None,
10841            session_store: None,
10842            openapi: Some(openapi),
10843            #[cfg(feature = "mcp")]
10844            mcp: None,
10845            #[cfg(feature = "webmcp")]
10846            webmcp: None,
10847        };
10848        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
10849            "openapi_json_path colliding with the widget stylesheet route should be rejected",
10850        );
10851        assert!(matches!(
10852            err,
10853            RouterBuildError::OpenApiPathCollision {
10854                field: "openapi_json_path",
10855                ..
10856            }
10857        ));
10858    }
10859
10860    /// Same as above for the flash stylesheet route (pre-existing gap, same
10861    /// class of bug: the flash CSS route was also missing from
10862    /// `collect_claimed_get_paths`).
10863    #[cfg(all(feature = "openapi", feature = "flash"))]
10864    #[test]
10865    fn try_build_router_detects_flash_css_path_collision() {
10866        use crate::openapi::OpenApiConfig;
10867
10868        let openapi =
10869            OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::flash::FLASH_CSS_PATH);
10870        let config = AutumnConfig::default();
10871        let ctx = RouterContext {
10872            exception_filters: Vec::new(),
10873            scoped_groups: Vec::new(),
10874            merge_routers: Vec::new(),
10875            nest_routers: Vec::new(),
10876            declared_routes: Vec::new(),
10877            custom_layers: Vec::new(),
10878            static_gate_layers: Vec::new(),
10879            #[cfg(feature = "maud")]
10880            error_page_renderer: None,
10881            session_store: None,
10882            openapi: Some(openapi),
10883            #[cfg(feature = "mcp")]
10884            mcp: None,
10885            #[cfg(feature = "webmcp")]
10886            webmcp: None,
10887        };
10888        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
10889            "openapi_json_path colliding with the flash stylesheet route should be rejected",
10890        );
10891        assert!(matches!(
10892            err,
10893            RouterBuildError::OpenApiPathCollision {
10894                field: "openapi_json_path",
10895                ..
10896            }
10897        ));
10898    }
10899
10900    #[cfg(feature = "openapi")]
10901    #[test]
10902    fn extract_path_params_matches_macro_behavior() {
10903        // Normal multi-param routes.
10904        assert_eq!(
10905            super::extract_path_params("/orgs/{org_id}/users/{id}"),
10906            vec!["org_id".to_owned(), "id".to_owned()]
10907        );
10908        assert_eq!(
10909            super::extract_path_params("/users/{id}/posts/{slug}"),
10910            vec!["id".to_owned(), "slug".to_owned()]
10911        );
10912        assert!(super::extract_path_params("/static").is_empty());
10913
10914        // `:constraint` suffixes are stripped to the bare name.
10915        assert_eq!(
10916            super::extract_path_params("/users/{id:[0-9]+}"),
10917            vec!["id".to_owned()]
10918        );
10919        // Regex constraint containing its own braces still yields just `id`.
10920        assert_eq!(
10921            super::extract_path_params("{id:[0-9]{1,3}}"),
10922            vec!["id".to_owned()]
10923        );
10924
10925        // Escaped literal braces (`{{` / `}}`) are matchit literals, NOT
10926        // params, and must emit nothing (mirrors the macro's escape skip).
10927        assert!(super::extract_path_params("{{hello}}").is_empty());
10928        assert_eq!(
10929            super::extract_path_params("{{literal}}/{id}"),
10930            vec!["id".to_owned()]
10931        );
10932
10933        // #1721 unbalanced/malformed cases: no phantom or brace-carrying params.
10934        assert!(super::extract_path_params("{{}").is_empty());
10935        assert!(super::extract_path_params("{a{b}").is_empty());
10936        assert!(super::extract_path_params("{").is_empty());
10937        assert!(super::extract_path_params("}").is_empty());
10938        assert!(super::extract_path_params("{}").is_empty());
10939    }
10940
10941    #[cfg(feature = "openapi")]
10942    #[test]
10943    fn extract_path_params_handles_unbalanced_braces() {
10944        // Regression for #1721: unbalanced/malformed braces must never yield a
10945        // param name that still contains a brace character. The brace-free
10946        // guard drops any candidate whose inner segment retains a stray brace,
10947        // so the emitted names are always non-empty and brace-free (and stray
10948        // braces yield no spurious params).
10949        for path in ["{{}", "{", "}", "{a{b}"] {
10950            for name in super::extract_path_params(path) {
10951                assert!(
10952                    !name.contains('{') && !name.contains('}'),
10953                    "param name should be brace-free for {path:?}: {name:?}"
10954                );
10955                assert!(
10956                    !name.is_empty(),
10957                    "param name should be non-empty for {path:?}"
10958                );
10959            }
10960        }
10961        // `"{{}"` yields no param: the leading `{{` is an escaped literal brace
10962        // that is skipped, leaving only a stray `}`.
10963        assert!(super::extract_path_params("{{}").is_empty());
10964        // `"{a{b}"` yields no param: the inner segment `"a{b"` still holds a
10965        // brace, so the brace-free guard drops it.
10966        assert!(super::extract_path_params("{a{b}").is_empty());
10967    }
10968
10969    #[cfg(feature = "openapi")]
10970    #[tokio::test]
10971    async fn openapi_merges_scoped_prefix_path_params() {
10972        use crate::openapi::{ApiDoc, OpenApiConfig};
10973
10974        // Scope prefix has `{org_id}`; the child route has `{id}`. The
10975        // generated ApiDoc must declare BOTH parameters, or Swagger
10976        // validators reject the document for referencing undeclared
10977        // path params.
10978        async fn handler() -> &'static str {
10979            "ok"
10980        }
10981        let child = Route {
10982            method: http::Method::GET,
10983            path: "/users/{id}",
10984            handler: axum::routing::get(handler),
10985            name: "child",
10986            api_doc: ApiDoc {
10987                method: "GET",
10988                path: "/users/{id}",
10989                operation_id: "child",
10990                path_params: &["id"],
10991                success_status: 200,
10992                ..Default::default()
10993            },
10994            repository: None,
10995            idempotency: crate::route::RouteIdempotency::Direct,
10996            timeout: crate::route::RouteTimeout::Inherit,
10997            criticality: crate::admission::Criticality::Default,
10998            seo: crate::seo::SeoRouteDefaults::EMPTY,
10999            api_version: None,
11000            sunset_opt_out: false,
11001        };
11002        let group = crate::app::ScopedGroup {
11003            prefix: "/orgs/{org_id}".to_owned(),
11004            routes: vec![child],
11005            source: crate::route_listing::RouteSource::User,
11006            layer_guarded: false,
11007            apply_layer: Box::new(|r| r),
11008        };
11009
11010        let config = OpenApiConfig::new("Demo", "1.0.0");
11011        let router = super::build_openapi_router(&[], &[group], Some(&config), "autumn.sid", &[])
11012            .expect("openapi sub-router builds")
11013            .expect("openapi sub-router present when config is Some");
11014        let state = test_state();
11015        let router = router.with_state(state);
11016
11017        let response = router
11018            .oneshot(
11019                Request::builder()
11020                    .uri("/openapi.json")
11021                    .body(Body::empty())
11022                    .unwrap(),
11023            )
11024            .await
11025            .unwrap();
11026        assert_eq!(response.status(), StatusCode::OK);
11027        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11028            .await
11029            .unwrap();
11030        let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
11031        let params = &spec["paths"]["/orgs/{org_id}/users/{id}"]["get"]["parameters"];
11032        let names: Vec<&str> = params
11033            .as_array()
11034            .unwrap()
11035            .iter()
11036            .map(|p| p["name"].as_str().unwrap())
11037            .collect();
11038        assert!(names.contains(&"org_id"), "missing org_id: {names:?}");
11039        assert!(names.contains(&"id"), "missing id: {names:?}");
11040    }
11041
11042    #[cfg(feature = "openapi")]
11043    #[tokio::test]
11044    async fn openapi_documents_configured_session_cookie_name() {
11045        use crate::openapi::{ApiDoc, OpenApiConfig};
11046
11047        async fn handler() -> &'static str {
11048            "ok"
11049        }
11050
11051        let route = Route {
11052            method: http::Method::GET,
11053            path: "/protected",
11054            handler: axum::routing::get(handler),
11055            name: "protected",
11056            api_doc: ApiDoc {
11057                method: "GET",
11058                path: "/protected",
11059                operation_id: "protected",
11060                success_status: 200,
11061                secured: true,
11062                ..Default::default()
11063            },
11064            repository: None,
11065            idempotency: crate::route::RouteIdempotency::Direct,
11066            timeout: crate::route::RouteTimeout::Inherit,
11067            criticality: crate::admission::Criticality::Default,
11068            seo: crate::seo::SeoRouteDefaults::EMPTY,
11069            api_version: None,
11070            sunset_opt_out: false,
11071        };
11072
11073        let protected_routes = vec![route];
11074        let config = OpenApiConfig::new("Demo", "1.0.0");
11075        let docs_router =
11076            super::build_openapi_router(&protected_routes, &[], Some(&config), "demo.sid", &[])
11077                .expect("openapi sub-router builds")
11078                .expect("openapi sub-router present when config is Some");
11079        let docs_router = docs_router.with_state(test_state());
11080
11081        let response = docs_router
11082            .oneshot(
11083                Request::builder()
11084                    .uri("/openapi.json")
11085                    .body(Body::empty())
11086                    .unwrap(),
11087            )
11088            .await
11089            .unwrap();
11090        assert_eq!(response.status(), StatusCode::OK);
11091        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11092            .await
11093            .unwrap();
11094        let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
11095        let schemes = &spec["components"]["securitySchemes"];
11096
11097        assert_eq!(schemes["SessionAuth"]["type"], "apiKey");
11098        assert_eq!(schemes["SessionAuth"]["in"], "cookie");
11099        assert_eq!(schemes["SessionAuth"]["name"], "demo.sid");
11100        assert!(
11101            schemes.get("BearerAuth").is_none(),
11102            "secured routes must not be documented as bearer JWT routes"
11103        );
11104    }
11105
11106    #[cfg(feature = "openapi")]
11107    #[test]
11108    fn openapi_rejects_json_path_without_leading_slash() {
11109        let config =
11110            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("openapi.json");
11111        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11112            .expect_err("non-slash path should be rejected");
11113        assert!(matches!(
11114            err,
11115            RouterBuildError::InvalidOpenApiPath {
11116                field: "openapi_json_path",
11117                ..
11118            }
11119        ));
11120    }
11121
11122    #[cfg(feature = "openapi")]
11123    #[test]
11124    fn openapi_rejects_path_with_captures() {
11125        // `{id}` captures would be a typo for a mount path — the
11126        // endpoints are static. Catch it before axum panics.
11127        let config =
11128            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id}");
11129        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11130            .expect_err("captures should be rejected");
11131        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11132    }
11133
11134    #[cfg(feature = "openapi")]
11135    #[test]
11136    fn openapi_rejects_path_with_unbalanced_brace() {
11137        let config =
11138            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id");
11139        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11140            .expect_err("unbalanced brace should be rejected");
11141        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11142    }
11143
11144    #[cfg(feature = "openapi")]
11145    #[test]
11146    fn openapi_rejects_path_with_wildcard() {
11147        let config =
11148            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/*rest");
11149        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11150            .expect_err("wildcard should be rejected");
11151        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11152    }
11153
11154    #[cfg(feature = "openapi")]
11155    #[test]
11156    fn openapi_rejects_path_with_double_slash() {
11157        let config =
11158            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("//docs");
11159        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11160            .expect_err("double-slash should be rejected");
11161        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11162    }
11163
11164    #[cfg(feature = "openapi")]
11165    #[test]
11166    fn openapi_rejects_swagger_ui_path_without_leading_slash() {
11167        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11168            .swagger_ui_path(Some("docs".to_owned()));
11169        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11170            .expect_err("non-slash path should be rejected");
11171        assert!(matches!(
11172            err,
11173            RouterBuildError::InvalidOpenApiPath {
11174                field: "swagger_ui_path",
11175                ..
11176            }
11177        ));
11178    }
11179
11180    #[cfg(feature = "openapi")]
11181    #[test]
11182    fn openapi_rejects_empty_json_path() {
11183        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("");
11184        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11185            .expect_err("empty path should be rejected");
11186        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11187    }
11188
11189    #[cfg(feature = "openapi")]
11190    #[test]
11191    fn openapi_accepts_valid_paths() {
11192        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11193            .openapi_json_path("/api-docs")
11194            .swagger_ui_path(Some("/ui".to_owned()));
11195        let out = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11196            .expect("valid paths must not error");
11197        assert!(out.is_some());
11198    }
11199
11200    #[cfg(feature = "openapi")]
11201    #[test]
11202    fn openapi_rejects_duplicate_json_and_swagger_paths() {
11203        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11204            .openapi_json_path("/docs")
11205            .swagger_ui_path(Some("/docs".to_owned()));
11206        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11207            .expect_err("colliding paths should be rejected before axum panics");
11208        assert!(matches!(
11209            err,
11210            RouterBuildError::DuplicateOpenApiPath { ref path } if path == "/docs"
11211        ));
11212    }
11213
11214    #[cfg(feature = "openapi")]
11215    async fn collision_test_handler() -> &'static str {
11216        "user"
11217    }
11218
11219    #[cfg(feature = "openapi")]
11220    #[tokio::test]
11221    async fn try_build_router_rejects_openapi_path_colliding_with_user_route() {
11222        let mut config = AutumnConfig::default();
11223        config.actuator.prefix = "/ops".to_owned();
11224        let openapi =
11225            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/my-api-docs");
11226
11227        let user_route = Route {
11228            method: http::Method::GET,
11229            path: "/my-api-docs",
11230            handler: axum::routing::get(collision_test_handler),
11231            name: "collides",
11232            api_doc: crate::openapi::ApiDoc {
11233                method: "GET",
11234                path: "/my-api-docs",
11235                operation_id: "collides",
11236                success_status: 200,
11237                ..Default::default()
11238            },
11239            repository: None,
11240            idempotency: crate::route::RouteIdempotency::Direct,
11241            timeout: crate::route::RouteTimeout::Inherit,
11242            criticality: crate::admission::Criticality::Default,
11243            seo: crate::seo::SeoRouteDefaults::EMPTY,
11244            api_version: None,
11245            sunset_opt_out: false,
11246        };
11247
11248        let ctx = RouterContext {
11249            exception_filters: Vec::new(),
11250            scoped_groups: Vec::new(),
11251            merge_routers: Vec::new(),
11252            nest_routers: Vec::new(),
11253            declared_routes: Vec::new(),
11254            custom_layers: Vec::new(),
11255            static_gate_layers: Vec::new(),
11256            #[cfg(feature = "maud")]
11257            error_page_renderer: None,
11258            session_store: None,
11259            openapi: Some(openapi),
11260            #[cfg(feature = "mcp")]
11261            mcp: None,
11262            #[cfg(feature = "webmcp")]
11263            webmcp: None,
11264        };
11265        let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
11266            .expect_err("user-owned path should prevent OpenAPI mount");
11267        assert!(matches!(
11268            err,
11269            RouterBuildError::OpenApiPathCollision { field: "openapi_json_path", ref path } if path == "/my-api-docs"
11270        ));
11271    }
11272
11273    #[cfg(feature = "openapi")]
11274    #[tokio::test]
11275    async fn try_build_router_rejects_openapi_path_colliding_with_framework_route() {
11276        let config = AutumnConfig::default(); // /actuator/health is a GET by default
11277        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11278            .openapi_json_path("/actuator/health");
11279        let ctx = RouterContext {
11280            exception_filters: Vec::new(),
11281            scoped_groups: Vec::new(),
11282            merge_routers: Vec::new(),
11283            nest_routers: Vec::new(),
11284            declared_routes: Vec::new(),
11285            custom_layers: Vec::new(),
11286            static_gate_layers: Vec::new(),
11287            #[cfg(feature = "maud")]
11288            error_page_renderer: None,
11289            session_store: None,
11290            openapi: Some(openapi),
11291            #[cfg(feature = "mcp")]
11292            mcp: None,
11293            #[cfg(feature = "webmcp")]
11294            webmcp: None,
11295        };
11296        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11297            .expect_err("framework-owned path should prevent OpenAPI mount");
11298        assert!(matches!(
11299            err,
11300            RouterBuildError::OpenApiPathCollision {
11301                field: "openapi_json_path",
11302                ..
11303            }
11304        ));
11305    }
11306
11307    #[cfg(feature = "openapi")]
11308    #[tokio::test]
11309    async fn try_build_router_rejects_swagger_ui_asset_path_colliding_with_user_route() {
11310        let config = AutumnConfig::default();
11311        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0");
11312
11313        let user_route = Route {
11314            method: http::Method::GET,
11315            path: "/swagger-ui/swagger-ui.css",
11316            handler: axum::routing::get(collision_test_handler),
11317            name: "swagger-ui-asset-collides",
11318            api_doc: crate::openapi::ApiDoc {
11319                method: "GET",
11320                path: "/swagger-ui/swagger-ui.css",
11321                operation_id: "swagger_ui_asset_collides",
11322                success_status: 200,
11323                ..Default::default()
11324            },
11325            repository: None,
11326            idempotency: crate::route::RouteIdempotency::Direct,
11327            timeout: crate::route::RouteTimeout::Inherit,
11328            criticality: crate::admission::Criticality::Default,
11329            seo: crate::seo::SeoRouteDefaults::EMPTY,
11330            api_version: None,
11331            sunset_opt_out: false,
11332        };
11333
11334        let ctx = RouterContext {
11335            exception_filters: Vec::new(),
11336            scoped_groups: Vec::new(),
11337            merge_routers: Vec::new(),
11338            nest_routers: Vec::new(),
11339            declared_routes: Vec::new(),
11340            custom_layers: Vec::new(),
11341            static_gate_layers: Vec::new(),
11342            #[cfg(feature = "maud")]
11343            error_page_renderer: None,
11344            session_store: None,
11345            openapi: Some(openapi),
11346            #[cfg(feature = "mcp")]
11347            mcp: None,
11348            #[cfg(feature = "webmcp")]
11349            webmcp: None,
11350        };
11351        let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
11352            .expect_err("swagger ui asset path should be reserved");
11353        assert!(matches!(
11354            err,
11355            RouterBuildError::OpenApiPathCollision {
11356                field: "swagger_ui_path",
11357                ref path,
11358            } if path == "/swagger-ui/swagger-ui.css"
11359        ));
11360    }
11361
11362    #[cfg(all(feature = "openapi", feature = "htmx"))]
11363    #[tokio::test]
11364    async fn try_build_router_rejects_openapi_path_colliding_with_htmx_csrf_route() {
11365        let config = AutumnConfig::default();
11366        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11367            .openapi_json_path(crate::htmx::HTMX_CSRF_JS_PATH);
11368        let ctx = RouterContext {
11369            exception_filters: Vec::new(),
11370            scoped_groups: Vec::new(),
11371            merge_routers: Vec::new(),
11372            nest_routers: Vec::new(),
11373            declared_routes: Vec::new(),
11374            custom_layers: Vec::new(),
11375            static_gate_layers: Vec::new(),
11376            #[cfg(feature = "maud")]
11377            error_page_renderer: None,
11378            session_store: None,
11379            openapi: Some(openapi),
11380            #[cfg(feature = "mcp")]
11381            mcp: None,
11382            #[cfg(feature = "webmcp")]
11383            webmcp: None,
11384        };
11385        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11386            .expect_err("htmx csrf helper path should be reserved");
11387        assert!(matches!(
11388            err,
11389            RouterBuildError::OpenApiPathCollision {
11390                field: "openapi_json_path",
11391                ref path,
11392            } if path == crate::htmx::HTMX_CSRF_JS_PATH
11393        ));
11394    }
11395
11396    #[cfg(feature = "openapi")]
11397    #[tokio::test]
11398    async fn try_build_router_rejects_openapi_path_under_nest_prefix() {
11399        // Nesting `/api` means that router owns everything under
11400        // `/api/...`. Mounting OpenAPI at `/api/docs` would either
11401        // panic on merge or silently lose one of the routes, so the
11402        // collision check rejects it.
11403        let config = AutumnConfig::default();
11404        let openapi =
11405            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api/docs");
11406        let nested = axum::Router::<AppState>::new()
11407            .route("/inner", axum::routing::get(|| async { "inner" }));
11408        let ctx = RouterContext {
11409            exception_filters: Vec::new(),
11410            scoped_groups: Vec::new(),
11411            merge_routers: Vec::new(),
11412            nest_routers: vec![("/api".to_owned(), nested)],
11413            declared_routes: Vec::new(),
11414            custom_layers: Vec::new(),
11415            static_gate_layers: Vec::new(),
11416            #[cfg(feature = "maud")]
11417            error_page_renderer: None,
11418            session_store: None,
11419            openapi: Some(openapi),
11420            #[cfg(feature = "mcp")]
11421            mcp: None,
11422            #[cfg(feature = "webmcp")]
11423            webmcp: None,
11424        };
11425        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11426            .expect_err("OpenAPI path under a nest prefix should collide");
11427        assert!(matches!(
11428            err,
11429            RouterBuildError::OpenApiPathCollision {
11430                field: "openapi_json_path",
11431                ref path,
11432            } if path == "/api/docs"
11433        ));
11434    }
11435
11436    #[cfg(all(feature = "openapi", feature = "mail"))]
11437    #[tokio::test]
11438    async fn try_build_router_rejects_openapi_path_on_unsubscribe_endpoint() {
11439        // The default one-click unsubscribe endpoint merges a GET at
11440        // `/_autumn/unsubscribe` before the late-merged OpenAPI router, so the
11441        // collision preflight must reserve it — otherwise mounting OpenAPI there
11442        // panics in `router.merge` instead of surfacing the typed collision.
11443        let mut config = AutumnConfig::default();
11444        config.mail.mount_unsubscribe_endpoint = true;
11445        config.mail.unsubscribe_base_url = Some("https://app.example.com".to_owned());
11446        assert!(config.mail.should_mount_unsubscribe_endpoint());
11447        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11448            .openapi_json_path(crate::mail::UNSUBSCRIBE_PATH);
11449        let ctx = RouterContext {
11450            exception_filters: Vec::new(),
11451            scoped_groups: Vec::new(),
11452            merge_routers: Vec::new(),
11453            nest_routers: Vec::new(),
11454            declared_routes: Vec::new(),
11455            custom_layers: Vec::new(),
11456            static_gate_layers: Vec::new(),
11457            #[cfg(feature = "maud")]
11458            error_page_renderer: None,
11459            session_store: None,
11460            openapi: Some(openapi),
11461            #[cfg(feature = "mcp")]
11462            mcp: None,
11463            #[cfg(feature = "webmcp")]
11464            webmcp: None,
11465        };
11466        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11467            .expect_err("unsubscribe endpoint path should be reserved");
11468        assert!(matches!(
11469            err,
11470            RouterBuildError::OpenApiPathCollision {
11471                field: "openapi_json_path",
11472                ref path,
11473            } if path == crate::mail::UNSUBSCRIBE_PATH
11474        ));
11475    }
11476
11477    #[cfg(feature = "openapi")]
11478    #[tokio::test]
11479    async fn try_build_router_rejects_openapi_path_on_job_status_endpoint() {
11480        // The tracked-job status endpoint merges a GET at
11481        // `/_autumn/jobs/{token}` before the late-merged OpenAPI router (on by
11482        // default), so the collision preflight must reserve it too.
11483        let config = AutumnConfig::default();
11484        assert!(config.jobs.tracking.route_enabled);
11485        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11486            .openapi_json_path(crate::job_tracking::JOB_STATUS_ROUTE_PATH);
11487        let ctx = RouterContext {
11488            exception_filters: Vec::new(),
11489            scoped_groups: Vec::new(),
11490            merge_routers: Vec::new(),
11491            nest_routers: Vec::new(),
11492            declared_routes: Vec::new(),
11493            custom_layers: Vec::new(),
11494            static_gate_layers: Vec::new(),
11495            #[cfg(feature = "maud")]
11496            error_page_renderer: None,
11497            session_store: None,
11498            openapi: Some(openapi),
11499            #[cfg(feature = "mcp")]
11500            mcp: None,
11501            #[cfg(feature = "webmcp")]
11502            webmcp: None,
11503        };
11504        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11505            .expect_err("job status endpoint path should be reserved");
11506        assert!(matches!(
11507            err,
11508            RouterBuildError::OpenApiPathCollision {
11509                field: "openapi_json_path",
11510                ref path,
11511            } if path == crate::job_tracking::JOB_STATUS_ROUTE_PATH
11512        ));
11513    }
11514
11515    #[cfg(all(feature = "openapi", feature = "maud"))]
11516    #[tokio::test]
11517    async fn try_build_router_rejects_openapi_path_on_story_gallery() {
11518        // The story gallery merges GETs at `/_stories` (+ `/_stories/{slug}`)
11519        // when `stories.enabled` resolves true, before the late-merged
11520        // OpenAPI router, so the collision preflight must reserve it —
11521        // otherwise mounting OpenAPI there panics in `router.merge` instead
11522        // of surfacing the typed collision.
11523        let mut config = AutumnConfig::default();
11524        config.stories.enabled = true;
11525        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11526            .openapi_json_path(crate::stories::STORIES_PATH);
11527        let ctx = RouterContext {
11528            exception_filters: Vec::new(),
11529            scoped_groups: Vec::new(),
11530            merge_routers: Vec::new(),
11531            nest_routers: Vec::new(),
11532            declared_routes: Vec::new(),
11533            custom_layers: Vec::new(),
11534            static_gate_layers: Vec::new(),
11535            error_page_renderer: None,
11536            session_store: None,
11537            openapi: Some(openapi),
11538            #[cfg(feature = "mcp")]
11539            mcp: None,
11540            #[cfg(feature = "webmcp")]
11541            webmcp: None,
11542        };
11543        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11544            .expect_err("story gallery path should be reserved while stories are enabled");
11545        assert!(matches!(
11546            err,
11547            RouterBuildError::OpenApiPathCollision {
11548                field: "openapi_json_path",
11549                ref path,
11550            } if path == crate::stories::STORIES_PATH
11551        ));
11552    }
11553
11554    #[cfg(all(feature = "openapi", feature = "maud"))]
11555    #[tokio::test]
11556    async fn try_build_router_rejects_openapi_path_on_story_gallery_demo_route() {
11557        // Review follow-up: the Active search / Autocomplete / Infinite feed
11558        // stories' live demo backends merge GETs the same way the index/detail
11559        // routes above do, but were missing from the preflight reservation —
11560        // an OpenAPI mount here would panic in `router.merge` instead of
11561        // surfacing this typed collision.
11562        let mut config = AutumnConfig::default();
11563        config.stories.enabled = true;
11564        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11565            .openapi_json_path("/_stories/demo/search");
11566        let ctx = RouterContext {
11567            exception_filters: Vec::new(),
11568            scoped_groups: Vec::new(),
11569            merge_routers: Vec::new(),
11570            nest_routers: Vec::new(),
11571            declared_routes: Vec::new(),
11572            custom_layers: Vec::new(),
11573            static_gate_layers: Vec::new(),
11574            error_page_renderer: None,
11575            session_store: None,
11576            openapi: Some(openapi),
11577            #[cfg(feature = "mcp")]
11578            mcp: None,
11579            #[cfg(feature = "webmcp")]
11580            webmcp: None,
11581        };
11582        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
11583            "story gallery demo search path should be reserved while stories are enabled",
11584        );
11585        assert!(matches!(
11586            err,
11587            RouterBuildError::OpenApiPathCollision {
11588                field: "openapi_json_path",
11589                ref path,
11590            } if path == "/_stories/demo/search"
11591        ));
11592    }
11593
11594    #[cfg(feature = "openapi")]
11595    #[test]
11596    fn try_build_router_rejects_openapi_path_on_dev_live_reload() {
11597        temp_env::with_vars(
11598            [
11599                ("AUTUMN_DEV_RELOAD", Some("1")),
11600                ("AUTUMN_DEV_RELOAD_STATE", Some("/tmp/autumn-reload-test")),
11601            ],
11602            || {
11603                let config = AutumnConfig::default();
11604                let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11605                    .openapi_json_path("/__autumn/live-reload");
11606                let ctx = RouterContext {
11607                    exception_filters: Vec::new(),
11608                    scoped_groups: Vec::new(),
11609                    merge_routers: Vec::new(),
11610                    nest_routers: Vec::new(),
11611                    declared_routes: Vec::new(),
11612                    custom_layers: Vec::new(),
11613                    static_gate_layers: Vec::new(),
11614                    error_page_renderer: None,
11615                    session_store: None,
11616                    openapi: Some(openapi),
11617                    #[cfg(feature = "mcp")]
11618                    mcp: None,
11619                    #[cfg(feature = "webmcp")]
11620                    webmcp: None,
11621                };
11622                let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11623                    .expect_err("dev reload path should be reserved");
11624                assert!(matches!(
11625                    err,
11626                    RouterBuildError::OpenApiPathCollision {
11627                        field: "openapi_json_path",
11628                        ..
11629                    }
11630                ));
11631            },
11632        );
11633    }
11634
11635    // --- Duplicate user-route detection tests (issue #1012) ---
11636
11637    async fn duplicate_route_handler() -> &'static str {
11638        "ok"
11639    }
11640
11641    /// Build a lightweight [`Route`] for the duplicate-detection tests. The
11642    /// `MethodRouter` is built with the same HTTP method as `method` so
11643    /// scenarios that intentionally exercise `GET`+`POST` on the same path
11644    /// (AC #4) actually merge cleanly at axum level; the caller sees the
11645    /// duplicate-preflight decision, not an axum method-router-merge panic.
11646    fn duplicate_test_route(method: http::Method, path: &'static str, name: &'static str) -> Route {
11647        let handler = match method {
11648            http::Method::POST => axum::routing::post(duplicate_route_handler),
11649            http::Method::PUT => axum::routing::put(duplicate_route_handler),
11650            http::Method::PATCH => axum::routing::patch(duplicate_route_handler),
11651            http::Method::DELETE => axum::routing::delete(duplicate_route_handler),
11652            _ => axum::routing::get(duplicate_route_handler),
11653        };
11654        let method_str = if method == http::Method::POST {
11655            "POST"
11656        } else if method == http::Method::PUT {
11657            "PUT"
11658        } else if method == http::Method::PATCH {
11659            "PATCH"
11660        } else if method == http::Method::DELETE {
11661            "DELETE"
11662        } else {
11663            "GET"
11664        };
11665        Route {
11666            method,
11667            path,
11668            handler,
11669            name,
11670            api_doc: crate::openapi::ApiDoc {
11671                method: method_str,
11672                path,
11673                operation_id: name,
11674                success_status: 200,
11675                ..Default::default()
11676            },
11677            repository: None,
11678            idempotency: crate::route::RouteIdempotency::Direct,
11679            timeout: crate::route::RouteTimeout::Inherit,
11680            criticality: crate::admission::Criticality::Default,
11681            seo: crate::seo::SeoRouteDefaults::EMPTY,
11682            api_version: None,
11683            sunset_opt_out: false,
11684        }
11685    }
11686
11687    fn duplicate_test_ctx() -> RouterContext {
11688        RouterContext {
11689            exception_filters: Vec::new(),
11690            scoped_groups: Vec::new(),
11691            merge_routers: Vec::new(),
11692            nest_routers: Vec::new(),
11693            declared_routes: Vec::new(),
11694            custom_layers: Vec::new(),
11695            static_gate_layers: Vec::new(),
11696            #[cfg(feature = "maud")]
11697            error_page_renderer: None,
11698            session_store: None,
11699            #[cfg(feature = "openapi")]
11700            openapi: None,
11701            #[cfg(feature = "mcp")]
11702            mcp: None,
11703            #[cfg(feature = "webmcp")]
11704            webmcp: None,
11705        }
11706    }
11707
11708    /// AC #1, #2, #6: two routes registered on the same (method, path) fail
11709    /// the build with a structured [`RouterBuildError::DuplicateUserRoute`]
11710    /// that names both handlers and the offending method + path — no axum
11711    /// panic escapes.
11712    #[tokio::test]
11713    async fn try_build_router_rejects_duplicate_user_route_paths() {
11714        let config = AutumnConfig::default();
11715        let a = duplicate_test_route(http::Method::GET, "/", "root_a");
11716        let b = duplicate_test_route(http::Method::GET, "/", "root_b");
11717        let err =
11718            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
11719                .expect_err("two GET / routes should be rejected before mount");
11720        let display = err.to_string();
11721        match err {
11722            RouterBuildError::DuplicateUserRoute {
11723                ref method,
11724                ref path,
11725                ref existing,
11726                ref incoming,
11727            } => {
11728                assert_eq!(method, "GET");
11729                assert_eq!(path, "/");
11730                assert_eq!(existing, "root_a");
11731                assert_eq!(incoming, "root_b");
11732            }
11733            other => panic!("expected DuplicateUserRoute, got {other:?}"),
11734        }
11735        assert!(
11736            display.contains("root_a"),
11737            "error message must name first handler; got: {display}"
11738        );
11739        assert!(
11740            display.contains("root_b"),
11741            "error message must name second handler; got: {display}"
11742        );
11743        assert!(
11744            display.contains("GET"),
11745            "error message must name the HTTP method; got: {display}"
11746        );
11747        assert!(
11748            display.contains('/'),
11749            "error message must contain the path; got: {display}"
11750        );
11751    }
11752
11753    /// A declared plugin route helper — the shape
11754    /// [`AppBuilder::declare_plugin_routes`](crate::app::AppBuilder::declare_plugin_routes)
11755    /// records, and the shape a sandboxed plugin's manifest produces.
11756    fn declared_route(method: &str, path: &str, plugin: &str) -> crate::route_listing::RouteInfo {
11757        crate::route_listing::RouteInfo {
11758            method: method.to_owned(),
11759            path: path.to_owned(),
11760            handler: format!("sandbox:{plugin}"),
11761            source: crate::route_listing::RouteSource::Plugin(plugin.to_owned()),
11762            ..crate::route_listing::RouteInfo::default()
11763        }
11764    }
11765
11766    /// An untrusted artifact must not be able to abort the host at boot.
11767    ///
11768    /// A `nest` mount is opaque to axum, so the duplicate-route preflight
11769    /// historically skipped it — but a sandboxed plugin's manifest IS its route
11770    /// table, and `Router::nest` panics with "Overlapping method route" when a
11771    /// declared path is one the application already serves. That panic is a
11772    /// denial of service against the WHOLE app reachable from an artifact the
11773    /// operator was told the sandbox contains, so it has to surface as a typed
11774    /// error before anything mounts.
11775    #[tokio::test]
11776    async fn try_build_router_rejects_a_declared_plugin_route_the_app_already_serves() {
11777        let config = AutumnConfig::default();
11778        let app_route = duplicate_test_route(http::Method::GET, "/hello/greet", "app_greet");
11779        let mut ctx = duplicate_test_ctx();
11780        ctx.declared_routes = vec![declared_route("GET", "/hello/greet", "evil-plugin")];
11781        let err = super::try_build_router_inner(vec![app_route], &config, test_state(), ctx)
11782            .expect_err("a plugin declaring a path the app serves must be refused");
11783        match err {
11784            RouterBuildError::DuplicateUserRoute {
11785                ref method,
11786                ref path,
11787                ref existing,
11788                ref incoming,
11789            } => {
11790                assert_eq!(method, "GET");
11791                assert_eq!(path, "/hello/greet");
11792                // The application route is first-seen, so the plugin is named
11793                // as the incoming side — the operator needs to know WHICH
11794                // artifact to stop installing.
11795                assert_eq!(existing, "app_greet");
11796                assert_eq!(incoming, "sandbox:evil-plugin");
11797            }
11798            other => panic!("expected DuplicateUserRoute, got {other:?}"),
11799        }
11800    }
11801
11802    /// The framework's own routes are mounted outside `route_list`, so the
11803    /// declared-route check has to know about them separately: a manifest
11804    /// declaring `GET /health` reached `Router::nest` and panicked with
11805    /// "Overlapping method route" even after declared routes were checked
11806    /// against the application's.
11807    ///
11808    /// Refused rather than yielded: a user route at a probe path legitimately
11809    /// takes it over (#1971), but silently handing an unaudited artifact the
11810    /// endpoint orchestrators read to decide whether the process is alive is a
11811    /// worse outcome than a loud refusal.
11812    #[tokio::test]
11813    async fn try_build_router_rejects_a_declared_plugin_route_on_a_framework_path() {
11814        let config = AutumnConfig::default();
11815        let mut ctx = duplicate_test_ctx();
11816        ctx.declared_routes = vec![declared_route("GET", &config.health.path, "evil-plugin")];
11817        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11818            .expect_err("a plugin declaring a framework probe path must be refused");
11819        match err {
11820            RouterBuildError::DuplicateUserRoute {
11821                ref path,
11822                ref existing,
11823                ref incoming,
11824                ..
11825            } => {
11826                assert_eq!(path, &config.health.path);
11827                assert!(
11828                    existing.starts_with("autumn framework route")
11829                        && existing.contains(&config.health.path),
11830                    "the refusal must name the framework route it clashed with; got: {existing}"
11831                );
11832                assert_eq!(incoming, "sandbox:evil-plugin");
11833            }
11834            other => panic!("expected the framework-path refusal, got {other:?}"),
11835        }
11836    }
11837
11838    /// The `OpenAPI` and MCP mounts live on the router context, not the config, so
11839    /// the framework-path check cannot see them. A manifest naming one still
11840    /// panics at `Router::nest`.
11841    #[cfg(feature = "openapi")]
11842    #[tokio::test]
11843    async fn try_build_router_rejects_a_declared_route_on_the_openapi_paths() {
11844        let config = AutumnConfig::default();
11845        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11846            .openapi_json_path("/spec.json")
11847            .swagger_ui_path(Some("/docs".to_owned()));
11848        let asset = crate::openapi::swagger_ui_asset_paths("/docs")
11849            .into_iter()
11850            .next()
11851            .expect("the UI serves at least one asset");
11852        for path in ["/spec.json", "/docs", asset.as_str()] {
11853            let mut ctx = duplicate_test_ctx();
11854            ctx.openapi = Some(openapi.clone());
11855            ctx.declared_routes = vec![declared_route("GET", path, "evil-plugin")];
11856            let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11857                .expect_err("a declared route on an OpenAPI path must be refused");
11858            match err {
11859                RouterBuildError::DuplicateUserRoute {
11860                    path: ref found,
11861                    ref existing,
11862                    ref incoming,
11863                    ..
11864                } => {
11865                    assert_eq!(found, path);
11866                    assert!(existing.starts_with("autumn framework route"), "{existing}");
11867                    assert_eq!(incoming, "sandbox:evil-plugin");
11868                }
11869                other => panic!("expected a typed refusal for {path}, got {other:?}"),
11870            }
11871        }
11872    }
11873
11874    /// Only a GET clashes with the `OpenAPI` GETs. A POST at the same path is a
11875    /// different method and axum merges it, so refusing it would reject a
11876    /// mount that works.
11877    #[cfg(feature = "openapi")]
11878    #[tokio::test]
11879    async fn try_build_router_accepts_a_declared_post_on_an_openapi_path() {
11880        let config = AutumnConfig::default();
11881        let mut ctx = duplicate_test_ctx();
11882        ctx.openapi = Some(
11883            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/spec.json"),
11884        );
11885        ctx.declared_routes = vec![declared_route("POST", "/spec.json", "honest-plugin")];
11886        let _built = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11887            .expect("a declared POST does not clash with the OpenAPI GET");
11888    }
11889
11890    /// The MCP endpoint mounts GET, POST and OPTIONS at `mount_path`.
11891    #[cfg(feature = "mcp")]
11892    #[tokio::test]
11893    async fn try_build_router_rejects_a_declared_route_on_the_mcp_mount_path() {
11894        let config = AutumnConfig::default();
11895        // OPTIONS too: the endpoint mounts its own CORS preflight there.
11896        for method in ["GET", "POST", "OPTIONS"] {
11897            let mut ctx = duplicate_test_ctx();
11898            ctx.mcp = Some(crate::mcp::McpRuntime::new("/agent-tools"));
11899            ctx.declared_routes = vec![declared_route(method, "/agent-tools", "evil-plugin")];
11900            let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11901                .expect_err("a declared route on the MCP path must be refused");
11902            assert!(
11903                matches!(
11904                    err,
11905                    RouterBuildError::DuplicateUserRoute { ref existing, .. }
11906                        if existing.starts_with("autumn framework route")
11907                ),
11908                "expected a typed refusal for {method}, got {err:?}"
11909            );
11910        }
11911    }
11912
11913    /// A DELETE is neither of the MCP verbs, so it mounts beside it.
11914    #[cfg(feature = "mcp")]
11915    #[tokio::test]
11916    async fn try_build_router_accepts_a_declared_delete_on_the_mcp_mount_path() {
11917        let config = AutumnConfig::default();
11918        let mut ctx = duplicate_test_ctx();
11919        ctx.mcp = Some(crate::mcp::McpRuntime::new("/agent-tools"));
11920        ctx.declared_routes = vec![declared_route("DELETE", "/agent-tools", "honest-plugin")];
11921        let _built = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11922            .expect("a declared DELETE does not clash with the MCP verbs");
11923    }
11924
11925    /// A shape clash is method-independent, so the check must be too.
11926    ///
11927    /// matchit sits *above* method routing: two different templates at the same
11928    /// shape (`/_stories/{slug}` against `/_stories/{id}`) are a route conflict
11929    /// axum refuses whatever method the second carries. Gating the shape check
11930    /// on GET let a declared POST through to a startup panic — the same class
11931    /// the GET case already covered, reached by choosing a different verb.
11932    ///
11933    /// The exact-path half stays method-aware: `GET /health` and a declared
11934    /// `POST /health` merge into one `MethodRouter` and must keep working.
11935    #[tokio::test]
11936    async fn try_build_router_rejects_a_non_get_declared_route_that_shape_clashes_with_a_framework_path()
11937     {
11938        let mut config = AutumnConfig::default();
11939        config.health.path = "/probe/{id}".to_owned();
11940        let mut ctx = duplicate_test_ctx();
11941        ctx.declared_routes = vec![declared_route("POST", "/probe/{slug}", "evil-plugin")];
11942        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11943            .expect_err("a shape clash must be refused whatever the method");
11944        match err {
11945            RouterBuildError::DuplicateUserRoute {
11946                ref method,
11947                ref existing,
11948                ..
11949            } => {
11950                assert_eq!(method, "POST", "the refusal must name the declared method");
11951                assert!(
11952                    existing.contains("/probe/{id}"),
11953                    "the refusal must name the framework template; got: {existing}"
11954                );
11955            }
11956            other => panic!("expected the framework shape refusal, got {other:?}"),
11957        }
11958    }
11959
11960    /// The other half of the same rule: an EXACT path match across methods
11961    /// merges cleanly, so a declared POST at a framework GET path is allowed.
11962    #[tokio::test]
11963    async fn try_build_router_allows_a_non_get_declared_route_at_a_framework_get_path() {
11964        let config = AutumnConfig::default();
11965        let mut ctx = duplicate_test_ctx();
11966        ctx.declared_routes = vec![declared_route("POST", &config.health.path, "honest-plugin")];
11967        assert!(
11968            super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).is_ok(),
11969            "a POST at a framework GET path merges into one MethodRouter and must be allowed"
11970        );
11971    }
11972
11973    /// The framework mounts non-GET routes too, and only GET was compared.
11974    ///
11975    /// With sensitive actuator endpoints on, `PUT {prefix}/loggers/{name}` is
11976    /// a real mount — so a manifest declaring it passed the preflight and
11977    /// panicked at `Router::nest`, which is the whole class this check exists
11978    /// to close.
11979    #[tokio::test]
11980    async fn try_build_router_rejects_a_declared_plugin_route_on_a_framework_mutating_path() {
11981        let mut config = AutumnConfig::default();
11982        config.actuator.sensitive = true;
11983        let loggers =
11984            crate::actuator::actuator_route_path(&config.actuator.prefix, "/loggers/{name}");
11985        let mut ctx = duplicate_test_ctx();
11986        ctx.declared_routes = vec![declared_route("PUT", &loggers, "evil-plugin")];
11987        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11988            .expect_err("a declared PUT at the actuator's own PUT must be refused");
11989        match err {
11990            RouterBuildError::DuplicateUserRoute {
11991                ref method,
11992                ref existing,
11993                ..
11994            } => {
11995                assert_eq!(method, "PUT", "the refusal must name the real method");
11996                assert!(
11997                    existing.contains(&loggers),
11998                    "the refusal must name the framework route; got: {existing}"
11999                );
12000            }
12001            other => panic!("expected the framework mutating-route refusal, got {other:?}"),
12002        }
12003    }
12004
12005    /// The other direction: a path the framework mounts only as POST must not
12006    /// be claimed as a GET.
12007    ///
12008    /// `{prefix}/webhooks/replay` is in `actuator_endpoint_paths` solely to
12009    /// seed the startup barrier (#1627), and it is mounted POST-only. GET and
12010    /// that POST merge into one `MethodRouter` cleanly, so refusing a declared
12011    /// GET there rejects a mount axum accepts — the failure mode this check
12012    /// has to avoid as much as the panic it prevents.
12013    #[cfg(feature = "http-client")]
12014    #[tokio::test]
12015    async fn try_build_router_allows_a_declared_get_at_a_post_only_framework_path() {
12016        let mut config = AutumnConfig::default();
12017        config.actuator.sensitive = true;
12018        let replay =
12019            crate::actuator::actuator_route_path(&config.actuator.prefix, "/webhooks/replay");
12020        let mut ctx = duplicate_test_ctx();
12021        ctx.declared_routes = vec![declared_route("GET", &replay, "honest-plugin")];
12022        assert!(
12023            super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).is_ok(),
12024            "a GET at a POST-only framework path merges cleanly and must be allowed"
12025        );
12026    }
12027
12028    /// A framework path that carries a capture conflicts with a differently
12029    /// NAMED capture at the same position — axum refuses that shape whatever
12030    /// the method — so the framework comparison has to go through matchit, not
12031    /// string equality. `/_stories/{slug}` is the live example; the
12032    /// operator-configurable paths (probes, actuator prefix, dev inspector,
12033    /// job status) can carry captures for the same reason.
12034    #[tokio::test]
12035    async fn try_build_router_rejects_a_declared_plugin_route_that_shape_clashes_with_a_framework_path()
12036     {
12037        let mut config = AutumnConfig::default();
12038        config.health.path = "/probe/{id}".to_owned();
12039        let mut ctx = duplicate_test_ctx();
12040        ctx.declared_routes = vec![declared_route("GET", "/probe/{slug}", "evil-plugin")];
12041        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12042            .expect_err("a shape clash with a framework path must be refused");
12043        match err {
12044            RouterBuildError::DuplicateUserRoute { ref existing, .. } => assert!(
12045                existing.contains("/probe/{id}"),
12046                "the refusal must name the framework template it clashed with; got: {existing}"
12047            ),
12048            other => panic!("expected the framework shape refusal, got {other:?}"),
12049        }
12050    }
12051
12052    /// The dev inspector mounts TWO routes — an index at the configured path
12053    /// and a detail template below it — so claiming only the configured path
12054    /// left `{inspector_path}/requests/{id}` open. It is invisible while the
12055    /// inspector sits under the reserved `/_autumn` root; move it anywhere
12056    /// else, as `dev.inspector_path` invites, and a plugin declaring that
12057    /// shape reaches `Router::merge` and panics.
12058    #[tokio::test]
12059    async fn try_build_router_rejects_a_declared_plugin_route_on_the_inspector_detail_path() {
12060        let mut config = AutumnConfig {
12061            profile: Some("dev".to_owned()),
12062            ..AutumnConfig::default()
12063        };
12064        config.dev.inspector_path = "/debug".to_owned();
12065        let mut ctx = duplicate_test_ctx();
12066        ctx.declared_routes = vec![declared_route(
12067            "GET",
12068            "/debug/requests/{slug}",
12069            "evil-plugin",
12070        )];
12071        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12072            .expect_err("the inspector detail route must be claimed too");
12073        match err {
12074            RouterBuildError::DuplicateUserRoute { ref existing, .. } => assert!(
12075                existing.contains("/debug/requests/{id}"),
12076                "the refusal must name the inspector detail template; got: {existing}"
12077            ),
12078            other => panic!("expected the inspector detail refusal, got {other:?}"),
12079        }
12080    }
12081
12082    /// Only `GET` actually clashes at a framework path — axum merges a declared
12083    /// `HEAD` or `POST` into the same `MethodRouter` without complaint
12084    /// (verified against axum 0.8.9). Refusing those would reject mounts axum
12085    /// accepts, so the check must stay method-aware.
12086    #[tokio::test]
12087    async fn try_build_router_allows_a_declared_plugin_post_on_a_framework_path() {
12088        let config = AutumnConfig::default();
12089        let mut ctx = duplicate_test_ctx();
12090        ctx.declared_routes = vec![
12091            declared_route("POST", &config.health.path, "good-plugin"),
12092            declared_route("HEAD", &config.health.path, "good-plugin"),
12093        ];
12094        let _router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12095            .expect("non-GET declarations at a framework path must mount");
12096    }
12097
12098    /// `/static` and `/_autumn` are framework namespaces, not enumerable
12099    /// routes: `ServeDir` serves whatever is on disk, so no exact-path claim
12100    /// can cover them. A plugin declaring a sub-path there does not even panic
12101    /// — it mounts and SHADOWS the framework, which for `/static/app.js` means
12102    /// an unaudited artifact serving script from the host's own origin.
12103    /// Refused for every method, at the namespace root and below.
12104    #[tokio::test]
12105    async fn try_build_router_rejects_declared_plugin_routes_inside_framework_namespaces() {
12106        for (method, path) in [
12107            ("GET", "/static/app.js"),
12108            ("GET", "/static"),
12109            // `_plugins/` is exempt only for the exact files of a bundle
12110            // installed through `AppBuilder::plugin_assets`; anything else
12111            // declared under it is still a shadowing attempt.
12112            ("GET", "/static/_plugins/not-installed/evil.js"),
12113            ("POST", "/_autumn/unsubscribe"),
12114            ("GET", "/_autumn/jobs/abc"),
12115        ] {
12116            let config = AutumnConfig::default();
12117            let mut ctx = duplicate_test_ctx();
12118            ctx.declared_routes = vec![declared_route(method, path, "evil-plugin")];
12119            let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12120                .expect_err("a plugin route inside a framework namespace must be refused");
12121            match err {
12122                RouterBuildError::DuplicateUserRoute {
12123                    path: ref got,
12124                    ref existing,
12125                    ..
12126                } => {
12127                    assert_eq!(got, path);
12128                    assert!(
12129                        existing.contains("framework namespace"),
12130                        "the refusal must name the namespace; got: {existing}"
12131                    );
12132                }
12133                other => {
12134                    panic!("expected the namespace refusal for {method} {path}, got {other:?}")
12135                }
12136            }
12137        }
12138    }
12139
12140    /// The namespace check matches on segment boundaries, not string prefixes:
12141    /// `/staticky` is an ordinary path a plugin may legitimately serve.
12142    #[tokio::test]
12143    async fn try_build_router_allows_a_plugin_path_that_merely_starts_like_a_namespace() {
12144        let config = AutumnConfig::default();
12145        let mut ctx = duplicate_test_ctx();
12146        ctx.declared_routes = vec![declared_route("GET", "/staticky/thing", "good-plugin")];
12147        let _router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12148            .expect("/staticky must not be read as /static");
12149    }
12150
12151    /// `mount_probe_endpoints` mounts nothing when `health.enabled = false`, so
12152    /// claiming the probe paths anyway would refuse a plugin over a collision
12153    /// that cannot happen — turning a working install into a startup failure.
12154    #[tokio::test]
12155    async fn try_build_router_allows_a_declared_probe_path_when_probes_are_disabled() {
12156        let mut config = AutumnConfig::default();
12157        config.health.enabled = false;
12158        let mut ctx = duplicate_test_ctx();
12159        ctx.declared_routes = vec![declared_route("GET", &config.health.path, "good-plugin")];
12160        let _router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12161            .expect("a disabled probe path must not be claimed");
12162    }
12163
12164    /// The same protection has to cover a *shape* clash, not just an exact
12165    /// path: `axum::Router::nest` panics identically when a declared
12166    /// `/hello/{slug}` meets an application `/hello/{id}` (matchit rejects the
12167    /// second template regardless of method).
12168    #[tokio::test]
12169    async fn try_build_router_rejects_a_declared_plugin_route_that_shape_clashes() {
12170        let config = AutumnConfig::default();
12171        let app_route = duplicate_test_route(http::Method::GET, "/hello/{id}", "app_show");
12172        let mut ctx = duplicate_test_ctx();
12173        ctx.declared_routes = vec![declared_route("POST", "/hello/{slug}", "evil-plugin")];
12174        let err = super::try_build_router_inner(vec![app_route], &config, test_state(), ctx)
12175            .expect_err("a shape clash between app and plugin routes must be refused");
12176        assert!(
12177            matches!(err, RouterBuildError::ConflictingRouteShape { .. }),
12178            "expected ConflictingRouteShape, got {err:?}"
12179        );
12180    }
12181
12182    /// The check must not fire on mounts axum accepts, or every plugin install
12183    /// becomes a startup failure. Disjoint paths under a shared prefix, and a
12184    /// plugin route sitting AT a path the app serves as a *parent*, both mount
12185    /// cleanly — verified against axum itself before this test was written.
12186    #[tokio::test]
12187    async fn try_build_router_allows_declared_plugin_routes_that_do_not_collide() {
12188        let config = AutumnConfig::default();
12189        let routes = vec![
12190            duplicate_test_route(http::Method::GET, "/hello/other", "app_other"),
12191            duplicate_test_route(http::Method::GET, "/hello", "app_index"),
12192        ];
12193        let mut ctx = duplicate_test_ctx();
12194        ctx.declared_routes = vec![
12195            declared_route("GET", "/hello/greet", "good-plugin"),
12196            // A GET route's implied HEAD is declared alongside it; the two
12197            // share a path and must not be read as a duplicate of each other.
12198            declared_route("HEAD", "/hello/greet", "good-plugin"),
12199        ];
12200        let _router = super::try_build_router_inner(routes, &config, test_state(), ctx)
12201            .expect("non-colliding plugin routes must mount");
12202    }
12203
12204    /// AC #4: distinct methods on the same path (`GET /admin` + `POST /admin`)
12205    /// must NOT be flagged — axum merges them cleanly into a single
12206    /// `MethodRouter`.
12207    #[tokio::test]
12208    async fn try_build_router_allows_distinct_methods_on_same_path() {
12209        let config = AutumnConfig::default();
12210        let get = duplicate_test_route(http::Method::GET, "/admin", "admin_index");
12211        let post = duplicate_test_route(http::Method::POST, "/admin", "admin_create");
12212        let _router = super::try_build_router_inner(
12213            vec![get, post],
12214            &config,
12215            test_state(),
12216            duplicate_test_ctx(),
12217        )
12218        .expect("GET + POST on the same path should build cleanly");
12219    }
12220
12221    /// AC #3: duplicates that span a top-level route and a scoped group
12222    /// (once the scope prefix is applied) are detected using the same
12223    /// preflight — the introspection reuses `RouteInfo`'s scope resolution.
12224    #[tokio::test]
12225    async fn try_build_router_rejects_duplicate_across_scoped_group() {
12226        let config = AutumnConfig::default();
12227        let top = duplicate_test_route(http::Method::GET, "/api/posts", "top_posts");
12228        let scoped_child = duplicate_test_route(http::Method::GET, "/posts", "scoped_posts");
12229        let group = crate::app::ScopedGroup {
12230            prefix: "/api".to_owned(),
12231            routes: vec![scoped_child],
12232            source: crate::route_listing::RouteSource::User,
12233            layer_guarded: false,
12234            apply_layer: Box::new(|r| r),
12235        };
12236        let mut ctx = duplicate_test_ctx();
12237        ctx.scoped_groups.push(group);
12238        let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
12239            .expect_err("top-level + scoped resolving to same path should be rejected");
12240        match err {
12241            RouterBuildError::DuplicateUserRoute {
12242                ref method,
12243                ref path,
12244                ..
12245            } => {
12246                assert_eq!(method, "GET");
12247                assert_eq!(path, "/api/posts");
12248            }
12249            other => panic!("expected DuplicateUserRoute, got {other:?}"),
12250        }
12251    }
12252
12253    /// AC #3: two scoped groups whose resolved paths collide are also
12254    /// caught (a plugin re-registering the same route class as user code).
12255    #[tokio::test]
12256    async fn try_build_router_rejects_duplicate_within_scoped_groups() {
12257        let config = AutumnConfig::default();
12258        let a = crate::app::ScopedGroup {
12259            prefix: "/api".to_owned(),
12260            routes: vec![duplicate_test_route(
12261                http::Method::GET,
12262                "/posts",
12263                "user_posts",
12264            )],
12265            source: crate::route_listing::RouteSource::User,
12266            layer_guarded: false,
12267            apply_layer: Box::new(|r| r),
12268        };
12269        let b = crate::app::ScopedGroup {
12270            prefix: "/api".to_owned(),
12271            routes: vec![duplicate_test_route(
12272                http::Method::GET,
12273                "/posts",
12274                "plugin_posts",
12275            )],
12276            source: crate::route_listing::RouteSource::Plugin("blog".to_owned()),
12277            layer_guarded: false,
12278            apply_layer: Box::new(|r| r),
12279        };
12280        let mut ctx = duplicate_test_ctx();
12281        ctx.scoped_groups.push(a);
12282        ctx.scoped_groups.push(b);
12283        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12284            .expect_err("two scoped groups colliding on /api/posts should be rejected");
12285        assert!(matches!(
12286            err,
12287            RouterBuildError::DuplicateUserRoute { ref existing, ref incoming, .. }
12288                if existing == "user_posts" && incoming == "plugin_posts"
12289        ));
12290    }
12291
12292    /// AC #5: an opaque `AppBuilder::merge` router coexisting with a clean
12293    /// route table must not cause a false-pass failure — the check is
12294    /// skipped (with the existing "check skipped" warning) and the build
12295    /// continues. Regression guard for the collision preflight.
12296    #[tokio::test]
12297    async fn try_build_router_skips_duplicate_check_for_opaque_merge_router() {
12298        let config = AutumnConfig::default();
12299        let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
12300        let raw = axum::Router::<AppState>::new()
12301            .route("/raw", axum::routing::get(duplicate_route_handler));
12302        let mut ctx = duplicate_test_ctx();
12303        ctx.merge_routers.push(raw);
12304        let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
12305            .expect("opaque merge routers must not fail the duplicate preflight");
12306    }
12307
12308    /// AC #5: same regression guard for opaque `AppBuilder::nest` routers.
12309    #[tokio::test]
12310    async fn try_build_router_skips_duplicate_check_for_opaque_nest_router() {
12311        let config = AutumnConfig::default();
12312        let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
12313        let nested = axum::Router::<AppState>::new()
12314            .route("/child", axum::routing::get(duplicate_route_handler));
12315        let mut ctx = duplicate_test_ctx();
12316        ctx.nest_routers.push(("/plugin".to_owned(), nested));
12317        let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
12318            .expect("opaque nest routers must not fail the duplicate preflight");
12319    }
12320
12321    /// Finding 1 (issue #1012 review): two handlers that differ ONLY by capture
12322    /// name — `/users/{id}` vs `/users/{slug}` — key by literal template so they
12323    /// look distinct to a naive preflight, but axum's matcher (verified against
12324    /// axum 0.8.9: matchit reports a "conflict") rejects the second route shape
12325    /// at mount. Because the two EXACT templates differ, this is a matchit route
12326    /// conflict (illegal regardless of method), surfaced as
12327    /// `ConflictingRouteShape` naming both handlers AND both original templates.
12328    #[tokio::test]
12329    async fn try_build_router_rejects_duplicate_capture_name_paths() {
12330        let config = AutumnConfig::default();
12331        let a = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
12332        let b = duplicate_test_route(http::Method::GET, "/users/{slug}", "by_slug");
12333        let err =
12334            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12335                .expect_err("capture-name-only difference must be rejected before mount");
12336        match err {
12337            RouterBuildError::ConflictingRouteShape {
12338                ref existing,
12339                ref existing_path,
12340                ref incoming,
12341                ref incoming_path,
12342            } => {
12343                assert_eq!(existing, "by_id");
12344                assert_eq!(existing_path, "/users/{id}");
12345                assert_eq!(incoming, "by_slug");
12346                assert_eq!(incoming_path, "/users/{slug}");
12347            }
12348            other => panic!("expected ConflictingRouteShape, got {other:?}"),
12349        }
12350        // The diagnostic must name BOTH original templates, not the normalized key.
12351        let display = err.to_string();
12352        assert!(
12353            display.contains("/users/{id}") && display.contains("/users/{slug}"),
12354            "error must show both original path templates; got: {display}"
12355        );
12356    }
12357
12358    /// Finding 1, scoped-group variant: the capture-name normalization must run
12359    /// AFTER `join_nested_path` prefix resolution, so a scoped `/users/{slug}`
12360    /// under `/api` collides with a top-level `/api/users/{id}`.
12361    #[tokio::test]
12362    async fn try_build_router_rejects_duplicate_capture_name_across_scoped_group() {
12363        let config = AutumnConfig::default();
12364        let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
12365        let scoped_child =
12366            duplicate_test_route(http::Method::GET, "/users/{slug}", "scoped_by_slug");
12367        let group = crate::app::ScopedGroup {
12368            prefix: "/api".to_owned(),
12369            routes: vec![scoped_child],
12370            source: crate::route_listing::RouteSource::User,
12371            layer_guarded: false,
12372            apply_layer: Box::new(|r| r),
12373        };
12374        let mut ctx = duplicate_test_ctx();
12375        ctx.scoped_groups.push(group);
12376        let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
12377            .expect_err("scoped capture-name collision must be rejected before mount");
12378        assert!(
12379            matches!(
12380                err,
12381                RouterBuildError::ConflictingRouteShape {
12382                    ref existing, ref incoming, ref existing_path, ref incoming_path
12383                }
12384                    if existing == "top_by_id" && incoming == "scoped_by_slug"
12385                        && existing_path == "/api/users/{id}"
12386                        && incoming_path == "/api/users/{slug}"
12387            ),
12388            "expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
12389        );
12390    }
12391
12392    /// Finding 1 NEGATIVE guard: normalization must not over-flag. Two genuinely
12393    /// different shapes that axum's matcher accepts (verified: `/users/{id}` and
12394    /// `/users/{id}/posts` do NOT conflict) must still build cleanly.
12395    #[tokio::test]
12396    async fn try_build_router_allows_distinct_route_shapes() {
12397        let config = AutumnConfig::default();
12398        let a = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
12399        let b = duplicate_test_route(http::Method::GET, "/users/{id}/posts", "posts");
12400        let _router =
12401            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12402                .expect("distinct route shapes must not be flagged as duplicates");
12403    }
12404
12405    /// Finding 2 (issue #1012 review): `#[ws]` records the synthetic `WS` method
12406    /// but `group_and_mount_routes` mounts its handler via `axum::routing::get`,
12407    /// so `#[get("/live")]` + `#[ws("/live")]` produce two overlapping `GET`
12408    /// `MethodRouter`s that panic on merge. Normalizing `WS` to its effective
12409    /// `GET` before keying makes the preflight catch it as `DuplicateUserRoute`.
12410    ///
12411    /// Not `#[cfg(feature = "ws")]`-gated: the synthetic `WS` method is a plain
12412    /// `http::Method` string, and the sibling `build_route_timeout_table_*` test
12413    /// exercises the same normalization ungated — gating would hide this from the
12414    /// default `cargo test` run since `ws` is not a default feature.
12415    #[tokio::test]
12416    async fn try_build_router_rejects_ws_get_collision() {
12417        let config = AutumnConfig::default();
12418        let get = duplicate_test_route(http::Method::GET, "/live", "live_poll");
12419        let ws = duplicate_test_route(
12420            http::Method::from_bytes(b"WS").unwrap(),
12421            "/live",
12422            "live_socket",
12423        );
12424        let err = super::try_build_router_inner(
12425            vec![get, ws],
12426            &config,
12427            test_state(),
12428            duplicate_test_ctx(),
12429        )
12430        .expect_err("GET + WS on the same path must be rejected before mount");
12431        match err {
12432            RouterBuildError::DuplicateUserRoute {
12433                ref method,
12434                ref path,
12435                ref existing,
12436                ref incoming,
12437            } => {
12438                assert_eq!(method, "GET", "WS must be normalized to its effective GET");
12439                assert_eq!(path, "/live");
12440                assert_eq!(existing, "live_poll");
12441                assert_eq!(incoming, "live_socket");
12442            }
12443            other => panic!("expected DuplicateUserRoute, got {other:?}"),
12444        }
12445    }
12446
12447    /// Finding A (round 2): different HTTP methods whose paths differ ONLY by
12448    /// capture name (`GET /users/{id}` + `POST /users/{slug}`) key as distinct
12449    /// `(method, shape)` pairs, so the method-independent shape check must catch
12450    /// them. Verified against axum 0.8.9: `Router::route("/users/{id}", get)`
12451    /// then `Router::route("/users/{slug}", post)` PANICS — matchit rejects the
12452    /// second template as a route conflict BEFORE method merging. The preflight
12453    /// surfaces it as `ConflictingRouteShape` naming both handlers + both
12454    /// templates; no axum panic escapes.
12455    #[tokio::test]
12456    async fn try_build_router_rejects_cross_method_shape_conflict() {
12457        let config = AutumnConfig::default();
12458        let get = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
12459        let post = duplicate_test_route(http::Method::POST, "/users/{slug}", "by_slug");
12460        let err = super::try_build_router_inner(
12461            vec![get, post],
12462            &config,
12463            test_state(),
12464            duplicate_test_ctx(),
12465        )
12466        .expect_err("cross-method capture-name-only conflict must be rejected before mount");
12467        match err {
12468            RouterBuildError::ConflictingRouteShape {
12469                ref existing,
12470                ref existing_path,
12471                ref incoming,
12472                ref incoming_path,
12473            } => {
12474                assert_eq!(existing, "by_id");
12475                assert_eq!(existing_path, "/users/{id}");
12476                assert_eq!(incoming, "by_slug");
12477                assert_eq!(incoming_path, "/users/{slug}");
12478            }
12479            other => panic!("expected ConflictingRouteShape, got {other:?}"),
12480        }
12481        let display = err.to_string();
12482        assert!(
12483            display.contains("by_id") && display.contains("by_slug"),
12484            "error must name both handlers; got: {display}"
12485        );
12486        assert!(
12487            display.contains("/users/{id}") && display.contains("/users/{slug}"),
12488            "error must name both original templates; got: {display}"
12489        );
12490    }
12491
12492    /// Finding A, scoped-group variant: the method-independent shape conflict
12493    /// check must run AFTER `join_nested_path`, so a scoped `POST /users/{slug}`
12494    /// under `/api` conflicts with a top-level `GET /api/users/{id}`.
12495    #[tokio::test]
12496    async fn try_build_router_rejects_cross_method_shape_conflict_across_scoped_group() {
12497        let config = AutumnConfig::default();
12498        let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
12499        let scoped_child =
12500            duplicate_test_route(http::Method::POST, "/users/{slug}", "scoped_by_slug");
12501        let group = crate::app::ScopedGroup {
12502            prefix: "/api".to_owned(),
12503            routes: vec![scoped_child],
12504            source: crate::route_listing::RouteSource::User,
12505            layer_guarded: false,
12506            apply_layer: Box::new(|r| r),
12507        };
12508        let mut ctx = duplicate_test_ctx();
12509        ctx.scoped_groups.push(group);
12510        let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
12511            .expect_err("scoped cross-method shape conflict must be rejected before mount");
12512        assert!(
12513            matches!(
12514                err,
12515                RouterBuildError::ConflictingRouteShape {
12516                    ref existing, ref incoming, ref existing_path, ref incoming_path
12517                }
12518                    if existing == "top_by_id" && incoming == "scoped_by_slug"
12519                        && existing_path == "/api/users/{id}"
12520                        && incoming_path == "/api/users/{slug}"
12521            ),
12522            "expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
12523        );
12524    }
12525
12526    /// AC #4 (round 2): the SAME exact capture template on distinct methods
12527    /// (`GET /users/{id}` + `POST /users/{id}`) is LEGAL — axum merges the two
12528    /// `MethodRouter`s. Verified against axum 0.8.9: this pair builds cleanly.
12529    /// The shape check keys on the FIRST exact template per shape, so an
12530    /// identical template never trips it — only a DIFFERENT template does.
12531    #[tokio::test]
12532    async fn try_build_router_allows_same_capture_template_distinct_methods() {
12533        let config = AutumnConfig::default();
12534        let get = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
12535        let post = duplicate_test_route(http::Method::POST, "/users/{id}", "update");
12536        let _router = super::try_build_router_inner(
12537            vec![get, post],
12538            &config,
12539            test_state(),
12540            duplicate_test_ctx(),
12541        )
12542        .expect("same capture template on GET + POST must build cleanly");
12543    }
12544
12545    /// Finding B (round 2): axum/matchit treat `{{`/`}}` as ESCAPED literal
12546    /// braces, so `/{{foo}}` and `/{{bar}}` are two DISTINCT static routes.
12547    /// Verified against axum 0.8.9: both build cleanly. The matchit oracle
12548    /// treats escaped braces as literals (not captures), so this valid app is
12549    /// not falsely rejected.
12550    #[tokio::test]
12551    async fn try_build_router_allows_escaped_brace_literals() {
12552        let config = AutumnConfig::default();
12553        let a = duplicate_test_route(http::Method::GET, "/{{foo}}", "lit_foo");
12554        let b = duplicate_test_route(http::Method::GET, "/{{bar}}", "lit_bar");
12555        let _router =
12556            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12557                .expect("distinct escaped-literal paths must not be flagged as duplicates");
12558    }
12559
12560    /// Finding B guard: an escaped-literal prefix combined with a real capture
12561    /// keeps the shapes distinct — `/{{x}}/{id}` and `/{{y}}/{id}` differ only in
12562    /// their literal segment. Verified against axum 0.8.9: both build cleanly.
12563    #[tokio::test]
12564    async fn try_build_router_allows_escaped_literal_prefix_with_capture() {
12565        let config = AutumnConfig::default();
12566        let a = duplicate_test_route(http::Method::GET, "/{{x}}/{id}", "x_show");
12567        let b = duplicate_test_route(http::Method::GET, "/{{y}}/{id}", "y_show");
12568        let _router =
12569            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12570                .expect("distinct escaped-literal prefixes with a shared capture must build");
12571    }
12572
12573    /// Adversarial sweep: a mixed literal+capture segment must normalize at the
12574    /// char level. `/file.{ext}` and `/file.{kind}` share the shape `/file.{…}`.
12575    /// Verified against axum 0.8.9: this pair PANICS (matchit conflict), so the
12576    /// preflight must flag it as `ConflictingRouteShape` naming both templates.
12577    #[tokio::test]
12578    async fn try_build_router_rejects_mixed_literal_capture_shape_conflict() {
12579        let config = AutumnConfig::default();
12580        let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
12581        let b = duplicate_test_route(http::Method::GET, "/file.{kind}", "by_kind");
12582        let err =
12583            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12584                .expect_err("mixed literal+capture shape conflict must be rejected before mount");
12585        assert!(
12586            matches!(
12587                err,
12588                RouterBuildError::ConflictingRouteShape {
12589                    ref existing_path, ref incoming_path, ..
12590                }
12591                    if existing_path == "/file.{ext}" && incoming_path == "/file.{kind}"
12592            ),
12593            "expected ConflictingRouteShape naming both templates, got {err:?}"
12594        );
12595    }
12596
12597    /// Adversarial sweep NEGATIVE guard: a mixed literal+capture segment stays
12598    /// distinct from a fully static segment. `/file.{ext}` and `/file.json` do
12599    /// NOT share a shape. Verified against axum 0.8.9: this pair builds cleanly,
12600    /// so the char-level normalization must not over-collapse the static one.
12601    #[tokio::test]
12602    async fn try_build_router_allows_mixed_capture_vs_static_segment() {
12603        let config = AutumnConfig::default();
12604        let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
12605        let b = duplicate_test_route(http::Method::GET, "/file.json", "static_json");
12606        let _router =
12607            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12608                .expect("a capture segment and a static segment must not be flagged as duplicates");
12609    }
12610
12611    /// Adversarial sweep: a normal capture and a catch-all at the same terminal
12612    /// position (`/u/{id}` vs `/u/{*rest}`) collapse to the same placeholder.
12613    /// Verified against axum 0.8.9: this pair PANICS (matchit conflict), so the
12614    /// preflight must flag it. Different exact templates → `ConflictingRouteShape`.
12615    #[tokio::test]
12616    async fn try_build_router_rejects_catch_all_vs_normal_capture() {
12617        let config = AutumnConfig::default();
12618        let a = duplicate_test_route(http::Method::GET, "/u/{id}", "one");
12619        let b = duplicate_test_route(http::Method::GET, "/u/{*rest}", "rest");
12620        let err =
12621            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12622                .expect_err("catch-all vs normal capture must be rejected before mount");
12623        assert!(
12624            matches!(
12625                err,
12626                RouterBuildError::ConflictingRouteShape {
12627                    ref existing_path, ref incoming_path, ..
12628                }
12629                    if existing_path == "/u/{id}" && incoming_path == "/u/{*rest}"
12630            ),
12631            "expected ConflictingRouteShape naming both templates, got {err:?}"
12632        );
12633    }
12634
12635    /// New #1012 finding (matchit oracle): a catch-all conflicts with a dynamic
12636    /// DESCENDANT, not just a sibling capture. `GET /cmd/{tool}/{sub}` +
12637    /// `POST /cmd/{*path}` slipped past the old hand-rolled shape normalizer
12638    /// (which only unified captures position-by-position) and axum still panicked
12639    /// at mount. Delegating to matchit — the engine axum uses — catches it:
12640    /// verified against axum 0.8.9 that this pair PANICS. The preflight surfaces
12641    /// it as `ConflictingRouteShape` naming both handlers + both templates; no
12642    /// axum/matchit panic escapes.
12643    #[tokio::test]
12644    async fn try_build_router_rejects_catch_all_vs_dynamic_descendant() {
12645        let config = AutumnConfig::default();
12646        let a = duplicate_test_route(http::Method::GET, "/cmd/{tool}/{sub}", "cmd_sub");
12647        let b = duplicate_test_route(http::Method::POST, "/cmd/{*path}", "cmd_all");
12648        let err =
12649            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12650                .expect_err("catch-all vs dynamic descendant must be rejected before mount");
12651        match err {
12652            RouterBuildError::ConflictingRouteShape {
12653                ref existing,
12654                ref existing_path,
12655                ref incoming,
12656                ref incoming_path,
12657            } => {
12658                assert_eq!(existing, "cmd_sub");
12659                assert_eq!(existing_path, "/cmd/{tool}/{sub}");
12660                assert_eq!(incoming, "cmd_all");
12661                assert_eq!(incoming_path, "/cmd/{*path}");
12662            }
12663            other => panic!("expected ConflictingRouteShape, got {other:?}"),
12664        }
12665        let display = err.to_string();
12666        assert!(
12667            display.contains("/cmd/{tool}/{sub}") && display.contains("/cmd/{*path}"),
12668            "error must name both original templates; got: {display}"
12669        );
12670    }
12671
12672    /// Negative regression guard for the matchit oracle: a STATIC segment and a
12673    /// dynamic capture at the same position (`/users/me` + `/users/{id}`) do NOT
12674    /// conflict — matchit (and thus axum 0.8.9) accepts both, matching static
12675    /// before dynamic. The oracle must NOT raise a false positive here. Confirmed
12676    /// by `matchit_agrees_with_axum_route_conflicts`.
12677    #[tokio::test]
12678    async fn try_build_router_allows_static_vs_dynamic_segment() {
12679        let config = AutumnConfig::default();
12680        let a = duplicate_test_route(http::Method::GET, "/users/me", "me");
12681        let b = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
12682        let _router =
12683            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12684                .expect("a static segment and a dynamic capture must not be flagged as a conflict");
12685    }
12686
12687    /// Parity guard for the #1012 matchit oracle: matchit's `insert` Ok/Err MUST
12688    /// agree with axum 0.8.9's `Router::route` accept/panic on every case of the
12689    /// conflict matrix. axum wraps matchit, so they should always agree — this
12690    /// test fails LOUDLY if a future axum bump (or a `matchit` version that
12691    /// drifts out of lockstep with the `=0.8.4` pin) changes conflict semantics,
12692    /// catching silent oracle divergence before it can introduce false
12693    /// positives/negatives at mount. Deterministic and fast (no async, no I/O).
12694    #[test]
12695    fn matchit_agrees_with_axum_route_conflicts() {
12696        // (template_a, template_b, expect_conflict)
12697        let matrix: &[(&str, &str, bool)] = &[
12698            ("/users/{id}", "/users/{slug}", true),
12699            ("/users/{id}", "/users/{id}/posts", false),
12700            ("/cmd/{tool}/{sub}", "/cmd/{*path}", true),
12701            ("/users/me", "/users/{id}", false),
12702            ("/{{foo}}", "/{{bar}}", false),
12703            ("/file.{ext}", "/file.{kind}", true),
12704            ("/file.{ext}", "/file.json", false),
12705            ("/u/{id}", "/u/{*rest}", true),
12706        ];
12707
12708        // Silence axum/matchit's panic backtrace noise while we intentionally
12709        // trip conflicts under catch_unwind; restore the hook afterwards.
12710        let prev_hook = std::panic::take_hook();
12711        std::panic::set_hook(Box::new(|_| {}));
12712
12713        let mut rows = Vec::new();
12714        let mut mismatches = Vec::new();
12715        for &(a, b, expect_conflict) in matrix {
12716            // axum: does registering BOTH templates panic inside `route()`?
12717            let axum_panics = std::panic::catch_unwind(|| {
12718                let _ = axum::Router::<()>::new()
12719                    .route(a, axum::routing::get(|| async { "a" }))
12720                    .route(b, axum::routing::get(|| async { "b" }));
12721            })
12722            .is_err();
12723
12724            // matchit: does inserting BOTH templates report a conflict?
12725            let mut r: matchit::Router<()> = matchit::Router::new();
12726            r.insert(a, ()).expect("first template must insert cleanly");
12727            let matchit_conflicts =
12728                matches!(r.insert(b, ()), Err(matchit::InsertError::Conflict { .. }));
12729
12730            rows.push(format!(
12731                "{a:<20} vs {b:<20} axum={} matchit={} expected={}",
12732                if axum_panics { "PANIC" } else { "ok" },
12733                if matchit_conflicts { "Err" } else { "Ok" },
12734                if expect_conflict { "conflict" } else { "ok" },
12735            ));
12736
12737            if axum_panics != matchit_conflicts || axum_panics != expect_conflict {
12738                mismatches.push(rows.last().unwrap().clone());
12739            }
12740        }
12741
12742        std::panic::set_hook(prev_hook);
12743
12744        assert!(
12745            mismatches.is_empty(),
12746            "matchit must agree with axum 0.8.9 AND the expected outcome on every \
12747             case (oracle divergence => false positives/negatives at mount).\n\
12748             full matrix:\n{}\nmismatches:\n{}",
12749            rows.join("\n"),
12750            mismatches.join("\n"),
12751        );
12752    }
12753
12754    // --- Static file serving (SSG/ISG) tests ---
12755
12756    // --- SSG/ISG response compression (#752) ---
12757    //
12758    // The static-first middleware serves manifest-backed `dist/` files from disk
12759    // and short-circuits before the dynamic router. Compression is applied
12760    // outside that middleware (see `try_build_router_with_static_inner`), and the
12761    // served response carries a MIME type derived from the file extension, so the
12762    // compression layer encodes compressible SSG pages and leaves binary assets
12763    // alone — matching dynamic handler responses.
12764
12765    /// Build a `dist/` dir + `manifest.json` mapping each `(route, file, bytes)`
12766    /// tuple to a file on disk. Returns the `TempDir` guard; the dist directory
12767    /// is at `<tmp>/dist`.
12768    fn create_ssg_dist(entries: &[(&str, &str, &[u8])]) -> tempfile::TempDir {
12769        let with_types: Vec<_> = entries
12770            .iter()
12771            .map(|(route, file, bytes)| (*route, *file, *bytes, None))
12772            .collect();
12773        create_ssg_dist_with_types(&with_types)
12774    }
12775
12776    /// Like [`create_ssg_dist`], but each entry also carries the `Content-Type`
12777    /// recorded in the manifest at generation time (#1832). `None` reproduces a
12778    /// pre-#1832 (or hand-written) manifest that records nothing.
12779    fn create_ssg_dist_with_types(
12780        entries: &[(&str, &str, &[u8], Option<&str>)],
12781    ) -> tempfile::TempDir {
12782        let dir = tempfile::tempdir().expect("tempdir");
12783        let dist = dir.path().join("dist");
12784        let mut routes = std::collections::HashMap::new();
12785        for (route, file, bytes, content_type) in entries {
12786            let path = dist.join(file);
12787            if let Some(parent) = path.parent() {
12788                std::fs::create_dir_all(parent).expect("mkdir");
12789            }
12790            std::fs::write(&path, bytes).expect("write file");
12791            routes.insert(
12792                (*route).to_owned(),
12793                crate::static_gen::ManifestEntry::new(*file)
12794                    .with_content_type(content_type.map(str::to_owned)),
12795            );
12796        }
12797        let manifest = crate::static_gen::StaticManifest {
12798            generated_at: "2026-07-12T00:00:00Z".to_owned(),
12799            autumn_version: "0.6.0".to_owned(),
12800            routes,
12801        };
12802        std::fs::write(
12803            dist.join("manifest.json"),
12804            serde_json::to_string(&manifest).unwrap(),
12805        )
12806        .unwrap();
12807        dir
12808    }
12809
12810    fn compression_enabled_config() -> AutumnConfig {
12811        let mut config = AutumnConfig::default();
12812        config.compression.enabled = true;
12813        config
12814    }
12815
12816    /// A cached SSG page must not outrank the mTLS requirement that guards it
12817    /// (#1640).
12818    ///
12819    /// The static-first middleware answers a manifest hit from disk without
12820    /// ever calling the inner router, so the copy of `RequireClientCertLayer`
12821    /// that lives in the inner router's stack never runs on that path. Without
12822    /// a second copy outside the static cache, an uncertified client is served
12823    /// the pre-rendered page while the posture manifest reports
12824    /// `mtls_required: true`.
12825    #[cfg(feature = "tls")]
12826    #[tokio::test]
12827    async fn a_cached_ssg_page_under_a_required_path_still_demands_a_certificate() {
12828        let tmp = create_ssg_dist(&[("/internal/report", "report.html", b"<h1>secret</h1>")]);
12829        let dist = tmp.path().join("dist");
12830
12831        let mut config = AutumnConfig::default();
12832        config.server.tls = Some(crate::config::TlsConfig {
12833            cert_path: Some("cert.pem".into()),
12834            key_path: Some("key.pem".into()),
12835            reload_interval_secs: 60,
12836            handshake_timeout_secs: 10,
12837            acme: None,
12838            client_auth: Some(crate::config::ClientAuthConfig {
12839                mode: crate::config::ClientAuthMode::Optional,
12840                ca_bundle_path: Some("client-ca.pem".into()),
12841                crl_path: None,
12842                required_paths: vec!["/internal/".to_owned()],
12843                reload_interval_secs: 60,
12844            }),
12845        });
12846
12847        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
12848            .expect("router builds");
12849
12850        // No `Arc<ClientIdentity>` extension: this request arrived over a
12851        // connection with no verified certificate.
12852        let response = router
12853            .clone()
12854            .oneshot(
12855                Request::builder()
12856                    .uri("/internal/report")
12857                    .body(Body::empty())
12858                    .unwrap(),
12859            )
12860            .await
12861            .unwrap();
12862        assert_eq!(
12863            response.status(),
12864            StatusCode::FORBIDDEN,
12865            "a cached page under a required_paths prefix must still demand a certificate"
12866        );
12867
12868        // A verified client gets the cached page, so the guard has not simply
12869        // broken static serving.
12870        let identity = std::sync::Arc::new(crate::tls::client_auth::ClientIdentity::new_for_test(
12871            "svc-reports",
12872            Vec::new(),
12873        ));
12874        let mut request = Request::builder()
12875            .uri("/internal/report")
12876            .body(Body::empty())
12877            .unwrap();
12878        request.extensions_mut().insert(identity);
12879        let response = router.oneshot(request).await.unwrap();
12880        assert_eq!(response.status(), StatusCode::OK);
12881        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12882            .await
12883            .unwrap();
12884        assert_eq!(&body[..], b"<h1>secret</h1>");
12885    }
12886
12887    /// Codex review (P2): a `noindex` home from a scoped group at `/` is
12888    /// left out of discovery, as a routed one is.
12889    #[test]
12890    fn a_scoped_noindex_home_is_not_listed() {
12891        let mut home = duplicate_test_route(http::Method::GET, "/", "home");
12892        home.seo = crate::seo::SeoRouteDefaults {
12893            robots: Some("noindex"),
12894            ..crate::seo::SeoRouteDefaults::EMPTY
12895        };
12896        let group = crate::app::ScopedGroup {
12897            prefix: "/".to_owned(),
12898            routes: vec![home],
12899            source: crate::route_listing::RouteSource::User,
12900            layer_guarded: false,
12901            apply_layer: Box::new(|r| r),
12902        };
12903        let site = super::build_aeo_site::<(), ()>(
12904            &AutumnConfig::default(),
12905            &AppState::for_test(),
12906            &[],
12907            &[group],
12908            false,
12909            None,
12910            None,
12911        );
12912        assert!(!site.facts.no_home);
12913        assert_eq!(site.facts.noindex_homes, vec!["/".to_owned()]);
12914    }
12915
12916    /// With a static build, the x402 copy outside the static-first
12917    /// middleware prices only the manifest's pages: a miss reaches the copy
12918    /// inside the router, which knows the free route axum picked.
12919    #[cfg(feature = "http-client")]
12920    #[tokio::test]
12921    async fn a_static_build_leaves_a_dynamic_miss_to_the_matched_route() {
12922        let tmp = create_ssg_dist(&[("/about", "about.html", b"<h1>About</h1>")]);
12923        let dist = tmp.path().join("dist");
12924        let mut config = AutumnConfig::default();
12925        config.aeo.x402 = toml::from_str(
12926            "facilitator_url = \"https://facilitator.example\"\npay_to = \"0xabc\"\n\
12927             network = \"eip155:84532\"\nasset = \"0xusdc\"",
12928        )
12929        .unwrap();
12930        config.aeo.paid_routes = vec![crate::aeo::commerce::PaidRoute::new(
12931            "GET",
12932            "/files/{*path}",
12933            "1",
12934        )];
12935        let router = try_build_router_with_static(
12936            vec![duplicate_test_route(
12937                http::Method::GET,
12938                "/files/public",
12939                "public",
12940            )],
12941            &config,
12942            test_state(),
12943            Some(&dist),
12944        )
12945        .expect("router builds");
12946        let get = |uri: &'static str| {
12947            router
12948                .clone()
12949                .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
12950        };
12951        // `/files/public` is a free route of its own, more specific than the
12952        // priced wildcard.
12953        assert_eq!(get("/files/public").await.unwrap().status(), StatusCode::OK);
12954        // A path only the wildcard covers is still charged.
12955        assert_eq!(
12956            get("/files/report.pdf").await.unwrap().status(),
12957            StatusCode::PAYMENT_REQUIRED
12958        );
12959    }
12960
12961    /// Codex review (P1): a pre-rendered page is priced by the route that
12962    /// rendered it. A free `/files/public` page stays free next to a priced
12963    /// `/files/{*path}`; a page the wildcard rendered is still charged.
12964    #[cfg(feature = "http-client")]
12965    #[tokio::test]
12966    async fn a_static_page_is_priced_by_the_route_that_rendered_it() {
12967        let tmp = create_ssg_dist(&[
12968            ("/files/public", "files/public.html", b"<h1>Public</h1>"),
12969            ("/files/report.pdf", "files/report.html", b"<h1>Report</h1>"),
12970        ]);
12971        let dist = tmp.path().join("dist");
12972        let mut config = AutumnConfig::default();
12973        config.aeo.x402 = toml::from_str(
12974            "facilitator_url = \"https://facilitator.example\"\npay_to = \"0xabc\"\n\
12975             network = \"eip155:84532\"\nasset = \"0xusdc\"",
12976        )
12977        .unwrap();
12978        config.aeo.paid_routes = vec![crate::aeo::commerce::PaidRoute::new(
12979            "GET",
12980            "/files/{*path}",
12981            "1",
12982        )];
12983        let router = try_build_router_with_static(
12984            vec![
12985                duplicate_test_route(http::Method::GET, "/files/public", "public"),
12986                duplicate_test_route(http::Method::GET, "/files/{*path}", "files"),
12987            ],
12988            &config,
12989            test_state(),
12990            Some(&dist),
12991        )
12992        .expect("router builds");
12993        let get = |uri: &'static str| {
12994            router
12995                .clone()
12996                .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
12997        };
12998        let public = get("/files/public").await.unwrap();
12999        assert_eq!(public.status(), StatusCode::OK);
13000        let body = axum::body::to_bytes(public.into_body(), usize::MAX)
13001            .await
13002            .unwrap();
13003        assert_eq!(&body[..], b"<h1>Public</h1>");
13004        assert_eq!(
13005            get("/files/report.pdf").await.unwrap().status(),
13006            StatusCode::PAYMENT_REQUIRED
13007        );
13008    }
13009
13010    /// Codex review: a priced pre-rendered page renders through the router,
13011    /// so the ingress stack (rate limiting, bot protection, load shedding,
13012    /// CORS) runs before x402 calls the facilitator. A free page is still
13013    /// served from the manifest.
13014    #[cfg(feature = "http-client")]
13015    #[tokio::test]
13016    async fn a_priced_static_page_passes_the_ingress_stack() {
13017        let tmp = create_ssg_dist(&[
13018            ("/report", "report.html", b"<h1>Report</h1>"),
13019            ("/about", "about.html", b"<h1>About</h1>"),
13020        ]);
13021        let dist = tmp.path().join("dist");
13022        let mut config = AutumnConfig::default();
13023        config.aeo.x402 = toml::from_str(
13024            "facilitator_url = \"https://facilitator.example\"\npay_to = \"0xabc\"\n\
13025             network = \"eip155:84532\"\nasset = \"0xusdc\"",
13026        )
13027        .unwrap();
13028        config.aeo.paid_routes = vec![crate::aeo::commerce::PaidRoute::new("GET", "/report", "1")];
13029        config.cors.allowed_origins = vec!["https://app.example".to_owned()];
13030        let router = try_build_router_with_static(
13031            vec![
13032                duplicate_test_route(http::Method::GET, "/report", "report"),
13033                duplicate_test_route(http::Method::GET, "/about", "about"),
13034            ],
13035            &config,
13036            test_state(),
13037            Some(&dist),
13038        )
13039        .expect("router builds");
13040        let get = |uri: &'static str| {
13041            router.clone().oneshot(
13042                Request::builder()
13043                    .uri(uri)
13044                    .header(http::header::ORIGIN, "https://app.example")
13045                    .body(Body::empty())
13046                    .unwrap(),
13047            )
13048        };
13049        let report = get("/report").await.unwrap();
13050        assert_eq!(report.status(), StatusCode::PAYMENT_REQUIRED);
13051        assert_eq!(
13052            report.headers()[http::header::ACCESS_CONTROL_ALLOW_ORIGIN],
13053            "https://app.example",
13054            "the CORS layer ran"
13055        );
13056        let about = get("/about").await.unwrap();
13057        assert_eq!(about.status(), StatusCode::OK);
13058        let body = axum::body::to_bytes(about.into_body(), usize::MAX)
13059            .await
13060            .unwrap();
13061        assert_eq!(&body[..], b"<h1>About</h1>", "served from the manifest");
13062    }
13063
13064    #[test]
13065    fn static_pages_resolve_to_the_route_axum_would_match() {
13066        let routes = vec![
13067            duplicate_test_route(http::Method::GET, "/files/public", "public"),
13068            duplicate_test_route(http::Method::GET, "/files/{*path}", "files"),
13069        ];
13070        let pages = [
13071            "/files/public".to_owned(),
13072            "/files/a.pdf".to_owned(),
13073            "/hand-written".to_owned(),
13074        ];
13075        let map = super::static_page_routes(pages.iter(), &routes, &[], &AutumnConfig::default());
13076        assert_eq!(map["/files/public"].as_deref(), Some("/files/public"));
13077        assert_eq!(map["/files/a.pdf"].as_deref(), Some("/files/{*path}"));
13078        assert_eq!(map["/hand-written"], None);
13079    }
13080
13081    /// A `HEAD` of a pre-rendered page carries the length its `GET` sends, so
13082    /// Markdown negotiation gives `HEAD` the same headers as `GET`.
13083    #[tokio::test]
13084    async fn ssg_head_mirrors_the_markdown_get() {
13085        let html = b"<html><body><main><h1>About</h1></main></body></html>";
13086        let tmp = create_ssg_dist(&[("/about", "about.html", html)]);
13087        let dist = tmp.path().join("dist");
13088        let router = try_build_router_with_static(
13089            Vec::new(),
13090            &AutumnConfig::default(),
13091            test_state(),
13092            Some(&dist),
13093        )
13094        .expect("router builds");
13095        let send = |method: http::Method| {
13096            router.clone().oneshot(
13097                Request::builder()
13098                    .method(method)
13099                    .uri("/about")
13100                    .header(http::header::ACCEPT, "text/markdown")
13101                    .body(Body::empty())
13102                    .unwrap(),
13103            )
13104        };
13105        let head = send(http::Method::HEAD).await.unwrap();
13106        let get = send(http::Method::GET).await.unwrap();
13107        assert_eq!(head.status(), StatusCode::OK);
13108        assert_eq!(
13109            head.headers().get(http::header::CONTENT_TYPE),
13110            get.headers().get(http::header::CONTENT_TYPE),
13111        );
13112        assert!(
13113            head.headers()[http::header::CONTENT_TYPE]
13114                .to_str()
13115                .unwrap()
13116                .starts_with("text/markdown"),
13117            "{:?}",
13118            head.headers()
13119        );
13120        // Codex review (P2): the Markdown sets the length; no body is sent.
13121        let get_len = get.headers()[http::header::CONTENT_LENGTH].clone();
13122        assert_eq!(head.headers()[http::header::CONTENT_LENGTH], get_len);
13123        let body = axum::body::to_bytes(head.into_body(), usize::MAX)
13124            .await
13125            .unwrap();
13126        assert!(body.is_empty(), "{body:?}");
13127    }
13128
13129    /// A manifest-backed HTML page is gzip-compressed when the client accepts
13130    /// gzip and framework compression is enabled, and it carries
13131    /// `Vary: Accept-Encoding`.
13132    #[tokio::test]
13133    async fn ssg_html_hit_is_gzip_compressed() {
13134        let html = format!(
13135            "<html><body>{}</body></html>",
13136            "Lorem ipsum dolor sit amet. ".repeat(64)
13137        );
13138        let tmp = create_ssg_dist(&[("/", "index.html", html.as_bytes())]);
13139        let dist = tmp.path().join("dist");
13140
13141        let router = try_build_router_with_static(
13142            Vec::new(),
13143            &compression_enabled_config(),
13144            test_state(),
13145            Some(&dist),
13146        )
13147        .expect("router builds");
13148        let response = router
13149            .oneshot(
13150                Request::builder()
13151                    .uri("/")
13152                    .header("accept-encoding", "gzip")
13153                    .body(Body::empty())
13154                    .unwrap(),
13155            )
13156            .await
13157            .unwrap();
13158
13159        assert_eq!(response.status(), StatusCode::OK);
13160        assert_eq!(
13161            response
13162                .headers()
13163                .get(http::header::CONTENT_ENCODING)
13164                .and_then(|v| v.to_str().ok()),
13165            Some("gzip"),
13166            "manifest-backed SSG HTML page must be gzip-compressed"
13167        );
13168        // The page carries `Vary: Accept` too (AEO), so read every value.
13169        let vary = response
13170            .headers()
13171            .get_all(http::header::VARY)
13172            .iter()
13173            .filter_map(|v| v.to_str().ok())
13174            .collect::<Vec<_>>()
13175            .join(", ");
13176        assert!(
13177            vary.to_lowercase().contains("accept-encoding"),
13178            "Vary must advertise Accept-Encoding, got {vary:?}"
13179        );
13180        assert_eq!(
13181            response
13182                .headers()
13183                .get(http::header::CONTENT_TYPE)
13184                .and_then(|v| v.to_str().ok()),
13185            Some("text/html; charset=utf-8"),
13186            "HTML page keeps its text/html content type"
13187        );
13188        // The transferred body is the gzip stream, not the raw HTML.
13189        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
13190            .await
13191            .unwrap();
13192        assert_ne!(
13193            body.as_ref(),
13194            html.as_bytes(),
13195            "compressed body must differ from the raw HTML"
13196        );
13197    }
13198
13199    /// A manifest-backed *binary* asset is served with its real MIME type and is
13200    /// NOT compressed, even though the client accepts gzip — proving the fix
13201    /// does not blindly compress non-text responses.
13202    #[tokio::test]
13203    async fn ssg_binary_asset_is_not_compressed_and_keeps_mime() {
13204        // PNG signature followed by pseudo-random bytes, padded well past the
13205        // compression size floor so size is not the reason it is skipped.
13206        let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
13207        bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13208        let tmp = create_ssg_dist(&[("/logo", "logo.png", &bytes)]);
13209        let dist = tmp.path().join("dist");
13210
13211        let router = try_build_router_with_static(
13212            Vec::new(),
13213            &compression_enabled_config(),
13214            test_state(),
13215            Some(&dist),
13216        )
13217        .expect("router builds");
13218        let response = router
13219            .oneshot(
13220                Request::builder()
13221                    .uri("/logo")
13222                    .header("accept-encoding", "gzip")
13223                    .body(Body::empty())
13224                    .unwrap(),
13225            )
13226            .await
13227            .unwrap();
13228
13229        assert_eq!(response.status(), StatusCode::OK);
13230        assert_eq!(
13231            response
13232                .headers()
13233                .get(http::header::CONTENT_TYPE)
13234                .and_then(|v| v.to_str().ok()),
13235            Some("image/png"),
13236            "binary manifest asset must keep its real MIME type, not text/html"
13237        );
13238        assert_eq!(
13239            response.headers().get(http::header::CONTENT_ENCODING),
13240            None,
13241            "binary asset must not be blindly compressed"
13242        );
13243    }
13244
13245    /// A manifest-backed pre-compressed web font (`.woff2`) is served with its
13246    /// `font/woff2` MIME type and is NOT gzip-compressed even though the client
13247    /// accepts gzip: WOFF/WOFF2 embed their own compression, so re-encoding only
13248    /// wastes CPU. Raw fonts (`.ttf`/`.otf`) are deliberately left compressible.
13249    #[tokio::test]
13250    async fn ssg_woff2_font_is_not_compressed_and_keeps_mime() {
13251        // Pad well past the compression size floor so size is not the reason it
13252        // is skipped — the font MIME type is.
13253        let mut bytes = b"wOF2".to_vec();
13254        bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13255        let tmp = create_ssg_dist(&[("/inter", "fonts/inter.woff2", &bytes)]);
13256        let dist = tmp.path().join("dist");
13257
13258        let router = try_build_router_with_static(
13259            Vec::new(),
13260            &compression_enabled_config(),
13261            test_state(),
13262            Some(&dist),
13263        )
13264        .expect("router builds");
13265        let response = router
13266            .oneshot(
13267                Request::builder()
13268                    .uri("/inter")
13269                    .header("accept-encoding", "gzip")
13270                    .body(Body::empty())
13271                    .unwrap(),
13272            )
13273            .await
13274            .unwrap();
13275
13276        assert_eq!(response.status(), StatusCode::OK);
13277        assert_eq!(
13278            response
13279                .headers()
13280                .get(http::header::CONTENT_TYPE)
13281                .and_then(|v| v.to_str().ok()),
13282            Some("font/woff2"),
13283            "woff2 manifest asset must keep its font/woff2 MIME type"
13284        );
13285        assert_eq!(
13286            response.headers().get(http::header::CONTENT_ENCODING),
13287            None,
13288            "pre-compressed woff2 font must not be re-compressed"
13289        );
13290    }
13291
13292    /// The other half of the two-entry font carve-out: WOFF **v1** is
13293    /// pre-compressed too, and `COMPRESSION_EXCLUDED_CONTENT_TYPES` lists
13294    /// `font/woff` separately from `font/woff2`. Without this, deleting the
13295    /// `font/woff` line leaves the suite green.
13296    #[tokio::test]
13297    async fn ssg_woff_font_is_not_compressed_and_keeps_mime() {
13298        let mut bytes = b"wOFF".to_vec();
13299        bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13300
13301        // Both paths: the legacy derivation from the file name, and a manifest
13302        // that recorded `font/woff` at generation time.
13303        for (label, recorded) in [("derived", None), ("recorded", Some("font/woff"))] {
13304            let tmp =
13305                create_ssg_dist_with_types(&[("/inter", "fonts/inter.woff", &bytes, recorded)]);
13306            let dist = tmp.path().join("dist");
13307            let router = try_build_router_with_static(
13308                Vec::new(),
13309                &compression_enabled_config(),
13310                test_state(),
13311                Some(&dist),
13312            )
13313            .expect("router builds");
13314            let response = router
13315                .oneshot(
13316                    Request::builder()
13317                        .uri("/inter")
13318                        .header("accept-encoding", "gzip")
13319                        .body(Body::empty())
13320                        .unwrap(),
13321                )
13322                .await
13323                .unwrap();
13324
13325            assert_eq!(response.status(), StatusCode::OK, "{label}");
13326            assert_eq!(
13327                response
13328                    .headers()
13329                    .get(http::header::CONTENT_TYPE)
13330                    .and_then(|v| v.to_str().ok()),
13331                Some("font/woff"),
13332                "{label}: woff asset must keep its font/woff MIME type"
13333            );
13334            assert_eq!(
13335                response.headers().get(http::header::CONTENT_ENCODING),
13336                None,
13337                "{label}: pre-compressed woff font must not be re-compressed"
13338            );
13339        }
13340    }
13341
13342    /// The carve-out is deliberately narrow: raw SFNT fonts (`.ttf`/`.otf`) are
13343    /// *not* pre-compressed and must keep being gzipped. Nothing else in the
13344    /// repo defends this, so "tidying" the exclusion list into a `font/` prefix
13345    /// match would silently stop compressing them — this is the test that says
13346    /// no.
13347    #[tokio::test]
13348    async fn ssg_raw_sfnt_fonts_stay_compressible() {
13349        // Highly compressible padding, well past the size floor.
13350        let mut bytes = vec![0u8, 1, 0, 0];
13351        bytes.extend(std::iter::repeat_n(b'A', 4096));
13352
13353        for (route, file, expected) in [
13354            ("/inter-ttf", "fonts/inter.ttf", "font/ttf"),
13355            ("/inter-otf", "fonts/inter.otf", "font/otf"),
13356        ] {
13357            let tmp = create_ssg_dist(&[(route, file, &bytes)]);
13358            let dist = tmp.path().join("dist");
13359            let router = try_build_router_with_static(
13360                Vec::new(),
13361                &compression_enabled_config(),
13362                test_state(),
13363                Some(&dist),
13364            )
13365            .expect("router builds");
13366            let response = router
13367                .oneshot(
13368                    Request::builder()
13369                        .uri(route)
13370                        .header("accept-encoding", "gzip")
13371                        .body(Body::empty())
13372                        .unwrap(),
13373                )
13374                .await
13375                .unwrap();
13376
13377            assert_eq!(response.status(), StatusCode::OK, "{route}");
13378            assert_eq!(
13379                response
13380                    .headers()
13381                    .get(http::header::CONTENT_TYPE)
13382                    .and_then(|v| v.to_str().ok()),
13383                Some(expected),
13384                "{route} must keep its raw-font MIME type"
13385            );
13386            assert_eq!(
13387                response
13388                    .headers()
13389                    .get(http::header::CONTENT_ENCODING)
13390                    .and_then(|v| v.to_str().ok()),
13391                Some("gzip"),
13392                "{route}: raw SFNT fonts are uncompressed data and must still be \
13393                 gzipped — only WOFF/WOFF2 embed their own compression"
13394            );
13395        }
13396    }
13397
13398    /// Returns true if the predicate compresses a 4 KiB body of this type.
13399    fn predicate_compresses(content_type: &str) -> bool {
13400        use tower_http::compression::predicate::Predicate;
13401        let response = http::Response::builder()
13402            .header(http::header::CONTENT_TYPE, content_type)
13403            .body(Body::from(vec![b'A'; 4096]))
13404            .unwrap();
13405        compression_predicate().should_compress(&response)
13406    }
13407
13408    /// Media types are case-insensitive (RFC 9110 §8.3.1). The excluded types
13409    /// must stay uncompressed in any case (#2416).
13410    #[test]
13411    fn compression_carve_outs_ignore_case() {
13412        for ct in [
13413            "Audio/MPEG",
13414            "VIDEO/mp4",
13415            "Application/Zip",
13416            "Application/Octet-Stream",
13417            "Font/WOFF2",
13418            "font/WOFF",
13419            "Font/Woff2; foo=bar",
13420            "audio/mpeg",
13421            "application/zip",
13422            "font/woff2",
13423        ] {
13424            assert!(!predicate_compresses(ct), "{ct} must stay uncompressed");
13425        }
13426    }
13427
13428    /// Fonts that are not excluded (ttf, otf) and other types are still
13429    /// compressed in any case. Near-miss prefixes (`application/zi`,
13430    /// `Font/WOF`) are also compressed.
13431    #[test]
13432    fn compression_still_applies_to_other_types_in_any_case() {
13433        for ct in [
13434            "font/ttf",
13435            "Font/TTF",
13436            "font/otf",
13437            "text/html",
13438            "Text/HTML",
13439            "Application/JSON; charset=utf-8",
13440            "application/zi",
13441            "Font/WOF",
13442            "au",
13443        ] {
13444            assert!(predicate_compresses(ct), "{ct} must be compressed");
13445        }
13446    }
13447
13448    /// Short and non-ASCII values must not panic or match.
13449    #[test]
13450    fn compression_exclusion_is_safe_on_short_and_non_ascii_input() {
13451        for ct in ["", "a", "au", "é", "audiö/x", "fönt/woff2"] {
13452            assert!(!is_compression_excluded(ct), "{ct:?} must not match");
13453        }
13454    }
13455
13456    /// A manifest-backed asset served from a nested path with a multi-dot file
13457    /// name (`assets/js/app.min.js`) resolves to the JavaScript MIME type. The
13458    /// middleware derives the type from the file name alone, so neither the
13459    /// intermediate directory components nor the extra `.min.` dot cause a
13460    /// misparse.
13461    #[tokio::test]
13462    async fn ssg_nested_multidot_asset_resolves_js_mime() {
13463        let js = format!("console.log({:?});", "x".repeat(256));
13464        let tmp = create_ssg_dist(&[("/app.js", "assets/js/app.min.js", js.as_bytes())]);
13465        let dist = tmp.path().join("dist");
13466
13467        let router = try_build_router_with_static(
13468            Vec::new(),
13469            &compression_enabled_config(),
13470            test_state(),
13471            Some(&dist),
13472        )
13473        .expect("router builds");
13474        let response = router
13475            .oneshot(
13476                Request::builder()
13477                    .uri("/app.js")
13478                    .header("accept-encoding", "gzip")
13479                    .body(Body::empty())
13480                    .unwrap(),
13481            )
13482            .await
13483            .unwrap();
13484
13485        assert_eq!(response.status(), StatusCode::OK);
13486        assert_eq!(
13487            response
13488                .headers()
13489                .get(http::header::CONTENT_TYPE)
13490                .and_then(|v| v.to_str().ok()),
13491            Some("text/javascript; charset=utf-8"),
13492            "nested multi-dot JS asset must resolve to the JavaScript MIME type"
13493        );
13494        // JS is a compressible content type, so the layer must still gzip it.
13495        assert_eq!(
13496            response
13497                .headers()
13498                .get(http::header::CONTENT_ENCODING)
13499                .and_then(|v| v.to_str().ok()),
13500            Some("gzip"),
13501            "compressible JS asset must be gzip-compressed"
13502        );
13503    }
13504
13505    /// An extensionless generated page route (`/about` ->
13506    /// `about/index.html`, the shape `static_gen::url_to_file_path` produces)
13507    /// keeps its `text/html; charset=utf-8` type and is gzip-compressed. This
13508    /// pins the fallback: routes without a file extension must NOT regress to
13509    /// octet-stream just because the served file is `index.html`.
13510    #[tokio::test]
13511    async fn ssg_generated_html_page_keeps_text_html_and_is_compressed() {
13512        let html = format!("<html><body>{}</body></html>", "About us. ".repeat(128));
13513        let tmp = create_ssg_dist(&[("/about", "about/index.html", html.as_bytes())]);
13514        let dist = tmp.path().join("dist");
13515
13516        let router = try_build_router_with_static(
13517            Vec::new(),
13518            &compression_enabled_config(),
13519            test_state(),
13520            Some(&dist),
13521        )
13522        .expect("router builds");
13523        let response = router
13524            .oneshot(
13525                Request::builder()
13526                    .uri("/about")
13527                    .header("accept-encoding", "gzip")
13528                    .body(Body::empty())
13529                    .unwrap(),
13530            )
13531            .await
13532            .unwrap();
13533
13534        assert_eq!(response.status(), StatusCode::OK);
13535        assert_eq!(
13536            response
13537                .headers()
13538                .get(http::header::CONTENT_TYPE)
13539                .and_then(|v| v.to_str().ok()),
13540            Some("text/html; charset=utf-8"),
13541            "extensionless generated page must stay text/html, not octet-stream"
13542        );
13543        assert_eq!(
13544            response
13545                .headers()
13546                .get(http::header::CONTENT_ENCODING)
13547                .and_then(|v| v.to_str().ok()),
13548            Some("gzip"),
13549            "generated HTML page must be gzip-compressed"
13550        );
13551    }
13552
13553    /// A generated `.txt` route (`/robots.txt` -> `robots.txt/index.html`) is
13554    /// served as `text/plain; charset=utf-8` — derived from the request
13555    /// route's extension, not the on-disk `index.html` file name — and is
13556    /// gzip-compressed. Reading the MIME off the served file would mislabel it
13557    /// as text/html.
13558    #[tokio::test]
13559    async fn ssg_generated_txt_route_is_text_plain_and_compressed() {
13560        let body_text = format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(128));
13561        let tmp =
13562            create_ssg_dist(&[("/robots.txt", "robots.txt/index.html", body_text.as_bytes())]);
13563        let dist = tmp.path().join("dist");
13564
13565        let router = try_build_router_with_static(
13566            Vec::new(),
13567            &compression_enabled_config(),
13568            test_state(),
13569            Some(&dist),
13570        )
13571        .expect("router builds");
13572        let response = router
13573            .oneshot(
13574                Request::builder()
13575                    .uri("/robots.txt")
13576                    .header("accept-encoding", "gzip")
13577                    .body(Body::empty())
13578                    .unwrap(),
13579            )
13580            .await
13581            .unwrap();
13582
13583        assert_eq!(response.status(), StatusCode::OK);
13584        assert_eq!(
13585            response
13586                .headers()
13587                .get(http::header::CONTENT_TYPE)
13588                .and_then(|v| v.to_str().ok()),
13589            Some("text/plain; charset=utf-8"),
13590            "generated .txt route must be text/plain, derived from the route extension"
13591        );
13592        assert_eq!(
13593            response
13594                .headers()
13595                .get(http::header::CONTENT_ENCODING)
13596                .and_then(|v| v.to_str().ok()),
13597            Some("gzip"),
13598            "compressible text/plain route must be gzip-compressed"
13599        );
13600    }
13601
13602    /// A generated `.xml` route (`/sitemap.xml` -> `sitemap.xml/index.html`) is
13603    /// served as `application/xml`, derived from the request route's extension
13604    /// rather than the served `index.html` file name.
13605    #[tokio::test]
13606    async fn ssg_generated_xml_route_is_xml_mime() {
13607        let xml = format!(
13608            "<?xml version=\"1.0\"?><urlset>{}</urlset>",
13609            "<url><loc>https://example.com/</loc></url>".repeat(64)
13610        );
13611        let tmp = create_ssg_dist(&[("/sitemap.xml", "sitemap.xml/index.html", xml.as_bytes())]);
13612        let dist = tmp.path().join("dist");
13613
13614        let router = try_build_router_with_static(
13615            Vec::new(),
13616            &compression_enabled_config(),
13617            test_state(),
13618            Some(&dist),
13619        )
13620        .expect("router builds");
13621        let response = router
13622            .oneshot(
13623                Request::builder()
13624                    .uri("/sitemap.xml")
13625                    .header("accept-encoding", "gzip")
13626                    .body(Body::empty())
13627                    .unwrap(),
13628            )
13629            .await
13630            .unwrap();
13631
13632        assert_eq!(response.status(), StatusCode::OK);
13633        assert_eq!(
13634            response
13635                .headers()
13636                .get(http::header::CONTENT_TYPE)
13637                .and_then(|v| v.to_str().ok()),
13638            Some("application/xml"),
13639            "generated .xml route must be application/xml, derived from the route extension"
13640        );
13641    }
13642
13643    /// A generated HTML page whose slug merely *contains* a dot but ends in an
13644    /// UNRECOGNIZED extension (`/posts/release.v1` -> `release.v1/index.html`)
13645    /// stays `text/html; charset=utf-8` and is gzip-compressed. The `.v1`
13646    /// pseudo-extension is not an asset type, so the MIME must come from the
13647    /// served `index.html` — not be mislabeled octet-stream by a loose
13648    /// `contains('.')` heuristic.
13649    #[tokio::test]
13650    async fn ssg_dotted_slug_generated_page_stays_html_and_compressed() {
13651        let html = format!(
13652            "<html><body>{}</body></html>",
13653            "Release notes. ".repeat(128)
13654        );
13655        let tmp = create_ssg_dist(&[(
13656            "/posts/release.v1",
13657            "release.v1/index.html",
13658            html.as_bytes(),
13659        )]);
13660        let dist = tmp.path().join("dist");
13661
13662        let router = try_build_router_with_static(
13663            Vec::new(),
13664            &compression_enabled_config(),
13665            test_state(),
13666            Some(&dist),
13667        )
13668        .expect("router builds");
13669        let response = router
13670            .oneshot(
13671                Request::builder()
13672                    .uri("/posts/release.v1")
13673                    .header("accept-encoding", "gzip")
13674                    .body(Body::empty())
13675                    .unwrap(),
13676            )
13677            .await
13678            .unwrap();
13679
13680        assert_eq!(response.status(), StatusCode::OK);
13681        assert_eq!(
13682            response
13683                .headers()
13684                .get(http::header::CONTENT_TYPE)
13685                .and_then(|v| v.to_str().ok()),
13686            Some("text/html; charset=utf-8"),
13687            "dotted-slug generated page must stay text/html, not octet-stream"
13688        );
13689        assert_eq!(
13690            response
13691                .headers()
13692                .get(http::header::CONTENT_ENCODING)
13693                .and_then(|v| v.to_str().ok()),
13694            Some("gzip"),
13695            "dotted-slug generated HTML page must be gzip-compressed"
13696        );
13697    }
13698
13699    /// A generated HTML page whose slug contains an email-like dotted suffix
13700    /// (`/users/alice@example.com` -> `alice@example.com/index.html`) stays
13701    /// `text/html; charset=utf-8`. Neither `.com` nor the `@` makes it an
13702    /// asset, so the MIME comes from the served `index.html`.
13703    #[tokio::test]
13704    async fn ssg_email_slug_generated_page_stays_html() {
13705        let html = format!("<html><body>{}</body></html>", "Profile. ".repeat(64));
13706        let tmp = create_ssg_dist(&[(
13707            "/users/alice@example.com",
13708            "alice@example.com/index.html",
13709            html.as_bytes(),
13710        )]);
13711        let dist = tmp.path().join("dist");
13712
13713        let router = try_build_router_with_static(
13714            Vec::new(),
13715            &compression_enabled_config(),
13716            test_state(),
13717            Some(&dist),
13718        )
13719        .expect("router builds");
13720        let response = router
13721            .oneshot(
13722                Request::builder()
13723                    .uri("/users/alice@example.com")
13724                    .header("accept-encoding", "gzip")
13725                    .body(Body::empty())
13726                    .unwrap(),
13727            )
13728            .await
13729            .unwrap();
13730
13731        assert_eq!(response.status(), StatusCode::OK);
13732        assert_eq!(
13733            response
13734                .headers()
13735                .get(http::header::CONTENT_TYPE)
13736                .and_then(|v| v.to_str().ok()),
13737            Some("text/html; charset=utf-8"),
13738            "email-like dotted-slug generated page must stay text/html"
13739        );
13740    }
13741
13742    // ── #1832: the manifest's recorded Content-Type is authoritative ────────
13743    //
13744    // The six tests above pin the *derivation* that runs when a manifest
13745    // records nothing — a `dist/` built before #1832, or a hand-written one.
13746    // The tests below cover the recorded path, which removes the guess.
13747
13748    /// A recorded `Content-Type` is served verbatim, even when the route
13749    /// extension would have produced something else. `/feed.xml` would derive
13750    /// `application/xml`; the manifest says `application/rss+xml`, and the
13751    /// manifest wins.
13752    #[tokio::test]
13753    async fn ssg_recorded_content_type_overrides_route_extension() {
13754        let rss = format!(
13755            "<?xml version=\"1.0\"?><rss>{}</rss>",
13756            "<item><title>Post</title></item>".repeat(64)
13757        );
13758        let tmp = create_ssg_dist_with_types(&[(
13759            "/feed.xml",
13760            "feed.xml/index.html",
13761            rss.as_bytes(),
13762            Some("application/rss+xml"),
13763        )]);
13764        let dist = tmp.path().join("dist");
13765
13766        let router = try_build_router_with_static(
13767            Vec::new(),
13768            &compression_enabled_config(),
13769            test_state(),
13770            Some(&dist),
13771        )
13772        .expect("router builds");
13773        let response = router
13774            .oneshot(
13775                Request::builder()
13776                    .uri("/feed.xml")
13777                    .header("accept-encoding", "gzip")
13778                    .body(Body::empty())
13779                    .unwrap(),
13780            )
13781            .await
13782            .unwrap();
13783
13784        assert_eq!(response.status(), StatusCode::OK);
13785        assert_eq!(
13786            response
13787                .headers()
13788                .get(http::header::CONTENT_TYPE)
13789                .and_then(|v| v.to_str().ok()),
13790            Some("application/rss+xml"),
13791            "the manifest's recorded Content-Type must win over route-extension derivation"
13792        );
13793    }
13794
13795    /// A type no extension in the asset table maps to (`text/calendar`) is
13796    /// served from an extensionless route — impossible under the old
13797    /// serve-time heuristic, which could only ever have said `text/html` here.
13798    #[tokio::test]
13799    async fn ssg_recorded_content_type_serves_type_outside_the_asset_table() {
13800        let ics = format!(
13801            "BEGIN:VCALENDAR\r\n{}END:VCALENDAR\r\n",
13802            "BEGIN:VEVENT\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n".repeat(64)
13803        );
13804        let tmp = create_ssg_dist_with_types(&[(
13805            "/calendar",
13806            "calendar/index.html",
13807            ics.as_bytes(),
13808            Some("text/calendar; charset=utf-8"),
13809        )]);
13810        let dist = tmp.path().join("dist");
13811
13812        let router = try_build_router_with_static(
13813            Vec::new(),
13814            &compression_enabled_config(),
13815            test_state(),
13816            Some(&dist),
13817        )
13818        .expect("router builds");
13819        let response = router
13820            .oneshot(
13821                Request::builder()
13822                    .uri("/calendar")
13823                    .header("accept-encoding", "gzip")
13824                    .body(Body::empty())
13825                    .unwrap(),
13826            )
13827            .await
13828            .unwrap();
13829
13830        assert_eq!(response.status(), StatusCode::OK);
13831        assert_eq!(
13832            response
13833                .headers()
13834                .get(http::header::CONTENT_TYPE)
13835                .and_then(|v| v.to_str().ok()),
13836            Some("text/calendar; charset=utf-8"),
13837            "a recorded type outside the asset table must still be served"
13838        );
13839    }
13840
13841    /// A recorded *binary* type on an extensionless route is honoured, and the
13842    /// compression layer skips it — the recorded value drives transport
13843    /// decisions exactly as a derived one does.
13844    #[tokio::test]
13845    async fn ssg_recorded_binary_content_type_is_not_compressed() {
13846        let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
13847        bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13848        let tmp = create_ssg_dist_with_types(&[(
13849            "/badge",
13850            "badge/index.html",
13851            &bytes,
13852            Some("image/png"),
13853        )]);
13854        let dist = tmp.path().join("dist");
13855
13856        let router = try_build_router_with_static(
13857            Vec::new(),
13858            &compression_enabled_config(),
13859            test_state(),
13860            Some(&dist),
13861        )
13862        .expect("router builds");
13863        let response = router
13864            .oneshot(
13865                Request::builder()
13866                    .uri("/badge")
13867                    .header("accept-encoding", "gzip")
13868                    .body(Body::empty())
13869                    .unwrap(),
13870            )
13871            .await
13872            .unwrap();
13873
13874        assert_eq!(response.status(), StatusCode::OK);
13875        assert_eq!(
13876            response
13877                .headers()
13878                .get(http::header::CONTENT_TYPE)
13879                .and_then(|v| v.to_str().ok()),
13880            Some("image/png"),
13881            "recorded binary type must be honoured on an extensionless route"
13882        );
13883        assert_eq!(
13884            response.headers().get(http::header::CONTENT_ENCODING),
13885            None,
13886            "a recorded binary type must suppress compression just like a derived one"
13887        );
13888    }
13889
13890    /// A manifest whose recorded value cannot be a header (CRLF injection
13891    /// attempt from a hand-edited or tampered `dist/`) must not panic the
13892    /// request path and must not emit the injected header: the response falls
13893    /// back to the derived type.
13894    #[tokio::test]
13895    async fn ssg_header_illegal_recorded_content_type_falls_back_without_panicking() {
13896        let html = format!("<html><body>{}</body></html>", "About us. ".repeat(128));
13897        let tmp = create_ssg_dist_with_types(&[(
13898            "/about",
13899            "about/index.html",
13900            html.as_bytes(),
13901            Some("text/html\r\nX-Injected: yes"),
13902        )]);
13903        let dist = tmp.path().join("dist");
13904
13905        let router = try_build_router_with_static(
13906            Vec::new(),
13907            &compression_enabled_config(),
13908            test_state(),
13909            Some(&dist),
13910        )
13911        .expect("router builds");
13912        let response = router
13913            .oneshot(
13914                Request::builder()
13915                    .uri("/about")
13916                    .body(Body::empty())
13917                    .unwrap(),
13918            )
13919            .await
13920            .unwrap();
13921
13922        assert_eq!(response.status(), StatusCode::OK);
13923        assert_eq!(
13924            response
13925                .headers()
13926                .get(http::header::CONTENT_TYPE)
13927                .and_then(|v| v.to_str().ok()),
13928            Some("text/html; charset=utf-8"),
13929            "a header-illegal recorded value must fall back to the derived type"
13930        );
13931        assert!(
13932            response.headers().get("x-injected").is_none(),
13933            "a CRLF in the manifest must never become a response header"
13934        );
13935    }
13936
13937    /// M1 — issue evidence item 1 (fonts), on the *recorded* path. The route
13938    /// is extensionless and the file is `index.html`, so both legacy clues say
13939    /// `text/html` (compressible); only the recorded `font/woff2` stops the
13940    /// compression layer from re-encoding an already-compressed font.
13941    #[tokio::test]
13942    async fn ssg_recorded_woff2_type_suppresses_compression() {
13943        let mut bytes = b"wOF2".to_vec();
13944        bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13945        let tmp = create_ssg_dist_with_types(&[(
13946            "/inter",
13947            "inter/index.html",
13948            &bytes,
13949            Some("font/woff2"),
13950        )]);
13951        let dist = tmp.path().join("dist");
13952
13953        let router = try_build_router_with_static(
13954            Vec::new(),
13955            &compression_enabled_config(),
13956            test_state(),
13957            Some(&dist),
13958        )
13959        .expect("router builds");
13960        let response = router
13961            .oneshot(
13962                Request::builder()
13963                    .uri("/inter")
13964                    .header("accept-encoding", "gzip")
13965                    .body(Body::empty())
13966                    .unwrap(),
13967            )
13968            .await
13969            .unwrap();
13970
13971        assert_eq!(response.status(), StatusCode::OK);
13972        assert_eq!(
13973            response
13974                .headers()
13975                .get(http::header::CONTENT_TYPE)
13976                .and_then(|v| v.to_str().ok()),
13977            Some("font/woff2"),
13978        );
13979        assert_eq!(
13980            response.headers().get(http::header::CONTENT_ENCODING),
13981            None,
13982            "a recorded pre-compressed font type must suppress compression that              the derived text/html would have allowed"
13983        );
13984    }
13985
13986    /// M2 — the other direction: the recorded type *enables* compression that
13987    /// the derivation would have refused. `/data` → `data.bin` derives
13988    /// `application/octet-stream` (never compressed); the manifest says the
13989    /// bytes are text, and they are compressed accordingly.
13990    #[tokio::test]
13991    async fn ssg_recorded_text_type_enables_compression_derivation_would_refuse() {
13992        let text = "log line ".repeat(256);
13993        let tmp = create_ssg_dist_with_types(&[(
13994            "/data",
13995            "data.bin",
13996            text.as_bytes(),
13997            Some("text/plain; charset=utf-8"),
13998        )]);
13999        let dist = tmp.path().join("dist");
14000
14001        let router = try_build_router_with_static(
14002            Vec::new(),
14003            &compression_enabled_config(),
14004            test_state(),
14005            Some(&dist),
14006        )
14007        .expect("router builds");
14008        let response = router
14009            .oneshot(
14010                Request::builder()
14011                    .uri("/data")
14012                    .header("accept-encoding", "gzip")
14013                    .body(Body::empty())
14014                    .unwrap(),
14015            )
14016            .await
14017            .unwrap();
14018
14019        assert_eq!(response.status(), StatusCode::OK);
14020        assert_eq!(
14021            response
14022                .headers()
14023                .get(http::header::CONTENT_TYPE)
14024                .and_then(|v| v.to_str().ok()),
14025            Some("text/plain; charset=utf-8"),
14026        );
14027        assert_eq!(
14028            response
14029                .headers()
14030                .get(http::header::CONTENT_ENCODING)
14031                .and_then(|v| v.to_str().ok()),
14032            Some("gzip"),
14033            "the recorded text type must enable compression the octet-stream              derivation would have refused"
14034        );
14035    }
14036
14037    /// M3 — a manifest route whose file is missing on disk falls through to the
14038    /// dynamic router. The recorded type must not leak onto that response: the
14039    /// header belongs to a cache *hit*, not to a manifest entry.
14040    #[tokio::test]
14041    async fn ssg_manifest_route_with_missing_file_falls_through_to_dynamic_router() {
14042        async fn dynamic() -> impl axum::response::IntoResponse {
14043            (
14044                [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
14045                "<h1>dynamic</h1>".to_owned(),
14046            )
14047        }
14048        let route = Route {
14049            method: http::Method::GET,
14050            path: "/feed",
14051            handler: axum::routing::get(dynamic),
14052            name: "feed",
14053            api_doc: crate::openapi::ApiDoc {
14054                method: "GET",
14055                path: "/feed",
14056                operation_id: "feed",
14057                success_status: 200,
14058                ..Default::default()
14059            },
14060            api_version: None,
14061            sunset_opt_out: false,
14062            repository: None,
14063            idempotency: crate::route::RouteIdempotency::default(),
14064            timeout: crate::route::RouteTimeout::default(),
14065            criticality: crate::admission::Criticality::Default,
14066            seo: crate::seo::SeoRouteDefaults::EMPTY,
14067        };
14068
14069        let tmp = create_ssg_dist_with_types(&[(
14070            "/feed",
14071            "feed/index.html",
14072            b"<rss/>",
14073            Some("application/rss+xml"),
14074        )]);
14075        let dist = tmp.path().join("dist");
14076        // The manifest still lists the route; the generated file is gone.
14077        std::fs::remove_file(dist.join("feed/index.html")).expect("remove generated file");
14078
14079        let router = try_build_router_with_static(
14080            vec![route],
14081            &compression_enabled_config(),
14082            test_state(),
14083            Some(&dist),
14084        )
14085        .expect("router builds");
14086        let response = router
14087            .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
14088            .await
14089            .unwrap();
14090
14091        assert_eq!(response.status(), StatusCode::OK);
14092        assert_eq!(
14093            response
14094                .headers()
14095                .get(http::header::CONTENT_TYPE)
14096                .and_then(|v| v.to_str().ok()),
14097            Some("text/html; charset=utf-8"),
14098            "a manifest miss on disk must serve the dynamic handler's own type,              not the recorded one"
14099        );
14100        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
14101            .await
14102            .unwrap();
14103        assert_eq!(body.as_ref(), b"<h1>dynamic</h1>");
14104    }
14105
14106    /// M7 — a trailing-slash request normalizes to the same manifest entry and
14107    /// therefore carries the same recorded type. This is a case the old
14108    /// heuristic got wrong: `/robots.txt/` has no recognized extension on its
14109    /// final segment, so route-extension derivation fell through to
14110    /// `index.html` and said `text/html`.
14111    #[tokio::test]
14112    async fn ssg_trailing_slash_request_serves_recorded_content_type() {
14113        let body_text = format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(64));
14114        let tmp = create_ssg_dist_with_types(&[(
14115            "/robots.txt",
14116            "robots.txt/index.html",
14117            body_text.as_bytes(),
14118            Some("text/plain; charset=utf-8"),
14119        )]);
14120        let dist = tmp.path().join("dist");
14121
14122        let router = try_build_router_with_static(
14123            Vec::new(),
14124            &compression_enabled_config(),
14125            test_state(),
14126            Some(&dist),
14127        )
14128        .expect("router builds");
14129        let response = router
14130            .oneshot(
14131                Request::builder()
14132                    .uri("/robots.txt/")
14133                    .body(Body::empty())
14134                    .unwrap(),
14135            )
14136            .await
14137            .unwrap();
14138
14139        assert_eq!(response.status(), StatusCode::OK);
14140        assert_eq!(
14141            response
14142                .headers()
14143                .get(http::header::CONTENT_TYPE)
14144                .and_then(|v| v.to_str().ok()),
14145            Some("text/plain; charset=utf-8"),
14146            "the normalized path drives both the manifest lookup and the header"
14147        );
14148    }
14149
14150    /// A `HEAD` request on a recorded route carries the same recorded type as
14151    /// the `GET`, with no body.
14152    #[tokio::test]
14153    async fn ssg_head_request_carries_recorded_content_type() {
14154        let tmp = create_ssg_dist_with_types(&[(
14155            "/feed",
14156            "feed/index.html",
14157            b"<rss/>",
14158            Some("application/rss+xml"),
14159        )]);
14160        let dist = tmp.path().join("dist");
14161
14162        let router = try_build_router_with_static(
14163            Vec::new(),
14164            &compression_enabled_config(),
14165            test_state(),
14166            Some(&dist),
14167        )
14168        .expect("router builds");
14169        let response = router
14170            .oneshot(
14171                Request::builder()
14172                    .method(http::Method::HEAD)
14173                    .uri("/feed")
14174                    .body(Body::empty())
14175                    .unwrap(),
14176            )
14177            .await
14178            .unwrap();
14179
14180        assert_eq!(response.status(), StatusCode::OK);
14181        assert_eq!(
14182            response
14183                .headers()
14184                .get(http::header::CONTENT_TYPE)
14185                .and_then(|v| v.to_str().ok()),
14186            Some("application/rss+xml")
14187        );
14188        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
14189            .await
14190            .unwrap();
14191        assert!(body.is_empty(), "HEAD response must have an empty body");
14192    }
14193
14194    /// End-to-end: a real `render_static_routes` build feeds the real serve
14195    /// path. The three routes that each needed a serve-time heuristic
14196    /// correction during #1819 — a generated `.txt`, a generated `.xml`, and a
14197    /// dotted-slug HTML page — plus two the heuristic could never get right:
14198    /// `/feed`, extensionless but RSS, and `/notes.txt`, whose handler declares
14199    /// JSON in direct contradiction of its slug.
14200    ///
14201    /// It asserts the recorded manifest values *and* the served headers.
14202    /// Asserting only the headers would be weak: for the three #1819 routes the
14203    /// old heuristic produced the same answer, so the header alone proves
14204    /// nothing about recording. The manifest assertions are what the pre-#1832
14205    /// code cannot satisfy at all, and `/notes.txt` is what no derivation from
14206    /// the route or the file name could ever produce.
14207    #[tokio::test]
14208    #[allow(clippy::too_many_lines)] // five routes x (handler + build + serve assertions)
14209    async fn ssg_generated_manifest_round_trips_content_types_end_to_end() {
14210        async fn robots() -> impl axum::response::IntoResponse {
14211            (
14212                [(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
14213                format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(128)),
14214            )
14215        }
14216        // Bodies are padded past the compression size floor throughout, so the
14217        // `Content-Encoding` assertions below turn on the recorded *type* and
14218        // never on the body being too small to bother with.
14219        async fn sitemap() -> impl axum::response::IntoResponse {
14220            (
14221                [(http::header::CONTENT_TYPE, "application/xml")],
14222                format!(
14223                    "<?xml version=\"1.0\"?><urlset>{}</urlset>",
14224                    "<url><loc>https://example.com/</loc></url>".repeat(16)
14225                ),
14226            )
14227        }
14228        async fn release_notes() -> impl axum::response::IntoResponse {
14229            axum::response::Html(format!(
14230                "<html><body>{}</body></html>",
14231                "Release notes. ".repeat(128)
14232            ))
14233        }
14234        async fn feed() -> impl axum::response::IntoResponse {
14235            (
14236                [(http::header::CONTENT_TYPE, "application/rss+xml")],
14237                format!("<rss><channel>{}</channel></rss>", "<item/>".repeat(64)),
14238            )
14239        }
14240        // Slug says `.txt`, handler says JSON. No derivation from the route or
14241        // the served file name can produce this; only recording can.
14242        async fn notes() -> impl axum::response::IntoResponse {
14243            (
14244                [(http::header::CONTENT_TYPE, "application/json")],
14245                format!(
14246                    r#"{{"notes":[{}]}}"#,
14247                    r#""note","#.repeat(32).trim_end_matches(',')
14248                ),
14249            )
14250        }
14251        // The third #1819 case, on the recorded path: a slug whose final
14252        // segment contains dots and an `@` but which is plain HTML.
14253        async fn profile() -> impl axum::response::IntoResponse {
14254            axum::response::Html(format!(
14255                "<html><body>{}</body></html>",
14256                "Alice. ".repeat(128)
14257            ))
14258        }
14259        // The behaviour change the changelog calls out as breaking, proven at
14260        // the served-header level and not just in the manifest: an
14261        // *extensionless* route returning a bare `String` declares
14262        // `text/plain; charset=utf-8`, and that is now what is served — where
14263        // the pre-#1832 heuristic assumed `text/html` from `about/index.html`.
14264        async fn about() -> impl axum::response::IntoResponse {
14265            "About us. ".repeat(128)
14266        }
14267
14268        fn meta(path: &'static str, name: &'static str) -> crate::static_gen::StaticRouteMeta {
14269            crate::static_gen::StaticRouteMeta {
14270                path,
14271                name,
14272                revalidate: None,
14273                params_fn: None,
14274                seo: crate::seo::SeoRouteDefaults::EMPTY,
14275                content_type: None,
14276            }
14277        }
14278
14279        let build_router = axum::Router::new()
14280            .route("/robots.txt", axum::routing::get(robots))
14281            .route("/sitemap.xml", axum::routing::get(sitemap))
14282            .route("/posts/release.v1", axum::routing::get(release_notes))
14283            .route("/feed", axum::routing::get(feed))
14284            .route("/notes.txt", axum::routing::get(notes))
14285            .route("/users/alice@example.com", axum::routing::get(profile))
14286            .route("/about", axum::routing::get(about));
14287
14288        let tmp = tempfile::tempdir().expect("tempdir");
14289        let dist = tmp.path().join("dist");
14290        crate::static_gen::render_static_routes(
14291            build_router,
14292            &[
14293                meta("/robots.txt", "robots"),
14294                meta("/sitemap.xml", "sitemap"),
14295                meta("/posts/release.v1", "release_notes"),
14296                meta("/feed", "feed"),
14297                meta("/notes.txt", "notes"),
14298                meta("/users/alice@example.com", "profile"),
14299                meta("/about", "about"),
14300            ],
14301            &dist,
14302        )
14303        .await
14304        .expect("static build succeeds");
14305
14306        // Every generated page is stored as `<route>/index.html` — the layout
14307        // that made the serve-time file-name heuristic unreliable.
14308        for file in [
14309            "robots.txt/index.html",
14310            "sitemap.xml/index.html",
14311            "posts/release.v1/index.html",
14312            "feed/index.html",
14313            "notes.txt/index.html",
14314            "users/alice@example.com/index.html",
14315            "about/index.html",
14316        ] {
14317            assert!(dist.join(file).is_file(), "{file} must have been generated");
14318        }
14319
14320        // Route, recorded/served type, and whether the type makes the body
14321        // compressible — the recorded type's whole transport consequence.
14322        let expected = [
14323            ("/robots.txt", "text/plain; charset=utf-8", true),
14324            ("/sitemap.xml", "application/xml", true),
14325            ("/posts/release.v1", "text/html; charset=utf-8", true),
14326            ("/feed", "application/rss+xml", true),
14327            ("/notes.txt", "application/json", true),
14328            ("/users/alice@example.com", "text/html; charset=utf-8", true),
14329            ("/about", "text/plain; charset=utf-8", true),
14330        ];
14331
14332        // The build recorded the intended type for every route. This is the
14333        // assertion the pre-#1832 generator cannot satisfy — it wrote no type
14334        // at all — and it is what makes the header assertions below meaningful
14335        // rather than a restatement of the old heuristic.
14336        let manifest =
14337            crate::static_gen::StaticManifest::load(&dist.join("manifest.json")).expect("manifest");
14338        for (route, content_type, _) in expected {
14339            assert_eq!(
14340                manifest.routes[route].content_type.as_deref(),
14341                Some(content_type),
14342                "{route} must carry its declared type in the manifest"
14343            );
14344        }
14345
14346        for (route, content_type, compressible) in expected {
14347            let router = try_build_router_with_static(
14348                Vec::new(),
14349                &compression_enabled_config(),
14350                test_state(),
14351                Some(&dist),
14352            )
14353            .expect("router builds");
14354            let response = router
14355                .oneshot(
14356                    Request::builder()
14357                        .uri(route)
14358                        .header("accept-encoding", "gzip")
14359                        .body(Body::empty())
14360                        .unwrap(),
14361                )
14362                .await
14363                .unwrap();
14364
14365            assert_eq!(response.status(), StatusCode::OK, "{route}");
14366            assert_eq!(
14367                response
14368                    .headers()
14369                    .get(http::header::CONTENT_TYPE)
14370                    .and_then(|v| v.to_str().ok()),
14371                Some(content_type),
14372                "{route} must be served as the type its handler declared at build time"
14373            );
14374            assert_eq!(
14375                response
14376                    .headers()
14377                    .get(http::header::CONTENT_ENCODING)
14378                    .and_then(|v| v.to_str().ok())
14379                    == Some("gzip"),
14380                compressible,
14381                "{route}: the recorded type must drive compression negotiation"
14382            );
14383        }
14384    }
14385
14386    /// A dynamic fallback route (not in the manifest) is compressed the same way
14387    /// as SSG pages, confirming parity between static-first and dynamic
14388    /// responses.
14389    #[tokio::test]
14390    async fn ssg_dynamic_fallback_route_is_gzip_compressed() {
14391        async fn dynamic() -> impl axum::response::IntoResponse {
14392            (
14393                [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
14394                format!(
14395                    "<html><body>{}</body></html>",
14396                    "dynamic content ".repeat(64)
14397                ),
14398            )
14399        }
14400        let route = Route {
14401            method: http::Method::GET,
14402            path: "/dynamic",
14403            handler: axum::routing::get(dynamic),
14404            name: "dynamic",
14405            api_doc: crate::openapi::ApiDoc {
14406                method: "GET",
14407                path: "/dynamic",
14408                operation_id: "dynamic",
14409                success_status: 200,
14410                ..Default::default()
14411            },
14412            api_version: None,
14413            sunset_opt_out: false,
14414            repository: None,
14415            idempotency: crate::route::RouteIdempotency::default(),
14416            timeout: crate::route::RouteTimeout::default(),
14417            criticality: crate::admission::Criticality::Default,
14418            seo: crate::seo::SeoRouteDefaults::EMPTY,
14419        };
14420
14421        // Manifest does NOT contain /dynamic, so the request falls through to
14422        // the dynamic router.
14423        let tmp = create_ssg_dist(&[("/", "index.html", b"<h1>home</h1>")]);
14424        let dist = tmp.path().join("dist");
14425
14426        let router = try_build_router_with_static(
14427            vec![route],
14428            &compression_enabled_config(),
14429            test_state(),
14430            Some(&dist),
14431        )
14432        .expect("router builds");
14433        let response = router
14434            .oneshot(
14435                Request::builder()
14436                    .uri("/dynamic")
14437                    .header("accept-encoding", "gzip")
14438                    .body(Body::empty())
14439                    .unwrap(),
14440            )
14441            .await
14442            .unwrap();
14443
14444        assert_eq!(response.status(), StatusCode::OK);
14445        assert_eq!(
14446            response
14447                .headers()
14448                .get(http::header::CONTENT_ENCODING)
14449                .and_then(|v| v.to_str().ok()),
14450            Some("gzip"),
14451            "dynamic fallback route must be compressed just like SSG pages"
14452        );
14453    }
14454
14455    fn create_static_dist(revalidate: Option<u64>) -> tempfile::TempDir {
14456        let dir = tempfile::tempdir().expect("tempdir");
14457        let dist = dir.path().join("dist");
14458        std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
14459        std::fs::write(dist.join("index.html"), b"<h1>Home</h1>").expect("write index");
14460        std::fs::write(dist.join("about/index.html"), b"<h1>About</h1>").expect("write about");
14461
14462        let mut routes = std::collections::HashMap::new();
14463        routes.insert(
14464            "/".to_owned(),
14465            crate::static_gen::ManifestEntry::new("index.html".to_owned()),
14466        );
14467        routes.insert(
14468            "/about".to_owned(),
14469            crate::static_gen::ManifestEntry::new("about/index.html".to_owned())
14470                .with_revalidate(revalidate),
14471        );
14472
14473        let manifest = crate::static_gen::StaticManifest {
14474            generated_at: "2026-05-18T00:00:00Z".to_owned(),
14475            autumn_version: "0.5.0".to_owned(),
14476            routes,
14477        };
14478        let json = serde_json::to_string(&manifest).expect("serialize manifest");
14479        std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
14480        dir
14481    }
14482
14483    #[tokio::test]
14484    async fn static_serving_serves_get_request_inside_user_layers() {
14485        let tmp = create_static_dist(None);
14486        let dist = tmp.path().join("dist");
14487        let config = AutumnConfig::default();
14488
14489        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14490            .expect("router builds");
14491
14492        let response = router
14493            .oneshot(
14494                Request::builder()
14495                    .uri("/about")
14496                    .body(Body::empty())
14497                    .unwrap(),
14498            )
14499            .await
14500            .unwrap();
14501
14502        assert_eq!(response.status(), StatusCode::OK);
14503        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
14504            .await
14505            .unwrap();
14506        assert_eq!(body.as_ref(), b"<h1>About</h1>");
14507    }
14508
14509    #[tokio::test]
14510    async fn static_serving_serves_head_request() {
14511        let tmp = create_static_dist(None);
14512        let dist = tmp.path().join("dist");
14513        let config = AutumnConfig::default();
14514
14515        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14516            .expect("router builds");
14517
14518        let response = router
14519            .oneshot(
14520                Request::builder()
14521                    .method("HEAD")
14522                    .uri("/about")
14523                    .body(Body::empty())
14524                    .unwrap(),
14525            )
14526            .await
14527            .unwrap();
14528
14529        assert_eq!(response.status(), StatusCode::OK);
14530        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
14531            .await
14532            .unwrap();
14533        assert!(body.is_empty(), "HEAD response body should be empty");
14534    }
14535
14536    #[tokio::test]
14537    async fn a_markdown_head_of_a_static_page_mirrors_the_get() {
14538        let tmp = create_static_dist(None);
14539        let dist = tmp.path().join("dist");
14540        let config = AutumnConfig::default();
14541        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14542            .expect("router builds");
14543        let send = |method: &str| {
14544            router.clone().oneshot(
14545                Request::builder()
14546                    .method(method)
14547                    .uri("/about")
14548                    .header("accept", "text/markdown")
14549                    .body(Body::empty())
14550                    .unwrap(),
14551            )
14552        };
14553        let get = send("GET").await.unwrap();
14554        let head = send("HEAD").await.unwrap();
14555        let header = |res: &axum::response::Response, name: &str| {
14556            res.headers()
14557                .get(name)
14558                .map(|v| v.to_str().unwrap().to_owned())
14559        };
14560        assert!(
14561            header(&get, "content-type")
14562                .unwrap()
14563                .starts_with("text/markdown")
14564        );
14565        assert_eq!(header(&head, "content-type"), header(&get, "content-type"));
14566        assert_eq!(
14567            header(&head, "x-markdown-tokens"),
14568            header(&get, "x-markdown-tokens")
14569        );
14570        assert!(header(&head, "x-markdown-tokens").is_some());
14571        let head_length = header(&head, "content-length");
14572        let get_body = axum::body::to_bytes(get.into_body(), usize::MAX)
14573            .await
14574            .unwrap();
14575        // The `HEAD` names the length of the Markdown the `GET` sends.
14576        assert_eq!(head_length, Some(get_body.len().to_string()));
14577    }
14578
14579    #[tokio::test]
14580    async fn static_serving_normalizes_trailing_slash() {
14581        let tmp = create_static_dist(None);
14582        let dist = tmp.path().join("dist");
14583        let config = AutumnConfig::default();
14584
14585        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14586            .expect("router builds");
14587
14588        let response = router
14589            .oneshot(
14590                Request::builder()
14591                    .uri("/about/")
14592                    .body(Body::empty())
14593                    .unwrap(),
14594            )
14595            .await
14596            .unwrap();
14597
14598        assert_eq!(response.status(), StatusCode::OK);
14599    }
14600
14601    #[tokio::test]
14602    async fn static_serving_falls_through_for_unknown_route() {
14603        let tmp = create_static_dist(None);
14604        let dist = tmp.path().join("dist");
14605        let config = AutumnConfig::default();
14606
14607        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14608            .expect("router builds");
14609
14610        let response = router
14611            .oneshot(
14612                Request::builder()
14613                    .uri("/not-in-manifest")
14614                    .body(Body::empty())
14615                    .unwrap(),
14616            )
14617            .await
14618            .unwrap();
14619
14620        assert_eq!(response.status(), StatusCode::NOT_FOUND);
14621    }
14622
14623    #[tokio::test]
14624    async fn static_serving_skipped_when_no_manifest() {
14625        let tmp = tempfile::tempdir().expect("tempdir");
14626        let dist = tmp.path().join("dist");
14627        std::fs::create_dir_all(&dist).expect("mkdir dist");
14628        let config = AutumnConfig::default();
14629
14630        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14631            .expect("router builds even without manifest");
14632
14633        let response = router
14634            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
14635            .await
14636            .unwrap();
14637
14638        assert_eq!(response.status(), StatusCode::NOT_FOUND);
14639    }
14640
14641    #[tokio::test]
14642    async fn static_serving_with_isr_manifest_builds_successfully() {
14643        let tmp = create_static_dist(Some(3600));
14644        let dist = tmp.path().join("dist");
14645        let config = AutumnConfig::default();
14646
14647        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14648            .expect("router with ISR manifest should build");
14649
14650        let response = router
14651            .oneshot(
14652                Request::builder()
14653                    .uri("/about")
14654                    .body(Body::empty())
14655                    .unwrap(),
14656            )
14657            .await
14658            .unwrap();
14659
14660        assert_eq!(response.status(), StatusCode::OK);
14661    }
14662}
14663
14664#[cfg(test)]
14665mod trusted_host_tests {
14666    use super::*;
14667    use axum::body::Body;
14668    use http::Request;
14669    use tower::util::ServiceExt;
14670
14671    #[tokio::test]
14672    async fn trusted_host_allows_matching_and_blocks_nonmatching() {
14673        let mut cfg = AutumnConfig::default();
14674        cfg.security.trusted_hosts.hosts = vec!["example.com".into(), ".example.com".into()];
14675        let state = crate::state::AppState::for_test();
14676        let router = build_router(vec![], &cfg, state);
14677
14678        let ok = router
14679            .clone()
14680            .oneshot(
14681                Request::builder()
14682                    .uri("/nope")
14683                    .header("host", "api.example.com")
14684                    .body(Body::empty())
14685                    .unwrap(),
14686            )
14687            .await
14688            .unwrap();
14689        assert_eq!(ok.status(), StatusCode::NOT_FOUND);
14690
14691        let blocked = router
14692            .oneshot(
14693                Request::builder()
14694                    .uri("/nope")
14695                    .header("host", "evil.com")
14696                    .body(Body::empty())
14697                    .unwrap(),
14698            )
14699            .await
14700            .unwrap();
14701        assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
14702    }
14703
14704    #[tokio::test]
14705    async fn trusted_host_wildcard_allows_any_host() {
14706        let mut cfg = AutumnConfig::default();
14707        cfg.security.trusted_hosts.hosts = vec!["*".into()];
14708        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14709        let response = router
14710            .oneshot(
14711                Request::builder()
14712                    .uri("/nope")
14713                    .header("host", "anything.example")
14714                    .body(Body::empty())
14715                    .expect("request should build"),
14716            )
14717            .await
14718            .expect("request should complete");
14719        assert_eq!(response.status(), StatusCode::NOT_FOUND);
14720    }
14721
14722    #[tokio::test]
14723    async fn trusted_host_bypasses_probe_paths() {
14724        let mut cfg = AutumnConfig::default();
14725        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14726        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14727        let response = router
14728            .oneshot(
14729                Request::builder()
14730                    .uri("/actuator/health")
14731                    .header("host", "evil.com")
14732                    .body(Body::empty())
14733                    .expect("request should build"),
14734            )
14735            .await
14736            .expect("request should complete");
14737        assert_eq!(response.status(), StatusCode::OK);
14738    }
14739
14740    #[tokio::test]
14741    async fn trusted_host_bypasses_actuator_health_path() {
14742        let mut cfg = AutumnConfig::default();
14743        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14744        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14745        let response = router
14746            .oneshot(
14747                Request::builder()
14748                    .uri("/actuator/health")
14749                    .header("host", "evil.com")
14750                    .body(Body::empty())
14751                    .expect("request should build"),
14752            )
14753            .await
14754            .expect("request should complete");
14755        assert_eq!(response.status(), StatusCode::OK);
14756    }
14757
14758    /// `probe_bypass_paths()` is meant to be the single canonical definition
14759    /// of "which exact paths bypass admission-style gates" — `TrustedHostPolicy`
14760    /// and `StartupBarrierState` must derive their own bypass sets from it
14761    /// rather than each re-implementing the same list, so a change to
14762    /// `probe_bypass_paths()` (or `config.health.*`) is automatically
14763    /// reflected in both without touching either of them directly.
14764    #[test]
14765    fn probe_bypass_paths_is_the_single_source_for_trusted_host_and_startup_barrier() {
14766        let mut cfg = AutumnConfig::default();
14767        cfg.health.path = "/custom-health-check".into();
14768        let expected = probe_bypass_paths(&cfg);
14769        assert!(expected.contains(&"/custom-health-check".to_string()));
14770
14771        let trusted_host = TrustedHostPolicy::from_config(&cfg);
14772        for path in &expected {
14773            assert!(
14774                trusted_host.probe_bypass_paths.contains(path),
14775                "TrustedHostPolicy must derive its bypass set from probe_bypass_paths(): missing {path}"
14776            );
14777        }
14778
14779        let state = crate::state::AppState::for_test();
14780        let barrier = StartupBarrierState::from_config(&cfg, &state);
14781        for path in &expected {
14782            assert!(
14783                barrier.allows_path(path),
14784                "StartupBarrierState must derive its bypass set from probe_bypass_paths(): missing {path}"
14785            );
14786        }
14787    }
14788
14789    /// Regression guard (#1627): the `StartupBarrierState` allow-list, which is
14790    /// seeded from `actuator_endpoint_paths`, must permit the mutating
14791    /// `POST {prefix}/webhooks/replay` route to bypass the startup barrier. A
14792    /// prior fix removed the path from `actuator_endpoint_paths` to kill a
14793    /// phantom GET in the route listing, which also silently dropped it from
14794    /// this bypass set.
14795    #[cfg(feature = "http-client")]
14796    #[test]
14797    fn startup_barrier_allows_webhook_replay_post_path() {
14798        let mut cfg = AutumnConfig::default();
14799        cfg.actuator.sensitive = true;
14800        let state = crate::state::AppState::for_test();
14801        let barrier = StartupBarrierState::from_config(&cfg, &state);
14802        let replay_path =
14803            crate::actuator::actuator_route_path(&cfg.actuator.prefix, "/webhooks/replay");
14804        assert!(
14805            barrier.allows_path(&replay_path),
14806            "startup barrier must allow {replay_path} to bypass admission"
14807        );
14808    }
14809
14810    /// `/ready` must keep answering 200 while maintenance mode is ON (issue
14811    /// #1621, T1.25) — this pins an EXISTING, deliberate contract that a fleet
14812    /// deploy depends on, so nobody "fixes" it later.
14813    ///
14814    /// [`build_maintenance_layer`] wires `with_probe_paths(probe_bypass_paths(cfg))`,
14815    /// which includes `health.ready_path`. The consequence operators must be told
14816    /// about: **maintenance mode does not drain a host from a load balancer.** The
14817    /// host stays in rotation (its `/ready` is green) and serves 503s with
14818    /// `Retry-After` to real users — by design, because the alternative would make
14819    /// every LB-fronted deployment eject every host the moment maintenance is
14820    /// enabled. `autumn deploy status` therefore reports readiness and maintenance
14821    /// as SEPARATE columns, and `autumn deploy maintenance on` says so out loud.
14822    #[tokio::test]
14823    async fn ready_probe_stays_green_while_maintenance_mode_is_active() {
14824        let cfg = AutumnConfig::default();
14825        let state = crate::state::AppState::for_test();
14826        // Startup must be complete or `/ready` is 503 for an unrelated reason and
14827        // the assertion below would pass vacuously.
14828        state.probes().mark_startup_complete();
14829        let maintenance = crate::maintenance::MaintenanceState::new();
14830        maintenance.enable(crate::maintenance::MaintenanceConfig::default());
14831        state.insert_extension(maintenance);
14832        let router = build_router(vec![], &cfg, state);
14833
14834        let ready = router
14835            .clone()
14836            .oneshot(
14837                Request::builder()
14838                    .uri(&cfg.health.ready_path)
14839                    .body(Body::empty())
14840                    .unwrap(),
14841            )
14842            .await
14843            .unwrap();
14844        assert_eq!(
14845            ready.status(),
14846            StatusCode::OK,
14847            "/ready must BYPASS maintenance mode: it is the load balancer's health \
14848             signal, and gating it would eject every host from rotation the moment \
14849             maintenance is enabled (#1621)"
14850        );
14851
14852        // …while ordinary traffic IS gated, proving the layer is actually active.
14853        let app_route = router
14854            .oneshot(
14855                Request::builder()
14856                    .uri("/some-app-route")
14857                    .body(Body::empty())
14858                    .unwrap(),
14859            )
14860            .await
14861            .unwrap();
14862        assert_eq!(
14863            app_route.status(),
14864            StatusCode::SERVICE_UNAVAILABLE,
14865            "maintenance mode must still gate non-probe traffic"
14866        );
14867    }
14868
14869    #[tokio::test]
14870    async fn trusted_host_release_rejects_loopback_unless_listed() {
14871        let mut cfg = AutumnConfig {
14872            profile: Some("prod".into()),
14873            ..AutumnConfig::default()
14874        };
14875        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14876        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14877        let response = router
14878            .oneshot(
14879                Request::builder()
14880                    .uri("/nope")
14881                    .header("host", "localhost")
14882                    .body(Body::empty())
14883                    .expect("request should build"),
14884            )
14885            .await
14886            .expect("request should complete");
14887        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
14888    }
14889
14890    #[tokio::test]
14891    async fn trusted_host_uses_uri_authority_when_host_header_missing() {
14892        let mut cfg = AutumnConfig::default();
14893        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14894        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14895        let response = router
14896            .oneshot(
14897                Request::builder()
14898                    .uri("http://EXAMPLE.COM/nope")
14899                    .body(Body::empty())
14900                    .expect("request should build"),
14901            )
14902            .await
14903            .expect("request should complete");
14904        assert_eq!(response.status(), StatusCode::NOT_FOUND);
14905    }
14906
14907    #[tokio::test]
14908    async fn trusted_host_accepts_bracketed_ipv6_loopback_in_dev() {
14909        let cfg = AutumnConfig::default();
14910        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14911        let response = router
14912            .oneshot(
14913                Request::builder()
14914                    .uri("/nope")
14915                    .header("host", "[::1]:3000")
14916                    .body(Body::empty())
14917                    .expect("request should build"),
14918            )
14919            .await
14920            .expect("request should complete");
14921        assert_eq!(response.status(), StatusCode::NOT_FOUND);
14922    }
14923
14924    #[tokio::test]
14925    async fn trusted_host_matching_is_case_insensitive() {
14926        let mut cfg = AutumnConfig::default();
14927        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14928        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14929        let response = router
14930            .oneshot(
14931                Request::builder()
14932                    .uri("/nope")
14933                    .header("host", "EXAMPLE.COM")
14934                    .body(Body::empty())
14935                    .expect("request should build"),
14936            )
14937            .await
14938            .expect("request should complete");
14939        assert_eq!(response.status(), StatusCode::NOT_FOUND);
14940    }
14941
14942    #[tokio::test]
14943    async fn trusted_host_rejects_malformed_port() {
14944        let mut cfg = AutumnConfig::default();
14945        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14946        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14947        let response = router
14948            .oneshot(
14949                Request::builder()
14950                    .uri("/nope")
14951                    .header("host", "example.com:abc")
14952                    .body(Body::empty())
14953                    .expect("request should build"),
14954            )
14955            .await
14956            .expect("request should complete");
14957        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
14958    }
14959
14960    #[tokio::test]
14961    async fn trusted_host_rejects_empty_port_suffix() {
14962        let mut cfg = AutumnConfig::default();
14963        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14964        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14965        let response = router
14966            .oneshot(
14967                Request::builder()
14968                    .uri("/nope")
14969                    .header("host", "example.com:")
14970                    .body(Body::empty())
14971                    .expect("request should build"),
14972            )
14973            .await
14974            .expect("request should complete");
14975        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
14976    }
14977
14978    #[tokio::test]
14979    async fn trusted_host_rejects_bracketed_reg_name() {
14980        let mut cfg = AutumnConfig::default();
14981        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14982        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14983        let response = router
14984            .oneshot(
14985                Request::builder()
14986                    .uri("/nope")
14987                    .header("host", "[example.com]")
14988                    .body(Body::empty())
14989                    .expect("request should build"),
14990            )
14991            .await
14992            .expect("request should complete");
14993        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
14994    }
14995    #[tokio::test]
14996    async fn trusted_host_configured_trailing_dot_matches_normalized_host() {
14997        let mut cfg = AutumnConfig::default();
14998        cfg.security.trusted_hosts.hosts = vec!["example.com.".into()];
14999        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15000        let response = router
15001            .oneshot(
15002                Request::builder()
15003                    .uri("/nope")
15004                    .header("host", "example.com")
15005                    .body(Body::empty())
15006                    .expect("request should build"),
15007            )
15008            .await
15009            .expect("request should complete");
15010        assert_eq!(response.status(), StatusCode::NOT_FOUND);
15011    }
15012
15013    #[tokio::test]
15014    async fn trusted_host_accepts_trailing_dot_fqdn() {
15015        let mut cfg = AutumnConfig::default();
15016        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
15017        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15018        let response = router
15019            .oneshot(
15020                Request::builder()
15021                    .uri("/nope")
15022                    .header("host", "example.com.")
15023                    .body(Body::empty())
15024                    .expect("request should build"),
15025            )
15026            .await
15027            .expect("request should complete");
15028        assert_eq!(response.status(), StatusCode::NOT_FOUND);
15029    }
15030
15031    /// The per-request host is borrowed when it is already lowercase and
15032    /// dot-free (what browsers send), and copied only to lowercase it. The
15033    /// text compared is the same either way.
15034    #[test]
15035    fn normalize_trusted_host_borrows_an_already_normal_host() {
15036        use std::borrow::Cow;
15037
15038        assert!(matches!(
15039            normalize_trusted_host("example.com"),
15040            Some(Cow::Borrowed("example.com"))
15041        ));
15042        assert!(matches!(
15043            normalize_trusted_host("example.com."),
15044            Some(Cow::Borrowed("example.com"))
15045        ));
15046        assert!(matches!(
15047            normalize_trusted_host("::1"),
15048            Some(Cow::Borrowed("::1"))
15049        ));
15050        assert!(matches!(
15051            normalize_trusted_host("API.Example.COM"),
15052            Some(Cow::Owned(_))
15053        ));
15054        assert_eq!(
15055            normalize_trusted_host("API.Example.COM..").as_deref(),
15056            Some("api.example.com")
15057        );
15058        assert!(normalize_trusted_host("...").is_none());
15059        assert!(normalize_trusted_host("").is_none());
15060    }
15061
15062    #[tokio::test]
15063    async fn trusted_host_mixed_case_trailing_dot_matches_subdomain_rule() {
15064        let mut cfg = AutumnConfig::default();
15065        cfg.security.trusted_hosts.hosts = vec![".example.com".into()];
15066        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15067        let response = router
15068            .oneshot(
15069                Request::builder()
15070                    .uri("/nope")
15071                    .header("host", "API.Example.COM.")
15072                    .body(Body::empty())
15073                    .expect("request should build"),
15074            )
15075            .await
15076            .expect("request should complete");
15077        assert_eq!(response.status(), StatusCode::NOT_FOUND);
15078    }
15079
15080    /// `hosts = ["*"]` still rejects a host that is nothing but dots: a host
15081    /// source is present but normalises to nothing, so there is no host to
15082    /// allow.
15083    #[tokio::test]
15084    async fn trusted_host_wildcard_still_rejects_dots_only_host() {
15085        let mut cfg = AutumnConfig::default();
15086        cfg.security.trusted_hosts.hosts = vec!["*".into()];
15087        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15088        let response = router
15089            .oneshot(
15090                Request::builder()
15091                    .uri("/nope")
15092                    .header("host", "...")
15093                    .body(Body::empty())
15094                    .expect("request should build"),
15095            )
15096            .await
15097            .expect("request should complete");
15098        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
15099    }
15100
15101    #[tokio::test]
15102    async fn trusted_host_bypasses_custom_probe_path_only() {
15103        let mut cfg = AutumnConfig::default();
15104        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
15105        cfg.health.path = "/healthz".into();
15106        cfg.health.startup_path = "/startupz".into();
15107        cfg.health.ready_path = "/readyz".into();
15108        cfg.health.live_path = "/livez".into();
15109        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15110
15111        let bypassed = router
15112            .clone()
15113            .oneshot(
15114                Request::builder()
15115                    .uri("/healthz")
15116                    .header("host", "evil.com")
15117                    .body(Body::empty())
15118                    .expect("request should build"),
15119            )
15120            .await
15121            .expect("request should complete");
15122        assert_eq!(bypassed.status(), StatusCode::OK);
15123
15124        let not_bypassed = router
15125            .oneshot(
15126                Request::builder()
15127                    .uri("/health")
15128                    .header("host", "evil.com")
15129                    .body(Body::empty())
15130                    .expect("request should build"),
15131            )
15132            .await
15133            .expect("request should complete");
15134        assert_eq!(not_bypassed.status(), StatusCode::BAD_REQUEST);
15135    }
15136
15137    #[tokio::test]
15138    async fn trusted_host_does_not_bypass_non_get_probe_path_requests() {
15139        let mut cfg = AutumnConfig::default();
15140        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
15141        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15142        let response = router
15143            .oneshot(
15144                Request::builder()
15145                    .method("POST")
15146                    .uri("/health")
15147                    .header("host", "evil.com")
15148                    .body(Body::empty())
15149                    .expect("request should build"),
15150            )
15151            .await
15152            .expect("request should complete");
15153        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
15154    }
15155
15156    // ── Global body-size limit (AC: DefaultBodyLimit covers all content types) ──
15157
15158    #[tokio::test]
15159    async fn apply_upload_middleware_rejects_oversized_json_body() {
15160        let mut config = AutumnConfig::default();
15161        config.security.upload.max_request_size_bytes = 100; // 100-byte limit
15162
15163        let base: axum::Router<AppState> = axum::Router::new().route(
15164            "/data",
15165            axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
15166        );
15167        let router =
15168            apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
15169
15170        // 200 bytes of JSON-shaped content exceeds the 100-byte cap.
15171        let big_body = "x".repeat(200);
15172        let response = router
15173            .oneshot(
15174                Request::builder()
15175                    .method("POST")
15176                    .uri("/data")
15177                    .header("content-type", "application/json")
15178                    .body(Body::from(big_body))
15179                    .unwrap(),
15180            )
15181            .await
15182            .unwrap();
15183
15184        assert_eq!(
15185            response.status(),
15186            StatusCode::PAYLOAD_TOO_LARGE,
15187            "oversized body must be rejected with 413 regardless of content type"
15188        );
15189    }
15190
15191    #[tokio::test]
15192    async fn apply_upload_middleware_accepts_body_within_limit() {
15193        let mut config = AutumnConfig::default();
15194        config.security.upload.max_request_size_bytes = 1024;
15195
15196        let base: axum::Router<AppState> = axum::Router::new().route(
15197            "/data",
15198            axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
15199        );
15200        let router =
15201            apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
15202
15203        let response = router
15204            .oneshot(
15205                Request::builder()
15206                    .method("POST")
15207                    .uri("/data")
15208                    .header("content-type", "application/json")
15209                    .body(Body::from("hello"))
15210                    .unwrap(),
15211            )
15212            .await
15213            .unwrap();
15214
15215        assert_eq!(response.status(), StatusCode::OK);
15216    }
15217
15218    // ── Per-request timeout (AC: 503 on timeout, metrics, WARN log) ──────────
15219
15220    /// Empty per-route override table (no route-level overrides).
15221    fn no_route_timeouts() -> RouteTimeoutTable {
15222        std::sync::Arc::new(std::collections::HashMap::new())
15223    }
15224
15225    /// Build a single-entry override table for `GET <path>` (the method the
15226    /// unit-test routers below register).
15227    fn get_route_timeouts(path: &str, timeout: crate::route::RouteTimeout) -> RouteTimeoutTable {
15228        let mut by_method = std::collections::HashMap::new();
15229        by_method.insert(http::Method::GET, timeout);
15230        let mut table = std::collections::HashMap::new();
15231        table.insert(path.to_owned(), by_method);
15232        std::sync::Arc::new(table)
15233    }
15234
15235    #[tokio::test(start_paused = true)]
15236    async fn request_timeout_returns_503_when_exceeded() {
15237        let mut config = AutumnConfig::default();
15238        config.server.timeouts.request_timeout_ms = Some(100);
15239
15240        let state = crate::state::AppState::for_test();
15241        let router: axum::Router<AppState> = axum::Router::new().route(
15242            "/slow",
15243            axum::routing::get(|| async {
15244                // This sleep is much longer than the 100ms timeout.
15245                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15246                "ok"
15247            }),
15248        );
15249
15250        // Place timeout inner to RequestIdLayer (matches apply_middleware ordering).
15251        let router =
15252            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15253                .layer(RequestIdLayer::default())
15254                .with_state(state);
15255
15256        let response = router
15257            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15258            .await
15259            .unwrap();
15260
15261        assert_eq!(
15262            response.status(),
15263            StatusCode::SERVICE_UNAVAILABLE,
15264            "a slow handler must trigger 503"
15265        );
15266        assert_eq!(
15267            response
15268                .headers()
15269                .get("content-type")
15270                .and_then(|v| v.to_str().ok()),
15271            Some("application/problem+json"),
15272            "timeout response must use Problem Details content type"
15273        );
15274    }
15275
15276    /// `MakeWriter` collecting a scoped `fmt` subscriber's output, so a test
15277    /// can assert on the rendered `autumn::timeout` warn.
15278    #[derive(Clone)]
15279    struct LogBuf(Arc<std::sync::Mutex<Vec<u8>>>);
15280    impl std::io::Write for LogBuf {
15281        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
15282            if let Ok(mut held) = self.0.lock() {
15283                held.extend_from_slice(bytes);
15284            }
15285            Ok(bytes.len())
15286        }
15287        fn flush(&mut self) -> std::io::Result<()> {
15288            Ok(())
15289        }
15290    }
15291    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogBuf {
15292        type Writer = Self;
15293        fn make_writer(&'a self) -> Self::Writer {
15294            self.clone()
15295        }
15296    }
15297    impl LogBuf {
15298        /// Install a plain-text `fmt` subscriber writing here, for this thread.
15299        fn install() -> (Self, tracing::subscriber::DefaultGuard) {
15300            let buf = Self(Arc::default());
15301            let subscriber = tracing_subscriber::fmt()
15302                .with_writer(buf.clone())
15303                .with_ansi(false)
15304                .finish();
15305            (buf, tracing::subscriber::set_default(subscriber))
15306        }
15307
15308        fn text(&self) -> String {
15309            String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
15310        }
15311    }
15312
15313    /// The timeout warn names the matched route template, never the raw path,
15314    /// as a `&str` field (quoted in text output). The layer keeps the
15315    /// `MatchedPath` itself until the deadline passes instead of a copy.
15316    #[tokio::test(start_paused = true)]
15317    async fn request_timeout_warn_names_the_route_template() {
15318        let (logs, _guard) = LogBuf::install();
15319        let mut config = AutumnConfig::default();
15320        config.server.timeouts.request_timeout_ms = Some(100);
15321
15322        let state = crate::state::AppState::for_test();
15323        let router: axum::Router<AppState> = axum::Router::new().route(
15324            "/slow/{id}",
15325            axum::routing::get(|| async {
15326                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15327                "ok"
15328            }),
15329        );
15330        let router =
15331            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15332                .layer(RequestIdLayer::default())
15333                .with_state(state);
15334
15335        let response = router
15336            .oneshot(
15337                Request::builder()
15338                    .uri("/slow/7")
15339                    .body(Body::empty())
15340                    .unwrap(),
15341            )
15342            .await
15343            .unwrap();
15344        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15345        let request_id = response.headers()["x-request-id"].to_str().unwrap();
15346
15347        let out = logs.text();
15348        assert!(out.contains("inbound request exceeded deadline"), "{out}");
15349        assert!(out.contains(r#"route="/slow/{id}""#), "{out}");
15350        assert!(
15351            out.contains(&format!(r#"request_id="{request_id}""#)),
15352            "{out}"
15353        );
15354        assert!(!out.contains("/slow/7"), "{out}");
15355    }
15356
15357    /// A caller deadline of 0 answers before the handler runs, on the other
15358    /// call site of the same warn: it too names the route template.
15359    #[tokio::test(start_paused = true)]
15360    async fn zero_caller_deadline_warn_names_the_route_template() {
15361        let (logs, _guard) = LogBuf::install();
15362        let mut config = AutumnConfig::default();
15363        config.server.timeouts.request_timeout_ms = Some(100);
15364        config.server.timeouts.accept_deadline_header = true;
15365
15366        let state = crate::state::AppState::for_test();
15367        let router: axum::Router<AppState> =
15368            axum::Router::new().route("/slow/{id}", axum::routing::get(|| async { "ok" }));
15369        let router =
15370            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15371                .with_state(state);
15372
15373        let response = router
15374            .oneshot(
15375                Request::builder()
15376                    .uri("/slow/7")
15377                    .header(crate::deadline::DEADLINE_HEADER, "0")
15378                    .body(Body::empty())
15379                    .unwrap(),
15380            )
15381            .await
15382            .unwrap();
15383        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15384
15385        let out = logs.text();
15386        assert!(out.contains(r#"route="/slow/{id}""#), "{out}");
15387        assert!(!out.contains("/slow/7"), "{out}");
15388    }
15389
15390    /// In a fault scope, the timeout takes only the time left: a fault wait
15391    /// before this layer (the Redis session load) uses part of it (#3071).
15392    #[tokio::test(start_paused = true)]
15393    async fn request_timeout_takes_the_time_left_in_a_fault_scope() {
15394        let mut config = AutumnConfig::default();
15395        config.server.timeouts.request_timeout_ms = Some(1000);
15396
15397        let state = crate::state::AppState::for_test();
15398        let router: axum::Router<AppState> = axum::Router::new().route(
15399            "/slow",
15400            axum::routing::get(|| async {
15401                tokio::time::sleep(std::time::Duration::from_millis(300)).await;
15402                "ok"
15403            }),
15404        );
15405        let router =
15406            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15407                .with_state(state);
15408
15409        let request = Request::builder().uri("/slow").body(Body::empty()).unwrap();
15410        let response = crate::fault_injection::with_request_budget(
15411            std::time::Duration::from_millis(200),
15412            router.oneshot(request),
15413        )
15414        .await
15415        .unwrap();
15416
15417        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15418    }
15419
15420    #[tokio::test(start_paused = true)]
15421    async fn request_timeout_increments_metric() {
15422        let mut config = AutumnConfig::default();
15423        config.server.timeouts.request_timeout_ms = Some(100);
15424
15425        let state = crate::state::AppState::for_test();
15426        let router: axum::Router<AppState> = axum::Router::new().route(
15427            "/slow",
15428            axum::routing::get(|| async {
15429                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15430                "ok"
15431            }),
15432        );
15433
15434        let router =
15435            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15436                .layer(RequestIdLayer::default())
15437                .with_state(state.clone());
15438
15439        router
15440            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15441            .await
15442            .unwrap();
15443
15444        let snap = state.metrics.snapshot();
15445        assert_eq!(
15446            snap.http.request_timeouts_total, 1,
15447            "autumn_request_timeouts_total must be incremented on timeout"
15448        );
15449    }
15450
15451    #[tokio::test(start_paused = true)]
15452    async fn render_deadline_exempt_marker_skips_timeout() {
15453        let mut config = AutumnConfig::default();
15454        config.server.timeouts.request_timeout_ms = Some(100);
15455
15456        let state = crate::state::AppState::for_test();
15457        let router: axum::Router<AppState> = axum::Router::new().route(
15458            "/slow",
15459            axum::routing::get(|| async {
15460                // Far longer than the 100ms deadline; the paused clock advances
15461                // automatically once the task is otherwise idle.
15462                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15463                "ok"
15464            }),
15465        );
15466
15467        let router =
15468            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15469                .layer(RequestIdLayer::default())
15470                .with_state(state);
15471
15472        // A live inbound request (no marker) is bounded by the deadline -> 503.
15473        let live = router
15474            .clone()
15475            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15476            .await
15477            .unwrap();
15478        assert_eq!(
15479            live.status(),
15480            StatusCode::SERVICE_UNAVAILABLE,
15481            "a live request to a slow handler must still time out"
15482        );
15483
15484        // An internal build/ISR render carrying `RenderDeadlineExempt` is exempt
15485        // and runs to completion.
15486        let exempt = router
15487            .oneshot(
15488                Request::builder()
15489                    .uri("/slow")
15490                    .extension(crate::static_gen::RenderDeadlineExempt)
15491                    .body(Body::empty())
15492                    .unwrap(),
15493            )
15494            .await
15495            .unwrap();
15496        assert_eq!(
15497            exempt.status(),
15498            StatusCode::OK,
15499            "the build/ISR render marker must exempt the request from the deadline"
15500        );
15501    }
15502
15503    #[tokio::test(start_paused = true)]
15504    async fn request_timeout_503_mirrors_cors_headers() {
15505        let mut config = AutumnConfig::default();
15506        config.server.timeouts.request_timeout_ms = Some(100);
15507        // CORS is configured with a concrete allowlist (the reflected-origin path).
15508        config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
15509        config.cors.allow_credentials = true;
15510
15511        let state = crate::state::AppState::for_test();
15512        let router: axum::Router<AppState> = axum::Router::new().route(
15513            "/slow",
15514            axum::routing::get(|| async {
15515                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15516                "ok"
15517            }),
15518        );
15519
15520        // `mirror_cors = true`, matching the main ingress stack where the timeout
15521        // layer is outside `CorsLayer` and the 503 would otherwise be opaque.
15522        let router =
15523            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), true)
15524                .layer(RequestIdLayer::default())
15525                .with_state(state);
15526
15527        let response = router
15528            .oneshot(
15529                Request::builder()
15530                    .uri("/slow")
15531                    .header("origin", "https://app.example.com")
15532                    .body(Body::empty())
15533                    .unwrap(),
15534            )
15535            .await
15536            .unwrap();
15537
15538        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15539        assert_eq!(
15540            response
15541                .headers()
15542                .get("access-control-allow-origin")
15543                .and_then(|v| v.to_str().ok()),
15544            Some("https://app.example.com"),
15545            "an allowed origin must be reflected on the timeout 503 so browsers can read it"
15546        );
15547        assert_eq!(
15548            response
15549                .headers()
15550                .get("access-control-allow-credentials")
15551                .and_then(|v| v.to_str().ok()),
15552            Some("true"),
15553            "credentials flag must be mirrored when configured"
15554        );
15555        assert!(
15556            response
15557                .headers()
15558                .get_all("vary")
15559                .iter()
15560                .any(|v| v.to_str().is_ok_and(|s| s.eq_ignore_ascii_case("origin"))),
15561            "a reflected origin must carry Vary: origin"
15562        );
15563    }
15564
15565    #[tokio::test(start_paused = true)]
15566    async fn request_timeout_503_omits_cors_for_disallowed_origin() {
15567        let mut config = AutumnConfig::default();
15568        config.server.timeouts.request_timeout_ms = Some(100);
15569        config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
15570
15571        let state = crate::state::AppState::for_test();
15572        let router: axum::Router<AppState> = axum::Router::new().route(
15573            "/slow",
15574            axum::routing::get(|| async {
15575                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15576                "ok"
15577            }),
15578        );
15579
15580        let router =
15581            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), true)
15582                .layer(RequestIdLayer::default())
15583                .with_state(state);
15584
15585        let response = router
15586            .oneshot(
15587                Request::builder()
15588                    .uri("/slow")
15589                    .header("origin", "https://evil.example.com")
15590                    .body(Body::empty())
15591                    .unwrap(),
15592            )
15593            .await
15594            .unwrap();
15595
15596        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15597        assert!(
15598            response
15599                .headers()
15600                .get("access-control-allow-origin")
15601                .is_none(),
15602            "a disallowed origin must not be reflected, mirroring CorsLayer"
15603        );
15604    }
15605
15606    #[tokio::test(start_paused = true)]
15607    async fn request_timeout_response_includes_request_id_header() {
15608        let mut config = AutumnConfig::default();
15609        config.server.timeouts.request_timeout_ms = Some(100);
15610
15611        let state = crate::state::AppState::for_test();
15612        let router: axum::Router<AppState> = axum::Router::new().route(
15613            "/slow",
15614            axum::routing::get(|| async {
15615                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15616                "ok"
15617            }),
15618        );
15619
15620        let router =
15621            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15622                .layer(RequestIdLayer::default())
15623                .with_state(state);
15624
15625        let response = router
15626            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15627            .await
15628            .unwrap();
15629
15630        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15631        // X-Request-Id is added by RequestIdLayer on the egress path.
15632        assert!(
15633            response.headers().contains_key("x-request-id"),
15634            "503 response must carry the X-Request-Id header"
15635        );
15636
15637        // The body must be a well-formed Problem Details document.
15638        let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
15639            .await
15640            .unwrap();
15641        let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
15642        assert_eq!(body["status"], 503);
15643    }
15644
15645    #[tokio::test]
15646    async fn request_timeout_disabled_when_none() {
15647        let config = AutumnConfig::default(); // request_timeout_ms = None
15648
15649        let state = crate::state::AppState::for_test();
15650        let router: axum::Router<AppState> =
15651            axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
15652
15653        let router =
15654            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15655                .with_state(state);
15656
15657        let response = router
15658            .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
15659            .await
15660            .unwrap();
15661
15662        assert_eq!(response.status(), StatusCode::OK);
15663    }
15664
15665    #[tokio::test]
15666    async fn request_timeout_zero_treated_as_disabled() {
15667        let mut config = AutumnConfig::default();
15668        config.server.timeouts.request_timeout_ms = Some(0); // 0 = disabled
15669
15670        let state = crate::state::AppState::for_test();
15671        let router: axum::Router<AppState> =
15672            axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
15673
15674        let router =
15675            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15676                .with_state(state);
15677
15678        let response = router
15679            .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
15680            .await
15681            .unwrap();
15682
15683        assert_eq!(response.status(), StatusCode::OK);
15684    }
15685
15686    // Exercises the warn branch when no RequestIdLayer is present (no request_id
15687    // extension), keeping coverage of the `None` request-id arm.
15688    #[tokio::test(start_paused = true)]
15689    async fn request_timeout_503_without_request_id_layer() {
15690        let mut config = AutumnConfig::default();
15691        config.server.timeouts.request_timeout_ms = Some(100);
15692
15693        let state = crate::state::AppState::for_test();
15694        let router: axum::Router<AppState> = axum::Router::new().route(
15695            "/slow",
15696            axum::routing::get(|| async {
15697                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15698                "ok"
15699            }),
15700        );
15701
15702        // No RequestIdLayer — exercises the `request_id: None` branch in
15703        // `RequestTimeoutService`.
15704        let router =
15705            apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15706                .with_state(state);
15707
15708        let response = router
15709            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15710            .await
15711            .unwrap();
15712
15713        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15714    }
15715
15716    // AC4: a per-route `Override` extends the deadline so a known-slow route
15717    // outlives the (smaller) global timeout.
15718    #[tokio::test(start_paused = true)]
15719    async fn request_timeout_per_route_override_extends_deadline() {
15720        let mut config = AutumnConfig::default();
15721        config.server.timeouts.request_timeout_ms = Some(100); // tight global
15722
15723        let state = crate::state::AppState::for_test();
15724        let router: axum::Router<AppState> = axum::Router::new().route(
15725            "/export",
15726            axum::routing::get(|| async {
15727                // Longer than the 100ms global, shorter than the 10s override.
15728                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
15729                "report"
15730            }),
15731        );
15732
15733        let table = get_route_timeouts(
15734            "/export",
15735            crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10)),
15736        );
15737        let router = apply_request_timeout_middleware(router, &config, &state, table, false)
15738            .with_state(state);
15739
15740        let response = router
15741            .oneshot(
15742                Request::builder()
15743                    .uri("/export")
15744                    .body(Body::empty())
15745                    .unwrap(),
15746            )
15747            .await
15748            .unwrap();
15749
15750        assert_eq!(
15751            response.status(),
15752            StatusCode::OK,
15753            "the override must let the slow route complete past the global deadline"
15754        );
15755    }
15756
15757    // AC4: a per-route `Disabled` exempts the route from the global timeout.
15758    #[tokio::test(start_paused = true)]
15759    async fn request_timeout_per_route_disabled_exempts_route() {
15760        let mut config = AutumnConfig::default();
15761        config.server.timeouts.request_timeout_ms = Some(100);
15762
15763        let state = crate::state::AppState::for_test();
15764        let router: axum::Router<AppState> = axum::Router::new().route(
15765            "/stream",
15766            axum::routing::get(|| async {
15767                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
15768                "done"
15769            }),
15770        );
15771
15772        let table = get_route_timeouts("/stream", crate::route::RouteTimeout::Disabled);
15773        let router = apply_request_timeout_middleware(router, &config, &state, table, false)
15774            .with_state(state.clone());
15775
15776        let response = router
15777            .oneshot(
15778                Request::builder()
15779                    .uri("/stream")
15780                    .body(Body::empty())
15781                    .unwrap(),
15782            )
15783            .await
15784            .unwrap();
15785
15786        assert_eq!(response.status(), StatusCode::OK);
15787        assert_eq!(
15788            state.metrics.snapshot().http.request_timeouts_total,
15789            0,
15790            "an exempt route must not record a timeout"
15791        );
15792    }
15793
15794    // AC4: an `Override` enables the layer even when the global timeout is off.
15795    #[tokio::test(start_paused = true)]
15796    async fn request_timeout_override_active_when_global_disabled() {
15797        let config = AutumnConfig::default(); // global timeout disabled (None)
15798
15799        let state = crate::state::AppState::for_test();
15800        let router: axum::Router<AppState> = axum::Router::new().route(
15801            "/export",
15802            axum::routing::get(|| async {
15803                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15804                "report"
15805            }),
15806        );
15807
15808        let table = get_route_timeouts(
15809            "/export",
15810            crate::route::RouteTimeout::Override(std::time::Duration::from_millis(100)),
15811        );
15812        let router = apply_request_timeout_middleware(router, &config, &state, table, false)
15813            .with_state(state);
15814
15815        let response = router
15816            .oneshot(
15817                Request::builder()
15818                    .uri("/export")
15819                    .body(Body::empty())
15820                    .unwrap(),
15821            )
15822            .await
15823            .unwrap();
15824
15825        assert_eq!(
15826            response.status(),
15827            StatusCode::SERVICE_UNAVAILABLE,
15828            "a per-route override must be enforced even with the global timeout off"
15829        );
15830    }
15831
15832    #[test]
15833    fn build_route_timeout_table_is_empty_without_routes() {
15834        // End-to-end keying (top-level + nested groups) is covered by the
15835        // `request_timeout` integration tests via the macro attribute; here we
15836        // assert the no-route base case yields a zero-overhead empty table.
15837        let table = build_route_timeout_table(&[], &[], &AutumnConfig::default());
15838        assert!(table.is_empty(), "no routes ⇒ empty override table");
15839    }
15840
15841    /// Build a minimal `Route` carrying just the fields `build_route_timeout_table`
15842    /// reads (method, path, timeout); the handler is a no-op.
15843    fn timeout_route(
15844        method: http::Method,
15845        path: &'static str,
15846        timeout: crate::route::RouteTimeout,
15847    ) -> Route {
15848        async fn noop() -> &'static str {
15849            "ok"
15850        }
15851        Route {
15852            method,
15853            path,
15854            handler: axum::routing::get(noop),
15855            name: "noop",
15856            api_doc: crate::openapi::ApiDoc::default(),
15857            repository: None,
15858            idempotency: crate::route::RouteIdempotency::Direct,
15859            timeout,
15860            criticality: crate::admission::Criticality::Default,
15861            api_version: None,
15862            sunset_opt_out: false,
15863            seo: crate::seo::SeoRouteDefaults::EMPTY,
15864        }
15865    }
15866
15867    #[test]
15868    fn build_route_timeout_table_normalizes_method_aliases() {
15869        let override_10s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10));
15870        let routes = vec![
15871            // A GET handler also serves HEAD in axum.
15872            timeout_route(http::Method::GET, "/export", override_10s),
15873            // A `#[ws]` route records the synthetic `WS` method but the upgrade
15874            // arrives as GET.
15875            timeout_route(
15876                http::Method::from_bytes(b"WS").unwrap(),
15877                "/live",
15878                crate::route::RouteTimeout::Disabled,
15879            ),
15880            // A non-aliased method keys only itself.
15881            timeout_route(http::Method::POST, "/submit", override_10s),
15882        ];
15883
15884        let table = build_route_timeout_table(&routes, &[], &AutumnConfig::default());
15885
15886        // GET override is reachable via both GET and HEAD.
15887        let export = table.get("/export").expect("/export keyed");
15888        assert_eq!(export.get(&http::Method::GET), Some(&override_10s));
15889        assert_eq!(
15890            export.get(&http::Method::HEAD),
15891            Some(&override_10s),
15892            "a GET override must also cover the HEAD alias axum serves"
15893        );
15894
15895        // WS override is reachable via the GET the upgrade actually uses, and is
15896        // NOT left under the synthetic `WS` method the lookup never sees.
15897        let live = table.get("/live").expect("/live keyed");
15898        assert_eq!(
15899            live.get(&http::Method::GET),
15900            Some(&crate::route::RouteTimeout::Disabled),
15901            "a WS override must be keyed under the GET the upgrade arrives as"
15902        );
15903        assert!(
15904            live.get(&http::Method::from_bytes(b"WS").unwrap())
15905                .is_none(),
15906            "the synthetic WS method is never seen at lookup time"
15907        );
15908
15909        // A non-aliased method keys only itself — no HEAD bleed.
15910        let submit = table.get("/submit").expect("/submit keyed");
15911        assert_eq!(submit.get(&http::Method::POST), Some(&override_10s));
15912        assert!(submit.get(&http::Method::HEAD).is_none());
15913    }
15914
15915    #[test]
15916    fn build_route_timeout_table_keys_scoped_root_by_axum_matched_path() {
15917        // A scoped group whose prefix carries a trailing slash mounts its `/`
15918        // child at "/api/" in axum (verified by
15919        // `join_nested_path_matches_axum_matched_path`), so the override must be
15920        // keyed there — not at "/api" — or the runtime `MatchedPath` lookup
15921        // misses and the per-route timeout is silently never enforced.
15922        let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
15923        let make_group = |prefix: &str| crate::app::ScopedGroup {
15924            prefix: prefix.to_owned(),
15925            routes: vec![timeout_route(http::Method::GET, "/", override_5s)],
15926            source: crate::route_listing::RouteSource::User,
15927            layer_guarded: false,
15928            apply_layer: Box::new(|r| r),
15929        };
15930
15931        let table =
15932            build_route_timeout_table(&[], &[make_group("/api/")], &AutumnConfig::default());
15933        assert_eq!(
15934            table.get("/api/").and_then(|m| m.get(&http::Method::GET)),
15935            Some(&override_5s),
15936            "trailing-slash scoped root must key the override at /api/"
15937        );
15938        assert!(
15939            table.get("/api").is_none(),
15940            "the stripped /api key would never match the runtime lookup"
15941        );
15942
15943        // The no-trailing-slash form still keys at "/api".
15944        let table = build_route_timeout_table(&[], &[make_group("/api")], &AutumnConfig::default());
15945        assert_eq!(
15946            table.get("/api").and_then(|m| m.get(&http::Method::GET)),
15947            Some(&override_5s),
15948        );
15949    }
15950
15951    #[test]
15952    fn build_route_criticality_table_keys_scoped_routes_and_head() {
15953        let mut shed = timeout_route(
15954            http::Method::GET,
15955            "/export",
15956            crate::route::RouteTimeout::Inherit,
15957        );
15958        shed.criticality = crate::admission::Criticality::Sheddable;
15959        let plain = timeout_route(
15960            http::Method::POST,
15961            "/export",
15962            crate::route::RouteTimeout::Inherit,
15963        );
15964        let group = crate::app::ScopedGroup {
15965            prefix: "/admin".to_owned(),
15966            routes: vec![{
15967                let mut r = timeout_route(
15968                    http::Method::POST,
15969                    "/pay",
15970                    crate::route::RouteTimeout::Inherit,
15971                );
15972                r.criticality = crate::admission::Criticality::Critical;
15973                r
15974            }],
15975            source: crate::route_listing::RouteSource::User,
15976            layer_guarded: false,
15977            apply_layer: Box::new(|r| r),
15978        };
15979        let table =
15980            build_route_criticality_table(&[shed, plain], &[group], &AutumnConfig::default());
15981        let get = |p: &str, m: http::Method| table.get(p).and_then(|t| t.get(&m)).copied();
15982        assert_eq!(
15983            get("/export", http::Method::GET),
15984            Some(crate::admission::Criticality::Sheddable)
15985        );
15986        assert_eq!(
15987            get("/export", http::Method::HEAD),
15988            Some(crate::admission::Criticality::Sheddable),
15989            "a GET route is also served for HEAD"
15990        );
15991        assert_eq!(
15992            get("/export", http::Method::POST),
15993            None,
15994            "default gets no entry"
15995        );
15996        assert_eq!(
15997            get("/admin/pay", http::Method::POST),
15998            Some(crate::admission::Criticality::Critical)
15999        );
16000        assert!(
16001            build_route_criticality_table(&[], &[], &AutumnConfig::default()).is_empty(),
16002            "no criticality routes: empty table, so a passive layer"
16003        );
16004    }
16005
16006    /// Regression (#3183 review): with the global timeout off, a table of only
16007    /// `timeout = "off"` routes still installs the timeout service under
16008    /// adaptive admission, so those routes get `InboundDeadline::Off` and give
16009    /// no latency sample.
16010    #[test]
16011    fn timeout_off_only_routes_install_the_service_for_adaptive_admission() {
16012        let mut by_method = std::collections::HashMap::new();
16013        by_method.insert(http::Method::GET, crate::route::RouteTimeout::Disabled);
16014        let mut table = std::collections::HashMap::new();
16015        table.insert("/poll".to_owned(), by_method);
16016        let table: RouteTimeoutTable = Arc::new(table);
16017        let state = AppState::for_test();
16018        let build = |config: &AutumnConfig| {
16019            build_request_timeout_settings(config, &state, table.clone(), false).is_some()
16020        };
16021
16022        let mut config = AutumnConfig::default();
16023        config.server.timeouts.request_timeout_ms = None;
16024        assert!(!build(&config), "static admission: nothing to do");
16025
16026        config.server.admission.mode = crate::config::AdmissionMode::Adaptive;
16027        assert!(build(&config), "adaptive admission needs the Off marker");
16028    }
16029
16030    /// Codex review (P1): `Router::nest("/{locale}", ...)` mounts each locale
16031    /// under a literal segment, so a locale-prefixed request's `MatchedPath`
16032    /// is `/{locale}{path}` verbatim — the timeout table must carry an entry
16033    /// there too, or a `timeout = "off"` long-poll route gets silently
16034    /// cancelled by the global deadline once locale-prefix routing is on.
16035    #[cfg(feature = "i18n")]
16036    #[test]
16037    fn build_route_timeout_table_expands_entries_under_each_locale_prefix() {
16038        let disabled = crate::route::RouteTimeout::Disabled;
16039        let routes = vec![timeout_route(http::Method::GET, "/events", disabled)];
16040        let mut config = AutumnConfig::default();
16041        config.i18n.locale_prefix_enabled = true;
16042        config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
16043
16044        let table = build_route_timeout_table(&routes, &[], &config);
16045
16046        for path in ["/events", "/en/events", "/es/events"] {
16047            assert_eq!(
16048                table.get(path).and_then(|m| m.get(&http::Method::GET)),
16049                Some(&disabled),
16050                "expected a timeout override at {path}"
16051            );
16052        }
16053    }
16054
16055    /// A route excluded from locale-prefix routing never gets nested under
16056    /// `/{locale}`, so its timeout table entry must stay bare-path only.
16057    #[cfg(feature = "i18n")]
16058    #[test]
16059    fn build_route_timeout_table_does_not_expand_excluded_routes() {
16060        let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
16061        let routes = vec![timeout_route(http::Method::GET, "/api/keys", override_5s)];
16062        let mut config = AutumnConfig::default();
16063        config.i18n.locale_prefix_enabled = true;
16064        config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
16065        config.i18n.locale_prefix_exclude = vec!["/api".to_owned()];
16066
16067        let table = build_route_timeout_table(&routes, &[], &config);
16068
16069        assert_eq!(
16070            table
16071                .get("/api/keys")
16072                .and_then(|m| m.get(&http::Method::GET)),
16073            Some(&override_5s)
16074        );
16075        assert!(
16076            table.get("/en/api/keys").is_none(),
16077            "an excluded route must not gain a locale-prefixed timeout entry"
16078        );
16079    }
16080
16081    /// Locale-prefix routing off (the default) must never expand the table,
16082    /// regardless of what `supported_locales` happens to contain.
16083    #[cfg(feature = "i18n")]
16084    #[test]
16085    fn build_route_timeout_table_does_not_expand_when_locale_prefix_disabled() {
16086        let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
16087        let routes = vec![timeout_route(http::Method::GET, "/events", override_5s)];
16088        let mut config = AutumnConfig::default();
16089        assert!(!config.i18n.locale_prefix_enabled);
16090        config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
16091
16092        let table = build_route_timeout_table(&routes, &[], &config);
16093
16094        assert_eq!(table.len(), 1, "only the bare-path entry should exist");
16095        assert!(table.get("/en/events").is_none());
16096    }
16097
16098    // ----------------------------------------------------------------------
16099    // static_gate: middleware that runs before the static cache lookup (#848)
16100    // ----------------------------------------------------------------------
16101
16102    /// Build a `CustomLayerRegistration` wrapping a `from_fn` gate that
16103    /// redirects (302 → /login) any request lacking an `x-authed` header.
16104    fn redirect_gate_registration() -> crate::app::CustomLayerRegistration {
16105        let gate = axum::middleware::from_fn(
16106            |req: axum::extract::Request, next: axum::middleware::Next| async move {
16107                if req.headers().contains_key("x-authed") {
16108                    next.run(req).await
16109                } else {
16110                    http::Response::builder()
16111                        .status(StatusCode::FOUND)
16112                        .header(http::header::LOCATION, "/login")
16113                        .body(Body::empty())
16114                        .unwrap()
16115                }
16116            },
16117        );
16118        crate::app::CustomLayerRegistration {
16119            type_id: std::any::TypeId::of::<()>(),
16120            type_name: "redirect_gate",
16121            layer: tower::util::BoxCloneSyncServiceLayer::new(gate),
16122        }
16123    }
16124
16125    // ----------------------------------------------------------------------
16126    // #2405: the static render path drains user layers (#2405)
16127    // ----------------------------------------------------------------------
16128
16129    /// A plain user layer must drain out of the static render entirely: the
16130    /// build and ISR regeneration both render through the pre-layer router,
16131    /// so the recorded Content-Type and the body on disk are the handler's
16132    /// own.
16133    #[test]
16134    fn static_render_partition_drains_user_layers() {
16135        let (kept, drained) =
16136            partition_custom_layers_for_static_render(vec![redirect_gate_registration()]);
16137        assert!(
16138            kept.is_empty(),
16139            "user layers must not survive the static-render drain"
16140        );
16141        assert_eq!(drained.len(), 1);
16142        assert_eq!(drained[0].type_name, "redirect_gate");
16143    }
16144
16145    /// The ambient-locale layer reads the session, so it must stay on the
16146    /// inner (pre-layer) router even though every other custom layer drains
16147    /// (#1384). The i18n bundle `Extension` must stay too:
16148    /// `Locale::from_request_parts` reads the bundle from that extension
16149    /// exclusively, and the static build drops the drained set outright, so
16150    /// draining it would make translated `#[static_get]` handlers write raw
16151    /// translation keys into `dist`. Only the `TypeId`s matter to the
16152    /// partition.
16153    #[cfg(feature = "i18n")]
16154    #[test]
16155    fn static_render_partition_keeps_the_ambient_locale_layer() {
16156        let ambient = crate::app::CustomLayerRegistration {
16157            type_id: std::any::TypeId::of::<crate::i18n::AmbientLocaleLayer>(),
16158            type_name: "ambient_locale",
16159            layer: tower::util::BoxCloneSyncServiceLayer::new(axum::middleware::from_fn(
16160                |req: axum::extract::Request, next: axum::middleware::Next| async move {
16161                    next.run(req).await
16162                },
16163            )),
16164        };
16165        let bundle_ext = crate::app::CustomLayerRegistration {
16166            type_id: std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
16167            type_name: "i18n_bundle_extension",
16168            layer: tower::util::BoxCloneSyncServiceLayer::new(axum::middleware::from_fn(
16169                |req: axum::extract::Request, next: axum::middleware::Next| async move {
16170                    next.run(req).await
16171                },
16172            )),
16173        };
16174        let (kept, drained) = partition_custom_layers_for_static_render(vec![
16175            redirect_gate_registration(),
16176            bundle_ext,
16177            ambient,
16178        ]);
16179        assert_eq!(
16180            kept.len(),
16181            2,
16182            "the ambient-locale layer and the i18n bundle extension must stay"
16183        );
16184        assert_eq!(kept[0].type_name, "i18n_bundle_extension");
16185        assert_eq!(kept[1].type_name, "ambient_locale");
16186        assert_eq!(drained.len(), 1, "the user layer must drain");
16187        assert_eq!(drained[0].type_name, "redirect_gate");
16188    }
16189
16190    /// Create a minimal dist dir with `manifest.json` mapping `/` → an
16191    /// `index.html` containing the marker text, and return the temp handle
16192    /// plus the dist path.
16193    fn build_cached_dist(marker: &str) -> (tempfile::TempDir, std::path::PathBuf) {
16194        let tmp = tempfile::tempdir().expect("tempdir");
16195        let dist = tmp.path().join("dist");
16196        std::fs::create_dir_all(&dist).expect("create dist");
16197        std::fs::write(dist.join("index.html"), marker).expect("write index.html");
16198        let mut routes = std::collections::HashMap::new();
16199        routes.insert(
16200            "/".to_owned(),
16201            crate::static_gen::ManifestEntry::new("index.html".to_owned()),
16202        );
16203        let manifest = crate::static_gen::StaticManifest {
16204            generated_at: "2026-06-14T00:00:00Z".to_owned(),
16205            autumn_version: "0.3.0".to_owned(),
16206            routes,
16207        };
16208        std::fs::write(
16209            dist.join("manifest.json"),
16210            serde_json::to_string(&manifest).expect("serialize manifest"),
16211        )
16212        .expect("write manifest");
16213        (tmp, dist)
16214    }
16215
16216    fn ctx_with_static_gate(gate: crate::app::CustomLayerRegistration) -> RouterContext {
16217        RouterContext {
16218            exception_filters: Vec::new(),
16219            scoped_groups: Vec::new(),
16220            merge_routers: Vec::new(),
16221            nest_routers: Vec::new(),
16222            declared_routes: Vec::new(),
16223            custom_layers: Vec::new(),
16224            static_gate_layers: vec![gate],
16225            #[cfg(feature = "maud")]
16226            error_page_renderer: None,
16227            session_store: None,
16228            #[cfg(feature = "openapi")]
16229            openapi: None,
16230            #[cfg(feature = "mcp")]
16231            mcp: None,
16232            #[cfg(feature = "webmcp")]
16233            webmcp: None,
16234        }
16235    }
16236
16237    /// #3071: on the SSG/ISG path the fault layers go outside the static
16238    /// cache, so a route fault reaches a cached page, and only once.
16239    #[tokio::test]
16240    async fn route_fault_reaches_a_cached_static_page() {
16241        let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
16242        let mut config = AutumnConfig {
16243            profile: Some("staging".to_owned()),
16244            ..AutumnConfig::default()
16245        };
16246        config.fault_injection.enabled = true;
16247        config.fault_injection.faults = vec![crate::fault_injection::FaultRule::new(
16248            crate::fault_injection::FaultTarget::Route,
16249            crate::fault_injection::FaultKind::Error,
16250            1.0,
16251        )];
16252        let state = crate::state::AppState::for_test();
16253        let app = super::try_build_router_with_static_inner(
16254            Vec::new(),
16255            &config,
16256            state.clone(),
16257            Some(dist.as_path()),
16258            ctx_with_static_gate(redirect_gate_registration()),
16259        )
16260        .expect("router builds");
16261
16262        let response = app
16263            .oneshot(
16264                Request::builder()
16265                    .uri("/")
16266                    .header("x-authed", "1")
16267                    .body(Body::empty())
16268                    .unwrap(),
16269            )
16270            .await
16271            .unwrap();
16272        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
16273        let faults = state
16274            .extension::<crate::fault_injection::FaultInjection>()
16275            .expect("the handle is installed");
16276        assert_eq!(faults.snapshot().injected, 1, "one fault, not two");
16277    }
16278
16279    #[tokio::test]
16280    async fn static_gate_runs_before_cached_static_page() {
16281        // A cached SSG page exists at "/". The static_gate must intercept the
16282        // request BEFORE the static-first middleware serves the pre-rendered
16283        // HTML, redirecting unauthenticated visitors.
16284        let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
16285        let config = AutumnConfig::default();
16286        let ctx = ctx_with_static_gate(redirect_gate_registration());
16287
16288        let app = super::try_build_router_with_static_inner(
16289            Vec::new(),
16290            &config,
16291            crate::state::AppState::for_test(),
16292            Some(dist.as_path()),
16293            ctx,
16294        )
16295        .expect("router builds");
16296
16297        // Unauthenticated: gate fires before the cached page is served.
16298        let unauthed = app
16299            .clone()
16300            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
16301            .await
16302            .unwrap();
16303        assert_eq!(
16304            unauthed.status(),
16305            StatusCode::FOUND,
16306            "static_gate must redirect before the cached page is served"
16307        );
16308        assert_eq!(
16309            unauthed.headers().get(http::header::LOCATION).unwrap(),
16310            "/login"
16311        );
16312
16313        // Authenticated: gate passes through and the cached HTML is served.
16314        let authed = app
16315            .oneshot(
16316                Request::builder()
16317                    .uri("/")
16318                    .header("x-authed", "1")
16319                    .body(Body::empty())
16320                    .unwrap(),
16321            )
16322            .await
16323            .unwrap();
16324        assert_eq!(authed.status(), StatusCode::OK);
16325        let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
16326            .await
16327            .unwrap();
16328        assert!(
16329            String::from_utf8_lossy(&body).contains("cached"),
16330            "authenticated request should receive the cached page"
16331        );
16332    }
16333
16334    #[tokio::test]
16335    async fn static_gate_runs_in_dynamic_mode() {
16336        // With no dist dir, the same gate must still run as the outermost
16337        // middleware so auth-gating code is portable across SSG and dynamic
16338        // modes.
16339        async fn dynamic_handler() -> &'static str {
16340            "dynamic"
16341        }
16342        let route = Route {
16343            method: http::Method::GET,
16344            path: "/",
16345            handler: axum::routing::get(dynamic_handler),
16346            name: "root",
16347            api_doc: crate::openapi::ApiDoc {
16348                method: "GET",
16349                path: "/",
16350                operation_id: "root",
16351                success_status: 200,
16352                ..Default::default()
16353            },
16354            repository: None,
16355            idempotency: crate::route::RouteIdempotency::Direct,
16356            timeout: crate::route::RouteTimeout::Inherit,
16357            criticality: crate::admission::Criticality::Default,
16358            seo: crate::seo::SeoRouteDefaults::EMPTY,
16359            api_version: None,
16360            sunset_opt_out: false,
16361        };
16362        let config = AutumnConfig::default();
16363        let ctx = ctx_with_static_gate(redirect_gate_registration());
16364
16365        let app = super::try_build_router_with_static_inner(
16366            vec![route],
16367            &config,
16368            crate::state::AppState::for_test(),
16369            None,
16370            ctx,
16371        )
16372        .expect("router builds");
16373
16374        let unauthed = app
16375            .clone()
16376            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
16377            .await
16378            .unwrap();
16379        assert_eq!(unauthed.status(), StatusCode::FOUND);
16380
16381        let authed = app
16382            .oneshot(
16383                Request::builder()
16384                    .uri("/")
16385                    .header("x-authed", "1")
16386                    .body(Body::empty())
16387                    .unwrap(),
16388            )
16389            .await
16390            .unwrap();
16391        assert_eq!(authed.status(), StatusCode::OK);
16392        let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
16393            .await
16394            .unwrap();
16395        assert_eq!(String::from_utf8_lossy(&body), "dynamic");
16396    }
16397
16398    /// Build a `CustomLayerRegistration` — the runtime shape of an
16399    /// `AppBuilder::layer(...)` registration — wrapping a `from_fn` gate that
16400    /// rejects a request to `/secret` lacking the `x-api-key: secret123`
16401    /// credential, but lets every other path (crucially, `/mcp` itself)
16402    /// through unconditionally. Stands in for a real, realistic global
16403    /// auth layer scoped by path prefix (protect `/admin/*`+`/secret/*`,
16404    /// leave `/mcp` and public routes alone) — exactly the shape that makes
16405    /// the outer `/mcp` envelope request itself pass the layer while the
16406    /// route the layer actually protects would not.
16407    #[cfg(feature = "mcp")]
16408    fn deny_unless_api_key_for_secret_path_registration() -> crate::app::CustomLayerRegistration {
16409        let gate = axum::middleware::from_fn(
16410            |req: axum::extract::Request, next: axum::middleware::Next| async move {
16411                let protected = req.uri().path() == "/secret";
16412                let has_credential = req.headers().get("x-api-key").and_then(|v| v.to_str().ok())
16413                    == Some("secret123");
16414                if !protected || has_credential {
16415                    next.run(req).await
16416                } else {
16417                    http::Response::builder()
16418                        .status(StatusCode::UNAUTHORIZED)
16419                        .body(Body::empty())
16420                        .unwrap()
16421                }
16422            },
16423        );
16424        crate::app::CustomLayerRegistration {
16425            type_id: std::any::TypeId::of::<()>(),
16426            type_name: "deny_unless_api_key_for_secret_path",
16427            layer: tower::util::BoxCloneSyncServiceLayer::new(gate),
16428        }
16429    }
16430
16431    /// A `dist` dir with a valid but empty manifest: enough to route through
16432    /// the SSG/ISG branch of `try_build_router_with_static_inner` without any
16433    /// route actually being served from the static cache.
16434    #[cfg(feature = "mcp")]
16435    fn build_empty_dist() -> (tempfile::TempDir, std::path::PathBuf) {
16436        let tmp = tempfile::tempdir().expect("tempdir");
16437        let dist = tmp.path().join("dist");
16438        std::fs::create_dir_all(&dist).expect("create dist");
16439        let manifest = crate::static_gen::StaticManifest {
16440            generated_at: "2026-09-07T00:00:00Z".to_owned(),
16441            autumn_version: "0.3.0".to_owned(),
16442            routes: std::collections::HashMap::new(),
16443        };
16444        std::fs::write(
16445            dist.join("manifest.json"),
16446            serde_json::to_string(&manifest).expect("serialize manifest"),
16447        )
16448        .expect("write manifest");
16449        (tmp, dist)
16450    }
16451
16452    /// 🛡 Warden (2026-09-07): regression test for an MCP authn-bypass —
16453    /// see the ledger at
16454    /// `docs/security/2026-09-07-mcp-custom-layer-static-mode/README.md`.
16455    ///
16456    /// Threat model: against an app that authenticates a path with a global
16457    /// `AppBuilder::layer(...)` Tower layer scoped by request path — a real,
16458    /// unrestricted, documented way to add a runtime check ("wrap every
16459    /// request... cross-cutting concerns that genuinely apply everywhere",
16460    /// `docs/guide/middleware.md`; nothing in the type system distinguishes
16461    /// "auth" from "cross-cutting concern") — and separately opts into
16462    /// SSG/ISR (`autumn build`, i.e. a `dist` manifest is present when the
16463    /// app serves) and exposes the same route over MCP, an MCP client with no
16464    /// credential at all (not even a session, not an API token — nothing)
16465    /// could reach the handler by calling the tool instead of the route,
16466    /// while the identical direct HTTP request is correctly rejected. The app
16467    /// author did nothing the docs warn against: `docs/guide/mcp.md` promises
16468    /// `tools/call` "runs through the real handler pipeline... the same
16469    /// in-process path, ... #[secured], authorization, tenancy, rate limits,
16470    /// and validation all apply identically to an agent call and an ordinary
16471    /// HTTP call" with no static-mode carve-out, and the one documented
16472    /// exception (`static_gate` "is never applied to MCP `tools/call`
16473    /// dispatch anyway") names a different, narrower registration.
16474    ///
16475    /// Root cause: `try_build_router_with_static_inner` used to drain
16476    /// `custom_layers` out of `RouterContext` and reapply them *outside* the
16477    /// static-first middleware — but only after `build_router_pre_state` had
16478    /// already taken the MCP dispatch clone (see the `mcp_prepared` comment
16479    /// near `build_router_pre_state`'s call site). A direct request
16480    /// traversed the reapplied layer; a `tools/call` replay dispatched
16481    /// against the pre-drain clone and never saw it.
16482    ///
16483    /// Fix: `try_build_router_with_static_inner` now clones the drained
16484    /// `custom_layers` set and hands the clone to `build_router_pre_state` as
16485    /// `mcp_dispatch_extra_layers`, applied only to the MCP dispatch clone —
16486    /// restoring parity with the fully-dynamic path (where these layers were
16487    /// always baked into the clone) without touching how the original set
16488    /// wraps the live-serving router (no double-application, no ordering
16489    /// change for direct requests).
16490    #[cfg(feature = "mcp")]
16491    #[tokio::test]
16492    #[allow(clippy::too_many_lines)]
16493    async fn custom_layer_protects_mcp_dispatch_in_static_mode() {
16494        async fn secret_handler() -> StatusCode {
16495            StatusCode::NO_CONTENT
16496        }
16497        let route = Route {
16498            method: http::Method::GET,
16499            path: "/secret",
16500            handler: axum::routing::get(secret_handler),
16501            name: "secret_tool",
16502            api_doc: crate::openapi::ApiDoc {
16503                method: "GET",
16504                path: "/secret",
16505                operation_id: "secret_tool",
16506                success_status: 204,
16507                ..Default::default()
16508            },
16509            repository: None,
16510            idempotency: crate::route::RouteIdempotency::Direct,
16511            timeout: crate::route::RouteTimeout::Inherit,
16512            criticality: crate::admission::Criticality::Default,
16513            seo: crate::seo::SeoRouteDefaults::EMPTY,
16514            api_version: None,
16515            sunset_opt_out: false,
16516        };
16517
16518        let mut config = AutumnConfig::default();
16519        config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
16520
16521        let ctx = RouterContext {
16522            exception_filters: Vec::new(),
16523            scoped_groups: Vec::new(),
16524            merge_routers: Vec::new(),
16525            nest_routers: Vec::new(),
16526            declared_routes: Vec::new(),
16527            custom_layers: vec![deny_unless_api_key_for_secret_path_registration()],
16528            static_gate_layers: Vec::new(),
16529            #[cfg(feature = "maud")]
16530            error_page_renderer: None,
16531            session_store: None,
16532            #[cfg(feature = "openapi")]
16533            openapi: None,
16534            #[cfg(feature = "mcp")]
16535            mcp: Some(crate::mcp::McpRuntime {
16536                expose_all: true,
16537                ..crate::mcp::McpRuntime::new("/mcp")
16538            }),
16539            #[cfg(feature = "webmcp")]
16540            webmcp: None,
16541        };
16542
16543        let (_tmp, dist) = build_empty_dist();
16544
16545        let app = super::try_build_router_with_static_inner(
16546            vec![route],
16547            &config,
16548            crate::state::AppState::for_test(),
16549            Some(dist.as_path()),
16550            ctx,
16551        )
16552        .expect("router builds");
16553
16554        // Direct HTTP request without the credential: the custom layer
16555        // (the runtime shape of `AppBuilder::layer(...)`) rejects it.
16556        let direct = app
16557            .clone()
16558            .oneshot(
16559                Request::builder()
16560                    .uri("/secret")
16561                    .header("host", "app.example")
16562                    .body(Body::empty())
16563                    .unwrap(),
16564            )
16565            .await
16566            .unwrap();
16567        assert_eq!(
16568            direct.status(),
16569            StatusCode::UNAUTHORIZED,
16570            "a direct request without the credential must be rejected by the custom layer"
16571        );
16572
16573        // The identical handler dispatched via MCP `tools/call`, same missing
16574        // credential: the custom layer must reject the replayed request too,
16575        // exactly as it rejects the direct one. `tools/call` itself always
16576        // returns `200` with a JSON-RPC envelope (see `serve_tools_call`), so
16577        // the rejection surfaces as `isError: true` with the layer's `401`
16578        // folded into the tool result rather than as an HTTP-level status.
16579        let mcp_resp = app
16580            .oneshot(
16581                Request::builder()
16582                    .method("POST")
16583                    .uri("/mcp")
16584                    .header("host", "app.example")
16585                    .header("content-type", "application/json")
16586                    .body(Body::from(
16587                        serde_json::json!({
16588                            "jsonrpc": "2.0",
16589                            "id": 1,
16590                            "method": "tools/call",
16591                            "params": {"name": "secret_tool", "arguments": {}}
16592                        })
16593                        .to_string(),
16594                    ))
16595                    .unwrap(),
16596            )
16597            .await
16598            .unwrap();
16599        assert_eq!(mcp_resp.status(), StatusCode::OK);
16600        let body = axum::body::to_bytes(mcp_resp.into_body(), usize::MAX)
16601            .await
16602            .unwrap();
16603        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
16604        assert_eq!(
16605            json["result"]["isError"], true,
16606            "the custom AppBuilder::layer() gate that protects the direct route \
16607             must also protect the MCP tools/call replay in static/ISR mode — an \
16608             unauthenticated tool call must not reach the handler: {json}"
16609        );
16610        let text = json["result"]["content"][0]["text"].as_str().unwrap_or("");
16611        assert!(
16612            text.contains("401") || text.to_ascii_lowercase().contains("unauthorized"),
16613            "the tool error should surface the layer's 401 rejection: {json}"
16614        );
16615    }
16616
16617    /// 🛡 Warden (2026-09-07 review round 2, Codex P2): the fix above closes
16618    /// the bypass, but the naive version of it — merging `mcp_router` inside
16619    /// `build_router_pre_state` as before, with only the dispatch clone
16620    /// getting `mcp_dispatch_extra_layers` on top — introduced a *different*
16621    /// bug: since `/mcp` is nested inside the router that
16622    /// `try_build_router_with_static_inner`'s own `custom_layers`
16623    /// reapplication wraps, a single `tools/call` would run every custom
16624    /// layer TWICE — once for the live `/mcp` POST (the envelope), once for
16625    /// the dispatch replay. Harmless for an idempotent layer (security
16626    /// headers), but a stateful one (a rate limiter, a counter, an audit
16627    /// logger) gets charged twice per call — effectively halving its
16628    /// configured limit for MCP traffic relative to direct HTTP. This test
16629    /// locks in the fix: `build_router_pre_state` now hands the `/mcp`
16630    /// router back to the caller instead of merging it in immediately in
16631    /// SSG/ISG mode, and the caller merges it in *after* the `custom_layers`
16632    /// reapplication, so the live envelope never traverses them — matching
16633    /// the fully-dynamic path, where `/mcp` structurally never does either.
16634    #[cfg(feature = "mcp")]
16635    #[tokio::test]
16636    async fn custom_layer_runs_exactly_once_per_tools_call_in_static_mode() {
16637        async fn secret_handler() -> StatusCode {
16638            StatusCode::NO_CONTENT
16639        }
16640        use std::sync::atomic::{AtomicUsize, Ordering};
16641        let counter = std::sync::Arc::new(AtomicUsize::new(0));
16642        let counter2 = counter.clone();
16643        let gate = axum::middleware::from_fn(
16644            move |req: axum::extract::Request, next: axum::middleware::Next| {
16645                let counter = counter2.clone();
16646                async move {
16647                    counter.fetch_add(1, Ordering::SeqCst);
16648                    next.run(req).await
16649                }
16650            },
16651        );
16652        let registration = crate::app::CustomLayerRegistration {
16653            type_id: std::any::TypeId::of::<()>(),
16654            type_name: "counter",
16655            layer: tower::util::BoxCloneSyncServiceLayer::new(gate),
16656        };
16657        let route = Route {
16658            method: http::Method::GET,
16659            path: "/secret",
16660            handler: axum::routing::get(secret_handler),
16661            name: "secret_tool",
16662            api_doc: crate::openapi::ApiDoc {
16663                method: "GET",
16664                path: "/secret",
16665                operation_id: "secret_tool",
16666                success_status: 204,
16667                ..Default::default()
16668            },
16669            repository: None,
16670            idempotency: crate::route::RouteIdempotency::Direct,
16671            timeout: crate::route::RouteTimeout::Inherit,
16672            criticality: crate::admission::Criticality::Default,
16673            seo: crate::seo::SeoRouteDefaults::EMPTY,
16674            api_version: None,
16675            sunset_opt_out: false,
16676        };
16677        let mut config = AutumnConfig::default();
16678        config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
16679        let ctx = RouterContext {
16680            exception_filters: Vec::new(),
16681            scoped_groups: Vec::new(),
16682            merge_routers: Vec::new(),
16683            nest_routers: Vec::new(),
16684            declared_routes: Vec::new(),
16685            custom_layers: vec![registration],
16686            static_gate_layers: Vec::new(),
16687            #[cfg(feature = "maud")]
16688            error_page_renderer: None,
16689            session_store: None,
16690            #[cfg(feature = "openapi")]
16691            openapi: None,
16692            #[cfg(feature = "mcp")]
16693            mcp: Some(crate::mcp::McpRuntime {
16694                expose_all: true,
16695                ..crate::mcp::McpRuntime::new("/mcp")
16696            }),
16697            #[cfg(feature = "webmcp")]
16698            webmcp: None,
16699        };
16700        let (_tmp, dist) = build_empty_dist();
16701        let app = super::try_build_router_with_static_inner(
16702            vec![route],
16703            &config,
16704            crate::state::AppState::for_test(),
16705            Some(dist.as_path()),
16706            ctx,
16707        )
16708        .expect("router builds");
16709
16710        let _resp = app
16711            .oneshot(
16712                Request::builder()
16713                    .method("POST")
16714                    .uri("/mcp")
16715                    .header("host", "app.example")
16716                    .header("content-type", "application/json")
16717                    .body(Body::from(
16718                        serde_json::json!({
16719                            "jsonrpc": "2.0",
16720                            "id": 1,
16721                            "method": "tools/call",
16722                            "params": {"name": "secret_tool", "arguments": {}}
16723                        })
16724                        .to_string(),
16725                    ))
16726                    .unwrap(),
16727            )
16728            .await
16729            .unwrap();
16730
16731        assert_eq!(
16732            counter.load(Ordering::SeqCst),
16733            1,
16734            "a custom layer protecting a route must run exactly once per \
16735             tools/call, matching a direct HTTP request — not once for the \
16736             live /mcp envelope and once again for the dispatch replay"
16737        );
16738    }
16739
16740    #[tokio::test]
16741    async fn static_gate_redirect_carries_security_headers_ssg() {
16742        // A gate short-circuit (302) must still carry the framework security
16743        // headers — SecurityHeadersLayer wraps the gate in the SSG path.
16744        let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
16745        let config = AutumnConfig::default();
16746        let ctx = ctx_with_static_gate(redirect_gate_registration());
16747
16748        let app = super::try_build_router_with_static_inner(
16749            Vec::new(),
16750            &config,
16751            crate::state::AppState::for_test(),
16752            Some(dist.as_path()),
16753            ctx,
16754        )
16755        .expect("router builds");
16756
16757        let unauthed = app
16758            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
16759            .await
16760            .unwrap();
16761        assert_eq!(unauthed.status(), StatusCode::FOUND);
16762        // X-Content-Type-Options: nosniff is applied by SecurityHeadersLayer by
16763        // default; its presence proves the layer wraps the gate's response.
16764        assert_eq!(
16765            unauthed
16766                .headers()
16767                .get("x-content-type-options")
16768                .expect("gate redirect must carry security headers"),
16769            "nosniff"
16770        );
16771    }
16772
16773    #[tokio::test]
16774    async fn static_gate_redirect_carries_security_headers_dynamic() {
16775        // Same contract in fully-dynamic mode (no dist): SecurityHeadersLayer is
16776        // the framework's outermost layer, so a gate short-circuit still carries
16777        // HSTS/CSP/nosniff. Guards against the dynamic/SSG inconsistency.
16778        async fn dynamic_handler() -> &'static str {
16779            "dynamic"
16780        }
16781        let route = Route {
16782            method: http::Method::GET,
16783            path: "/",
16784            handler: axum::routing::get(dynamic_handler),
16785            name: "root",
16786            api_doc: crate::openapi::ApiDoc {
16787                method: "GET",
16788                path: "/",
16789                operation_id: "root",
16790                success_status: 200,
16791                ..Default::default()
16792            },
16793            repository: None,
16794            idempotency: crate::route::RouteIdempotency::Direct,
16795            timeout: crate::route::RouteTimeout::Inherit,
16796            criticality: crate::admission::Criticality::Default,
16797            seo: crate::seo::SeoRouteDefaults::EMPTY,
16798            api_version: None,
16799            sunset_opt_out: false,
16800        };
16801        let config = AutumnConfig::default();
16802        let ctx = ctx_with_static_gate(redirect_gate_registration());
16803
16804        let app = super::try_build_router_with_static_inner(
16805            vec![route],
16806            &config,
16807            crate::state::AppState::for_test(),
16808            None,
16809            ctx,
16810        )
16811        .expect("router builds");
16812
16813        let unauthed = app
16814            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
16815            .await
16816            .unwrap();
16817        assert_eq!(unauthed.status(), StatusCode::FOUND);
16818        assert_eq!(
16819            unauthed
16820                .headers()
16821                .get("x-content-type-options")
16822                .expect("dynamic gate redirect must carry security headers"),
16823            "nosniff"
16824        );
16825    }
16826
16827    #[test]
16828    fn static_gate_layer_requires_fail_closed_idempotency() {
16829        // A static_gate (e.g. a JWT/auth layer) is an opaque app layer for
16830        // idempotency: it must force fail-closed replay so a cached mutation
16831        // can't be served to a different principal sharing an Idempotency-Key.
16832        let gate = vec![redirect_gate_registration()];
16833        assert!(super::custom_layers_require_fail_closed_idempotency(&gate));
16834        // An empty set requires no fail-closed behaviour.
16835        assert!(!super::custom_layers_require_fail_closed_idempotency(&[]));
16836    }
16837
16838    // ----------------------------------------------------------------------
16839    // #2214: the ingress stack's middleware futures must stay unboxed
16840    // ----------------------------------------------------------------------
16841
16842    /// The structural half of issue #2214's fix, in one assertion per converted
16843    /// middleware.
16844    ///
16845    /// Each of these layers used to be an `axum::middleware::from_fn`, whose
16846    /// `FromFn::call` must `Box::pin` the async block it generates — the block's
16847    /// type cannot be named, so there is nowhere else for it to live. That was
16848    /// one heap allocation per request per call site, 19.57% of every byte the
16849    /// `request_pipeline` benchmark allocated.
16850    ///
16851    /// A hand-rolled `tower::Service` fixes it only if its `Future` associated
16852    /// type is genuinely named all the way down. Writing one that still returns
16853    /// `Pin<Box<dyn Future>>` — the shape `SessionService`, `LogContextService`
16854    /// and `TrustedProxiesService` use — would satisfy every behavioural test in
16855    /// the suite while allocating exactly as much as before. This test is what
16856    /// makes that impossible to do by accident: the sibling allocation gate in
16857    /// `tests/config_alloc_gate.rs` pins the *total*, and this pins *where* the
16858    /// total comes from.
16859    ///
16860    /// What this pins precisely: that the associated `Future` type is not
16861    /// literally a `Pin<Box<dyn Future>>`. `std::any::type_name` prints a type's
16862    /// path and generic arguments, never its fields, so it cannot see a box
16863    /// hidden inside a variant of an otherwise-named future — which is exactly
16864    /// what `WebhookReplayCleanupFuture`'s rare `Releasing` branch is, and why
16865    /// that one is pinned by the sibling test below (on size) instead of here.
16866    #[test]
16867    fn converted_ingress_middleware_futures_are_never_boxed() {
16868        /// The inner service every layer under test is stacked on: its own
16869        /// future is a named `Ready`, so any `Box` in the resulting type name
16870        /// was contributed by the layer.
16871        type Inner = tower::util::ServiceFn<
16872            fn(
16873                axum::extract::Request,
16874            )
16875                -> std::future::Ready<Result<axum::response::Response, std::convert::Infallible>>,
16876        >;
16877        type Fut<Svc> = <Svc as tower::Service<axum::extract::Request>>::Future;
16878
16879        fn assert_unboxed<Svc: tower::Service<axum::extract::Request>>(what: &str) {
16880            let name = std::any::type_name::<Fut<Svc>>();
16881            assert!(
16882                !name.contains("Box"),
16883                "{what} must return a named, unboxed future — an \
16884                 `axum::middleware::from_fn`-shaped `Box::pin` here is one heap \
16885                 allocation on every request (issue #2214). Got: {name}"
16886            );
16887        }
16888
16889        assert_unboxed::<super::TrustedHostService<Inner>>("TrustedHostService");
16890        assert_unboxed::<super::StartupBarrierService<Inner>>("StartupBarrierService");
16891        assert_unboxed::<super::RequestTimeoutService<Inner>>("RequestTimeoutService");
16892        assert_unboxed::<crate::assets::AssetCacheControlService<Inner>>(
16893            "AssetCacheControlService",
16894        );
16895        assert_unboxed::<crate::events::EventAppContextService<Inner>>("EventAppContextService");
16896        assert_unboxed::<crate::read_your_writes::ReadYourWritesService<Inner>>(
16897            "ReadYourWritesService",
16898        );
16899        assert_unboxed::<crate::middleware::method_override::MethodOverrideRejectionService<Inner>>(
16900            "MethodOverrideRejectionService",
16901        );
16902        assert_unboxed::<crate::aeo::negotiate::NegotiateService<Inner>>("NegotiateService");
16903        #[cfg(feature = "oauth2")]
16904        assert_unboxed::<super::HttpInterceptorService<Inner>>("HttpInterceptorService");
16905    }
16906
16907    /// `WebhookReplayCleanupFuture` is the one converted middleware that still
16908    /// boxes, and that is deliberate: releasing the replay keys a failed webhook
16909    /// delivery registered is genuinely async work that has to run *after* the
16910    /// inner future resolves, so it cannot be folded into the inner service's
16911    /// own future.
16912    ///
16913    /// The box lives in the `Releasing` variant, which is only ever constructed
16914    /// for a `5xx` response that actually registered keys — so the happy path
16915    /// (and every request that is not a webhook delivery at all) never takes it.
16916    /// The sibling test above cannot pin that, because `type_name` does not
16917    /// print field types; this one does, by size. `Serving` stores the scoped
16918    /// inner future, the replay cell and the drop guard **inline**, so the enum
16919    /// must be at least as large as all three together. Move the box to
16920    /// `Serving` and the enum collapses to roughly a pointer plus the response,
16921    /// and this fails.
16922    #[test]
16923    fn webhook_replay_cleanup_boxes_only_its_rare_release_branch() {
16924        use std::mem::size_of;
16925
16926        type Ready = std::future::Ready<Result<axum::response::Response, std::convert::Infallible>>;
16927        type Scoped = tokio::task::futures::TaskLocalFuture<crate::webhook::ReplayStoreCell, Ready>;
16928
16929        let serving_inline = size_of::<Scoped>()
16930            + size_of::<crate::webhook::ReplayStoreCell>()
16931            + size_of::<crate::webhook::ReplayKeyGuard>();
16932        let whole = size_of::<crate::webhook::WebhookReplayCleanupFuture<Ready>>();
16933        assert!(
16934            whole >= serving_inline,
16935            "WebhookReplayCleanupFuture must hold the scoped inner future, the \
16936             replay cell and the drop guard inline in its `Serving` variant \
16937             ({serving_inline} bytes together), but the whole future is only \
16938             {whole} bytes — the inner future has been boxed, which costs an \
16939             allocation on every request rather than only on a failed webhook \
16940             delivery (issue #2214)"
16941        );
16942    }
16943
16944    /// The startup barrier's short-circuit branch: until the app reports startup
16945    /// complete, a request to a non-exempt path is refused with `503`.
16946    ///
16947    /// Driven directly over a stub inner service because `apply_startup_barrier`
16948    /// is production-only — `TestApp::build` deliberately mirrors just its two
16949    /// response-side fallbacks (see `crate::test`), so no end-to-end test in the
16950    /// suite can reach this branch.
16951    #[tokio::test]
16952    async fn startup_barrier_refuses_requests_until_startup_completes() {
16953        use tower::{Layer, ServiceExt};
16954
16955        fn ok_service() -> impl tower::Service<
16956            axum::extract::Request,
16957            Response = axum::response::Response,
16958            Error = std::convert::Infallible,
16959        > + Clone {
16960            tower::service_fn(|_req: axum::extract::Request| async move {
16961                Ok::<_, std::convert::Infallible>("handler ran".into_response())
16962            })
16963        }
16964
16965        // `#[allow(future_not_send)]`: `tower::service_fn`'s closure is not
16966        // `Sync`, so this helper's future is `!Send`. It only ever runs on a
16967        // `#[tokio::test]` current-thread runtime, which never moves it.
16968        #[allow(clippy::future_not_send)]
16969        async fn status_for(startup_complete: bool, path: &str) -> (StatusCode, String) {
16970            let state = AppState::for_test().with_startup_complete(startup_complete);
16971            let config = AutumnConfig::default();
16972            let layer = super::StartupBarrierLayer::new(super::StartupBarrierState::from_config(
16973                &config, &state,
16974            ));
16975            let response = layer
16976                .layer(ok_service())
16977                .oneshot(
16978                    axum::extract::Request::builder()
16979                        .uri(path)
16980                        .body(Body::empty())
16981                        .expect("request builds"),
16982                )
16983                .await
16984                .expect("infallible");
16985            let status = response.status();
16986            let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
16987                .await
16988                .expect("body collects");
16989            (status, String::from_utf8_lossy(&body).into_owned())
16990        }
16991
16992        let (status, body) = status_for(false, "/anything").await;
16993        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
16994        assert_eq!(body, "Service is still starting up");
16995
16996        // Control: the same request once startup has completed reaches the
16997        // handler, so the 503 above is the barrier and not a broken stub.
16998        let (status, body) = status_for(true, "/anything").await;
16999        assert_eq!(status, StatusCode::OK);
17000        assert_eq!(body, "handler ran");
17001
17002        // Probe/actuator paths are exempt even before startup completes, so a
17003        // platform readiness check can still reach them.
17004        let (status, _) = status_for(false, "/actuator/health").await;
17005        assert_ne!(
17006            status,
17007            StatusCode::SERVICE_UNAVAILABLE,
17008            "probe paths must bypass the startup barrier"
17009        );
17010    }
17011}
17012#[derive(Clone, Debug)]
17013pub struct TrustedHostPolicy {
17014    rules: Arc<Vec<String>>,
17015    allow_any: bool,
17016    allow_missing_host: bool,
17017    probe_bypass_paths: Arc<std::collections::HashSet<String>>,
17018    /// Where a hostname that no static rule matches is looked up (#2657).
17019    ///
17020    /// A tenant's connected hostname is never in `[security.trusted_hosts]
17021    /// hosts` — that is the point of the feature — so without this the
17022    /// trusted-host layer answers `400 Invalid Host header` before tenancy
17023    /// resolution runs, and custom domains work only with `hosts = ["*"]`.
17024    ///
17025    /// Read late, not captured, because the registry is published at bind
17026    /// time, after the router is built. `None` for a policy built without a
17027    /// state (the MCP unit tests); an app that does not enable custom domains
17028    /// publishes no registry, so the lookup finds nothing.
17029    custom_domains: Option<crate::state::LateExtensions>,
17030}
17031
17032impl TrustedHostPolicy {
17033    pub fn from_config(config: &AutumnConfig) -> Self {
17034        let mut rules: Vec<String> = config
17035            .security
17036            .trusted_hosts
17037            .hosts
17038            .iter()
17039            .map(|h| h.trim().to_ascii_lowercase())
17040            .map(|h| h.trim_end_matches('.').to_owned())
17041            .filter(|h| !h.is_empty())
17042            .collect();
17043        let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
17044        if !is_production {
17045            rules.extend(
17046                ["localhost", "127.0.0.1", "::1"]
17047                    .into_iter()
17048                    .map(std::borrow::ToOwned::to_owned),
17049            );
17050        }
17051        let allow_any = rules.iter().any(|h| h == "*");
17052        let probe_bypass_paths = probe_bypass_paths(config).into_iter().collect();
17053        Self {
17054            rules: Arc::new(rules),
17055            allow_any,
17056            allow_missing_host: !is_production,
17057            probe_bypass_paths: Arc::new(probe_bypass_paths),
17058            custom_domains: None,
17059        }
17060    }
17061
17062    /// [`from_config`](Self::from_config), plus the app state that publishes
17063    /// the custom-domain registry (#2657).
17064    ///
17065    /// Every ingress policy is built this way. The state is read per request,
17066    /// so a domain connected — or offboarded — while the app runs takes effect
17067    /// without a restart.
17068    pub(crate) fn from_config_with_state(config: &AutumnConfig, state: &AppState) -> Self {
17069        Self {
17070            custom_domains: Some(state.late_extensions()),
17071            ..Self::from_config(config)
17072        }
17073    }
17074
17075    /// Whether a request carrying no usable `Host` is allowed through. Mirrors
17076    /// `trusted_host_rejection`'s missing-host branch for callers (e.g. the MCP
17077    /// envelope) that enforce the policy outside [`TrustedHostService`].
17078    ///
17079    /// Only the `mcp` feature consumes this today; gated so default-feature
17080    /// builds don't flag it as dead code.
17081    #[cfg(feature = "mcp")]
17082    pub const fn allows_missing_host(&self) -> bool {
17083        self.allow_missing_host
17084    }
17085
17086    pub fn allows_host(&self, host: &str) -> bool {
17087        if self.allow_any {
17088            return true;
17089        }
17090        let matches_rule = self.rules.iter().any(|rule| {
17091            rule.strip_prefix('.').map_or_else(
17092                || host == rule,
17093                |suffix| {
17094                    host == suffix
17095                        || host
17096                            .strip_suffix(suffix)
17097                            .is_some_and(|prefix| prefix.ends_with('.'))
17098                },
17099            )
17100        });
17101        matches_rule || self.is_connected_domain(host)
17102    }
17103
17104    /// Is `host` a tenant custom domain this deployment serves right now?
17105    ///
17106    /// Only after the static rules miss, so the common path stays a slice
17107    /// comparison. Only a *servable* (`active`) domain passes, which is the
17108    /// rule SNI already applies at the handshake: a registration stuck at
17109    /// `pending_dns` must not become a way past host validation.
17110    fn is_connected_domain(&self, host: &str) -> bool {
17111        self.custom_domains.as_ref().is_some_and(|extensions| {
17112            extensions
17113                .get::<Arc<crate::custom_domain::CustomDomainRegistry>>()
17114                .is_some_and(|registry| registry.is_servable(host))
17115        })
17116    }
17117}
17118
17119/// Metadata carrying API version, sunset opt-out, and security configuration for a route.
17120#[derive(Clone, Debug)]
17121pub struct RouteVersionMetadata {
17122    pub version: String,
17123    pub sunset_opt_out: bool,
17124    pub secured: bool,
17125    pub required_roles: &'static [&'static str],
17126    pub has_policy: bool,
17127}
17128
17129/// Middleware that handles API deprecation, sunsets, and Gone responses.
17130async fn api_versioning_middleware(
17131    state: axum::extract::State<AppState>,
17132    route_version: Option<axum::extract::Extension<RouteVersionMetadata>>,
17133    request: axum::http::Request<axum::body::Body>,
17134    next: axum::middleware::Next,
17135) -> axum::response::Response {
17136    let Some(axum::extract::Extension(meta)) = route_version else {
17137        return next.run(request).await;
17138    };
17139
17140    let clock = state.clock();
17141    let now = clock.now();
17142
17143    let versions = state.extension::<crate::app::RegisteredApiVersions>();
17144    let matching_version = versions
17145        .as_ref()
17146        .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
17147
17148    let Some(version) = matching_version else {
17149        return next.run(request).await;
17150    };
17151
17152    let is_deprecated = version.deprecated_at.is_some_and(|d| now >= d);
17153    let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
17154
17155    if is_sunset && !meta.sunset_opt_out {
17156        if meta.has_policy {
17157            return next.run(request).await;
17158        }
17159        if meta.secured {
17160            let session = request.extensions().get::<crate::session::Session>();
17161            let mut auth_failed = false;
17162            let mut auth_error = None;
17163            if let Some(session) = session {
17164                if let Err(err) = crate::auth::__check_secured_with_key(
17165                    session,
17166                    state.auth_session_key(),
17167                    meta.required_roles,
17168                )
17169                .await
17170                {
17171                    auth_failed = true;
17172                    auth_error = Some(err);
17173                }
17174            } else {
17175                auth_failed = true;
17176                auth_error = Some(crate::error::AutumnError::unauthorized_msg(
17177                    "authentication required",
17178                ));
17179            }
17180            if auth_failed {
17181                return auth_error.unwrap().into_response();
17182            }
17183        }
17184
17185        let err = crate::error::AutumnError::gone_msg(format!(
17186            "API version '{}' has been sunsetted.",
17187            meta.version
17188        ));
17189        let mut response = err.into_response();
17190        if let Some(sunset) = version.sunset_at {
17191            let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
17192            if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
17193                response.headers_mut().insert("Sunset", val);
17194            }
17195        }
17196        let deprecation_date = match (version.deprecated_at, version.sunset_at) {
17197            (Some(d), Some(s)) => Some(d.min(s)),
17198            (d, s) => d.or(s),
17199        };
17200        if let Some(date) = deprecation_date {
17201            let timestamp = date.timestamp();
17202            if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
17203                response.headers_mut().insert("Deprecation", val);
17204            }
17205        }
17206        return response;
17207    }
17208
17209    let mut response = next.run(request).await;
17210
17211    if is_deprecated || is_sunset {
17212        let deprecation_date = match (version.deprecated_at, version.sunset_at) {
17213            (Some(d), Some(s)) => Some(d.min(s)),
17214            (d, s) => d.or(s),
17215        };
17216        if let Some(date) = deprecation_date {
17217            let timestamp = date.timestamp();
17218            if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
17219                response.headers_mut().insert("Deprecation", val);
17220            }
17221        }
17222    }
17223    if let Some(sunset) = version.sunset_at.filter(|_| is_deprecated || is_sunset) {
17224        let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
17225        if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
17226            response.headers_mut().insert("Sunset", val);
17227        }
17228    }
17229
17230    response
17231}
17232
17233/// Helper function to perform a sunset check during dynamic handler execution.
17234/// Returns a `410 Gone` response if the route version has sunsetted.
17235#[must_use]
17236pub fn check_sunset(
17237    state: &crate::state::AppState,
17238    meta: &RouteVersionMetadata,
17239) -> Option<axum::response::Response> {
17240    let clock = state.clock();
17241    let now = clock.now();
17242
17243    let versions = state.extension::<crate::app::RegisteredApiVersions>();
17244    let matching_version = versions
17245        .as_ref()
17246        .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
17247
17248    let version = matching_version?;
17249    let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
17250
17251    if is_sunset && !meta.sunset_opt_out {
17252        let err = crate::error::AutumnError::gone_msg(format!(
17253            "API version '{}' has been sunsetted.",
17254            meta.version
17255        ));
17256        let mut response = axum::response::IntoResponse::into_response(err);
17257        if let Some(sunset) = version.sunset_at {
17258            let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
17259            if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
17260                response.headers_mut().insert("Sunset", val);
17261            }
17262        }
17263        let deprecation_date = match (version.deprecated_at, version.sunset_at) {
17264            (Some(d), Some(s)) => Some(d.min(s)),
17265            (d, s) => d.or(s),
17266        };
17267        if let Some(date) = deprecation_date {
17268            let timestamp = date.timestamp();
17269            if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
17270                response.headers_mut().insert("Deprecation", val);
17271            }
17272        }
17273        return Some(response);
17274    }
17275
17276    None
17277}
17278
17279#[cfg(all(test, feature = "htmx"))]
17280mod framework_scripts_tests {
17281    use super::*;
17282    use http::StatusCode;
17283    use http_body_util::BodyExt;
17284    use tower::ServiceExt as _;
17285
17286    async fn get(path: &str, if_none_match: Option<&str>) -> axum::response::Response {
17287        let mut request = http::Request::builder().uri(path);
17288        if let Some(tag) = if_none_match {
17289            request = request.header(http::header::IF_NONE_MATCH, tag);
17290        }
17291        framework_scripts_router()
17292            .with_state(AppState::for_test())
17293            .oneshot(request.body(axum::body::Body::empty()).unwrap())
17294            .await
17295            .unwrap()
17296    }
17297
17298    fn header(response: &axum::response::Response, name: http::HeaderName) -> &str {
17299        response
17300            .headers()
17301            .get(name)
17302            .and_then(|v| v.to_str().ok())
17303            .unwrap_or("")
17304    }
17305
17306    /// Every framework script keeps its historical plain path. That path is
17307    /// not content-fingerprinted, so it must revalidate (with a weak,
17308    /// content-derived `ETag`) rather than advertise a year-long `immutable`
17309    /// cache; otherwise returning clients run a stale copy after an upgrade.
17310    #[tokio::test]
17311    async fn plain_paths_revalidate_with_a_weak_etag() {
17312        for (path, bytes) in [
17313            (crate::htmx::HTMX_JS_PATH, crate::htmx::HTMX_JS),
17314            (crate::htmx::HTMX_SSE_JS_PATH, crate::htmx::HTMX_SSE_JS),
17315            (crate::htmx::IDIOMORPH_JS_PATH, crate::htmx::IDIOMORPH_JS),
17316            (
17317                crate::htmx::AUTUMN_WIDGETS_JS_PATH,
17318                crate::htmx::AUTUMN_WIDGETS_JS,
17319            ),
17320            (
17321                crate::htmx::HTMX_CSRF_JS_PATH,
17322                crate::htmx::HTMX_CSRF_JS.as_bytes(),
17323            ),
17324        ] {
17325            let response = get(path, None).await;
17326            assert_eq!(response.status(), StatusCode::OK, "{path}");
17327            assert_eq!(
17328                header(&response, http::header::CONTENT_TYPE),
17329                "text/javascript; charset=utf-8",
17330                "{path}"
17331            );
17332            let cc = header(&response, http::header::CACHE_CONTROL);
17333            assert!(cc.contains("must-revalidate"), "{path}: {cc}");
17334            assert!(!cc.contains("immutable"), "{path}: {cc}");
17335            let etag = header(&response, http::header::ETAG);
17336            assert!(etag.starts_with("W/\""), "{path}: {etag}");
17337            let body = response.into_body().collect().await.unwrap().to_bytes();
17338            assert_eq!(&body[..], bytes, "{path}");
17339        }
17340    }
17341
17342    /// The hashed path of every framework script is cached `immutable`, and
17343    /// `asset_url` hands it out for the plain logical path.
17344    #[tokio::test]
17345    async fn fingerprinted_paths_are_immutable_and_resolved_by_asset_url() {
17346        let mut seen = 0;
17347        for asset in crate::htmx::framework_scripts() {
17348            seen += 1;
17349            assert_eq!(
17350                crate::assets::asset_url(asset.logical_path()),
17351                asset.url(),
17352                "asset_url must return the hashed URL"
17353            );
17354            assert_ne!(asset.url(), asset.plain_url());
17355            let response = get(asset.url(), None).await;
17356            assert_eq!(response.status(), StatusCode::OK, "{}", asset.url());
17357            assert_eq!(
17358                header(&response, http::header::CACHE_CONTROL),
17359                "public, max-age=31536000, immutable"
17360            );
17361            let body = response.into_body().collect().await.unwrap().to_bytes();
17362            assert_eq!(&body[..], asset.bytes());
17363        }
17364        // The `webmcp` feature adds the WebMCP shim (#2619).
17365        let expected = 5 + usize::from(cfg!(feature = "webmcp"));
17366        assert_eq!(
17367            seen, expected,
17368            "htmx, sse, idiomorph, csrf helper, widgets (+ webmcp)"
17369        );
17370    }
17371
17372    #[tokio::test]
17373    async fn revalidation_with_the_current_etag_is_not_modified() {
17374        let first = get(crate::htmx::HTMX_JS_PATH, None).await;
17375        let etag = header(&first, http::header::ETAG).to_owned();
17376        let second = get(crate::htmx::HTMX_JS_PATH, Some(&etag)).await;
17377        assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
17378        let body = second.into_body().collect().await.unwrap().to_bytes();
17379        assert!(body.is_empty());
17380    }
17381}
17382
17383#[cfg(test)]
17384mod proptests {
17385    //! Property-based invariants for the low-level path/host string helpers.
17386    //! These are `pub(crate)` (only reachable via the `cfg(fuzzing)` seam
17387    //! module), so they are exercised here in-crate rather than from an
17388    //! integration test.
17389    use super::*;
17390    use proptest::prelude::*;
17391
17392    proptest! {
17393        #![proptest_config(ProptestConfig::with_cases(256))]
17394
17395        /// Mounting the root child (`"/"` or empty) is the identity on a
17396        /// non-empty prefix, and idempotent: re-mounting the root child on the
17397        /// result leaves it unchanged. (An empty prefix collapses to `"/"`.)
17398        #[test]
17399        fn join_nested_path_root_child_is_identity(prefix in "/?[a-z0-9/]{0,20}", root in prop::sample::select(vec!["/", ""])) {
17400            let once = join_nested_path(&prefix, root);
17401            let expected = if prefix.is_empty() { "/".to_owned() } else { prefix };
17402            prop_assert_eq!(&once, &expected);
17403            let twice = join_nested_path(&once, root);
17404            prop_assert_eq!(once, twice);
17405        }
17406
17407        /// `join_nested_path` never introduces a doubled slash at the join seam
17408        /// for well-formed single-segment children.
17409        #[test]
17410        fn join_nested_path_no_double_slash_at_seam(prefix in "/[a-z0-9]{1,8}/?", child in "/[a-z0-9]{1,8}") {
17411            let joined = join_nested_path(&prefix, &child);
17412            prop_assert!(!joined.contains("//"), "unexpected `//` in {joined:?}");
17413        }
17414
17415        /// `extract_host_without_port` never panics on arbitrary input and,
17416        /// when it returns something, that something is a substring of the
17417        /// trimmed input (it only ever strips a port / brackets, never invents
17418        /// characters).
17419        #[test]
17420        fn extract_host_without_port_never_panics(header in ".*") {
17421            if let Some(host) = extract_host_without_port(&header) {
17422                prop_assert!(header.contains(host));
17423            }
17424        }
17425
17426        /// `path_matches_route_prefix` never panics and is reflexive: a path
17427        /// always matches itself as a prefix.
17428        #[test]
17429        fn path_matches_route_prefix_reflexive(path in ".*") {
17430            prop_assert!(path_matches_route_prefix(&path, &path));
17431        }
17432
17433        /// `path_matches_route_prefix` is consistent with its documented
17434        /// contract: a match means either exact equality or a `/`-delimited
17435        /// boundary immediately after the prefix.
17436        #[test]
17437        fn path_matches_route_prefix_boundary(path in "/?[a-z0-9/]{0,24}", prefix in "/?[a-z0-9/]{0,24}") {
17438            if path_matches_route_prefix(&path, &prefix) {
17439                let boundary_ok = path == prefix
17440                    || path.strip_prefix(&prefix).is_some_and(|rest| rest.starts_with('/'));
17441                prop_assert!(boundary_ok, "match without boundary: path={path:?} prefix={prefix:?}");
17442            }
17443        }
17444    }
17445
17446    // `extract_path_params` (openapi-only) never panics on arbitrary input and
17447    // only ever returns non-empty, brace-free parameter names.
17448    #[cfg(feature = "openapi")]
17449    proptest! {
17450        #![proptest_config(ProptestConfig::with_cases(256))]
17451
17452        #[test]
17453        fn extract_path_params_never_panics(path in ".*") {
17454            for name in extract_path_params(&path) {
17455                prop_assert!(!name.is_empty());
17456                let has_brace = name.contains('{') || name.contains('}');
17457                prop_assert!(!has_brace, "param name should be brace-free: {name:?}");
17458            }
17459        }
17460
17461        /// Brace-dense variant of the invariant above. `.*` makes brace
17462        /// characters astronomically rare, so unbalanced-brace inputs like
17463        /// `"{{}"` (the #1721 regression) only surface via lucky CI seeds. This
17464        /// strategy draws exclusively from brace/colon/letter characters so
17465        /// malformed braces are exercised on nearly every case, and a committed
17466        /// regression seed (proptest-regressions/router.txt) pins a brace-dense
17467        /// input (which replays to `"{{iw:}"` under this strategy) that trips the
17468        /// pre-fix brace-in-name bug deterministically.
17469        #[test]
17470        fn extract_path_params_brace_inputs_are_brace_free(path in "[{}a-z:]{0,6}") {
17471            for name in extract_path_params(&path) {
17472                prop_assert!(!name.is_empty());
17473                let has_brace = name.contains('{') || name.contains('}');
17474                prop_assert!(!has_brace, "param name should be brace-free for {path:?}: {name:?}");
17475            }
17476        }
17477    }
17478}