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
8use std::sync::Arc;
9use std::time::Duration;
10
11use crate::app::ScopedGroup;
12use crate::config::AutumnConfig;
13#[cfg(feature = "maud")]
14use crate::error_pages::{self, SharedRenderer};
15use crate::extract::State;
16use crate::idempotency::{IdempotencyLayer, IdempotencyStore, MemoryIdempotencyStore};
17use crate::middleware::RequestIdLayer;
18use crate::middleware::dev;
19use crate::middleware::exception_filter::{
20    ExceptionFilter, ExceptionFilterLayer, ProblemDetailsFilter,
21};
22use crate::route::Route;
23use crate::state::AppState;
24use axum::middleware::Next;
25use axum::response::IntoResponse;
26use http::{Request, StatusCode};
27use thiserror::Error;
28
29pub const DEFAULT_FAVICON_PATH: &str = "/favicon.ico";
30
31/// Errors that can occur during the router build process.
32///
33/// These errors are typically fatal and represent configuration or routing
34/// definition issues that must be fixed before the application can start.
35#[derive(Debug, Error, PartialEq, Eq)]
36pub enum RouterBuildError {
37    /// The session backend configuration is invalid (e.g. Redis without a URL).
38    #[error("invalid session backend configuration: {0}")]
39    InvalidSessionBackend(#[from] crate::session::SessionBackendConfigError),
40    /// The idempotency backend configuration is invalid.
41    #[error("invalid idempotency backend configuration: {0}")]
42    #[allow(dead_code)] // constructed only in the `redis` feature path
43    InvalidIdempotencyBackend(String),
44    /// The submit-token backend configuration is invalid for production — an
45    /// explicit `[security.submit_token].backend = "memory"` cannot safely
46    /// deduplicate submits across replicas. Mirrors the idempotency
47    /// production-memory fail-fast.
48    #[error("invalid submit-token backend configuration: {0}")]
49    InvalidSubmitTokenBackend(String),
50    /// A user-defined route conflicts with a framework-provided route.
51    #[error("framework route overlap at {path}: {existing} conflicts with {incoming}")]
52    FrameworkRouteOverlap {
53        /// The HTTP path where the overlap occurred.
54        path: String,
55        /// The name of the existing framework route.
56        existing: &'static str,
57        /// The name of the incoming user route.
58        incoming: &'static str,
59    },
60    /// An `OpenApiConfig` path (e.g. `openapi_json_path` or
61    /// `swagger_ui_path`) is not a valid route path (must start with `/`
62    /// and be non-empty).
63    #[cfg(feature = "openapi")]
64    #[error("invalid OpenAPI {field} path: {value:?} (must start with '/' and be non-empty)")]
65    InvalidOpenApiPath {
66        /// Which config field carried the invalid path.
67        field: &'static str,
68        /// The offending value from the user's config.
69        value: String,
70    },
71    /// `openapi_json_path` and `swagger_ui_path` collide on the same
72    /// URL. Mounting both would cause axum to panic on overlapping
73    /// method routes at startup.
74    #[cfg(feature = "openapi")]
75    #[error(
76        "openapi_json_path and swagger_ui_path both resolve to {path:?}; they must differ or `swagger_ui_path` must be `None`"
77    )]
78    DuplicateOpenApiPath {
79        /// The path that both fields pointed at.
80        path: String,
81    },
82    /// An `OpenAPI` mount path overlaps with an existing `GET` handler,
83    /// which would panic at `axum::Router::merge` time.
84    #[cfg(feature = "openapi")]
85    #[error(
86        "OpenAPI {field} path {path:?} collides with an existing GET route; choose a different `OpenApiConfig::{field}`"
87    )]
88    OpenApiPathCollision {
89        /// Which config field carried the colliding path.
90        field: &'static str,
91        /// The colliding path.
92        path: String,
93    },
94    /// A route is annotated with an API version that is not registered.
95    #[error("route '{route_name}' uses unregistered API version '{version}'")]
96    UnregisteredApiVersion { route_name: String, version: String },
97    /// The MCP mount path (from [`AppBuilder::mount_mcp`](crate::app::AppBuilder::mount_mcp))
98    /// is not a valid route path. axum requires paths to start with `/`, so an
99    /// invalid path is surfaced here rather than panicking at mount time.
100    #[cfg(feature = "mcp")]
101    #[error("invalid MCP mount path: {value:?} (must start with '/' and be non-empty)")]
102    InvalidMcpPath {
103        /// The offending mount path.
104        value: String,
105    },
106    /// The MCP mount path collides with an existing application route at the
107    /// same path. Mounting the MCP endpoint there would panic at
108    /// `axum::Router::merge` time on overlapping method routes, so this is
109    /// surfaced as a recoverable error instead.
110    #[cfg(feature = "mcp")]
111    #[error(
112        "MCP mount path {path:?} collides with an existing {method} route; choose a different `mount_mcp` path"
113    )]
114    McpPathCollision {
115        /// The colliding mount path.
116        path: String,
117        /// The HTTP method of the existing route at that path.
118        method: String,
119    },
120    /// Two user- or plugin-registered routes resolve to the same
121    /// `(method, path)` after scope-prefix resolution. Mounting both would
122    /// panic inside `axum::routing::MethodRouter::merge` at startup on
123    /// overlapping method routes (issue #1012), so the collision preflight
124    /// surfaces it as a recoverable [`RouterBuildError`] BEFORE any router
125    /// is mounted and names both handlers so the offending call sites are
126    /// obvious in the log.
127    ///
128    /// Opaque routers registered via
129    /// [`AppBuilder::merge`](crate::app::AppBuilder::merge) or
130    /// [`AppBuilder::nest`](crate::app::AppBuilder::nest) are NOT introspectable
131    /// through axum's public API, so a collision that involves one of those
132    /// routers cannot be detected up front and will still surface as an axum
133    /// startup panic — the preflight emits a `tracing::warn!` in that case so
134    /// operators know the check was skipped (mirrors the existing OpenAPI/MCP
135    /// merge-router warnings).
136    #[error(
137        "duplicate user route: {existing:?} and {incoming:?} both resolve to {method} {path:?}; \
138         choose a different path for one of them or remove the duplicate registration"
139    )]
140    DuplicateUserRoute {
141        /// The HTTP method both handlers registered.
142        method: String,
143        /// The URL path both handlers registered (post scope-prefix resolution).
144        path: String,
145        /// The `route.name` of the first (already-seen) handler.
146        existing: String,
147        /// The `route.name` of the second (duplicate) handler that triggered
148        /// the collision.
149        incoming: String,
150    },
151    /// Two user- or plugin-registered routes normalize to the SAME Axum path
152    /// shape but use DIFFERENT exact path templates — e.g. their capture names
153    /// differ (`/users/{id}` vs `/users/{slug}`) or a normal capture meets a
154    /// catch-all at the same position (`/u/{id}` vs `/u/{*rest}`).
155    ///
156    /// axum's matchit router rejects the second template as a route conflict
157    /// *before* method-router merging, so — unlike an exact-duplicate path,
158    /// which axum happily merges across distinct HTTP methods
159    /// ([`DuplicateUserRoute`](Self::DuplicateUserRoute)) — these two templates
160    /// can never coexist REGARDLESS of method. Issue #1012 surfaces the clash
161    /// here (naming both handlers and both original templates) instead of
162    /// letting the matchit conflict panic inside `Router::route` at startup.
163    ///
164    /// Opaque `AppBuilder::merge` / `AppBuilder::nest` routers are exempt for
165    /// the same reason as [`DuplicateUserRoute`](Self::DuplicateUserRoute).
166    #[error(
167        "conflicting route shapes: {existing:?} ({existing_path:?}) and {incoming:?} ({incoming_path:?}) \
168         resolve to the same Axum path shape but use different path templates; axum's matchit router \
169         rejects this as a route conflict regardless of HTTP method — rename the captures so both use the \
170         same template, or make their static paths distinct"
171    )]
172    ConflictingRouteShape {
173        /// The `route.name` of the first (already-seen) handler.
174        existing: String,
175        /// The original path template registered by the first handler.
176        existing_path: String,
177        /// The `route.name` of the second handler that triggered the conflict.
178        incoming: String,
179        /// The original path template registered by the second handler.
180        incoming_path: String,
181    },
182}
183
184/// Build the fully-configured Axum router from routes, config, and state.
185///
186/// Extracted from `AppBuilder::run` so the router construction logic is
187/// testable without binding a real TCP listener.
188///
189/// # Panics
190///
191/// Panics when framework router assembly encounters invalid configuration.
192/// Use [`try_build_router`] to handle configuration errors explicitly.
193#[allow(dead_code)]
194pub fn build_router(
195    route_list: Vec<Route>,
196    config: &AutumnConfig,
197    state: AppState,
198) -> axum::Router {
199    try_build_router(route_list, config, state)
200        .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
201}
202
203/// Checked variant of [`build_router`] that returns configuration errors
204/// instead of panicking.
205///
206/// # Errors
207///
208/// Returns [`RouterBuildError`] when router assembly encounters invalid
209/// framework configuration, such as an unusable session backend.
210pub struct RouterContext {
211    pub exception_filters: Vec<Arc<dyn ExceptionFilter>>,
212    pub scoped_groups: Vec<ScopedGroup>,
213    pub merge_routers: Vec<axum::Router<AppState>>,
214    pub nest_routers: Vec<(String, axum::Router<AppState>)>,
215    /// Custom Tower layers registered via
216    /// [`AppBuilder::layer`](crate::app::AppBuilder::layer). Applied inside
217    /// [`RequestIdLayer`] and the session layer on the ingress path so user
218    /// middleware observes the generated request ID and session context.
219    ///
220    /// **SSG/ISG mode trade-off**: when `dist_dir` is active, layers are
221    /// moved outside the static-first middleware so they can process
222    /// pre-rendered responses (e.g. compression).  As a side effect they also
223    /// run *before* `RequestIdLayer`, session, `MetricsLayer`, and
224    /// `ExceptionFilterLayer` for all requests (static and dynamic).  Layers
225    /// that depend on extensions set by those framework layers — such as the
226    /// request ID or session data — will not find them in SSG mode.
227    pub custom_layers: Vec<crate::app::CustomLayerRegistration>,
228    /// Pre-static gate layers registered via
229    /// [`AppBuilder::static_gate`](crate::app::AppBuilder::static_gate).
230    /// Applied as the **outermost** middleware — outside the session layer and
231    /// ahead of the static-first middleware — so they can auth-gate / redirect
232    /// a request before a cached SSG/ISG page is served. Unlike
233    /// [`custom_layers`](Self::custom_layers), these always run in this
234    /// outermost position in both static and fully-dynamic modes, and never
235    /// see the session extension.
236    pub static_gate_layers: Vec<crate::app::CustomLayerRegistration>,
237    #[cfg(feature = "maud")]
238    pub error_page_renderer: Option<SharedRenderer>,
239    /// Custom session store installed via
240    /// [`AppBuilder::with_session_store`](crate::app::AppBuilder::with_session_store).
241    /// When `Some`, [`apply_session_layer`](crate::session::apply_session_layer)
242    /// uses it directly and skips the config-driven backend selection.
243    pub session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
244    /// `OpenAPI` generation configuration. When `Some`, the router mounts
245    /// an `openapi.json` endpoint and (optionally) a Swagger UI page
246    /// describing the application's routes.
247    ///
248    /// Gated behind the `openapi` feature.
249    #[cfg(feature = "openapi")]
250    pub openapi: Option<crate::openapi::OpenApiConfig>,
251    /// MCP (Model Context Protocol) runtime config. When `Some`, the router
252    /// mounts a Streamable-HTTP MCP endpoint that projects opted-in routes as
253    /// agent-callable tools and dispatches `tools/call` through the real
254    /// handler pipeline.
255    ///
256    /// Gated behind the `mcp` feature.
257    #[cfg(feature = "mcp")]
258    pub mcp: Option<crate::mcp::McpRuntime>,
259}
260
261/// Checked variant of [`build_router`] that returns configuration errors
262/// instead of panicking.
263///
264/// # Errors
265///
266/// Returns [`RouterBuildError`] when router assembly encounters invalid
267/// framework configuration, such as an unusable session backend.
268pub fn try_build_router(
269    route_list: Vec<Route>,
270    config: &AutumnConfig,
271    state: AppState,
272) -> Result<axum::Router, RouterBuildError> {
273    let startup_barrier_state = state.clone();
274    let router = try_build_router_inner(
275        route_list,
276        config,
277        state,
278        RouterContext {
279            exception_filters: Vec::new(),
280            scoped_groups: Vec::new(),
281            merge_routers: Vec::new(),
282            nest_routers: Vec::new(),
283            custom_layers: Vec::new(),
284            static_gate_layers: Vec::new(),
285            #[cfg(feature = "maud")]
286            error_page_renderer: None,
287            session_store: None,
288            #[cfg(feature = "openapi")]
289            openapi: None,
290            #[cfg(feature = "mcp")]
291            mcp: None,
292        },
293    )?;
294    Ok(apply_startup_barrier(
295        router,
296        config,
297        &startup_barrier_state,
298    ))
299}
300
301/// Build a router that includes user-supplied raw Axum routers.
302///
303/// Like [`build_router`], but also merges and nests additional raw
304/// Axum routers. This is primarily useful for integration testing;
305/// in production, use [`AppBuilder::merge`](crate::app::AppBuilder::merge) and [`AppBuilder::nest`](crate::app::AppBuilder::nest).
306///
307/// # Panics
308///
309/// Panics when framework router assembly encounters invalid configuration.
310/// Use [`try_build_router_merged`] to handle configuration errors explicitly.
311#[allow(dead_code)]
312pub fn build_router_merged(
313    route_list: Vec<Route>,
314    config: &AutumnConfig,
315    state: AppState,
316    merge_routers: Vec<axum::Router<AppState>>,
317    nest_routers: Vec<(String, axum::Router<AppState>)>,
318) -> axum::Router {
319    try_build_router_merged(route_list, config, state, merge_routers, nest_routers)
320        .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
321}
322
323/// Checked variant of [`build_router_merged`] that returns configuration
324/// errors instead of panicking.
325///
326/// # Errors
327///
328/// Returns [`RouterBuildError`] when router assembly encounters invalid
329/// framework configuration, such as an unusable session backend.
330#[allow(dead_code)]
331pub fn try_build_router_merged(
332    route_list: Vec<Route>,
333    config: &AutumnConfig,
334    state: AppState,
335    merge_routers: Vec<axum::Router<AppState>>,
336    nest_routers: Vec<(String, axum::Router<AppState>)>,
337) -> Result<axum::Router, RouterBuildError> {
338    let startup_barrier_state = state.clone();
339    let router = try_build_router_inner(
340        route_list,
341        config,
342        state,
343        RouterContext {
344            exception_filters: Vec::new(),
345            scoped_groups: Vec::new(),
346            merge_routers,
347            nest_routers,
348            custom_layers: Vec::new(),
349            static_gate_layers: Vec::new(),
350            #[cfg(feature = "maud")]
351            error_page_renderer: None,
352            session_store: None,
353            #[cfg(feature = "openapi")]
354            openapi: None,
355            #[cfg(feature = "mcp")]
356            mcp: None,
357        },
358    )?;
359    Ok(apply_startup_barrier(
360        router,
361        config,
362        &startup_barrier_state,
363    ))
364}
365
366pub fn try_build_router_inner(
367    route_list: Vec<Route>,
368    config: &AutumnConfig,
369    state: AppState,
370    ctx: RouterContext,
371) -> Result<axum::Router, RouterBuildError> {
372    // Fully-dynamic path: no outer SecurityHeadersLayer is applied after this
373    // returns, so build_router_pre_state applies it (outermost, wrapping the
374    // gate).
375    let router = build_router_pre_state(route_list, config, &state, ctx, None, false)?;
376    Ok(router.with_state(state))
377}
378
379/// Build a probe-only router for the [`Worker`](crate::config::ProcessRole::Worker)
380/// process role.
381///
382/// A worker replica runs job workers and the cron scheduler but serves no user
383/// routes. It still binds the HTTP listener so orchestrators can supervise it,
384/// exposing **only** the framework liveness/readiness/startup/health probes
385/// (per `config.health.*`) and the actuator (`/actuator/*`, so `/actuator/jobs`
386/// works). This mirrors how [`try_build_router_with_static_inner`] finalizes the
387/// full router — same startup barrier and `with_state` — so probe/actuator
388/// behavior is identical, only the user route table is absent.
389///
390/// # Errors
391///
392/// Returns [`RouterBuildError`] when the actuator prefix collides with a probe
393/// path (the same guard the full build path applies).
394pub fn try_build_probe_only_router(
395    config: &AutumnConfig,
396    state: AppState,
397) -> Result<axum::Router, RouterBuildError> {
398    let barrier_state = state.clone();
399    // A worker replica serves no user routes, so nothing can shadow a probe.
400    let no_user_routes = std::collections::HashSet::new();
401    let (mounted_probe_paths, router) =
402        mount_probe_endpoints(axum::Router::<AppState>::new(), config, &no_user_routes);
403    let router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
404    let router = router.with_state(state);
405    Ok(apply_startup_barrier(router, config, &barrier_state))
406}
407
408/// Prepared MCP exposure carried through `build_router_pre_state`: the mount
409/// path, the derived tool catalog, and the optional whole-endpoint auth layer.
410#[cfg(feature = "mcp")]
411type McpPrepared = (
412    String,
413    Vec<crate::mcp::McpToolInfo>,
414    Option<crate::mcp::McpEndpointLayer>,
415);
416
417/// Like [`try_build_router_inner`] but returns `Router<AppState>` before
418/// [`with_state`](axum::Router::with_state) is called.  Used by
419/// [`try_build_router_with_static_inner`] so that user layers and the static
420/// file middleware can be applied to the typed router before state is baked in.
421#[allow(clippy::too_many_lines)]
422fn build_router_pre_state(
423    route_list: Vec<Route>,
424    config: &AutumnConfig,
425    state: &AppState,
426    #[cfg_attr(not(feature = "mcp"), allow(unused_mut))] mut ctx: RouterContext,
427    // When custom_layers are extracted from ctx before this call (SSG path),
428    // the caller pre-computes the flag so the idempotency selector still sees
429    // the real layer list even though ctx.custom_layers is empty.
430    opaque_app_layers_override: Option<bool>,
431    // When true (SSG/ISG path), the `SecurityHeadersLayer` is NOT applied here:
432    // `try_build_router_with_static_inner` applies a single one OUTSIDE the
433    // static-first middleware (wrapping cached pages, dynamic misses, and the
434    // gate), so applying it here too would double-apply it (which breaks CSP
435    // nonces). In the fully-dynamic path this is `false` and the layer is
436    // applied as the outermost framework layer below, wrapping the gate.
437    defer_security_headers: bool,
438) -> Result<axum::Router<AppState>, RouterBuildError> {
439    // Verify registered API versions
440    let versions = state.extension::<crate::app::RegisteredApiVersions>();
441    let registered_versions: std::collections::HashSet<&str> = versions
442        .as_ref()
443        .map(|v| v.0.iter().map(|av| av.version.as_str()).collect())
444        .unwrap_or_default();
445
446    let check_route_version = |route: &Route| -> Result<(), RouterBuildError> {
447        if let Some(version) = route
448            .api_version
449            .filter(|ver| !registered_versions.contains(*ver))
450        {
451            return Err(RouterBuildError::UnregisteredApiVersion {
452                route_name: route.name.to_string(),
453                version: version.to_string(),
454            });
455        }
456        Ok(())
457    };
458
459    for route in &route_list {
460        check_route_version(route)?;
461    }
462    for group in &ctx.scoped_groups {
463        for route in &group.routes {
464            check_route_version(route)?;
465        }
466    }
467
468    // Fail-fast if two user- or plugin-registered routes resolve to the same
469    // `(method, path)` — `group_and_mount_routes` below would otherwise hand
470    // overlapping method routes to `axum::routing::MethodRouter::merge`,
471    // which panics inside `Router::route` at startup (issue #1012). Runs
472    // BEFORE the OpenAPI/MCP preflights so a duplicate user route surfaces
473    // as `DuplicateUserRoute` regardless of which optional subsystem is
474    // configured, and BEFORE any router is mounted so the failure is
475    // structured rather than an axum panic.
476    reject_duplicate_user_routes(
477        &route_list,
478        &ctx.scoped_groups,
479        &ctx.merge_routers,
480        &ctx.nest_routers,
481    )?;
482
483    // Fail-fast if an OpenAPI mount path collides with a user or
484    // framework GET route — axum panics on overlapping method routes,
485    // so surface this as a recoverable error before we start merging.
486    #[cfg(feature = "openapi")]
487    reject_openapi_path_collisions(
488        ctx.openapi.as_ref(),
489        &route_list,
490        &ctx.scoped_groups,
491        &ctx.merge_routers,
492        &ctx.nest_routers,
493        config,
494    )?;
495
496    // Build the OpenAPI spec BEFORE moving the routes into axum, because
497    // group_and_mount_routes consumes the Route list.
498    #[cfg(feature = "openapi")]
499    let openapi_router = build_openapi_router(
500        &route_list,
501        &ctx.scoped_groups,
502        ctx.openapi.as_ref(),
503        &config.session.cookie_name,
504        versions.as_ref().map_or(&[], |v| v.0.as_slice()),
505    )?;
506
507    // Prepare MCP exposure *before* `route_list` is moved into axum below.
508    // Validate the mount path up front (a typo like `"mcp"` surfaces as a
509    // recoverable error, mirroring the OpenAPI path validation, instead of an
510    // axum panic), derive the tool catalog, and carry the optional endpoint
511    // auth layer to be applied once the router is assembled.
512    #[cfg(feature = "mcp")]
513    let mcp_prepared: Option<McpPrepared> = if let Some(rt) = ctx.mcp.take() {
514        let path = rt.mount_path.as_str();
515        // The mount path must be a single static endpoint: reject empty,
516        // non-absolute, doubled-slash, and dynamic (`{capture}` / `{*rest}`)
517        // paths so MCP cannot shadow a whole path class and so the exact-path
518        // collision preflight reserves the concrete URL it actually matches.
519        // Colon-prefixed segments (`/:mcp`, axum 0.7 capture syntax) are also
520        // rejected: axum 0.8's `Router::route` panics on them during assembly
521        // (`validate_v07_paths`), so catching them here yields the recoverable
522        // `InvalidMcpPath` error instead of a startup crash.
523        if path.is_empty()
524            || !path.starts_with('/')
525            || path.contains("//")
526            || path.contains('{')
527            || path.contains('*')
528            || path.split('/').any(|segment| segment.starts_with(':'))
529        {
530            return Err(RouterBuildError::InvalidMcpPath {
531                value: rt.mount_path,
532            });
533        }
534        // The MCP endpoint mounts GET+POST at `mount_path`. If a user, framework,
535        // or OpenAPI route already owns that exact path, the later `merge` would
536        // panic on overlapping method routes; surface it as a recoverable error
537        // first (mirroring the OpenAPI collision preflight).
538        reject_mcp_path_collisions(
539            path,
540            &route_list,
541            &ctx.scoped_groups,
542            config,
543            ctx.openapi.as_ref(),
544            &ctx.merge_routers,
545            &ctx.nest_routers,
546        )?;
547        let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
548        // Pass the app's OpenAPI config (if any) so MCP tool `inputSchema`s
549        // reuse the same registered component schemas as the served spec.
550        let tools = crate::mcp::derive_tools(&docs, rt.expose_all, ctx.openapi.as_ref());
551        Some((rt.mount_path, tools, rt.endpoint_layer))
552    } else {
553        None
554    };
555
556    // Build the per-route timeout override table before `route_list` and the
557    // scoped groups are consumed by the mounting steps below.
558    let route_timeouts = build_route_timeout_table(&route_list, &ctx.scoped_groups);
559
560    let idempotency_layers = build_idempotency_layers(config, state)?;
561    // Both `.layer(..)` custom layers and `.static_gate(..)` gate layers are
562    // opaque app layers for idempotency: an auth/tenant layer in either slot
563    // must force fail-closed replay so a cached mutation can't be served to a
564    // different principal carrying the same Idempotency-Key.
565    let opaque_app_layers_present = opaque_app_layers_override.unwrap_or_else(|| {
566        custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
567            || custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers)
568    });
569    // Capture the paths a user handler already owns BEFORE `route_list` is
570    // consumed below, so the auto-mounted probes can yield to a user route at
571    // the same path instead of panicking on an overlapping `GET` (issue #1971).
572    let user_get_paths = collect_user_get_paths(&route_list, &ctx.scoped_groups);
573
574    let mut router = group_and_mount_routes(
575        route_list,
576        idempotency_layers.as_ref(),
577        opaque_app_layers_present,
578        state,
579    );
580
581    let dev_reload_enabled = dev::is_enabled_with_env(&crate::config::OsEnv);
582
583    router = mount_framework_routes(router, config, dev_reload_enabled);
584
585    let (mounted_probe_paths, router_with_probes) =
586        mount_probe_endpoints(router, config, &user_get_paths);
587    router = router_with_probes;
588
589    router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
590
591    #[cfg(feature = "openapi")]
592    if let Some(openapi_router) = openapi_router {
593        router = router.merge(openapi_router);
594    }
595
596    // Static file serving. Fingerprinted assets (e.g. `autumn.a1b2c3d4.css`)
597    // are served with `Cache-Control: public, max-age=31536000, immutable`; all
598    // other static files use the default browser policy.
599    //
600    // When the app embedded its `static/` tree (feature = "embed-assets" plus a
601    // registered dir), serve `/static/*` from the binary — no disk read, no
602    // sidecar directory. Otherwise serve from the project's `static/` directory
603    // on disk (the dev default, preserving hot-reload).
604    #[cfg(feature = "embed-assets")]
605    let embedded_static = crate::assets::embedded_static_dir().is_some();
606    #[cfg(not(feature = "embed-assets"))]
607    let embedded_static = false;
608
609    if embedded_static {
610        #[cfg(feature = "embed-assets")]
611        {
612            router = router.route(
613                "/static/{*path}",
614                axum::routing::get(crate::assets::serve_embedded),
615            );
616        }
617    } else {
618        let env = crate::config::OsEnv;
619        let static_dir = crate::app::project_dir("static", &env);
620        router = router.nest_service("/static", tower_http::services::ServeDir::new(&static_dir));
621    }
622    router = router.layer(axum::middleware::from_fn(
623        crate::assets::asset_cache_control,
624    ));
625
626    router = mount_scoped_groups(
627        router,
628        ctx.scoped_groups,
629        idempotency_layers.as_ref(),
630        state,
631    );
632
633    router = mount_raw_routers(
634        router,
635        ctx.merge_routers,
636        ctx.nest_routers,
637        idempotency_layers.as_ref(),
638    );
639
640    // Extract the pre-static gate layers (AppBuilder::static_gate) before
641    // applying the rest of the middleware. They are applied LAST — after the MCP
642    // dispatch clone is taken below — so a `tools/call` replay never traverses
643    // the page-cache gate. In the SSG/ISG path the caller already drained these
644    // into `try_build_router_with_static_inner`, so this take yields an empty
645    // list there.
646    let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
647
648    // Built once and shared (by clone — it wraps an `Arc` in-flight counter)
649    // between the direct-route stack below and the late-mounted `/mcp`
650    // envelope further down, so both ingress surfaces admit against the same
651    // ceiling instead of each getting its own independent (never-shared)
652    // counter. See `apply_middleware`'s `load_shed_layer` parameter doc.
653    let load_shed_layer = build_load_shed_layer(config, state);
654    #[cfg(feature = "mcp")]
655    let mcp_load_shed_layer = load_shed_layer.clone();
656
657    router = apply_middleware(
658        router,
659        config,
660        state,
661        ctx.exception_filters,
662        ctx.custom_layers,
663        #[cfg(feature = "maud")]
664        ctx.error_page_renderer,
665        ctx.session_store,
666        route_timeouts,
667        load_shed_layer,
668    )?;
669
670    if dev_reload_enabled {
671        router = router
672            .layer(axum::middleware::from_fn(dev::disable_static_cache))
673            .layer(axum::middleware::from_fn(dev::inject_live_reload));
674    }
675
676    // Dev request inspector: mount UI and apply recording middleware.
677    // Only active when profile = "dev"; returns 404 for all other profiles.
678    let is_dev_profile = matches!(config.profile.as_deref(), Some("dev" | "development"));
679    if is_dev_profile {
680        // Capture the matched route pattern for the dev error overlay.
681        // Applied as a route_layer so MatchedPath is already set when this runs.
682        router = router.route_layer(axum::middleware::from_fn(
683            crate::middleware::dev::capture_matched_path_middleware,
684        ));
685    }
686    if is_dev_profile {
687        let buf = crate::inspector::InspectorBuffer::new(config.dev.inspector_capacity);
688        let inspector_path = config.dev.inspector_path.clone();
689        let threshold = config.dev.inspector_n_plus_one_threshold;
690
691        // Mount the inspector UI routes.
692        router = router.merge(crate::inspector::inspector_router(
693            buf.clone(),
694            &inspector_path,
695        ));
696        tracing::debug!(
697            path = %inspector_path,
698            "Mounted dev request inspector"
699        );
700
701        // Apply the recording middleware (outermost layer so it captures
702        // all routes). Self-excludes inspector's own path prefix.
703        let layer = crate::inspector::InspectorLayer::new(buf, threshold, inspector_path)
704            .with_session_cookie_name(config.session.cookie_name.clone());
705        router = router.layer(layer);
706    }
707
708    #[cfg(feature = "oauth2")]
709    let router = router.layer(axum::middleware::from_fn_with_state(
710        state.clone(),
711        http_interceptor_middleware,
712    ));
713
714    // Install the request's app as the ambient event-bus context so any code in
715    // the request (handlers, services) that calls the free `events::publish`
716    // dispatches against this app rather than the process-global bus — keeping
717    // parallel in-process apps (notably tests) isolated.
718    let router = router.layer(axum::middleware::from_fn_with_state(
719        state.clone(),
720        event_app_context_middleware,
721    ));
722
723    // Mount the MCP endpoint last so its dispatch target — a clone of the
724    // fully-assembled router with state applied — traverses the exact same
725    // routes, layers, and middleware an HTTP request would. The clone is
726    // taken *before* the MCP route is added, so `tools/call` never recurses
727    // into the MCP endpoint itself.
728    //
729    // `static_gate` is intentionally NOT in this dispatch clone, in EITHER mode:
730    // the gate layers are applied after this clone is taken (below, after the MCP
731    // merge, in the fully-dynamic path; outside the static-first middleware in the
732    // SSG/ISG path). A `static_gate` is a page-cache gate whose only action is a
733    // browser redirect/reject, which is meaningless for a JSON-RPC `tools/call`.
734    // MCP/API auth belongs in route-level guards / `#[secured]` / session, which
735    // DO traverse this clone.
736    //
737    // KNOWN LIMITATION (static/ISR mode): when an app has a `dist` manifest,
738    // `try_build_router_with_static_inner` also drains the global custom layers
739    // (`AppBuilder::layer`) and applies them outside the static-first middleware,
740    // after this clone is taken. So in static mode a `tools/call` replay does not
741    // pass through hand-rolled global `.layer(...)` middleware (it would in the
742    // fully-dynamic path, where custom layers are applied via `apply_middleware`
743    // before the clone). Restoring full parity for custom layers would require
744    // making the appliers re-usable (they are `FnOnce` today), so this is left
745    // documented rather than fixed for that narrow combination.
746    #[cfg(feature = "mcp")]
747    let router = if let Some((mount_path, tools, endpoint_layer)) = mcp_prepared {
748        // The framework's outermost `SecurityHeadersLayer` is applied AFTER this
749        // clone (below, with the gate), so the dispatch snapshot would otherwise
750        // miss it. That layer also injects `CspNonce` into request extensions, so
751        // without it a `tools/call` replay of a handler using the `CspNonce`
752        // extractor would 500 when `csp_nonce` is enabled. Re-attach it to the
753        // dispatch clone only: a direct HTTP request gets the same layer via the
754        // outer application, and the replay's response headers are discarded when
755        // `serve_mcp` rebuilds the JSON-RPC envelope, so there is no duplicate
756        // live header. (The gate is intentionally NOT re-attached here — a browser
757        // redirect/reject is meaningless for JSON-RPC dispatch.)
758        let dispatch = router
759            .clone()
760            .layer(crate::security::SecurityHeadersLayer::from_config(
761                &config.security.headers,
762            ))
763            .with_state(state.clone());
764        // For header-based tenancy, forward the configured tenant header on
765        // dispatch so tenant-scoped tools resolve the same tenant a direct HTTP
766        // call would. Other sources key off already-forwarded headers/Host.
767        let tenant_header = (config.tenancy.enabled && config.tenancy.source == "header")
768            .then(|| config.tenancy.header_name.clone());
769        let wiring = crate::mcp::McpWiring {
770            // The CORS config drives the cross-origin Origin allowlist and the
771            // endpoint's own OPTIONS preflight responses.
772            cors: config.cors.clone(),
773            // The same-origin shortcut is gated on the app's trusted-Host
774            // policy so it can't be abused for DNS rebinding.
775            trusted_hosts: TrustedHostPolicy::from_config(config),
776            tenant_header,
777            // Forward the configured CSRF header (default `x-csrf-token`) so
778            // customized CsrfConfig::token_header deployments work via MCP.
779            csrf_header: config.security.csrf.token_header.to_ascii_lowercase(),
780            // The envelope is rate-limited below iff rate limiting is enabled;
781            // when so, a tools/call is counted there and its replay is exempted
782            // from the dispatch pipeline's limiter (avoiding double-counting).
783            envelope_rate_limited: config.security.rate_limit.enabled,
784            // `dispatch` above is cloned from `router`, which already carries
785            // `load_shed_layer` (applied inside `apply_middleware`) — so when
786            // the envelope below is ALSO wrapped with that same shared layer,
787            // a tools/call must mark its replay exempt (avoiding double-
788            // counting against the same in-flight counter).
789            envelope_load_shed: mcp_load_shed_layer.is_some(),
790        };
791        let mut mcp_router =
792            crate::mcp::build_mcp_router(&mount_path, tools, dispatch, wiring, endpoint_layer);
793        // NOTE: the inbound request-timeout layer for this envelope is applied
794        // further down, *outer* to the rate-limit layer (search for
795        // `apply_request_timeout_middleware` below). It must wrap the limiter so a
796        // stalled Redis rate-limit decision is bounded by `request_timeout_ms`,
797        // matching the main stack where `apply_middleware` installs the timeout
798        // outer to `apply_rate_limit_middleware`.
799        // Gate the envelope under maintenance mode, mirroring the layer
800        // `apply_middleware` installs for direct routes. The `/mcp` router is
801        // merged after that layer, so without this `initialize`/`tools/list`
802        // would keep serving the tool catalog during maintenance (the
803        // `tools/call` replay is already gated — the dispatch clone carries the
804        // layer). Applied before the `TrustedProxiesLayer` below so it is inner
805        // to it: the maintenance IP allow-list then reads the proxy-resolved
806        // identity, exactly as the direct-route layer does, instead of a
807        // spoofable raw `X-Forwarded-For`.
808        mcp_router = mcp_router.layer(build_maintenance_layer(config, state));
809        // Admission control / load shedding (#1006), mirroring the layer
810        // `apply_middleware` installs for direct routes (see the comment
811        // there). The `/mcp` router is merged after that layer, so without
812        // this, `initialize`/`tools/list`/`tools/call` would bypass
813        // `server.max_concurrent_requests` entirely. Reuses the SAME
814        // `load_shed_layer` instance passed to `apply_middleware` above
815        // (cloned, sharing its `Arc` in-flight counter) rather than building
816        // a second, independently-counting layer — see that call site's
817        // comment. `None` (the default) is a no-op, matching direct routes.
818        if let Some(load_shed) = mcp_load_shed_layer {
819            mcp_router = mcp_router.layer(load_shed);
820        }
821        // Stamp `ResolvedClientIdentity` on the *outer* `/mcp` request too. The
822        // MCP route is merged after `apply_middleware`, so the centralized
823        // `TrustedProxiesLayer` above does not wrap it; without this, the
824        // endpoint's own DNS-rebinding / same-origin check would fall back to
825        // the raw (possibly proxy-rewritten) `Host` and wrongly 403 a
826        // same-origin browser client behind a TLS-terminating proxy. The
827        // dispatch clone already carries its own copy of this layer.
828        mcp_router = apply_trusted_proxies_middleware(mcp_router, config);
829        // The MCP route is merged after `apply_upload_middleware`, so axum's
830        // built-in 2 MiB `DefaultBodyLimit` — not the app's configured limit —
831        // would otherwise govern the `tools/call` envelope's `Bytes` body. Apply
832        // the same cap a direct JSON endpoint gets so larger-but-valid tool
833        // payloads aren't rejected before dispatch.
834        mcp_router = mcp_router.layer(axum::extract::DefaultBodyLimit::max(
835            config.security.upload.max_request_size_bytes,
836        ));
837        // Rate-limit the envelope so `secure_mcp` auth rejections — which never
838        // reach the dispatch clone's limiter — are throttled (credential
839        // guessing otherwise consumes no per-client bucket). A successful
840        // tools/call is counted once here and replayed with `RateLimitExempt`,
841        // so it isn't double-counted by the dispatch pipeline's own limiter.
842        // No-op when rate limiting is disabled (matching `envelope_rate_limited`).
843        //
844        // KNOWN LIMITATION (key_strategy = AuthenticatedPrincipal + session
845        // auth): the envelope keys on the IP fallback because the session layer
846        // — which `populate_rate_limit_principal` reads the principal from — is
847        // applied inside `apply_middleware` and does not wrap this late-merged
848        // router, so no `RateLimitPrincipal` is resolved here. Because the
849        // tools/call replay is then exempted, the dispatch clone's
850        // principal-aware limiter is skipped too, so a session-authenticated MCP
851        // call does not consume the same per-user bucket a direct request would
852        // (the framework only derives `RateLimitPrincipal` from the session).
853        mcp_router = apply_rate_limit_middleware(mcp_router, config, state);
854        // Bound the whole envelope — the rate-limit decision (a stalled
855        // Redis-backed limiter would otherwise tie up `/mcp` indefinitely), the
856        // metadata/auth work (initialize, tools/list, and `secure_mcp` auth
857        // rejections that never reach the dispatch clone), and the in-process
858        // `tools/call` dispatch — by the global inbound deadline. The `/mcp`
859        // router is merged after `apply_middleware`, so the timeout layer
860        // installed there does NOT wrap it; without this the prod global deadline
861        // would not bound this surface. Applied here, outer to the rate-limit
862        // layer above (matching the main stack, where `apply_middleware` installs
863        // the timeout outer to `apply_rate_limit_middleware`) but inner to the
864        // security-header and CORS layers below, so a stalled limiter is bounded
865        // while the timeout 503 still flows out through those layers and stays
866        // CORS-readable. Route-level overrides do not apply to the fixed mount
867        // path, so an empty override table is passed (the layer is a no-op when
868        // the global timeout is disabled).
869        //
870        // KNOWN LIMITATION (tools/call vs per-route timeout): this envelope timer
871        // wraps the whole POST, including the in-process `tools/call` dispatch
872        // replay, with the global default deadline. The dispatch clone carries
873        // its own per-route timeout layer, but it is *inner* to this one, so a
874        // tool whose route declares `timeout = "off"` or a longer `timeout_ms`
875        // is still capped at the global default when invoked via MCP (it runs
876        // unbounded / longer over a direct HTTP call). Honoring the per-route
877        // policy here would require propagating the dispatched route's timeout
878        // out to this single fixed-path endpoint, which has no per-route
879        // distinction at the layer level; the global deadline is kept as a
880        // safety bound instead. `mirror_cors = false`: the 503 already flows out
881        // through this router's own (outer) `CorsLayer` from `apply_mcp_cors_layer`.
882        mcp_router = apply_request_timeout_middleware(
883            mcp_router,
884            config,
885            state.metrics.clone(),
886            std::sync::Arc::new(std::collections::HashMap::new()),
887            false,
888        );
889        // Security headers (HSTS/CSP/etc.), mirroring the `SecurityHeadersLayer`
890        // `apply_middleware` installs for direct routes. The `/mcp` router is
891        // merged after that layer, so without this the envelope's responses —
892        // `initialize`/`tools/list`, auth 401/403, and rate-limit 429 — would
893        // ship without the configured `security.headers` every direct route
894        // carries. (The `tools/call` replay's headers are produced on the
895        // dispatch clone and discarded when `serve_mcp` rebuilds the JSON-RPC
896        // response, so the envelope needs its own copy.)
897        mcp_router = mcp_router.layer(crate::security::SecurityHeadersLayer::from_config(
898            &config.security.headers,
899        ));
900        // CORS grant outermost so every response — including auth 401/403, the
901        // 413 body-limit rejection, and a 429 from the limiter above, all
902        // produced before `serve_mcp` runs — is readable by an allowlisted
903        // browser client instead of being masked as a CORS failure.
904        mcp_router = crate::mcp::apply_mcp_cors_layer(mcp_router, &config.cors);
905        router.merge(mcp_router)
906    } else {
907        router
908    };
909
910    // Apply the pre-static gate and the framework's outermost `SecurityHeadersLayer`
911    // LAST, after the MCP dispatch clone above was taken. This keeps the gate out
912    // of the `tools/call` dispatch path in fully-dynamic mode (matching the SSG/ISG
913    // path and the documented intent that a browser redirect/reject is meaningless
914    // for JSON-RPC dispatch), while still running the gate before session and the
915    // static cache for ordinary HTTP requests. `SecurityHeadersLayer` is applied
916    // outermost so a gate redirect/401 short-circuit still carries HSTS/CSP/nosniff;
917    // a single application keeps CSP nonces consistent.
918    //
919    // In the SSG/ISG path `defer_security_headers` is true and the gate layers were
920    // drained by `try_build_router_with_static_inner` (which applies both the gate
921    // and the single outer `SecurityHeadersLayer` outside the static-first
922    // middleware), so this block is a no-op there.
923    let router = if defer_security_headers {
924        router
925    } else {
926        let router =
927            apply_layers_in_registration_order(router, static_gate_layers, "Pre-static gate");
928        router.layer(crate::security::SecurityHeadersLayer::from_config(
929            &config.security.headers,
930        ))
931    };
932
933    Ok(router)
934}
935
936/// Parse `{name}` captures from a route path.
937///
938/// Mirrors the compile-time extractor in `autumn_macros::api_doc` so
939/// runtime spec assembly (which sees scope prefixes that the macro
940/// never does) produces consistent parameter lists.
941#[cfg(feature = "openapi")]
942pub fn extract_path_params(path: &str) -> Vec<String> {
943    let mut out = Vec::new();
944    let mut remaining = path;
945
946    while let Some(start) = remaining.find('{') {
947        let after_brace = &remaining[start + 1..];
948        // `{{` is an escaped literal brace (matchit renders `{{`/`}}` as literal
949        // `{`/`}`), not a parameter. Skip the escaped brace and continue,
950        // mirroring `autumn_macros::api_doc::extract_path_params`. The prior
951        // `rfind`-based variant dropped this branch and so injected a phantom
952        // param for valid escaped-brace routes (`{{hello}}` -> `hello`).
953        if let Some(rest) = after_brace.strip_prefix('{') {
954            remaining = rest;
955            continue;
956        }
957        let Some(end_rel) = after_brace.find('}') else {
958            break;
959        };
960
961        let inner = &after_brace[..end_rel];
962        // Isolate the parameter name from any `:constraint` suffix
963        // (`{id:[0-9]+}` -> `id`).
964        let name = inner.split(':').next().unwrap_or(inner).trim();
965        // Brace-free guard: only emit a name that is non-empty and contains no
966        // stray brace. On nested/unbalanced input the inner segment may still
967        // hold a `{` (e.g. `"{a{b}"` -> inner `"a{b"`); dropping such names
968        // keeps the emitted list brace-free, which the macro algorithm alone
969        // would not (#1721).
970        if !name.is_empty() && !name.contains('{') && !name.contains('}') {
971            out.push(name.to_owned());
972        }
973
974        remaining = &after_brace[end_rel + 1..];
975    }
976
977    out
978}
979
980/// Handler that dynamically constructs the `OpenAPI` specification document per request
981/// so deprecation and sunset statuses do not go stale.
982#[cfg(feature = "openapi")]
983async fn serve_openapi_spec(
984    state: axum::extract::State<AppState>,
985    axum::extract::Extension(config): axum::extract::Extension<
986        std::sync::Arc<crate::openapi::OpenApiConfig>,
987    >,
988    axum::extract::Extension(docs): axum::extract::Extension<
989        std::sync::Arc<Vec<crate::openapi::ApiDoc>>,
990    >,
991) -> impl axum::response::IntoResponse {
992    use axum::response::IntoResponse;
993    let refs: Vec<&crate::openapi::ApiDoc> = docs.iter().collect();
994    let now = state.clock().now();
995    let spec = crate::openapi::generate_spec_at(&config, &refs, now);
996    let spec_json = serde_json::to_string_pretty(&spec)
997        .unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize spec: {e}\"}}"));
998    (
999        [(http::header::CONTENT_TYPE, "application/json")],
1000        spec_json,
1001    )
1002        .into_response()
1003}
1004
1005/// Build an Axum sub-router that serves the generated `OpenAPI` document
1006/// and (optionally) a Swagger UI HTML page.
1007///
1008/// Returns `None` when `OpenAPI` generation is disabled, i.e. the user
1009/// never called [`AppBuilder::openapi`](crate::app::AppBuilder::openapi).
1010///
1011/// The spec is dynamically generated on request to prevent lifecycle status from going stale.
1012#[cfg(feature = "openapi")]
1013fn build_openapi_router(
1014    route_list: &[Route],
1015    scoped_groups: &[ScopedGroup],
1016    openapi_config: Option<&crate::openapi::OpenApiConfig>,
1017    session_cookie_name: &str,
1018    api_versions: &[crate::app::ApiVersion],
1019) -> Result<Option<axum::Router<AppState>>, RouterBuildError> {
1020    let Some(config) = openapi_config else {
1021        return Ok(None);
1022    };
1023    let mut config = config.clone();
1024    session_cookie_name.clone_into(&mut config.session_cookie_name);
1025    config.api_versions = api_versions.to_vec();
1026
1027    // Validate user-provided paths up front so a typo like
1028    // `"openapi.json"` surfaces as a recoverable RouterBuildError
1029    // rather than an axum panic (`Paths must start with a '/'`).
1030    validate_route_path("openapi_json_path", &config.openapi_json_path)?;
1031    if let Some(path) = &config.swagger_ui_path {
1032        validate_route_path("swagger_ui_path", path)?;
1033        // Registering two GET handlers on the same path would cause an
1034        // axum `Route::route` panic, so reject collisions as a
1035        // configuration error instead.
1036        if path == &config.openapi_json_path {
1037            return Err(RouterBuildError::DuplicateOpenApiPath { path: path.clone() });
1038        }
1039    }
1040
1041    let docs = collect_openapi_docs(route_list, scoped_groups);
1042
1043    let json_path = config.openapi_json_path.clone();
1044    let swagger_path = config.swagger_ui_path.clone();
1045    let title = config.title.clone();
1046
1047    let mut router = axum::Router::<AppState>::new()
1048        .route(&json_path, axum::routing::get(serve_openapi_spec))
1049        .layer(axum::extract::Extension(std::sync::Arc::new(
1050            config.clone(),
1051        )))
1052        .layer(axum::extract::Extension(std::sync::Arc::new(docs)));
1053
1054    if let Some(path) = swagger_path {
1055        router = mount_swagger_ui_routes(router, &path, &title, &json_path);
1056    }
1057
1058    tracing::debug!(
1059        openapi_json = %json_path,
1060        swagger_ui = ?config.swagger_ui_path,
1061        swagger_ui_version = crate::openapi::SWAGGER_UI_VERSION,
1062        "Mounted OpenAPI endpoints"
1063    );
1064
1065    Ok(Some(router))
1066}
1067
1068/// Join a nest/scope prefix with a child route path, matching
1069/// `axum::Router::nest` normalization.
1070///
1071/// `nest("/api", r)` mounts r's `/` at `/api` (not `/api/`), and any
1072/// other child path `/foo` at `/api/foo`. The collision check and the
1073/// path emitted into the `OpenAPI` spec must use the same shape or we
1074/// end up either missing real collisions (the reviewer's case:
1075/// `/api` + `/` recorded as `/api/` but axum routes it at `/api`) or
1076/// generating a spec whose URLs don't match what axum serves.
1077#[allow(dead_code)]
1078pub fn join_nested_path(prefix: &str, child: &str) -> String {
1079    if child == "/" || child.is_empty() {
1080        // axum mounts the root child at the prefix *verbatim*, keeping any
1081        // trailing slash: `nest("/api", route("/"))` is served at "/api" while
1082        // `nest("/api/", route("/"))` is served at "/api/" — and `MatchedPath`
1083        // reports the same string. Preserve the prefix as-is so the per-route
1084        // timeout table keys by exactly what the runtime looks up; only the
1085        // empty (root) prefix collapses to "/".
1086        if prefix.is_empty() {
1087            "/".to_owned()
1088        } else {
1089            prefix.to_owned()
1090        }
1091    } else {
1092        // Non-root children always join on a single slash, matching axum (e.g.
1093        // `nest("/api/", route("/users"))` resolves to "/api/users").
1094        let prefix_trimmed = prefix.trim_end_matches('/');
1095        if child.starts_with('/') {
1096            format!("{prefix_trimmed}{child}")
1097        } else {
1098            format!("{prefix_trimmed}/{child}")
1099        }
1100    }
1101}
1102
1103/// Shared validator for user-supplied `OpenAPI` mount paths.
1104///
1105/// Catches the common typos that would otherwise manifest as axum
1106/// panics inside `Router::route` at startup:
1107///
1108/// * empty or missing leading slash,
1109/// * unbalanced `{` / `}` pairs,
1110/// * any `{…}` / `{*…}` capture or wildcard syntax (the mount points
1111///   are static endpoints — a user that needs templated paths shouldn't
1112///   be using this field), and
1113/// * any `*` wildcard character (axum treats these as catch-alls).
1114///
1115/// The check intentionally stays conservative: rejecting a few valid-
1116/// but-weird paths is far better than letting a typo like
1117/// `"openapi.json"` or `"/docs/{id}"` crash boot.
1118#[cfg(feature = "openapi")]
1119fn validate_route_path(field: &'static str, value: &str) -> Result<(), RouterBuildError> {
1120    let reject = |reason_fragment: &str| {
1121        Err(RouterBuildError::InvalidOpenApiPath {
1122            field,
1123            value: format!("{value:?} {reason_fragment}"),
1124        })
1125    };
1126
1127    if value.is_empty() {
1128        return reject("(must be non-empty)");
1129    }
1130    if !value.starts_with('/') {
1131        return reject("(must start with '/')");
1132    }
1133    // Double-slash inside the path is almost always a typo (e.g.
1134    // `//v3/api-docs`) and axum normalizes it away on match, so
1135    // treating it as invalid avoids surprising "route can't be hit"
1136    // reports in the field.
1137    if value.contains("//") {
1138        return reject("(must not contain '//')");
1139    }
1140
1141    let mut depth: i32 = 0;
1142    for ch in value.chars() {
1143        match ch {
1144            '{' => depth += 1,
1145            '}' => {
1146                depth -= 1;
1147                if depth < 0 {
1148                    return reject("(unbalanced '}')");
1149                }
1150            }
1151            '*' => return reject("(wildcard '*' is not allowed in an OpenAPI mount path)"),
1152            _ => {}
1153        }
1154    }
1155    if depth != 0 {
1156        return reject("(unbalanced '{')");
1157    }
1158    if value.contains('{') {
1159        return reject("(OpenAPI mount paths must be static; `{…}` captures are not allowed)");
1160    }
1161    Ok(())
1162}
1163
1164/// Collect the exact `GET`/`WS` paths owned by the user's *typed* route table
1165/// (top-level routes plus scoped-group routes, after scope-prefix resolution).
1166///
1167/// Unlike [`collect_claimed_get_paths`], this deliberately excludes every
1168/// framework-mounted path: its sole purpose is to let the auto-mounted probe
1169/// endpoints ([`mount_probe_endpoints`]) detect when a *user* handler already
1170/// owns a probe path and yield to it, rather than panicking inside
1171/// `axum::Router::route` on an overlapping `GET` (issue #1971). A `WS` route is
1172/// a `GET` under the hood, so it claims the path too (mirroring
1173/// [`collect_claimed_get_paths`]). Opaque routers registered via
1174/// [`AppBuilder::merge`](crate::app::AppBuilder::merge) /
1175/// [`AppBuilder::nest`](crate::app::AppBuilder::nest) are not introspectable
1176/// and so are not covered here — the same limitation the OpenAPI/MCP collision
1177/// preflights carry.
1178fn collect_user_get_paths(
1179    route_list: &[Route],
1180    scoped_groups: &[ScopedGroup],
1181) -> std::collections::HashSet<String> {
1182    let mut owned: std::collections::HashSet<String> = std::collections::HashSet::new();
1183    for route in route_list {
1184        if route.method == http::Method::GET || route.method.as_str() == "WS" {
1185            owned.insert(route.path.to_owned());
1186        }
1187    }
1188    for group in scoped_groups {
1189        for route in &group.routes {
1190            if route.method == http::Method::GET || route.method.as_str() == "WS" {
1191                owned.insert(join_nested_path(&group.prefix, route.path));
1192            }
1193        }
1194    }
1195    owned
1196}
1197
1198/// Gather every path that a `GET` (or `WS`, which mounts as a `GET`) handler
1199/// will already own by the time a late-merged sub-router (`OpenAPI` or MCP) is
1200/// added: user routes (top-level + scoped groups) plus framework-mounted `GET`s
1201/// (probes, actuator, htmx assets, dev live-reload, mail previews). Shared by
1202/// the `OpenAPI` and MCP mount-collision preflights so they stay in lockstep.
1203#[cfg(feature = "openapi")]
1204fn collect_claimed_get_paths(
1205    route_list: &[Route],
1206    scoped_groups: &[ScopedGroup],
1207    config: &AutumnConfig,
1208) -> std::collections::HashSet<String> {
1209    let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
1210    for route in route_list {
1211        if route.method == http::Method::GET || route.method.as_str() == "WS" {
1212            claimed.insert(route.path.to_owned());
1213        }
1214    }
1215    for group in scoped_groups {
1216        for route in &group.routes {
1217            if route.method == http::Method::GET || route.method.as_str() == "WS" {
1218                claimed.insert(join_nested_path(&group.prefix, route.path));
1219            }
1220        }
1221    }
1222    // Framework-mounted GETs.
1223    claimed.insert(config.health.path.clone());
1224    claimed.insert(config.health.live_path.clone());
1225    claimed.insert(config.health.ready_path.clone());
1226    claimed.insert(config.health.startup_path.clone());
1227    for path in crate::actuator::actuator_endpoint_paths(
1228        &config.actuator.prefix,
1229        config.actuator.sensitive,
1230        config.actuator.prometheus,
1231    ) {
1232        claimed.insert(path);
1233    }
1234    #[cfg(feature = "htmx")]
1235    {
1236        // Only claim the htmx path when the built-in handler is actually
1237        // mounted; when htmx is vendored via `autumn assets`, ServeDir serves
1238        // the file and the path must not appear in the claimed-routes set.
1239        if !crate::assets::htmx_is_vendored() {
1240            claimed.insert(crate::htmx::HTMX_JS_PATH.to_owned());
1241        }
1242        claimed.insert(crate::htmx::HTMX_CSRF_JS_PATH.to_owned());
1243        claimed.insert(crate::htmx::AUTUMN_WIDGETS_JS_PATH.to_owned());
1244        claimed.insert(crate::htmx::IDIOMORPH_JS_PATH.to_owned());
1245        claimed.insert(crate::htmx::HTMX_SSE_JS_PATH.to_owned());
1246    }
1247    // Framework CSS routes (flash/widget stylesheets) merge a GET
1248    // unconditionally whenever their feature is on, before the late-merged
1249    // OpenAPI/MCP routers — reserve them so a colliding configured path
1250    // surfaces the typed collision error instead of panicking in
1251    // `router.merge`.
1252    #[cfg(feature = "flash")]
1253    claimed.insert(crate::flash::FLASH_CSS_PATH.to_owned());
1254    #[cfg(feature = "maud")]
1255    claimed.insert(crate::ui::WIDGETS_CSS_PATH.to_owned());
1256    // Dev live-reload endpoints are only mounted when the env vars
1257    // that enable them are set, but reserving the paths regardless
1258    // makes the error message deterministic across dev/prod.
1259    if dev::is_enabled_with_env(&crate::config::OsEnv) {
1260        claimed.insert(dev::LIVE_RELOAD_PATH.to_owned());
1261        claimed.insert(dev::LIVE_RELOAD_SCRIPT_PATH.to_owned());
1262    }
1263    // The dev request inspector merges a GET at `config.dev.inspector_path`
1264    // (only under the dev profile), before the late-merged OpenAPI/MCP routers.
1265    // Reserve it so a mount path colliding with the inspector surfaces a
1266    // recoverable error instead of panicking in `router.merge`.
1267    if matches!(config.profile.as_deref(), Some("dev" | "development")) {
1268        claimed.insert(config.dev.inspector_path.clone());
1269    }
1270    #[cfg(feature = "mail")]
1271    if config
1272        .mail
1273        .preview_routes_enabled(config.profile.as_deref())
1274    {
1275        claimed.insert(crate::mail::MAIL_PREVIEW_PATH.to_owned());
1276        claimed.insert("/_autumn/mail/messages/{message_id}".to_owned());
1277        claimed.insert("/_autumn/mail/previews/{mailer}/{method}".to_owned());
1278    }
1279    // The widget story gallery merges GETs at `/_stories` and
1280    // `/_stories/{slug}` when `stories.enabled` resolves true, before the
1281    // late-merged OpenAPI/MCP routers — reserve them so a colliding
1282    // configured mount path surfaces the typed collision error instead of
1283    // panicking in `router.merge`.
1284    #[cfg(feature = "maud")]
1285    if config.stories.enabled {
1286        claimed.insert(crate::stories::STORIES_PATH.to_owned());
1287        claimed.insert("/_stories/{slug}".to_owned());
1288    }
1289    // The default unsubscribe endpoint merges a GET (+POST) at `UNSUBSCRIBE_PATH`
1290    // before the late-merged OpenAPI/MCP routers, so reserve it too — otherwise an
1291    // OpenAPI/MCP mount configured at `/_autumn/unsubscribe` passes this preflight
1292    // and then panics in `router.merge` instead of surfacing the typed collision.
1293    #[cfg(feature = "mail")]
1294    if config.mail.should_mount_unsubscribe_endpoint() {
1295        claimed.insert(crate::mail::UNSUBSCRIBE_PATH.to_owned());
1296    }
1297    // The tracked-job status endpoint merges a GET before the late-merged
1298    // OpenAPI/MCP routers, so reserve it too (same rationale as unsubscribe
1299    // above): an OpenAPI/MCP mount at this path should surface the typed
1300    // collision instead of panicking in `router.merge`.
1301    if config.jobs.tracking.route_enabled {
1302        claimed.insert(crate::job_tracking::JOB_STATUS_ROUTE_PATH.to_owned());
1303    }
1304    claimed
1305}
1306
1307/// Reject an MCP mount path that overlaps with a route already owning that
1308/// path. The MCP endpoint mounts `GET`+`POST` at `mount_path`; merging it would
1309/// panic in axum if a `GET` (any user/framework route) or `POST` (a user route)
1310/// already lives there. We surface a recoverable
1311/// [`RouterBuildError::McpPathCollision`] instead, reusing the same claimed-GET
1312/// gathering as the `OpenAPI` preflight so framework routes (health/probe,
1313/// actuator, htmx, dev) are covered too — e.g. `mount_mcp(config.health.path)`.
1314/// The configured `OpenAPI` JSON/UI/asset paths (which merge as `GET`s before
1315/// the MCP router) are checked as well.
1316#[cfg(feature = "mcp")]
1317fn reject_mcp_path_collisions(
1318    mount_path: &str,
1319    route_list: &[Route],
1320    scoped_groups: &[ScopedGroup],
1321    config: &AutumnConfig,
1322    openapi: Option<&crate::openapi::OpenApiConfig>,
1323    merge_routers: &[axum::Router<AppState>],
1324    nest_routers: &[(String, axum::Router<AppState>)],
1325) -> Result<(), RouterBuildError> {
1326    let mut claimed_get = collect_claimed_get_paths(route_list, scoped_groups, config);
1327    // The OpenAPI JSON/Swagger-UI endpoints (and UI assets) merge as GETs
1328    // before the MCP router, so a mount path colliding with them would panic.
1329    if let Some(openapi) = openapi {
1330        claimed_get.insert(openapi.openapi_json_path.clone());
1331        if let Some(ui_path) = &openapi.swagger_ui_path {
1332            claimed_get.insert(ui_path.clone());
1333            claimed_get.extend(crate::openapi::swagger_ui_asset_paths(ui_path));
1334        }
1335    }
1336    if claimed_get.contains(mount_path) {
1337        return Err(RouterBuildError::McpPathCollision {
1338            path: mount_path.to_owned(),
1339            method: "GET".to_owned(),
1340        });
1341    }
1342    // POST handlers come from user routes (framework routes are GETs).
1343    let post_owns_path = route_list
1344        .iter()
1345        .any(|route| route.method == http::Method::POST && route.path == mount_path)
1346        || scoped_groups.iter().any(|group| {
1347            group.routes.iter().any(|route| {
1348                route.method == http::Method::POST
1349                    && join_nested_path(&group.prefix, route.path) == mount_path
1350            })
1351        });
1352    if post_owns_path {
1353        return Err(RouterBuildError::McpPathCollision {
1354            path: mount_path.to_owned(),
1355            method: "POST".to_owned(),
1356        });
1357    }
1358    // A nest prefix P owns every route under P (`/P/...`), and those raw routers
1359    // are mounted before the MCP router. A mount path equal to P or falling
1360    // under `P/` would be shadowed by (or panic against) the nested router, so
1361    // reject it up front — mirroring the OpenAPI nest-collision preflight. The
1362    // framework unconditionally nests the static-file service at `/static`, so
1363    // reserve that prefix too.
1364    let nest_prefixes = nest_routers
1365        .iter()
1366        .map(|(prefix, _)| prefix.as_str())
1367        .chain(std::iter::once("/static"));
1368    for prefix in nest_prefixes {
1369        let prefix_slash = format!("{prefix}/");
1370        if mount_path == prefix || mount_path.starts_with(&prefix_slash) {
1371            return Err(RouterBuildError::McpPathCollision {
1372                path: mount_path.to_owned(),
1373                method: "nested router".to_owned(),
1374            });
1375        }
1376    }
1377    // Raw merged routers are opaque — axum does not expose their route table —
1378    // so an overlapping handler there would still panic at merge time. Warn so
1379    // operators know the check can't cover this case (mirrors the OpenAPI one).
1380    if !merge_routers.is_empty() {
1381        tracing::warn!(
1382            mcp_mount_path = %mount_path,
1383            merged_routers = merge_routers.len(),
1384            "MCP mount collision check skipped for AppBuilder::merge routers: \
1385             axum does not expose their route table, so an overlapping handler \
1386             will still panic at startup. Choose an MCP mount path that doesn't \
1387             overlap with any merged router's handlers."
1388        );
1389    }
1390    Ok(())
1391}
1392
1393/// Reject `OpenAPI` mount paths that overlap with an existing `GET`
1394/// handler.
1395///
1396/// `axum::Router::merge` panics when the merged routers have method
1397/// handlers on the same path (e.g. two `GET` handlers on
1398/// `/v3/api-docs`). We surface that as a recoverable
1399/// [`RouterBuildError::OpenApiPathCollision`] so misconfiguration
1400/// produces an actionable error instead of a crash on startup.
1401///
1402/// We check against:
1403/// * user routes (top-level + scoped groups) that will be mounted
1404///   before the `OpenAPI` sub-router merges in,
1405/// * framework `GET`s: probes, actuator, htmx assets, and dev
1406///   live-reload when enabled,
1407/// * nest prefixes from [`AppBuilder::nest`](crate::app::AppBuilder::nest)
1408///   when the `OpenAPI` path falls under one.
1409///
1410/// Raw routers passed to [`AppBuilder::merge`](crate::app::AppBuilder::merge)
1411/// cannot be introspected — axum does not expose their route table.
1412/// We emit a `tracing::warn!` so operators know the check is
1413/// incomplete in that case.
1414#[cfg(feature = "openapi")]
1415fn reject_openapi_path_collisions(
1416    openapi_config: Option<&crate::openapi::OpenApiConfig>,
1417    route_list: &[Route],
1418    scoped_groups: &[ScopedGroup],
1419    merge_routers: &[axum::Router<AppState>],
1420    nest_routers: &[(String, axum::Router<AppState>)],
1421    config: &AutumnConfig,
1422) -> Result<(), RouterBuildError> {
1423    let Some(openapi) = openapi_config else {
1424        return Ok(());
1425    };
1426
1427    // Gather every path a GET (or WS, which mounts as GET) will already
1428    // own by the time we merge.
1429    let claimed = collect_claimed_get_paths(route_list, scoped_groups, config);
1430
1431    check_openapi_path_against(
1432        "openapi_json_path",
1433        &openapi.openapi_json_path,
1434        &claimed,
1435        nest_routers,
1436    )?;
1437    if let Some(path) = &openapi.swagger_ui_path {
1438        check_openapi_path_against("swagger_ui_path", path, &claimed, nest_routers)?;
1439        let mut claimed_with_openapi = claimed;
1440        claimed_with_openapi.insert(openapi.openapi_json_path.clone());
1441        for asset_path in crate::openapi::swagger_ui_asset_paths(path) {
1442            check_openapi_path_against(
1443                "swagger_ui_path",
1444                &asset_path,
1445                &claimed_with_openapi,
1446                nest_routers,
1447            )?;
1448        }
1449    }
1450
1451    // Raw merged routers are opaque — we can't inspect their route
1452    // tables through the axum API. Warn instead of failing so users
1453    // know the check doesn't cover this code path.
1454    if !merge_routers.is_empty() {
1455        tracing::warn!(
1456            openapi_json_path = %openapi.openapi_json_path,
1457            swagger_ui_path = ?openapi.swagger_ui_path,
1458            merged_routers = merge_routers.len(),
1459            "OpenAPI mount collision check skipped for AppBuilder::merge routers: \
1460             axum does not expose their route table, so overlapping GET handlers \
1461             will still panic at startup. Choose OpenAPI paths that don't overlap \
1462             with any merged router's handlers."
1463        );
1464    }
1465
1466    Ok(())
1467}
1468
1469/// Evaluate a single `OpenAPI` path against the claimed-path set plus
1470/// any nest prefixes. Returns an `OpenApiPathCollision` error on
1471/// collision.
1472#[cfg(feature = "openapi")]
1473fn check_openapi_path_against(
1474    field: &'static str,
1475    path: &str,
1476    claimed: &std::collections::HashSet<String>,
1477    nest_routers: &[(String, axum::Router<AppState>)],
1478) -> Result<(), RouterBuildError> {
1479    if claimed.contains(path) {
1480        return Err(RouterBuildError::OpenApiPathCollision {
1481            field,
1482            path: path.to_owned(),
1483        });
1484    }
1485    // A nest prefix P owns every route under P (`/P/...`), so any
1486    // OpenAPI path that equals P or starts with `P/` will either
1487    // panic on merge (exact match) or nest inside the user's router
1488    // (where axum routing semantics decide which handler wins).
1489    // Reject both cases so the spec endpoint can't silently vanish.
1490    for (prefix, _) in nest_routers {
1491        let prefix_slash = format!("{prefix}/");
1492        if path == prefix || path.starts_with(&prefix_slash) {
1493            return Err(RouterBuildError::OpenApiPathCollision {
1494                field,
1495                path: path.to_owned(),
1496            });
1497        }
1498    }
1499    Ok(())
1500}
1501
1502/// The HTTP method axum actually mounts a handler under — the effective verb the
1503/// duplicate preflight and the request-timeout table must both key on so the two
1504/// never drift.
1505///
1506/// `#[ws]` records the synthetic `WS` method, but the macro builds its handler
1507/// with `axum::routing::get` and [`group_and_mount_routes`] merges it as a `GET`
1508/// `MethodRouter`. So a `#[ws("/p")]` and a `#[get("/p")]` are the SAME mount as
1509/// far as axum is concerned and would panic on merge. Every other method mounts
1510/// under itself.
1511fn effective_mount_method(method: &http::Method) -> http::Method {
1512    if method.as_str() == "WS" {
1513        http::Method::GET
1514    } else {
1515        method.clone()
1516    }
1517}
1518
1519/// Probe whether two path templates conflict under matchit — the SAME engine
1520/// axum 0.8 routes through — by inserting both into a throwaway router. axum's
1521/// `Router::route` forwards each template to matchit verbatim (brace syntax:
1522/// `{param}` / `{*wild}`), so a matchit `Conflict` here is exactly the mount
1523/// panic `reject_duplicate_user_routes` is preventing. Used only on the error
1524/// path to name the specific prior template a conflicting insert collided with.
1525fn paths_conflict_under_matchit(existing: &str, incoming: &str) -> bool {
1526    let mut probe: matchit::Router<()> = matchit::Router::new();
1527    // If `existing` is itself malformed its insert fails; then it isn't in the
1528    // tree and can't be the conflict partner — return false so the caller keeps
1529    // scanning earlier templates.
1530    if probe.insert(existing, ()).is_err() {
1531        return false;
1532    }
1533    matches!(
1534        probe.insert(incoming, ()),
1535        Err(matchit::InsertError::Conflict { .. })
1536    )
1537}
1538
1539/// Fail-fast preflight for issue #1012: reject two user- or plugin-registered
1540/// routes that resolve to the same `(method, path)` before
1541/// [`group_and_mount_routes`] hands overlapping method routes to
1542/// [`axum::routing::MethodRouter::merge`] (which panics inside
1543/// `Router::route` at startup).
1544///
1545/// **Coverage** — mirrors `collect_route_infos`'s scope-prefix resolution so
1546/// duplicates across the same source, across sources (top-level +
1547/// scoped/plugin, plugin + plugin), and across `.scoped(...)` groups are
1548/// caught uniformly. `#[repository]`-generated API routes land in
1549/// `route_list` like any other route macro output, so they are covered
1550/// for free.
1551///
1552/// **Not covered — opaque routers**:
1553/// * [`AppBuilder::merge`](crate::app::AppBuilder::merge) — axum does not
1554///   expose the merged router's route table.
1555/// * [`AppBuilder::nest`](crate::app::AppBuilder::nest) — same limitation.
1556///
1557/// A non-empty opaque table emits a `tracing::warn!` (same pattern as the
1558/// existing `OpenAPI` and MCP merge-router warnings) so operators know the
1559/// preflight cannot see inside — an overlap involving one of those routers
1560/// will still surface as an axum startup panic.
1561///
1562/// The first pairwise collision wins: `existing` names the handler that
1563/// registered the path first (in the iteration order used by the actual
1564/// mount step), `incoming` names the duplicate that triggered the error.
1565fn reject_duplicate_user_routes(
1566    route_list: &[Route],
1567    scoped_groups: &[ScopedGroup],
1568    merge_routers: &[axum::Router<AppState>],
1569    nest_routers: &[(String, axum::Router<AppState>)],
1570) -> Result<(), RouterBuildError> {
1571    // `claimed` keys on `(effective_method, exact_path)`; the value is the
1572    // first-seen handler name so the error can point at BOTH sides of an
1573    // EXACT-duplicate collision (AC #2). Iterate in the same order the mount
1574    // pass will: top-level routes first (`group_and_mount_routes`), then scoped
1575    // groups (`mount_scoped_groups`).
1576    //
1577    // NOTE: the key is the EXACT path string, not the normalized shape — axum
1578    // merges the same exact path across distinct methods (AC #4: `GET /admin` +
1579    // `POST /admin`, `GET /users/{id}` + `POST /users/{id}`), so a same-shape
1580    // clash is NOT a duplicate unless the exact path AND effective method both
1581    // match. The cross-method shape conflict is handled separately below.
1582    let mut claimed: std::collections::HashMap<(String, String), String> =
1583        std::collections::HashMap::new();
1584
1585    // Method-independent path-shape conflicts are delegated to matchit — the
1586    // SAME engine axum 0.8 routes through — instead of a hand-rolled shape
1587    // normalizer. Every DISTINCT exact template is inserted into a throwaway
1588    // `matchit::Router`; an `InsertError::Conflict` means the two templates
1589    // resolve to overlapping shapes that axum's `Router::route` would reject
1590    // with a mount panic BEFORE any method merging (`/users/{id}` vs
1591    // `/users/{slug}`, `/u/{id}` vs `/u/{*rest}`, `/cmd/{tool}/{sub}` vs
1592    // `/cmd/{*path}`, `/file.{ext}` vs `/file.{kind}`). Delegating to matchit
1593    // converges every capture-name / escaped-brace / catch-all-vs-dynamic edge
1594    // case on axum's own semantics — see `matchit_agrees_with_axum_route_conflicts`
1595    // for the parity guard that fails loudly if matchit ever drifts from axum.
1596    //
1597    // IMPORTANT: exact-duplicate templates legitimately MERGE across distinct
1598    // methods (AC #4: `GET /users/{id}` + `POST /users/{id}`), so identical
1599    // strings are deduplicated BEFORE insertion — re-inserting the same string
1600    // would falsely self-conflict. Those fall through to the method-keyed
1601    // `claimed` check, which alone distinguishes a real duplicate from a legal
1602    // cross-method registration.
1603    let mut shape_router: matchit::Router<String> = matchit::Router::new();
1604    // DISTINCT exact templates inserted into `shape_router`, in insertion order,
1605    // paired with their handler name. matchit's `InsertError::Conflict { with }`
1606    // reports the conflicting route as an unescaped/merged node string that need
1607    // not equal any template we registered, so we recover the conflict partner
1608    // ourselves by re-probing this list (first prior template that conflicts
1609    // under matchit wins, matching the "first-seen is `existing`" convention).
1610    let mut inserted_shapes: Vec<(String, String)> = Vec::new();
1611
1612    let mut record =
1613        |method: &http::Method, path: String, name: &str| -> Result<(), RouterBuildError> {
1614            let effective_method = effective_mount_method(method).to_string();
1615
1616            // Shape conflict (method-independent) via the matchit oracle. Skip
1617            // templates whose EXACT string was already inserted: an identical
1618            // string is a legal cross-method merge, not a shape conflict, and
1619            // re-inserting it would self-conflict.
1620            let already_inserted = inserted_shapes.iter().any(|(p, _)| p == &path);
1621            if !already_inserted {
1622                match shape_router.insert(&path, name.to_owned()) {
1623                    Ok(()) => inserted_shapes.push((path.clone(), name.to_owned())),
1624                    Err(matchit::InsertError::Conflict { .. }) => {
1625                        // Name the specific prior template this one collides with.
1626                        let (existing_path, existing_name) = inserted_shapes
1627                            .iter()
1628                            .find(|(prior, _)| paths_conflict_under_matchit(prior, &path))
1629                            .cloned()
1630                            // Defensive fallback (a full-tree conflict with no
1631                            // single pairwise partner is not expected for real
1632                            // route templates): attribute to the first insert.
1633                            .unwrap_or_else(|| inserted_shapes[0].clone());
1634                        return Err(RouterBuildError::ConflictingRouteShape {
1635                            existing: existing_name,
1636                            existing_path,
1637                            incoming: name.to_owned(),
1638                            incoming_path: path,
1639                        });
1640                    }
1641                    // Any other `InsertError` (malformed param/catch-all syntax)
1642                    // is a single-template validity problem, not a cross-route
1643                    // conflict; leave it to the existing path-validation seams
1644                    // and axum itself rather than mislabeling it a shape clash.
1645                    Err(_) => {}
1646                }
1647            }
1648
1649            // Exact-duplicate check: same effective method AND same exact path
1650            // → axum's `MethodRouter::merge` would panic. Distinct methods on
1651            // the same exact path are legal (axum merges them) and fall through.
1652            let key = (effective_method.clone(), path.clone());
1653            if let Some(existing) = claimed.get(&key) {
1654                return Err(RouterBuildError::DuplicateUserRoute {
1655                    method: effective_method,
1656                    path,
1657                    existing: existing.clone(),
1658                    incoming: name.to_owned(),
1659                });
1660            }
1661            claimed.insert(key, name.to_owned());
1662            Ok(())
1663        };
1664
1665    for route in route_list {
1666        record(&route.method, route.path.to_owned(), route.name)?;
1667    }
1668    for group in scoped_groups {
1669        for route in &group.routes {
1670            record(
1671                &route.method,
1672                join_nested_path(&group.prefix, route.path),
1673                route.name,
1674            )?;
1675        }
1676    }
1677
1678    // Raw merged / nested routers are opaque — axum does not expose their
1679    // route tables. Warn so operators know the check does not cover those
1680    // code paths (mirrors the OpenAPI and MCP merge-router warnings).
1681    if !merge_routers.is_empty() {
1682        tracing::warn!(
1683            merged_routers = merge_routers.len(),
1684            "duplicate-route preflight (#1012) skipped for AppBuilder::merge routers: \
1685             axum does not expose their route table, so an overlapping handler on a \
1686             method+path Autumn already owns will still panic at startup. Keep merged \
1687             routers on disjoint paths from your `.routes()`/`.scoped()` registrations."
1688        );
1689    }
1690    if !nest_routers.is_empty() {
1691        tracing::warn!(
1692            nested_routers = nest_routers.len(),
1693            "duplicate-route preflight (#1012) skipped for AppBuilder::nest routers: \
1694             axum does not expose their route table, so an overlapping handler on a \
1695             method+path Autumn already owns will still panic at startup. Keep nested \
1696             routers on disjoint prefixes from your `.routes()`/`.scoped()` registrations."
1697        );
1698    }
1699
1700    Ok(())
1701}
1702
1703fn group_and_mount_routes(
1704    route_list: Vec<Route>,
1705    idempotency_layers: Option<&BuiltIdempotencyLayers>,
1706    opaque_app_layers_present: bool,
1707    state: &AppState,
1708) -> axum::Router<AppState> {
1709    // Group routes by path so multiple methods on the same path
1710    // (e.g. GET /admin + POST /admin) are merged into a single
1711    // MethodRouter. Axum 0.7+ panics if .route() is called twice
1712    // with the same path — merging avoids this.
1713    let mut grouped: indexmap::IndexMap<&str, axum::routing::MethodRouter<AppState>> =
1714        indexmap::IndexMap::new();
1715    for route in &route_list {
1716        tracing::debug!(
1717            method = %route.method,
1718            path = route.path,
1719            name = route.name,
1720            "Mounted route"
1721        );
1722    }
1723    for route in route_list {
1724        let selected_layer = idempotency_layers
1725            .map(|layers| idempotency_layer_for_route(&route, layers, opaque_app_layers_present));
1726        let mut handler = route.handler;
1727        if let Some(layer) = selected_layer {
1728            handler = handler.layer(layer.clone());
1729        }
1730        if let Some(version) = route.api_version {
1731            handler = handler.layer(axum::middleware::from_fn_with_state(
1732                state.clone(),
1733                api_versioning_middleware,
1734            ));
1735            handler = handler.layer(axum::Extension(RouteVersionMetadata {
1736                version: version.to_string(),
1737                sunset_opt_out: route.sunset_opt_out,
1738                secured: route.api_doc.secured,
1739                required_roles: route.api_doc.required_roles,
1740                has_policy: route.api_doc.has_policy,
1741            }));
1742        }
1743        grouped
1744            .entry(route.path)
1745            .and_modify(|existing| {
1746                *existing = std::mem::take(existing).merge(handler.clone());
1747            })
1748            .or_insert(handler);
1749    }
1750
1751    let mut router = axum::Router::new();
1752    for (path, method_router) in grouped {
1753        router = router.route(path, method_router);
1754    }
1755    router
1756}
1757
1758const fn idempotency_layer_for_route<'a>(
1759    route: &Route,
1760    layers: &'a BuiltIdempotencyLayers,
1761    opaque_app_layers_present: bool,
1762) -> &'a IdempotencyLayer {
1763    if opaque_app_layers_present {
1764        &layers.manual
1765    } else if route_uses_generated_replay_stop(route) {
1766        &layers.route
1767    } else {
1768        &layers.manual
1769    }
1770}
1771
1772const fn route_uses_generated_replay_stop(route: &Route) -> bool {
1773    matches!(
1774        route.idempotency,
1775        crate::route::RouteIdempotency::ReplayThroughInner
1776    )
1777}
1778
1779fn custom_layers_require_fail_closed_idempotency(
1780    custom_layers: &[crate::app::CustomLayerRegistration],
1781) -> bool {
1782    custom_layers
1783        .iter()
1784        .any(|registered| !is_idempotency_transparent_app_layer(registered))
1785}
1786
1787fn is_idempotency_transparent_app_layer(registered: &crate::app::CustomLayerRegistration) -> bool {
1788    registered
1789        .type_name
1790        .starts_with("autumn_web::session::SessionLayer<")
1791        || registered
1792            .type_name
1793            .starts_with("autumn::session::SessionLayer<")
1794        || registered.type_id
1795            == std::any::TypeId::of::<crate::session::SessionLayer<crate::session::MemoryStore>>()
1796        || is_i18n_bundle_extension_layer(registered.type_id)
1797}
1798
1799#[cfg(feature = "i18n")]
1800fn is_i18n_bundle_extension_layer(type_id: std::any::TypeId) -> bool {
1801    type_id == std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>()
1802}
1803
1804#[cfg(not(feature = "i18n"))]
1805const fn is_i18n_bundle_extension_layer(_type_id: std::any::TypeId) -> bool {
1806    false
1807}
1808
1809#[cfg_attr(not(feature = "mail"), allow(unused_variables))]
1810#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
1811fn mount_framework_routes(
1812    mut router: axum::Router<AppState>,
1813    config: &AutumnConfig,
1814    dev_reload_enabled: bool,
1815) -> axum::Router<AppState> {
1816    #[cfg(not(feature = "mail"))]
1817    let _ = config;
1818
1819    // Framework-provided routes
1820    #[cfg(feature = "htmx")]
1821    {
1822        // When htmx is vendored via `autumn assets add htmx@…`, skip the
1823        // built-in handler so ServeDir serves the correctly-pinned file.
1824        // Axum explicit routes beat `nest_service`, so without this guard the
1825        // embedded 2.0.4 bytes would shadow any updated vendored version.
1826        if crate::assets::htmx_is_vendored() {
1827            tracing::debug!(
1828                path = crate::htmx::HTMX_JS_PATH,
1829                "htmx vendored via `autumn assets`; built-in handler skipped, ServeDir serves it"
1830            );
1831        } else {
1832            router = router.route(crate::htmx::HTMX_JS_PATH, axum::routing::get(htmx_handler));
1833            tracing::debug!(
1834                method = "GET",
1835                path = crate::htmx::HTMX_JS_PATH,
1836                name = format!("htmx {}", crate::htmx::HTMX_VERSION),
1837                "Mounted route"
1838            );
1839        }
1840        router = router.route(
1841            crate::htmx::HTMX_CSRF_JS_PATH,
1842            axum::routing::get(htmx_csrf_handler),
1843        );
1844        router = router.route(
1845            crate::htmx::AUTUMN_WIDGETS_JS_PATH,
1846            axum::routing::get(autumn_widgets_handler),
1847        );
1848        router = router.route(
1849            crate::htmx::IDIOMORPH_JS_PATH,
1850            axum::routing::get(idiomorph_handler),
1851        );
1852        router = router.route(
1853            crate::htmx::HTMX_SSE_JS_PATH,
1854            axum::routing::get(htmx_sse_handler),
1855        );
1856        tracing::debug!(
1857            method = "GET",
1858            path = crate::htmx::HTMX_CSRF_JS_PATH,
1859            name = "htmx csrf helper",
1860            "Mounted route"
1861        );
1862        tracing::debug!(
1863            method = "GET",
1864            path = crate::htmx::AUTUMN_WIDGETS_JS_PATH,
1865            name = "autumn widget runtime",
1866            "Mounted route"
1867        );
1868        tracing::debug!(
1869            method = "GET",
1870            path = crate::htmx::IDIOMORPH_JS_PATH,
1871            name = "idiomorph DOM morphing",
1872            "Mounted route"
1873        );
1874        tracing::debug!(
1875            method = "GET",
1876            path = crate::htmx::HTMX_SSE_JS_PATH,
1877            name = "htmx SSE extension",
1878            "Mounted route"
1879        );
1880    }
1881
1882    // Framework-provided flash-message stylesheet. Served as a same-origin
1883    // asset (rather than inline styles) so the `.flash` classes emitted by
1884    // `Flash::render` stay compatible with a strict `style-src 'self'` CSP.
1885    #[cfg(feature = "flash")]
1886    {
1887        router = router.route(
1888            crate::flash::FLASH_CSS_PATH,
1889            axum::routing::get(flash_css_handler),
1890        );
1891        tracing::debug!(
1892            method = "GET",
1893            path = crate::flash::FLASH_CSS_PATH,
1894            name = "autumn flash stylesheet",
1895            "Mounted route"
1896        );
1897    }
1898
1899    // Framework-provided widget stylesheet (#1215). Backs every `autumn-*`
1900    // class emitted by form/widgets/wizard/pagination/storage/job-tracking so
1901    // widgets render styled without an app-authored copy — Tailwind or not.
1902    #[cfg(feature = "maud")]
1903    {
1904        router = router.route(
1905            crate::ui::WIDGETS_CSS_PATH,
1906            axum::routing::get(widgets_css_handler),
1907        );
1908        tracing::debug!(
1909            method = "GET",
1910            path = crate::ui::WIDGETS_CSS_PATH,
1911            name = "autumn widget stylesheet",
1912            "Mounted route"
1913        );
1914    }
1915
1916    if dev_reload_enabled {
1917        router = router.route(
1918            dev::LIVE_RELOAD_PATH,
1919            axum::routing::get(dev::live_reload_state_handler),
1920        );
1921        router = router.route(
1922            dev::LIVE_RELOAD_SCRIPT_PATH,
1923            axum::routing::get(dev::live_reload_script_handler),
1924        );
1925        tracing::debug!(
1926            state_path = dev::LIVE_RELOAD_PATH,
1927            script_path = dev::LIVE_RELOAD_SCRIPT_PATH,
1928            "Mounted dev live reload endpoints"
1929        );
1930    }
1931
1932    #[cfg(feature = "mail")]
1933    if config
1934        .mail
1935        .preview_routes_enabled(config.profile.as_deref())
1936    {
1937        router = router.merge(crate::mail::mail_preview_router(
1938            config.mail.file_dir.clone(),
1939        ));
1940        tracing::debug!(
1941            path = crate::mail::MAIL_PREVIEW_PATH,
1942            "Mounted dev mail preview endpoints"
1943        );
1944    }
1945
1946    // Widget story gallery (#1526) — off by default, opt-in in ANY profile
1947    // via `[stories] enabled = true` (profile-layered). Handlers read the
1948    // StoryRegistry from the AppState extension installed by
1949    // `AppBuilder::with_story_gallery`.
1950    #[cfg(feature = "maud")]
1951    if config.stories.enabled {
1952        router = router.merge(crate::stories::story_router());
1953        tracing::debug!(
1954            path = crate::stories::STORIES_PATH,
1955            "Mounted story gallery endpoints"
1956        );
1957    }
1958
1959    // RFC 8058 one-click unsubscribe endpoint — opt-in via
1960    // `mail.mount_unsubscribe_endpoint` / `AppBuilder::mount_unsubscribe_endpoint`
1961    // so JSON-only apps never get an HTML endpoint they didn't request.
1962    #[cfg(feature = "mail")]
1963    if config.mail.should_mount_unsubscribe_endpoint() {
1964        router = router.merge(crate::mail::unsubscribe_router());
1965        tracing::debug!(
1966            path = crate::mail::UNSUBSCRIBE_PATH,
1967            "Mounted default unsubscribe endpoint"
1968        );
1969    }
1970
1971    // Tracked-job status endpoint (enqueue_tracked / #[job] JobContext) — on
1972    // by default; opt out via `jobs.tracking.route_enabled = false`.
1973    if config.jobs.tracking.route_enabled {
1974        router = router.merge(crate::job_tracking::status_router());
1975        tracing::debug!(
1976            path = crate::job_tracking::JOB_STATUS_ROUTE_PATH,
1977            "Mounted tracked-job status endpoint"
1978        );
1979    }
1980
1981    router
1982}
1983
1984fn mount_probe_endpoints<S>(
1985    mut router: axum::Router<S>,
1986    config: &AutumnConfig,
1987    user_get_paths: &std::collections::HashSet<String>,
1988) -> (std::collections::HashSet<String>, axum::Router<S>)
1989where
1990    S: Clone + Send + Sync + 'static,
1991    AppState: axum::extract::FromRef<S>,
1992{
1993    // Probe endpoints (auto-mounted). Each probe is a `GET`; when a user route
1994    // already owns that exact path, yield to the user handler instead of
1995    // handing axum a second `GET` for the same path — which panics at startup
1996    // with a raw "Overlapping method route" message that names none of the
1997    // user's code (issue #1971). A user who hand-writes `GET /health` clearly
1998    // wants their handler, so the built-in steps aside and logs the override.
1999    let mut mounted_probe_paths = std::collections::HashSet::new();
2000
2001    let mut mount_probe = |mut router: axum::Router<S>,
2002                           path: &str,
2003                           label: &'static str,
2004                           handler: axum::routing::MethodRouter<S>|
2005     -> axum::Router<S> {
2006        if user_get_paths.contains(path) {
2007            tracing::info!(
2008                probe = label,
2009                path,
2010                "a user route already owns this path; the built-in probe was \
2011                 not auto-mounted (the user handler wins)"
2012            );
2013            // Still record the ceded path: `mount_actuator_endpoints` keys its
2014            // overlap guard off this set, and a configured probe path stays a
2015            // collision hazard for the actuator even when a user route (not the
2016            // built-in probe) owns it. Dropping it here would let an actuator at
2017            // prefix "/" merge its own `GET /health` onto the user's `GET
2018            // /health` and axum would panic during construction instead of
2019            // returning a checked `FrameworkRouteOverlap` (issue #1971 P2).
2020            mounted_probe_paths.insert(path.to_owned());
2021            return router;
2022        }
2023        if mounted_probe_paths.insert(path.to_owned()) {
2024            router = router.route(path, handler);
2025        }
2026        router
2027    };
2028
2029    router = mount_probe(
2030        router,
2031        &config.health.live_path,
2032        "liveness",
2033        axum::routing::get(crate::probe::live_handler::<AppState>),
2034    );
2035    router = mount_probe(
2036        router,
2037        &config.health.ready_path,
2038        "readiness",
2039        axum::routing::get(crate::probe::ready_handler::<AppState>),
2040    );
2041    router = mount_probe(
2042        router,
2043        &config.health.startup_path,
2044        "startup",
2045        axum::routing::get(crate::probe::startup_handler::<AppState>),
2046    );
2047    router = mount_probe(
2048        router,
2049        &config.health.path,
2050        "health",
2051        axum::routing::get(crate::health::handler::<AppState>),
2052    );
2053    tracing::debug!(
2054        health = %config.health.path,
2055        live = %config.health.live_path,
2056        ready = %config.health.ready_path,
2057        startup = %config.health.startup_path,
2058        "Mounted probe endpoints"
2059    );
2060
2061    (mounted_probe_paths, router)
2062}
2063
2064fn mount_actuator_endpoints(
2065    mut router: axum::Router<AppState>,
2066    config: &AutumnConfig,
2067    mounted_probe_paths: &std::collections::HashSet<String>,
2068) -> Result<axum::Router<AppState>, RouterBuildError> {
2069    // Actuator endpoints
2070    let actuator_sensitive = config.actuator.sensitive;
2071    let actuator_prometheus = config.actuator.prometheus;
2072    let actuator_paths = crate::actuator::actuator_endpoint_paths(
2073        &config.actuator.prefix,
2074        actuator_sensitive,
2075        actuator_prometheus,
2076    );
2077    if let Some(path) = actuator_paths
2078        .iter()
2079        .find(|path| mounted_probe_paths.contains(path.as_str()))
2080    {
2081        return Err(RouterBuildError::FrameworkRouteOverlap {
2082            path: path.clone(),
2083            existing: "probe endpoint",
2084            incoming: "actuator endpoint",
2085        });
2086    }
2087    router = router.merge(crate::actuator::actuator_router_with_prefix(
2088        &config.actuator.prefix,
2089        actuator_sensitive,
2090        actuator_prometheus,
2091    ));
2092    tracing::debug!(
2093        sensitive = actuator_sensitive,
2094        prometheus = actuator_prometheus,
2095        prefix = %config.actuator.prefix,
2096        "Mounted actuator endpoints"
2097    );
2098    Ok(router)
2099}
2100
2101fn mount_scoped_groups(
2102    mut router: axum::Router<AppState>,
2103    scoped_groups: Vec<ScopedGroup>,
2104    idempotency_layers: Option<&BuiltIdempotencyLayers>,
2105    state: &AppState,
2106) -> axum::Router<AppState> {
2107    // Mount scoped route groups (each with its own middleware layer).
2108    for group in scoped_groups {
2109        let mut sub_router = axum::Router::new();
2110        for route in group.routes {
2111            tracing::debug!(
2112                method = %route.method,
2113                path = route.path,
2114                name = route.name,
2115                scope = %group.prefix,
2116                "Mounted scoped route"
2117            );
2118            // Scoped groups are wrapped by an opaque user-provided layer after
2119            // the route handlers are built. The idempotency storage key cannot
2120            // know whether that layer authorizes, audits, or resolves tenant
2121            // state from non-whitelisted headers/extensions, so cached hits
2122            // fail closed instead of replaying through a generated stop inside
2123            // the scoped route.
2124            let selected_layer = idempotency_layers.map(|layers| &layers.manual);
2125            let mut handler = route.handler;
2126            if let Some(layer) = selected_layer {
2127                handler = handler.layer(layer.clone());
2128            }
2129            if let Some(version) = route.api_version {
2130                handler = handler.layer(axum::middleware::from_fn_with_state(
2131                    state.clone(),
2132                    api_versioning_middleware,
2133                ));
2134                handler = handler.layer(axum::Extension(RouteVersionMetadata {
2135                    version: version.to_string(),
2136                    sunset_opt_out: route.sunset_opt_out,
2137                    secured: route.api_doc.secured,
2138                    required_roles: route.api_doc.required_roles,
2139                    has_policy: route.api_doc.has_policy,
2140                }));
2141            }
2142            sub_router = sub_router.route(route.path, handler);
2143        }
2144        sub_router = (group.apply_layer)(sub_router);
2145        router = router.nest(&group.prefix, sub_router);
2146    }
2147    router
2148}
2149
2150fn mount_raw_routers(
2151    mut router: axum::Router<AppState>,
2152    merge_routers: Vec<axum::Router<AppState>>,
2153    nest_routers: Vec<(String, axum::Router<AppState>)>,
2154    idempotency_layers: Option<&BuiltIdempotencyLayers>,
2155) -> axum::Router<AppState> {
2156    // Merge user-supplied raw Axum routers (escape hatch).
2157    // Merged after annotated routes so annotated routes take precedence.
2158    for raw_router in merge_routers {
2159        tracing::debug!("Merged raw Axum router");
2160        let raw_router = if let Some(layers) = idempotency_layers {
2161            raw_router.layer(layers.manual.clone())
2162        } else {
2163            raw_router
2164        };
2165        router = router.merge(raw_router);
2166    }
2167
2168    // Nest user-supplied raw Axum routers under path prefixes.
2169    for (prefix, raw_router) in nest_routers {
2170        tracing::debug!(prefix = %prefix, "Nested raw Axum router");
2171        // We explicitly apply the fallback to the nested router before nesting,
2172        // so that unmatched routes within this prefix are protected by global middleware.
2173        let nested_router =
2174            raw_router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
2175        let nested_router = if let Some(layers) = idempotency_layers {
2176            nested_router.layer(layers.manual.clone())
2177        } else {
2178            nested_router
2179        };
2180        router = router.nest(&prefix, nested_router);
2181    }
2182    router
2183}
2184
2185fn apply_compression_middleware<S>(
2186    mut router: axum::Router<S>,
2187    config: &AutumnConfig,
2188) -> axum::Router<S>
2189where
2190    S: Clone + Send + Sync + 'static,
2191{
2192    if config.compression.enabled {
2193        use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
2194        // Extend the default predicate (skips images, gRPC, SSE, small bodies) to also
2195        // skip binary media and already-compressed formats — compressing these wastes
2196        // CPU, increases transfer size for archives, and can confuse media players.
2197        let predicate = DefaultPredicate::new()
2198            // Binary media — already-encoded by codec, not compressible by gzip/br.
2199            .and(NotForContentType::const_new("audio/"))
2200            .and(NotForContentType::const_new("video/"))
2201            .and(NotForContentType::const_new("application/octet-stream"))
2202            // Compressed archive formats — re-compressing wastes CPU.
2203            .and(NotForContentType::const_new("application/zip"))
2204            .and(NotForContentType::const_new("application/gzip"))
2205            .and(NotForContentType::const_new("application/x-gzip"))
2206            .and(NotForContentType::const_new("application/zstd"))
2207            .and(NotForContentType::const_new("application/x-bzip2"))
2208            .and(NotForContentType::const_new("application/x-bzip"))
2209            .and(NotForContentType::const_new("application/x-rar-compressed"))
2210            .and(NotForContentType::const_new("application/vnd.rar"))
2211            .and(NotForContentType::const_new("application/x-7z-compressed"))
2212            // Pre-compressed web fonts — WOFF/WOFF2 embed their own compression,
2213            // so gzip/br only wastes CPU and can inflate them. Raw fonts
2214            // (`font/ttf`, `font/otf`) are NOT excluded: they are uncompressed
2215            // SFNT data that genuinely benefits from transfer compression.
2216            .and(NotForContentType::const_new("font/woff"))
2217            .and(NotForContentType::const_new("font/woff2"));
2218        router =
2219            router.layer(tower_http::compression::CompressionLayer::new().compress_when(predicate));
2220        tracing::info!("Response compression enabled (gzip/brotli)");
2221    }
2222    router
2223}
2224
2225fn apply_cors_middleware<S>(mut router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
2226where
2227    S: Clone + Send + Sync + 'static,
2228{
2229    // CORS middleware (only applied when allowed_origins is non-empty)
2230    if !config.cors.allowed_origins.is_empty() {
2231        let cors = build_cors_layer(&config.cors);
2232        tracing::info!(
2233            origins = ?config.cors.allowed_origins,
2234            credentials = config.cors.allow_credentials,
2235            "CORS enabled"
2236        );
2237        router = router.layer(cors);
2238    }
2239    router
2240}
2241
2242fn apply_csrf_middleware<S>(
2243    mut router: axum::Router<S>,
2244    config: &AutumnConfig,
2245    signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
2246) -> axum::Router<S>
2247where
2248    S: Clone + Send + Sync + 'static,
2249{
2250    // CSRF middleware (only applied when enabled)
2251    if config.security.csrf.enabled {
2252        // The CSRF token scan reads only a bounded prefix of the body
2253        // (`security.csrf.token_scan_bytes`, 2 MiB default) and streams the
2254        // remainder through, so the cap comes from CSRF config — NOT from
2255        // `upload.max_request_size_bytes` (which would force whole uploads into
2256        // memory and defeat the streaming upload path).
2257        //
2258        // Clamp the effective prefix to the global body limit: the CSRF layer
2259        // must never buffer more than `upload.max_request_size_bytes`. In the
2260        // normal/high-upload case the small `token_scan_bytes` prefix wins (the
2261        // `min` keeps it at 2 MiB — it is *not* raised to the upload limit).
2262        // Only when an operator deliberately lowers the global limit *below* the
2263        // prefix cap does the upload limit clamp the scan down — the whole body
2264        // is ≤ that limit anyway, so an early `_csrf` token is still in range,
2265        // and anything larger is rejected downstream by `DefaultBodyLimit`.
2266        let effective_scan_bytes = config
2267            .security
2268            .csrf
2269            .token_scan_bytes
2270            .min(config.security.upload.max_request_size_bytes);
2271        let mut csrf_layer = crate::security::CsrfLayer::from_config(&config.security.csrf)
2272            .with_max_scan_bytes(effective_scan_bytes);
2273        if let Some(keys) = signing_keys {
2274            csrf_layer = csrf_layer.with_signing_keys(keys);
2275        }
2276        for endpoint in &config.security.webhooks.endpoints {
2277            csrf_layer = csrf_layer.with_exempt_path(&endpoint.path);
2278        }
2279        // RFC 8058 one-click unsubscribe POSTs arrive from mailbox providers
2280        // with no Autumn CSRF cookie/header; exempt the endpoint only when the
2281        // framework owns it (opt-in), so a custom override keeps its own CSRF.
2282        #[cfg(feature = "mail")]
2283        if config.mail.should_mount_unsubscribe_endpoint() {
2284            csrf_layer = csrf_layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
2285        }
2286        tracing::info!("CSRF protection enabled");
2287        router = router.layer(csrf_layer);
2288    }
2289    router
2290}
2291
2292/// Apply the one-time submit-token guard (issue #1360).
2293///
2294/// Enabled by default. The layer is applied *inner* to the CSRF layer (it is
2295/// registered before `apply_csrf_middleware`, so on the request path CSRF is
2296/// validated first): a request bearing a valid `_csrf` but an already-consumed
2297/// `_submit_token` is still short-circuited by this guard. The store backend
2298/// mirrors [`build_idempotency_layers`]; the `redis` backend reuses the
2299/// `[idempotency.redis]` connection settings.
2300fn apply_submit_token_middleware<S>(
2301    mut router: axum::Router<S>,
2302    config: &AutumnConfig,
2303    is_production: bool,
2304) -> Result<axum::Router<S>, RouterBuildError>
2305where
2306    S: Clone + Send + Sync + 'static,
2307{
2308    let cfg = &config.security.submit_token;
2309    if !cfg.enabled {
2310        return Ok(router);
2311    }
2312
2313    // Production guard for the resolved consumed-token backend. Submit tokens
2314    // are DEFAULT-ON, so the resolved backend can land on the per-process memory
2315    // store in production — which cannot deduplicate submits across replicas.
2316    // Mirrors the idempotency production-memory guard
2317    // (`fail_fast_on_invalid_idempotency_config`): an EXPLICIT
2318    // `[security.submit_token].backend = "memory"` in prod fails fast, while an
2319    // INHERITED default only warns so upgrading Autumn never becomes
2320    // "prod won't boot without Redis".
2321    match cfg.production_memory_guard(config.idempotency.backend, is_production) {
2322        crate::security::config::SubmitTokenMemoryGuard::Ok => {}
2323        crate::security::config::SubmitTokenMemoryGuard::WarnInherited => {
2324            tracing::warn!(
2325                "[security.submit_token].backend resolved to the in-memory store in production \
2326                 (inherited from [idempotency].backend, which is unset or memory). \
2327                 Single-replica deployments are fine, but multi-replica deployments need a shared \
2328                 backend: configure [idempotency] with backend = \"redis\" (or set \
2329                 [security.submit_token].backend = \"redis\") so consumed tokens are shared across \
2330                 replicas — otherwise a duplicate submit can slip through on a different replica."
2331            );
2332        }
2333        crate::security::config::SubmitTokenMemoryGuard::FailExplicit => {
2334            return Err(RouterBuildError::InvalidSubmitTokenBackend(
2335                "the in-memory submit-token backend is not safe for multi-replica production use. \
2336                 Set `[security.submit_token].backend = \"redis\"` in autumn.toml (it reuses the \
2337                 [idempotency.redis] connection settings), or remove the explicit `backend` \
2338                 override to inherit `[idempotency].backend`."
2339                    .to_owned(),
2340            ));
2341        }
2342    }
2343
2344    let ttl = Duration::from_secs(cfg.ttl_secs);
2345    // Backend selection: an explicit `[security.submit_token].backend` wins;
2346    // otherwise inherit `[idempotency].backend` so a Redis-configured app shares
2347    // one consumed-token store across replicas by default (issue #1360), while a
2348    // dev app on the default memory idempotency backend keeps memory tokens.
2349    // `resolved_backend` is the single source of truth so this cannot drift from
2350    // `build_idempotency_layers`.
2351    let backend = cfg.resolved_backend(config.idempotency.backend);
2352    let store: std::sync::Arc<dyn IdempotencyStore> = match backend {
2353        crate::config::IdempotencyBackend::Memory => {
2354            std::sync::Arc::new(MemoryIdempotencyStore::new(ttl))
2355        }
2356        #[cfg(feature = "redis")]
2357        crate::config::IdempotencyBackend::Redis => {
2358            match crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency) {
2359                Ok(s) => std::sync::Arc::new(s),
2360                Err(e) => return Err(RouterBuildError::InvalidIdempotencyBackend(e)),
2361            }
2362        }
2363        #[cfg(not(feature = "redis"))]
2364        crate::config::IdempotencyBackend::Redis => {
2365            return Err(RouterBuildError::InvalidIdempotencyBackend(
2366                "submit_token backend 'redis' requires the autumn-web 'redis' feature \
2367                 flag; rebuild with --features redis or switch to backend = \"memory\""
2368                    .to_owned(),
2369            ));
2370        }
2371    };
2372
2373    let mut layer = crate::security::SubmitTokenLayer::new(store, cfg)
2374        .with_max_scan_bytes(config.security.upload.max_request_size_bytes);
2375    for endpoint in &config.security.webhooks.endpoints {
2376        layer = layer.with_exempt_path(&endpoint.path);
2377    }
2378    #[cfg(feature = "mail")]
2379    if config.mail.should_mount_unsubscribe_endpoint() {
2380        layer = layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
2381    }
2382    tracing::info!(
2383        backend = ?backend,
2384        inherited = cfg.backend.is_none(),
2385        ttl_secs = cfg.ttl_secs,
2386        "One-time submit-token protection enabled"
2387    );
2388    router = router.layer(layer);
2389    Ok(router)
2390}
2391
2392fn apply_bot_protection_middleware<S>(
2393    mut router: axum::Router<S>,
2394    config: &AutumnConfig,
2395) -> axum::Router<S>
2396where
2397    S: Clone + Send + Sync + 'static,
2398{
2399    if config.bot_protection.enabled {
2400        // Use the dedicated captcha_exempt_paths list — NOT csrf.exempt_paths —
2401        // so that a route exempt from CSRF for non-cookie auth reasons does not
2402        // automatically bypass bot-protection as well.
2403        let mut exempt = config.security.captcha_exempt_paths.clone();
2404        for endpoint in &config.security.webhooks.endpoints {
2405            exempt.push(endpoint.path.clone());
2406        }
2407        // One-click unsubscribe POSTs carry no CAPTCHA token; exempt the
2408        // framework-owned endpoint when mounted.
2409        #[cfg(feature = "mail")]
2410        if config.mail.should_mount_unsubscribe_endpoint() {
2411            exempt.push(crate::mail::UNSUBSCRIBE_PATH.to_owned());
2412        }
2413        let layer =
2414            crate::security::captcha::BotProtectionLayer::from_config(&config.bot_protection)
2415                .with_max_scan_bytes(config.security.upload.max_request_size_bytes)
2416                .with_exempt_paths(exempt);
2417        tracing::info!(
2418            provider = ?config.bot_protection.provider,
2419            dev_bypass = config.bot_protection.dev_bypass,
2420            "Bot protection (CAPTCHA) enabled"
2421        );
2422        router = router.layer(layer);
2423    }
2424    router
2425}
2426
2427async fn populate_rate_limit_principal(
2428    axum::extract::State(state): axum::extract::State<AppState>,
2429    mut req: axum::extract::Request,
2430    next: axum::middleware::Next,
2431) -> axum::response::Response {
2432    // Populate RateLimitPrincipal from the *verified* session identity only.
2433    //
2434    // We deliberately do NOT fall back to a raw Authorization header here: this
2435    // shim runs as a global layer outer to route-scoped auth (RequireApiToken),
2436    // so any bearer token visible at this point is still unverified and fully
2437    // attacker-controlled. Keying the limiter on it would let a caller rotate
2438    // the token to mint unlimited buckets (defeating the per-IP fallback) or
2439    // forge another user's principal to exhaust their bucket. When no verified
2440    // principal is available, the limiter's extract_key falls back to IP keying,
2441    // which is the correct safe default. API-token routes that want
2442    // per-principal limiting should place a RateLimitLayer inner to
2443    // RequireApiToken, which sets the verified principal ID (see
2444    // RequireApiTokenService::call).
2445    if let Some(session) = req.extensions().get::<crate::session::Session>() {
2446        let auth_session_key = state.auth_session_key();
2447        if let Some(user_id) = session.get(auth_session_key).await {
2448            req.extensions_mut()
2449                .insert(crate::security::RateLimitPrincipal(user_id));
2450        }
2451    }
2452    next.run(req).await
2453}
2454
2455fn apply_trusted_proxies_middleware<S>(
2456    router: axum::Router<S>,
2457    config: &AutumnConfig,
2458) -> axum::Router<S>
2459where
2460    S: Clone + Send + Sync + 'static,
2461{
2462    let tp = &config.security.trusted_proxies;
2463    let layer = crate::security::TrustedProxiesLayer::from_config(tp);
2464    if tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some() {
2465        tracing::info!(
2466            ranges = ?tp.ranges,
2467            trusted_hops = ?tp.trusted_hops,
2468            "Centralized trusted-proxy resolution enabled"
2469        );
2470    }
2471    router.layer(layer)
2472}
2473
2474fn apply_rate_limit_middleware(
2475    mut router: axum::Router<AppState>,
2476    config: &AutumnConfig,
2477    state: &AppState,
2478) -> axum::Router<AppState> {
2479    if config.security.rate_limit.enabled {
2480        let tp = &config.security.trusted_proxies;
2481        let rl = &config.security.rate_limit;
2482        let has_top_level_proxy_config =
2483            tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some();
2484        // Preserve explicit rate-limit proxy config (legacy fields). The shared
2485        // top-level resolver is only injected when the rate-limit section carries
2486        // no proxy config of its own, preventing dev defaults from silently
2487        // overriding an operator's explicit security.rate_limit.trusted_proxies.
2488        let has_rate_limit_proxy_config =
2489            rl.trust_forwarded_headers || !rl.trusted_proxies.is_empty();
2490        // The framework default limiter shares its bucket with the MCP `/mcp`
2491        // envelope limiter (both built here), so it honors `RateLimitEnvelopeCounted`
2492        // to avoid double-counting an already-charged `tools/call`. User-installed
2493        // limiters don't, so MCP replays still consume their per-route buckets.
2494        let mut layer = crate::security::RateLimitLayer::from_config(rl).honoring_mcp_exempt();
2495        if has_top_level_proxy_config && !has_rate_limit_proxy_config {
2496            let resolver = crate::security::ProxyResolver::from_config(tp);
2497            layer = layer.with_proxy_resolver(resolver);
2498        }
2499        tracing::info!(
2500            rps = config.security.rate_limit.requests_per_second,
2501            burst = config.security.rate_limit.burst,
2502            "Rate limiting enabled"
2503        );
2504        router = router.layer(layer);
2505
2506        if config.security.rate_limit.key_strategy
2507            == crate::security::KeyStrategy::AuthenticatedPrincipal
2508        {
2509            router = router.layer(axum::middleware::from_fn_with_state(
2510                state.clone(),
2511                populate_rate_limit_principal,
2512            ));
2513        }
2514    }
2515    router
2516}
2517
2518fn apply_upload_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
2519where
2520    S: Clone + Send + Sync + 'static,
2521{
2522    let upload_config = config.security.upload.clone();
2523    let max_request_size = upload_config.max_request_size_bytes;
2524    tracing::info!(
2525        max_request_size_bytes = max_request_size,
2526        max_file_size_bytes = upload_config.max_file_size_bytes,
2527        allowed_mime_types = ?upload_config.allowed_mime_types,
2528        "Request body size limits enabled (applies to all content types)"
2529    );
2530
2531    // Apply a global body-size cap covering JSON, form, raw bytes, and multipart.
2532    // The Multipart extractor further refines this per the UploadConfig extension.
2533    let router = router.layer(axum::extract::DefaultBodyLimit::max(max_request_size));
2534
2535    // Insert UploadConfig into extensions so the Multipart extractor can read
2536    // per-file limits and the allowed MIME-type list.
2537    router.layer(axum::middleware::from_fn(
2538        move |mut req: axum::extract::Request, next: axum::middleware::Next| {
2539            let upload_config = upload_config.clone();
2540            async move {
2541                req.extensions_mut().insert(upload_config);
2542                next.run(req).await
2543            }
2544        },
2545    ))
2546}
2547
2548/// Exact-match health/probe paths that must always bypass admission-style
2549/// gates (maintenance mode, the startup barrier, load shedding): the
2550/// compat health endpoint plus the `/live`, `/ready`, `/startup` lifecycle
2551/// probes and the actuator's own `/health` alias. Callers additionally
2552/// exempt the whole actuator prefix (`with_health_prefix`), since these
2553/// gates are keyed on exact paths, not prefixes.
2554fn probe_bypass_paths(config: &AutumnConfig) -> Vec<String> {
2555    vec![
2556        config.health.path.clone(),
2557        config.health.live_path.clone(),
2558        config.health.ready_path.clone(),
2559        config.health.startup_path.clone(),
2560        crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
2561    ]
2562}
2563
2564/// Build the [`MaintenanceLayer`](crate::middleware::maintenance::MaintenanceLayer)
2565/// from config + state, with the health/probe paths that always bypass the gate.
2566///
2567/// Shared by [`apply_middleware`] (direct routes) and the late-mounted `/mcp`
2568/// envelope so both return the documented `503` identically when maintenance
2569/// mode is active — the `/mcp` router is merged after `apply_middleware`, so
2570/// without an explicit layer its `initialize`/`tools/list` would keep serving
2571/// the catalog during maintenance.
2572fn build_maintenance_layer(
2573    config: &AutumnConfig,
2574    state: &AppState,
2575) -> crate::middleware::maintenance::MaintenanceLayer {
2576    let maintenance_state = state
2577        .extension::<crate::maintenance::MaintenanceState>()
2578        .map(|s| (*s).clone())
2579        .unwrap_or_default();
2580    crate::middleware::maintenance::MaintenanceLayer::new(maintenance_state)
2581        .with_health_prefix(config.actuator.prefix.clone())
2582        .with_probe_paths(probe_bypass_paths(config))
2583}
2584
2585/// Build the admission-control ([`LoadShedLayer`](crate::middleware::LoadShedLayer))
2586/// layer from config, or `None` when `server.max_concurrent_requests` is unset
2587/// or `0` — the default, preserving today's unlimited behavior with zero
2588/// overhead (the layer is simply never applied; see [`apply_middleware`]).
2589///
2590/// Reuses the same probe/actuator bypass list as [`build_maintenance_layer`]
2591/// so health/liveness/readiness probes are never shed under load (#1006).
2592fn build_load_shed_layer(
2593    config: &AutumnConfig,
2594    state: &AppState,
2595) -> Option<crate::middleware::LoadShedLayer> {
2596    let limit = config.server.max_concurrent_requests.filter(|&n| n > 0)?;
2597    // Mirror CORS headers onto a shed 503 the same way the timeout middleware
2598    // does for the main stack (`mirror_cors = true` there): this layer sits
2599    // outside `CorsLayer` on direct routes, so without mirroring a
2600    // cross-origin browser client sees an opaque CORS failure instead of a
2601    // readable 503. Harmless (but redundant) at the `/mcp` mount point, since
2602    // that shares this same layer instance yet sits *inside* its own
2603    // `CorsLayer`, which overwrites these headers with its own regardless.
2604    let cors =
2605        (!config.cors.allowed_origins.is_empty()).then(|| std::sync::Arc::new(config.cors.clone()));
2606    Some(
2607        crate::middleware::LoadShedLayer::new(limit, state.metrics.clone())
2608            .with_health_prefix(config.actuator.prefix.clone())
2609            .with_probe_paths(probe_bypass_paths(config))
2610            .with_cors(cors),
2611    )
2612}
2613
2614/// Per-route timeout lookup table, keyed by the fully-qualified route template
2615/// (matching [`axum::extract::MatchedPath`]) and then by HTTP method, so an
2616/// override on one handler never bleeds onto sibling methods sharing the path
2617/// (e.g. `GET /items` vs `POST /items`). The nested layout also lets the
2618/// middleware resolve the deadline from a borrowed `&str` + `&Method`, avoiding
2619/// any allocation on exempt/disabled routes. Built once at router-assembly time
2620/// from each [`Route`]'s `timeout` field and shared (cheaply cloned) into the
2621/// global timeout middleware.
2622type RouteTimeoutTable = std::sync::Arc<
2623    std::collections::HashMap<
2624        String,
2625        std::collections::HashMap<http::Method, crate::route::RouteTimeout>,
2626    >,
2627>;
2628
2629/// Error surfaced as the cause of the `503` when an inbound request exceeds its
2630/// wall-clock deadline. Carried into [`crate::error::AutumnError::service_unavailable`]
2631/// so the response flows through the standard Problem Details / error-page stack
2632/// (JSON for API clients, HTML for browsers) instead of a raw tower `BoxError`.
2633#[derive(Debug)]
2634struct RequestDeadlineExceeded {
2635    timeout_ms: u64,
2636}
2637
2638impl std::fmt::Display for RequestDeadlineExceeded {
2639    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2640        write!(
2641            f,
2642            "the server did not produce a response within the configured {}ms deadline",
2643            self.timeout_ms
2644        )
2645    }
2646}
2647
2648impl std::error::Error for RequestDeadlineExceeded {}
2649
2650/// Response-extension marker stamped on the `503` produced when the inbound
2651/// request-timeout deadline cancels the handler future.
2652///
2653/// The session layer is applied *outer* to the timeout layer, so when the
2654/// deadline fires it observes the (still-shared) `Session` handle as dirty even
2655/// though the handler was cancelled mid-flight. Persisting that partial mutation
2656/// would commit half-finished state — e.g. a login that set the user id but
2657/// never finished — so `SessionService` checks for this marker and skips the
2658/// dirty save/destroy when it is present. Only the timeout handler sets it, so
2659/// ordinary handler-produced `503`s still persist session changes as before.
2660#[derive(Clone, Copy, Debug)]
2661pub struct RequestDeadlineCancelled;
2662
2663/// Build the per-route timeout override table from the top-level routes and any
2664/// scoped (prefixed) groups. Group routes are keyed by their nested template so
2665/// the runtime lookup matches [`axum::extract::MatchedPath`].
2666fn build_route_timeout_table(
2667    route_list: &[Route],
2668    scoped_groups: &[ScopedGroup],
2669) -> RouteTimeoutTable {
2670    let mut table: std::collections::HashMap<
2671        String,
2672        std::collections::HashMap<http::Method, crate::route::RouteTimeout>,
2673    > = std::collections::HashMap::new();
2674    let mut insert = |path: String, method: &http::Method, timeout: crate::route::RouteTimeout| {
2675        // `Inherit` carries no override, so it never needs a table entry.
2676        if matches!(timeout, crate::route::RouteTimeout::Inherit) {
2677            return;
2678        }
2679        // Key by (path, *effective request method*) so an override on one handler
2680        // never bleeds onto sibling methods that share the template, while still
2681        // resolving when the request reaches the handler through a method alias.
2682        // `request_timeout_handler` looks up `req.method()`, which differs from
2683        // the declared method in two cases:
2684        //   - axum serves `HEAD` through a `#[get]` handler, so a GET override
2685        //     must also cover HEAD.
2686        //   - `#[ws]` records the synthetic `WS` method but mounts a `GET`
2687        //     handler, so the upgrade (and its auth work) arrives as GET.
2688        // Each (effective method, path) pair is still unique across the router, so
2689        // `insert` cannot lose a competing entry.
2690        let by_method = table.entry(path).or_default();
2691        // Key under the same effective verb the router mounts the handler as, so
2692        // a `#[ws]` override lands on the GET the upgrade actually arrives as
2693        // (shared with the duplicate-route preflight via `effective_mount_method`
2694        // so the two mappings can never drift).
2695        by_method.insert(effective_mount_method(method), timeout);
2696        // A real `#[get]` is also served for HEAD in axum; a WS upgrade is not,
2697        // so only expand HEAD for a genuine GET (not the WS→GET alias).
2698        if *method == http::Method::GET {
2699            by_method.insert(http::Method::HEAD, timeout);
2700        }
2701    };
2702    for route in route_list {
2703        insert(route.path.to_owned(), &route.method, route.timeout);
2704    }
2705    for group in scoped_groups {
2706        for route in &group.routes {
2707            insert(
2708                join_nested_path(&group.prefix, route.path),
2709                &route.method,
2710                route.timeout,
2711            );
2712        }
2713    }
2714    std::sync::Arc::new(table)
2715}
2716
2717/// Apply the built-in inbound request timeout.
2718///
2719/// A single global layer enforces `config.server.timeouts.request_timeout_ms`
2720/// (the `prod` profile smart-defaults this to 30s) as a per-request wall-clock
2721/// deadline, with per-route overrides resolved from `route_timeouts` via the
2722/// matched route template. On expiry the handler returns a framework-standard
2723/// `503 Service Unavailable` (Problem Details JSON for API clients, the error
2724/// page for browsers — never a raw tower `BoxError`).
2725///
2726/// Streaming responses are exempt by construction: the deadline bounds the time
2727/// to produce the response head, not the duration of body streaming, so SSE and
2728/// chunked responses are never interrupted once the head is sent. Long-poll
2729/// handlers, which block *before* returning the head, are bound by the deadline
2730/// and must opt out via `timeout = "off"`. WebSocket routes inherit the deadline
2731/// ([`RouteTimeout::Inherit`](crate::route::RouteTimeout), emitted by `#[ws]`),
2732/// so it bounds a hung pre-upgrade handshake but never the established socket —
2733/// that future runs on a separate task via `on_upgrade` and is unbounded by
2734/// design.
2735///
2736/// The layer is a no-op (zero overhead) when the global timeout is disabled and
2737/// no route declares an `Override`.
2738///
2739/// `mirror_cors` makes a synthesized 503 carry the CORS response headers a
2740/// normal response would. Set it for the main ingress stack, where this layer
2741/// sits *outside* `CorsLayer` (see the order in `apply_middleware`) so the 503
2742/// never flows back through it; leave it off for the `/mcp` envelope, whose
2743/// timeout is applied *inner* to its `CorsLayer` and whose 503 is therefore
2744/// already CORS-readable.
2745fn apply_request_timeout_middleware(
2746    router: axum::Router<AppState>,
2747    config: &AutumnConfig,
2748    metrics: crate::middleware::MetricsCollector,
2749    route_timeouts: RouteTimeoutTable,
2750    mirror_cors: bool,
2751) -> axum::Router<AppState> {
2752    let global = config
2753        .server
2754        .timeouts
2755        .request_timeout_ms
2756        .filter(|ms| *ms > 0)
2757        .map(std::time::Duration::from_millis);
2758    let has_override = route_timeouts
2759        .values()
2760        .flat_map(std::collections::HashMap::values)
2761        .any(|t| matches!(t, crate::route::RouteTimeout::Override(_)));
2762    if global.is_none() && !has_override {
2763        return router;
2764    }
2765    if let Some(duration) = global {
2766        tracing::info!(
2767            timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
2768            "Inbound request timeout enabled"
2769        );
2770    }
2771    // Snapshot the CORS config once iff we must mirror it onto timeout 503s and
2772    // any origin is configured (otherwise `CorsLayer` itself is absent).
2773    let cors = (mirror_cors && !config.cors.allowed_origins.is_empty())
2774        .then(|| std::sync::Arc::new(config.cors.clone()));
2775    router.layer(axum::middleware::from_fn(move |req, next| {
2776        request_timeout_handler(
2777            req,
2778            next,
2779            global,
2780            route_timeouts.clone(),
2781            metrics.clone(),
2782            cors.clone(),
2783        )
2784    }))
2785}
2786
2787async fn request_timeout_handler(
2788    req: axum::extract::Request,
2789    next: axum::middleware::Next,
2790    global: Option<std::time::Duration>,
2791    route_timeouts: RouteTimeoutTable,
2792    metrics: crate::middleware::MetricsCollector,
2793    cors: Option<std::sync::Arc<crate::config::CorsConfig>>,
2794) -> axum::response::Response {
2795    // Internal `autumn build` / ISR regeneration renders drive a `#[static_get]`
2796    // route directly via `oneshot` and tag the request with `RenderDeadlineExempt`
2797    // (there is no client connection whose deadline should apply). Skip the
2798    // deadline for these; live inbound requests to the same route do not carry
2799    // the marker and are bounded normally below.
2800    if req
2801        .extensions()
2802        .get::<crate::static_gen::RenderDeadlineExempt>()
2803        .is_some()
2804    {
2805        return next.run(req).await;
2806    }
2807
2808    // Resolve the effective deadline from the matched route template + method,
2809    // using borrowed lookups so exempt/disabled routes allocate nothing.
2810    let matched_path_ref = req
2811        .extensions()
2812        .get::<axum::extract::MatchedPath>()
2813        .map(axum::extract::MatchedPath::as_str);
2814    let route_timeout = matched_path_ref
2815        .and_then(|p| route_timeouts.get(p))
2816        .and_then(|by_method| by_method.get(req.method()))
2817        .copied()
2818        .unwrap_or(crate::route::RouteTimeout::Inherit);
2819    let deadline = match route_timeout {
2820        crate::route::RouteTimeout::Disabled => None,
2821        crate::route::RouteTimeout::Override(d) => Some(d),
2822        crate::route::RouteTimeout::Inherit => global,
2823    };
2824    let Some(duration) = deadline else {
2825        // Exempt (disabled route, or global off with a non-Override route) —
2826        // no allocation on this hot path.
2827        return next.run(req).await;
2828    };
2829
2830    // A deadline is active: now it's worth owning the path for the warn log.
2831    let matched_path = matched_path_ref.map(ToOwned::to_owned);
2832    let request_id = req
2833        .extensions()
2834        .get::<crate::middleware::RequestId>()
2835        .cloned();
2836    // Capture the request Origin before `req` is consumed so a timeout 503 can
2837    // mirror the CORS headers `CorsLayer` would have added (only when mirroring
2838    // is enabled — see `apply_request_timeout_middleware`).
2839    let cors_origin = cors
2840        .as_ref()
2841        .and_then(|_| req.headers().get(http::header::ORIGIN).cloned());
2842    let start = std::time::Instant::now();
2843    match tokio::time::timeout(duration, next.run(req)).await {
2844        Ok(response) => response,
2845        Err(_elapsed) => {
2846            let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
2847            let route = matched_path.as_deref().unwrap_or("<unmatched>");
2848            // Structured telemetry: route template + elapsed time so operators
2849            // can alert on the (already-counted) timeout event.
2850            tracing::warn!(
2851                target: "autumn::timeout",
2852                route = route,
2853                elapsed_ms = elapsed_ms,
2854                timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
2855                request_id = request_id.as_ref().map(ToString::to_string),
2856                "inbound request exceeded deadline"
2857            );
2858            metrics.record_request_timeout();
2859            // Return a 503 via the standard error type so the exception-filter
2860            // and error-page stack negotiate JSON vs HTML and enrich with the
2861            // request id — no manual Problem Details assembly, no raw BoxError.
2862            let mut response =
2863                crate::error::AutumnError::service_unavailable(RequestDeadlineExceeded {
2864                    timeout_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
2865                })
2866                .into_response();
2867            // Tag the 503 so the outer session layer skips persisting any partial
2868            // session mutation the cancelled handler made before the deadline.
2869            response.extensions_mut().insert(RequestDeadlineCancelled);
2870            // This layer is outside `CorsLayer` in the main stack, so the 503
2871            // never passes back through it; mirror the CORS headers ourselves so
2872            // cross-origin browser clients can read the Problem Details body
2873            // instead of seeing an opaque CORS failure.
2874            if let Some(cors) = cors.as_deref() {
2875                mirror_cors_headers(cors, cors_origin.as_ref(), &mut response);
2876            }
2877            response
2878        }
2879    }
2880}
2881
2882struct BuiltIdempotencyLayers {
2883    route: crate::idempotency::IdempotencyLayer,
2884    manual: crate::idempotency::IdempotencyLayer,
2885}
2886
2887fn build_idempotency_layers(
2888    config: &AutumnConfig,
2889    state: &AppState,
2890) -> Result<Option<BuiltIdempotencyLayers>, RouterBuildError> {
2891    if !config.idempotency.enabled.unwrap_or(false) {
2892        return Ok(None);
2893    }
2894
2895    let ttl = Duration::from_secs(config.idempotency.ttl_secs);
2896    let in_flight_ttl = Duration::from_secs(config.idempotency.in_flight_ttl_secs);
2897    let store: std::sync::Arc<dyn IdempotencyStore> = match config.idempotency.backend {
2898        crate::config::IdempotencyBackend::Memory => {
2899            std::sync::Arc::new(MemoryIdempotencyStore::new(ttl))
2900        }
2901        #[cfg(feature = "redis")]
2902        crate::config::IdempotencyBackend::Redis => {
2903            match crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency) {
2904                Ok(s) => std::sync::Arc::new(s),
2905                Err(e) => return Err(RouterBuildError::InvalidIdempotencyBackend(e)),
2906            }
2907        }
2908        #[cfg(not(feature = "redis"))]
2909        crate::config::IdempotencyBackend::Redis => {
2910            return Err(RouterBuildError::InvalidIdempotencyBackend(
2911                "idempotency backend 'redis' requires the autumn-web 'redis' feature \
2912                 flag; rebuild with --features redis or switch to backend = \"memory\""
2913                    .to_owned(),
2914            ));
2915        }
2916    };
2917
2918    tracing::debug!(
2919        backend = ?config.idempotency.backend,
2920        ttl_secs = config.idempotency.ttl_secs,
2921        in_flight_ttl_secs = config.idempotency.in_flight_ttl_secs,
2922        "Idempotency-key middleware enabled"
2923    );
2924
2925    let base = IdempotencyLayer::new(store)
2926        .with_ttl(ttl)
2927        .with_in_flight_ttl(in_flight_ttl)
2928        .with_metrics(state.metrics.clone());
2929
2930    Ok(Some(BuiltIdempotencyLayers {
2931        route: base.clone().replay_through_inner(),
2932        manual: base.fail_closed_on_replay(),
2933    }))
2934}
2935
2936#[allow(
2937    clippy::cognitive_complexity,
2938    clippy::too_many_lines,
2939    clippy::too_many_arguments
2940)]
2941fn apply_middleware(
2942    mut router: axum::Router<AppState>,
2943    config: &AutumnConfig,
2944    state: &AppState,
2945    exception_filters: Vec<Arc<dyn ExceptionFilter>>,
2946    custom_layers: Vec<crate::app::CustomLayerRegistration>,
2947    #[cfg(feature = "maud")] error_page_renderer: Option<SharedRenderer>,
2948    session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
2949    route_timeouts: RouteTimeoutTable,
2950    // Built once by the caller (`build_router_pre_state`) and cloned into the
2951    // late-mounted `/mcp` envelope too, so both ingress surfaces admit
2952    // against the SAME shared in-flight counter — constructing a second
2953    // `LoadShedLayer` here would give `/mcp` its own independent (always-zero)
2954    // counter that never sheds. See `build_load_shed_layer`.
2955    load_shed_layer: Option<crate::middleware::LoadShedLayer>,
2956) -> Result<axum::Router<AppState>, RouterBuildError> {
2957    // 404 fallback handler for unmatched routes must be registered BEFORE global middleware
2958    // so that unmatched routes are still protected by rate limiting, CSRF, CORS, etc.
2959    router = router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
2960
2961    // Resolve signing keys once; shared across session and CSRF layers.
2962    let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
2963    let signing_keys = std::sync::Arc::new(crate::security::config::resolve_signing_keys(
2964        &config.security.signing_secret,
2965    ));
2966    // Only thread signing keys when a secret is configured (or in production where
2967    // fail_fast already ensures one is present). In dev without a configured secret
2968    // the ephemeral key is generated per-process — useful but not required.
2969    let signing_keys_opt: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>> =
2970        if config.security.signing_secret.secret.is_some() || is_production {
2971            Some(signing_keys)
2972        } else {
2973            None
2974        };
2975
2976    router = apply_cors_middleware(router, config);
2977    let trusted_host_policy = TrustedHostPolicy::from_config(config);
2978    router = router.layer(axum::middleware::from_fn(move |req, next| {
2979        trusted_host_middleware(req, next, trusted_host_policy.clone())
2980    }));
2981    // Applied before (i.e. inner to) the CSRF layer so CSRF is validated first
2982    // on the request path; a replayed `_submit_token` is still short-circuited
2983    // even when the request carries a valid `_csrf` (issue #1360, AC #4).
2984    router = apply_submit_token_middleware(router, config, is_production)?;
2985    router = apply_csrf_middleware(router, config, signing_keys_opt.clone());
2986    router = apply_bot_protection_middleware(router, config);
2987    // Method-override rejection filter. The outer `MethodOverrideLayer`
2988    // (applied at the `axum::serve` boundary so it can rewrite the
2989    // request method before route matching) stamps a
2990    // [`MethodOverrideRejection`] extension when the override field
2991    // value is invalid or the body was too large to scan; this inner
2992    // middleware converts that extension into the corresponding
2993    // `400`/`413` response. Running it here means the rejection flows
2994    // through the rest of the response stack (security headers,
2995    // request IDs, metrics, error-page filter) rather than bypassing
2996    // them. Placed outside CSRF so a `BodyTooLarge` (empty body)
2997    // doesn't get masked by a `403` from CSRF's missing-token branch,
2998    // and a clear `400 invalid _method` outranks "missing CSRF".
2999    router = router.layer(axum::middleware::from_fn(
3000        crate::middleware::method_override_rejection_filter,
3001    ));
3002    router = apply_rate_limit_middleware(router, config, state);
3003
3004    // Register MaintenanceLayer automatically (shared construction with the
3005    // late-mounted `/mcp` envelope — see `build_maintenance_layer`).
3006    router = router.layer(build_maintenance_layer(config, state));
3007
3008    // Admission control / load shedding (#1006). Outer to MaintenanceLayer so
3009    // the cheap in-flight-count check runs before maintenance mode's
3010    // bypass-header/IP-allowlist evaluation. `None` (the default — no
3011    // `server.max_concurrent_requests` configured) applies no layer at all,
3012    // so there is no overhead when the feature is unused.
3013    if let Some(load_shed) = load_shed_layer {
3014        router = router.layer(load_shed);
3015    }
3016
3017    router = router.layer(axum::middleware::from_fn(
3018        crate::webhook::webhook_replay_cleanup_middleware,
3019    ));
3020    router = apply_upload_middleware(router, config);
3021
3022    // User-registered Tower layers (AppBuilder::layer). Outermost — applied
3023    // last so they wrap all framework middleware.  Iterate in reverse so the
3024    // first registered layer ends up outermost among user layers — matching
3025    // tower::ServiceBuilder ordering.
3026    //
3027    // When a static dist dir is active (SSG/ISG build), these layers are
3028    // NOT passed here — they are extracted by try_build_router_with_static_inner
3029    // and applied outside the static-first middleware instead, so they can
3030    // process pre-rendered responses without creating a session dependency.
3031    let custom_layer_count = custom_layers.len();
3032    for registered in custom_layers.into_iter().rev() {
3033        router = (registered.apply)(router);
3034    }
3035    if custom_layer_count > 0 {
3036        tracing::debug!(count = custom_layer_count, "Custom Tower layers applied");
3037    }
3038
3039    // TrustedProxiesLayer is applied after user layers so it is outermost in the
3040    // ingress request path, stamping ResolvedClientIdentity before any user or
3041    // framework middleware reads ClientAddr / ClientHost / ClientScheme.
3042    router = apply_trusted_proxies_middleware(router, config);
3043
3044    let mut router = router;
3045
3046    if config.tenancy.enabled {
3047        router = router.layer(axum::middleware::from_fn_with_state(
3048            state.clone(),
3049            crate::tenancy::tenancy_middleware,
3050        ));
3051        tracing::debug!("Multi-tenancy middleware enabled");
3052    }
3053
3054    // Per-request timeout (inner to RequestId so the request ID set by that
3055    // layer is available when the timeout fires — see request_timeout_handler).
3056    //
3057    // Full ingress layer order (outermost → innermost):
3058    //   TraceContext → AccessLog-fallback (applied in apply_startup_barrier) →
3059    //   StartupBarrier → Compression → Metrics → ExceptionFilter → ErrorPageContext →
3060    //   Session → SecurityHeaders → RequestId → LogContext → AccessLog-primary →
3061    //   Timeout → [user layers] → Tenancy → BodyLimit/UploadConfig →
3062    //   MethodOverride → RateLimit → CSRF → CORS → handler
3063    // `mirror_cors = true`: this layer is outside `CorsLayer` (CORS is applied
3064    // earlier, hence inner), so its timeout 503 must carry CORS headers itself.
3065    //
3066    // KNOWN LIMITATION (session store I/O is not bounded): `Session` sits outside
3067    // this layer (see order above), so `store.load` runs before the timer starts
3068    // and `store.save`/`destroy` after it completes. A stalled session backend can
3069    // therefore tie up a worker despite `request_timeout_ms`. This placement is
3070    // deliberate: the timer is kept inner to `RequestId` so a timeout 503 (and its
3071    // warn log) carries `X-Request-Id` for log correlation — moving it outside
3072    // `Session` would also move it outside `RequestId` and lose that. Operators
3073    // who need to bound session-store I/O should configure a store-level deadline
3074    // (e.g. the Redis command/connection timeout); a cancelled inbound request
3075    // cannot abort an already-issued store call regardless of layer order.
3076    //
3077    // The same applies to the edge layers `App::run` wraps around the finished
3078    // router at the `axum::serve` boundary (`MethodOverrideLayer`,
3079    // `TrustedProxiesLayer`): they sit outside `RequestId` and therefore outside
3080    // this timer. In particular `MethodOverrideLayer` buffers an HTML form body
3081    // (`axum::body::to_bytes`, capped at `upload.max_request_size_bytes`) before
3082    // the inner router runs, so a slow `_method` form upload is not bounded by
3083    // `request_timeout_ms`. Moving the timer out there would again lose the
3084    // `X-Request-Id` correlation; bound this with a server/proxy read timeout
3085    // instead.
3086    router = apply_request_timeout_middleware(
3087        router,
3088        config,
3089        state.metrics.clone(),
3090        route_timeouts,
3091        true,
3092    );
3093
3094    // Error-reporting + panic-catch layer. Placed inner to `RequestIdLayer`
3095    // (so the request id is available when a handler panics) and outer to the
3096    // timeout, user layers, and handler (so their panics are caught and turned
3097    // into a clean 500 instead of aborting the worker task). The resulting 500
3098    // still flows out through the exception-filter chain for HTML negotiation.
3099    #[cfg(feature = "reporting")]
3100    {
3101        router = router.layer(crate::reporting::ReportingLayer::new(
3102            state.error_reporters(),
3103            config.reporting.enabled,
3104            config.reporting.sample_rate,
3105        ));
3106    }
3107
3108    // Structured per-request access log (#999), primary emitter: one INFO
3109    // event (target `autumn::access`) per served request at the response
3110    // boundary. Inner to RequestId (so the request id is available) and to
3111    // LogContext (so the event is emitted inside the request span); outer to
3112    // the reporting and timeout layers so panics-turned-500s and timeout
3113    // responses are logged with the status the client receives. Emitted
3114    // responses are marked so the outermost fallback (apply_startup_barrier)
3115    // does not double-log; that fallback covers requests that short-circuit
3116    // before this layer runs.
3117    if config.log.access_log {
3118        router = router.layer(crate::middleware::AccessLogLayer::new(
3119            config.log.access_log_exclude.clone(),
3120        ));
3121    }
3122
3123    // Server-Timing response header (#1348). Applied outer to AccessLogLayer
3124    // (it is added after, so it wraps it) — its `total` metric is therefore
3125    // the outermost wall-clock measure and is `>=` the access-log
3126    // `duration_ms` by a few microseconds; both share the same
3127    // `Instant`-based formula. Opt-in via
3128    // `[observability] server_timing`; defaults on in dev, off in prod so
3129    // timings never leak to anonymous prod clients without explicit opt-in.
3130    if crate::config::server_timing_enabled(config) {
3131        router = router.layer(crate::middleware::ServerTimingLayer::new(true));
3132    }
3133
3134    // Request-scoped log context (#1169). Established for every request, inner
3135    // to `RequestIdLayer` (so the request id is available to seed it) and outer
3136    // to tenancy, user layers, and the handler (so all of them, and every
3137    // `tracing` event they emit, inherit the same correlating context). The
3138    // filter mirrors the error-page scrubber so sensitive custom fields never
3139    // enter the context output.
3140    let mut log_context_filter_parameters = config.log.filter_parameters.clone();
3141    log_context_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
3142    let log_context_filter = Arc::new(crate::log::filter::ParameterFilter::new(
3143        &log_context_filter_parameters,
3144        &config.log.unfilter_parameters,
3145    ));
3146    let router = router.layer(crate::middleware::LogContextLayer::new(log_context_filter));
3147
3148    // `security_headers` is applied LATER as the framework's outermost layer
3149    // (after the gate, below) so that a gate short-circuit (redirect/401) still
3150    // carries HSTS/CSP/nosniff — see the application point after the gate loop.
3151    // RequestId stays here (inner to session) so the request id seeds the
3152    // session, logs, and trace context.
3153    let router = router.layer(RequestIdLayer);
3154
3155    // Pre-clone signing keys for the RYWW middleware (session mode needs to
3156    // sign/verify the `autumn.ryw` cookie; `signing_keys_opt` is consumed below).
3157    #[cfg(feature = "db")]
3158    let signing_keys_for_ryw = signing_keys_opt.clone();
3159
3160    let router = crate::session::apply_session_layer(
3161        router,
3162        &config.session,
3163        config.profile.as_deref(),
3164        session_store,
3165        signing_keys_opt,
3166    )?;
3167    tracing::debug!(backend = ?config.session.backend, "Session management enabled");
3168
3169    // Read-your-own-writes middleware: installed only when the mode is not
3170    // `off`. When active, it scopes a per-request task-local `RequestPin`
3171    // that generated repository read methods consult at acquire time.
3172    // Inner to Session so the task-local wraps the handler; the `autumn.ryw`
3173    // cookie is parsed from raw `Cookie` headers and does not require the
3174    // Session extractor to have run first.
3175    #[cfg(feature = "db")]
3176    let router = if config.database.read_your_writes == crate::config::ReadYourWrites::Off {
3177        router
3178    } else {
3179        let ryw_mode = config.database.read_your_writes;
3180        let window_secs = config.database.pin_after_write_secs;
3181        let keys = signing_keys_for_ryw;
3182        if ryw_mode == crate::config::ReadYourWrites::Session && keys.is_none() {
3183            tracing::warn!(
3184                "read_your_writes = \"session\" requires a configured \
3185                 security.signing_secret to sign the autumn.ryw cookie; \
3186                 cross-request pinning is disabled until a secret is set"
3187            );
3188        }
3189        let metrics = state.metrics().clone();
3190        router.layer(axum::middleware::from_fn(move |req, next| {
3191            crate::read_your_writes::middleware(
3192                req,
3193                next,
3194                ryw_mode,
3195                window_secs,
3196                keys.clone(),
3197                metrics.clone(),
3198            )
3199        }))
3200    };
3201
3202    // Error page filter: renders HTML error pages for browser requests.
3203    // Always registered (uses default renderer if no custom one is provided).
3204    let is_dev = config
3205        .profile
3206        .as_deref()
3207        .map_or(cfg!(debug_assertions), |p| p == "dev");
3208
3209    // When the `maud` feature is enabled, an ErrorPageFilter renders styled HTML
3210    // error pages for browser requests. Without `maud`, only the
3211    // ProblemDetailsFilter (JSON error normalization) is installed.
3212    let mut all_filters: Vec<Arc<dyn ExceptionFilter>> =
3213        vec![Arc::new(ProblemDetailsFilter { is_dev })];
3214    #[cfg(feature = "maud")]
3215    {
3216        // Encrypted columns (#805) compose into log scrubbing (#697): their names are
3217        // always scrubbed from trace/error parameter output so ciphertext-backed
3218        // values never leak through logs even if an app forgets to list them.
3219        let mut filter_parameters = config.log.filter_parameters.clone();
3220        filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
3221        let renderer = error_page_renderer.unwrap_or_else(error_pages::default_renderer);
3222        let error_page_filter = crate::middleware::error_page_filter::ErrorPageFilter {
3223            renderer,
3224            is_dev,
3225            parameter_filter: crate::log::filter::ParameterFilter::new(
3226                &filter_parameters,
3227                &config.log.unfilter_parameters,
3228            ),
3229        };
3230        all_filters.push(Arc::new(error_page_filter));
3231    }
3232    all_filters.extend(exception_filters);
3233
3234    let count = all_filters.len();
3235    tracing::debug!(
3236        count,
3237        "Registered exception filters (including error page filter)"
3238    );
3239
3240    // Error page context layer must be inner to the exception filter so
3241    // WantsHtml is set on the response before the filter inspects it.
3242    // Full ingress layer order (outermost -> innermost). NOTE: the framework's
3243    // outermost `SecurityHeadersLayer` and the `static_gate` layers are applied
3244    // by `build_router_pre_state` AFTER this function returns (and, crucially,
3245    // after the MCP dispatch clone is taken), so they are NOT in this list:
3246    //   SecurityHeaders (framework outermost — applied in build_router_pre_state) ->
3247    //   [static_gate layers — applied in build_router_pre_state, after the MCP
3248    //   dispatch clone, outside session and the static cache] ->
3249    //   TraceContext (applied outside the startup barrier so short-circuit
3250    //   responses still carry traceparent) ->
3251    //   Compression (outer to ExceptionFilter — see note below) ->
3252    //   [user layers, when SSG/ISG dist dir active] ->
3253    //   StaticFileMiddleware (when SSG/ISG enabled) ->
3254    //   Metrics -> ExceptionFilter -> ErrorPageContext -> Session ->
3255    //   RequestId -> LogContext -> AccessLog-primary ->
3256    //   [user layers, non-static build] ->
3257    //   Tenancy -> RateLimit -> CSRF -> CORS -> handler
3258    //   (An AccessLog fallback sits outermost, applied in apply_startup_barrier.)
3259    let router = router
3260        .layer(crate::middleware::error_page_filter::ErrorPageContextLayer { is_dev })
3261        .layer(ExceptionFilterLayer::new(all_filters))
3262        .layer(crate::middleware::MetricsLayer::new(state.metrics.clone()));
3263
3264    // Response compression is applied outermost (outside ExceptionFilter) so that
3265    // exception filters which rebuild the response body (e.g. ProblemDetailsFilter
3266    // normalising AutumnErrors to JSON Problem Details) do so before the body is
3267    // encoded. If compression were inner to ExceptionFilter, the filter would
3268    // inherit a Content-Encoding: gzip header on the rebuilt uncompressed body,
3269    // causing clients to receive uncompressed bytes labeled as gzip.
3270    // User-registered layers (EtagLayer etc.) remain inner to Compression, so
3271    // ETags are still computed on the uncompressed body before encoding occurs.
3272    let router = apply_compression_middleware(router, config);
3273
3274    // NOTE: the `static_gate` layers and the framework's outermost
3275    // `SecurityHeadersLayer` are intentionally NOT applied here. They are applied
3276    // by `build_router_pre_state` after this function returns and after the MCP
3277    // dispatch clone is taken, so a `tools/call` replay never traverses the
3278    // page-cache gate (matching the SSG/ISG path and the documented intent).
3279    Ok(router)
3280}
3281
3282/// Apply a set of user-registered layer registrations so that the
3283/// first-registered layer ends up outermost on ingress — matching
3284/// [`tower::ServiceBuilder`] ordering. Returns the wrapped router.
3285fn apply_layers_in_registration_order(
3286    mut router: axum::Router<AppState>,
3287    layers: Vec<crate::app::CustomLayerRegistration>,
3288    what: &str,
3289) -> axum::Router<AppState> {
3290    let count = layers.len();
3291    for registered in layers.into_iter().rev() {
3292        router = (registered.apply)(router);
3293    }
3294    if count > 0 {
3295        tracing::debug!(count, "{what} Tower layers applied");
3296    }
3297    router
3298}
3299
3300async fn trusted_host_middleware(
3301    req: Request<axum::body::Body>,
3302    next: Next,
3303    policy: TrustedHostPolicy,
3304) -> axum::response::Response {
3305    let path = req.uri().path();
3306    if (req.method() == http::Method::GET || req.method() == http::Method::HEAD)
3307        && policy.probe_bypass_paths.contains(path)
3308    {
3309        return next.run(req).await;
3310    }
3311    let authority = req.uri().authority().map(http::uri::Authority::as_str);
3312    let host_header = req
3313        .headers()
3314        .get(http::header::HOST)
3315        .and_then(|v| v.to_str().ok());
3316    let raw_host = authority.or(host_header);
3317    let parsed_host = raw_host.and_then(extract_host_without_port);
3318    let host = parsed_host
3319        .map(str::to_ascii_lowercase)
3320        .map(|h| h.trim_end_matches('.').to_owned())
3321        .filter(|h| !h.is_empty());
3322    let host_source_present = raw_host.is_some();
3323    if host.is_none() && !host_source_present && policy.allow_missing_host {
3324        return next.run(req).await;
3325    }
3326    if host.as_deref().is_some_and(|host| policy.allows_host(host)) {
3327        next.run(req).await
3328    } else {
3329        tracing::warn!(host = ?host, "trusted host rejected request");
3330        let body = crate::error::problem_details_json_string(
3331            StatusCode::BAD_REQUEST,
3332            "Invalid Host header",
3333            None,
3334            None,
3335            None,
3336            None,
3337            true,
3338        );
3339        (
3340            StatusCode::BAD_REQUEST,
3341            [(http::header::CONTENT_TYPE, "application/problem+json")],
3342            body,
3343        )
3344            .into_response()
3345    }
3346}
3347
3348pub fn extract_host_without_port(header: &str) -> Option<&str> {
3349    let host = header.trim();
3350    if host.is_empty() {
3351        return None;
3352    }
3353    if host.starts_with('[') {
3354        let end = host.find(']')?;
3355        let literal = host.get(1..end)?;
3356        if literal.is_empty() || literal.parse::<std::net::IpAddr>().is_err() {
3357            return None;
3358        }
3359
3360        let remainder = host.get(end + 1..)?;
3361        if remainder.is_empty() {
3362            return Some(literal);
3363        }
3364
3365        let maybe_port = remainder.strip_prefix(':')?;
3366        if !maybe_port.is_empty() && maybe_port.chars().all(|c| c.is_ascii_digit()) {
3367            return Some(literal);
3368        }
3369
3370        return None;
3371    }
3372    let Some((candidate, maybe_port)) = host.rsplit_once(':') else {
3373        return Some(host);
3374    };
3375    if candidate.contains(':') {
3376        // unbracketed IPv6 literal; keep host verbatim
3377        return Some(host);
3378    }
3379    if !maybe_port.is_empty()
3380        && maybe_port.chars().all(|c| c.is_ascii_digit())
3381        && !candidate.is_empty()
3382    {
3383        Some(candidate)
3384    } else {
3385        None
3386    }
3387}
3388
3389/// Build the router with optional static-file-first serving.
3390///
3391/// If `dist_dir` is `Some` and contains a valid `manifest.json`, the
3392/// returned router intercepts GET/HEAD requests whose path appears in
3393/// the manifest and serves pre-built HTML directly — before the dynamic
3394/// router runs.  This matches Next.js SSG/ISR semantics where static
3395/// pages always win over dynamic handlers.
3396///
3397/// Requests not in the manifest (including non-GET/HEAD methods) fall
3398/// through to the dynamic router unchanged.
3399///
3400/// When `dist_dir` is `None` or the manifest is missing, the returned
3401/// router is identical to [`build_router`].
3402///
3403/// This function is public primarily for integration testing.
3404///
3405/// # Panics
3406///
3407/// Panics when framework router assembly encounters invalid configuration.
3408/// Use [`try_build_router_with_static`] to handle configuration errors
3409/// explicitly.
3410#[allow(dead_code)]
3411pub fn build_router_with_static(
3412    route_list: Vec<Route>,
3413    config: &AutumnConfig,
3414    state: AppState,
3415    dist_dir: Option<&std::path::Path>,
3416) -> axum::Router {
3417    try_build_router_with_static(route_list, config, state, dist_dir)
3418        .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
3419}
3420
3421/// Checked variant of [`build_router_with_static`] that returns configuration
3422/// errors instead of panicking.
3423///
3424/// # Errors
3425///
3426/// Returns [`RouterBuildError`] when router assembly encounters invalid
3427/// framework configuration, such as an unusable session backend.
3428#[allow(dead_code)]
3429pub fn try_build_router_with_static(
3430    route_list: Vec<Route>,
3431    config: &AutumnConfig,
3432    state: AppState,
3433    dist_dir: Option<&std::path::Path>,
3434) -> Result<axum::Router, RouterBuildError> {
3435    try_build_router_with_static_inner(
3436        route_list,
3437        config,
3438        state,
3439        dist_dir,
3440        RouterContext {
3441            exception_filters: Vec::new(),
3442            scoped_groups: Vec::new(),
3443            merge_routers: Vec::new(),
3444            nest_routers: Vec::new(),
3445            custom_layers: Vec::new(),
3446            static_gate_layers: Vec::new(),
3447            #[cfg(feature = "maud")]
3448            error_page_renderer: None,
3449            session_store: None,
3450            #[cfg(feature = "openapi")]
3451            openapi: None,
3452            #[cfg(feature = "mcp")]
3453            mcp: None,
3454        },
3455    )
3456}
3457
3458#[allow(clippy::too_many_lines)]
3459pub fn try_build_router_with_static_inner(
3460    route_list: Vec<Route>,
3461    config: &AutumnConfig,
3462    state: AppState,
3463    dist_dir: Option<&std::path::Path>,
3464    mut ctx: RouterContext,
3465) -> Result<axum::Router, RouterBuildError> {
3466    let startup_barrier_state = state.clone();
3467
3468    let Some(dist) = dist_dir else {
3469        let app_router = try_build_router_inner(route_list, config, state, ctx)?;
3470        return Ok(apply_startup_barrier(
3471            app_router,
3472            config,
3473            &startup_barrier_state,
3474        ));
3475    };
3476
3477    let Some(layer) = crate::static_gen::StaticFileLayer::new(dist) else {
3478        tracing::debug!(
3479            dist = %dist.display(),
3480            "No valid manifest.json in dist dir; skipping static file layer"
3481        );
3482        let app_router = try_build_router_inner(route_list, config, state, ctx)?;
3483        return Ok(apply_startup_barrier(
3484            app_router,
3485            config,
3486            &startup_barrier_state,
3487        ));
3488    };
3489
3490    for (route, entry) in &layer.manifest().routes {
3491        tracing::debug!(
3492            route = %route,
3493            file = %entry.file,
3494            revalidate = ?entry.revalidate,
3495            "Static route"
3496        );
3497    }
3498
3499    // Extract user layers before building the inner router. They are applied
3500    // OUTSIDE the static-first middleware (and outside session) so that:
3501    //   • User layers (e.g. compression) can process pre-rendered responses.
3502    //   • Static serving remains available even if the session backend is down.
3503    //   • ISR regeneration uses the inner router (no user layers), ensuring
3504    //     re-rendered pages are saved as raw HTML rather than pre-transformed.
3505    //
3506    // KNOWN LIMITATION (`request_timeout_ms` does not bound these outer layers):
3507    // the per-request timeout lives inside `inner_router` (applied by
3508    // `apply_middleware`, inner to `RequestId`). Because `custom_layers` and
3509    // `static_gate_layers` are reapplied OUTSIDE the static-first middleware
3510    // (below), they — and the static cache lookup itself — run before the timer
3511    // starts. So when a `dist` manifest is active, a hung async `static_gate`
3512    // (e.g. remote JWT/IdP validation) or custom layer is NOT bounded by
3513    // `request_timeout_ms`, unlike the non-static path where the timer wraps the
3514    // user layers and tenancy. This is the same trade-off as the documented
3515    // session-store and edge-layer (`MethodOverrideLayer`, `TrustedProxiesLayer`)
3516    // limitations in `apply_middleware`: pulling the timer out here to cover them
3517    // would place it outside `RequestId` (losing `X-Request-Id` on the timeout
3518    // 503), double-time dynamic misses, and apply a global deadline to cached
3519    // hits that have no route-table entry. Operators who terminate auth/tenant
3520    // work in a `static_gate` should bound it with a layer-level or
3521    // server/proxy read timeout instead.
3522    //
3523    // Compute the idempotency flag NOW while custom_layers is still populated,
3524    // then drain it. build_router_pre_state would otherwise see an empty list
3525    // and incorrectly treat opaque layers as absent when selecting idempotency
3526    // behaviour for each route.
3527    //
3528    // Pre-static gate layers count here too: a `static_gate` used as a
3529    // JWT/stateless auth layer is an opaque app layer for idempotency purposes
3530    // (idempotency keys exclude `Authorization`, so without fail-closed replay a
3531    // second principal with the same key+body could receive the first
3532    // principal's cached mutation). Include them BEFORE either list is drained.
3533    let opaque_present = Some(
3534        custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
3535            || custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers),
3536    );
3537    let custom_layers = std::mem::take(&mut ctx.custom_layers);
3538
3539    // Pre-static gate layers (AppBuilder::static_gate) are likewise extracted
3540    // and applied OUTSIDE the static-first middleware (the outermost layer of
3541    // all), so they run before the static cache lookup serves a pre-rendered
3542    // page. Draining them here keeps build_router_pre_state from applying them
3543    // to the inner router (which would place them inside the static middleware
3544    // and defeat the gate for cached hits).
3545    let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
3546
3547    // SSG/ISG path: a single SecurityHeadersLayer is applied OUTSIDE the
3548    // static-first middleware below (wrapping cached pages, dynamic misses, and
3549    // the gate), so the inner router must NOT apply its own — hence `true`.
3550    let inner_router =
3551        build_router_pre_state(route_list, config, &state, ctx, opaque_present, true)?;
3552
3553    // Attach the inner router for ISR background regeneration. Because user
3554    // layers are excluded, re-renders produce raw HTML (no compression, etc.)
3555    // that is then saved to disk and served with user-layer processing applied
3556    // at request time.
3557    let has_isr = layer
3558        .manifest()
3559        .routes
3560        .values()
3561        .any(|e| e.revalidate.is_some());
3562    let layer = if has_isr {
3563        // The inner router defers `SecurityHeadersLayer` to the single outer
3564        // application (see `defer_security_headers`), but ISR background
3565        // regeneration drives this router directly and never reaches that outer
3566        // layer. `SecurityHeadersLayer` is also what injects `CspNonce` into
3567        // request extensions, so without it a handler using the `CspNonce`
3568        // extractor would 500 during regeneration and the stale file would never
3569        // refresh. Re-attach the layer here, on the regeneration router only.
3570        // Its response headers are discarded (only the rendered HTML body is
3571        // persisted), so this does not affect live-request headers and avoids the
3572        // duplicate-header / nonce conflict that a second live layer would cause.
3573        let regen_router = inner_router
3574            .clone()
3575            .layer(crate::security::SecurityHeadersLayer::from_config(
3576                &config.security.headers,
3577            ))
3578            .with_state(state.clone());
3579        layer.with_router(regen_router)
3580    } else {
3581        layer
3582    };
3583    let layer = Arc::new(layer);
3584
3585    // Static-first serving: intercept GET/HEAD requests whose path appears
3586    // in the manifest and serve pre-built HTML directly — BEFORE the dynamic
3587    // router (and session layer) runs. This preserves availability of static
3588    // pages even when the session backend is unavailable.
3589    //
3590    // Requests not in the manifest (including non-GET/HEAD methods) fall
3591    // through to the dynamic router unchanged.
3592    //
3593    // ISR staleness checking happens inside `resolve()`: stale pages are
3594    // still served immediately while background regeneration runs
3595    // (stale-while-revalidate).
3596    let static_layer = layer;
3597    let mut router: axum::Router<AppState> = inner_router.layer(axum::middleware::from_fn(
3598        move |req: axum::extract::Request, next: axum::middleware::Next| {
3599            let static_layer = static_layer.clone();
3600            async move {
3601                let is_get = req.method() == http::Method::GET;
3602                let is_head = req.method() == http::Method::HEAD;
3603                if is_get || is_head {
3604                    let path = req.uri().path();
3605                    // Normalize trailing slash: /about/ → /about (but keep / as /)
3606                    let normalized = if path.len() > 1 && path.ends_with('/') {
3607                        &path[..path.len() - 1]
3608                    } else {
3609                        path
3610                    };
3611                    if let Some(file_path) = static_layer.resolve(normalized)
3612                        && let Ok(contents) = tokio::fs::read(&file_path).await
3613                    {
3614                        // Derive the Content-Type from the request route's
3615                        // extension rather than hard-coding text/html. The
3616                        // response compression layer (applied OUTSIDE this
3617                        // middleware) negotiates gzip/brotli by content type, so
3618                        // an accurate MIME type is what lets compressible SSG
3619                        // pages (HTML/CSS/JS/JSON/XML/text) be encoded while
3620                        // binary manifest assets (images, fonts, octet-stream)
3621                        // are left untouched.
3622                        //
3623                        // The served file name is NOT a reliable MIME source for
3624                        // generated routes: `static_gen::url_to_file_path` stores
3625                        // every non-root route as `<route>/index.html`, so
3626                        // `/robots.txt` -> `robots.txt/index.html` and
3627                        // `/sitemap.xml` -> `sitemap.xml/index.html`. Reading the
3628                        // extension off `index.html` would mislabel those as
3629                        // text/html. The request route carries the true
3630                        // extension, so prefer it — but ONLY when its final path
3631                        // segment ends in an extension the asset table actually
3632                        // recognizes.
3633                        //
3634                        // A bare `contains('.')` check is too loose: a generated
3635                        // page whose slug merely contains a dot
3636                        // (`/posts/release.v1`, `/users/alice@example.com`) is
3637                        // still stored as `<slug>/index.html` HTML, yet `.v1` /
3638                        // `.com` are not asset extensions. Deriving the MIME from
3639                        // the route there would mislabel HTML as
3640                        // `application/octet-stream` and break its compression.
3641                        // `content_type_for_opt` returns `Some` only for a
3642                        // recognized extension, so those unrecognized-dot slugs
3643                        // fall through to the served file name (`index.html` ->
3644                        // text/html), exactly like extensionless pages.
3645                        //
3646                        // Extensionless routes are real pages (`/about` ->
3647                        // `about/index.html`) and resolve to text/html via the
3648                        // same file-name fallback. Hand-written manifests that map
3649                        // an extensionless route directly at an extensioned file
3650                        // (e.g. `/logo` -> `logo.png`, `/inter` ->
3651                        // `fonts/inter.woff2`) are likewise covered by it. The URL
3652                        // path is always '/'-delimited, so inspecting the last
3653                        // segment is unaffected by platform path separators.
3654                        let content_type = crate::assets::content_type_for_opt(normalized)
3655                            .unwrap_or_else(|| {
3656                                file_path
3657                                    .file_name()
3658                                    .and_then(|name| name.to_str())
3659                                    .map_or("application/octet-stream", |name| {
3660                                        crate::assets::content_type_for(name)
3661                                    })
3662                            });
3663                        let body = if is_head {
3664                            axum::body::Body::empty()
3665                        } else {
3666                            axum::body::Body::from(contents)
3667                        };
3668                        return http::Response::builder()
3669                            .status(http::StatusCode::OK)
3670                            .header(http::header::CONTENT_TYPE, content_type)
3671                            .body(body)
3672                            .expect("infallible response builder");
3673                    }
3674                }
3675                next.run(req).await
3676            }
3677        },
3678    ));
3679
3680    // Apply user layers OUTSIDE the static middleware so they wrap it and can
3681    // process both static and dynamic responses (e.g. compress the HTML on
3682    // the way out). Iterate in reverse so the first registered layer ends up
3683    // outermost — matching tower::ServiceBuilder ordering.
3684    router = apply_layers_in_registration_order(
3685        router,
3686        custom_layers,
3687        "Custom (outside static middleware)",
3688    );
3689
3690    // Compression must also be applied OUTSIDE the static-first middleware so
3691    // that pre-rendered HTML pages (served directly by StaticFileLayer without
3692    // reaching inner_router) are also compressed. This mirrors the placement in
3693    // apply_middleware for the dynamic-only path.
3694    router = apply_compression_middleware(router, config);
3695
3696    // Pre-static gate layers run before the static cache lookup (they wrap the
3697    // static-first middleware) so they can redirect / reject a request before a
3698    // cached SSG/ISG page is served. They are applied INNER to the
3699    // SecurityHeadersLayer below so that a gate's short-circuit response
3700    // (redirect / 401) still carries the framework security headers (HSTS/CSP,
3701    // etc.) — matching the headers a normal cached or dynamic response gets.
3702    router = apply_layers_in_registration_order(
3703        router,
3704        static_gate_layers,
3705        "Pre-static gate (outside static middleware)",
3706    );
3707
3708    // Security headers are applied OUTERMOST so they wrap both cached pages and
3709    // any gate short-circuit response. This is the SINGLE application for the
3710    // SSG/ISG path: the inner router skips it (build_router_pre_state is called
3711    // with `defer_security_headers = true`), so dynamic misses are not
3712    // double-wrapped (which would break CSP nonces).
3713    let router = router.layer(crate::security::SecurityHeadersLayer::from_config(
3714        &config.security.headers,
3715    ));
3716
3717    Ok(apply_startup_barrier(
3718        router.with_state(state),
3719        config,
3720        &startup_barrier_state,
3721    ))
3722}
3723
3724#[derive(Clone)]
3725struct StartupBarrierState {
3726    app_state: AppState,
3727    // Canonical exact-match probe/health paths (`probe_bypass_paths`), the
3728    // single source of truth shared with `TrustedHostPolicy` and the
3729    // maintenance/load-shed gates — see that function's doc comment.
3730    probe_paths: Vec<String>,
3731    actuator_paths: Vec<String>,
3732    actuator_subtree_paths: Vec<String>,
3733}
3734
3735impl StartupBarrierState {
3736    fn from_config(config: &AutumnConfig, app_state: &AppState) -> Self {
3737        let actuator_subtree_paths = if config.actuator.sensitive {
3738            vec![crate::actuator::actuator_route_path(
3739                &config.actuator.prefix,
3740                "/loggers",
3741            )]
3742        } else {
3743            Vec::new()
3744        };
3745
3746        Self {
3747            app_state: app_state.clone(),
3748            probe_paths: probe_bypass_paths(config),
3749            actuator_paths: crate::actuator::actuator_endpoint_paths(
3750                &config.actuator.prefix,
3751                config.actuator.sensitive,
3752                config.actuator.prometheus,
3753            ),
3754            actuator_subtree_paths,
3755        }
3756    }
3757
3758    fn allows_path(&self, path: &str) -> bool {
3759        self.probe_paths.iter().any(|allowed| path == allowed)
3760            || self.actuator_paths.iter().any(|allowed| path == allowed)
3761            || self
3762                .actuator_subtree_paths
3763                .iter()
3764                .any(|allowed| path_matches_route_prefix(path, allowed))
3765    }
3766}
3767
3768fn apply_startup_barrier(
3769    router: axum::Router,
3770    config: &AutumnConfig,
3771    state: &AppState,
3772) -> axum::Router {
3773    let barrier_state = StartupBarrierState::from_config(config, state);
3774    let router = router.layer(axum::middleware::from_fn_with_state(
3775        barrier_state,
3776        startup_barrier,
3777    ));
3778    // Access-log fallback (#999), applied OUTSIDE the startup barrier, the
3779    // static-first (SSG/ISR) middleware, the session layer, and the
3780    // exception-filter chain — every production build path funnels through
3781    // this function, including after the late MCP endpoint merge. It emits
3782    // only for responses the primary in-stack layer never saw (it checks the
3783    // AccessLogEmitted response marker), giving startup 503s, pre-built
3784    // static page hits, session-store outage 503s, and MCP endpoint requests
3785    // an access line too. Those short-circuits never ran RequestIdLayer, so
3786    // the fallback reads `x-request-id` from the response when present and
3787    // logs without a request id otherwise.
3788    let router = if config.log.access_log {
3789        router.layer(crate::middleware::AccessLogLayer::fallback(
3790            config.log.access_log_exclude.clone(),
3791        ))
3792    } else {
3793        router
3794    };
3795    // Server-Timing fallback (#1348), applied OUTSIDE the startup barrier, the
3796    // static-first (SSG/ISR) middleware, the session layer, and the late MCP
3797    // merge — exactly the short-circuit paths the primary ServerTimingLayer in
3798    // `apply_middleware` never sees. Gated on the same `server_timing_enabled`
3799    // resolver as the primary. It appends only for responses missing the
3800    // `ServerTimingEmitted` marker, so requests that reach the primary carry a
3801    // single `total`; short-circuits (startup 503, pre-built static hits) get a
3802    // `total` here.
3803    let router = if crate::config::server_timing_enabled(config) {
3804        router.layer(crate::middleware::ServerTimingLayer::fallback(true))
3805    } else {
3806        router
3807    };
3808    // W3C Trace Context propagation wraps the startup barrier (and the
3809    // static-first middleware above it) so short-circuit responses —
3810    // startup 503s and pre-built static file hits — still extract the
3811    // incoming `traceparent` and inject the current context into the
3812    // outgoing response. Applied here rather than inside `apply_middleware`
3813    // because those outer wrappers can return without ever invoking the
3814    // inner router. Outer to AccessLog so the access event is emitted while
3815    // the trace context is current.
3816    #[cfg(feature = "telemetry-otlp")]
3817    let router = router.layer(crate::middleware::TraceContextLayer);
3818    router
3819}
3820
3821async fn startup_barrier(
3822    State(state): State<StartupBarrierState>,
3823    request: axum::extract::Request,
3824    next: Next,
3825) -> axum::response::Response {
3826    if crate::app::is_static_build_mode()
3827        || state.app_state.probes().is_startup_complete()
3828        || state.allows_path(request.uri().path())
3829    {
3830        next.run(request).await
3831    } else {
3832        (
3833            StatusCode::SERVICE_UNAVAILABLE,
3834            "Service is still starting up",
3835        )
3836            .into_response()
3837    }
3838}
3839
3840pub fn path_matches_route_prefix(path: &str, prefix: &str) -> bool {
3841    path == prefix
3842        || path
3843            .strip_prefix(prefix)
3844            .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
3845}
3846
3847/// Build a `tower_http::cors::CorsLayer` from the framework's [`crate::config::CorsConfig`].
3848///
3849/// Called only when `config.cors.allowed_origins` is non-empty.
3850pub fn build_cors_layer(cors: &crate::config::CorsConfig) -> tower_http::cors::CorsLayer {
3851    use http::header::HeaderName;
3852    use tower_http::cors::{AllowOrigin, CorsLayer};
3853
3854    let layer = if cors.allowed_origins.iter().any(|o| o == "*") {
3855        CorsLayer::new().allow_origin(AllowOrigin::any())
3856    } else {
3857        let origins: Vec<http::HeaderValue> = cors
3858            .allowed_origins
3859            .iter()
3860            .filter_map(|o| match o.parse() {
3861                Ok(v) => Some(v),
3862                Err(e) => {
3863                    tracing::warn!(origin = %o, error = %e, "CORS: ignoring malformed allowed_origin");
3864                    None
3865                }
3866            })
3867            .collect();
3868        CorsLayer::new().allow_origin(origins)
3869    };
3870
3871    let methods: Vec<http::Method> = cors
3872        .allowed_methods
3873        .iter()
3874        .filter_map(|m| match m.parse() {
3875            Ok(v) => Some(v),
3876            Err(e) => {
3877                tracing::warn!(method = %m, error = %e, "CORS: ignoring malformed allowed_method");
3878                None
3879            }
3880        })
3881        .collect();
3882
3883    let headers: Vec<HeaderName> = cors
3884        .allowed_headers
3885        .iter()
3886        .filter_map(|h| match h.parse() {
3887            Ok(v) => Some(v),
3888            Err(e) => {
3889                tracing::warn!(header = %h, error = %e, "CORS: ignoring malformed allowed_header");
3890                None
3891            }
3892        })
3893        .collect();
3894
3895    layer
3896        .allow_methods(methods)
3897        .allow_headers(headers)
3898        .allow_credentials(cors.allow_credentials)
3899        .max_age(std::time::Duration::from_secs(cors.max_age_secs))
3900}
3901
3902/// Mirror onto a timeout-generated 503 the CORS response headers `CorsLayer`
3903/// would add to a normal (non-preflight) response.
3904///
3905/// In the main ingress stack the per-request timeout layer sits *outside*
3906/// `CorsLayer` (see the layer order in `apply_middleware`), so a 503 it
3907/// synthesizes on expiry never flows back through `CorsLayer`. Without this a
3908/// cross-origin browser client sees an opaque CORS failure instead of the
3909/// documented Problem Details 503. Only the simple-response subset is needed:
3910/// the resolved `Access-Control-Allow-Origin` (with `Vary: origin` when it is
3911/// reflected) and `Access-Control-Allow-Credentials`. Preflight (OPTIONS)
3912/// requests are answered by `CorsLayer` directly and never reach the timer.
3913/// Mirror the `Access-Control-*` response headers a real `CorsLayer` would
3914/// have added, onto a `response` synthesized by a layer that sits outside
3915/// (outer to) `CorsLayer` in the ingress stack — so its 503 is CORS-readable
3916/// instead of the client seeing an opaque CORS failure. Shared by the
3917/// per-request timeout middleware and [`crate::middleware::LoadShedLayer`],
3918/// the two admission-style gates that can short-circuit before `CorsLayer`
3919/// runs.
3920pub fn mirror_cors_headers(
3921    cors: &crate::config::CorsConfig,
3922    origin: Option<&http::HeaderValue>,
3923    response: &mut axum::response::Response,
3924) {
3925    use http::header;
3926    let allow_any = cors.allowed_origins.iter().any(|o| o == "*");
3927    let allow_origin = if allow_any {
3928        Some(http::HeaderValue::from_static("*"))
3929    } else {
3930        // Echo the request Origin iff it is in the configured allowlist, exactly
3931        // as `CorsLayer` does for a reflected origin.
3932        origin.and_then(|value| {
3933            let value_str = value.to_str().ok()?;
3934            cors.allowed_origins
3935                .iter()
3936                .any(|allowed| allowed == value_str)
3937                .then(|| value.clone())
3938        })
3939    };
3940    let Some(allow_origin) = allow_origin else {
3941        // Origin missing or not allowed: a real `CorsLayer` would add nothing.
3942        return;
3943    };
3944    let headers = response.headers_mut();
3945    headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin);
3946    if !allow_any {
3947        // A reflected origin makes the response origin-dependent; mirror the
3948        // `Vary: origin` `CorsLayer` adds so shared caches don't serve it to a
3949        // different origin.
3950        headers.insert(header::VARY, http::HeaderValue::from_static("origin"));
3951    }
3952    if cors.allow_credentials {
3953        headers.insert(
3954            header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
3955            http::HeaderValue::from_static("true"),
3956        );
3957    }
3958}
3959
3960#[cfg(feature = "htmx")]
3961pub async fn htmx_handler() -> axum::response::Response {
3962    use axum::response::IntoResponse;
3963    (
3964        [
3965            (http::header::CONTENT_TYPE, "application/javascript"),
3966            (
3967                http::header::CACHE_CONTROL,
3968                "public, max-age=31536000, immutable",
3969            ),
3970        ],
3971        crate::htmx::HTMX_JS,
3972    )
3973        .into_response()
3974}
3975
3976/// Gzip/brotli encodings of a compile-time-constant CSS body, computed once
3977/// per process (via a call-site-owned [`std::sync::OnceLock`], see
3978/// [`flash_css_handler`]/[`widgets_css_handler`]) rather than redone on every
3979/// request — the bytes never change, so recompressing them per-request would
3980/// burn CPU for a byte-identical result each time.
3981#[cfg(any(feature = "flash", feature = "maud"))]
3982struct PrecompressedCss {
3983    gzip: bytes::Bytes,
3984    brotli: bytes::Bytes,
3985}
3986
3987#[cfg(any(feature = "flash", feature = "maud"))]
3988impl PrecompressedCss {
3989    fn compute(body: &'static str) -> Self {
3990        use std::io::Write as _;
3991
3992        let mut gzip_encoder =
3993            flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
3994        gzip_encoder
3995            .write_all(body.as_bytes())
3996            .expect("in-memory gzip encoding cannot fail");
3997        let gzip = gzip_encoder
3998            .finish()
3999            .expect("in-memory gzip encoding cannot fail");
4000
4001        let mut brotli_writer = brotli::CompressorWriter::new(Vec::new(), 4096, 11, 22);
4002        brotli_writer
4003            .write_all(body.as_bytes())
4004            .expect("in-memory brotli encoding cannot fail");
4005        let brotli = brotli_writer.into_inner();
4006
4007        Self {
4008            gzip: gzip.into(),
4009            brotli: brotli.into(),
4010        }
4011    }
4012}
4013
4014/// `true` when the request's `Accept-Encoding` header accepts `coding`
4015/// (case-insensitive, comma-separated, honoring an explicit `q=0` opt-out
4016/// per RFC 7231 §5.3.4). A minimal parser rather than a full content-
4017/// negotiation crate, since only `gzip`/`br` ever need checking here.
4018#[cfg(any(feature = "flash", feature = "maud"))]
4019fn accepts_encoding(headers: &http::HeaderMap, coding: &str) -> bool {
4020    let Some(value) = headers
4021        .get(http::header::ACCEPT_ENCODING)
4022        .and_then(|v| v.to_str().ok())
4023    else {
4024        return false;
4025    };
4026    value.split(',').any(|part| {
4027        let mut segments = part.split(';');
4028        let name = segments.next().unwrap_or("").trim();
4029        name.eq_ignore_ascii_case(coding)
4030            && segments
4031                .find_map(|q| q.trim().strip_prefix("q="))
4032                .and_then(|q| q.parse::<f32>().ok())
4033                .is_none_or(|q| q > 0.0)
4034    })
4035}
4036
4037/// Serves a framework-owned, compile-time-constant CSS asset: same-origin,
4038/// immutably cached, conditional-GET aware (a strong `ETag` hashed from
4039/// `body`, so a revalidating client gets a bodyless `304` instead of the
4040/// full asset), and served pre-compressed from `precompressed` when the
4041/// client's `Accept-Encoding` allows it — computed once per process, not
4042/// per request. Shared by every framework CSS route ([`flash_css_handler`],
4043/// [`widgets_css_handler`]) so the caching/content-type/compression policy
4044/// lives in one place.
4045#[cfg(any(feature = "flash", feature = "maud"))]
4046fn static_css_response(
4047    headers: &http::HeaderMap,
4048    body: &'static str,
4049    precompressed: &'static PrecompressedCss,
4050) -> axum::response::Response {
4051    use crate::etag::IntoETag as _;
4052    use axum::response::IntoResponse;
4053
4054    let (encoded_body, content_encoding): (axum::body::Body, Option<&'static str>) =
4055        if accepts_encoding(headers, "br") {
4056            (precompressed.brotli.clone().into(), Some("br"))
4057        } else if accepts_encoding(headers, "gzip") {
4058            (precompressed.gzip.clone().into(), Some("gzip"))
4059        } else {
4060            (body.into(), None)
4061        };
4062
4063    let mut response_headers = http::HeaderMap::new();
4064    response_headers.insert(
4065        http::header::CONTENT_TYPE,
4066        http::HeaderValue::from_static("text/css; charset=utf-8"),
4067    );
4068    response_headers.insert(
4069        http::header::CACHE_CONTROL,
4070        http::HeaderValue::from_static("public, max-age=31536000, immutable"),
4071    );
4072    // Cache intermediaries must key on the request's Accept-Encoding since
4073    // the body served for this same URL differs (plain/gzip/br).
4074    response_headers.insert(
4075        http::header::VARY,
4076        http::HeaderValue::from_static("Accept-Encoding"),
4077    );
4078    if let Some(encoding) = content_encoding {
4079        response_headers.insert(
4080            http::header::CONTENT_ENCODING,
4081            http::HeaderValue::from_static(encoding),
4082        );
4083    }
4084
4085    // A weak validator: the identity/gzip/br byte streams served for this
4086    // one logical resource are not byte-identical, so a strong ETag (which
4087    // asserts byte-for-byte equivalence — see `ETag::strong`) would be
4088    // incorrect here, even though `Vary: Accept-Encoding` already keeps
4089    // cache entries for different encodings distinct.
4090    let etag = crate::etag::ETag::weak(body.into_etag().tag().to_owned());
4091
4092    crate::etag::fresh_when(headers, etag)
4093        .or((response_headers, encoded_body))
4094        .into_response()
4095}
4096
4097/// Serves the framework's default flash-message stylesheet
4098/// ([`crate::flash::FLASH_CSS`]) at [`crate::flash::FLASH_CSS_PATH`].
4099#[cfg(feature = "flash")]
4100pub async fn flash_css_handler(headers: http::HeaderMap) -> axum::response::Response {
4101    static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
4102    static_css_response(
4103        &headers,
4104        crate::flash::FLASH_CSS,
4105        PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::flash::FLASH_CSS)),
4106    )
4107}
4108
4109/// Serves the framework's widget stylesheet ([`crate::ui::WIDGETS_CSS`]) at
4110/// [`crate::ui::WIDGETS_CSS_PATH`] (#1215).
4111#[cfg(feature = "maud")]
4112pub async fn widgets_css_handler(headers: http::HeaderMap) -> axum::response::Response {
4113    static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
4114    static_css_response(
4115        &headers,
4116        crate::ui::WIDGETS_CSS,
4117        PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::ui::WIDGETS_CSS)),
4118    )
4119}
4120
4121#[cfg(feature = "htmx")]
4122pub async fn htmx_csrf_handler() -> axum::response::Response {
4123    use axum::response::IntoResponse;
4124    (
4125        [
4126            (http::header::CONTENT_TYPE, "application/javascript"),
4127            (
4128                http::header::CACHE_CONTROL,
4129                "public, max-age=31536000, immutable",
4130            ),
4131        ],
4132        crate::htmx::HTMX_CSRF_JS,
4133    )
4134        .into_response()
4135}
4136
4137#[cfg(feature = "htmx")]
4138pub async fn autumn_widgets_handler() -> axum::response::Response {
4139    use axum::response::IntoResponse;
4140    (
4141        [
4142            (http::header::CONTENT_TYPE, "application/javascript"),
4143            (
4144                http::header::CACHE_CONTROL,
4145                "public, max-age=31536000, immutable",
4146            ),
4147        ],
4148        crate::htmx::AUTUMN_WIDGETS_JS,
4149    )
4150        .into_response()
4151}
4152
4153/// Weak `ETag` for the vendored idiomorph script, derived once from the
4154/// embedded bytes.
4155///
4156/// The idiomorph URL is **not** content-fingerprinted, so it cannot safely use
4157/// an `immutable` cache. Instead the handler emits this content-derived `ETag`
4158/// alongside a revalidating `Cache-Control`, letting caches confirm freshness
4159/// (and pick up new bytes) whenever the vendored script changes.
4160///
4161/// The validator is **weak**: when compression is enabled,
4162/// `apply_compression_middleware` gzips/brotli-encodes this
4163/// `application/javascript` response after the handler attaches the `ETag`, so
4164/// the identity, gzip, and br variants share one tag despite differing byte
4165/// streams. A strong `ETag` asserts byte-for-byte equivalence and would be
4166/// invalid across those encodings (matching the sibling CSS asset handler).
4167#[cfg(feature = "htmx")]
4168static IDIOMORPH_ETAG: std::sync::LazyLock<crate::etag::ETag> = std::sync::LazyLock::new(|| {
4169    use sha2::{Digest, Sha256};
4170    use std::fmt::Write as _;
4171
4172    let digest = Sha256::digest(crate::htmx::IDIOMORPH_JS);
4173    let mut hex = String::with_capacity(digest.len() * 2);
4174    for byte in digest {
4175        let _ = write!(hex, "{byte:02x}");
4176    }
4177    crate::etag::ETag::weak(format!("idiomorph-{hex}"))
4178});
4179
4180/// Serves the vendored idiomorph DOM-morphing library at [`crate::htmx::IDIOMORPH_JS_PATH`].
4181///
4182/// Idiomorph enables smooth DOM morphing via `hx-swap="morph"` in htmx.
4183///
4184/// Because the serving URL is not content-fingerprinted, the response uses a
4185/// revalidating cache policy (`must-revalidate` plus a weak content-derived
4186/// `ETag`) rather than a year-long `immutable` cache. This ensures clients that
4187/// cached an earlier version of the script pick up new bytes instead of running
4188/// a stale copy for up to a year.
4189#[cfg(feature = "htmx")]
4190pub async fn idiomorph_handler() -> axum::response::Response {
4191    use axum::response::IntoResponse;
4192    let mut response = (
4193        [
4194            (http::header::CONTENT_TYPE, "application/javascript"),
4195            (
4196                http::header::CACHE_CONTROL,
4197                "public, max-age=0, must-revalidate",
4198            ),
4199        ],
4200        crate::htmx::IDIOMORPH_JS,
4201    )
4202        .into_response();
4203    response
4204        .headers_mut()
4205        .insert(http::header::ETAG, IDIOMORPH_ETAG.header_value());
4206    response
4207}
4208
4209/// Serves the vendored htmx SSE extension at [`crate::htmx::HTMX_SSE_JS_PATH`].
4210///
4211/// The SSE extension enables `hx-ext="sse"` for server-sent event streams.
4212#[cfg(feature = "htmx")]
4213pub async fn htmx_sse_handler() -> axum::response::Response {
4214    use axum::response::IntoResponse;
4215    (
4216        [
4217            (http::header::CONTENT_TYPE, "application/javascript"),
4218            (
4219                http::header::CACHE_CONTROL,
4220                "public, max-age=31536000, immutable",
4221            ),
4222        ],
4223        crate::htmx::HTMX_SSE_JS,
4224    )
4225        .into_response()
4226}
4227
4228#[cfg(feature = "openapi")]
4229fn collect_openapi_docs(
4230    route_list: &[Route],
4231    scoped_groups: &[ScopedGroup],
4232) -> Vec<crate::openapi::ApiDoc> {
4233    // Walk both top-level routes and scoped groups. For scoped groups the
4234    // effective path is `prefix + route.path`; we materialize these into
4235    // fresh `ApiDoc`s so the rendered spec reflects the actual URL the
4236    // user will call.
4237    let mut docs: Vec<crate::openapi::ApiDoc> = Vec::new();
4238    for route in route_list {
4239        let mut doc = route.api_doc.clone();
4240        doc.api_version = route.api_version;
4241        doc.sunset_opt_out = route.sunset_opt_out;
4242        docs.push(doc);
4243    }
4244    for group in scoped_groups {
4245        // Extract `{name}` captures from the scope prefix so parameters
4246        // declared in the prefix (e.g. `/orgs/{org_id}`) show up on the
4247        // generated operation alongside the child route's own params.
4248        let prefix_params = extract_path_params(&group.prefix);
4249        for route in &group.routes {
4250            let mut doc = route.api_doc.clone();
4251            doc.api_version = route.api_version;
4252            doc.sunset_opt_out = route.sunset_opt_out;
4253            // Leak the combined path so it fits the `&'static str` shape of
4254            // ApiDoc. The spec is built once per process; the leak is
4255            // bounded by the route table size. Using the same
4256            // normalization as `join_nested_path` keeps the spec's
4257            // paths aligned with the URLs axum actually routes.
4258            let full = join_nested_path(&group.prefix, route.api_doc.path);
4259            doc.path = Box::leak(full.into_boxed_str());
4260
4261            if !prefix_params.is_empty() {
4262                let mut merged: Vec<&'static str> = prefix_params
4263                    .iter()
4264                    .map(|p| &*Box::leak(p.clone().into_boxed_str()))
4265                    .collect();
4266                for existing in route.api_doc.path_params {
4267                    if !merged.iter().any(|n| n == existing) {
4268                        merged.push(existing);
4269                    }
4270                }
4271                doc.path_params = Box::leak(merged.into_boxed_slice());
4272            }
4273
4274            docs.push(doc);
4275        }
4276    }
4277    docs
4278}
4279
4280#[cfg(feature = "openapi")]
4281fn mount_swagger_ui_routes(
4282    mut router: axum::Router<AppState>,
4283    path: &str,
4284    title: &str,
4285    json_path: &str,
4286) -> axum::Router<AppState> {
4287    let [css_path, bundle_path, initializer_path] = crate::openapi::swagger_ui_asset_paths(path);
4288    let html_body = Arc::new(crate::openapi::swagger_ui_html(
4289        title,
4290        &css_path,
4291        &bundle_path,
4292        &initializer_path,
4293    ));
4294    let initializer_body = Arc::new(crate::openapi::swagger_ui_initializer_js(json_path));
4295    router = router.route(
4296        path,
4297        axum::routing::get(move || {
4298            let html = html_body.clone();
4299            async move {
4300                use axum::response::IntoResponse;
4301                (
4302                    [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
4303                    (*html).clone(),
4304                )
4305                    .into_response()
4306            }
4307        }),
4308    );
4309    router = router.route(
4310        &css_path,
4311        axum::routing::get(|| async move {
4312            use axum::response::IntoResponse;
4313            (
4314                [(http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
4315                crate::openapi::SWAGGER_UI_CSS,
4316            )
4317                .into_response()
4318        }),
4319    );
4320    router = router.route(
4321        &bundle_path,
4322        axum::routing::get(|| async move {
4323            use axum::body::Bytes;
4324            use axum::response::IntoResponse;
4325            (
4326                [(
4327                    http::header::CONTENT_TYPE,
4328                    "application/javascript; charset=utf-8",
4329                )],
4330                Bytes::from_static(crate::openapi::SWAGGER_UI_BUNDLE),
4331            )
4332                .into_response()
4333        }),
4334    );
4335    router = router.route(
4336        &initializer_path,
4337        axum::routing::get(move || {
4338            let js = initializer_body.clone();
4339            async move {
4340                use axum::response::IntoResponse;
4341                (
4342                    [(
4343                        http::header::CONTENT_TYPE,
4344                        "application/javascript; charset=utf-8",
4345                    )],
4346                    (*js).clone(),
4347                )
4348                    .into_response()
4349            }
4350        }),
4351    );
4352    router
4353}
4354
4355/// Scope the request's [`AppState`] as the ambient event-bus app for the
4356/// duration of the request, so the free `events::publish` resolves this app.
4357async fn event_app_context_middleware(
4358    state: axum::extract::State<AppState>,
4359    req: axum::extract::Request,
4360    next: axum::middleware::Next,
4361) -> axum::response::Response {
4362    crate::events::scope_event_app(state.0.clone(), async move { next.run(req).await }).await
4363}
4364
4365#[cfg(feature = "oauth2")]
4366async fn http_interceptor_middleware(
4367    state: axum::extract::State<AppState>,
4368    req: axum::extract::Request,
4369    next: axum::middleware::Next,
4370) -> axum::response::Response {
4371    use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
4372    if let Some(interceptor_arc) = state.extension::<Arc<dyn HttpInterceptor>>() {
4373        let interceptor = (*interceptor_arc).clone();
4374        let interceptors = vec![interceptor];
4375        ACTIVE_HTTP_INTERCEPTORS
4376            .scope(interceptors, async move { next.run(req).await })
4377            .await
4378    } else {
4379        next.run(req).await
4380    }
4381}
4382
4383#[cfg(test)]
4384mod tests {
4385    use super::*;
4386    use axum::body::Body;
4387    use axum::http::{Request, StatusCode};
4388    use tower::ServiceExt;
4389
4390    fn test_state() -> AppState {
4391        AppState {
4392            extensions: std::sync::Arc::new(std::sync::RwLock::new(
4393                std::collections::HashMap::new(),
4394            )),
4395            #[cfg(feature = "db")]
4396            pool: None,
4397            #[cfg(feature = "db")]
4398            replica_pool: None,
4399            #[cfg(feature = "db")]
4400            shards: None,
4401            profile: Some("test".to_owned()),
4402            role: crate::config::ProcessRole::Combined,
4403            started_at: std::time::Instant::now(),
4404            health_detailed: false,
4405            probes: crate::probe::ProbeState::ready_for_test(),
4406            metrics: crate::middleware::MetricsCollector::new(),
4407            log_levels: crate::actuator::LogLevels::new("info"),
4408            task_registry: crate::actuator::TaskRegistry::new(),
4409            job_registry: crate::actuator::JobRegistry::new(),
4410            config_props: crate::actuator::ConfigProperties::default(),
4411            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
4412            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
4413            #[cfg(feature = "ws")]
4414            channels: crate::channels::Channels::new(32),
4415            #[cfg(feature = "presence")]
4416            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
4417            #[cfg(feature = "ws")]
4418            shutdown: tokio_util::sync::CancellationToken::new(),
4419            policy_registry: crate::authorization::PolicyRegistry::default(),
4420            forbidden_response: crate::authorization::ForbiddenResponse::default(),
4421            auth_session_key: "user_id".to_owned(),
4422            shared_cache: None,
4423            clock: std::sync::Arc::new(crate::time::SystemClock),
4424            app_id: crate::state::AppState::next_app_id(),
4425        }
4426    }
4427
4428    // ── submit-token production memory guard wiring (Finding O) ─────────────
4429
4430    #[test]
4431    fn submit_token_explicit_memory_in_production_fails_router_build() {
4432        // EXPLICIT `[security.submit_token].backend = "memory"` + production →
4433        // hard fail at router build, mirroring the idempotency prod-memory guard.
4434        let mut config = AutumnConfig::default();
4435        config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
4436        let err = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
4437            .expect_err("explicit memory submit-token backend in prod must fail router build");
4438        assert!(
4439            matches!(err, RouterBuildError::InvalidSubmitTokenBackend(_)),
4440            "expected InvalidSubmitTokenBackend, got {err:?}"
4441        );
4442    }
4443
4444    #[test]
4445    fn submit_token_inherited_memory_in_production_builds() {
4446        // INHERITED default (`backend = None`) resolving to Memory in prod must
4447        // NOT fail — it only warns. Router build succeeds.
4448        let mut config = AutumnConfig::default();
4449        config.security.submit_token.backend = None;
4450        config.idempotency.backend = crate::config::IdempotencyBackend::Memory;
4451        let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
4452            .expect("inherited memory submit-token backend in prod must still build (warn only)");
4453    }
4454
4455    #[test]
4456    fn submit_token_memory_outside_production_builds() {
4457        // Non-production → no fail regardless of explicit memory.
4458        let mut config = AutumnConfig::default();
4459        config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
4460        let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, false)
4461            .expect("memory submit-token backend outside production must build");
4462    }
4463
4464    #[tokio::test]
4465    async fn build_router_mounts_actuator_at_configured_prefix() {
4466        let mut config = AutumnConfig::default();
4467        config.actuator.prefix = "/ops".to_owned();
4468        config.actuator.sensitive = true;
4469
4470        let app = build_router(Vec::new(), &config, test_state());
4471
4472        let prefixed = app
4473            .clone()
4474            .oneshot(
4475                Request::builder()
4476                    .uri("/ops/health")
4477                    .body(Body::empty())
4478                    .unwrap(),
4479            )
4480            .await
4481            .unwrap();
4482        assert_eq!(prefixed.status(), StatusCode::OK);
4483
4484        let legacy = app
4485            .oneshot(
4486                Request::builder()
4487                    .uri("/actuator/health")
4488                    .body(Body::empty())
4489                    .unwrap(),
4490            )
4491            .await
4492            .unwrap();
4493        assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
4494    }
4495
4496    /// Worker-role (#1613) probe-only router: exposes the framework probes and
4497    /// the actuator, but no user routes. A sample user path 404s.
4498    #[tokio::test]
4499    async fn probe_only_router_mounts_probes_and_actuator_but_no_user_routes() {
4500        let config = AutumnConfig::default();
4501        let app = try_build_probe_only_router(&config, test_state())
4502            .expect("probe-only router should build");
4503
4504        // Probe + actuator paths respond.
4505        for path in [
4506            config.health.live_path.as_str(),
4507            config.health.ready_path.as_str(),
4508            config.health.startup_path.as_str(),
4509            config.health.path.as_str(),
4510            "/actuator/health",
4511            "/actuator/info",
4512        ] {
4513            let response = app
4514                .clone()
4515                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
4516                .await
4517                .unwrap();
4518            assert_ne!(
4519                response.status(),
4520                StatusCode::NOT_FOUND,
4521                "probe-only router should serve {path}"
4522            );
4523        }
4524
4525        // A made-up user route is absent (probe-only router has no user table).
4526        let missing = app
4527            .oneshot(
4528                Request::builder()
4529                    .uri("/definitely-not-a-user-route")
4530                    .body(Body::empty())
4531                    .unwrap(),
4532            )
4533            .await
4534            .unwrap();
4535        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
4536    }
4537
4538    /// Issue #1971: a user route registered at the auto-mounted health path
4539    /// must WIN. The router build succeeds — no raw axum "Overlapping method
4540    /// route. Handler for GET /health already exists" panic — the user's own
4541    /// handler serves `/health`, and the remaining built-in probes (`/live`,
4542    /// `/ready`, `/startup`) still mount and respond.
4543    #[tokio::test]
4544    async fn user_route_at_health_path_overrides_builtin_probe() {
4545        async fn user_health() -> &'static str {
4546            "user-health-handler"
4547        }
4548
4549        let config = AutumnConfig::default();
4550        // Precondition: the default health alias is exactly the path we shadow.
4551        assert_eq!(config.health.path, "/health");
4552
4553        let route = Route {
4554            method: http::Method::GET,
4555            path: "/health",
4556            handler: axum::routing::get(user_health),
4557            name: "user_health",
4558            api_doc: crate::openapi::ApiDoc {
4559                method: "GET",
4560                path: "/health",
4561                operation_id: "user_health",
4562                success_status: 200,
4563                ..Default::default()
4564            },
4565            repository: None,
4566            idempotency: crate::route::RouteIdempotency::Direct,
4567            timeout: crate::route::RouteTimeout::Inherit,
4568            api_version: None,
4569            sunset_opt_out: false,
4570        };
4571
4572        // Before the fix this panicked inside `axum::Router::route`; now it
4573        // builds cleanly (`build_router` panics on any RouterBuildError, so a
4574        // successful return also proves no structured error is raised).
4575        let app = build_router(vec![route], &config, test_state());
4576
4577        // `/health` is served by the USER handler, not the framework probe.
4578        let response = app
4579            .clone()
4580            .oneshot(
4581                Request::builder()
4582                    .uri("/health")
4583                    .body(Body::empty())
4584                    .unwrap(),
4585            )
4586            .await
4587            .unwrap();
4588        assert_eq!(response.status(), StatusCode::OK);
4589        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4590            .await
4591            .unwrap();
4592        assert_eq!(
4593            &body[..],
4594            b"user-health-handler",
4595            "user route must win at the health path"
4596        );
4597
4598        // The other built-in probes are untouched and still respond.
4599        for path in ["/live", "/ready", "/startup"] {
4600            let resp = app
4601                .clone()
4602                .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
4603                .await
4604                .unwrap();
4605            assert_ne!(
4606                resp.status(),
4607                StatusCode::NOT_FOUND,
4608                "built-in probe {path} should still be mounted"
4609            );
4610        }
4611    }
4612
4613    /// The framework-owned widget stylesheet (#1215) is served the same way
4614    /// as the flash stylesheet: a same-origin, immutably-cached asset — not
4615    /// inline styles — so a strict `style-src 'self'` CSP still works and the
4616    /// asset is embeddable in the single binary (#1004) with no loose files.
4617    #[cfg(feature = "maud")]
4618    #[tokio::test]
4619    async fn widgets_css_route_serves_the_shared_stylesheet() {
4620        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4621
4622        let response = app
4623            .oneshot(
4624                Request::builder()
4625                    .uri(crate::ui::WIDGETS_CSS_PATH)
4626                    .body(Body::empty())
4627                    .unwrap(),
4628            )
4629            .await
4630            .unwrap();
4631
4632        assert_eq!(response.status(), StatusCode::OK);
4633        let content_type = response
4634            .headers()
4635            .get(http::header::CONTENT_TYPE)
4636            .unwrap()
4637            .to_str()
4638            .unwrap();
4639        assert!(content_type.contains("text/css"), "{content_type}");
4640
4641        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4642            .await
4643            .unwrap();
4644        let body = String::from_utf8(body.to_vec()).unwrap();
4645        assert!(body.contains(".autumn-field"), "{body}");
4646        assert!(body.contains(":root"), "{body}");
4647    }
4648
4649    /// The widget stylesheet is conditional-GET aware (shared `static_css_response`
4650    /// helper): a revalidating client sends back the `ETag` it was given and gets
4651    /// a bodyless `304`, instead of re-downloading the full asset every time the
4652    /// far-future `Cache-Control` gets bypassed (hard refresh, a CDN stripping
4653    /// cache headers, etc.).
4654    #[cfg(feature = "maud")]
4655    #[tokio::test]
4656    async fn widgets_css_route_supports_conditional_get() {
4657        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4658
4659        let first = app
4660            .clone()
4661            .oneshot(
4662                Request::builder()
4663                    .uri(crate::ui::WIDGETS_CSS_PATH)
4664                    .body(Body::empty())
4665                    .unwrap(),
4666            )
4667            .await
4668            .unwrap();
4669        assert_eq!(first.status(), StatusCode::OK);
4670        let etag = first
4671            .headers()
4672            .get(http::header::ETAG)
4673            .expect("widget stylesheet response should carry an ETag")
4674            .clone();
4675
4676        let revalidated = app
4677            .oneshot(
4678                Request::builder()
4679                    .uri(crate::ui::WIDGETS_CSS_PATH)
4680                    .header(http::header::IF_NONE_MATCH, etag)
4681                    .body(Body::empty())
4682                    .unwrap(),
4683            )
4684            .await
4685            .unwrap();
4686        assert_eq!(revalidated.status(), StatusCode::NOT_MODIFIED);
4687        let revalidated_body = axum::body::to_bytes(revalidated.into_body(), usize::MAX)
4688            .await
4689            .unwrap();
4690        assert!(revalidated_body.is_empty());
4691    }
4692
4693    /// The widget stylesheet's `ETag` must be weak (`W/"..."`), not strong:
4694    /// the identity/gzip/br byte streams served under it are not
4695    /// byte-identical, and a strong `ETag` asserts exactly that (RFC 7232
4696    /// §2.1).
4697    #[cfg(feature = "maud")]
4698    #[tokio::test]
4699    async fn widgets_css_route_etag_is_weak_not_strong() {
4700        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4701
4702        let response = app
4703            .oneshot(
4704                Request::builder()
4705                    .uri(crate::ui::WIDGETS_CSS_PATH)
4706                    .body(Body::empty())
4707                    .unwrap(),
4708            )
4709            .await
4710            .unwrap();
4711
4712        let etag = response
4713            .headers()
4714            .get(http::header::ETAG)
4715            .expect("widget stylesheet response should carry an ETag")
4716            .to_str()
4717            .unwrap()
4718            .to_owned();
4719        assert!(
4720            etag.starts_with("W/\""),
4721            "ETag must be weak since encoded variants aren't byte-identical: {etag}"
4722        );
4723    }
4724
4725    /// A client that sends `Accept-Encoding: br` gets the pre-computed brotli
4726    /// encoding straight back (`Content-Encoding: br`), not a plain body that
4727    /// the outer `CompressionLayer` then has to compress on the fly.
4728    #[cfg(feature = "maud")]
4729    #[tokio::test]
4730    async fn widgets_css_route_serves_precompressed_brotli_when_accepted() {
4731        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4732
4733        let response = app
4734            .oneshot(
4735                Request::builder()
4736                    .uri(crate::ui::WIDGETS_CSS_PATH)
4737                    .header(http::header::ACCEPT_ENCODING, "br")
4738                    .body(Body::empty())
4739                    .unwrap(),
4740            )
4741            .await
4742            .unwrap();
4743
4744        assert_eq!(response.status(), StatusCode::OK);
4745        assert_eq!(
4746            response
4747                .headers()
4748                .get(http::header::CONTENT_ENCODING)
4749                .unwrap(),
4750            "br"
4751        );
4752        assert_eq!(
4753            response.headers().get(http::header::VARY).unwrap(),
4754            "Accept-Encoding"
4755        );
4756
4757        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4758            .await
4759            .unwrap();
4760        let mut decoded = Vec::new();
4761        brotli::BrotliDecompress(&mut std::io::Cursor::new(body.as_ref()), &mut decoded)
4762            .expect("response body must be valid brotli");
4763        assert_eq!(String::from_utf8(decoded).unwrap(), crate::ui::WIDGETS_CSS);
4764    }
4765
4766    /// Same as the brotli case, for a `gzip`-only client.
4767    #[cfg(feature = "maud")]
4768    #[tokio::test]
4769    async fn widgets_css_route_serves_precompressed_gzip_when_accepted() {
4770        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4771
4772        let response = app
4773            .oneshot(
4774                Request::builder()
4775                    .uri(crate::ui::WIDGETS_CSS_PATH)
4776                    .header(http::header::ACCEPT_ENCODING, "gzip")
4777                    .body(Body::empty())
4778                    .unwrap(),
4779            )
4780            .await
4781            .unwrap();
4782
4783        assert_eq!(response.status(), StatusCode::OK);
4784        assert_eq!(
4785            response
4786                .headers()
4787                .get(http::header::CONTENT_ENCODING)
4788                .unwrap(),
4789            "gzip"
4790        );
4791
4792        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4793            .await
4794            .unwrap();
4795        let mut gz = flate2::read::GzDecoder::new(body.as_ref());
4796        let mut output = String::new();
4797        std::io::Read::read_to_string(&mut gz, &mut output)
4798            .expect("response body must be valid gzip");
4799        assert_eq!(output, crate::ui::WIDGETS_CSS);
4800    }
4801
4802    /// `q=0` is an explicit opt-out (RFC 7231 §5.3.4): a client that lists
4803    /// `br` but disqualifies it must fall back to the identity encoding
4804    /// rather than being served brotli anyway.
4805    #[cfg(feature = "maud")]
4806    #[tokio::test]
4807    async fn widgets_css_route_honors_q_zero_opt_out() {
4808        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4809
4810        let response = app
4811            .oneshot(
4812                Request::builder()
4813                    .uri(crate::ui::WIDGETS_CSS_PATH)
4814                    .header(http::header::ACCEPT_ENCODING, "br;q=0, gzip")
4815                    .body(Body::empty())
4816                    .unwrap(),
4817            )
4818            .await
4819            .unwrap();
4820
4821        assert_eq!(response.status(), StatusCode::OK);
4822        assert_eq!(
4823            response
4824                .headers()
4825                .get(http::header::CONTENT_ENCODING)
4826                .unwrap(),
4827            "gzip"
4828        );
4829    }
4830
4831    /// No `Accept-Encoding` header at all means identity — no
4832    /// `Content-Encoding` header, plain-text body (matches the existing
4833    /// `widgets_css_route_serves_the_shared_stylesheet` assertions).
4834    #[cfg(feature = "maud")]
4835    #[tokio::test]
4836    async fn widgets_css_route_serves_identity_with_no_accept_encoding() {
4837        let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
4838
4839        let response = app
4840            .oneshot(
4841                Request::builder()
4842                    .uri(crate::ui::WIDGETS_CSS_PATH)
4843                    .body(Body::empty())
4844                    .unwrap(),
4845            )
4846            .await
4847            .unwrap();
4848
4849        assert_eq!(response.status(), StatusCode::OK);
4850        assert!(
4851            !response
4852                .headers()
4853                .contains_key(http::header::CONTENT_ENCODING)
4854        );
4855    }
4856
4857    /// Pins the production access-log wiring (#999): the layer is applied in
4858    /// `apply_startup_barrier`, outside the barrier itself, so even requests
4859    /// rejected with 503 before the app router runs emit one access event
4860    /// carrying the status the client receives.
4861    #[test]
4862    fn startup_barrier_503s_are_access_logged() {
4863        use tracing_subscriber::layer::SubscriberExt as _;
4864
4865        #[derive(Clone, Default)]
4866        struct Capture {
4867            events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
4868        }
4869        struct Visitor<'a>(&'a mut std::collections::BTreeMap<String, String>);
4870        impl tracing::field::Visit for Visitor<'_> {
4871            fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
4872                self.0.insert(field.name().to_owned(), format!("{value:?}"));
4873            }
4874            fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
4875                self.0.insert(field.name().to_owned(), value.to_string());
4876            }
4877        }
4878        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
4879            fn on_event(
4880                &self,
4881                event: &tracing::Event<'_>,
4882                _ctx: tracing_subscriber::layer::Context<'_, S>,
4883            ) {
4884                if event.metadata().target() != crate::middleware::ACCESS_LOG_TARGET {
4885                    return;
4886                }
4887                let mut fields = std::collections::BTreeMap::new();
4888                event.record(&mut Visitor(&mut fields));
4889                self.events.lock().unwrap().push(fields);
4890            }
4891        }
4892
4893        let capture = Capture::default();
4894        let events = Arc::clone(&capture.events);
4895        let subscriber = tracing_subscriber::registry().with(capture);
4896
4897        tracing::subscriber::with_default(subscriber, || {
4898            // With startup incomplete, the barrier rejects non-probe requests
4899            // with 503 before the app router runs.
4900            let state = AppState::for_test()
4901                .with_profile("test")
4902                .with_startup_complete(false);
4903            let app = build_router(Vec::new(), &AutumnConfig::default(), state);
4904            let rt = tokio::runtime::Builder::new_current_thread()
4905                .enable_all()
4906                .build()
4907                .unwrap();
4908
4909            // `tracing` callsite `Interest` is a single value cached per
4910            // callsite across the WHOLE PROCESS, combined from every
4911            // concurrently active dispatcher. `cargo test` runs this
4912            // alongside thousands of other unit tests in the same binary,
4913            // many of which touch the same `autumn::access` callsite without
4914            // an active capturing subscriber; the combined interest can
4915            // occasionally end up (re-)cached as "not interested" in the
4916            // narrow window between rebuilding it and firing the request
4917            // below. Rebuilding and re-firing converges almost immediately in
4918            // practice, so retry a few times rather than flake.
4919            let mut response = None;
4920            for attempt in 1..=5 {
4921                tracing::callsite::rebuild_interest_cache();
4922                let resp = rt.block_on(async {
4923                    app.clone()
4924                        .oneshot(
4925                            Request::builder()
4926                                .uri("/not-a-probe")
4927                                .body(Body::empty())
4928                                .unwrap(),
4929                        )
4930                        .await
4931                        .unwrap()
4932                });
4933                let captured = !events.lock().unwrap().is_empty();
4934                response = Some(resp);
4935                if captured {
4936                    break;
4937                }
4938                assert!(
4939                    attempt < 5,
4940                    "access-log event was not captured after {attempt} attempts \
4941                     (tracing interest-cache race with a concurrent test)"
4942                );
4943            }
4944            assert_eq!(response.unwrap().status(), StatusCode::SERVICE_UNAVAILABLE);
4945        });
4946
4947        let events = events.lock().unwrap().clone();
4948        assert_eq!(
4949            events.len(),
4950            1,
4951            "a barrier-rejected request should emit one access event: {events:?}"
4952        );
4953        assert_eq!(events[0].get("status").map(String::as_str), Some("503"));
4954        assert!(
4955            !events[0].contains_key("request_id"),
4956            "barrier short-circuits before RequestIdLayer, so no request id"
4957        );
4958    }
4959
4960    /// Pins the Server-Timing fallback wiring (#1348): the fallback layer is
4961    /// applied in `apply_startup_barrier`, outside the barrier itself, so a
4962    /// request rejected with 503 before the app router (and its primary
4963    /// `ServerTimingLayer`) runs still carries a `Server-Timing` header. Without
4964    /// the fallback the header is silently dropped on these short-circuits.
4965    #[tokio::test]
4966    async fn startup_barrier_503s_carry_server_timing_header() {
4967        // Startup incomplete → the barrier 503s non-probe requests before the
4968        // app router (and the primary ServerTimingLayer) ever run.
4969        let state = AppState::for_test()
4970            .with_profile("test")
4971            .with_startup_complete(false);
4972        let mut config = AutumnConfig::default();
4973        config.observability.server_timing = Some(true);
4974
4975        let app = build_router(Vec::new(), &config, state);
4976        let response = app
4977            .oneshot(
4978                Request::builder()
4979                    .uri("/not-a-probe")
4980                    .body(Body::empty())
4981                    .unwrap(),
4982            )
4983            .await
4984            .unwrap();
4985
4986        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
4987        let header = response
4988            .headers()
4989            .get("server-timing")
4990            .expect("startup 503 short-circuit should still carry Server-Timing via the fallback")
4991            .to_str()
4992            .expect("server-timing header should be valid ASCII");
4993        assert!(
4994            header.starts_with("total;dur="),
4995            "fallback should emit a `total` metric, got {header:?}"
4996        );
4997        // Exactly one metric on the short-circuit path — the primary never ran,
4998        // so there is no second `total`.
4999        assert_eq!(
5000            header.matches("total;dur=").count(),
5001            1,
5002            "short-circuit response must carry a single total metric: {header:?}"
5003        );
5004    }
5005
5006    #[test]
5007    fn try_build_router_rejects_invalid_session_backend_config() {
5008        let mut config = AutumnConfig::default();
5009        config.session.backend = crate::session::SessionBackend::Redis;
5010
5011        let error = try_build_router(Vec::new(), &config, test_state())
5012            .expect_err("missing redis config should fail checked router build");
5013
5014        assert!(matches!(
5015            error,
5016            RouterBuildError::InvalidSessionBackend(
5017                crate::session::SessionBackendConfigError::MissingRedisUrl
5018            )
5019        ));
5020    }
5021
5022    #[test]
5023    fn try_build_router_with_static_rejects_invalid_session_backend_config() {
5024        let mut config = AutumnConfig::default();
5025        config.session.backend = crate::session::SessionBackend::Redis;
5026
5027        let error = try_build_router_with_static(Vec::new(), &config, test_state(), None)
5028            .expect_err("missing redis config should fail checked static router build");
5029
5030        assert!(matches!(
5031            error,
5032            RouterBuildError::InvalidSessionBackend(
5033                crate::session::SessionBackendConfigError::MissingRedisUrl
5034            )
5035        ));
5036    }
5037
5038    #[test]
5039    fn try_build_router_returns_error_for_probe_actuator_path_overlap() {
5040        let mut config = AutumnConfig::default();
5041        config.actuator.prefix = "/".to_owned();
5042
5043        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5044            try_build_router(Vec::new(), &config, test_state())
5045        }));
5046
5047        assert!(result.is_ok(), "try_build_router panicked on route overlap");
5048        assert!(
5049            result.unwrap().is_err(),
5050            "route overlap should be reported as a checked router build error"
5051        );
5052    }
5053
5054    /// Regression for issue #1971 P2: when a user route already owns `/health`,
5055    /// the built-in probe cedes that path (#1971) — but a root-prefix actuator
5056    /// still normalizes its own `GET /health` onto it. The ceded probe path must
5057    /// remain visible to the actuator overlap guard so this surfaces as a checked
5058    /// `FrameworkRouteOverlap` rather than an axum construction panic (matching
5059    /// the no-user-route case in
5060    /// `try_build_router_returns_error_for_probe_actuator_path_overlap`).
5061    #[test]
5062    fn probe_actuator_overlap_detected_when_user_route_owns_probe_path() {
5063        async fn user_health() -> &'static str {
5064            "user-health-handler"
5065        }
5066
5067        let mut config = AutumnConfig::default();
5068        config.actuator.prefix = "/".to_owned();
5069        // Precondition: the user route, the ceded probe, and the actuator all
5070        // land on exactly `/health`.
5071        assert_eq!(config.health.path, "/health");
5072
5073        let route = Route {
5074            method: http::Method::GET,
5075            path: "/health",
5076            handler: axum::routing::get(user_health),
5077            name: "user_health",
5078            api_doc: crate::openapi::ApiDoc {
5079                method: "GET",
5080                path: "/health",
5081                operation_id: "user_health",
5082                success_status: 200,
5083                ..Default::default()
5084            },
5085            repository: None,
5086            idempotency: crate::route::RouteIdempotency::Direct,
5087            timeout: crate::route::RouteTimeout::Inherit,
5088            api_version: None,
5089            sunset_opt_out: false,
5090        };
5091
5092        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5093            try_build_router(vec![route], &config, test_state())
5094        }));
5095
5096        assert!(
5097            result.is_ok(),
5098            "try_build_router panicked instead of returning a checked overlap error"
5099        );
5100        let build = result.unwrap();
5101        assert!(
5102            matches!(
5103                &build,
5104                Err(RouterBuildError::FrameworkRouteOverlap {
5105                    path,
5106                    incoming: "actuator endpoint",
5107                    ..
5108                }) if path == "/health"
5109            ),
5110            "root-prefix actuator over a user-owned probe path must yield a checked \
5111             FrameworkRouteOverlap for /health, got: {:?}",
5112            build.as_ref().map(|_| "Ok(router)"),
5113        );
5114    }
5115
5116    #[tokio::test]
5117    async fn apply_cors_middleware_skipped_when_no_origins() {
5118        let config = AutumnConfig::default();
5119        assert!(config.cors.allowed_origins.is_empty());
5120
5121        let base: axum::Router<AppState> =
5122            axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
5123        let router = apply_cors_middleware(base, &config).with_state(test_state());
5124
5125        let response = router
5126            .oneshot(
5127                Request::builder()
5128                    .uri("/test")
5129                    .header("Origin", "https://example.com")
5130                    .body(Body::empty())
5131                    .unwrap(),
5132            )
5133            .await
5134            .unwrap();
5135
5136        assert_eq!(response.status(), StatusCode::OK);
5137        assert!(
5138            response
5139                .headers()
5140                .get("access-control-allow-origin")
5141                .is_none(),
5142            "CORS header must be absent when no origins are configured"
5143        );
5144    }
5145
5146    #[tokio::test]
5147    async fn apply_cors_middleware_present_when_origins_configured() {
5148        let mut config = AutumnConfig::default();
5149        config.cors.allowed_origins = vec!["https://example.com".to_owned()];
5150
5151        let base: axum::Router<AppState> =
5152            axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
5153        let router = apply_cors_middleware(base, &config).with_state(test_state());
5154
5155        let response = router
5156            .oneshot(
5157                Request::builder()
5158                    .uri("/test")
5159                    .header("Origin", "https://example.com")
5160                    .body(Body::empty())
5161                    .unwrap(),
5162            )
5163            .await
5164            .unwrap();
5165
5166        assert_eq!(response.status(), StatusCode::OK);
5167        assert!(
5168            response
5169                .headers()
5170                .get("access-control-allow-origin")
5171                .is_some(),
5172            "CORS header must be present when origins are configured"
5173        );
5174    }
5175
5176    #[tokio::test]
5177    async fn apply_cors_middleware_handles_preflight_request() {
5178        let mut config = AutumnConfig::default();
5179        config.cors.allowed_origins = vec!["https://example.com".to_owned()];
5180
5181        let base: axum::Router<AppState> =
5182            axum::Router::new().route("/api/widgets", axum::routing::post(|| async { "ok" }));
5183        let router = apply_cors_middleware(base, &config).with_state(test_state());
5184
5185        let response = router
5186            .oneshot(
5187                Request::builder()
5188                    .method("OPTIONS")
5189                    .uri("/api/widgets")
5190                    .header("Origin", "https://example.com")
5191                    .header("Access-Control-Request-Method", "POST")
5192                    .header("Access-Control-Request-Headers", "Content-Type")
5193                    .body(Body::empty())
5194                    .unwrap(),
5195            )
5196            .await
5197            .unwrap();
5198
5199        let headers = response.headers();
5200        assert_eq!(
5201            headers
5202                .get("access-control-allow-origin")
5203                .and_then(|v| v.to_str().ok()),
5204            Some("https://example.com"),
5205            "preflight must echo the allowed origin"
5206        );
5207        assert!(
5208            headers.get("access-control-allow-methods").is_some(),
5209            "preflight must advertise allowed methods"
5210        );
5211        assert!(
5212            headers.get("access-control-allow-headers").is_some(),
5213            "preflight must advertise allowed headers"
5214        );
5215        assert!(
5216            headers.get("access-control-max-age").is_some(),
5217            "preflight must advertise max-age so browsers can cache it"
5218        );
5219    }
5220
5221    #[tokio::test]
5222    async fn apply_csrf_middleware_skipped_when_disabled() {
5223        let config = AutumnConfig::default();
5224        assert!(!config.security.csrf.enabled);
5225
5226        let base: axum::Router<AppState> =
5227            axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
5228        let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
5229
5230        // Without CSRF the POST should pass through with no CSRF-specific response
5231        let response = router
5232            .oneshot(
5233                Request::builder()
5234                    .method("POST")
5235                    .uri("/form")
5236                    .body(Body::empty())
5237                    .unwrap(),
5238            )
5239            .await
5240            .unwrap();
5241
5242        assert_eq!(response.status(), StatusCode::OK);
5243    }
5244
5245    #[tokio::test]
5246    async fn apply_rate_limit_middleware_skipped_when_disabled() {
5247        let config = AutumnConfig::default();
5248        assert!(!config.security.rate_limit.enabled);
5249
5250        let base: axum::Router<AppState> =
5251            axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
5252        let state = test_state();
5253        let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
5254
5255        // Fire several rapid requests; none should be throttled.
5256        for _ in 0..5 {
5257            let response = router
5258                .clone()
5259                .oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
5260                .await
5261                .unwrap();
5262            assert_eq!(response.status(), StatusCode::OK);
5263        }
5264    }
5265
5266    #[tokio::test]
5267    async fn apply_rate_limit_middleware_returns_429_when_exhausted() {
5268        let mut config = AutumnConfig::default();
5269        config.security.rate_limit.enabled = true;
5270        config.security.rate_limit.requests_per_second = 0.1;
5271        config.security.rate_limit.burst = 1;
5272        config.security.rate_limit.trust_forwarded_headers = true;
5273
5274        let base: axum::Router<AppState> =
5275            axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
5276        let state = test_state();
5277        let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
5278
5279        let ok = router
5280            .clone()
5281            .oneshot(
5282                Request::builder()
5283                    .uri("/ping")
5284                    .header("X-Forwarded-For", "203.0.113.9")
5285                    .body(Body::empty())
5286                    .unwrap(),
5287            )
5288            .await
5289            .unwrap();
5290        assert_eq!(ok.status(), StatusCode::OK);
5291
5292        let blocked = router
5293            .oneshot(
5294                Request::builder()
5295                    .uri("/ping")
5296                    .header("X-Forwarded-For", "203.0.113.9")
5297                    .body(Body::empty())
5298                    .unwrap(),
5299            )
5300            .await
5301            .unwrap();
5302        assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
5303        assert!(blocked.headers().get("retry-after").is_some());
5304    }
5305
5306    #[cfg(feature = "mcp")]
5307    #[tokio::test]
5308    async fn mcp_envelope_is_gated_during_maintenance() {
5309        use crate::maintenance::{MaintenanceConfig, MaintenanceState};
5310
5311        // Trust the host the control request sends so that, with maintenance
5312        // off, the envelope's host guard lets `initialize` through.
5313        let mut config = AutumnConfig::default();
5314        config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
5315
5316        let wiring = crate::mcp::McpWiring {
5317            cors: crate::config::CorsConfig::default(),
5318            trusted_hosts: TrustedHostPolicy::from_config(&config),
5319            tenant_header: None,
5320            csrf_header: "x-csrf-token".to_owned(),
5321            envelope_rate_limited: false,
5322            envelope_load_shed: false,
5323        };
5324        let mcp_router =
5325            crate::mcp::build_mcp_router("/mcp", Vec::new(), axum::Router::new(), wiring, None);
5326
5327        let initialize = || {
5328            Request::builder()
5329                .method("POST")
5330                .uri("/mcp")
5331                .header("host", "app.example")
5332                .header("content-type", "application/json")
5333                .body(Body::from(
5334                    serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize"}).to_string(),
5335                ))
5336                .unwrap()
5337        };
5338
5339        // Maintenance ON: the late-mounted envelope returns the documented 503
5340        // instead of serving the catalog — the gap this layer closes.
5341        let state = test_state();
5342        let maintenance = MaintenanceState::new();
5343        maintenance.enable(MaintenanceConfig::default());
5344        state.insert_extension(maintenance);
5345        let gated = mcp_router
5346            .clone()
5347            .layer(build_maintenance_layer(&config, &state))
5348            .with_state(state);
5349        let resp = gated.oneshot(initialize()).await.unwrap();
5350        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
5351
5352        // Maintenance OFF (no enabled state): the same envelope serves
5353        // `initialize` normally, confirming the gate is the only difference.
5354        let state = test_state();
5355        let open = mcp_router
5356            .layer(build_maintenance_layer(&config, &state))
5357            .with_state(state);
5358        let resp = open.oneshot(initialize()).await.unwrap();
5359        assert_eq!(resp.status(), StatusCode::OK);
5360    }
5361
5362    #[cfg(feature = "mail")]
5363    fn dev_mail_preview_config(dir: &std::path::Path) -> AutumnConfig {
5364        let mut config = AutumnConfig {
5365            profile: Some("dev".to_owned()),
5366            mail: crate::mail::MailConfig {
5367                transport: crate::mail::Transport::File,
5368                file_dir: dir.to_path_buf(),
5369                ..Default::default()
5370            },
5371            ..Default::default()
5372        };
5373        config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
5374        config
5375    }
5376
5377    #[cfg(any(feature = "mail", feature = "maud"))]
5378    async fn response_text(response: axum::response::Response) -> String {
5379        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5380            .await
5381            .expect("body should collect");
5382        String::from_utf8(body.to_vec()).expect("body should be utf8")
5383    }
5384
5385    #[cfg(feature = "mail")]
5386    #[tokio::test]
5387    async fn build_router_mounts_dev_mail_preview_empty_state_for_file_transport() {
5388        let dir = tempfile::tempdir().expect("tempdir");
5389        let config = dev_mail_preview_config(dir.path());
5390        let router = build_router(Vec::new(), &config, test_state());
5391
5392        let response = router
5393            .oneshot(
5394                Request::builder()
5395                    .uri("/_autumn/mail")
5396                    .header("host", "example.com")
5397                    .body(Body::empty())
5398                    .unwrap(),
5399            )
5400            .await
5401            .unwrap();
5402
5403        assert_eq!(response.status(), StatusCode::OK);
5404        let body = response_text(response).await;
5405        assert!(
5406            body.contains("No captured emails"),
5407            "missing empty state: {body}"
5408        );
5409        assert!(
5410            body.contains("mail.transport = &quot;file&quot;"),
5411            "empty state should explain capture setup: {body}"
5412        );
5413    }
5414
5415    #[cfg(feature = "mail")]
5416    #[tokio::test]
5417    async fn build_router_lists_captured_mail_newest_first() {
5418        let dir = tempfile::tempdir().expect("tempdir");
5419        let older = dir.path().join("older.eml");
5420        let newer = dir.path().join("newer.eml");
5421        std::fs::write(
5422            &older,
5423            "To: first@example.com\nSubject: First\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <first@example.com>\n\nfirst body\n",
5424        )
5425        .expect("write older eml");
5426        std::fs::write(
5427            &newer,
5428            "To: second@example.com\nSubject: Second\nDate: Tue, 05 May 2026 10:01:00 +0000\nMessage-Id: <second@example.com>\n\nsecond body\n",
5429        )
5430        .expect("write newer eml");
5431        filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(100, 0))
5432            .expect("set older mtime");
5433        filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(200, 0))
5434            .expect("set newer mtime");
5435
5436        let config = dev_mail_preview_config(dir.path());
5437        let router = build_router(Vec::new(), &config, test_state());
5438        let response = router
5439            .oneshot(
5440                Request::builder()
5441                    .uri("/_autumn/mail")
5442                    .header("host", "example.com")
5443                    .body(Body::empty())
5444                    .unwrap(),
5445            )
5446            .await
5447            .unwrap();
5448
5449        assert_eq!(response.status(), StatusCode::OK);
5450        let body = response_text(response).await;
5451        let second = body.find("Second").expect("newer subject should render");
5452        let first = body.find("First").expect("older subject should render");
5453        assert!(second < first, "newest message should render first: {body}");
5454        assert!(
5455            body.contains("second@example.com"),
5456            "missing To column: {body}"
5457        );
5458        assert!(
5459            body.contains("Timestamp"),
5460            "missing timestamp column: {body}"
5461        );
5462    }
5463
5464    #[cfg(feature = "mail")]
5465    #[tokio::test]
5466    async fn build_router_mail_preview_detail_renders_html_in_sandboxed_iframe() {
5467        let dir = tempfile::tempdir().expect("tempdir");
5468        std::fs::write(
5469            dir.path().join("detail.eml"),
5470            "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",
5471        )
5472        .expect("write detail eml");
5473
5474        let config = dev_mail_preview_config(dir.path());
5475        let router = build_router(Vec::new(), &config, test_state());
5476        let response = router
5477            .oneshot(
5478                Request::builder()
5479                    .uri("/_autumn/mail/messages/detail.eml")
5480                    .header("host", "example.com")
5481                    .body(Body::empty())
5482                    .unwrap(),
5483            )
5484            .await
5485            .unwrap();
5486
5487        assert_eq!(response.status(), StatusCode::OK);
5488        let body = response_text(response).await;
5489        assert!(body.contains("<iframe"), "missing iframe: {body}");
5490        assert!(body.contains("sandbox"), "iframe must be sandboxed: {body}");
5491        assert!(body.contains("Hello iframe"), "missing html body: {body}");
5492        assert!(body.contains("Plain text"), "missing text toggle: {body}");
5493        assert!(body.contains("Headers"), "missing headers toggle: {body}");
5494        assert!(
5495            body.contains("Raw .eml"),
5496            "missing raw source toggle: {body}"
5497        );
5498        assert!(
5499            body.contains("Message-Id"),
5500            "missing message id header: {body}"
5501        );
5502    }
5503
5504    #[cfg(feature = "mail")]
5505    #[tokio::test]
5506    async fn build_router_does_not_mount_mail_preview_outside_dev() {
5507        let dir = tempfile::tempdir().expect("tempdir");
5508        let mut config = dev_mail_preview_config(dir.path());
5509        config.profile = Some("prod".to_owned());
5510        let router = build_router(Vec::new(), &config, test_state());
5511
5512        let response = router
5513            .oneshot(
5514                Request::builder()
5515                    .uri("/_autumn/mail")
5516                    .header("host", "example.com")
5517                    .body(Body::empty())
5518                    .unwrap(),
5519            )
5520            .await
5521            .unwrap();
5522
5523        assert_eq!(response.status(), StatusCode::NOT_FOUND);
5524    }
5525
5526    // ── Widget story gallery mount gating (issue #1526) ─────────────────────
5527    //
5528    // Unlike the dev-only mail preview, `/_stories` is opt-in in ANY profile
5529    // via `[stories] enabled = true` (default false): mounting is gated only
5530    // on the resolved config flag, while handlers read the `StoryRegistry`
5531    // from the AppState extension installed by `with_story_gallery`.
5532
5533    #[cfg(feature = "maud")]
5534    fn story_gallery_config() -> AutumnConfig {
5535        let mut config = AutumnConfig::default();
5536        config.stories.enabled = true;
5537        config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
5538        config
5539    }
5540
5541    #[cfg(feature = "maud")]
5542    fn stories_state_with_builtin() -> AppState {
5543        let state = test_state();
5544        state.insert_extension(crate::stories::builtin());
5545        state
5546    }
5547
5548    #[cfg(feature = "maud")]
5549    async fn get_with_host(router: axum::Router, uri: &str) -> axum::response::Response {
5550        router
5551            .oneshot(
5552                Request::builder()
5553                    .uri(uri)
5554                    .header("host", "example.com")
5555                    .body(Body::empty())
5556                    .unwrap(),
5557            )
5558            .await
5559            .unwrap()
5560    }
5561
5562    /// T1 (AC4/AC5): with `[stories] enabled = true` and the builtin registry
5563    /// installed, the grouped index is served at `/_stories` and pulls in the
5564    /// framework widget stylesheet.
5565    #[cfg(feature = "maud")]
5566    #[tokio::test]
5567    async fn build_router_mounts_story_gallery_when_enabled() {
5568        let router = build_router(
5569            Vec::new(),
5570            &story_gallery_config(),
5571            stories_state_with_builtin(),
5572        );
5573
5574        let response = get_with_host(router, crate::stories::STORIES_PATH).await;
5575        assert_eq!(response.status(), StatusCode::OK);
5576        let body = response_text(response).await;
5577        assert!(
5578            body.contains("Data table"),
5579            "index should list builtin story names: {body}"
5580        );
5581        assert!(
5582            body.contains("autumn-widgets.css"),
5583            "index should link the framework widget stylesheet: {body}"
5584        );
5585    }
5586
5587    /// T2 (AC4): the detail route serves the live render plus Source and
5588    /// Rendered HTML tabs.
5589    #[cfg(feature = "maud")]
5590    #[tokio::test]
5591    async fn story_detail_route_serves_render_source_and_html() {
5592        let router = build_router(
5593            Vec::new(),
5594            &story_gallery_config(),
5595            stories_state_with_builtin(),
5596        );
5597
5598        let response = get_with_host(router, "/_stories/data-table").await;
5599        assert_eq!(response.status(), StatusCode::OK);
5600        let body = response_text(response).await;
5601        assert!(
5602            body.contains("<table"),
5603            "detail page must contain the live data_table render: {body}"
5604        );
5605        assert!(
5606            body.contains("data_table("),
5607            "detail page must show the source snippet that produced the render: {body}"
5608        );
5609        assert!(
5610            body.contains("Rendered HTML"),
5611            "detail page must offer the rendered-HTML tab: {body}"
5612        );
5613        assert!(
5614            body.contains("Source"),
5615            "detail page must offer the source tab: {body}"
5616        );
5617    }
5618
5619    /// T3 (AC4): a mounted gallery 404s unknown slugs while the index stays up.
5620    #[cfg(feature = "maud")]
5621    #[tokio::test]
5622    async fn story_detail_unknown_slug_is_404() {
5623        let router = build_router(
5624            Vec::new(),
5625            &story_gallery_config(),
5626            stories_state_with_builtin(),
5627        );
5628
5629        let missing = get_with_host(router.clone(), "/_stories/nope").await;
5630        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
5631
5632        let index = get_with_host(router, "/_stories").await;
5633        assert_eq!(
5634            index.status(),
5635            StatusCode::OK,
5636            "index route must exist even when a slug misses"
5637        );
5638    }
5639
5640    /// T4 (AC5/AC6): off by default — no `[stories] enabled = true`, no
5641    /// routes, even when a registry extension is installed.
5642    #[cfg(feature = "maud")]
5643    #[tokio::test]
5644    async fn build_router_omits_story_gallery_by_default() {
5645        let mut config = AutumnConfig::default();
5646        assert!(
5647            !config.stories.enabled,
5648            "stories gallery must be off by default"
5649        );
5650        config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
5651
5652        let router = build_router(Vec::new(), &config, stories_state_with_builtin());
5653        let response = get_with_host(router, "/_stories").await;
5654        assert_eq!(response.status(), StatusCode::NOT_FOUND);
5655    }
5656
5657    /// Loads a layered `autumn.toml` for `profile` via `MockEnv` (no process
5658    /// env, no `set_current_dir`) and reports the status `/_stories` returns.
5659    #[cfg(feature = "maud")]
5660    async fn stories_status_for_layered_profile(toml: &str, profile: &str) -> StatusCode {
5661        let dir = tempfile::tempdir().expect("tempdir");
5662        std::fs::write(dir.path().join("autumn.toml"), toml).expect("write autumn.toml");
5663        let env = crate::config::MockEnv::new()
5664            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap())
5665            .with("AUTUMN_ENV", profile);
5666        let mut config = AutumnConfig::load_with_env(&env).expect("layered config should load");
5667        config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
5668
5669        let router = build_router(Vec::new(), &config, stories_state_with_builtin());
5670        get_with_host(router, "/_stories").await.status()
5671    }
5672
5673    /// T5 (AC6): profile-scoped gating works both ways through the existing
5674    /// config layering — routes mount iff the resolved flag is true.
5675    #[cfg(feature = "maud")]
5676    #[tokio::test]
5677    async fn story_routes_mount_iff_resolved_profile_flag() {
5678        // Private app: dev-only gallery, absent in prod.
5679        let dev_only = r"
5680[stories]
5681enabled = false
5682
5683[profile.dev.stories]
5684enabled = true
5685";
5686        assert_eq!(
5687            stories_status_for_layered_profile(dev_only, "dev").await,
5688            StatusCode::OK,
5689            "dev profile override must mount the gallery"
5690        );
5691        assert_eq!(
5692            stories_status_for_layered_profile(dev_only, "prod").await,
5693            StatusCode::NOT_FOUND,
5694            "prod must not mount the gallery when only dev enables it"
5695        );
5696
5697        // Public showcase: enabled in prod, absent in dev.
5698        let public_showcase = r"
5699[stories]
5700enabled = false
5701
5702[profile.prod.stories]
5703enabled = true
5704";
5705        assert_eq!(
5706            stories_status_for_layered_profile(public_showcase, "prod").await,
5707            StatusCode::OK,
5708            "prod profile override must mount the gallery for a public showcase"
5709        );
5710        assert_eq!(
5711            stories_status_for_layered_profile(public_showcase, "dev").await,
5712            StatusCode::NOT_FOUND,
5713            "dev must not mount the gallery when only prod enables it"
5714        );
5715    }
5716
5717    /// T6 (AC7): a custom app story registered via
5718    /// `StoryGallery::builtin().extend(...)` is served alongside builtins.
5719    #[cfg(feature = "maud")]
5720    #[tokio::test]
5721    async fn custom_story_served_alongside_builtins() {
5722        let custom = crate::stories::story! {
5723            "App",
5724            "Greeting",
5725            {
5726                maud::html! { span class="app-greeting" { "hi from the app" } }
5727            }
5728        };
5729        let state = test_state();
5730        state.insert_extension(
5731            crate::stories::StoryGallery::builtin()
5732                .extend([custom])
5733                .into_registry(),
5734        );
5735
5736        let router = build_router(Vec::new(), &story_gallery_config(), state);
5737
5738        let detail = get_with_host(router.clone(), "/_stories/greeting").await;
5739        assert_eq!(detail.status(), StatusCode::OK);
5740        let body = response_text(detail).await;
5741        assert!(
5742            body.contains("hi from the app"),
5743            "custom story must render at its slug: {body}"
5744        );
5745
5746        let index = get_with_host(router, "/_stories").await;
5747        let body = response_text(index).await;
5748        assert!(
5749            body.contains("Greeting"),
5750            "index must list the custom story: {body}"
5751        );
5752        assert!(
5753            body.contains("App"),
5754            "index must show the custom story's group: {body}"
5755        );
5756        assert!(
5757            body.contains("Data table"),
5758            "builtins must still be listed alongside the custom story: {body}"
5759        );
5760    }
5761
5762    /// Review follow-up (#1526): with `security.headers.csp_nonce.enabled =
5763    /// true` the default CSP's `style-src` drops `'unsafe-inline'` in favor of
5764    /// a per-request nonce, so the gallery's inline `<style>` must carry the
5765    /// exact nonce the CSP header advertises or browsers block all of the
5766    /// gallery chrome CSS.
5767    #[cfg(feature = "maud")]
5768    #[tokio::test]
5769    async fn story_pages_inline_style_carries_csp_header_nonce() {
5770        let mut config = story_gallery_config();
5771        config.security.headers.csp_nonce.enabled = true;
5772
5773        let router = build_router(Vec::new(), &config, stories_state_with_builtin());
5774
5775        for uri in ["/_stories", "/_stories/data-table"] {
5776            let response = get_with_host(router.clone(), uri).await;
5777            assert_eq!(response.status(), StatusCode::OK);
5778
5779            let csp = response
5780                .headers()
5781                .get("content-security-policy")
5782                .expect("CSP header must be present")
5783                .to_str()
5784                .unwrap()
5785                .to_owned();
5786            let nonce = csp
5787                .split("'nonce-")
5788                .nth(1)
5789                .and_then(|rest| rest.split('\'').next())
5790                .unwrap_or_else(|| panic!("CSP header must advertise a nonce: {csp}"))
5791                .to_owned();
5792            assert!(!nonce.is_empty(), "advertised nonce must be non-empty");
5793
5794            let body = response_text(response).await;
5795            assert!(
5796                body.contains(&format!(r#"<style nonce="{nonce}">"#)),
5797                "{uri} inline style must carry the CSP header nonce {nonce}: {body}"
5798            );
5799        }
5800    }
5801
5802    /// T17 (AC5, R12): enabled config but no registry extension (the user
5803    /// forgot `with_story_gallery`) serves a friendly empty state, not a 500.
5804    #[cfg(feature = "maud")]
5805    #[tokio::test]
5806    async fn enabled_without_registry_shows_empty_state() {
5807        let router = build_router(Vec::new(), &story_gallery_config(), test_state());
5808
5809        let response = get_with_host(router, "/_stories").await;
5810        assert_eq!(response.status(), StatusCode::OK);
5811        let body = response_text(response).await;
5812        assert!(
5813            body.contains("with_story_gallery"),
5814            "empty state should point at AppBuilder::with_story_gallery: {body}"
5815        );
5816    }
5817
5818    #[tokio::test]
5819    async fn apply_csrf_middleware_blocks_without_token_when_enabled() {
5820        let mut config = AutumnConfig::default();
5821        config.security.csrf.enabled = true;
5822
5823        let base: axum::Router<AppState> =
5824            axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
5825        let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
5826
5827        // POST without CSRF token should be rejected
5828        let response = router
5829            .oneshot(
5830                Request::builder()
5831                    .method("POST")
5832                    .uri("/form")
5833                    .body(Body::empty())
5834                    .unwrap(),
5835            )
5836            .await
5837            .unwrap();
5838
5839        assert_ne!(
5840            response.status(),
5841            StatusCode::OK,
5842            "POST without CSRF token should be rejected when CSRF is enabled"
5843        );
5844    }
5845
5846    #[test]
5847    fn join_nested_path_normalizes_like_axum() {
5848        // Reviewer's reported case: scope "/api" + child "/" must
5849        // produce "/api", not "/api/" — otherwise a user-configured
5850        // openapi_json_path("/api") won't match the effective mount
5851        // point and the collision check is unreliable.
5852        assert_eq!(super::join_nested_path("/api", "/"), "/api");
5853        // Trailing slash on the prefix is preserved for the root child:
5854        // axum mounts `nest("/api/", route("/"))` at "/api/" and reports
5855        // `MatchedPath` as "/api/" (verified by
5856        // `join_nested_path_matches_axum_matched_path`), so the joined key
5857        // must keep the slash or the runtime lookup misses.
5858        assert_eq!(super::join_nested_path("/api/", "/"), "/api/");
5859        // Normal case: prefix + child.
5860        assert_eq!(super::join_nested_path("/api", "/users"), "/api/users");
5861        // Trailing slash on prefix + child starting with slash doesn't
5862        // produce doubled slashes.
5863        assert_eq!(super::join_nested_path("/api/", "/users"), "/api/users");
5864        // Root prefix handles sensibly.
5865        assert_eq!(super::join_nested_path("", "/"), "/");
5866        assert_eq!(super::join_nested_path("", "/users"), "/users");
5867    }
5868
5869    /// Pins `join_nested_path` to axum's real `MatchedPath` so the per-route
5870    /// timeout table (and the `OpenAPI` collision check) key by exactly the
5871    /// string the runtime looks up. The trailing-slash root child is the
5872    /// subtle case: `nest("/api/", route("/"))` is served at "/api/", not
5873    /// "/api".
5874    #[tokio::test]
5875    async fn join_nested_path_matches_axum_matched_path() {
5876        use axum::routing::get;
5877        async fn matched(mp: Option<axum::extract::MatchedPath>) -> String {
5878            mp.map(|m| m.as_str().to_owned()).unwrap_or_default()
5879        }
5880        // (nest prefix, child route, request path that reaches the child)
5881        for (prefix, child, req) in [
5882            ("/api", "/", "/api"),
5883            ("/api/", "/", "/api/"),
5884            ("/api", "/users", "/api/users"),
5885            ("/api/", "/users", "/api/users"),
5886        ] {
5887            let sub = axum::Router::new().route(child, get(matched));
5888            let app: axum::Router = axum::Router::new().nest(prefix, sub);
5889            let resp = tower::ServiceExt::oneshot(
5890                app,
5891                axum::http::Request::builder()
5892                    .uri(req)
5893                    .body(axum::body::Body::empty())
5894                    .unwrap(),
5895            )
5896            .await
5897            .unwrap();
5898            assert_eq!(resp.status(), http::StatusCode::OK, "{prefix} + {child}");
5899            let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5900                .await
5901                .unwrap();
5902            let axum_matched = String::from_utf8(body.to_vec()).unwrap();
5903            assert_eq!(
5904                super::join_nested_path(prefix, child),
5905                axum_matched,
5906                "join_nested_path must equal axum MatchedPath for nest({prefix:?}, {child:?})"
5907            );
5908        }
5909    }
5910
5911    #[cfg(feature = "openapi")]
5912    #[tokio::test]
5913    async fn try_build_router_detects_scoped_root_collision() {
5914        // Scope "/api" + child "/" mounts axum's handler at "/api"
5915        // (not "/api/"). The collision check must use the same
5916        // normalization or we'd miss this overlap.
5917        use crate::openapi::{ApiDoc, OpenApiConfig};
5918        async fn child() -> &'static str {
5919            "inner"
5920        }
5921        let group = crate::app::ScopedGroup {
5922            prefix: "/api".to_owned(),
5923            routes: vec![Route {
5924                method: http::Method::GET,
5925                path: "/",
5926                handler: axum::routing::get(child),
5927                name: "root",
5928                api_doc: ApiDoc {
5929                    method: "GET",
5930                    path: "/",
5931                    operation_id: "root",
5932                    success_status: 200,
5933                    ..Default::default()
5934                },
5935                repository: None,
5936                idempotency: crate::route::RouteIdempotency::Direct,
5937                timeout: crate::route::RouteTimeout::Inherit,
5938                api_version: None,
5939                sunset_opt_out: false,
5940            }],
5941            source: crate::route_listing::RouteSource::User,
5942            apply_layer: Box::new(|r| r),
5943        };
5944
5945        let openapi = OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api");
5946        let config = AutumnConfig::default();
5947        let ctx = RouterContext {
5948            exception_filters: Vec::new(),
5949            scoped_groups: vec![group],
5950            merge_routers: Vec::new(),
5951            nest_routers: Vec::new(),
5952            custom_layers: Vec::new(),
5953            static_gate_layers: Vec::new(),
5954            #[cfg(feature = "maud")]
5955            error_page_renderer: None,
5956            session_store: None,
5957            openapi: Some(openapi),
5958            #[cfg(feature = "mcp")]
5959            mcp: None,
5960        };
5961        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
5962            .expect_err("scope '/api' + child '/' should collide with openapi path '/api'");
5963        assert!(matches!(
5964            err,
5965            RouterBuildError::OpenApiPathCollision {
5966                field: "openapi_json_path",
5967                ..
5968            }
5969        ));
5970    }
5971
5972    /// The widget stylesheet route merges a GET unconditionally whenever
5973    /// `maud` is on, before the late-merged `OpenAPI` router — an
5974    /// `openapi_json_path` configured to the same path must be rejected by
5975    /// the preflight, not panic in `router.merge`.
5976    #[cfg(all(feature = "openapi", feature = "maud"))]
5977    #[test]
5978    fn try_build_router_detects_widgets_css_path_collision() {
5979        use crate::openapi::OpenApiConfig;
5980
5981        let openapi =
5982            OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::ui::WIDGETS_CSS_PATH);
5983        let config = AutumnConfig::default();
5984        let ctx = RouterContext {
5985            exception_filters: Vec::new(),
5986            scoped_groups: Vec::new(),
5987            merge_routers: Vec::new(),
5988            nest_routers: Vec::new(),
5989            custom_layers: Vec::new(),
5990            static_gate_layers: Vec::new(),
5991            #[cfg(feature = "maud")]
5992            error_page_renderer: None,
5993            session_store: None,
5994            openapi: Some(openapi),
5995            #[cfg(feature = "mcp")]
5996            mcp: None,
5997        };
5998        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
5999            "openapi_json_path colliding with the widget stylesheet route should be rejected",
6000        );
6001        assert!(matches!(
6002            err,
6003            RouterBuildError::OpenApiPathCollision {
6004                field: "openapi_json_path",
6005                ..
6006            }
6007        ));
6008    }
6009
6010    /// Same as above for the flash stylesheet route (pre-existing gap, same
6011    /// class of bug: the flash CSS route was also missing from
6012    /// `collect_claimed_get_paths`).
6013    #[cfg(all(feature = "openapi", feature = "flash"))]
6014    #[test]
6015    fn try_build_router_detects_flash_css_path_collision() {
6016        use crate::openapi::OpenApiConfig;
6017
6018        let openapi =
6019            OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::flash::FLASH_CSS_PATH);
6020        let config = AutumnConfig::default();
6021        let ctx = RouterContext {
6022            exception_filters: Vec::new(),
6023            scoped_groups: Vec::new(),
6024            merge_routers: Vec::new(),
6025            nest_routers: Vec::new(),
6026            custom_layers: Vec::new(),
6027            static_gate_layers: Vec::new(),
6028            #[cfg(feature = "maud")]
6029            error_page_renderer: None,
6030            session_store: None,
6031            openapi: Some(openapi),
6032            #[cfg(feature = "mcp")]
6033            mcp: None,
6034        };
6035        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
6036            "openapi_json_path colliding with the flash stylesheet route should be rejected",
6037        );
6038        assert!(matches!(
6039            err,
6040            RouterBuildError::OpenApiPathCollision {
6041                field: "openapi_json_path",
6042                ..
6043            }
6044        ));
6045    }
6046
6047    #[cfg(feature = "openapi")]
6048    #[test]
6049    fn extract_path_params_matches_macro_behavior() {
6050        // Normal multi-param routes.
6051        assert_eq!(
6052            super::extract_path_params("/orgs/{org_id}/users/{id}"),
6053            vec!["org_id".to_owned(), "id".to_owned()]
6054        );
6055        assert_eq!(
6056            super::extract_path_params("/users/{id}/posts/{slug}"),
6057            vec!["id".to_owned(), "slug".to_owned()]
6058        );
6059        assert!(super::extract_path_params("/static").is_empty());
6060
6061        // `:constraint` suffixes are stripped to the bare name.
6062        assert_eq!(
6063            super::extract_path_params("/users/{id:[0-9]+}"),
6064            vec!["id".to_owned()]
6065        );
6066        // Regex constraint containing its own braces still yields just `id`.
6067        assert_eq!(
6068            super::extract_path_params("{id:[0-9]{1,3}}"),
6069            vec!["id".to_owned()]
6070        );
6071
6072        // Escaped literal braces (`{{` / `}}`) are matchit literals, NOT
6073        // params, and must emit nothing (mirrors the macro's escape skip).
6074        assert!(super::extract_path_params("{{hello}}").is_empty());
6075        assert_eq!(
6076            super::extract_path_params("{{literal}}/{id}"),
6077            vec!["id".to_owned()]
6078        );
6079
6080        // #1721 unbalanced/malformed cases: no phantom or brace-carrying params.
6081        assert!(super::extract_path_params("{{}").is_empty());
6082        assert!(super::extract_path_params("{a{b}").is_empty());
6083        assert!(super::extract_path_params("{").is_empty());
6084        assert!(super::extract_path_params("}").is_empty());
6085        assert!(super::extract_path_params("{}").is_empty());
6086    }
6087
6088    #[cfg(feature = "openapi")]
6089    #[test]
6090    fn extract_path_params_handles_unbalanced_braces() {
6091        // Regression for #1721: unbalanced/malformed braces must never yield a
6092        // param name that still contains a brace character. The brace-free
6093        // guard drops any candidate whose inner segment retains a stray brace,
6094        // so the emitted names are always non-empty and brace-free (and stray
6095        // braces yield no spurious params).
6096        for path in ["{{}", "{", "}", "{a{b}"] {
6097            for name in super::extract_path_params(path) {
6098                assert!(
6099                    !name.contains('{') && !name.contains('}'),
6100                    "param name should be brace-free for {path:?}: {name:?}"
6101                );
6102                assert!(
6103                    !name.is_empty(),
6104                    "param name should be non-empty for {path:?}"
6105                );
6106            }
6107        }
6108        // `"{{}"` yields no param: the leading `{{` is an escaped literal brace
6109        // that is skipped, leaving only a stray `}`.
6110        assert!(super::extract_path_params("{{}").is_empty());
6111        // `"{a{b}"` yields no param: the inner segment `"a{b"` still holds a
6112        // brace, so the brace-free guard drops it.
6113        assert!(super::extract_path_params("{a{b}").is_empty());
6114    }
6115
6116    #[cfg(feature = "openapi")]
6117    #[tokio::test]
6118    async fn openapi_merges_scoped_prefix_path_params() {
6119        use crate::openapi::{ApiDoc, OpenApiConfig};
6120
6121        // Scope prefix has `{org_id}`; the child route has `{id}`. The
6122        // generated ApiDoc must declare BOTH parameters, or Swagger
6123        // validators reject the document for referencing undeclared
6124        // path params.
6125        async fn handler() -> &'static str {
6126            "ok"
6127        }
6128        let child = Route {
6129            method: http::Method::GET,
6130            path: "/users/{id}",
6131            handler: axum::routing::get(handler),
6132            name: "child",
6133            api_doc: ApiDoc {
6134                method: "GET",
6135                path: "/users/{id}",
6136                operation_id: "child",
6137                path_params: &["id"],
6138                success_status: 200,
6139                ..Default::default()
6140            },
6141            repository: None,
6142            idempotency: crate::route::RouteIdempotency::Direct,
6143            timeout: crate::route::RouteTimeout::Inherit,
6144            api_version: None,
6145            sunset_opt_out: false,
6146        };
6147        let group = crate::app::ScopedGroup {
6148            prefix: "/orgs/{org_id}".to_owned(),
6149            routes: vec![child],
6150            source: crate::route_listing::RouteSource::User,
6151            apply_layer: Box::new(|r| r),
6152        };
6153
6154        let config = OpenApiConfig::new("Demo", "1.0.0");
6155        let router = super::build_openapi_router(&[], &[group], Some(&config), "autumn.sid", &[])
6156            .expect("openapi sub-router builds")
6157            .expect("openapi sub-router present when config is Some");
6158        let state = test_state();
6159        let router = router.with_state(state);
6160
6161        let response = router
6162            .oneshot(
6163                Request::builder()
6164                    .uri("/openapi.json")
6165                    .body(Body::empty())
6166                    .unwrap(),
6167            )
6168            .await
6169            .unwrap();
6170        assert_eq!(response.status(), StatusCode::OK);
6171        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
6172            .await
6173            .unwrap();
6174        let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
6175        let params = &spec["paths"]["/orgs/{org_id}/users/{id}"]["get"]["parameters"];
6176        let names: Vec<&str> = params
6177            .as_array()
6178            .unwrap()
6179            .iter()
6180            .map(|p| p["name"].as_str().unwrap())
6181            .collect();
6182        assert!(names.contains(&"org_id"), "missing org_id: {names:?}");
6183        assert!(names.contains(&"id"), "missing id: {names:?}");
6184    }
6185
6186    #[cfg(feature = "openapi")]
6187    #[tokio::test]
6188    async fn openapi_documents_configured_session_cookie_name() {
6189        use crate::openapi::{ApiDoc, OpenApiConfig};
6190
6191        async fn handler() -> &'static str {
6192            "ok"
6193        }
6194
6195        let route = Route {
6196            method: http::Method::GET,
6197            path: "/protected",
6198            handler: axum::routing::get(handler),
6199            name: "protected",
6200            api_doc: ApiDoc {
6201                method: "GET",
6202                path: "/protected",
6203                operation_id: "protected",
6204                success_status: 200,
6205                secured: true,
6206                ..Default::default()
6207            },
6208            repository: None,
6209            idempotency: crate::route::RouteIdempotency::Direct,
6210            timeout: crate::route::RouteTimeout::Inherit,
6211            api_version: None,
6212            sunset_opt_out: false,
6213        };
6214
6215        let protected_routes = vec![route];
6216        let config = OpenApiConfig::new("Demo", "1.0.0");
6217        let docs_router =
6218            super::build_openapi_router(&protected_routes, &[], Some(&config), "demo.sid", &[])
6219                .expect("openapi sub-router builds")
6220                .expect("openapi sub-router present when config is Some");
6221        let docs_router = docs_router.with_state(test_state());
6222
6223        let response = docs_router
6224            .oneshot(
6225                Request::builder()
6226                    .uri("/openapi.json")
6227                    .body(Body::empty())
6228                    .unwrap(),
6229            )
6230            .await
6231            .unwrap();
6232        assert_eq!(response.status(), StatusCode::OK);
6233        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
6234            .await
6235            .unwrap();
6236        let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
6237        let schemes = &spec["components"]["securitySchemes"];
6238
6239        assert_eq!(schemes["SessionAuth"]["type"], "apiKey");
6240        assert_eq!(schemes["SessionAuth"]["in"], "cookie");
6241        assert_eq!(schemes["SessionAuth"]["name"], "demo.sid");
6242        assert!(
6243            schemes.get("BearerAuth").is_none(),
6244            "secured routes must not be documented as bearer JWT routes"
6245        );
6246    }
6247
6248    #[cfg(feature = "openapi")]
6249    #[test]
6250    fn openapi_rejects_json_path_without_leading_slash() {
6251        let config =
6252            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("openapi.json");
6253        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6254            .expect_err("non-slash path should be rejected");
6255        assert!(matches!(
6256            err,
6257            RouterBuildError::InvalidOpenApiPath {
6258                field: "openapi_json_path",
6259                ..
6260            }
6261        ));
6262    }
6263
6264    #[cfg(feature = "openapi")]
6265    #[test]
6266    fn openapi_rejects_path_with_captures() {
6267        // `{id}` captures would be a typo for a mount path — the
6268        // endpoints are static. Catch it before axum panics.
6269        let config =
6270            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id}");
6271        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6272            .expect_err("captures should be rejected");
6273        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6274    }
6275
6276    #[cfg(feature = "openapi")]
6277    #[test]
6278    fn openapi_rejects_path_with_unbalanced_brace() {
6279        let config =
6280            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id");
6281        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6282            .expect_err("unbalanced brace should be rejected");
6283        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6284    }
6285
6286    #[cfg(feature = "openapi")]
6287    #[test]
6288    fn openapi_rejects_path_with_wildcard() {
6289        let config =
6290            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/*rest");
6291        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6292            .expect_err("wildcard should be rejected");
6293        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6294    }
6295
6296    #[cfg(feature = "openapi")]
6297    #[test]
6298    fn openapi_rejects_path_with_double_slash() {
6299        let config =
6300            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("//docs");
6301        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6302            .expect_err("double-slash should be rejected");
6303        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6304    }
6305
6306    #[cfg(feature = "openapi")]
6307    #[test]
6308    fn openapi_rejects_swagger_ui_path_without_leading_slash() {
6309        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6310            .swagger_ui_path(Some("docs".to_owned()));
6311        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6312            .expect_err("non-slash path should be rejected");
6313        assert!(matches!(
6314            err,
6315            RouterBuildError::InvalidOpenApiPath {
6316                field: "swagger_ui_path",
6317                ..
6318            }
6319        ));
6320    }
6321
6322    #[cfg(feature = "openapi")]
6323    #[test]
6324    fn openapi_rejects_empty_json_path() {
6325        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("");
6326        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6327            .expect_err("empty path should be rejected");
6328        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
6329    }
6330
6331    #[cfg(feature = "openapi")]
6332    #[test]
6333    fn openapi_accepts_valid_paths() {
6334        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6335            .openapi_json_path("/api-docs")
6336            .swagger_ui_path(Some("/ui".to_owned()));
6337        let out = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6338            .expect("valid paths must not error");
6339        assert!(out.is_some());
6340    }
6341
6342    #[cfg(feature = "openapi")]
6343    #[test]
6344    fn openapi_rejects_duplicate_json_and_swagger_paths() {
6345        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6346            .openapi_json_path("/docs")
6347            .swagger_ui_path(Some("/docs".to_owned()));
6348        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
6349            .expect_err("colliding paths should be rejected before axum panics");
6350        assert!(matches!(
6351            err,
6352            RouterBuildError::DuplicateOpenApiPath { ref path } if path == "/docs"
6353        ));
6354    }
6355
6356    #[cfg(feature = "openapi")]
6357    async fn collision_test_handler() -> &'static str {
6358        "user"
6359    }
6360
6361    #[cfg(feature = "openapi")]
6362    #[tokio::test]
6363    async fn try_build_router_rejects_openapi_path_colliding_with_user_route() {
6364        let mut config = AutumnConfig::default();
6365        config.actuator.prefix = "/ops".to_owned();
6366        let openapi =
6367            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/my-api-docs");
6368
6369        let user_route = Route {
6370            method: http::Method::GET,
6371            path: "/my-api-docs",
6372            handler: axum::routing::get(collision_test_handler),
6373            name: "collides",
6374            api_doc: crate::openapi::ApiDoc {
6375                method: "GET",
6376                path: "/my-api-docs",
6377                operation_id: "collides",
6378                success_status: 200,
6379                ..Default::default()
6380            },
6381            repository: None,
6382            idempotency: crate::route::RouteIdempotency::Direct,
6383            timeout: crate::route::RouteTimeout::Inherit,
6384            api_version: None,
6385            sunset_opt_out: false,
6386        };
6387
6388        let ctx = RouterContext {
6389            exception_filters: Vec::new(),
6390            scoped_groups: Vec::new(),
6391            merge_routers: Vec::new(),
6392            nest_routers: Vec::new(),
6393            custom_layers: Vec::new(),
6394            static_gate_layers: Vec::new(),
6395            #[cfg(feature = "maud")]
6396            error_page_renderer: None,
6397            session_store: None,
6398            openapi: Some(openapi),
6399            #[cfg(feature = "mcp")]
6400            mcp: None,
6401        };
6402        let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
6403            .expect_err("user-owned path should prevent OpenAPI mount");
6404        assert!(matches!(
6405            err,
6406            RouterBuildError::OpenApiPathCollision { field: "openapi_json_path", ref path } if path == "/my-api-docs"
6407        ));
6408    }
6409
6410    #[cfg(feature = "openapi")]
6411    #[tokio::test]
6412    async fn try_build_router_rejects_openapi_path_colliding_with_framework_route() {
6413        let config = AutumnConfig::default(); // /actuator/health is a GET by default
6414        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6415            .openapi_json_path("/actuator/health");
6416        let ctx = RouterContext {
6417            exception_filters: Vec::new(),
6418            scoped_groups: Vec::new(),
6419            merge_routers: Vec::new(),
6420            nest_routers: Vec::new(),
6421            custom_layers: Vec::new(),
6422            static_gate_layers: Vec::new(),
6423            #[cfg(feature = "maud")]
6424            error_page_renderer: None,
6425            session_store: None,
6426            openapi: Some(openapi),
6427            #[cfg(feature = "mcp")]
6428            mcp: None,
6429        };
6430        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6431            .expect_err("framework-owned path should prevent OpenAPI mount");
6432        assert!(matches!(
6433            err,
6434            RouterBuildError::OpenApiPathCollision {
6435                field: "openapi_json_path",
6436                ..
6437            }
6438        ));
6439    }
6440
6441    #[cfg(feature = "openapi")]
6442    #[tokio::test]
6443    async fn try_build_router_rejects_swagger_ui_asset_path_colliding_with_user_route() {
6444        let config = AutumnConfig::default();
6445        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0");
6446
6447        let user_route = Route {
6448            method: http::Method::GET,
6449            path: "/swagger-ui/swagger-ui.css",
6450            handler: axum::routing::get(collision_test_handler),
6451            name: "swagger-ui-asset-collides",
6452            api_doc: crate::openapi::ApiDoc {
6453                method: "GET",
6454                path: "/swagger-ui/swagger-ui.css",
6455                operation_id: "swagger_ui_asset_collides",
6456                success_status: 200,
6457                ..Default::default()
6458            },
6459            repository: None,
6460            idempotency: crate::route::RouteIdempotency::Direct,
6461            timeout: crate::route::RouteTimeout::Inherit,
6462            api_version: None,
6463            sunset_opt_out: false,
6464        };
6465
6466        let ctx = RouterContext {
6467            exception_filters: Vec::new(),
6468            scoped_groups: Vec::new(),
6469            merge_routers: Vec::new(),
6470            nest_routers: Vec::new(),
6471            custom_layers: Vec::new(),
6472            static_gate_layers: Vec::new(),
6473            #[cfg(feature = "maud")]
6474            error_page_renderer: None,
6475            session_store: None,
6476            openapi: Some(openapi),
6477            #[cfg(feature = "mcp")]
6478            mcp: None,
6479        };
6480        let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
6481            .expect_err("swagger ui asset path should be reserved");
6482        assert!(matches!(
6483            err,
6484            RouterBuildError::OpenApiPathCollision {
6485                field: "swagger_ui_path",
6486                ref path,
6487            } if path == "/swagger-ui/swagger-ui.css"
6488        ));
6489    }
6490
6491    #[cfg(all(feature = "openapi", feature = "htmx"))]
6492    #[tokio::test]
6493    async fn try_build_router_rejects_openapi_path_colliding_with_htmx_csrf_route() {
6494        let config = AutumnConfig::default();
6495        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6496            .openapi_json_path(crate::htmx::HTMX_CSRF_JS_PATH);
6497        let ctx = RouterContext {
6498            exception_filters: Vec::new(),
6499            scoped_groups: Vec::new(),
6500            merge_routers: Vec::new(),
6501            nest_routers: Vec::new(),
6502            custom_layers: Vec::new(),
6503            static_gate_layers: Vec::new(),
6504            #[cfg(feature = "maud")]
6505            error_page_renderer: None,
6506            session_store: None,
6507            openapi: Some(openapi),
6508            #[cfg(feature = "mcp")]
6509            mcp: None,
6510        };
6511        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6512            .expect_err("htmx csrf helper path should be reserved");
6513        assert!(matches!(
6514            err,
6515            RouterBuildError::OpenApiPathCollision {
6516                field: "openapi_json_path",
6517                ref path,
6518            } if path == crate::htmx::HTMX_CSRF_JS_PATH
6519        ));
6520    }
6521
6522    #[cfg(feature = "openapi")]
6523    #[tokio::test]
6524    async fn try_build_router_rejects_openapi_path_under_nest_prefix() {
6525        // Nesting `/api` means that router owns everything under
6526        // `/api/...`. Mounting OpenAPI at `/api/docs` would either
6527        // panic on merge or silently lose one of the routes, so the
6528        // collision check rejects it.
6529        let config = AutumnConfig::default();
6530        let openapi =
6531            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api/docs");
6532        let nested = axum::Router::<AppState>::new()
6533            .route("/inner", axum::routing::get(|| async { "inner" }));
6534        let ctx = RouterContext {
6535            exception_filters: Vec::new(),
6536            scoped_groups: Vec::new(),
6537            merge_routers: Vec::new(),
6538            nest_routers: vec![("/api".to_owned(), nested)],
6539            custom_layers: Vec::new(),
6540            static_gate_layers: Vec::new(),
6541            #[cfg(feature = "maud")]
6542            error_page_renderer: None,
6543            session_store: None,
6544            openapi: Some(openapi),
6545            #[cfg(feature = "mcp")]
6546            mcp: None,
6547        };
6548        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6549            .expect_err("OpenAPI path under a nest prefix should collide");
6550        assert!(matches!(
6551            err,
6552            RouterBuildError::OpenApiPathCollision {
6553                field: "openapi_json_path",
6554                ref path,
6555            } if path == "/api/docs"
6556        ));
6557    }
6558
6559    #[cfg(all(feature = "openapi", feature = "mail"))]
6560    #[tokio::test]
6561    async fn try_build_router_rejects_openapi_path_on_unsubscribe_endpoint() {
6562        // The default one-click unsubscribe endpoint merges a GET at
6563        // `/_autumn/unsubscribe` before the late-merged OpenAPI router, so the
6564        // collision preflight must reserve it — otherwise mounting OpenAPI there
6565        // panics in `router.merge` instead of surfacing the typed collision.
6566        let mut config = AutumnConfig::default();
6567        config.mail.mount_unsubscribe_endpoint = true;
6568        config.mail.unsubscribe_base_url = Some("https://app.example.com".to_owned());
6569        assert!(config.mail.should_mount_unsubscribe_endpoint());
6570        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6571            .openapi_json_path(crate::mail::UNSUBSCRIBE_PATH);
6572        let ctx = RouterContext {
6573            exception_filters: Vec::new(),
6574            scoped_groups: Vec::new(),
6575            merge_routers: Vec::new(),
6576            nest_routers: Vec::new(),
6577            custom_layers: Vec::new(),
6578            static_gate_layers: Vec::new(),
6579            #[cfg(feature = "maud")]
6580            error_page_renderer: None,
6581            session_store: None,
6582            openapi: Some(openapi),
6583            #[cfg(feature = "mcp")]
6584            mcp: None,
6585        };
6586        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6587            .expect_err("unsubscribe endpoint path should be reserved");
6588        assert!(matches!(
6589            err,
6590            RouterBuildError::OpenApiPathCollision {
6591                field: "openapi_json_path",
6592                ref path,
6593            } if path == crate::mail::UNSUBSCRIBE_PATH
6594        ));
6595    }
6596
6597    #[cfg(feature = "openapi")]
6598    #[tokio::test]
6599    async fn try_build_router_rejects_openapi_path_on_job_status_endpoint() {
6600        // The tracked-job status endpoint merges a GET at
6601        // `/_autumn/jobs/{token}` before the late-merged OpenAPI router (on by
6602        // default), so the collision preflight must reserve it too.
6603        let config = AutumnConfig::default();
6604        assert!(config.jobs.tracking.route_enabled);
6605        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6606            .openapi_json_path(crate::job_tracking::JOB_STATUS_ROUTE_PATH);
6607        let ctx = RouterContext {
6608            exception_filters: Vec::new(),
6609            scoped_groups: Vec::new(),
6610            merge_routers: Vec::new(),
6611            nest_routers: Vec::new(),
6612            custom_layers: Vec::new(),
6613            static_gate_layers: Vec::new(),
6614            #[cfg(feature = "maud")]
6615            error_page_renderer: None,
6616            session_store: None,
6617            openapi: Some(openapi),
6618            #[cfg(feature = "mcp")]
6619            mcp: None,
6620        };
6621        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6622            .expect_err("job status endpoint path should be reserved");
6623        assert!(matches!(
6624            err,
6625            RouterBuildError::OpenApiPathCollision {
6626                field: "openapi_json_path",
6627                ref path,
6628            } if path == crate::job_tracking::JOB_STATUS_ROUTE_PATH
6629        ));
6630    }
6631
6632    #[cfg(all(feature = "openapi", feature = "maud"))]
6633    #[tokio::test]
6634    async fn try_build_router_rejects_openapi_path_on_story_gallery() {
6635        // The story gallery merges GETs at `/_stories` (+ `/_stories/{slug}`)
6636        // when `stories.enabled` resolves true, before the late-merged
6637        // OpenAPI router, so the collision preflight must reserve it —
6638        // otherwise mounting OpenAPI there panics in `router.merge` instead
6639        // of surfacing the typed collision.
6640        let mut config = AutumnConfig::default();
6641        config.stories.enabled = true;
6642        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6643            .openapi_json_path(crate::stories::STORIES_PATH);
6644        let ctx = RouterContext {
6645            exception_filters: Vec::new(),
6646            scoped_groups: Vec::new(),
6647            merge_routers: Vec::new(),
6648            nest_routers: Vec::new(),
6649            custom_layers: Vec::new(),
6650            static_gate_layers: Vec::new(),
6651            error_page_renderer: None,
6652            session_store: None,
6653            openapi: Some(openapi),
6654            #[cfg(feature = "mcp")]
6655            mcp: None,
6656        };
6657        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6658            .expect_err("story gallery path should be reserved while stories are enabled");
6659        assert!(matches!(
6660            err,
6661            RouterBuildError::OpenApiPathCollision {
6662                field: "openapi_json_path",
6663                ref path,
6664            } if path == crate::stories::STORIES_PATH
6665        ));
6666    }
6667
6668    #[cfg(feature = "openapi")]
6669    #[test]
6670    fn try_build_router_rejects_openapi_path_on_dev_live_reload() {
6671        temp_env::with_vars(
6672            [
6673                ("AUTUMN_DEV_RELOAD", Some("1")),
6674                ("AUTUMN_DEV_RELOAD_STATE", Some("/tmp/autumn-reload-test")),
6675            ],
6676            || {
6677                let config = AutumnConfig::default();
6678                let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
6679                    .openapi_json_path("/__autumn/live-reload");
6680                let ctx = RouterContext {
6681                    exception_filters: Vec::new(),
6682                    scoped_groups: Vec::new(),
6683                    merge_routers: Vec::new(),
6684                    nest_routers: Vec::new(),
6685                    custom_layers: Vec::new(),
6686                    static_gate_layers: Vec::new(),
6687                    error_page_renderer: None,
6688                    session_store: None,
6689                    openapi: Some(openapi),
6690                    #[cfg(feature = "mcp")]
6691                    mcp: None,
6692                };
6693                let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6694                    .expect_err("dev reload path should be reserved");
6695                assert!(matches!(
6696                    err,
6697                    RouterBuildError::OpenApiPathCollision {
6698                        field: "openapi_json_path",
6699                        ..
6700                    }
6701                ));
6702            },
6703        );
6704    }
6705
6706    // --- Duplicate user-route detection tests (issue #1012) ---
6707
6708    async fn duplicate_route_handler() -> &'static str {
6709        "ok"
6710    }
6711
6712    /// Build a lightweight [`Route`] for the duplicate-detection tests. The
6713    /// `MethodRouter` is built with the same HTTP method as `method` so
6714    /// scenarios that intentionally exercise `GET`+`POST` on the same path
6715    /// (AC #4) actually merge cleanly at axum level; the caller sees the
6716    /// duplicate-preflight decision, not an axum method-router-merge panic.
6717    fn duplicate_test_route(method: http::Method, path: &'static str, name: &'static str) -> Route {
6718        let handler = match method {
6719            http::Method::POST => axum::routing::post(duplicate_route_handler),
6720            http::Method::PUT => axum::routing::put(duplicate_route_handler),
6721            http::Method::PATCH => axum::routing::patch(duplicate_route_handler),
6722            http::Method::DELETE => axum::routing::delete(duplicate_route_handler),
6723            _ => axum::routing::get(duplicate_route_handler),
6724        };
6725        let method_str = if method == http::Method::POST {
6726            "POST"
6727        } else if method == http::Method::PUT {
6728            "PUT"
6729        } else if method == http::Method::PATCH {
6730            "PATCH"
6731        } else if method == http::Method::DELETE {
6732            "DELETE"
6733        } else {
6734            "GET"
6735        };
6736        Route {
6737            method,
6738            path,
6739            handler,
6740            name,
6741            api_doc: crate::openapi::ApiDoc {
6742                method: method_str,
6743                path,
6744                operation_id: name,
6745                success_status: 200,
6746                ..Default::default()
6747            },
6748            repository: None,
6749            idempotency: crate::route::RouteIdempotency::Direct,
6750            timeout: crate::route::RouteTimeout::Inherit,
6751            api_version: None,
6752            sunset_opt_out: false,
6753        }
6754    }
6755
6756    fn duplicate_test_ctx() -> RouterContext {
6757        RouterContext {
6758            exception_filters: Vec::new(),
6759            scoped_groups: Vec::new(),
6760            merge_routers: Vec::new(),
6761            nest_routers: Vec::new(),
6762            custom_layers: Vec::new(),
6763            static_gate_layers: Vec::new(),
6764            #[cfg(feature = "maud")]
6765            error_page_renderer: None,
6766            session_store: None,
6767            #[cfg(feature = "openapi")]
6768            openapi: None,
6769            #[cfg(feature = "mcp")]
6770            mcp: None,
6771        }
6772    }
6773
6774    /// AC #1, #2, #6: two routes registered on the same (method, path) fail
6775    /// the build with a structured [`RouterBuildError::DuplicateUserRoute`]
6776    /// that names both handlers and the offending method + path — no axum
6777    /// panic escapes.
6778    #[tokio::test]
6779    async fn try_build_router_rejects_duplicate_user_route_paths() {
6780        let config = AutumnConfig::default();
6781        let a = duplicate_test_route(http::Method::GET, "/", "root_a");
6782        let b = duplicate_test_route(http::Method::GET, "/", "root_b");
6783        let err =
6784            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
6785                .expect_err("two GET / routes should be rejected before mount");
6786        let display = err.to_string();
6787        match err {
6788            RouterBuildError::DuplicateUserRoute {
6789                ref method,
6790                ref path,
6791                ref existing,
6792                ref incoming,
6793            } => {
6794                assert_eq!(method, "GET");
6795                assert_eq!(path, "/");
6796                assert_eq!(existing, "root_a");
6797                assert_eq!(incoming, "root_b");
6798            }
6799            other => panic!("expected DuplicateUserRoute, got {other:?}"),
6800        }
6801        assert!(
6802            display.contains("root_a"),
6803            "error message must name first handler; got: {display}"
6804        );
6805        assert!(
6806            display.contains("root_b"),
6807            "error message must name second handler; got: {display}"
6808        );
6809        assert!(
6810            display.contains("GET"),
6811            "error message must name the HTTP method; got: {display}"
6812        );
6813        assert!(
6814            display.contains('/'),
6815            "error message must contain the path; got: {display}"
6816        );
6817    }
6818
6819    /// AC #4: distinct methods on the same path (`GET /admin` + `POST /admin`)
6820    /// must NOT be flagged — axum merges them cleanly into a single
6821    /// `MethodRouter`.
6822    #[tokio::test]
6823    async fn try_build_router_allows_distinct_methods_on_same_path() {
6824        let config = AutumnConfig::default();
6825        let get = duplicate_test_route(http::Method::GET, "/admin", "admin_index");
6826        let post = duplicate_test_route(http::Method::POST, "/admin", "admin_create");
6827        let _router = super::try_build_router_inner(
6828            vec![get, post],
6829            &config,
6830            test_state(),
6831            duplicate_test_ctx(),
6832        )
6833        .expect("GET + POST on the same path should build cleanly");
6834    }
6835
6836    /// AC #3: duplicates that span a top-level route and a scoped group
6837    /// (once the scope prefix is applied) are detected using the same
6838    /// preflight — the introspection reuses `RouteInfo`'s scope resolution.
6839    #[tokio::test]
6840    async fn try_build_router_rejects_duplicate_across_scoped_group() {
6841        let config = AutumnConfig::default();
6842        let top = duplicate_test_route(http::Method::GET, "/api/posts", "top_posts");
6843        let scoped_child = duplicate_test_route(http::Method::GET, "/posts", "scoped_posts");
6844        let group = crate::app::ScopedGroup {
6845            prefix: "/api".to_owned(),
6846            routes: vec![scoped_child],
6847            source: crate::route_listing::RouteSource::User,
6848            apply_layer: Box::new(|r| r),
6849        };
6850        let mut ctx = duplicate_test_ctx();
6851        ctx.scoped_groups.push(group);
6852        let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
6853            .expect_err("top-level + scoped resolving to same path should be rejected");
6854        match err {
6855            RouterBuildError::DuplicateUserRoute {
6856                ref method,
6857                ref path,
6858                ..
6859            } => {
6860                assert_eq!(method, "GET");
6861                assert_eq!(path, "/api/posts");
6862            }
6863            other => panic!("expected DuplicateUserRoute, got {other:?}"),
6864        }
6865    }
6866
6867    /// AC #3: two scoped groups whose resolved paths collide are also
6868    /// caught (a plugin re-registering the same route class as user code).
6869    #[tokio::test]
6870    async fn try_build_router_rejects_duplicate_within_scoped_groups() {
6871        let config = AutumnConfig::default();
6872        let a = crate::app::ScopedGroup {
6873            prefix: "/api".to_owned(),
6874            routes: vec![duplicate_test_route(
6875                http::Method::GET,
6876                "/posts",
6877                "user_posts",
6878            )],
6879            source: crate::route_listing::RouteSource::User,
6880            apply_layer: Box::new(|r| r),
6881        };
6882        let b = crate::app::ScopedGroup {
6883            prefix: "/api".to_owned(),
6884            routes: vec![duplicate_test_route(
6885                http::Method::GET,
6886                "/posts",
6887                "plugin_posts",
6888            )],
6889            source: crate::route_listing::RouteSource::Plugin("blog".to_owned()),
6890            apply_layer: Box::new(|r| r),
6891        };
6892        let mut ctx = duplicate_test_ctx();
6893        ctx.scoped_groups.push(a);
6894        ctx.scoped_groups.push(b);
6895        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
6896            .expect_err("two scoped groups colliding on /api/posts should be rejected");
6897        assert!(matches!(
6898            err,
6899            RouterBuildError::DuplicateUserRoute { ref existing, ref incoming, .. }
6900                if existing == "user_posts" && incoming == "plugin_posts"
6901        ));
6902    }
6903
6904    /// AC #5: an opaque `AppBuilder::merge` router coexisting with a clean
6905    /// route table must not cause a false-pass failure — the check is
6906    /// skipped (with the existing "check skipped" warning) and the build
6907    /// continues. Regression guard for the collision preflight.
6908    #[tokio::test]
6909    async fn try_build_router_skips_duplicate_check_for_opaque_merge_router() {
6910        let config = AutumnConfig::default();
6911        let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
6912        let raw = axum::Router::<AppState>::new()
6913            .route("/raw", axum::routing::get(duplicate_route_handler));
6914        let mut ctx = duplicate_test_ctx();
6915        ctx.merge_routers.push(raw);
6916        let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
6917            .expect("opaque merge routers must not fail the duplicate preflight");
6918    }
6919
6920    /// AC #5: same regression guard for opaque `AppBuilder::nest` routers.
6921    #[tokio::test]
6922    async fn try_build_router_skips_duplicate_check_for_opaque_nest_router() {
6923        let config = AutumnConfig::default();
6924        let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
6925        let nested = axum::Router::<AppState>::new()
6926            .route("/child", axum::routing::get(duplicate_route_handler));
6927        let mut ctx = duplicate_test_ctx();
6928        ctx.nest_routers.push(("/plugin".to_owned(), nested));
6929        let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
6930            .expect("opaque nest routers must not fail the duplicate preflight");
6931    }
6932
6933    /// Finding 1 (issue #1012 review): two handlers that differ ONLY by capture
6934    /// name — `/users/{id}` vs `/users/{slug}` — key by literal template so they
6935    /// look distinct to a naive preflight, but axum's matcher (verified against
6936    /// axum 0.8.9: matchit reports a "conflict") rejects the second route shape
6937    /// at mount. Because the two EXACT templates differ, this is a matchit route
6938    /// conflict (illegal regardless of method), surfaced as
6939    /// `ConflictingRouteShape` naming both handlers AND both original templates.
6940    #[tokio::test]
6941    async fn try_build_router_rejects_duplicate_capture_name_paths() {
6942        let config = AutumnConfig::default();
6943        let a = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
6944        let b = duplicate_test_route(http::Method::GET, "/users/{slug}", "by_slug");
6945        let err =
6946            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
6947                .expect_err("capture-name-only difference must be rejected before mount");
6948        match err {
6949            RouterBuildError::ConflictingRouteShape {
6950                ref existing,
6951                ref existing_path,
6952                ref incoming,
6953                ref incoming_path,
6954            } => {
6955                assert_eq!(existing, "by_id");
6956                assert_eq!(existing_path, "/users/{id}");
6957                assert_eq!(incoming, "by_slug");
6958                assert_eq!(incoming_path, "/users/{slug}");
6959            }
6960            other => panic!("expected ConflictingRouteShape, got {other:?}"),
6961        }
6962        // The diagnostic must name BOTH original templates, not the normalized key.
6963        let display = err.to_string();
6964        assert!(
6965            display.contains("/users/{id}") && display.contains("/users/{slug}"),
6966            "error must show both original path templates; got: {display}"
6967        );
6968    }
6969
6970    /// Finding 1, scoped-group variant: the capture-name normalization must run
6971    /// AFTER `join_nested_path` prefix resolution, so a scoped `/users/{slug}`
6972    /// under `/api` collides with a top-level `/api/users/{id}`.
6973    #[tokio::test]
6974    async fn try_build_router_rejects_duplicate_capture_name_across_scoped_group() {
6975        let config = AutumnConfig::default();
6976        let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
6977        let scoped_child =
6978            duplicate_test_route(http::Method::GET, "/users/{slug}", "scoped_by_slug");
6979        let group = crate::app::ScopedGroup {
6980            prefix: "/api".to_owned(),
6981            routes: vec![scoped_child],
6982            source: crate::route_listing::RouteSource::User,
6983            apply_layer: Box::new(|r| r),
6984        };
6985        let mut ctx = duplicate_test_ctx();
6986        ctx.scoped_groups.push(group);
6987        let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
6988            .expect_err("scoped capture-name collision must be rejected before mount");
6989        assert!(
6990            matches!(
6991                err,
6992                RouterBuildError::ConflictingRouteShape {
6993                    ref existing, ref incoming, ref existing_path, ref incoming_path
6994                }
6995                    if existing == "top_by_id" && incoming == "scoped_by_slug"
6996                        && existing_path == "/api/users/{id}"
6997                        && incoming_path == "/api/users/{slug}"
6998            ),
6999            "expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
7000        );
7001    }
7002
7003    /// Finding 1 NEGATIVE guard: normalization must not over-flag. Two genuinely
7004    /// different shapes that axum's matcher accepts (verified: `/users/{id}` and
7005    /// `/users/{id}/posts` do NOT conflict) must still build cleanly.
7006    #[tokio::test]
7007    async fn try_build_router_allows_distinct_route_shapes() {
7008        let config = AutumnConfig::default();
7009        let a = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
7010        let b = duplicate_test_route(http::Method::GET, "/users/{id}/posts", "posts");
7011        let _router =
7012            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7013                .expect("distinct route shapes must not be flagged as duplicates");
7014    }
7015
7016    /// Finding 2 (issue #1012 review): `#[ws]` records the synthetic `WS` method
7017    /// but `group_and_mount_routes` mounts its handler via `axum::routing::get`,
7018    /// so `#[get("/live")]` + `#[ws("/live")]` produce two overlapping `GET`
7019    /// `MethodRouter`s that panic on merge. Normalizing `WS` to its effective
7020    /// `GET` before keying makes the preflight catch it as `DuplicateUserRoute`.
7021    ///
7022    /// Not `#[cfg(feature = "ws")]`-gated: the synthetic `WS` method is a plain
7023    /// `http::Method` string, and the sibling `build_route_timeout_table_*` test
7024    /// exercises the same normalization ungated — gating would hide this from the
7025    /// default `cargo test` run since `ws` is not a default feature.
7026    #[tokio::test]
7027    async fn try_build_router_rejects_ws_get_collision() {
7028        let config = AutumnConfig::default();
7029        let get = duplicate_test_route(http::Method::GET, "/live", "live_poll");
7030        let ws = duplicate_test_route(
7031            http::Method::from_bytes(b"WS").unwrap(),
7032            "/live",
7033            "live_socket",
7034        );
7035        let err = super::try_build_router_inner(
7036            vec![get, ws],
7037            &config,
7038            test_state(),
7039            duplicate_test_ctx(),
7040        )
7041        .expect_err("GET + WS on the same path must be rejected before mount");
7042        match err {
7043            RouterBuildError::DuplicateUserRoute {
7044                ref method,
7045                ref path,
7046                ref existing,
7047                ref incoming,
7048            } => {
7049                assert_eq!(method, "GET", "WS must be normalized to its effective GET");
7050                assert_eq!(path, "/live");
7051                assert_eq!(existing, "live_poll");
7052                assert_eq!(incoming, "live_socket");
7053            }
7054            other => panic!("expected DuplicateUserRoute, got {other:?}"),
7055        }
7056    }
7057
7058    /// Finding A (round 2): different HTTP methods whose paths differ ONLY by
7059    /// capture name (`GET /users/{id}` + `POST /users/{slug}`) key as distinct
7060    /// `(method, shape)` pairs, so the method-independent shape check must catch
7061    /// them. Verified against axum 0.8.9: `Router::route("/users/{id}", get)`
7062    /// then `Router::route("/users/{slug}", post)` PANICS — matchit rejects the
7063    /// second template as a route conflict BEFORE method merging. The preflight
7064    /// surfaces it as `ConflictingRouteShape` naming both handlers + both
7065    /// templates; no axum panic escapes.
7066    #[tokio::test]
7067    async fn try_build_router_rejects_cross_method_shape_conflict() {
7068        let config = AutumnConfig::default();
7069        let get = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
7070        let post = duplicate_test_route(http::Method::POST, "/users/{slug}", "by_slug");
7071        let err = super::try_build_router_inner(
7072            vec![get, post],
7073            &config,
7074            test_state(),
7075            duplicate_test_ctx(),
7076        )
7077        .expect_err("cross-method capture-name-only conflict must be rejected before mount");
7078        match err {
7079            RouterBuildError::ConflictingRouteShape {
7080                ref existing,
7081                ref existing_path,
7082                ref incoming,
7083                ref incoming_path,
7084            } => {
7085                assert_eq!(existing, "by_id");
7086                assert_eq!(existing_path, "/users/{id}");
7087                assert_eq!(incoming, "by_slug");
7088                assert_eq!(incoming_path, "/users/{slug}");
7089            }
7090            other => panic!("expected ConflictingRouteShape, got {other:?}"),
7091        }
7092        let display = err.to_string();
7093        assert!(
7094            display.contains("by_id") && display.contains("by_slug"),
7095            "error must name both handlers; got: {display}"
7096        );
7097        assert!(
7098            display.contains("/users/{id}") && display.contains("/users/{slug}"),
7099            "error must name both original templates; got: {display}"
7100        );
7101    }
7102
7103    /// Finding A, scoped-group variant: the method-independent shape conflict
7104    /// check must run AFTER `join_nested_path`, so a scoped `POST /users/{slug}`
7105    /// under `/api` conflicts with a top-level `GET /api/users/{id}`.
7106    #[tokio::test]
7107    async fn try_build_router_rejects_cross_method_shape_conflict_across_scoped_group() {
7108        let config = AutumnConfig::default();
7109        let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
7110        let scoped_child =
7111            duplicate_test_route(http::Method::POST, "/users/{slug}", "scoped_by_slug");
7112        let group = crate::app::ScopedGroup {
7113            prefix: "/api".to_owned(),
7114            routes: vec![scoped_child],
7115            source: crate::route_listing::RouteSource::User,
7116            apply_layer: Box::new(|r| r),
7117        };
7118        let mut ctx = duplicate_test_ctx();
7119        ctx.scoped_groups.push(group);
7120        let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
7121            .expect_err("scoped cross-method shape conflict must be rejected before mount");
7122        assert!(
7123            matches!(
7124                err,
7125                RouterBuildError::ConflictingRouteShape {
7126                    ref existing, ref incoming, ref existing_path, ref incoming_path
7127                }
7128                    if existing == "top_by_id" && incoming == "scoped_by_slug"
7129                        && existing_path == "/api/users/{id}"
7130                        && incoming_path == "/api/users/{slug}"
7131            ),
7132            "expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
7133        );
7134    }
7135
7136    /// AC #4 (round 2): the SAME exact capture template on distinct methods
7137    /// (`GET /users/{id}` + `POST /users/{id}`) is LEGAL — axum merges the two
7138    /// `MethodRouter`s. Verified against axum 0.8.9: this pair builds cleanly.
7139    /// The shape check keys on the FIRST exact template per shape, so an
7140    /// identical template never trips it — only a DIFFERENT template does.
7141    #[tokio::test]
7142    async fn try_build_router_allows_same_capture_template_distinct_methods() {
7143        let config = AutumnConfig::default();
7144        let get = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
7145        let post = duplicate_test_route(http::Method::POST, "/users/{id}", "update");
7146        let _router = super::try_build_router_inner(
7147            vec![get, post],
7148            &config,
7149            test_state(),
7150            duplicate_test_ctx(),
7151        )
7152        .expect("same capture template on GET + POST must build cleanly");
7153    }
7154
7155    /// Finding B (round 2): axum/matchit treat `{{`/`}}` as ESCAPED literal
7156    /// braces, so `/{{foo}}` and `/{{bar}}` are two DISTINCT static routes.
7157    /// Verified against axum 0.8.9: both build cleanly. The matchit oracle
7158    /// treats escaped braces as literals (not captures), so this valid app is
7159    /// not falsely rejected.
7160    #[tokio::test]
7161    async fn try_build_router_allows_escaped_brace_literals() {
7162        let config = AutumnConfig::default();
7163        let a = duplicate_test_route(http::Method::GET, "/{{foo}}", "lit_foo");
7164        let b = duplicate_test_route(http::Method::GET, "/{{bar}}", "lit_bar");
7165        let _router =
7166            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7167                .expect("distinct escaped-literal paths must not be flagged as duplicates");
7168    }
7169
7170    /// Finding B guard: an escaped-literal prefix combined with a real capture
7171    /// keeps the shapes distinct — `/{{x}}/{id}` and `/{{y}}/{id}` differ only in
7172    /// their literal segment. Verified against axum 0.8.9: both build cleanly.
7173    #[tokio::test]
7174    async fn try_build_router_allows_escaped_literal_prefix_with_capture() {
7175        let config = AutumnConfig::default();
7176        let a = duplicate_test_route(http::Method::GET, "/{{x}}/{id}", "x_show");
7177        let b = duplicate_test_route(http::Method::GET, "/{{y}}/{id}", "y_show");
7178        let _router =
7179            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7180                .expect("distinct escaped-literal prefixes with a shared capture must build");
7181    }
7182
7183    /// Adversarial sweep: a mixed literal+capture segment must normalize at the
7184    /// char level. `/file.{ext}` and `/file.{kind}` share the shape `/file.{…}`.
7185    /// Verified against axum 0.8.9: this pair PANICS (matchit conflict), so the
7186    /// preflight must flag it as `ConflictingRouteShape` naming both templates.
7187    #[tokio::test]
7188    async fn try_build_router_rejects_mixed_literal_capture_shape_conflict() {
7189        let config = AutumnConfig::default();
7190        let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
7191        let b = duplicate_test_route(http::Method::GET, "/file.{kind}", "by_kind");
7192        let err =
7193            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7194                .expect_err("mixed literal+capture shape conflict must be rejected before mount");
7195        assert!(
7196            matches!(
7197                err,
7198                RouterBuildError::ConflictingRouteShape {
7199                    ref existing_path, ref incoming_path, ..
7200                }
7201                    if existing_path == "/file.{ext}" && incoming_path == "/file.{kind}"
7202            ),
7203            "expected ConflictingRouteShape naming both templates, got {err:?}"
7204        );
7205    }
7206
7207    /// Adversarial sweep NEGATIVE guard: a mixed literal+capture segment stays
7208    /// distinct from a fully static segment. `/file.{ext}` and `/file.json` do
7209    /// NOT share a shape. Verified against axum 0.8.9: this pair builds cleanly,
7210    /// so the char-level normalization must not over-collapse the static one.
7211    #[tokio::test]
7212    async fn try_build_router_allows_mixed_capture_vs_static_segment() {
7213        let config = AutumnConfig::default();
7214        let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
7215        let b = duplicate_test_route(http::Method::GET, "/file.json", "static_json");
7216        let _router =
7217            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7218                .expect("a capture segment and a static segment must not be flagged as duplicates");
7219    }
7220
7221    /// Adversarial sweep: a normal capture and a catch-all at the same terminal
7222    /// position (`/u/{id}` vs `/u/{*rest}`) collapse to the same placeholder.
7223    /// Verified against axum 0.8.9: this pair PANICS (matchit conflict), so the
7224    /// preflight must flag it. Different exact templates → `ConflictingRouteShape`.
7225    #[tokio::test]
7226    async fn try_build_router_rejects_catch_all_vs_normal_capture() {
7227        let config = AutumnConfig::default();
7228        let a = duplicate_test_route(http::Method::GET, "/u/{id}", "one");
7229        let b = duplicate_test_route(http::Method::GET, "/u/{*rest}", "rest");
7230        let err =
7231            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7232                .expect_err("catch-all vs normal capture must be rejected before mount");
7233        assert!(
7234            matches!(
7235                err,
7236                RouterBuildError::ConflictingRouteShape {
7237                    ref existing_path, ref incoming_path, ..
7238                }
7239                    if existing_path == "/u/{id}" && incoming_path == "/u/{*rest}"
7240            ),
7241            "expected ConflictingRouteShape naming both templates, got {err:?}"
7242        );
7243    }
7244
7245    /// New #1012 finding (matchit oracle): a catch-all conflicts with a dynamic
7246    /// DESCENDANT, not just a sibling capture. `GET /cmd/{tool}/{sub}` +
7247    /// `POST /cmd/{*path}` slipped past the old hand-rolled shape normalizer
7248    /// (which only unified captures position-by-position) and axum still panicked
7249    /// at mount. Delegating to matchit — the engine axum uses — catches it:
7250    /// verified against axum 0.8.9 that this pair PANICS. The preflight surfaces
7251    /// it as `ConflictingRouteShape` naming both handlers + both templates; no
7252    /// axum/matchit panic escapes.
7253    #[tokio::test]
7254    async fn try_build_router_rejects_catch_all_vs_dynamic_descendant() {
7255        let config = AutumnConfig::default();
7256        let a = duplicate_test_route(http::Method::GET, "/cmd/{tool}/{sub}", "cmd_sub");
7257        let b = duplicate_test_route(http::Method::POST, "/cmd/{*path}", "cmd_all");
7258        let err =
7259            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7260                .expect_err("catch-all vs dynamic descendant must be rejected before mount");
7261        match err {
7262            RouterBuildError::ConflictingRouteShape {
7263                ref existing,
7264                ref existing_path,
7265                ref incoming,
7266                ref incoming_path,
7267            } => {
7268                assert_eq!(existing, "cmd_sub");
7269                assert_eq!(existing_path, "/cmd/{tool}/{sub}");
7270                assert_eq!(incoming, "cmd_all");
7271                assert_eq!(incoming_path, "/cmd/{*path}");
7272            }
7273            other => panic!("expected ConflictingRouteShape, got {other:?}"),
7274        }
7275        let display = err.to_string();
7276        assert!(
7277            display.contains("/cmd/{tool}/{sub}") && display.contains("/cmd/{*path}"),
7278            "error must name both original templates; got: {display}"
7279        );
7280    }
7281
7282    /// Negative regression guard for the matchit oracle: a STATIC segment and a
7283    /// dynamic capture at the same position (`/users/me` + `/users/{id}`) do NOT
7284    /// conflict — matchit (and thus axum 0.8.9) accepts both, matching static
7285    /// before dynamic. The oracle must NOT raise a false positive here. Confirmed
7286    /// by `matchit_agrees_with_axum_route_conflicts`.
7287    #[tokio::test]
7288    async fn try_build_router_allows_static_vs_dynamic_segment() {
7289        let config = AutumnConfig::default();
7290        let a = duplicate_test_route(http::Method::GET, "/users/me", "me");
7291        let b = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
7292        let _router =
7293            super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
7294                .expect("a static segment and a dynamic capture must not be flagged as a conflict");
7295    }
7296
7297    /// Parity guard for the #1012 matchit oracle: matchit's `insert` Ok/Err MUST
7298    /// agree with axum 0.8.9's `Router::route` accept/panic on every case of the
7299    /// conflict matrix. axum wraps matchit, so they should always agree — this
7300    /// test fails LOUDLY if a future axum bump (or a `matchit` version that
7301    /// drifts out of lockstep with the `=0.8.4` pin) changes conflict semantics,
7302    /// catching silent oracle divergence before it can introduce false
7303    /// positives/negatives at mount. Deterministic and fast (no async, no I/O).
7304    #[test]
7305    fn matchit_agrees_with_axum_route_conflicts() {
7306        // (template_a, template_b, expect_conflict)
7307        let matrix: &[(&str, &str, bool)] = &[
7308            ("/users/{id}", "/users/{slug}", true),
7309            ("/users/{id}", "/users/{id}/posts", false),
7310            ("/cmd/{tool}/{sub}", "/cmd/{*path}", true),
7311            ("/users/me", "/users/{id}", false),
7312            ("/{{foo}}", "/{{bar}}", false),
7313            ("/file.{ext}", "/file.{kind}", true),
7314            ("/file.{ext}", "/file.json", false),
7315            ("/u/{id}", "/u/{*rest}", true),
7316        ];
7317
7318        // Silence axum/matchit's panic backtrace noise while we intentionally
7319        // trip conflicts under catch_unwind; restore the hook afterwards.
7320        let prev_hook = std::panic::take_hook();
7321        std::panic::set_hook(Box::new(|_| {}));
7322
7323        let mut rows = Vec::new();
7324        let mut mismatches = Vec::new();
7325        for &(a, b, expect_conflict) in matrix {
7326            // axum: does registering BOTH templates panic inside `route()`?
7327            let axum_panics = std::panic::catch_unwind(|| {
7328                let _ = axum::Router::<()>::new()
7329                    .route(a, axum::routing::get(|| async { "a" }))
7330                    .route(b, axum::routing::get(|| async { "b" }));
7331            })
7332            .is_err();
7333
7334            // matchit: does inserting BOTH templates report a conflict?
7335            let mut r: matchit::Router<()> = matchit::Router::new();
7336            r.insert(a, ()).expect("first template must insert cleanly");
7337            let matchit_conflicts =
7338                matches!(r.insert(b, ()), Err(matchit::InsertError::Conflict { .. }));
7339
7340            rows.push(format!(
7341                "{a:<20} vs {b:<20} axum={} matchit={} expected={}",
7342                if axum_panics { "PANIC" } else { "ok" },
7343                if matchit_conflicts { "Err" } else { "Ok" },
7344                if expect_conflict { "conflict" } else { "ok" },
7345            ));
7346
7347            if axum_panics != matchit_conflicts || axum_panics != expect_conflict {
7348                mismatches.push(rows.last().unwrap().clone());
7349            }
7350        }
7351
7352        std::panic::set_hook(prev_hook);
7353
7354        assert!(
7355            mismatches.is_empty(),
7356            "matchit must agree with axum 0.8.9 AND the expected outcome on every \
7357             case (oracle divergence => false positives/negatives at mount).\n\
7358             full matrix:\n{}\nmismatches:\n{}",
7359            rows.join("\n"),
7360            mismatches.join("\n"),
7361        );
7362    }
7363
7364    // --- Static file serving (SSG/ISG) tests ---
7365
7366    // --- SSG/ISG response compression (#752) ---
7367    //
7368    // The static-first middleware serves manifest-backed `dist/` files by
7369    // reading them from disk and building a response directly, short-circuiting
7370    // before the dynamic router. Compression is applied OUTSIDE that middleware
7371    // (see `try_build_router_with_static_inner`), and the served response now
7372    // carries a MIME type derived from the file extension, so the compression
7373    // layer encodes compressible SSG pages while leaving binary assets alone —
7374    // matching the transport behaviour of dynamic handler responses.
7375
7376    /// Build a `dist/` dir + `manifest.json` mapping each `(route, file, bytes)`
7377    /// tuple to a file on disk. Returns the `TempDir` guard; the dist directory
7378    /// is at `<tmp>/dist`.
7379    fn create_ssg_dist(entries: &[(&str, &str, &[u8])]) -> tempfile::TempDir {
7380        let dir = tempfile::tempdir().expect("tempdir");
7381        let dist = dir.path().join("dist");
7382        let mut routes = std::collections::HashMap::new();
7383        for (route, file, bytes) in entries {
7384            let path = dist.join(file);
7385            if let Some(parent) = path.parent() {
7386                std::fs::create_dir_all(parent).expect("mkdir");
7387            }
7388            std::fs::write(&path, bytes).expect("write file");
7389            routes.insert(
7390                (*route).to_owned(),
7391                crate::static_gen::ManifestEntry {
7392                    file: (*file).to_owned(),
7393                    revalidate: None,
7394                },
7395            );
7396        }
7397        let manifest = crate::static_gen::StaticManifest {
7398            generated_at: "2026-07-12T00:00:00Z".to_owned(),
7399            autumn_version: "0.6.0".to_owned(),
7400            routes,
7401        };
7402        std::fs::write(
7403            dist.join("manifest.json"),
7404            serde_json::to_string(&manifest).unwrap(),
7405        )
7406        .unwrap();
7407        dir
7408    }
7409
7410    fn compression_enabled_config() -> AutumnConfig {
7411        let mut config = AutumnConfig::default();
7412        config.compression.enabled = true;
7413        config
7414    }
7415
7416    /// A manifest-backed HTML page is gzip-compressed when the client accepts
7417    /// gzip and framework compression is enabled, and it carries
7418    /// `Vary: Accept-Encoding`.
7419    #[tokio::test]
7420    async fn ssg_html_hit_is_gzip_compressed() {
7421        let html = format!(
7422            "<html><body>{}</body></html>",
7423            "Lorem ipsum dolor sit amet. ".repeat(64)
7424        );
7425        let tmp = create_ssg_dist(&[("/", "index.html", html.as_bytes())]);
7426        let dist = tmp.path().join("dist");
7427
7428        let router = try_build_router_with_static(
7429            Vec::new(),
7430            &compression_enabled_config(),
7431            test_state(),
7432            Some(&dist),
7433        )
7434        .expect("router builds");
7435        let response = router
7436            .oneshot(
7437                Request::builder()
7438                    .uri("/")
7439                    .header("accept-encoding", "gzip")
7440                    .body(Body::empty())
7441                    .unwrap(),
7442            )
7443            .await
7444            .unwrap();
7445
7446        assert_eq!(response.status(), StatusCode::OK);
7447        assert_eq!(
7448            response
7449                .headers()
7450                .get(http::header::CONTENT_ENCODING)
7451                .and_then(|v| v.to_str().ok()),
7452            Some("gzip"),
7453            "manifest-backed SSG HTML page must be gzip-compressed"
7454        );
7455        let vary = response
7456            .headers()
7457            .get(http::header::VARY)
7458            .and_then(|v| v.to_str().ok())
7459            .unwrap_or("");
7460        assert!(
7461            vary.to_lowercase().contains("accept-encoding"),
7462            "Vary must advertise Accept-Encoding, got {vary:?}"
7463        );
7464        assert_eq!(
7465            response
7466                .headers()
7467                .get(http::header::CONTENT_TYPE)
7468                .and_then(|v| v.to_str().ok()),
7469            Some("text/html; charset=utf-8"),
7470            "HTML page keeps its text/html content type"
7471        );
7472        // The transferred body is the gzip stream, not the raw HTML.
7473        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
7474            .await
7475            .unwrap();
7476        assert_ne!(
7477            body.as_ref(),
7478            html.as_bytes(),
7479            "compressed body must differ from the raw HTML"
7480        );
7481    }
7482
7483    /// A manifest-backed *binary* asset is served with its real MIME type and is
7484    /// NOT compressed, even though the client accepts gzip — proving the fix
7485    /// does not blindly compress non-text responses.
7486    #[tokio::test]
7487    async fn ssg_binary_asset_is_not_compressed_and_keeps_mime() {
7488        // PNG signature followed by pseudo-random bytes, padded well past the
7489        // compression size floor so size is not the reason it is skipped.
7490        let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
7491        bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
7492        let tmp = create_ssg_dist(&[("/logo", "logo.png", &bytes)]);
7493        let dist = tmp.path().join("dist");
7494
7495        let router = try_build_router_with_static(
7496            Vec::new(),
7497            &compression_enabled_config(),
7498            test_state(),
7499            Some(&dist),
7500        )
7501        .expect("router builds");
7502        let response = router
7503            .oneshot(
7504                Request::builder()
7505                    .uri("/logo")
7506                    .header("accept-encoding", "gzip")
7507                    .body(Body::empty())
7508                    .unwrap(),
7509            )
7510            .await
7511            .unwrap();
7512
7513        assert_eq!(response.status(), StatusCode::OK);
7514        assert_eq!(
7515            response
7516                .headers()
7517                .get(http::header::CONTENT_TYPE)
7518                .and_then(|v| v.to_str().ok()),
7519            Some("image/png"),
7520            "binary manifest asset must keep its real MIME type, not text/html"
7521        );
7522        assert_eq!(
7523            response.headers().get(http::header::CONTENT_ENCODING),
7524            None,
7525            "binary asset must not be blindly compressed"
7526        );
7527    }
7528
7529    /// A manifest-backed pre-compressed web font (`.woff2`) is served with its
7530    /// `font/woff2` MIME type and is NOT gzip-compressed even though the client
7531    /// accepts gzip: WOFF/WOFF2 embed their own compression, so re-encoding only
7532    /// wastes CPU. Raw fonts (`.ttf`/`.otf`) are deliberately left compressible.
7533    #[tokio::test]
7534    async fn ssg_woff2_font_is_not_compressed_and_keeps_mime() {
7535        // Pad well past the compression size floor so size is not the reason it
7536        // is skipped — the font MIME type is.
7537        let mut bytes = b"wOF2".to_vec();
7538        bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
7539        let tmp = create_ssg_dist(&[("/inter", "fonts/inter.woff2", &bytes)]);
7540        let dist = tmp.path().join("dist");
7541
7542        let router = try_build_router_with_static(
7543            Vec::new(),
7544            &compression_enabled_config(),
7545            test_state(),
7546            Some(&dist),
7547        )
7548        .expect("router builds");
7549        let response = router
7550            .oneshot(
7551                Request::builder()
7552                    .uri("/inter")
7553                    .header("accept-encoding", "gzip")
7554                    .body(Body::empty())
7555                    .unwrap(),
7556            )
7557            .await
7558            .unwrap();
7559
7560        assert_eq!(response.status(), StatusCode::OK);
7561        assert_eq!(
7562            response
7563                .headers()
7564                .get(http::header::CONTENT_TYPE)
7565                .and_then(|v| v.to_str().ok()),
7566            Some("font/woff2"),
7567            "woff2 manifest asset must keep its font/woff2 MIME type"
7568        );
7569        assert_eq!(
7570            response.headers().get(http::header::CONTENT_ENCODING),
7571            None,
7572            "pre-compressed woff2 font must not be re-compressed"
7573        );
7574    }
7575
7576    /// A manifest-backed asset served from a nested path with a multi-dot file
7577    /// name (`assets/js/app.min.js`) resolves to the JavaScript MIME type. The
7578    /// middleware derives the type from the file name alone, so neither the
7579    /// intermediate directory components nor the extra `.min.` dot cause a
7580    /// misparse.
7581    #[tokio::test]
7582    async fn ssg_nested_multidot_asset_resolves_js_mime() {
7583        let js = format!("console.log({:?});", "x".repeat(256));
7584        let tmp = create_ssg_dist(&[("/app.js", "assets/js/app.min.js", js.as_bytes())]);
7585        let dist = tmp.path().join("dist");
7586
7587        let router = try_build_router_with_static(
7588            Vec::new(),
7589            &compression_enabled_config(),
7590            test_state(),
7591            Some(&dist),
7592        )
7593        .expect("router builds");
7594        let response = router
7595            .oneshot(
7596                Request::builder()
7597                    .uri("/app.js")
7598                    .header("accept-encoding", "gzip")
7599                    .body(Body::empty())
7600                    .unwrap(),
7601            )
7602            .await
7603            .unwrap();
7604
7605        assert_eq!(response.status(), StatusCode::OK);
7606        assert_eq!(
7607            response
7608                .headers()
7609                .get(http::header::CONTENT_TYPE)
7610                .and_then(|v| v.to_str().ok()),
7611            Some("text/javascript; charset=utf-8"),
7612            "nested multi-dot JS asset must resolve to the JavaScript MIME type"
7613        );
7614        // JS is a compressible content type, so the layer must still gzip it.
7615        assert_eq!(
7616            response
7617                .headers()
7618                .get(http::header::CONTENT_ENCODING)
7619                .and_then(|v| v.to_str().ok()),
7620            Some("gzip"),
7621            "compressible JS asset must be gzip-compressed"
7622        );
7623    }
7624
7625    /// An extensionless generated page route (`/about` ->
7626    /// `about/index.html`, the shape `static_gen::url_to_file_path` produces)
7627    /// keeps its `text/html; charset=utf-8` type and is gzip-compressed. This
7628    /// pins the fallback: routes without a file extension must NOT regress to
7629    /// octet-stream just because the served file is `index.html`.
7630    #[tokio::test]
7631    async fn ssg_generated_html_page_keeps_text_html_and_is_compressed() {
7632        let html = format!("<html><body>{}</body></html>", "About us. ".repeat(128));
7633        let tmp = create_ssg_dist(&[("/about", "about/index.html", html.as_bytes())]);
7634        let dist = tmp.path().join("dist");
7635
7636        let router = try_build_router_with_static(
7637            Vec::new(),
7638            &compression_enabled_config(),
7639            test_state(),
7640            Some(&dist),
7641        )
7642        .expect("router builds");
7643        let response = router
7644            .oneshot(
7645                Request::builder()
7646                    .uri("/about")
7647                    .header("accept-encoding", "gzip")
7648                    .body(Body::empty())
7649                    .unwrap(),
7650            )
7651            .await
7652            .unwrap();
7653
7654        assert_eq!(response.status(), StatusCode::OK);
7655        assert_eq!(
7656            response
7657                .headers()
7658                .get(http::header::CONTENT_TYPE)
7659                .and_then(|v| v.to_str().ok()),
7660            Some("text/html; charset=utf-8"),
7661            "extensionless generated page must stay text/html, not octet-stream"
7662        );
7663        assert_eq!(
7664            response
7665                .headers()
7666                .get(http::header::CONTENT_ENCODING)
7667                .and_then(|v| v.to_str().ok()),
7668            Some("gzip"),
7669            "generated HTML page must be gzip-compressed"
7670        );
7671    }
7672
7673    /// A generated `.txt` route (`/robots.txt` -> `robots.txt/index.html`) is
7674    /// served as `text/plain; charset=utf-8` — derived from the request
7675    /// route's extension, not the on-disk `index.html` file name — and is
7676    /// gzip-compressed. Reading the MIME off the served file would mislabel it
7677    /// as text/html.
7678    #[tokio::test]
7679    async fn ssg_generated_txt_route_is_text_plain_and_compressed() {
7680        let body_text = format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(128));
7681        let tmp =
7682            create_ssg_dist(&[("/robots.txt", "robots.txt/index.html", body_text.as_bytes())]);
7683        let dist = tmp.path().join("dist");
7684
7685        let router = try_build_router_with_static(
7686            Vec::new(),
7687            &compression_enabled_config(),
7688            test_state(),
7689            Some(&dist),
7690        )
7691        .expect("router builds");
7692        let response = router
7693            .oneshot(
7694                Request::builder()
7695                    .uri("/robots.txt")
7696                    .header("accept-encoding", "gzip")
7697                    .body(Body::empty())
7698                    .unwrap(),
7699            )
7700            .await
7701            .unwrap();
7702
7703        assert_eq!(response.status(), StatusCode::OK);
7704        assert_eq!(
7705            response
7706                .headers()
7707                .get(http::header::CONTENT_TYPE)
7708                .and_then(|v| v.to_str().ok()),
7709            Some("text/plain; charset=utf-8"),
7710            "generated .txt route must be text/plain, derived from the route extension"
7711        );
7712        assert_eq!(
7713            response
7714                .headers()
7715                .get(http::header::CONTENT_ENCODING)
7716                .and_then(|v| v.to_str().ok()),
7717            Some("gzip"),
7718            "compressible text/plain route must be gzip-compressed"
7719        );
7720    }
7721
7722    /// A generated `.xml` route (`/sitemap.xml` -> `sitemap.xml/index.html`) is
7723    /// served as `application/xml`, derived from the request route's extension
7724    /// rather than the served `index.html` file name.
7725    #[tokio::test]
7726    async fn ssg_generated_xml_route_is_xml_mime() {
7727        let xml = format!(
7728            "<?xml version=\"1.0\"?><urlset>{}</urlset>",
7729            "<url><loc>https://example.com/</loc></url>".repeat(64)
7730        );
7731        let tmp = create_ssg_dist(&[("/sitemap.xml", "sitemap.xml/index.html", xml.as_bytes())]);
7732        let dist = tmp.path().join("dist");
7733
7734        let router = try_build_router_with_static(
7735            Vec::new(),
7736            &compression_enabled_config(),
7737            test_state(),
7738            Some(&dist),
7739        )
7740        .expect("router builds");
7741        let response = router
7742            .oneshot(
7743                Request::builder()
7744                    .uri("/sitemap.xml")
7745                    .header("accept-encoding", "gzip")
7746                    .body(Body::empty())
7747                    .unwrap(),
7748            )
7749            .await
7750            .unwrap();
7751
7752        assert_eq!(response.status(), StatusCode::OK);
7753        assert_eq!(
7754            response
7755                .headers()
7756                .get(http::header::CONTENT_TYPE)
7757                .and_then(|v| v.to_str().ok()),
7758            Some("application/xml"),
7759            "generated .xml route must be application/xml, derived from the route extension"
7760        );
7761    }
7762
7763    /// A generated HTML page whose slug merely *contains* a dot but ends in an
7764    /// UNRECOGNIZED extension (`/posts/release.v1` -> `release.v1/index.html`)
7765    /// stays `text/html; charset=utf-8` and is gzip-compressed. The `.v1`
7766    /// pseudo-extension is not an asset type, so the MIME must come from the
7767    /// served `index.html` — not be mislabeled octet-stream by a loose
7768    /// `contains('.')` heuristic.
7769    #[tokio::test]
7770    async fn ssg_dotted_slug_generated_page_stays_html_and_compressed() {
7771        let html = format!(
7772            "<html><body>{}</body></html>",
7773            "Release notes. ".repeat(128)
7774        );
7775        let tmp = create_ssg_dist(&[(
7776            "/posts/release.v1",
7777            "release.v1/index.html",
7778            html.as_bytes(),
7779        )]);
7780        let dist = tmp.path().join("dist");
7781
7782        let router = try_build_router_with_static(
7783            Vec::new(),
7784            &compression_enabled_config(),
7785            test_state(),
7786            Some(&dist),
7787        )
7788        .expect("router builds");
7789        let response = router
7790            .oneshot(
7791                Request::builder()
7792                    .uri("/posts/release.v1")
7793                    .header("accept-encoding", "gzip")
7794                    .body(Body::empty())
7795                    .unwrap(),
7796            )
7797            .await
7798            .unwrap();
7799
7800        assert_eq!(response.status(), StatusCode::OK);
7801        assert_eq!(
7802            response
7803                .headers()
7804                .get(http::header::CONTENT_TYPE)
7805                .and_then(|v| v.to_str().ok()),
7806            Some("text/html; charset=utf-8"),
7807            "dotted-slug generated page must stay text/html, not octet-stream"
7808        );
7809        assert_eq!(
7810            response
7811                .headers()
7812                .get(http::header::CONTENT_ENCODING)
7813                .and_then(|v| v.to_str().ok()),
7814            Some("gzip"),
7815            "dotted-slug generated HTML page must be gzip-compressed"
7816        );
7817    }
7818
7819    /// A generated HTML page whose slug contains an email-like dotted suffix
7820    /// (`/users/alice@example.com` -> `alice@example.com/index.html`) stays
7821    /// `text/html; charset=utf-8`. Neither `.com` nor the `@` makes it an
7822    /// asset, so the MIME comes from the served `index.html`.
7823    #[tokio::test]
7824    async fn ssg_email_slug_generated_page_stays_html() {
7825        let html = format!("<html><body>{}</body></html>", "Profile. ".repeat(64));
7826        let tmp = create_ssg_dist(&[(
7827            "/users/alice@example.com",
7828            "alice@example.com/index.html",
7829            html.as_bytes(),
7830        )]);
7831        let dist = tmp.path().join("dist");
7832
7833        let router = try_build_router_with_static(
7834            Vec::new(),
7835            &compression_enabled_config(),
7836            test_state(),
7837            Some(&dist),
7838        )
7839        .expect("router builds");
7840        let response = router
7841            .oneshot(
7842                Request::builder()
7843                    .uri("/users/alice@example.com")
7844                    .header("accept-encoding", "gzip")
7845                    .body(Body::empty())
7846                    .unwrap(),
7847            )
7848            .await
7849            .unwrap();
7850
7851        assert_eq!(response.status(), StatusCode::OK);
7852        assert_eq!(
7853            response
7854                .headers()
7855                .get(http::header::CONTENT_TYPE)
7856                .and_then(|v| v.to_str().ok()),
7857            Some("text/html; charset=utf-8"),
7858            "email-like dotted-slug generated page must stay text/html"
7859        );
7860    }
7861
7862    /// A dynamic fallback route (not in the manifest) is compressed the same way
7863    /// as SSG pages, confirming parity between static-first and dynamic
7864    /// responses.
7865    #[tokio::test]
7866    async fn ssg_dynamic_fallback_route_is_gzip_compressed() {
7867        async fn dynamic() -> impl axum::response::IntoResponse {
7868            (
7869                [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
7870                format!(
7871                    "<html><body>{}</body></html>",
7872                    "dynamic content ".repeat(64)
7873                ),
7874            )
7875        }
7876        let route = Route {
7877            method: http::Method::GET,
7878            path: "/dynamic",
7879            handler: axum::routing::get(dynamic),
7880            name: "dynamic",
7881            api_doc: crate::openapi::ApiDoc {
7882                method: "GET",
7883                path: "/dynamic",
7884                operation_id: "dynamic",
7885                success_status: 200,
7886                ..Default::default()
7887            },
7888            api_version: None,
7889            sunset_opt_out: false,
7890            repository: None,
7891            idempotency: crate::route::RouteIdempotency::default(),
7892            timeout: crate::route::RouteTimeout::default(),
7893        };
7894
7895        // Manifest does NOT contain /dynamic, so the request falls through to
7896        // the dynamic router.
7897        let tmp = create_ssg_dist(&[("/", "index.html", b"<h1>home</h1>")]);
7898        let dist = tmp.path().join("dist");
7899
7900        let router = try_build_router_with_static(
7901            vec![route],
7902            &compression_enabled_config(),
7903            test_state(),
7904            Some(&dist),
7905        )
7906        .expect("router builds");
7907        let response = router
7908            .oneshot(
7909                Request::builder()
7910                    .uri("/dynamic")
7911                    .header("accept-encoding", "gzip")
7912                    .body(Body::empty())
7913                    .unwrap(),
7914            )
7915            .await
7916            .unwrap();
7917
7918        assert_eq!(response.status(), StatusCode::OK);
7919        assert_eq!(
7920            response
7921                .headers()
7922                .get(http::header::CONTENT_ENCODING)
7923                .and_then(|v| v.to_str().ok()),
7924            Some("gzip"),
7925            "dynamic fallback route must be compressed just like SSG pages"
7926        );
7927    }
7928
7929    fn create_static_dist(revalidate: Option<u64>) -> tempfile::TempDir {
7930        let dir = tempfile::tempdir().expect("tempdir");
7931        let dist = dir.path().join("dist");
7932        std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
7933        std::fs::write(dist.join("index.html"), b"<h1>Home</h1>").expect("write index");
7934        std::fs::write(dist.join("about/index.html"), b"<h1>About</h1>").expect("write about");
7935
7936        let mut routes = std::collections::HashMap::new();
7937        routes.insert(
7938            "/".to_owned(),
7939            crate::static_gen::ManifestEntry {
7940                file: "index.html".to_owned(),
7941                revalidate: None,
7942            },
7943        );
7944        routes.insert(
7945            "/about".to_owned(),
7946            crate::static_gen::ManifestEntry {
7947                file: "about/index.html".to_owned(),
7948                revalidate,
7949            },
7950        );
7951
7952        let manifest = crate::static_gen::StaticManifest {
7953            generated_at: "2026-05-18T00:00:00Z".to_owned(),
7954            autumn_version: "0.5.0".to_owned(),
7955            routes,
7956        };
7957        let json = serde_json::to_string(&manifest).expect("serialize manifest");
7958        std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
7959        dir
7960    }
7961
7962    #[tokio::test]
7963    async fn static_serving_serves_get_request_inside_user_layers() {
7964        let tmp = create_static_dist(None);
7965        let dist = tmp.path().join("dist");
7966        let config = AutumnConfig::default();
7967
7968        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
7969            .expect("router builds");
7970
7971        let response = router
7972            .oneshot(
7973                Request::builder()
7974                    .uri("/about")
7975                    .body(Body::empty())
7976                    .unwrap(),
7977            )
7978            .await
7979            .unwrap();
7980
7981        assert_eq!(response.status(), StatusCode::OK);
7982        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
7983            .await
7984            .unwrap();
7985        assert_eq!(body.as_ref(), b"<h1>About</h1>");
7986    }
7987
7988    #[tokio::test]
7989    async fn static_serving_serves_head_request() {
7990        let tmp = create_static_dist(None);
7991        let dist = tmp.path().join("dist");
7992        let config = AutumnConfig::default();
7993
7994        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
7995            .expect("router builds");
7996
7997        let response = router
7998            .oneshot(
7999                Request::builder()
8000                    .method("HEAD")
8001                    .uri("/about")
8002                    .body(Body::empty())
8003                    .unwrap(),
8004            )
8005            .await
8006            .unwrap();
8007
8008        assert_eq!(response.status(), StatusCode::OK);
8009        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8010            .await
8011            .unwrap();
8012        assert!(body.is_empty(), "HEAD response body should be empty");
8013    }
8014
8015    #[tokio::test]
8016    async fn static_serving_normalizes_trailing_slash() {
8017        let tmp = create_static_dist(None);
8018        let dist = tmp.path().join("dist");
8019        let config = AutumnConfig::default();
8020
8021        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
8022            .expect("router builds");
8023
8024        let response = router
8025            .oneshot(
8026                Request::builder()
8027                    .uri("/about/")
8028                    .body(Body::empty())
8029                    .unwrap(),
8030            )
8031            .await
8032            .unwrap();
8033
8034        assert_eq!(response.status(), StatusCode::OK);
8035    }
8036
8037    #[tokio::test]
8038    async fn static_serving_falls_through_for_unknown_route() {
8039        let tmp = create_static_dist(None);
8040        let dist = tmp.path().join("dist");
8041        let config = AutumnConfig::default();
8042
8043        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
8044            .expect("router builds");
8045
8046        let response = router
8047            .oneshot(
8048                Request::builder()
8049                    .uri("/not-in-manifest")
8050                    .body(Body::empty())
8051                    .unwrap(),
8052            )
8053            .await
8054            .unwrap();
8055
8056        assert_eq!(response.status(), StatusCode::NOT_FOUND);
8057    }
8058
8059    #[tokio::test]
8060    async fn static_serving_skipped_when_no_manifest() {
8061        let tmp = tempfile::tempdir().expect("tempdir");
8062        let dist = tmp.path().join("dist");
8063        std::fs::create_dir_all(&dist).expect("mkdir dist");
8064        let config = AutumnConfig::default();
8065
8066        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
8067            .expect("router builds even without manifest");
8068
8069        let response = router
8070            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
8071            .await
8072            .unwrap();
8073
8074        assert_eq!(response.status(), StatusCode::NOT_FOUND);
8075    }
8076
8077    #[tokio::test]
8078    async fn static_serving_with_isr_manifest_builds_successfully() {
8079        let tmp = create_static_dist(Some(3600));
8080        let dist = tmp.path().join("dist");
8081        let config = AutumnConfig::default();
8082
8083        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
8084            .expect("router with ISR manifest should build");
8085
8086        let response = router
8087            .oneshot(
8088                Request::builder()
8089                    .uri("/about")
8090                    .body(Body::empty())
8091                    .unwrap(),
8092            )
8093            .await
8094            .unwrap();
8095
8096        assert_eq!(response.status(), StatusCode::OK);
8097    }
8098}
8099
8100#[cfg(test)]
8101mod trusted_host_tests {
8102    use super::*;
8103    use axum::body::Body;
8104    use http::Request;
8105    use tower::util::ServiceExt;
8106
8107    #[tokio::test]
8108    async fn trusted_host_allows_matching_and_blocks_nonmatching() {
8109        let mut cfg = AutumnConfig::default();
8110        cfg.security.trusted_hosts.hosts = vec!["example.com".into(), ".example.com".into()];
8111        let state = crate::state::AppState::for_test();
8112        let router = build_router(vec![], &cfg, state);
8113
8114        let ok = router
8115            .clone()
8116            .oneshot(
8117                Request::builder()
8118                    .uri("/nope")
8119                    .header("host", "api.example.com")
8120                    .body(Body::empty())
8121                    .unwrap(),
8122            )
8123            .await
8124            .unwrap();
8125        assert_eq!(ok.status(), StatusCode::NOT_FOUND);
8126
8127        let blocked = router
8128            .oneshot(
8129                Request::builder()
8130                    .uri("/nope")
8131                    .header("host", "evil.com")
8132                    .body(Body::empty())
8133                    .unwrap(),
8134            )
8135            .await
8136            .unwrap();
8137        assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
8138    }
8139
8140    #[tokio::test]
8141    async fn trusted_host_wildcard_allows_any_host() {
8142        let mut cfg = AutumnConfig::default();
8143        cfg.security.trusted_hosts.hosts = vec!["*".into()];
8144        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8145        let response = router
8146            .oneshot(
8147                Request::builder()
8148                    .uri("/nope")
8149                    .header("host", "anything.example")
8150                    .body(Body::empty())
8151                    .expect("request should build"),
8152            )
8153            .await
8154            .expect("request should complete");
8155        assert_eq!(response.status(), StatusCode::NOT_FOUND);
8156    }
8157
8158    #[tokio::test]
8159    async fn trusted_host_bypasses_probe_paths() {
8160        let mut cfg = AutumnConfig::default();
8161        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8162        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8163        let response = router
8164            .oneshot(
8165                Request::builder()
8166                    .uri("/actuator/health")
8167                    .header("host", "evil.com")
8168                    .body(Body::empty())
8169                    .expect("request should build"),
8170            )
8171            .await
8172            .expect("request should complete");
8173        assert_eq!(response.status(), StatusCode::OK);
8174    }
8175
8176    #[tokio::test]
8177    async fn trusted_host_bypasses_actuator_health_path() {
8178        let mut cfg = AutumnConfig::default();
8179        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8180        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8181        let response = router
8182            .oneshot(
8183                Request::builder()
8184                    .uri("/actuator/health")
8185                    .header("host", "evil.com")
8186                    .body(Body::empty())
8187                    .expect("request should build"),
8188            )
8189            .await
8190            .expect("request should complete");
8191        assert_eq!(response.status(), StatusCode::OK);
8192    }
8193
8194    /// `probe_bypass_paths()` is meant to be the single canonical definition
8195    /// of "which exact paths bypass admission-style gates" — `TrustedHostPolicy`
8196    /// and `StartupBarrierState` must derive their own bypass sets from it
8197    /// rather than each re-implementing the same list, so a change to
8198    /// `probe_bypass_paths()` (or `config.health.*`) is automatically
8199    /// reflected in both without touching either of them directly.
8200    #[test]
8201    fn probe_bypass_paths_is_the_single_source_for_trusted_host_and_startup_barrier() {
8202        let mut cfg = AutumnConfig::default();
8203        cfg.health.path = "/custom-health-check".into();
8204        let expected = probe_bypass_paths(&cfg);
8205        assert!(expected.contains(&"/custom-health-check".to_string()));
8206
8207        let trusted_host = TrustedHostPolicy::from_config(&cfg);
8208        for path in &expected {
8209            assert!(
8210                trusted_host.probe_bypass_paths.contains(path),
8211                "TrustedHostPolicy must derive its bypass set from probe_bypass_paths(): missing {path}"
8212            );
8213        }
8214
8215        let state = crate::state::AppState::for_test();
8216        let barrier = StartupBarrierState::from_config(&cfg, &state);
8217        for path in &expected {
8218            assert!(
8219                barrier.allows_path(path),
8220                "StartupBarrierState must derive its bypass set from probe_bypass_paths(): missing {path}"
8221            );
8222        }
8223    }
8224
8225    /// Regression guard (#1627): the `StartupBarrierState` allow-list, which is
8226    /// seeded from `actuator_endpoint_paths`, must permit the mutating
8227    /// `POST {prefix}/webhooks/replay` route to bypass the startup barrier. A
8228    /// prior fix removed the path from `actuator_endpoint_paths` to kill a
8229    /// phantom GET in the route listing, which also silently dropped it from
8230    /// this bypass set.
8231    #[cfg(feature = "http-client")]
8232    #[test]
8233    fn startup_barrier_allows_webhook_replay_post_path() {
8234        let mut cfg = AutumnConfig::default();
8235        cfg.actuator.sensitive = true;
8236        let state = crate::state::AppState::for_test();
8237        let barrier = StartupBarrierState::from_config(&cfg, &state);
8238        let replay_path =
8239            crate::actuator::actuator_route_path(&cfg.actuator.prefix, "/webhooks/replay");
8240        assert!(
8241            barrier.allows_path(&replay_path),
8242            "startup barrier must allow {replay_path} to bypass admission"
8243        );
8244    }
8245
8246    #[tokio::test]
8247    async fn trusted_host_release_rejects_loopback_unless_listed() {
8248        let mut cfg = AutumnConfig {
8249            profile: Some("prod".into()),
8250            ..AutumnConfig::default()
8251        };
8252        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8253        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8254        let response = router
8255            .oneshot(
8256                Request::builder()
8257                    .uri("/nope")
8258                    .header("host", "localhost")
8259                    .body(Body::empty())
8260                    .expect("request should build"),
8261            )
8262            .await
8263            .expect("request should complete");
8264        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8265    }
8266
8267    #[tokio::test]
8268    async fn trusted_host_uses_uri_authority_when_host_header_missing() {
8269        let mut cfg = AutumnConfig::default();
8270        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8271        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8272        let response = router
8273            .oneshot(
8274                Request::builder()
8275                    .uri("http://EXAMPLE.COM/nope")
8276                    .body(Body::empty())
8277                    .expect("request should build"),
8278            )
8279            .await
8280            .expect("request should complete");
8281        assert_eq!(response.status(), StatusCode::NOT_FOUND);
8282    }
8283
8284    #[tokio::test]
8285    async fn trusted_host_accepts_bracketed_ipv6_loopback_in_dev() {
8286        let cfg = AutumnConfig::default();
8287        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8288        let response = router
8289            .oneshot(
8290                Request::builder()
8291                    .uri("/nope")
8292                    .header("host", "[::1]:3000")
8293                    .body(Body::empty())
8294                    .expect("request should build"),
8295            )
8296            .await
8297            .expect("request should complete");
8298        assert_eq!(response.status(), StatusCode::NOT_FOUND);
8299    }
8300
8301    #[tokio::test]
8302    async fn trusted_host_matching_is_case_insensitive() {
8303        let mut cfg = AutumnConfig::default();
8304        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8305        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8306        let response = router
8307            .oneshot(
8308                Request::builder()
8309                    .uri("/nope")
8310                    .header("host", "EXAMPLE.COM")
8311                    .body(Body::empty())
8312                    .expect("request should build"),
8313            )
8314            .await
8315            .expect("request should complete");
8316        assert_eq!(response.status(), StatusCode::NOT_FOUND);
8317    }
8318
8319    #[tokio::test]
8320    async fn trusted_host_rejects_malformed_port() {
8321        let mut cfg = AutumnConfig::default();
8322        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8323        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8324        let response = router
8325            .oneshot(
8326                Request::builder()
8327                    .uri("/nope")
8328                    .header("host", "example.com:abc")
8329                    .body(Body::empty())
8330                    .expect("request should build"),
8331            )
8332            .await
8333            .expect("request should complete");
8334        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8335    }
8336
8337    #[tokio::test]
8338    async fn trusted_host_rejects_empty_port_suffix() {
8339        let mut cfg = AutumnConfig::default();
8340        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8341        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8342        let response = router
8343            .oneshot(
8344                Request::builder()
8345                    .uri("/nope")
8346                    .header("host", "example.com:")
8347                    .body(Body::empty())
8348                    .expect("request should build"),
8349            )
8350            .await
8351            .expect("request should complete");
8352        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8353    }
8354
8355    #[tokio::test]
8356    async fn trusted_host_rejects_bracketed_reg_name() {
8357        let mut cfg = AutumnConfig::default();
8358        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8359        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8360        let response = router
8361            .oneshot(
8362                Request::builder()
8363                    .uri("/nope")
8364                    .header("host", "[example.com]")
8365                    .body(Body::empty())
8366                    .expect("request should build"),
8367            )
8368            .await
8369            .expect("request should complete");
8370        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8371    }
8372    #[tokio::test]
8373    async fn trusted_host_configured_trailing_dot_matches_normalized_host() {
8374        let mut cfg = AutumnConfig::default();
8375        cfg.security.trusted_hosts.hosts = vec!["example.com.".into()];
8376        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8377        let response = router
8378            .oneshot(
8379                Request::builder()
8380                    .uri("/nope")
8381                    .header("host", "example.com")
8382                    .body(Body::empty())
8383                    .expect("request should build"),
8384            )
8385            .await
8386            .expect("request should complete");
8387        assert_eq!(response.status(), StatusCode::NOT_FOUND);
8388    }
8389
8390    #[tokio::test]
8391    async fn trusted_host_accepts_trailing_dot_fqdn() {
8392        let mut cfg = AutumnConfig::default();
8393        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8394        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8395        let response = router
8396            .oneshot(
8397                Request::builder()
8398                    .uri("/nope")
8399                    .header("host", "example.com.")
8400                    .body(Body::empty())
8401                    .expect("request should build"),
8402            )
8403            .await
8404            .expect("request should complete");
8405        assert_eq!(response.status(), StatusCode::NOT_FOUND);
8406    }
8407
8408    #[tokio::test]
8409    async fn trusted_host_bypasses_custom_probe_path_only() {
8410        let mut cfg = AutumnConfig::default();
8411        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8412        cfg.health.path = "/healthz".into();
8413        cfg.health.startup_path = "/startupz".into();
8414        cfg.health.ready_path = "/readyz".into();
8415        cfg.health.live_path = "/livez".into();
8416        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8417
8418        let bypassed = router
8419            .clone()
8420            .oneshot(
8421                Request::builder()
8422                    .uri("/healthz")
8423                    .header("host", "evil.com")
8424                    .body(Body::empty())
8425                    .expect("request should build"),
8426            )
8427            .await
8428            .expect("request should complete");
8429        assert_eq!(bypassed.status(), StatusCode::OK);
8430
8431        let not_bypassed = router
8432            .oneshot(
8433                Request::builder()
8434                    .uri("/health")
8435                    .header("host", "evil.com")
8436                    .body(Body::empty())
8437                    .expect("request should build"),
8438            )
8439            .await
8440            .expect("request should complete");
8441        assert_eq!(not_bypassed.status(), StatusCode::BAD_REQUEST);
8442    }
8443
8444    #[tokio::test]
8445    async fn trusted_host_does_not_bypass_non_get_probe_path_requests() {
8446        let mut cfg = AutumnConfig::default();
8447        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
8448        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
8449        let response = router
8450            .oneshot(
8451                Request::builder()
8452                    .method("POST")
8453                    .uri("/health")
8454                    .header("host", "evil.com")
8455                    .body(Body::empty())
8456                    .expect("request should build"),
8457            )
8458            .await
8459            .expect("request should complete");
8460        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
8461    }
8462
8463    // ── Global body-size limit (AC: DefaultBodyLimit covers all content types) ──
8464
8465    #[tokio::test]
8466    async fn apply_upload_middleware_rejects_oversized_json_body() {
8467        let mut config = AutumnConfig::default();
8468        config.security.upload.max_request_size_bytes = 100; // 100-byte limit
8469
8470        let base: axum::Router<AppState> = axum::Router::new().route(
8471            "/data",
8472            axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
8473        );
8474        let router =
8475            apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
8476
8477        // 200 bytes of JSON-shaped content exceeds the 100-byte cap.
8478        let big_body = "x".repeat(200);
8479        let response = router
8480            .oneshot(
8481                Request::builder()
8482                    .method("POST")
8483                    .uri("/data")
8484                    .header("content-type", "application/json")
8485                    .body(Body::from(big_body))
8486                    .unwrap(),
8487            )
8488            .await
8489            .unwrap();
8490
8491        assert_eq!(
8492            response.status(),
8493            StatusCode::PAYLOAD_TOO_LARGE,
8494            "oversized body must be rejected with 413 regardless of content type"
8495        );
8496    }
8497
8498    #[tokio::test]
8499    async fn apply_upload_middleware_accepts_body_within_limit() {
8500        let mut config = AutumnConfig::default();
8501        config.security.upload.max_request_size_bytes = 1024;
8502
8503        let base: axum::Router<AppState> = axum::Router::new().route(
8504            "/data",
8505            axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
8506        );
8507        let router =
8508            apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
8509
8510        let response = router
8511            .oneshot(
8512                Request::builder()
8513                    .method("POST")
8514                    .uri("/data")
8515                    .header("content-type", "application/json")
8516                    .body(Body::from("hello"))
8517                    .unwrap(),
8518            )
8519            .await
8520            .unwrap();
8521
8522        assert_eq!(response.status(), StatusCode::OK);
8523    }
8524
8525    // ── Per-request timeout (AC: 503 on timeout, metrics, WARN log) ──────────
8526
8527    /// Empty per-route override table (no route-level overrides).
8528    fn no_route_timeouts() -> RouteTimeoutTable {
8529        std::sync::Arc::new(std::collections::HashMap::new())
8530    }
8531
8532    /// Build a single-entry override table for `GET <path>` (the method the
8533    /// unit-test routers below register).
8534    fn get_route_timeouts(path: &str, timeout: crate::route::RouteTimeout) -> RouteTimeoutTable {
8535        let mut by_method = std::collections::HashMap::new();
8536        by_method.insert(http::Method::GET, timeout);
8537        let mut table = std::collections::HashMap::new();
8538        table.insert(path.to_owned(), by_method);
8539        std::sync::Arc::new(table)
8540    }
8541
8542    #[tokio::test(start_paused = true)]
8543    async fn request_timeout_returns_503_when_exceeded() {
8544        let mut config = AutumnConfig::default();
8545        config.server.timeouts.request_timeout_ms = Some(100);
8546
8547        let state = crate::state::AppState::for_test();
8548        let router: axum::Router<AppState> = axum::Router::new().route(
8549            "/slow",
8550            axum::routing::get(|| async {
8551                // This sleep is much longer than the 100ms timeout.
8552                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8553                "ok"
8554            }),
8555        );
8556
8557        // Place timeout inner to RequestIdLayer (matches apply_middleware ordering).
8558        let router = apply_request_timeout_middleware(
8559            router,
8560            &config,
8561            state.metrics.clone(),
8562            no_route_timeouts(),
8563            false,
8564        )
8565        .layer(RequestIdLayer)
8566        .with_state(state);
8567
8568        let response = router
8569            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8570            .await
8571            .unwrap();
8572
8573        assert_eq!(
8574            response.status(),
8575            StatusCode::SERVICE_UNAVAILABLE,
8576            "a slow handler must trigger 503"
8577        );
8578        assert_eq!(
8579            response
8580                .headers()
8581                .get("content-type")
8582                .and_then(|v| v.to_str().ok()),
8583            Some("application/problem+json"),
8584            "timeout response must use Problem Details content type"
8585        );
8586    }
8587
8588    #[tokio::test(start_paused = true)]
8589    async fn request_timeout_increments_metric() {
8590        let mut config = AutumnConfig::default();
8591        config.server.timeouts.request_timeout_ms = Some(100);
8592
8593        let state = crate::state::AppState::for_test();
8594        let router: axum::Router<AppState> = axum::Router::new().route(
8595            "/slow",
8596            axum::routing::get(|| async {
8597                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8598                "ok"
8599            }),
8600        );
8601
8602        let router = apply_request_timeout_middleware(
8603            router,
8604            &config,
8605            state.metrics.clone(),
8606            no_route_timeouts(),
8607            false,
8608        )
8609        .layer(RequestIdLayer)
8610        .with_state(state.clone());
8611
8612        router
8613            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8614            .await
8615            .unwrap();
8616
8617        let snap = state.metrics.snapshot();
8618        assert_eq!(
8619            snap.http.request_timeouts_total, 1,
8620            "autumn_request_timeouts_total must be incremented on timeout"
8621        );
8622    }
8623
8624    #[tokio::test(start_paused = true)]
8625    async fn render_deadline_exempt_marker_skips_timeout() {
8626        let mut config = AutumnConfig::default();
8627        config.server.timeouts.request_timeout_ms = Some(100);
8628
8629        let state = crate::state::AppState::for_test();
8630        let router: axum::Router<AppState> = axum::Router::new().route(
8631            "/slow",
8632            axum::routing::get(|| async {
8633                // Far longer than the 100ms deadline; the paused clock advances
8634                // automatically once the task is otherwise idle.
8635                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8636                "ok"
8637            }),
8638        );
8639
8640        let router = apply_request_timeout_middleware(
8641            router,
8642            &config,
8643            state.metrics.clone(),
8644            no_route_timeouts(),
8645            false,
8646        )
8647        .layer(RequestIdLayer)
8648        .with_state(state);
8649
8650        // A live inbound request (no marker) is bounded by the deadline -> 503.
8651        let live = router
8652            .clone()
8653            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8654            .await
8655            .unwrap();
8656        assert_eq!(
8657            live.status(),
8658            StatusCode::SERVICE_UNAVAILABLE,
8659            "a live request to a slow handler must still time out"
8660        );
8661
8662        // An internal build/ISR render carrying `RenderDeadlineExempt` is exempt
8663        // and runs to completion.
8664        let exempt = router
8665            .oneshot(
8666                Request::builder()
8667                    .uri("/slow")
8668                    .extension(crate::static_gen::RenderDeadlineExempt)
8669                    .body(Body::empty())
8670                    .unwrap(),
8671            )
8672            .await
8673            .unwrap();
8674        assert_eq!(
8675            exempt.status(),
8676            StatusCode::OK,
8677            "the build/ISR render marker must exempt the request from the deadline"
8678        );
8679    }
8680
8681    #[tokio::test(start_paused = true)]
8682    async fn request_timeout_503_mirrors_cors_headers() {
8683        let mut config = AutumnConfig::default();
8684        config.server.timeouts.request_timeout_ms = Some(100);
8685        // CORS is configured with a concrete allowlist (the reflected-origin path).
8686        config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
8687        config.cors.allow_credentials = true;
8688
8689        let state = crate::state::AppState::for_test();
8690        let router: axum::Router<AppState> = axum::Router::new().route(
8691            "/slow",
8692            axum::routing::get(|| async {
8693                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8694                "ok"
8695            }),
8696        );
8697
8698        // `mirror_cors = true`, matching the main ingress stack where the timeout
8699        // layer is outside `CorsLayer` and the 503 would otherwise be opaque.
8700        let router = apply_request_timeout_middleware(
8701            router,
8702            &config,
8703            state.metrics.clone(),
8704            no_route_timeouts(),
8705            true,
8706        )
8707        .layer(RequestIdLayer)
8708        .with_state(state);
8709
8710        let response = router
8711            .oneshot(
8712                Request::builder()
8713                    .uri("/slow")
8714                    .header("origin", "https://app.example.com")
8715                    .body(Body::empty())
8716                    .unwrap(),
8717            )
8718            .await
8719            .unwrap();
8720
8721        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8722        assert_eq!(
8723            response
8724                .headers()
8725                .get("access-control-allow-origin")
8726                .and_then(|v| v.to_str().ok()),
8727            Some("https://app.example.com"),
8728            "an allowed origin must be reflected on the timeout 503 so browsers can read it"
8729        );
8730        assert_eq!(
8731            response
8732                .headers()
8733                .get("access-control-allow-credentials")
8734                .and_then(|v| v.to_str().ok()),
8735            Some("true"),
8736            "credentials flag must be mirrored when configured"
8737        );
8738        assert!(
8739            response
8740                .headers()
8741                .get_all("vary")
8742                .iter()
8743                .any(|v| v.to_str().is_ok_and(|s| s.eq_ignore_ascii_case("origin"))),
8744            "a reflected origin must carry Vary: origin"
8745        );
8746    }
8747
8748    #[tokio::test(start_paused = true)]
8749    async fn request_timeout_503_omits_cors_for_disallowed_origin() {
8750        let mut config = AutumnConfig::default();
8751        config.server.timeouts.request_timeout_ms = Some(100);
8752        config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
8753
8754        let state = crate::state::AppState::for_test();
8755        let router: axum::Router<AppState> = axum::Router::new().route(
8756            "/slow",
8757            axum::routing::get(|| async {
8758                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8759                "ok"
8760            }),
8761        );
8762
8763        let router = apply_request_timeout_middleware(
8764            router,
8765            &config,
8766            state.metrics.clone(),
8767            no_route_timeouts(),
8768            true,
8769        )
8770        .layer(RequestIdLayer)
8771        .with_state(state);
8772
8773        let response = router
8774            .oneshot(
8775                Request::builder()
8776                    .uri("/slow")
8777                    .header("origin", "https://evil.example.com")
8778                    .body(Body::empty())
8779                    .unwrap(),
8780            )
8781            .await
8782            .unwrap();
8783
8784        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8785        assert!(
8786            response
8787                .headers()
8788                .get("access-control-allow-origin")
8789                .is_none(),
8790            "a disallowed origin must not be reflected, mirroring CorsLayer"
8791        );
8792    }
8793
8794    #[tokio::test(start_paused = true)]
8795    async fn request_timeout_response_includes_request_id_header() {
8796        let mut config = AutumnConfig::default();
8797        config.server.timeouts.request_timeout_ms = Some(100);
8798
8799        let state = crate::state::AppState::for_test();
8800        let router: axum::Router<AppState> = axum::Router::new().route(
8801            "/slow",
8802            axum::routing::get(|| async {
8803                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8804                "ok"
8805            }),
8806        );
8807
8808        let router = apply_request_timeout_middleware(
8809            router,
8810            &config,
8811            state.metrics.clone(),
8812            no_route_timeouts(),
8813            false,
8814        )
8815        .layer(RequestIdLayer)
8816        .with_state(state);
8817
8818        let response = router
8819            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8820            .await
8821            .unwrap();
8822
8823        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8824        // X-Request-Id is added by RequestIdLayer on the egress path.
8825        assert!(
8826            response.headers().contains_key("x-request-id"),
8827            "503 response must carry the X-Request-Id header"
8828        );
8829
8830        // The body must be a well-formed Problem Details document.
8831        let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
8832            .await
8833            .unwrap();
8834        let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
8835        assert_eq!(body["status"], 503);
8836    }
8837
8838    #[tokio::test]
8839    async fn request_timeout_disabled_when_none() {
8840        let config = AutumnConfig::default(); // request_timeout_ms = None
8841
8842        let state = crate::state::AppState::for_test();
8843        let router: axum::Router<AppState> =
8844            axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
8845
8846        let router = apply_request_timeout_middleware(
8847            router,
8848            &config,
8849            state.metrics.clone(),
8850            no_route_timeouts(),
8851            false,
8852        )
8853        .with_state(state);
8854
8855        let response = router
8856            .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
8857            .await
8858            .unwrap();
8859
8860        assert_eq!(response.status(), StatusCode::OK);
8861    }
8862
8863    #[tokio::test]
8864    async fn request_timeout_zero_treated_as_disabled() {
8865        let mut config = AutumnConfig::default();
8866        config.server.timeouts.request_timeout_ms = Some(0); // 0 = disabled
8867
8868        let state = crate::state::AppState::for_test();
8869        let router: axum::Router<AppState> =
8870            axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
8871
8872        let router = apply_request_timeout_middleware(
8873            router,
8874            &config,
8875            state.metrics.clone(),
8876            no_route_timeouts(),
8877            false,
8878        )
8879        .with_state(state);
8880
8881        let response = router
8882            .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
8883            .await
8884            .unwrap();
8885
8886        assert_eq!(response.status(), StatusCode::OK);
8887    }
8888
8889    // Exercises the warn branch when no RequestIdLayer is present (no request_id
8890    // extension), keeping coverage of the `None` request-id arm.
8891    #[tokio::test(start_paused = true)]
8892    async fn request_timeout_503_without_request_id_layer() {
8893        let mut config = AutumnConfig::default();
8894        config.server.timeouts.request_timeout_ms = Some(100);
8895
8896        let state = crate::state::AppState::for_test();
8897        let router: axum::Router<AppState> = axum::Router::new().route(
8898            "/slow",
8899            axum::routing::get(|| async {
8900                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
8901                "ok"
8902            }),
8903        );
8904
8905        // No RequestIdLayer — exercises the else branch in request_timeout_handler.
8906        let router = apply_request_timeout_middleware(
8907            router,
8908            &config,
8909            state.metrics.clone(),
8910            no_route_timeouts(),
8911            false,
8912        )
8913        .with_state(state);
8914
8915        let response = router
8916            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
8917            .await
8918            .unwrap();
8919
8920        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8921    }
8922
8923    // AC4: a per-route `Override` extends the deadline so a known-slow route
8924    // outlives the (smaller) global timeout.
8925    #[tokio::test(start_paused = true)]
8926    async fn request_timeout_per_route_override_extends_deadline() {
8927        let mut config = AutumnConfig::default();
8928        config.server.timeouts.request_timeout_ms = Some(100); // tight global
8929
8930        let state = crate::state::AppState::for_test();
8931        let router: axum::Router<AppState> = axum::Router::new().route(
8932            "/export",
8933            axum::routing::get(|| async {
8934                // Longer than the 100ms global, shorter than the 10s override.
8935                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
8936                "report"
8937            }),
8938        );
8939
8940        let table = get_route_timeouts(
8941            "/export",
8942            crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10)),
8943        );
8944        let router =
8945            apply_request_timeout_middleware(router, &config, state.metrics.clone(), table, false)
8946                .with_state(state);
8947
8948        let response = router
8949            .oneshot(
8950                Request::builder()
8951                    .uri("/export")
8952                    .body(Body::empty())
8953                    .unwrap(),
8954            )
8955            .await
8956            .unwrap();
8957
8958        assert_eq!(
8959            response.status(),
8960            StatusCode::OK,
8961            "the override must let the slow route complete past the global deadline"
8962        );
8963    }
8964
8965    // AC4: a per-route `Disabled` exempts the route from the global timeout.
8966    #[tokio::test(start_paused = true)]
8967    async fn request_timeout_per_route_disabled_exempts_route() {
8968        let mut config = AutumnConfig::default();
8969        config.server.timeouts.request_timeout_ms = Some(100);
8970
8971        let state = crate::state::AppState::for_test();
8972        let router: axum::Router<AppState> = axum::Router::new().route(
8973            "/stream",
8974            axum::routing::get(|| async {
8975                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
8976                "done"
8977            }),
8978        );
8979
8980        let table = get_route_timeouts("/stream", crate::route::RouteTimeout::Disabled);
8981        let router =
8982            apply_request_timeout_middleware(router, &config, state.metrics.clone(), table, false)
8983                .with_state(state.clone());
8984
8985        let response = router
8986            .oneshot(
8987                Request::builder()
8988                    .uri("/stream")
8989                    .body(Body::empty())
8990                    .unwrap(),
8991            )
8992            .await
8993            .unwrap();
8994
8995        assert_eq!(response.status(), StatusCode::OK);
8996        assert_eq!(
8997            state.metrics.snapshot().http.request_timeouts_total,
8998            0,
8999            "an exempt route must not record a timeout"
9000        );
9001    }
9002
9003    // AC4: an `Override` enables the layer even when the global timeout is off.
9004    #[tokio::test(start_paused = true)]
9005    async fn request_timeout_override_active_when_global_disabled() {
9006        let config = AutumnConfig::default(); // global timeout disabled (None)
9007
9008        let state = crate::state::AppState::for_test();
9009        let router: axum::Router<AppState> = axum::Router::new().route(
9010            "/export",
9011            axum::routing::get(|| async {
9012                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
9013                "report"
9014            }),
9015        );
9016
9017        let table = get_route_timeouts(
9018            "/export",
9019            crate::route::RouteTimeout::Override(std::time::Duration::from_millis(100)),
9020        );
9021        let router =
9022            apply_request_timeout_middleware(router, &config, state.metrics.clone(), table, false)
9023                .with_state(state);
9024
9025        let response = router
9026            .oneshot(
9027                Request::builder()
9028                    .uri("/export")
9029                    .body(Body::empty())
9030                    .unwrap(),
9031            )
9032            .await
9033            .unwrap();
9034
9035        assert_eq!(
9036            response.status(),
9037            StatusCode::SERVICE_UNAVAILABLE,
9038            "a per-route override must be enforced even with the global timeout off"
9039        );
9040    }
9041
9042    #[test]
9043    fn build_route_timeout_table_is_empty_without_routes() {
9044        // End-to-end keying (top-level + nested groups) is covered by the
9045        // `request_timeout` integration tests via the macro attribute; here we
9046        // assert the no-route base case yields a zero-overhead empty table.
9047        let table = build_route_timeout_table(&[], &[]);
9048        assert!(table.is_empty(), "no routes ⇒ empty override table");
9049    }
9050
9051    /// Build a minimal `Route` carrying just the fields `build_route_timeout_table`
9052    /// reads (method, path, timeout); the handler is a no-op.
9053    fn timeout_route(
9054        method: http::Method,
9055        path: &'static str,
9056        timeout: crate::route::RouteTimeout,
9057    ) -> Route {
9058        async fn noop() -> &'static str {
9059            "ok"
9060        }
9061        Route {
9062            method,
9063            path,
9064            handler: axum::routing::get(noop),
9065            name: "noop",
9066            api_doc: crate::openapi::ApiDoc::default(),
9067            repository: None,
9068            idempotency: crate::route::RouteIdempotency::Direct,
9069            timeout,
9070            api_version: None,
9071            sunset_opt_out: false,
9072        }
9073    }
9074
9075    #[test]
9076    fn build_route_timeout_table_normalizes_method_aliases() {
9077        let override_10s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10));
9078        let routes = vec![
9079            // A GET handler also serves HEAD in axum.
9080            timeout_route(http::Method::GET, "/export", override_10s),
9081            // A `#[ws]` route records the synthetic `WS` method but the upgrade
9082            // arrives as GET.
9083            timeout_route(
9084                http::Method::from_bytes(b"WS").unwrap(),
9085                "/live",
9086                crate::route::RouteTimeout::Disabled,
9087            ),
9088            // A non-aliased method keys only itself.
9089            timeout_route(http::Method::POST, "/submit", override_10s),
9090        ];
9091
9092        let table = build_route_timeout_table(&routes, &[]);
9093
9094        // GET override is reachable via both GET and HEAD.
9095        let export = table.get("/export").expect("/export keyed");
9096        assert_eq!(export.get(&http::Method::GET), Some(&override_10s));
9097        assert_eq!(
9098            export.get(&http::Method::HEAD),
9099            Some(&override_10s),
9100            "a GET override must also cover the HEAD alias axum serves"
9101        );
9102
9103        // WS override is reachable via the GET the upgrade actually uses, and is
9104        // NOT left under the synthetic `WS` method the lookup never sees.
9105        let live = table.get("/live").expect("/live keyed");
9106        assert_eq!(
9107            live.get(&http::Method::GET),
9108            Some(&crate::route::RouteTimeout::Disabled),
9109            "a WS override must be keyed under the GET the upgrade arrives as"
9110        );
9111        assert!(
9112            live.get(&http::Method::from_bytes(b"WS").unwrap())
9113                .is_none(),
9114            "the synthetic WS method is never seen at lookup time"
9115        );
9116
9117        // A non-aliased method keys only itself — no HEAD bleed.
9118        let submit = table.get("/submit").expect("/submit keyed");
9119        assert_eq!(submit.get(&http::Method::POST), Some(&override_10s));
9120        assert!(submit.get(&http::Method::HEAD).is_none());
9121    }
9122
9123    #[test]
9124    fn build_route_timeout_table_keys_scoped_root_by_axum_matched_path() {
9125        // A scoped group whose prefix carries a trailing slash mounts its `/`
9126        // child at "/api/" in axum (verified by
9127        // `join_nested_path_matches_axum_matched_path`), so the override must be
9128        // keyed there — not at "/api" — or the runtime `MatchedPath` lookup
9129        // misses and the per-route timeout is silently never enforced.
9130        let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
9131        let make_group = |prefix: &str| crate::app::ScopedGroup {
9132            prefix: prefix.to_owned(),
9133            routes: vec![timeout_route(http::Method::GET, "/", override_5s)],
9134            source: crate::route_listing::RouteSource::User,
9135            apply_layer: Box::new(|r| r),
9136        };
9137
9138        let table = build_route_timeout_table(&[], &[make_group("/api/")]);
9139        assert_eq!(
9140            table.get("/api/").and_then(|m| m.get(&http::Method::GET)),
9141            Some(&override_5s),
9142            "trailing-slash scoped root must key the override at /api/"
9143        );
9144        assert!(
9145            table.get("/api").is_none(),
9146            "the stripped /api key would never match the runtime lookup"
9147        );
9148
9149        // The no-trailing-slash form still keys at "/api".
9150        let table = build_route_timeout_table(&[], &[make_group("/api")]);
9151        assert_eq!(
9152            table.get("/api").and_then(|m| m.get(&http::Method::GET)),
9153            Some(&override_5s),
9154        );
9155    }
9156
9157    // ----------------------------------------------------------------------
9158    // static_gate: middleware that runs before the static cache lookup (#848)
9159    // ----------------------------------------------------------------------
9160
9161    /// Build a `CustomLayerRegistration` wrapping a `from_fn` gate that
9162    /// redirects (302 → /login) any request lacking an `x-authed` header.
9163    fn redirect_gate_registration() -> crate::app::CustomLayerRegistration {
9164        let gate = axum::middleware::from_fn(
9165            |req: axum::extract::Request, next: axum::middleware::Next| async move {
9166                if req.headers().contains_key("x-authed") {
9167                    next.run(req).await
9168                } else {
9169                    http::Response::builder()
9170                        .status(StatusCode::FOUND)
9171                        .header(http::header::LOCATION, "/login")
9172                        .body(Body::empty())
9173                        .unwrap()
9174                }
9175            },
9176        );
9177        crate::app::CustomLayerRegistration {
9178            type_id: std::any::TypeId::of::<()>(),
9179            type_name: "redirect_gate",
9180            apply: Box::new(move |router| router.layer(gate)),
9181        }
9182    }
9183
9184    /// Create a minimal dist dir with `manifest.json` mapping `/` → an
9185    /// `index.html` containing the marker text, and return the temp handle
9186    /// plus the dist path.
9187    fn build_cached_dist(marker: &str) -> (tempfile::TempDir, std::path::PathBuf) {
9188        let tmp = tempfile::tempdir().expect("tempdir");
9189        let dist = tmp.path().join("dist");
9190        std::fs::create_dir_all(&dist).expect("create dist");
9191        std::fs::write(dist.join("index.html"), marker).expect("write index.html");
9192        let mut routes = std::collections::HashMap::new();
9193        routes.insert(
9194            "/".to_owned(),
9195            crate::static_gen::ManifestEntry {
9196                file: "index.html".to_owned(),
9197                revalidate: None,
9198            },
9199        );
9200        let manifest = crate::static_gen::StaticManifest {
9201            generated_at: "2026-06-14T00:00:00Z".to_owned(),
9202            autumn_version: "0.3.0".to_owned(),
9203            routes,
9204        };
9205        std::fs::write(
9206            dist.join("manifest.json"),
9207            serde_json::to_string(&manifest).expect("serialize manifest"),
9208        )
9209        .expect("write manifest");
9210        (tmp, dist)
9211    }
9212
9213    fn ctx_with_static_gate(gate: crate::app::CustomLayerRegistration) -> RouterContext {
9214        RouterContext {
9215            exception_filters: Vec::new(),
9216            scoped_groups: Vec::new(),
9217            merge_routers: Vec::new(),
9218            nest_routers: Vec::new(),
9219            custom_layers: Vec::new(),
9220            static_gate_layers: vec![gate],
9221            #[cfg(feature = "maud")]
9222            error_page_renderer: None,
9223            session_store: None,
9224            #[cfg(feature = "openapi")]
9225            openapi: None,
9226            #[cfg(feature = "mcp")]
9227            mcp: None,
9228        }
9229    }
9230
9231    #[tokio::test]
9232    async fn static_gate_runs_before_cached_static_page() {
9233        // A cached SSG page exists at "/". The static_gate must intercept the
9234        // request BEFORE the static-first middleware serves the pre-rendered
9235        // HTML, redirecting unauthenticated visitors.
9236        let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
9237        let config = AutumnConfig::default();
9238        let ctx = ctx_with_static_gate(redirect_gate_registration());
9239
9240        let app = super::try_build_router_with_static_inner(
9241            Vec::new(),
9242            &config,
9243            crate::state::AppState::for_test(),
9244            Some(dist.as_path()),
9245            ctx,
9246        )
9247        .expect("router builds");
9248
9249        // Unauthenticated: gate fires before the cached page is served.
9250        let unauthed = app
9251            .clone()
9252            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
9253            .await
9254            .unwrap();
9255        assert_eq!(
9256            unauthed.status(),
9257            StatusCode::FOUND,
9258            "static_gate must redirect before the cached page is served"
9259        );
9260        assert_eq!(
9261            unauthed.headers().get(http::header::LOCATION).unwrap(),
9262            "/login"
9263        );
9264
9265        // Authenticated: gate passes through and the cached HTML is served.
9266        let authed = app
9267            .oneshot(
9268                Request::builder()
9269                    .uri("/")
9270                    .header("x-authed", "1")
9271                    .body(Body::empty())
9272                    .unwrap(),
9273            )
9274            .await
9275            .unwrap();
9276        assert_eq!(authed.status(), StatusCode::OK);
9277        let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
9278            .await
9279            .unwrap();
9280        assert!(
9281            String::from_utf8_lossy(&body).contains("cached"),
9282            "authenticated request should receive the cached page"
9283        );
9284    }
9285
9286    #[tokio::test]
9287    async fn static_gate_runs_in_dynamic_mode() {
9288        // With no dist dir, the same gate must still run as the outermost
9289        // middleware so auth-gating code is portable across SSG and dynamic
9290        // modes.
9291        async fn dynamic_handler() -> &'static str {
9292            "dynamic"
9293        }
9294        let route = Route {
9295            method: http::Method::GET,
9296            path: "/",
9297            handler: axum::routing::get(dynamic_handler),
9298            name: "root",
9299            api_doc: crate::openapi::ApiDoc {
9300                method: "GET",
9301                path: "/",
9302                operation_id: "root",
9303                success_status: 200,
9304                ..Default::default()
9305            },
9306            repository: None,
9307            idempotency: crate::route::RouteIdempotency::Direct,
9308            timeout: crate::route::RouteTimeout::Inherit,
9309            api_version: None,
9310            sunset_opt_out: false,
9311        };
9312        let config = AutumnConfig::default();
9313        let ctx = ctx_with_static_gate(redirect_gate_registration());
9314
9315        let app = super::try_build_router_with_static_inner(
9316            vec![route],
9317            &config,
9318            crate::state::AppState::for_test(),
9319            None,
9320            ctx,
9321        )
9322        .expect("router builds");
9323
9324        let unauthed = app
9325            .clone()
9326            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
9327            .await
9328            .unwrap();
9329        assert_eq!(unauthed.status(), StatusCode::FOUND);
9330
9331        let authed = app
9332            .oneshot(
9333                Request::builder()
9334                    .uri("/")
9335                    .header("x-authed", "1")
9336                    .body(Body::empty())
9337                    .unwrap(),
9338            )
9339            .await
9340            .unwrap();
9341        assert_eq!(authed.status(), StatusCode::OK);
9342        let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
9343            .await
9344            .unwrap();
9345        assert_eq!(String::from_utf8_lossy(&body), "dynamic");
9346    }
9347
9348    #[tokio::test]
9349    async fn static_gate_redirect_carries_security_headers_ssg() {
9350        // A gate short-circuit (302) must still carry the framework security
9351        // headers — SecurityHeadersLayer wraps the gate in the SSG path.
9352        let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
9353        let config = AutumnConfig::default();
9354        let ctx = ctx_with_static_gate(redirect_gate_registration());
9355
9356        let app = super::try_build_router_with_static_inner(
9357            Vec::new(),
9358            &config,
9359            crate::state::AppState::for_test(),
9360            Some(dist.as_path()),
9361            ctx,
9362        )
9363        .expect("router builds");
9364
9365        let unauthed = app
9366            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
9367            .await
9368            .unwrap();
9369        assert_eq!(unauthed.status(), StatusCode::FOUND);
9370        // X-Content-Type-Options: nosniff is applied by SecurityHeadersLayer by
9371        // default; its presence proves the layer wraps the gate's response.
9372        assert_eq!(
9373            unauthed
9374                .headers()
9375                .get("x-content-type-options")
9376                .expect("gate redirect must carry security headers"),
9377            "nosniff"
9378        );
9379    }
9380
9381    #[tokio::test]
9382    async fn static_gate_redirect_carries_security_headers_dynamic() {
9383        // Same contract in fully-dynamic mode (no dist): SecurityHeadersLayer is
9384        // the framework's outermost layer, so a gate short-circuit still carries
9385        // HSTS/CSP/nosniff. Guards against the dynamic/SSG inconsistency.
9386        async fn dynamic_handler() -> &'static str {
9387            "dynamic"
9388        }
9389        let route = Route {
9390            method: http::Method::GET,
9391            path: "/",
9392            handler: axum::routing::get(dynamic_handler),
9393            name: "root",
9394            api_doc: crate::openapi::ApiDoc {
9395                method: "GET",
9396                path: "/",
9397                operation_id: "root",
9398                success_status: 200,
9399                ..Default::default()
9400            },
9401            repository: None,
9402            idempotency: crate::route::RouteIdempotency::Direct,
9403            timeout: crate::route::RouteTimeout::Inherit,
9404            api_version: None,
9405            sunset_opt_out: false,
9406        };
9407        let config = AutumnConfig::default();
9408        let ctx = ctx_with_static_gate(redirect_gate_registration());
9409
9410        let app = super::try_build_router_with_static_inner(
9411            vec![route],
9412            &config,
9413            crate::state::AppState::for_test(),
9414            None,
9415            ctx,
9416        )
9417        .expect("router builds");
9418
9419        let unauthed = app
9420            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
9421            .await
9422            .unwrap();
9423        assert_eq!(unauthed.status(), StatusCode::FOUND);
9424        assert_eq!(
9425            unauthed
9426                .headers()
9427                .get("x-content-type-options")
9428                .expect("dynamic gate redirect must carry security headers"),
9429            "nosniff"
9430        );
9431    }
9432
9433    #[test]
9434    fn static_gate_layer_requires_fail_closed_idempotency() {
9435        // A static_gate (e.g. a JWT/auth layer) is an opaque app layer for
9436        // idempotency: it must force fail-closed replay so a cached mutation
9437        // can't be served to a different principal sharing an Idempotency-Key.
9438        let gate = vec![redirect_gate_registration()];
9439        assert!(super::custom_layers_require_fail_closed_idempotency(&gate));
9440        // An empty set requires no fail-closed behaviour.
9441        assert!(!super::custom_layers_require_fail_closed_idempotency(&[]));
9442    }
9443}
9444#[derive(Clone, Debug)]
9445pub struct TrustedHostPolicy {
9446    rules: Arc<Vec<String>>,
9447    allow_any: bool,
9448    allow_missing_host: bool,
9449    probe_bypass_paths: Arc<std::collections::HashSet<String>>,
9450}
9451
9452impl TrustedHostPolicy {
9453    pub fn from_config(config: &AutumnConfig) -> Self {
9454        let mut rules: Vec<String> = config
9455            .security
9456            .trusted_hosts
9457            .hosts
9458            .iter()
9459            .map(|h| h.trim().to_ascii_lowercase())
9460            .map(|h| h.trim_end_matches('.').to_owned())
9461            .filter(|h| !h.is_empty())
9462            .collect();
9463        let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
9464        if !is_production {
9465            rules.extend(
9466                ["localhost", "127.0.0.1", "::1"]
9467                    .into_iter()
9468                    .map(std::borrow::ToOwned::to_owned),
9469            );
9470        }
9471        let allow_any = rules.iter().any(|h| h == "*");
9472        let probe_bypass_paths = probe_bypass_paths(config).into_iter().collect();
9473        Self {
9474            rules: Arc::new(rules),
9475            allow_any,
9476            allow_missing_host: !is_production,
9477            probe_bypass_paths: Arc::new(probe_bypass_paths),
9478        }
9479    }
9480
9481    /// Whether a request carrying no usable `Host` is allowed through. Mirrors
9482    /// `trusted_host_middleware`'s missing-host branch for callers (e.g. the MCP
9483    /// envelope) that enforce the policy outside that middleware.
9484    ///
9485    /// Only the `mcp` feature consumes this today; gated so default-feature
9486    /// builds don't flag it as dead code.
9487    #[cfg(feature = "mcp")]
9488    pub const fn allows_missing_host(&self) -> bool {
9489        self.allow_missing_host
9490    }
9491
9492    pub fn allows_host(&self, host: &str) -> bool {
9493        if self.allow_any {
9494            return true;
9495        }
9496        self.rules.iter().any(|rule| {
9497            rule.strip_prefix('.').map_or_else(
9498                || host == rule,
9499                |suffix| {
9500                    host == suffix
9501                        || host
9502                            .strip_suffix(suffix)
9503                            .is_some_and(|prefix| prefix.ends_with('.'))
9504                },
9505            )
9506        })
9507    }
9508}
9509
9510/// Metadata carrying API version, sunset opt-out, and security configuration for a route.
9511#[derive(Clone, Debug)]
9512pub struct RouteVersionMetadata {
9513    pub version: String,
9514    pub sunset_opt_out: bool,
9515    pub secured: bool,
9516    pub required_roles: &'static [&'static str],
9517    pub has_policy: bool,
9518}
9519
9520/// Middleware that handles API deprecation, sunsets, and Gone responses.
9521async fn api_versioning_middleware(
9522    state: axum::extract::State<AppState>,
9523    route_version: Option<axum::extract::Extension<RouteVersionMetadata>>,
9524    request: axum::http::Request<axum::body::Body>,
9525    next: axum::middleware::Next,
9526) -> axum::response::Response {
9527    let Some(axum::extract::Extension(meta)) = route_version else {
9528        return next.run(request).await;
9529    };
9530
9531    let clock = state.clock();
9532    let now = clock.now();
9533
9534    let versions = state.extension::<crate::app::RegisteredApiVersions>();
9535    let matching_version = versions
9536        .as_ref()
9537        .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
9538
9539    let Some(version) = matching_version else {
9540        return next.run(request).await;
9541    };
9542
9543    let is_deprecated = version.deprecated_at.is_some_and(|d| now >= d);
9544    let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
9545
9546    if is_sunset && !meta.sunset_opt_out {
9547        if meta.has_policy {
9548            return next.run(request).await;
9549        }
9550        if meta.secured {
9551            let session = request.extensions().get::<crate::session::Session>();
9552            let mut auth_failed = false;
9553            let mut auth_error = None;
9554            if let Some(session) = session {
9555                if let Err(err) = crate::auth::__check_secured_with_key(
9556                    session,
9557                    state.auth_session_key(),
9558                    meta.required_roles,
9559                )
9560                .await
9561                {
9562                    auth_failed = true;
9563                    auth_error = Some(err);
9564                }
9565            } else {
9566                auth_failed = true;
9567                auth_error = Some(crate::error::AutumnError::unauthorized_msg(
9568                    "authentication required",
9569                ));
9570            }
9571            if auth_failed {
9572                return auth_error.unwrap().into_response();
9573            }
9574        }
9575
9576        let err = crate::error::AutumnError::gone_msg(format!(
9577            "API version '{}' has been sunsetted.",
9578            meta.version
9579        ));
9580        let mut response = err.into_response();
9581        if let Some(sunset) = version.sunset_at {
9582            let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
9583            if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
9584                response.headers_mut().insert("Sunset", val);
9585            }
9586        }
9587        let deprecation_date = match (version.deprecated_at, version.sunset_at) {
9588            (Some(d), Some(s)) => Some(d.min(s)),
9589            (d, s) => d.or(s),
9590        };
9591        if let Some(date) = deprecation_date {
9592            let timestamp = date.timestamp();
9593            if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
9594                response.headers_mut().insert("Deprecation", val);
9595            }
9596        }
9597        return response;
9598    }
9599
9600    let mut response = next.run(request).await;
9601
9602    if is_deprecated || is_sunset {
9603        let deprecation_date = match (version.deprecated_at, version.sunset_at) {
9604            (Some(d), Some(s)) => Some(d.min(s)),
9605            (d, s) => d.or(s),
9606        };
9607        if let Some(date) = deprecation_date {
9608            let timestamp = date.timestamp();
9609            if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
9610                response.headers_mut().insert("Deprecation", val);
9611            }
9612        }
9613    }
9614    if let Some(sunset) = version.sunset_at.filter(|_| is_deprecated || is_sunset) {
9615        let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
9616        if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
9617            response.headers_mut().insert("Sunset", val);
9618        }
9619    }
9620
9621    response
9622}
9623
9624/// Helper function to perform a sunset check during dynamic handler execution.
9625/// Returns a `410 Gone` response if the route version has sunsetted.
9626#[must_use]
9627pub fn check_sunset(
9628    state: &crate::state::AppState,
9629    meta: &RouteVersionMetadata,
9630) -> Option<axum::response::Response> {
9631    let clock = state.clock();
9632    let now = clock.now();
9633
9634    let versions = state.extension::<crate::app::RegisteredApiVersions>();
9635    let matching_version = versions
9636        .as_ref()
9637        .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
9638
9639    let version = matching_version?;
9640    let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
9641
9642    if is_sunset && !meta.sunset_opt_out {
9643        let err = crate::error::AutumnError::gone_msg(format!(
9644            "API version '{}' has been sunsetted.",
9645            meta.version
9646        ));
9647        let mut response = axum::response::IntoResponse::into_response(err);
9648        if let Some(sunset) = version.sunset_at {
9649            let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
9650            if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
9651                response.headers_mut().insert("Sunset", val);
9652            }
9653        }
9654        let deprecation_date = match (version.deprecated_at, version.sunset_at) {
9655            (Some(d), Some(s)) => Some(d.min(s)),
9656            (d, s) => d.or(s),
9657        };
9658        if let Some(date) = deprecation_date {
9659            let timestamp = date.timestamp();
9660            if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
9661                response.headers_mut().insert("Deprecation", val);
9662            }
9663        }
9664        return Some(response);
9665    }
9666
9667    None
9668}
9669
9670#[cfg(all(test, feature = "htmx"))]
9671mod idiomorph_tests {
9672    use super::*;
9673    use http::StatusCode;
9674    use http_body_util::BodyExt;
9675
9676    #[tokio::test]
9677    async fn idiomorph_handler_returns_js_with_correct_headers() {
9678        let response = idiomorph_handler().await;
9679
9680        assert_eq!(response.status(), StatusCode::OK);
9681
9682        let ct = response
9683            .headers()
9684            .get(http::header::CONTENT_TYPE)
9685            .and_then(|v| v.to_str().ok())
9686            .unwrap_or("");
9687        assert_eq!(ct, "application/javascript");
9688
9689        let cc = response
9690            .headers()
9691            .get(http::header::CACHE_CONTROL)
9692            .and_then(|v| v.to_str().ok())
9693            .unwrap_or("");
9694        // The idiomorph URL is not content-fingerprinted, so the response must
9695        // revalidate rather than advertise a year-long `immutable` cache. This
9696        // guards against returning clients running a stale copy after the
9697        // vendored bytes change.
9698        assert!(
9699            cc.contains("must-revalidate"),
9700            "expected revalidating cache-control, got: {cc}"
9701        );
9702        assert!(
9703            !cc.contains("immutable"),
9704            "cache-control must not be immutable for a non-fingerprinted URL, got: {cc}"
9705        );
9706
9707        // A weak, content-derived ETag lets caches revalidate (and pick up new
9708        // bytes when the script changes). It is weak rather than strong because
9709        // compression middleware may re-encode this response after the handler
9710        // attaches the validator, so the identity/gzip/br variants share a tag
9711        // despite differing byte streams.
9712        let etag = response
9713            .headers()
9714            .get(http::header::ETAG)
9715            .and_then(|v| v.to_str().ok())
9716            .unwrap_or("");
9717        assert!(
9718            etag.starts_with("W/\"idiomorph-") && etag.ends_with('"'),
9719            "expected a weak quoted idiomorph ETag, got: {etag}"
9720        );
9721
9722        let body = response.into_body().collect().await.unwrap().to_bytes();
9723        assert!(!body.is_empty(), "idiomorph JS body must be non-empty");
9724    }
9725}
9726
9727#[cfg(test)]
9728mod proptests {
9729    //! Property-based invariants for the low-level path/host string helpers.
9730    //! These are `pub(crate)` (only reachable via the `cfg(fuzzing)` seam
9731    //! module), so they are exercised here in-crate rather than from an
9732    //! integration test.
9733    use super::*;
9734    use proptest::prelude::*;
9735
9736    proptest! {
9737        #![proptest_config(ProptestConfig::with_cases(256))]
9738
9739        /// Mounting the root child (`"/"` or empty) is the identity on a
9740        /// non-empty prefix, and idempotent: re-mounting the root child on the
9741        /// result leaves it unchanged. (An empty prefix collapses to `"/"`.)
9742        #[test]
9743        fn join_nested_path_root_child_is_identity(prefix in "/?[a-z0-9/]{0,20}", root in prop::sample::select(vec!["/", ""])) {
9744            let once = join_nested_path(&prefix, root);
9745            let expected = if prefix.is_empty() { "/".to_owned() } else { prefix };
9746            prop_assert_eq!(&once, &expected);
9747            let twice = join_nested_path(&once, root);
9748            prop_assert_eq!(once, twice);
9749        }
9750
9751        /// `join_nested_path` never introduces a doubled slash at the join seam
9752        /// for well-formed single-segment children.
9753        #[test]
9754        fn join_nested_path_no_double_slash_at_seam(prefix in "/[a-z0-9]{1,8}/?", child in "/[a-z0-9]{1,8}") {
9755            let joined = join_nested_path(&prefix, &child);
9756            prop_assert!(!joined.contains("//"), "unexpected `//` in {joined:?}");
9757        }
9758
9759        /// `extract_host_without_port` never panics on arbitrary input and,
9760        /// when it returns something, that something is a substring of the
9761        /// trimmed input (it only ever strips a port / brackets, never invents
9762        /// characters).
9763        #[test]
9764        fn extract_host_without_port_never_panics(header in ".*") {
9765            if let Some(host) = extract_host_without_port(&header) {
9766                prop_assert!(header.contains(host));
9767            }
9768        }
9769
9770        /// `path_matches_route_prefix` never panics and is reflexive: a path
9771        /// always matches itself as a prefix.
9772        #[test]
9773        fn path_matches_route_prefix_reflexive(path in ".*") {
9774            prop_assert!(path_matches_route_prefix(&path, &path));
9775        }
9776
9777        /// `path_matches_route_prefix` is consistent with its documented
9778        /// contract: a match means either exact equality or a `/`-delimited
9779        /// boundary immediately after the prefix.
9780        #[test]
9781        fn path_matches_route_prefix_boundary(path in "/?[a-z0-9/]{0,24}", prefix in "/?[a-z0-9/]{0,24}") {
9782            if path_matches_route_prefix(&path, &prefix) {
9783                let boundary_ok = path == prefix
9784                    || path.strip_prefix(&prefix).is_some_and(|rest| rest.starts_with('/'));
9785                prop_assert!(boundary_ok, "match without boundary: path={path:?} prefix={prefix:?}");
9786            }
9787        }
9788    }
9789
9790    // `extract_path_params` (openapi-only) never panics on arbitrary input and
9791    // only ever returns non-empty, brace-free parameter names.
9792    #[cfg(feature = "openapi")]
9793    proptest! {
9794        #![proptest_config(ProptestConfig::with_cases(256))]
9795
9796        #[test]
9797        fn extract_path_params_never_panics(path in ".*") {
9798            for name in extract_path_params(&path) {
9799                prop_assert!(!name.is_empty());
9800                let has_brace = name.contains('{') || name.contains('}');
9801                prop_assert!(!has_brace, "param name should be brace-free: {name:?}");
9802            }
9803        }
9804
9805        /// Brace-dense variant of the invariant above. `.*` makes brace
9806        /// characters astronomically rare, so unbalanced-brace inputs like
9807        /// `"{{}"` (the #1721 regression) only surface via lucky CI seeds. This
9808        /// strategy draws exclusively from brace/colon/letter characters so
9809        /// malformed braces are exercised on nearly every case, and a committed
9810        /// regression seed (proptest-regressions/router.txt) pins a brace-dense
9811        /// input (which replays to `"{{iw:}"` under this strategy) that trips the
9812        /// pre-fix brace-in-name bug deterministically.
9813        #[test]
9814        fn extract_path_params_brace_inputs_are_brace_free(path in "[{}a-z:]{0,6}") {
9815            for name in extract_path_params(&path) {
9816                prop_assert!(!name.is_empty());
9817                let has_brace = name.contains('{') || name.contains('}');
9818                prop_assert!(!has_brace, "param name should be brace-free for {path:?}: {name:?}");
9819            }
9820        }
9821    }
9822}