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;
13use crate::error_pages::{self, SharedRenderer};
14use crate::extract::State;
15use crate::idempotency::{IdempotencyLayer, IdempotencyStore, MemoryIdempotencyStore};
16use crate::middleware::RequestIdLayer;
17use crate::middleware::dev;
18use crate::middleware::exception_filter::{
19    ExceptionFilter, ExceptionFilterLayer, ProblemDetailsFilter,
20};
21use crate::route::Route;
22use crate::state::AppState;
23use axum::middleware::Next;
24use axum::response::IntoResponse;
25use http::{Request, StatusCode};
26use thiserror::Error;
27
28pub const DEFAULT_FAVICON_PATH: &str = "/favicon.ico";
29
30/// Errors that can occur during the router build process.
31///
32/// These errors are typically fatal and represent configuration or routing
33/// definition issues that must be fixed before the application can start.
34#[derive(Debug, Error, PartialEq, Eq)]
35pub enum RouterBuildError {
36    /// The session backend configuration is invalid (e.g. Redis without a URL).
37    #[error("invalid session backend configuration: {0}")]
38    InvalidSessionBackend(#[from] crate::session::SessionBackendConfigError),
39    /// The idempotency backend configuration is invalid.
40    #[error("invalid idempotency backend configuration: {0}")]
41    #[allow(dead_code)] // constructed only in the `redis` feature path
42    InvalidIdempotencyBackend(String),
43    /// A user-defined route conflicts with a framework-provided route.
44    #[error("framework route overlap at {path}: {existing} conflicts with {incoming}")]
45    FrameworkRouteOverlap {
46        /// The HTTP path where the overlap occurred.
47        path: String,
48        /// The name of the existing framework route.
49        existing: &'static str,
50        /// The name of the incoming user route.
51        incoming: &'static str,
52    },
53    /// An `OpenApiConfig` path (e.g. `openapi_json_path` or
54    /// `swagger_ui_path`) is not a valid route path (must start with `/`
55    /// and be non-empty).
56    #[cfg(feature = "openapi")]
57    #[error("invalid OpenAPI {field} path: {value:?} (must start with '/' and be non-empty)")]
58    InvalidOpenApiPath {
59        /// Which config field carried the invalid path.
60        field: &'static str,
61        /// The offending value from the user's config.
62        value: String,
63    },
64    /// `openapi_json_path` and `swagger_ui_path` collide on the same
65    /// URL. Mounting both would cause axum to panic on overlapping
66    /// method routes at startup.
67    #[cfg(feature = "openapi")]
68    #[error(
69        "openapi_json_path and swagger_ui_path both resolve to {path:?}; they must differ or `swagger_ui_path` must be `None`"
70    )]
71    DuplicateOpenApiPath {
72        /// The path that both fields pointed at.
73        path: String,
74    },
75    /// An `OpenAPI` mount path overlaps with an existing `GET` handler,
76    /// which would panic at `axum::Router::merge` time.
77    #[cfg(feature = "openapi")]
78    #[error(
79        "OpenAPI {field} path {path:?} collides with an existing GET route; choose a different `OpenApiConfig::{field}`"
80    )]
81    OpenApiPathCollision {
82        /// Which config field carried the colliding path.
83        field: &'static str,
84        /// The colliding path.
85        path: String,
86    },
87    /// A route is annotated with an API version that is not registered.
88    #[error("route '{route_name}' uses unregistered API version '{version}'")]
89    UnregisteredApiVersion { route_name: String, version: String },
90    /// The MCP mount path (from [`AppBuilder::mount_mcp`](crate::app::AppBuilder::mount_mcp))
91    /// is not a valid route path. axum requires paths to start with `/`, so an
92    /// invalid path is surfaced here rather than panicking at mount time.
93    #[cfg(feature = "mcp")]
94    #[error("invalid MCP mount path: {value:?} (must start with '/' and be non-empty)")]
95    InvalidMcpPath {
96        /// The offending mount path.
97        value: String,
98    },
99    /// The MCP mount path collides with an existing application route at the
100    /// same path. Mounting the MCP endpoint there would panic at
101    /// `axum::Router::merge` time on overlapping method routes, so this is
102    /// surfaced as a recoverable error instead.
103    #[cfg(feature = "mcp")]
104    #[error(
105        "MCP mount path {path:?} collides with an existing {method} route; choose a different `mount_mcp` path"
106    )]
107    McpPathCollision {
108        /// The colliding mount path.
109        path: String,
110        /// The HTTP method of the existing route at that path.
111        method: String,
112    },
113}
114
115/// Build the fully-configured Axum router from routes, config, and state.
116///
117/// Extracted from `AppBuilder::run` so the router construction logic is
118/// testable without binding a real TCP listener.
119///
120/// # Panics
121///
122/// Panics when framework router assembly encounters invalid configuration.
123/// Use [`try_build_router`] to handle configuration errors explicitly.
124#[allow(dead_code)]
125pub fn build_router(
126    route_list: Vec<Route>,
127    config: &AutumnConfig,
128    state: AppState,
129) -> axum::Router {
130    try_build_router(route_list, config, state)
131        .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
132}
133
134/// Checked variant of [`build_router`] that returns configuration errors
135/// instead of panicking.
136///
137/// # Errors
138///
139/// Returns [`RouterBuildError`] when router assembly encounters invalid
140/// framework configuration, such as an unusable session backend.
141pub struct RouterContext {
142    pub exception_filters: Vec<Arc<dyn ExceptionFilter>>,
143    pub scoped_groups: Vec<ScopedGroup>,
144    pub merge_routers: Vec<axum::Router<AppState>>,
145    pub nest_routers: Vec<(String, axum::Router<AppState>)>,
146    /// Custom Tower layers registered via
147    /// [`AppBuilder::layer`](crate::app::AppBuilder::layer). Applied inside
148    /// [`RequestIdLayer`] and the session layer on the ingress path so user
149    /// middleware observes the generated request ID and session context.
150    ///
151    /// **SSG/ISG mode trade-off**: when `dist_dir` is active, layers are
152    /// moved outside the static-first middleware so they can process
153    /// pre-rendered responses (e.g. compression).  As a side effect they also
154    /// run *before* `RequestIdLayer`, session, `MetricsLayer`, and
155    /// `ExceptionFilterLayer` for all requests (static and dynamic).  Layers
156    /// that depend on extensions set by those framework layers — such as the
157    /// request ID or session data — will not find them in SSG mode.
158    pub custom_layers: Vec<crate::app::CustomLayerRegistration>,
159    pub error_page_renderer: Option<SharedRenderer>,
160    /// Custom session store installed via
161    /// [`AppBuilder::with_session_store`](crate::app::AppBuilder::with_session_store).
162    /// When `Some`, [`apply_session_layer`](crate::session::apply_session_layer)
163    /// uses it directly and skips the config-driven backend selection.
164    pub session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
165    /// `OpenAPI` generation configuration. When `Some`, the router mounts
166    /// an `openapi.json` endpoint and (optionally) a Swagger UI page
167    /// describing the application's routes.
168    ///
169    /// Gated behind the `openapi` feature.
170    #[cfg(feature = "openapi")]
171    pub openapi: Option<crate::openapi::OpenApiConfig>,
172    /// MCP (Model Context Protocol) runtime config. When `Some`, the router
173    /// mounts a Streamable-HTTP MCP endpoint that projects opted-in routes as
174    /// agent-callable tools and dispatches `tools/call` through the real
175    /// handler pipeline.
176    ///
177    /// Gated behind the `mcp` feature.
178    #[cfg(feature = "mcp")]
179    pub mcp: Option<crate::mcp::McpRuntime>,
180}
181
182/// Checked variant of [`build_router`] that returns configuration errors
183/// instead of panicking.
184///
185/// # Errors
186///
187/// Returns [`RouterBuildError`] when router assembly encounters invalid
188/// framework configuration, such as an unusable session backend.
189pub fn try_build_router(
190    route_list: Vec<Route>,
191    config: &AutumnConfig,
192    state: AppState,
193) -> Result<axum::Router, RouterBuildError> {
194    let startup_barrier_state = state.clone();
195    let router = try_build_router_inner(
196        route_list,
197        config,
198        state,
199        RouterContext {
200            exception_filters: Vec::new(),
201            scoped_groups: Vec::new(),
202            merge_routers: Vec::new(),
203            nest_routers: Vec::new(),
204            custom_layers: Vec::new(),
205            error_page_renderer: None,
206            session_store: None,
207            #[cfg(feature = "openapi")]
208            openapi: None,
209            #[cfg(feature = "mcp")]
210            mcp: None,
211        },
212    )?;
213    Ok(apply_startup_barrier(
214        router,
215        config,
216        &startup_barrier_state,
217    ))
218}
219
220/// Build a router that includes user-supplied raw Axum routers.
221///
222/// Like [`build_router`], but also merges and nests additional raw
223/// Axum routers. This is primarily useful for integration testing;
224/// in production, use [`AppBuilder::merge`](crate::app::AppBuilder::merge) and [`AppBuilder::nest`](crate::app::AppBuilder::nest).
225///
226/// # Panics
227///
228/// Panics when framework router assembly encounters invalid configuration.
229/// Use [`try_build_router_merged`] to handle configuration errors explicitly.
230#[allow(dead_code)]
231pub fn build_router_merged(
232    route_list: Vec<Route>,
233    config: &AutumnConfig,
234    state: AppState,
235    merge_routers: Vec<axum::Router<AppState>>,
236    nest_routers: Vec<(String, axum::Router<AppState>)>,
237) -> axum::Router {
238    try_build_router_merged(route_list, config, state, merge_routers, nest_routers)
239        .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
240}
241
242/// Checked variant of [`build_router_merged`] that returns configuration
243/// errors instead of panicking.
244///
245/// # Errors
246///
247/// Returns [`RouterBuildError`] when router assembly encounters invalid
248/// framework configuration, such as an unusable session backend.
249#[allow(dead_code)]
250pub fn try_build_router_merged(
251    route_list: Vec<Route>,
252    config: &AutumnConfig,
253    state: AppState,
254    merge_routers: Vec<axum::Router<AppState>>,
255    nest_routers: Vec<(String, axum::Router<AppState>)>,
256) -> Result<axum::Router, RouterBuildError> {
257    let startup_barrier_state = state.clone();
258    let router = try_build_router_inner(
259        route_list,
260        config,
261        state,
262        RouterContext {
263            exception_filters: Vec::new(),
264            scoped_groups: Vec::new(),
265            merge_routers,
266            nest_routers,
267            custom_layers: Vec::new(),
268            error_page_renderer: None,
269            session_store: None,
270            #[cfg(feature = "openapi")]
271            openapi: None,
272            #[cfg(feature = "mcp")]
273            mcp: None,
274        },
275    )?;
276    Ok(apply_startup_barrier(
277        router,
278        config,
279        &startup_barrier_state,
280    ))
281}
282
283pub fn try_build_router_inner(
284    route_list: Vec<Route>,
285    config: &AutumnConfig,
286    state: AppState,
287    ctx: RouterContext,
288) -> Result<axum::Router, RouterBuildError> {
289    let router = build_router_pre_state(route_list, config, &state, ctx, None)?;
290    Ok(router.with_state(state))
291}
292
293/// Prepared MCP exposure carried through `build_router_pre_state`: the mount
294/// path, the derived tool catalog, and the optional whole-endpoint auth layer.
295#[cfg(feature = "mcp")]
296type McpPrepared = (
297    String,
298    Vec<crate::mcp::McpToolInfo>,
299    Option<crate::mcp::McpEndpointLayer>,
300);
301
302/// Like [`try_build_router_inner`] but returns `Router<AppState>` before
303/// [`with_state`](axum::Router::with_state) is called.  Used by
304/// [`try_build_router_with_static_inner`] so that user layers and the static
305/// file middleware can be applied to the typed router before state is baked in.
306#[allow(clippy::too_many_lines)]
307fn build_router_pre_state(
308    route_list: Vec<Route>,
309    config: &AutumnConfig,
310    state: &AppState,
311    #[cfg_attr(not(feature = "mcp"), allow(unused_mut))] mut ctx: RouterContext,
312    // When custom_layers are extracted from ctx before this call (SSG path),
313    // the caller pre-computes the flag so the idempotency selector still sees
314    // the real layer list even though ctx.custom_layers is empty.
315    opaque_app_layers_override: Option<bool>,
316) -> Result<axum::Router<AppState>, RouterBuildError> {
317    // Verify registered API versions
318    let versions = state.extension::<crate::app::RegisteredApiVersions>();
319    let registered_versions: std::collections::HashSet<&str> = versions
320        .as_ref()
321        .map(|v| v.0.iter().map(|av| av.version.as_str()).collect())
322        .unwrap_or_default();
323
324    let check_route_version = |route: &Route| -> Result<(), RouterBuildError> {
325        if let Some(version) = route
326            .api_version
327            .filter(|ver| !registered_versions.contains(*ver))
328        {
329            return Err(RouterBuildError::UnregisteredApiVersion {
330                route_name: route.name.to_string(),
331                version: version.to_string(),
332            });
333        }
334        Ok(())
335    };
336
337    for route in &route_list {
338        check_route_version(route)?;
339    }
340    for group in &ctx.scoped_groups {
341        for route in &group.routes {
342            check_route_version(route)?;
343        }
344    }
345
346    // Fail-fast if an OpenAPI mount path collides with a user or
347    // framework GET route — axum panics on overlapping method routes,
348    // so surface this as a recoverable error before we start merging.
349    #[cfg(feature = "openapi")]
350    reject_openapi_path_collisions(
351        ctx.openapi.as_ref(),
352        &route_list,
353        &ctx.scoped_groups,
354        &ctx.merge_routers,
355        &ctx.nest_routers,
356        config,
357    )?;
358
359    // Build the OpenAPI spec BEFORE moving the routes into axum, because
360    // group_and_mount_routes consumes the Route list.
361    #[cfg(feature = "openapi")]
362    let openapi_router = build_openapi_router(
363        &route_list,
364        &ctx.scoped_groups,
365        ctx.openapi.as_ref(),
366        &config.session.cookie_name,
367        versions.as_ref().map_or(&[], |v| v.0.as_slice()),
368    )?;
369
370    // Prepare MCP exposure *before* `route_list` is moved into axum below.
371    // Validate the mount path up front (a typo like `"mcp"` surfaces as a
372    // recoverable error, mirroring the OpenAPI path validation, instead of an
373    // axum panic), derive the tool catalog, and carry the optional endpoint
374    // auth layer to be applied once the router is assembled.
375    #[cfg(feature = "mcp")]
376    let mcp_prepared: Option<McpPrepared> = if let Some(rt) = ctx.mcp.take() {
377        let path = rt.mount_path.as_str();
378        // The mount path must be a single static endpoint: reject empty,
379        // non-absolute, doubled-slash, and dynamic (`{capture}` / `{*rest}`)
380        // paths so MCP cannot shadow a whole path class and so the exact-path
381        // collision preflight reserves the concrete URL it actually matches.
382        // Colon-prefixed segments (`/:mcp`, axum 0.7 capture syntax) are also
383        // rejected: axum 0.8's `Router::route` panics on them during assembly
384        // (`validate_v07_paths`), so catching them here yields the recoverable
385        // `InvalidMcpPath` error instead of a startup crash.
386        if path.is_empty()
387            || !path.starts_with('/')
388            || path.contains("//")
389            || path.contains('{')
390            || path.contains('*')
391            || path.split('/').any(|segment| segment.starts_with(':'))
392        {
393            return Err(RouterBuildError::InvalidMcpPath {
394                value: rt.mount_path,
395            });
396        }
397        // The MCP endpoint mounts GET+POST at `mount_path`. If a user, framework,
398        // or OpenAPI route already owns that exact path, the later `merge` would
399        // panic on overlapping method routes; surface it as a recoverable error
400        // first (mirroring the OpenAPI collision preflight).
401        reject_mcp_path_collisions(
402            path,
403            &route_list,
404            &ctx.scoped_groups,
405            config,
406            ctx.openapi.as_ref(),
407            &ctx.merge_routers,
408            &ctx.nest_routers,
409        )?;
410        let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
411        // Pass the app's OpenAPI config (if any) so MCP tool `inputSchema`s
412        // reuse the same registered component schemas as the served spec.
413        let tools = crate::mcp::derive_tools(&docs, rt.expose_all, ctx.openapi.as_ref());
414        Some((rt.mount_path, tools, rt.endpoint_layer))
415    } else {
416        None
417    };
418
419    let idempotency_layers = build_idempotency_layers(config, state)?;
420    let opaque_app_layers_present = opaque_app_layers_override
421        .unwrap_or_else(|| custom_layers_require_fail_closed_idempotency(&ctx.custom_layers));
422    let mut router = group_and_mount_routes(
423        route_list,
424        idempotency_layers.as_ref(),
425        opaque_app_layers_present,
426        state,
427    );
428
429    let dev_reload_enabled = dev::is_enabled_with_env(&crate::config::OsEnv);
430
431    router = mount_framework_routes(router, config, dev_reload_enabled);
432
433    let (mounted_probe_paths, router_with_probes) = mount_probe_endpoints(router, config);
434    router = router_with_probes;
435
436    router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
437
438    #[cfg(feature = "openapi")]
439    if let Some(openapi_router) = openapi_router {
440        router = router.merge(openapi_router);
441    }
442
443    // Static file serving from project's static/ directory.
444    // Fingerprinted assets (e.g. `autumn.a1b2c3d4.css`) are served with
445    // `Cache-Control: public, max-age=31536000, immutable`; all other static
446    // files use the default browser policy.
447    let env = crate::config::OsEnv;
448    let static_dir = crate::app::project_dir("static", &env);
449    router = router.nest_service("/static", tower_http::services::ServeDir::new(&static_dir));
450    router = router.layer(axum::middleware::from_fn(asset_cache_control));
451
452    router = mount_scoped_groups(
453        router,
454        ctx.scoped_groups,
455        idempotency_layers.as_ref(),
456        state,
457    );
458
459    router = mount_raw_routers(
460        router,
461        ctx.merge_routers,
462        ctx.nest_routers,
463        idempotency_layers.as_ref(),
464    );
465
466    router = apply_middleware(
467        router,
468        config,
469        state,
470        ctx.exception_filters,
471        ctx.custom_layers,
472        ctx.error_page_renderer,
473        ctx.session_store,
474    )?;
475
476    if dev_reload_enabled {
477        router = router
478            .layer(axum::middleware::from_fn(dev::disable_static_cache))
479            .layer(axum::middleware::from_fn(dev::inject_live_reload));
480    }
481
482    // Dev request inspector: mount UI and apply recording middleware.
483    // Only active when profile = "dev"; returns 404 for all other profiles.
484    let is_dev_profile = matches!(config.profile.as_deref(), Some("dev" | "development"));
485    if is_dev_profile {
486        // Capture the matched route pattern for the dev error overlay.
487        // Applied as a route_layer so MatchedPath is already set when this runs.
488        router = router.route_layer(axum::middleware::from_fn(
489            crate::middleware::dev::capture_matched_path_middleware,
490        ));
491    }
492    if is_dev_profile {
493        let buf = crate::inspector::InspectorBuffer::new(config.dev.inspector_capacity);
494        let inspector_path = config.dev.inspector_path.clone();
495        let threshold = config.dev.inspector_n_plus_one_threshold;
496
497        // Mount the inspector UI routes.
498        router = router.merge(crate::inspector::inspector_router(
499            buf.clone(),
500            &inspector_path,
501        ));
502        tracing::debug!(
503            path = %inspector_path,
504            "Mounted dev request inspector"
505        );
506
507        // Apply the recording middleware (outermost layer so it captures
508        // all routes). Self-excludes inspector's own path prefix.
509        let layer = crate::inspector::InspectorLayer::new(buf, threshold, inspector_path)
510            .with_session_cookie_name(config.session.cookie_name.clone());
511        router = router.layer(layer);
512    }
513
514    #[cfg(feature = "oauth2")]
515    let router = router.layer(axum::middleware::from_fn_with_state(
516        state.clone(),
517        http_interceptor_middleware,
518    ));
519
520    // Mount the MCP endpoint last so its dispatch target — a clone of the
521    // fully-assembled router with state applied — traverses the exact same
522    // routes, layers, and middleware an HTTP request would. The clone is
523    // taken *before* the MCP route is added, so `tools/call` never recurses
524    // into the MCP endpoint itself.
525    //
526    // KNOWN LIMITATION (static/ISR mode): when an app has a `dist` manifest,
527    // `try_build_router_with_static_inner` drains the global custom layers
528    // (`AppBuilder::layer`) and applies them *outside* the static-first
529    // middleware — i.e. after this builder returns. This dispatch clone is
530    // built here, before that, so a `tools/call` replay does not pass through
531    // those outer custom layers (it would in the non-static path, where they
532    // are applied via `apply_middleware` before the clone is taken). Route-level
533    // guards and `#[secured]` dispatch through this clone and so still apply;
534    // only hand-rolled global `.layer(...)` middleware is skipped for MCP calls
535    // in static mode. Restoring full parity would require making custom-layer
536    // appliers re-usable (they are `FnOnce` today), so this is left documented
537    // rather than fixed for that narrow combination.
538    #[cfg(feature = "mcp")]
539    let router = if let Some((mount_path, tools, endpoint_layer)) = mcp_prepared {
540        let dispatch = router.clone().with_state(state.clone());
541        // For header-based tenancy, forward the configured tenant header on
542        // dispatch so tenant-scoped tools resolve the same tenant a direct HTTP
543        // call would. Other sources key off already-forwarded headers/Host.
544        let tenant_header = (config.tenancy.enabled && config.tenancy.source == "header")
545            .then(|| config.tenancy.header_name.clone());
546        let wiring = crate::mcp::McpWiring {
547            // The CORS config drives the cross-origin Origin allowlist and the
548            // endpoint's own OPTIONS preflight responses.
549            cors: config.cors.clone(),
550            // The same-origin shortcut is gated on the app's trusted-Host
551            // policy so it can't be abused for DNS rebinding.
552            trusted_hosts: TrustedHostPolicy::from_config(config),
553            tenant_header,
554            // Forward the configured CSRF header (default `x-csrf-token`) so
555            // customized CsrfConfig::token_header deployments work via MCP.
556            csrf_header: config.security.csrf.token_header.to_ascii_lowercase(),
557            // The envelope is rate-limited below iff rate limiting is enabled;
558            // when so, a tools/call is counted there and its replay is exempted
559            // from the dispatch pipeline's limiter (avoiding double-counting).
560            envelope_rate_limited: config.security.rate_limit.enabled,
561        };
562        let mut mcp_router =
563            crate::mcp::build_mcp_router(&mount_path, tools, dispatch, wiring, endpoint_layer);
564        // Gate the envelope under maintenance mode, mirroring the layer
565        // `apply_middleware` installs for direct routes. The `/mcp` router is
566        // merged after that layer, so without this `initialize`/`tools/list`
567        // would keep serving the tool catalog during maintenance (the
568        // `tools/call` replay is already gated — the dispatch clone carries the
569        // layer). Applied before the `TrustedProxiesLayer` below so it is inner
570        // to it: the maintenance IP allow-list then reads the proxy-resolved
571        // identity, exactly as the direct-route layer does, instead of a
572        // spoofable raw `X-Forwarded-For`.
573        mcp_router = mcp_router.layer(build_maintenance_layer(config, state));
574        // Stamp `ResolvedClientIdentity` on the *outer* `/mcp` request too. The
575        // MCP route is merged after `apply_middleware`, so the centralized
576        // `TrustedProxiesLayer` above does not wrap it; without this, the
577        // endpoint's own DNS-rebinding / same-origin check would fall back to
578        // the raw (possibly proxy-rewritten) `Host` and wrongly 403 a
579        // same-origin browser client behind a TLS-terminating proxy. The
580        // dispatch clone already carries its own copy of this layer.
581        mcp_router = apply_trusted_proxies_middleware(mcp_router, config);
582        // The MCP route is merged after `apply_upload_middleware`, so axum's
583        // built-in 2 MiB `DefaultBodyLimit` — not the app's configured limit —
584        // would otherwise govern the `tools/call` envelope's `Bytes` body. Apply
585        // the same cap a direct JSON endpoint gets so larger-but-valid tool
586        // payloads aren't rejected before dispatch.
587        mcp_router = mcp_router.layer(axum::extract::DefaultBodyLimit::max(
588            config.security.upload.max_request_size_bytes,
589        ));
590        // Rate-limit the envelope so `secure_mcp` auth rejections — which never
591        // reach the dispatch clone's limiter — are throttled (credential
592        // guessing otherwise consumes no per-client bucket). A successful
593        // tools/call is counted once here and replayed with `RateLimitExempt`,
594        // so it isn't double-counted by the dispatch pipeline's own limiter.
595        // No-op when rate limiting is disabled (matching `envelope_rate_limited`).
596        //
597        // KNOWN LIMITATION (key_strategy = AuthenticatedPrincipal + session
598        // auth): the envelope keys on the IP fallback because the session layer
599        // — which `populate_rate_limit_principal` reads the principal from — is
600        // applied inside `apply_middleware` and does not wrap this late-merged
601        // router, so no `RateLimitPrincipal` is resolved here. Because the
602        // tools/call replay is then exempted, the dispatch clone's
603        // principal-aware limiter is skipped too, so a session-authenticated MCP
604        // call does not consume the same per-user bucket a direct request would
605        // (the framework only derives `RateLimitPrincipal` from the session).
606        mcp_router = apply_rate_limit_middleware(mcp_router, config, state);
607        // Security headers (HSTS/CSP/etc.), mirroring the `SecurityHeadersLayer`
608        // `apply_middleware` installs for direct routes. The `/mcp` router is
609        // merged after that layer, so without this the envelope's responses —
610        // `initialize`/`tools/list`, auth 401/403, and rate-limit 429 — would
611        // ship without the configured `security.headers` every direct route
612        // carries. (The `tools/call` replay's headers are produced on the
613        // dispatch clone and discarded when `serve_mcp` rebuilds the JSON-RPC
614        // response, so the envelope needs its own copy.)
615        mcp_router = mcp_router.layer(crate::security::SecurityHeadersLayer::from_config(
616            &config.security.headers,
617        ));
618        // CORS grant outermost so every response — including auth 401/403, the
619        // 413 body-limit rejection, and a 429 from the limiter above, all
620        // produced before `serve_mcp` runs — is readable by an allowlisted
621        // browser client instead of being masked as a CORS failure.
622        mcp_router = crate::mcp::apply_mcp_cors_layer(mcp_router, &config.cors);
623        router.merge(mcp_router)
624    } else {
625        router
626    };
627
628    Ok(router)
629}
630
631/// Parse `{name}` captures from a route path.
632///
633/// Mirrors the compile-time extractor in `autumn_macros::api_doc` so
634/// runtime spec assembly (which sees scope prefixes that the macro
635/// never does) produces consistent parameter lists.
636#[cfg(feature = "openapi")]
637pub fn extract_path_params(path: &str) -> Vec<String> {
638    let mut out = Vec::new();
639    let mut remaining = path;
640
641    while let Some(start) = remaining.find('{') {
642        let after_brace = &remaining[start + 1..];
643        let Some(end_rel) = after_brace.find('}') else {
644            break;
645        };
646
647        let inner = &after_brace[..end_rel];
648        let name = inner.split(':').next().unwrap_or(inner).trim();
649        if !name.is_empty() {
650            out.push(name.to_owned());
651        }
652
653        remaining = &after_brace[end_rel + 1..];
654    }
655
656    out
657}
658
659/// Handler that dynamically constructs the `OpenAPI` specification document per request
660/// so deprecation and sunset statuses do not go stale.
661#[cfg(feature = "openapi")]
662async fn serve_openapi_spec(
663    state: axum::extract::State<AppState>,
664    axum::extract::Extension(config): axum::extract::Extension<
665        std::sync::Arc<crate::openapi::OpenApiConfig>,
666    >,
667    axum::extract::Extension(docs): axum::extract::Extension<
668        std::sync::Arc<Vec<crate::openapi::ApiDoc>>,
669    >,
670) -> impl axum::response::IntoResponse {
671    use axum::response::IntoResponse;
672    let refs: Vec<&crate::openapi::ApiDoc> = docs.iter().collect();
673    let now = state.clock().now();
674    let spec = crate::openapi::generate_spec_at(&config, &refs, now);
675    let spec_json = serde_json::to_string_pretty(&spec)
676        .unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize spec: {e}\"}}"));
677    (
678        [(http::header::CONTENT_TYPE, "application/json")],
679        spec_json,
680    )
681        .into_response()
682}
683
684/// Build an Axum sub-router that serves the generated `OpenAPI` document
685/// and (optionally) a Swagger UI HTML page.
686///
687/// Returns `None` when `OpenAPI` generation is disabled, i.e. the user
688/// never called [`AppBuilder::openapi`](crate::app::AppBuilder::openapi).
689///
690/// The spec is dynamically generated on request to prevent lifecycle status from going stale.
691#[cfg(feature = "openapi")]
692fn build_openapi_router(
693    route_list: &[Route],
694    scoped_groups: &[ScopedGroup],
695    openapi_config: Option<&crate::openapi::OpenApiConfig>,
696    session_cookie_name: &str,
697    api_versions: &[crate::app::ApiVersion],
698) -> Result<Option<axum::Router<AppState>>, RouterBuildError> {
699    let Some(config) = openapi_config else {
700        return Ok(None);
701    };
702    let mut config = config.clone();
703    session_cookie_name.clone_into(&mut config.session_cookie_name);
704    config.api_versions = api_versions.to_vec();
705
706    // Validate user-provided paths up front so a typo like
707    // `"openapi.json"` surfaces as a recoverable RouterBuildError
708    // rather than an axum panic (`Paths must start with a '/'`).
709    validate_route_path("openapi_json_path", &config.openapi_json_path)?;
710    if let Some(path) = &config.swagger_ui_path {
711        validate_route_path("swagger_ui_path", path)?;
712        // Registering two GET handlers on the same path would cause an
713        // axum `Route::route` panic, so reject collisions as a
714        // configuration error instead.
715        if path == &config.openapi_json_path {
716            return Err(RouterBuildError::DuplicateOpenApiPath { path: path.clone() });
717        }
718    }
719
720    let docs = collect_openapi_docs(route_list, scoped_groups);
721
722    let json_path = config.openapi_json_path.clone();
723    let swagger_path = config.swagger_ui_path.clone();
724    let title = config.title.clone();
725
726    let mut router = axum::Router::<AppState>::new()
727        .route(&json_path, axum::routing::get(serve_openapi_spec))
728        .layer(axum::extract::Extension(std::sync::Arc::new(
729            config.clone(),
730        )))
731        .layer(axum::extract::Extension(std::sync::Arc::new(docs)));
732
733    if let Some(path) = swagger_path {
734        router = mount_swagger_ui_routes(router, &path, &title, &json_path);
735    }
736
737    tracing::debug!(
738        openapi_json = %json_path,
739        swagger_ui = ?config.swagger_ui_path,
740        swagger_ui_version = crate::openapi::SWAGGER_UI_VERSION,
741        "Mounted OpenAPI endpoints"
742    );
743
744    Ok(Some(router))
745}
746
747/// Join a nest/scope prefix with a child route path, matching
748/// `axum::Router::nest` normalization.
749///
750/// `nest("/api", r)` mounts r's `/` at `/api` (not `/api/`), and any
751/// other child path `/foo` at `/api/foo`. The collision check and the
752/// path emitted into the `OpenAPI` spec must use the same shape or we
753/// end up either missing real collisions (the reviewer's case:
754/// `/api` + `/` recorded as `/api/` but axum routes it at `/api`) or
755/// generating a spec whose URLs don't match what axum serves.
756#[allow(dead_code)]
757pub fn join_nested_path(prefix: &str, child: &str) -> String {
758    let prefix_trimmed = prefix.trim_end_matches('/');
759    if child == "/" || child.is_empty() {
760        if prefix_trimmed.is_empty() {
761            "/".to_owned()
762        } else {
763            prefix_trimmed.to_owned()
764        }
765    } else if child.starts_with('/') {
766        format!("{prefix_trimmed}{child}")
767    } else {
768        format!("{prefix_trimmed}/{child}")
769    }
770}
771
772/// Shared validator for user-supplied `OpenAPI` mount paths.
773///
774/// Catches the common typos that would otherwise manifest as axum
775/// panics inside `Router::route` at startup:
776///
777/// * empty or missing leading slash,
778/// * unbalanced `{` / `}` pairs,
779/// * any `{…}` / `{*…}` capture or wildcard syntax (the mount points
780///   are static endpoints — a user that needs templated paths shouldn't
781///   be using this field), and
782/// * any `*` wildcard character (axum treats these as catch-alls).
783///
784/// The check intentionally stays conservative: rejecting a few valid-
785/// but-weird paths is far better than letting a typo like
786/// `"openapi.json"` or `"/docs/{id}"` crash boot.
787#[cfg(feature = "openapi")]
788fn validate_route_path(field: &'static str, value: &str) -> Result<(), RouterBuildError> {
789    let reject = |reason_fragment: &str| {
790        Err(RouterBuildError::InvalidOpenApiPath {
791            field,
792            value: format!("{value:?} {reason_fragment}"),
793        })
794    };
795
796    if value.is_empty() {
797        return reject("(must be non-empty)");
798    }
799    if !value.starts_with('/') {
800        return reject("(must start with '/')");
801    }
802    // Double-slash inside the path is almost always a typo (e.g.
803    // `//v3/api-docs`) and axum normalizes it away on match, so
804    // treating it as invalid avoids surprising "route can't be hit"
805    // reports in the field.
806    if value.contains("//") {
807        return reject("(must not contain '//')");
808    }
809
810    let mut depth: i32 = 0;
811    for ch in value.chars() {
812        match ch {
813            '{' => depth += 1,
814            '}' => {
815                depth -= 1;
816                if depth < 0 {
817                    return reject("(unbalanced '}')");
818                }
819            }
820            '*' => return reject("(wildcard '*' is not allowed in an OpenAPI mount path)"),
821            _ => {}
822        }
823    }
824    if depth != 0 {
825        return reject("(unbalanced '{')");
826    }
827    if value.contains('{') {
828        return reject("(OpenAPI mount paths must be static; `{…}` captures are not allowed)");
829    }
830    Ok(())
831}
832
833/// Gather every path that a `GET` (or `WS`, which mounts as a `GET`) handler
834/// will already own by the time a late-merged sub-router (`OpenAPI` or MCP) is
835/// added: user routes (top-level + scoped groups) plus framework-mounted `GET`s
836/// (probes, actuator, htmx assets, dev live-reload, mail previews). Shared by
837/// the `OpenAPI` and MCP mount-collision preflights so they stay in lockstep.
838#[cfg(feature = "openapi")]
839fn collect_claimed_get_paths(
840    route_list: &[Route],
841    scoped_groups: &[ScopedGroup],
842    config: &AutumnConfig,
843) -> std::collections::HashSet<String> {
844    let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
845    for route in route_list {
846        if route.method == http::Method::GET || route.method.as_str() == "WS" {
847            claimed.insert(route.path.to_owned());
848        }
849    }
850    for group in scoped_groups {
851        for route in &group.routes {
852            if route.method == http::Method::GET || route.method.as_str() == "WS" {
853                claimed.insert(join_nested_path(&group.prefix, route.path));
854            }
855        }
856    }
857    // Framework-mounted GETs.
858    claimed.insert(config.health.path.clone());
859    claimed.insert(config.health.live_path.clone());
860    claimed.insert(config.health.ready_path.clone());
861    claimed.insert(config.health.startup_path.clone());
862    for path in crate::actuator::actuator_endpoint_paths(
863        &config.actuator.prefix,
864        config.actuator.sensitive,
865        config.actuator.prometheus,
866    ) {
867        claimed.insert(path);
868    }
869    #[cfg(feature = "htmx")]
870    {
871        claimed.insert(crate::htmx::HTMX_JS_PATH.to_owned());
872        claimed.insert(crate::htmx::HTMX_CSRF_JS_PATH.to_owned());
873        claimed.insert(crate::htmx::AUTUMN_WIDGETS_JS_PATH.to_owned());
874    }
875    // Dev live-reload endpoints are only mounted when the env vars
876    // that enable them are set, but reserving the paths regardless
877    // makes the error message deterministic across dev/prod.
878    if dev::is_enabled_with_env(&crate::config::OsEnv) {
879        claimed.insert(dev::LIVE_RELOAD_PATH.to_owned());
880        claimed.insert(dev::LIVE_RELOAD_SCRIPT_PATH.to_owned());
881    }
882    // The dev request inspector merges a GET at `config.dev.inspector_path`
883    // (only under the dev profile), before the late-merged OpenAPI/MCP routers.
884    // Reserve it so a mount path colliding with the inspector surfaces a
885    // recoverable error instead of panicking in `router.merge`.
886    if matches!(config.profile.as_deref(), Some("dev" | "development")) {
887        claimed.insert(config.dev.inspector_path.clone());
888    }
889    #[cfg(feature = "mail")]
890    if config
891        .mail
892        .preview_routes_enabled(config.profile.as_deref())
893    {
894        claimed.insert(crate::mail::MAIL_PREVIEW_PATH.to_owned());
895        claimed.insert("/_autumn/mail/messages/{message_id}".to_owned());
896        claimed.insert("/_autumn/mail/previews/{mailer}/{method}".to_owned());
897    }
898    claimed
899}
900
901/// Reject an MCP mount path that overlaps with a route already owning that
902/// path. The MCP endpoint mounts `GET`+`POST` at `mount_path`; merging it would
903/// panic in axum if a `GET` (any user/framework route) or `POST` (a user route)
904/// already lives there. We surface a recoverable
905/// [`RouterBuildError::McpPathCollision`] instead, reusing the same claimed-GET
906/// gathering as the `OpenAPI` preflight so framework routes (health/probe,
907/// actuator, htmx, dev) are covered too — e.g. `mount_mcp(config.health.path)`.
908/// The configured `OpenAPI` JSON/UI/asset paths (which merge as `GET`s before
909/// the MCP router) are checked as well.
910#[cfg(feature = "mcp")]
911fn reject_mcp_path_collisions(
912    mount_path: &str,
913    route_list: &[Route],
914    scoped_groups: &[ScopedGroup],
915    config: &AutumnConfig,
916    openapi: Option<&crate::openapi::OpenApiConfig>,
917    merge_routers: &[axum::Router<AppState>],
918    nest_routers: &[(String, axum::Router<AppState>)],
919) -> Result<(), RouterBuildError> {
920    let mut claimed_get = collect_claimed_get_paths(route_list, scoped_groups, config);
921    // The OpenAPI JSON/Swagger-UI endpoints (and UI assets) merge as GETs
922    // before the MCP router, so a mount path colliding with them would panic.
923    if let Some(openapi) = openapi {
924        claimed_get.insert(openapi.openapi_json_path.clone());
925        if let Some(ui_path) = &openapi.swagger_ui_path {
926            claimed_get.insert(ui_path.clone());
927            claimed_get.extend(crate::openapi::swagger_ui_asset_paths(ui_path));
928        }
929    }
930    if claimed_get.contains(mount_path) {
931        return Err(RouterBuildError::McpPathCollision {
932            path: mount_path.to_owned(),
933            method: "GET".to_owned(),
934        });
935    }
936    // POST handlers come from user routes (framework routes are GETs).
937    let post_owns_path = route_list
938        .iter()
939        .any(|route| route.method == http::Method::POST && route.path == mount_path)
940        || scoped_groups.iter().any(|group| {
941            group.routes.iter().any(|route| {
942                route.method == http::Method::POST
943                    && join_nested_path(&group.prefix, route.path) == mount_path
944            })
945        });
946    if post_owns_path {
947        return Err(RouterBuildError::McpPathCollision {
948            path: mount_path.to_owned(),
949            method: "POST".to_owned(),
950        });
951    }
952    // A nest prefix P owns every route under P (`/P/...`), and those raw routers
953    // are mounted before the MCP router. A mount path equal to P or falling
954    // under `P/` would be shadowed by (or panic against) the nested router, so
955    // reject it up front — mirroring the OpenAPI nest-collision preflight. The
956    // framework unconditionally nests the static-file service at `/static`, so
957    // reserve that prefix too.
958    let nest_prefixes = nest_routers
959        .iter()
960        .map(|(prefix, _)| prefix.as_str())
961        .chain(std::iter::once("/static"));
962    for prefix in nest_prefixes {
963        let prefix_slash = format!("{prefix}/");
964        if mount_path == prefix || mount_path.starts_with(&prefix_slash) {
965            return Err(RouterBuildError::McpPathCollision {
966                path: mount_path.to_owned(),
967                method: "nested router".to_owned(),
968            });
969        }
970    }
971    // Raw merged routers are opaque — axum does not expose their route table —
972    // so an overlapping handler there would still panic at merge time. Warn so
973    // operators know the check can't cover this case (mirrors the OpenAPI one).
974    if !merge_routers.is_empty() {
975        tracing::warn!(
976            mcp_mount_path = %mount_path,
977            merged_routers = merge_routers.len(),
978            "MCP mount collision check skipped for AppBuilder::merge routers: \
979             axum does not expose their route table, so an overlapping handler \
980             will still panic at startup. Choose an MCP mount path that doesn't \
981             overlap with any merged router's handlers."
982        );
983    }
984    Ok(())
985}
986
987/// Reject `OpenAPI` mount paths that overlap with an existing `GET`
988/// handler.
989///
990/// `axum::Router::merge` panics when the merged routers have method
991/// handlers on the same path (e.g. two `GET` handlers on
992/// `/v3/api-docs`). We surface that as a recoverable
993/// [`RouterBuildError::OpenApiPathCollision`] so misconfiguration
994/// produces an actionable error instead of a crash on startup.
995///
996/// We check against:
997/// * user routes (top-level + scoped groups) that will be mounted
998///   before the `OpenAPI` sub-router merges in,
999/// * framework `GET`s: probes, actuator, htmx assets, and dev
1000///   live-reload when enabled,
1001/// * nest prefixes from [`AppBuilder::nest`](crate::app::AppBuilder::nest)
1002///   when the `OpenAPI` path falls under one.
1003///
1004/// Raw routers passed to [`AppBuilder::merge`](crate::app::AppBuilder::merge)
1005/// cannot be introspected — axum does not expose their route table.
1006/// We emit a `tracing::warn!` so operators know the check is
1007/// incomplete in that case.
1008#[cfg(feature = "openapi")]
1009fn reject_openapi_path_collisions(
1010    openapi_config: Option<&crate::openapi::OpenApiConfig>,
1011    route_list: &[Route],
1012    scoped_groups: &[ScopedGroup],
1013    merge_routers: &[axum::Router<AppState>],
1014    nest_routers: &[(String, axum::Router<AppState>)],
1015    config: &AutumnConfig,
1016) -> Result<(), RouterBuildError> {
1017    let Some(openapi) = openapi_config else {
1018        return Ok(());
1019    };
1020
1021    // Gather every path a GET (or WS, which mounts as GET) will already
1022    // own by the time we merge.
1023    let claimed = collect_claimed_get_paths(route_list, scoped_groups, config);
1024
1025    check_openapi_path_against(
1026        "openapi_json_path",
1027        &openapi.openapi_json_path,
1028        &claimed,
1029        nest_routers,
1030    )?;
1031    if let Some(path) = &openapi.swagger_ui_path {
1032        check_openapi_path_against("swagger_ui_path", path, &claimed, nest_routers)?;
1033        let mut claimed_with_openapi = claimed;
1034        claimed_with_openapi.insert(openapi.openapi_json_path.clone());
1035        for asset_path in crate::openapi::swagger_ui_asset_paths(path) {
1036            check_openapi_path_against(
1037                "swagger_ui_path",
1038                &asset_path,
1039                &claimed_with_openapi,
1040                nest_routers,
1041            )?;
1042        }
1043    }
1044
1045    // Raw merged routers are opaque — we can't inspect their route
1046    // tables through the axum API. Warn instead of failing so users
1047    // know the check doesn't cover this code path.
1048    if !merge_routers.is_empty() {
1049        tracing::warn!(
1050            openapi_json_path = %openapi.openapi_json_path,
1051            swagger_ui_path = ?openapi.swagger_ui_path,
1052            merged_routers = merge_routers.len(),
1053            "OpenAPI mount collision check skipped for AppBuilder::merge routers: \
1054             axum does not expose their route table, so overlapping GET handlers \
1055             will still panic at startup. Choose OpenAPI paths that don't overlap \
1056             with any merged router's handlers."
1057        );
1058    }
1059
1060    Ok(())
1061}
1062
1063/// Evaluate a single `OpenAPI` path against the claimed-path set plus
1064/// any nest prefixes. Returns an `OpenApiPathCollision` error on
1065/// collision.
1066#[cfg(feature = "openapi")]
1067fn check_openapi_path_against(
1068    field: &'static str,
1069    path: &str,
1070    claimed: &std::collections::HashSet<String>,
1071    nest_routers: &[(String, axum::Router<AppState>)],
1072) -> Result<(), RouterBuildError> {
1073    if claimed.contains(path) {
1074        return Err(RouterBuildError::OpenApiPathCollision {
1075            field,
1076            path: path.to_owned(),
1077        });
1078    }
1079    // A nest prefix P owns every route under P (`/P/...`), so any
1080    // OpenAPI path that equals P or starts with `P/` will either
1081    // panic on merge (exact match) or nest inside the user's router
1082    // (where axum routing semantics decide which handler wins).
1083    // Reject both cases so the spec endpoint can't silently vanish.
1084    for (prefix, _) in nest_routers {
1085        let prefix_slash = format!("{prefix}/");
1086        if path == prefix || path.starts_with(&prefix_slash) {
1087            return Err(RouterBuildError::OpenApiPathCollision {
1088                field,
1089                path: path.to_owned(),
1090            });
1091        }
1092    }
1093    Ok(())
1094}
1095
1096fn group_and_mount_routes(
1097    route_list: Vec<Route>,
1098    idempotency_layers: Option<&BuiltIdempotencyLayers>,
1099    opaque_app_layers_present: bool,
1100    state: &AppState,
1101) -> axum::Router<AppState> {
1102    // Group routes by path so multiple methods on the same path
1103    // (e.g. GET /admin + POST /admin) are merged into a single
1104    // MethodRouter. Axum 0.7+ panics if .route() is called twice
1105    // with the same path — merging avoids this.
1106    let mut grouped: indexmap::IndexMap<&str, axum::routing::MethodRouter<AppState>> =
1107        indexmap::IndexMap::new();
1108    for route in &route_list {
1109        tracing::debug!(
1110            method = %route.method,
1111            path = route.path,
1112            name = route.name,
1113            "Mounted route"
1114        );
1115    }
1116    for route in route_list {
1117        let selected_layer = idempotency_layers
1118            .map(|layers| idempotency_layer_for_route(&route, layers, opaque_app_layers_present));
1119        let mut handler = route.handler;
1120        if let Some(layer) = selected_layer {
1121            handler = handler.layer(layer.clone());
1122        }
1123        if let Some(version) = route.api_version {
1124            handler = handler.layer(axum::middleware::from_fn_with_state(
1125                state.clone(),
1126                api_versioning_middleware,
1127            ));
1128            handler = handler.layer(axum::Extension(RouteVersionMetadata {
1129                version: version.to_string(),
1130                sunset_opt_out: route.sunset_opt_out,
1131                secured: route.api_doc.secured,
1132                required_roles: route.api_doc.required_roles,
1133                has_policy: route.api_doc.has_policy,
1134            }));
1135        }
1136        grouped
1137            .entry(route.path)
1138            .and_modify(|existing| {
1139                *existing = std::mem::take(existing).merge(handler.clone());
1140            })
1141            .or_insert(handler);
1142    }
1143
1144    let mut router = axum::Router::new();
1145    for (path, method_router) in grouped {
1146        router = router.route(path, method_router);
1147    }
1148    router
1149}
1150
1151const fn idempotency_layer_for_route<'a>(
1152    route: &Route,
1153    layers: &'a BuiltIdempotencyLayers,
1154    opaque_app_layers_present: bool,
1155) -> &'a IdempotencyLayer {
1156    if opaque_app_layers_present {
1157        &layers.manual
1158    } else if route_uses_generated_replay_stop(route) {
1159        &layers.route
1160    } else {
1161        &layers.manual
1162    }
1163}
1164
1165const fn route_uses_generated_replay_stop(route: &Route) -> bool {
1166    matches!(
1167        route.idempotency,
1168        crate::route::RouteIdempotency::ReplayThroughInner
1169    )
1170}
1171
1172fn custom_layers_require_fail_closed_idempotency(
1173    custom_layers: &[crate::app::CustomLayerRegistration],
1174) -> bool {
1175    custom_layers
1176        .iter()
1177        .any(|registered| !is_idempotency_transparent_app_layer(registered))
1178}
1179
1180fn is_idempotency_transparent_app_layer(registered: &crate::app::CustomLayerRegistration) -> bool {
1181    registered
1182        .type_name
1183        .starts_with("autumn_web::session::SessionLayer<")
1184        || registered
1185            .type_name
1186            .starts_with("autumn::session::SessionLayer<")
1187        || registered.type_id
1188            == std::any::TypeId::of::<crate::session::SessionLayer<crate::session::MemoryStore>>()
1189        || is_i18n_bundle_extension_layer(registered.type_id)
1190}
1191
1192#[cfg(feature = "i18n")]
1193fn is_i18n_bundle_extension_layer(type_id: std::any::TypeId) -> bool {
1194    type_id == std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>()
1195}
1196
1197#[cfg(not(feature = "i18n"))]
1198const fn is_i18n_bundle_extension_layer(_type_id: std::any::TypeId) -> bool {
1199    false
1200}
1201
1202#[cfg_attr(not(feature = "mail"), allow(unused_variables))]
1203#[allow(clippy::cognitive_complexity)]
1204fn mount_framework_routes(
1205    mut router: axum::Router<AppState>,
1206    config: &AutumnConfig,
1207    dev_reload_enabled: bool,
1208) -> axum::Router<AppState> {
1209    #[cfg(not(feature = "mail"))]
1210    let _ = config;
1211
1212    // Framework-provided routes
1213    #[cfg(feature = "htmx")]
1214    {
1215        router = router.route(crate::htmx::HTMX_JS_PATH, axum::routing::get(htmx_handler));
1216        router = router.route(
1217            crate::htmx::HTMX_CSRF_JS_PATH,
1218            axum::routing::get(htmx_csrf_handler),
1219        );
1220        router = router.route(
1221            crate::htmx::AUTUMN_WIDGETS_JS_PATH,
1222            axum::routing::get(autumn_widgets_handler),
1223        );
1224        tracing::debug!(
1225            method = "GET",
1226            path = crate::htmx::HTMX_JS_PATH,
1227            name = format!("htmx {}", crate::htmx::HTMX_VERSION),
1228            "Mounted route"
1229        );
1230        tracing::debug!(
1231            method = "GET",
1232            path = crate::htmx::HTMX_CSRF_JS_PATH,
1233            name = "htmx csrf helper",
1234            "Mounted route"
1235        );
1236        tracing::debug!(
1237            method = "GET",
1238            path = crate::htmx::AUTUMN_WIDGETS_JS_PATH,
1239            name = "autumn widget runtime",
1240            "Mounted route"
1241        );
1242    }
1243
1244    if dev_reload_enabled {
1245        router = router.route(
1246            dev::LIVE_RELOAD_PATH,
1247            axum::routing::get(dev::live_reload_state_handler),
1248        );
1249        router = router.route(
1250            dev::LIVE_RELOAD_SCRIPT_PATH,
1251            axum::routing::get(dev::live_reload_script_handler),
1252        );
1253        tracing::debug!(
1254            state_path = dev::LIVE_RELOAD_PATH,
1255            script_path = dev::LIVE_RELOAD_SCRIPT_PATH,
1256            "Mounted dev live reload endpoints"
1257        );
1258    }
1259
1260    #[cfg(feature = "mail")]
1261    if config
1262        .mail
1263        .preview_routes_enabled(config.profile.as_deref())
1264    {
1265        router = router.merge(crate::mail::mail_preview_router(
1266            config.mail.file_dir.clone(),
1267        ));
1268        tracing::debug!(
1269            path = crate::mail::MAIL_PREVIEW_PATH,
1270            "Mounted dev mail preview endpoints"
1271        );
1272    }
1273
1274    router
1275}
1276
1277fn mount_probe_endpoints<S>(
1278    mut router: axum::Router<S>,
1279    config: &AutumnConfig,
1280) -> (std::collections::HashSet<String>, axum::Router<S>)
1281where
1282    S: Clone + Send + Sync + 'static,
1283    AppState: axum::extract::FromRef<S>,
1284{
1285    // Probe endpoints (auto-mounted)
1286    let mut mounted_probe_paths = std::collections::HashSet::new();
1287
1288    if mounted_probe_paths.insert(config.health.live_path.clone()) {
1289        router = router.route(
1290            &config.health.live_path,
1291            axum::routing::get(crate::probe::live_handler::<AppState>),
1292        );
1293    }
1294    if mounted_probe_paths.insert(config.health.ready_path.clone()) {
1295        router = router.route(
1296            &config.health.ready_path,
1297            axum::routing::get(crate::probe::ready_handler::<AppState>),
1298        );
1299    }
1300    if mounted_probe_paths.insert(config.health.startup_path.clone()) {
1301        router = router.route(
1302            &config.health.startup_path,
1303            axum::routing::get(crate::probe::startup_handler::<AppState>),
1304        );
1305    }
1306    if mounted_probe_paths.insert(config.health.path.clone()) {
1307        router = router.route(
1308            &config.health.path,
1309            axum::routing::get(crate::health::handler::<AppState>),
1310        );
1311    }
1312    tracing::debug!(
1313        health = %config.health.path,
1314        live = %config.health.live_path,
1315        ready = %config.health.ready_path,
1316        startup = %config.health.startup_path,
1317        "Mounted probe endpoints"
1318    );
1319
1320    (mounted_probe_paths, router)
1321}
1322
1323fn mount_actuator_endpoints(
1324    mut router: axum::Router<AppState>,
1325    config: &AutumnConfig,
1326    mounted_probe_paths: &std::collections::HashSet<String>,
1327) -> Result<axum::Router<AppState>, RouterBuildError> {
1328    // Actuator endpoints
1329    let actuator_sensitive = config.actuator.sensitive;
1330    let actuator_prometheus = config.actuator.prometheus;
1331    let actuator_paths = crate::actuator::actuator_endpoint_paths(
1332        &config.actuator.prefix,
1333        actuator_sensitive,
1334        actuator_prometheus,
1335    );
1336    if let Some(path) = actuator_paths
1337        .iter()
1338        .find(|path| mounted_probe_paths.contains(path.as_str()))
1339    {
1340        return Err(RouterBuildError::FrameworkRouteOverlap {
1341            path: path.clone(),
1342            existing: "probe endpoint",
1343            incoming: "actuator endpoint",
1344        });
1345    }
1346    router = router.merge(crate::actuator::actuator_router_with_prefix(
1347        &config.actuator.prefix,
1348        actuator_sensitive,
1349        actuator_prometheus,
1350    ));
1351    tracing::debug!(
1352        sensitive = actuator_sensitive,
1353        prometheus = actuator_prometheus,
1354        prefix = %config.actuator.prefix,
1355        "Mounted actuator endpoints"
1356    );
1357    Ok(router)
1358}
1359
1360fn mount_scoped_groups(
1361    mut router: axum::Router<AppState>,
1362    scoped_groups: Vec<ScopedGroup>,
1363    idempotency_layers: Option<&BuiltIdempotencyLayers>,
1364    state: &AppState,
1365) -> axum::Router<AppState> {
1366    // Mount scoped route groups (each with its own middleware layer).
1367    for group in scoped_groups {
1368        let mut sub_router = axum::Router::new();
1369        for route in group.routes {
1370            tracing::debug!(
1371                method = %route.method,
1372                path = route.path,
1373                name = route.name,
1374                scope = %group.prefix,
1375                "Mounted scoped route"
1376            );
1377            // Scoped groups are wrapped by an opaque user-provided layer after
1378            // the route handlers are built. The idempotency storage key cannot
1379            // know whether that layer authorizes, audits, or resolves tenant
1380            // state from non-whitelisted headers/extensions, so cached hits
1381            // fail closed instead of replaying through a generated stop inside
1382            // the scoped route.
1383            let selected_layer = idempotency_layers.map(|layers| &layers.manual);
1384            let mut handler = route.handler;
1385            if let Some(layer) = selected_layer {
1386                handler = handler.layer(layer.clone());
1387            }
1388            if let Some(version) = route.api_version {
1389                handler = handler.layer(axum::middleware::from_fn_with_state(
1390                    state.clone(),
1391                    api_versioning_middleware,
1392                ));
1393                handler = handler.layer(axum::Extension(RouteVersionMetadata {
1394                    version: version.to_string(),
1395                    sunset_opt_out: route.sunset_opt_out,
1396                    secured: route.api_doc.secured,
1397                    required_roles: route.api_doc.required_roles,
1398                    has_policy: route.api_doc.has_policy,
1399                }));
1400            }
1401            sub_router = sub_router.route(route.path, handler);
1402        }
1403        sub_router = (group.apply_layer)(sub_router);
1404        router = router.nest(&group.prefix, sub_router);
1405    }
1406    router
1407}
1408
1409fn mount_raw_routers(
1410    mut router: axum::Router<AppState>,
1411    merge_routers: Vec<axum::Router<AppState>>,
1412    nest_routers: Vec<(String, axum::Router<AppState>)>,
1413    idempotency_layers: Option<&BuiltIdempotencyLayers>,
1414) -> axum::Router<AppState> {
1415    // Merge user-supplied raw Axum routers (escape hatch).
1416    // Merged after annotated routes so annotated routes take precedence.
1417    for raw_router in merge_routers {
1418        tracing::debug!("Merged raw Axum router");
1419        let raw_router = if let Some(layers) = idempotency_layers {
1420            raw_router.layer(layers.manual.clone())
1421        } else {
1422            raw_router
1423        };
1424        router = router.merge(raw_router);
1425    }
1426
1427    // Nest user-supplied raw Axum routers under path prefixes.
1428    for (prefix, raw_router) in nest_routers {
1429        tracing::debug!(prefix = %prefix, "Nested raw Axum router");
1430        // We explicitly apply the fallback to the nested router before nesting,
1431        // so that unmatched routes within this prefix are protected by global middleware.
1432        let nested_router =
1433            raw_router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
1434        let nested_router = if let Some(layers) = idempotency_layers {
1435            nested_router.layer(layers.manual.clone())
1436        } else {
1437            nested_router
1438        };
1439        router = router.nest(&prefix, nested_router);
1440    }
1441    router
1442}
1443
1444fn apply_compression_middleware<S>(
1445    mut router: axum::Router<S>,
1446    config: &AutumnConfig,
1447) -> axum::Router<S>
1448where
1449    S: Clone + Send + Sync + 'static,
1450{
1451    if config.compression.enabled {
1452        use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
1453        // Extend the default predicate (skips images, gRPC, SSE, small bodies) to also
1454        // skip binary media and already-compressed formats — compressing these wastes
1455        // CPU, increases transfer size for archives, and can confuse media players.
1456        let predicate = DefaultPredicate::new()
1457            // Binary media — already-encoded by codec, not compressible by gzip/br.
1458            .and(NotForContentType::const_new("audio/"))
1459            .and(NotForContentType::const_new("video/"))
1460            .and(NotForContentType::const_new("application/octet-stream"))
1461            // Compressed archive formats — re-compressing wastes CPU.
1462            .and(NotForContentType::const_new("application/zip"))
1463            .and(NotForContentType::const_new("application/gzip"))
1464            .and(NotForContentType::const_new("application/x-gzip"))
1465            .and(NotForContentType::const_new("application/zstd"))
1466            .and(NotForContentType::const_new("application/x-bzip2"))
1467            .and(NotForContentType::const_new("application/x-bzip"))
1468            .and(NotForContentType::const_new("application/x-rar-compressed"))
1469            .and(NotForContentType::const_new("application/vnd.rar"))
1470            .and(NotForContentType::const_new("application/x-7z-compressed"));
1471        router =
1472            router.layer(tower_http::compression::CompressionLayer::new().compress_when(predicate));
1473        tracing::info!("Response compression enabled (gzip/brotli)");
1474    }
1475    router
1476}
1477
1478fn apply_cors_middleware<S>(mut router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
1479where
1480    S: Clone + Send + Sync + 'static,
1481{
1482    // CORS middleware (only applied when allowed_origins is non-empty)
1483    if !config.cors.allowed_origins.is_empty() {
1484        let cors = build_cors_layer(&config.cors);
1485        tracing::info!(
1486            origins = ?config.cors.allowed_origins,
1487            credentials = config.cors.allow_credentials,
1488            "CORS enabled"
1489        );
1490        router = router.layer(cors);
1491    }
1492    router
1493}
1494
1495fn apply_csrf_middleware<S>(
1496    mut router: axum::Router<S>,
1497    config: &AutumnConfig,
1498    signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
1499) -> axum::Router<S>
1500where
1501    S: Clone + Send + Sync + 'static,
1502{
1503    // CSRF middleware (only applied when enabled)
1504    if config.security.csrf.enabled {
1505        let mut csrf_layer = crate::security::CsrfLayer::from_config(&config.security.csrf)
1506            .with_max_scan_bytes(config.security.upload.max_request_size_bytes);
1507        if let Some(keys) = signing_keys {
1508            csrf_layer = csrf_layer.with_signing_keys(keys);
1509        }
1510        for endpoint in &config.security.webhooks.endpoints {
1511            csrf_layer = csrf_layer.with_exempt_path(&endpoint.path);
1512        }
1513        tracing::info!("CSRF protection enabled");
1514        router = router.layer(csrf_layer);
1515    }
1516    router
1517}
1518
1519fn apply_bot_protection_middleware<S>(
1520    mut router: axum::Router<S>,
1521    config: &AutumnConfig,
1522) -> axum::Router<S>
1523where
1524    S: Clone + Send + Sync + 'static,
1525{
1526    if config.bot_protection.enabled {
1527        // Use the dedicated captcha_exempt_paths list — NOT csrf.exempt_paths —
1528        // so that a route exempt from CSRF for non-cookie auth reasons does not
1529        // automatically bypass bot-protection as well.
1530        let mut exempt = config.security.captcha_exempt_paths.clone();
1531        for endpoint in &config.security.webhooks.endpoints {
1532            exempt.push(endpoint.path.clone());
1533        }
1534        let layer =
1535            crate::security::captcha::BotProtectionLayer::from_config(&config.bot_protection)
1536                .with_max_scan_bytes(config.security.upload.max_request_size_bytes)
1537                .with_exempt_paths(exempt);
1538        tracing::info!(
1539            provider = ?config.bot_protection.provider,
1540            dev_bypass = config.bot_protection.dev_bypass,
1541            "Bot protection (CAPTCHA) enabled"
1542        );
1543        router = router.layer(layer);
1544    }
1545    router
1546}
1547
1548async fn populate_rate_limit_principal(
1549    axum::extract::State(state): axum::extract::State<AppState>,
1550    mut req: axum::extract::Request,
1551    next: axum::middleware::Next,
1552) -> axum::response::Response {
1553    if let Some(session) = req.extensions().get::<crate::session::Session>() {
1554        let auth_session_key = state.auth_session_key();
1555        if let Some(user_id) = session.get(auth_session_key).await {
1556            req.extensions_mut()
1557                .insert(crate::security::RateLimitPrincipal(user_id));
1558        }
1559    }
1560    next.run(req).await
1561}
1562
1563fn apply_trusted_proxies_middleware<S>(
1564    router: axum::Router<S>,
1565    config: &AutumnConfig,
1566) -> axum::Router<S>
1567where
1568    S: Clone + Send + Sync + 'static,
1569{
1570    let tp = &config.security.trusted_proxies;
1571    let layer = crate::security::TrustedProxiesLayer::from_config(tp);
1572    if tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some() {
1573        tracing::info!(
1574            ranges = ?tp.ranges,
1575            trusted_hops = ?tp.trusted_hops,
1576            "Centralized trusted-proxy resolution enabled"
1577        );
1578    }
1579    router.layer(layer)
1580}
1581
1582fn apply_rate_limit_middleware(
1583    mut router: axum::Router<AppState>,
1584    config: &AutumnConfig,
1585    state: &AppState,
1586) -> axum::Router<AppState> {
1587    if config.security.rate_limit.enabled {
1588        let tp = &config.security.trusted_proxies;
1589        let rl = &config.security.rate_limit;
1590        let has_top_level_proxy_config =
1591            tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some();
1592        // Preserve explicit rate-limit proxy config (legacy fields). The shared
1593        // top-level resolver is only injected when the rate-limit section carries
1594        // no proxy config of its own, preventing dev defaults from silently
1595        // overriding an operator's explicit security.rate_limit.trusted_proxies.
1596        let has_rate_limit_proxy_config =
1597            rl.trust_forwarded_headers || !rl.trusted_proxies.is_empty();
1598        // The framework default limiter shares its bucket with the MCP `/mcp`
1599        // envelope limiter (both built here), so it honors `RateLimitExempt` to
1600        // avoid double-counting an already-charged `tools/call`. User-installed
1601        // limiters don't, so MCP replays still consume their per-route buckets.
1602        let mut layer = crate::security::RateLimitLayer::from_config(rl).honoring_mcp_exempt();
1603        if has_top_level_proxy_config && !has_rate_limit_proxy_config {
1604            let resolver = crate::security::ProxyResolver::from_config(tp);
1605            layer = layer.with_proxy_resolver(resolver);
1606        }
1607        tracing::info!(
1608            rps = config.security.rate_limit.requests_per_second,
1609            burst = config.security.rate_limit.burst,
1610            "Rate limiting enabled"
1611        );
1612        router = router.layer(layer);
1613
1614        if config.security.rate_limit.key_strategy
1615            == crate::security::KeyStrategy::AuthenticatedPrincipal
1616        {
1617            router = router.layer(axum::middleware::from_fn_with_state(
1618                state.clone(),
1619                populate_rate_limit_principal,
1620            ));
1621        }
1622    }
1623    router
1624}
1625
1626fn apply_upload_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
1627where
1628    S: Clone + Send + Sync + 'static,
1629{
1630    let upload_config = config.security.upload.clone();
1631    let max_request_size = upload_config.max_request_size_bytes;
1632    tracing::info!(
1633        max_request_size_bytes = max_request_size,
1634        max_file_size_bytes = upload_config.max_file_size_bytes,
1635        allowed_mime_types = ?upload_config.allowed_mime_types,
1636        "Request body size limits enabled (applies to all content types)"
1637    );
1638
1639    // Apply a global body-size cap covering JSON, form, raw bytes, and multipart.
1640    // The Multipart extractor further refines this per the UploadConfig extension.
1641    let router = router.layer(axum::extract::DefaultBodyLimit::max(max_request_size));
1642
1643    // Insert UploadConfig into extensions so the Multipart extractor can read
1644    // per-file limits and the allowed MIME-type list.
1645    router.layer(axum::middleware::from_fn(
1646        move |mut req: axum::extract::Request, next: axum::middleware::Next| {
1647            let upload_config = upload_config.clone();
1648            async move {
1649                req.extensions_mut().insert(upload_config);
1650                next.run(req).await
1651            }
1652        },
1653    ))
1654}
1655
1656/// Build the [`MaintenanceLayer`](crate::middleware::maintenance::MaintenanceLayer)
1657/// from config + state, with the health/probe paths that always bypass the gate.
1658///
1659/// Shared by [`apply_middleware`] (direct routes) and the late-mounted `/mcp`
1660/// envelope so both return the documented `503` identically when maintenance
1661/// mode is active — the `/mcp` router is merged after `apply_middleware`, so
1662/// without an explicit layer its `initialize`/`tools/list` would keep serving
1663/// the catalog during maintenance.
1664fn build_maintenance_layer(
1665    config: &AutumnConfig,
1666    state: &AppState,
1667) -> crate::middleware::maintenance::MaintenanceLayer {
1668    let maintenance_state = state
1669        .extension::<crate::maintenance::MaintenanceState>()
1670        .map(|s| (*s).clone())
1671        .unwrap_or_default();
1672    let bypass_paths = vec![
1673        config.health.path.clone(),
1674        config.health.live_path.clone(),
1675        config.health.ready_path.clone(),
1676        config.health.startup_path.clone(),
1677        crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
1678    ];
1679    crate::middleware::maintenance::MaintenanceLayer::new(maintenance_state)
1680        .with_health_prefix(config.actuator.prefix.clone())
1681        .with_probe_paths(bypass_paths)
1682}
1683
1684/// Apply a per-request-cycle timeout when `config.server.timeouts.request_timeout_ms`
1685/// is set and non-zero.
1686///
1687/// The middleware is inserted inner to [`RequestIdLayer`] so the request ID is
1688/// available in the warning log and 408 response body. The layer is a no-op when
1689/// the timeout is disabled, preserving zero overhead for unconfigured deployments.
1690fn apply_request_timeout_middleware(
1691    router: axum::Router<AppState>,
1692    config: &AutumnConfig,
1693    metrics: crate::middleware::MetricsCollector,
1694) -> axum::Router<AppState> {
1695    let timeout_ms = match config.server.timeouts.request_timeout_ms {
1696        Some(ms) if ms > 0 => ms,
1697        _ => return router,
1698    };
1699    let duration = std::time::Duration::from_millis(timeout_ms);
1700    let is_dev = matches!(
1701        config.profile.as_deref(),
1702        Some("dev" | "development") | None
1703    );
1704    tracing::info!(timeout_ms, "Per-request timeout enabled");
1705    router.layer(axum::middleware::from_fn(move |req, next| {
1706        request_timeout_handler(req, next, duration, metrics.clone(), is_dev)
1707    }))
1708}
1709
1710async fn request_timeout_handler(
1711    req: axum::extract::Request,
1712    next: axum::middleware::Next,
1713    duration: std::time::Duration,
1714    metrics: crate::middleware::MetricsCollector,
1715    is_dev: bool,
1716) -> axum::response::Response {
1717    let request_id = req
1718        .extensions()
1719        .get::<crate::middleware::RequestId>()
1720        .cloned();
1721    match tokio::time::timeout(duration, next.run(req)).await {
1722        Ok(response) => response,
1723        Err(_elapsed) => {
1724            if let Some(ref rid) = request_id {
1725                tracing::warn!(request_id = %rid, "Request timed out");
1726            } else {
1727                tracing::warn!("Request timed out");
1728            }
1729            metrics.record_request_timeout();
1730            let body = crate::error::problem_details_json_string(
1731                http::StatusCode::REQUEST_TIMEOUT,
1732                "The server did not receive a complete request within the allowed time",
1733                None,
1734                None,
1735                request_id.as_ref().map(ToString::to_string),
1736                None,
1737                is_dev,
1738            );
1739            (
1740                http::StatusCode::REQUEST_TIMEOUT,
1741                [(http::header::CONTENT_TYPE, "application/problem+json")],
1742                body,
1743            )
1744                .into_response()
1745        }
1746    }
1747}
1748
1749struct BuiltIdempotencyLayers {
1750    route: crate::idempotency::IdempotencyLayer,
1751    manual: crate::idempotency::IdempotencyLayer,
1752}
1753
1754fn build_idempotency_layers(
1755    config: &AutumnConfig,
1756    state: &AppState,
1757) -> Result<Option<BuiltIdempotencyLayers>, RouterBuildError> {
1758    if !config.idempotency.enabled.unwrap_or(false) {
1759        return Ok(None);
1760    }
1761
1762    let ttl = Duration::from_secs(config.idempotency.ttl_secs);
1763    let in_flight_ttl = Duration::from_secs(config.idempotency.in_flight_ttl_secs);
1764    let store: std::sync::Arc<dyn IdempotencyStore> = match config.idempotency.backend {
1765        crate::config::IdempotencyBackend::Memory => {
1766            std::sync::Arc::new(MemoryIdempotencyStore::new(ttl))
1767        }
1768        #[cfg(feature = "redis")]
1769        crate::config::IdempotencyBackend::Redis => {
1770            match crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency) {
1771                Ok(s) => std::sync::Arc::new(s),
1772                Err(e) => return Err(RouterBuildError::InvalidIdempotencyBackend(e)),
1773            }
1774        }
1775        #[cfg(not(feature = "redis"))]
1776        crate::config::IdempotencyBackend::Redis => {
1777            return Err(RouterBuildError::InvalidIdempotencyBackend(
1778                "idempotency backend 'redis' requires the autumn-web 'redis' feature \
1779                 flag; rebuild with --features redis or switch to backend = \"memory\""
1780                    .to_owned(),
1781            ));
1782        }
1783    };
1784
1785    tracing::debug!(
1786        backend = ?config.idempotency.backend,
1787        ttl_secs = config.idempotency.ttl_secs,
1788        in_flight_ttl_secs = config.idempotency.in_flight_ttl_secs,
1789        "Idempotency-key middleware enabled"
1790    );
1791
1792    let base = IdempotencyLayer::new(store)
1793        .with_ttl(ttl)
1794        .with_in_flight_ttl(in_flight_ttl)
1795        .with_metrics(state.metrics.clone());
1796
1797    Ok(Some(BuiltIdempotencyLayers {
1798        route: base.clone().replay_through_inner(),
1799        manual: base.fail_closed_on_replay(),
1800    }))
1801}
1802
1803#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
1804fn apply_middleware(
1805    mut router: axum::Router<AppState>,
1806    config: &AutumnConfig,
1807    state: &AppState,
1808    exception_filters: Vec<Arc<dyn ExceptionFilter>>,
1809    custom_layers: Vec<crate::app::CustomLayerRegistration>,
1810    error_page_renderer: Option<SharedRenderer>,
1811    session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
1812) -> Result<axum::Router<AppState>, RouterBuildError> {
1813    // 404 fallback handler for unmatched routes must be registered BEFORE global middleware
1814    // so that unmatched routes are still protected by rate limiting, CSRF, CORS, etc.
1815    router = router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
1816
1817    // Resolve signing keys once; shared across session and CSRF layers.
1818    let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
1819    let signing_keys = std::sync::Arc::new(crate::security::config::resolve_signing_keys(
1820        &config.security.signing_secret,
1821    ));
1822    // Only thread signing keys when a secret is configured (or in production where
1823    // fail_fast already ensures one is present). In dev without a configured secret
1824    // the ephemeral key is generated per-process — useful but not required.
1825    let signing_keys_opt: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>> =
1826        if config.security.signing_secret.secret.is_some() || is_production {
1827            Some(signing_keys)
1828        } else {
1829            None
1830        };
1831
1832    router = apply_cors_middleware(router, config);
1833    let trusted_host_policy = TrustedHostPolicy::from_config(config);
1834    router = router.layer(axum::middleware::from_fn(move |req, next| {
1835        trusted_host_middleware(req, next, trusted_host_policy.clone())
1836    }));
1837    router = apply_csrf_middleware(router, config, signing_keys_opt.clone());
1838    router = apply_bot_protection_middleware(router, config);
1839    // Method-override rejection filter. The outer `MethodOverrideLayer`
1840    // (applied at the `axum::serve` boundary so it can rewrite the
1841    // request method before route matching) stamps a
1842    // [`MethodOverrideRejection`] extension when the override field
1843    // value is invalid or the body was too large to scan; this inner
1844    // middleware converts that extension into the corresponding
1845    // `400`/`413` response. Running it here means the rejection flows
1846    // through the rest of the response stack (security headers,
1847    // request IDs, metrics, error-page filter) rather than bypassing
1848    // them. Placed outside CSRF so a `BodyTooLarge` (empty body)
1849    // doesn't get masked by a `403` from CSRF's missing-token branch,
1850    // and a clear `400 invalid _method` outranks "missing CSRF".
1851    router = router.layer(axum::middleware::from_fn(
1852        crate::middleware::method_override_rejection_filter,
1853    ));
1854    router = apply_rate_limit_middleware(router, config, state);
1855
1856    // Register MaintenanceLayer automatically (shared construction with the
1857    // late-mounted `/mcp` envelope — see `build_maintenance_layer`).
1858    router = router.layer(build_maintenance_layer(config, state));
1859
1860    router = router.layer(axum::middleware::from_fn(
1861        crate::webhook::webhook_replay_cleanup_middleware,
1862    ));
1863    router = apply_upload_middleware(router, config);
1864
1865    // Security headers layer (always applied)
1866    let security_headers =
1867        crate::security::SecurityHeadersLayer::from_config(&config.security.headers);
1868    tracing::debug!("Security headers enabled");
1869
1870    // User-registered Tower layers (AppBuilder::layer). Outermost — applied
1871    // last so they wrap all framework middleware.  Iterate in reverse so the
1872    // first registered layer ends up outermost among user layers — matching
1873    // tower::ServiceBuilder ordering.
1874    //
1875    // When a static dist dir is active (SSG/ISG build), these layers are
1876    // NOT passed here — they are extracted by try_build_router_with_static_inner
1877    // and applied outside the static-first middleware instead, so they can
1878    // process pre-rendered responses without creating a session dependency.
1879    let custom_layer_count = custom_layers.len();
1880    for registered in custom_layers.into_iter().rev() {
1881        router = (registered.apply)(router);
1882    }
1883    if custom_layer_count > 0 {
1884        tracing::debug!(count = custom_layer_count, "Custom Tower layers applied");
1885    }
1886
1887    // TrustedProxiesLayer is applied after user layers so it is outermost in the
1888    // ingress request path, stamping ResolvedClientIdentity before any user or
1889    // framework middleware reads ClientAddr / ClientHost / ClientScheme.
1890    router = apply_trusted_proxies_middleware(router, config);
1891
1892    let mut router = router;
1893
1894    if config.tenancy.enabled {
1895        router = router.layer(axum::middleware::from_fn_with_state(
1896            state.clone(),
1897            crate::tenancy::tenancy_middleware,
1898        ));
1899        tracing::debug!("Multi-tenancy middleware enabled");
1900    }
1901
1902    // Per-request timeout (inner to RequestId so the request ID set by that
1903    // layer is available when the timeout fires — see request_timeout_handler).
1904    //
1905    // Full ingress layer order (outermost → innermost):
1906    //   TraceContext → AccessLog-fallback (applied in apply_startup_barrier) →
1907    //   StartupBarrier → Compression → Metrics → ExceptionFilter → ErrorPageContext →
1908    //   Session → SecurityHeaders → RequestId → LogContext → AccessLog-primary →
1909    //   Timeout → [user layers] → Tenancy → BodyLimit/UploadConfig →
1910    //   MethodOverride → RateLimit → CSRF → CORS → handler
1911    router = apply_request_timeout_middleware(router, config, state.metrics.clone());
1912
1913    // Error-reporting + panic-catch layer. Placed inner to `RequestIdLayer`
1914    // (so the request id is available when a handler panics) and outer to the
1915    // timeout, user layers, and handler (so their panics are caught and turned
1916    // into a clean 500 instead of aborting the worker task). The resulting 500
1917    // still flows out through the exception-filter chain for HTML negotiation.
1918    #[cfg(feature = "reporting")]
1919    {
1920        router = router.layer(crate::reporting::ReportingLayer::new(
1921            state.error_reporters(),
1922            config.reporting.enabled,
1923            config.reporting.sample_rate,
1924        ));
1925    }
1926
1927    // Structured per-request access log (#999), primary emitter: one INFO
1928    // event (target `autumn::access`) per served request at the response
1929    // boundary. Inner to RequestId (so the request id is available) and to
1930    // LogContext (so the event is emitted inside the request span); outer to
1931    // the reporting and timeout layers so panics-turned-500s and timeout
1932    // responses are logged with the status the client receives. Emitted
1933    // responses are marked so the outermost fallback (apply_startup_barrier)
1934    // does not double-log; that fallback covers requests that short-circuit
1935    // before this layer runs.
1936    if config.log.access_log {
1937        router = router.layer(crate::middleware::AccessLogLayer::new(
1938            config.log.access_log_exclude.clone(),
1939        ));
1940    }
1941
1942    // Request-scoped log context (#1169). Established for every request, inner
1943    // to `RequestIdLayer` (so the request id is available to seed it) and outer
1944    // to tenancy, user layers, and the handler (so all of them, and every
1945    // `tracing` event they emit, inherit the same correlating context). The
1946    // filter mirrors the error-page scrubber so sensitive custom fields never
1947    // enter the context output.
1948    let mut log_context_filter_parameters = config.log.filter_parameters.clone();
1949    log_context_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
1950    let log_context_filter = Arc::new(crate::log::filter::ParameterFilter::new(
1951        &log_context_filter_parameters,
1952        &config.log.unfilter_parameters,
1953    ));
1954    let router = router.layer(crate::middleware::LogContextLayer::new(log_context_filter));
1955
1956    let router = router.layer(RequestIdLayer).layer(security_headers);
1957
1958    let router = crate::session::apply_session_layer(
1959        router,
1960        &config.session,
1961        config.profile.as_deref(),
1962        session_store,
1963        signing_keys_opt,
1964    )?;
1965    tracing::debug!(backend = ?config.session.backend, "Session management enabled");
1966
1967    // Error page filter: renders HTML error pages for browser requests.
1968    // Always registered (uses default renderer if no custom one is provided).
1969    let is_dev = config
1970        .profile
1971        .as_deref()
1972        .map_or(cfg!(debug_assertions), |p| p == "dev");
1973    let renderer = error_page_renderer.unwrap_or_else(error_pages::default_renderer);
1974    // Encrypted columns (#805) compose into log scrubbing (#697): their names are
1975    // always scrubbed from trace/error parameter output so ciphertext-backed
1976    // values never leak through logs even if an app forgets to list them.
1977    let mut filter_parameters = config.log.filter_parameters.clone();
1978    filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
1979    let error_page_filter = crate::middleware::error_page_filter::ErrorPageFilter {
1980        renderer,
1981        is_dev,
1982        parameter_filter: crate::log::filter::ParameterFilter::new(
1983            &filter_parameters,
1984            &config.log.unfilter_parameters,
1985        ),
1986    };
1987
1988    // Combine the Problem Details normalizer and error page filter with user
1989    // exception filters. Problem Details runs first so HTML negotiation can
1990    // still replace the JSON response for browser requests.
1991    let mut all_filters: Vec<Arc<dyn ExceptionFilter>> = vec![
1992        Arc::new(ProblemDetailsFilter { is_dev }),
1993        Arc::new(error_page_filter),
1994    ];
1995    all_filters.extend(exception_filters);
1996
1997    let count = all_filters.len();
1998    tracing::debug!(
1999        count,
2000        "Registered exception filters (including error page filter)"
2001    );
2002
2003    // Error page context layer must be inner to the exception filter so
2004    // WantsHtml is set on the response before the filter inspects it.
2005    // Full ingress layer order (outermost -> innermost):
2006    //   TraceContext (applied outside the startup barrier so short-circuit
2007    //   responses still carry traceparent) ->
2008    //   Compression (outer to ExceptionFilter — see note below) ->
2009    //   [user layers, when SSG/ISG dist dir active] ->
2010    //   StaticFileMiddleware (when SSG/ISG enabled) ->
2011    //   Metrics -> ExceptionFilter -> ErrorPageContext -> Session ->
2012    //   SecurityHeaders -> RequestId -> LogContext -> AccessLog-primary ->
2013    //   [user layers, non-static build] ->
2014    //   Tenancy -> RateLimit -> CSRF -> CORS -> handler
2015    //   (An AccessLog fallback sits outermost, applied in apply_startup_barrier.)
2016    let router = router
2017        .layer(crate::middleware::error_page_filter::ErrorPageContextLayer { is_dev })
2018        .layer(ExceptionFilterLayer::new(all_filters))
2019        .layer(crate::middleware::MetricsLayer::new(state.metrics.clone()));
2020
2021    // Response compression is applied outermost (outside ExceptionFilter) so that
2022    // exception filters which rebuild the response body (e.g. ProblemDetailsFilter
2023    // normalising AutumnErrors to JSON Problem Details) do so before the body is
2024    // encoded. If compression were inner to ExceptionFilter, the filter would
2025    // inherit a Content-Encoding: gzip header on the rebuilt uncompressed body,
2026    // causing clients to receive uncompressed bytes labeled as gzip.
2027    // User-registered layers (EtagLayer etc.) remain inner to Compression, so
2028    // ETags are still computed on the uncompressed body before encoding occurs.
2029    let router = apply_compression_middleware(router, config);
2030
2031    Ok(router)
2032}
2033
2034async fn trusted_host_middleware(
2035    req: Request<axum::body::Body>,
2036    next: Next,
2037    policy: TrustedHostPolicy,
2038) -> axum::response::Response {
2039    let path = req.uri().path();
2040    if (req.method() == http::Method::GET || req.method() == http::Method::HEAD)
2041        && policy.probe_bypass_paths.contains(path)
2042    {
2043        return next.run(req).await;
2044    }
2045    let authority = req.uri().authority().map(http::uri::Authority::as_str);
2046    let host_header = req
2047        .headers()
2048        .get(http::header::HOST)
2049        .and_then(|v| v.to_str().ok());
2050    let raw_host = authority.or(host_header);
2051    let parsed_host = raw_host.and_then(extract_host_without_port);
2052    let host = parsed_host
2053        .map(str::to_ascii_lowercase)
2054        .map(|h| h.trim_end_matches('.').to_owned())
2055        .filter(|h| !h.is_empty());
2056    let host_source_present = raw_host.is_some();
2057    if host.is_none() && !host_source_present && policy.allow_missing_host {
2058        return next.run(req).await;
2059    }
2060    if host.as_deref().is_some_and(|host| policy.allows_host(host)) {
2061        next.run(req).await
2062    } else {
2063        tracing::warn!(host = ?host, "trusted host rejected request");
2064        let body = crate::error::problem_details_json_string(
2065            StatusCode::BAD_REQUEST,
2066            "Invalid Host header",
2067            None,
2068            None,
2069            None,
2070            None,
2071            true,
2072        );
2073        (
2074            StatusCode::BAD_REQUEST,
2075            [(http::header::CONTENT_TYPE, "application/problem+json")],
2076            body,
2077        )
2078            .into_response()
2079    }
2080}
2081
2082pub fn extract_host_without_port(header: &str) -> Option<&str> {
2083    let host = header.trim();
2084    if host.is_empty() {
2085        return None;
2086    }
2087    if host.starts_with('[') {
2088        let end = host.find(']')?;
2089        let literal = host.get(1..end)?;
2090        if literal.is_empty() || literal.parse::<std::net::IpAddr>().is_err() {
2091            return None;
2092        }
2093
2094        let remainder = host.get(end + 1..)?;
2095        if remainder.is_empty() {
2096            return Some(literal);
2097        }
2098
2099        let maybe_port = remainder.strip_prefix(':')?;
2100        if !maybe_port.is_empty() && maybe_port.chars().all(|c| c.is_ascii_digit()) {
2101            return Some(literal);
2102        }
2103
2104        return None;
2105    }
2106    let Some((candidate, maybe_port)) = host.rsplit_once(':') else {
2107        return Some(host);
2108    };
2109    if candidate.contains(':') {
2110        // unbracketed IPv6 literal; keep host verbatim
2111        return Some(host);
2112    }
2113    if !maybe_port.is_empty()
2114        && maybe_port.chars().all(|c| c.is_ascii_digit())
2115        && !candidate.is_empty()
2116    {
2117        Some(candidate)
2118    } else {
2119        None
2120    }
2121}
2122
2123/// Build the router with optional static-file-first serving.
2124///
2125/// If `dist_dir` is `Some` and contains a valid `manifest.json`, the
2126/// returned router intercepts GET/HEAD requests whose path appears in
2127/// the manifest and serves pre-built HTML directly — before the dynamic
2128/// router runs.  This matches Next.js SSG/ISR semantics where static
2129/// pages always win over dynamic handlers.
2130///
2131/// Requests not in the manifest (including non-GET/HEAD methods) fall
2132/// through to the dynamic router unchanged.
2133///
2134/// When `dist_dir` is `None` or the manifest is missing, the returned
2135/// router is identical to [`build_router`].
2136///
2137/// This function is public primarily for integration testing.
2138///
2139/// # Panics
2140///
2141/// Panics when framework router assembly encounters invalid configuration.
2142/// Use [`try_build_router_with_static`] to handle configuration errors
2143/// explicitly.
2144#[allow(dead_code)]
2145pub fn build_router_with_static(
2146    route_list: Vec<Route>,
2147    config: &AutumnConfig,
2148    state: AppState,
2149    dist_dir: Option<&std::path::Path>,
2150) -> axum::Router {
2151    try_build_router_with_static(route_list, config, state, dist_dir)
2152        .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
2153}
2154
2155/// Checked variant of [`build_router_with_static`] that returns configuration
2156/// errors instead of panicking.
2157///
2158/// # Errors
2159///
2160/// Returns [`RouterBuildError`] when router assembly encounters invalid
2161/// framework configuration, such as an unusable session backend.
2162#[allow(dead_code)]
2163pub fn try_build_router_with_static(
2164    route_list: Vec<Route>,
2165    config: &AutumnConfig,
2166    state: AppState,
2167    dist_dir: Option<&std::path::Path>,
2168) -> Result<axum::Router, RouterBuildError> {
2169    try_build_router_with_static_inner(
2170        route_list,
2171        config,
2172        state,
2173        dist_dir,
2174        RouterContext {
2175            exception_filters: Vec::new(),
2176            scoped_groups: Vec::new(),
2177            merge_routers: Vec::new(),
2178            nest_routers: Vec::new(),
2179            custom_layers: Vec::new(),
2180            error_page_renderer: None,
2181            session_store: None,
2182            #[cfg(feature = "openapi")]
2183            openapi: None,
2184            #[cfg(feature = "mcp")]
2185            mcp: None,
2186        },
2187    )
2188}
2189
2190pub fn try_build_router_with_static_inner(
2191    route_list: Vec<Route>,
2192    config: &AutumnConfig,
2193    state: AppState,
2194    dist_dir: Option<&std::path::Path>,
2195    mut ctx: RouterContext,
2196) -> Result<axum::Router, RouterBuildError> {
2197    let startup_barrier_state = state.clone();
2198
2199    let Some(dist) = dist_dir else {
2200        let app_router = try_build_router_inner(route_list, config, state, ctx)?;
2201        return Ok(apply_startup_barrier(
2202            app_router,
2203            config,
2204            &startup_barrier_state,
2205        ));
2206    };
2207
2208    let Some(layer) = crate::static_gen::StaticFileLayer::new(dist) else {
2209        tracing::debug!(
2210            dist = %dist.display(),
2211            "No valid manifest.json in dist dir; skipping static file layer"
2212        );
2213        let app_router = try_build_router_inner(route_list, config, state, ctx)?;
2214        return Ok(apply_startup_barrier(
2215            app_router,
2216            config,
2217            &startup_barrier_state,
2218        ));
2219    };
2220
2221    for (route, entry) in &layer.manifest().routes {
2222        tracing::debug!(
2223            route = %route,
2224            file = %entry.file,
2225            revalidate = ?entry.revalidate,
2226            "Static route"
2227        );
2228    }
2229
2230    // Extract user layers before building the inner router. They are applied
2231    // OUTSIDE the static-first middleware (and outside session) so that:
2232    //   • User layers (e.g. compression) can process pre-rendered responses.
2233    //   • Static serving remains available even if the session backend is down.
2234    //   • ISR regeneration uses the inner router (no user layers), ensuring
2235    //     re-rendered pages are saved as raw HTML rather than pre-transformed.
2236    //
2237    // Compute the idempotency flag NOW while custom_layers is still populated,
2238    // then drain it. build_router_pre_state would otherwise see an empty list
2239    // and incorrectly treat opaque layers as absent when selecting idempotency
2240    // behaviour for each route.
2241    let opaque_present = Some(custom_layers_require_fail_closed_idempotency(
2242        &ctx.custom_layers,
2243    ));
2244    let custom_layers = std::mem::take(&mut ctx.custom_layers);
2245
2246    let inner_router = build_router_pre_state(route_list, config, &state, ctx, opaque_present)?;
2247
2248    // Attach the inner router for ISR background regeneration. Because user
2249    // layers are excluded, re-renders produce raw HTML (no compression, etc.)
2250    // that is then saved to disk and served with user-layer processing applied
2251    // at request time.
2252    let has_isr = layer
2253        .manifest()
2254        .routes
2255        .values()
2256        .any(|e| e.revalidate.is_some());
2257    let layer = if has_isr {
2258        layer.with_router(inner_router.clone().with_state(state.clone()))
2259    } else {
2260        layer
2261    };
2262    let layer = Arc::new(layer);
2263
2264    // Static-first serving: intercept GET/HEAD requests whose path appears
2265    // in the manifest and serve pre-built HTML directly — BEFORE the dynamic
2266    // router (and session layer) runs. This preserves availability of static
2267    // pages even when the session backend is unavailable.
2268    //
2269    // Requests not in the manifest (including non-GET/HEAD methods) fall
2270    // through to the dynamic router unchanged.
2271    //
2272    // ISR staleness checking happens inside `resolve()`: stale pages are
2273    // still served immediately while background regeneration runs
2274    // (stale-while-revalidate).
2275    let static_layer = layer;
2276    let mut router: axum::Router<AppState> = inner_router.layer(axum::middleware::from_fn(
2277        move |req: axum::extract::Request, next: axum::middleware::Next| {
2278            let static_layer = static_layer.clone();
2279            async move {
2280                let is_get = req.method() == http::Method::GET;
2281                let is_head = req.method() == http::Method::HEAD;
2282                if is_get || is_head {
2283                    let path = req.uri().path();
2284                    // Normalize trailing slash: /about/ → /about (but keep / as /)
2285                    let normalized = if path.len() > 1 && path.ends_with('/') {
2286                        &path[..path.len() - 1]
2287                    } else {
2288                        path
2289                    };
2290                    if let Some(file_path) = static_layer.resolve(normalized)
2291                        && let Ok(contents) = tokio::fs::read(&file_path).await
2292                    {
2293                        let body = if is_head {
2294                            axum::body::Body::empty()
2295                        } else {
2296                            axum::body::Body::from(contents)
2297                        };
2298                        return http::Response::builder()
2299                            .status(http::StatusCode::OK)
2300                            .header(http::header::CONTENT_TYPE, "text/html; charset=utf-8")
2301                            .body(body)
2302                            .expect("infallible response builder");
2303                    }
2304                }
2305                next.run(req).await
2306            }
2307        },
2308    ));
2309
2310    // Apply user layers OUTSIDE the static middleware so they wrap it and can
2311    // process both static and dynamic responses (e.g. compress the HTML on
2312    // the way out). Iterate in reverse so the first registered layer ends up
2313    // outermost — matching tower::ServiceBuilder ordering.
2314    let custom_layer_count = custom_layers.len();
2315    for registered in custom_layers.into_iter().rev() {
2316        router = (registered.apply)(router);
2317    }
2318    if custom_layer_count > 0 {
2319        tracing::debug!(
2320            count = custom_layer_count,
2321            "Custom Tower layers applied outside static middleware"
2322        );
2323    }
2324
2325    // Compression must also be applied OUTSIDE the static-first middleware so
2326    // that pre-rendered HTML pages (served directly by StaticFileLayer without
2327    // reaching inner_router) are also compressed. This mirrors the placement in
2328    // apply_middleware for the dynamic-only path.
2329    router = apply_compression_middleware(router, config);
2330
2331    let router = router.layer(crate::security::SecurityHeadersLayer::from_config(
2332        &config.security.headers,
2333    ));
2334
2335    Ok(apply_startup_barrier(
2336        router.with_state(state),
2337        config,
2338        &startup_barrier_state,
2339    ))
2340}
2341
2342#[derive(Clone)]
2343struct StartupBarrierState {
2344    app_state: AppState,
2345    live_path: String,
2346    ready_path: String,
2347    startup_path: String,
2348    health_path: String,
2349    actuator_paths: Vec<String>,
2350    actuator_subtree_paths: Vec<String>,
2351}
2352
2353impl StartupBarrierState {
2354    fn from_config(config: &AutumnConfig, app_state: &AppState) -> Self {
2355        let actuator_subtree_paths = if config.actuator.sensitive {
2356            vec![crate::actuator::actuator_route_path(
2357                &config.actuator.prefix,
2358                "/loggers",
2359            )]
2360        } else {
2361            Vec::new()
2362        };
2363
2364        Self {
2365            app_state: app_state.clone(),
2366            live_path: config.health.live_path.clone(),
2367            ready_path: config.health.ready_path.clone(),
2368            startup_path: config.health.startup_path.clone(),
2369            health_path: config.health.path.clone(),
2370            actuator_paths: crate::actuator::actuator_endpoint_paths(
2371                &config.actuator.prefix,
2372                config.actuator.sensitive,
2373                config.actuator.prometheus,
2374            ),
2375            actuator_subtree_paths,
2376        }
2377    }
2378
2379    fn allows_path(&self, path: &str) -> bool {
2380        path == self.live_path
2381            || path == self.ready_path
2382            || path == self.startup_path
2383            || path == self.health_path
2384            || self.actuator_paths.iter().any(|allowed| path == allowed)
2385            || self
2386                .actuator_subtree_paths
2387                .iter()
2388                .any(|allowed| path_matches_route_prefix(path, allowed))
2389    }
2390}
2391
2392fn apply_startup_barrier(
2393    router: axum::Router,
2394    config: &AutumnConfig,
2395    state: &AppState,
2396) -> axum::Router {
2397    let barrier_state = StartupBarrierState::from_config(config, state);
2398    let router = router.layer(axum::middleware::from_fn_with_state(
2399        barrier_state,
2400        startup_barrier,
2401    ));
2402    // Access-log fallback (#999), applied OUTSIDE the startup barrier, the
2403    // static-first (SSG/ISR) middleware, the session layer, and the
2404    // exception-filter chain — every production build path funnels through
2405    // this function, including after the late MCP endpoint merge. It emits
2406    // only for responses the primary in-stack layer never saw (it checks the
2407    // AccessLogEmitted response marker), giving startup 503s, pre-built
2408    // static page hits, session-store outage 503s, and MCP endpoint requests
2409    // an access line too. Those short-circuits never ran RequestIdLayer, so
2410    // the fallback reads `x-request-id` from the response when present and
2411    // logs without a request id otherwise.
2412    let router = if config.log.access_log {
2413        router.layer(crate::middleware::AccessLogLayer::fallback(
2414            config.log.access_log_exclude.clone(),
2415        ))
2416    } else {
2417        router
2418    };
2419    // W3C Trace Context propagation wraps the startup barrier (and the
2420    // static-first middleware above it) so short-circuit responses —
2421    // startup 503s and pre-built static file hits — still extract the
2422    // incoming `traceparent` and inject the current context into the
2423    // outgoing response. Applied here rather than inside `apply_middleware`
2424    // because those outer wrappers can return without ever invoking the
2425    // inner router. Outer to AccessLog so the access event is emitted while
2426    // the trace context is current.
2427    #[cfg(feature = "telemetry-otlp")]
2428    let router = router.layer(crate::middleware::TraceContextLayer);
2429    router
2430}
2431
2432async fn startup_barrier(
2433    State(state): State<StartupBarrierState>,
2434    request: axum::extract::Request,
2435    next: Next,
2436) -> axum::response::Response {
2437    if crate::app::is_static_build_mode()
2438        || state.app_state.probes().is_startup_complete()
2439        || state.allows_path(request.uri().path())
2440    {
2441        next.run(request).await
2442    } else {
2443        (
2444            StatusCode::SERVICE_UNAVAILABLE,
2445            "Service is still starting up",
2446        )
2447            .into_response()
2448    }
2449}
2450
2451fn path_matches_route_prefix(path: &str, prefix: &str) -> bool {
2452    path == prefix
2453        || path
2454            .strip_prefix(prefix)
2455            .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
2456}
2457
2458/// Build a `tower_http::cors::CorsLayer` from the framework's [`crate::config::CorsConfig`].
2459///
2460/// Called only when `config.cors.allowed_origins` is non-empty.
2461pub fn build_cors_layer(cors: &crate::config::CorsConfig) -> tower_http::cors::CorsLayer {
2462    use http::header::HeaderName;
2463    use tower_http::cors::{AllowOrigin, CorsLayer};
2464
2465    let layer = if cors.allowed_origins.iter().any(|o| o == "*") {
2466        CorsLayer::new().allow_origin(AllowOrigin::any())
2467    } else {
2468        let origins: Vec<http::HeaderValue> = cors
2469            .allowed_origins
2470            .iter()
2471            .filter_map(|o| match o.parse() {
2472                Ok(v) => Some(v),
2473                Err(e) => {
2474                    tracing::warn!(origin = %o, error = %e, "CORS: ignoring malformed allowed_origin");
2475                    None
2476                }
2477            })
2478            .collect();
2479        CorsLayer::new().allow_origin(origins)
2480    };
2481
2482    let methods: Vec<http::Method> = cors
2483        .allowed_methods
2484        .iter()
2485        .filter_map(|m| match m.parse() {
2486            Ok(v) => Some(v),
2487            Err(e) => {
2488                tracing::warn!(method = %m, error = %e, "CORS: ignoring malformed allowed_method");
2489                None
2490            }
2491        })
2492        .collect();
2493
2494    let headers: Vec<HeaderName> = cors
2495        .allowed_headers
2496        .iter()
2497        .filter_map(|h| match h.parse() {
2498            Ok(v) => Some(v),
2499            Err(e) => {
2500                tracing::warn!(header = %h, error = %e, "CORS: ignoring malformed allowed_header");
2501                None
2502            }
2503        })
2504        .collect();
2505
2506    layer
2507        .allow_methods(methods)
2508        .allow_headers(headers)
2509        .allow_credentials(cors.allow_credentials)
2510        .max_age(std::time::Duration::from_secs(cors.max_age_secs))
2511}
2512
2513/// Set `Cache-Control` headers for static assets based on whether the path is
2514/// fingerprinted.
2515///
2516/// | Path | Header |
2517/// |------|--------|
2518/// | `/static/**.<8hex>.*` | `public, max-age=31536000, immutable` |
2519/// | `/static/**` (other) | `public, max-age=0, must-revalidate` |
2520/// | Everything else | unchanged |
2521///
2522/// The short `must-revalidate` policy for plain static paths ensures that
2523/// returning visitors always fetch the latest file after a deploy, while the
2524/// long `immutable` policy for fingerprinted files lets browsers skip the
2525/// network entirely for assets whose content will never change.
2526pub async fn asset_cache_control(
2527    req: axum::extract::Request,
2528    next: axum::middleware::Next,
2529) -> axum::response::Response {
2530    let path = req.uri().path().to_owned();
2531    let mut resp = next.run(req).await;
2532    if path.starts_with("/static/") && resp.status().is_success() {
2533        // Use manifest membership rather than filename pattern so that
2534        // user-authored assets like `vendor.deadbeef.js` are never given an
2535        // immutable cache lifetime.
2536        let is_immutable = path
2537            .strip_prefix("/static/")
2538            .is_some_and(crate::assets::is_manifest_asset);
2539        let header = if is_immutable {
2540            "public, max-age=31536000, immutable"
2541        } else {
2542            "public, max-age=0, must-revalidate"
2543        };
2544        resp.headers_mut().insert(
2545            http::header::CACHE_CONTROL,
2546            http::HeaderValue::from_static(header),
2547        );
2548    }
2549    resp
2550}
2551
2552#[cfg(feature = "htmx")]
2553pub async fn htmx_handler() -> axum::response::Response {
2554    use axum::response::IntoResponse;
2555    (
2556        [
2557            (http::header::CONTENT_TYPE, "application/javascript"),
2558            (
2559                http::header::CACHE_CONTROL,
2560                "public, max-age=31536000, immutable",
2561            ),
2562        ],
2563        crate::htmx::HTMX_JS,
2564    )
2565        .into_response()
2566}
2567
2568#[cfg(feature = "htmx")]
2569pub async fn htmx_csrf_handler() -> axum::response::Response {
2570    use axum::response::IntoResponse;
2571    (
2572        [
2573            (http::header::CONTENT_TYPE, "application/javascript"),
2574            (
2575                http::header::CACHE_CONTROL,
2576                "public, max-age=31536000, immutable",
2577            ),
2578        ],
2579        crate::htmx::HTMX_CSRF_JS,
2580    )
2581        .into_response()
2582}
2583
2584#[cfg(feature = "htmx")]
2585pub async fn autumn_widgets_handler() -> axum::response::Response {
2586    use axum::response::IntoResponse;
2587    (
2588        [
2589            (http::header::CONTENT_TYPE, "application/javascript"),
2590            (
2591                http::header::CACHE_CONTROL,
2592                "public, max-age=31536000, immutable",
2593            ),
2594        ],
2595        crate::htmx::AUTUMN_WIDGETS_JS,
2596    )
2597        .into_response()
2598}
2599
2600#[cfg(feature = "openapi")]
2601fn collect_openapi_docs(
2602    route_list: &[Route],
2603    scoped_groups: &[ScopedGroup],
2604) -> Vec<crate::openapi::ApiDoc> {
2605    // Walk both top-level routes and scoped groups. For scoped groups the
2606    // effective path is `prefix + route.path`; we materialize these into
2607    // fresh `ApiDoc`s so the rendered spec reflects the actual URL the
2608    // user will call.
2609    let mut docs: Vec<crate::openapi::ApiDoc> = Vec::new();
2610    for route in route_list {
2611        let mut doc = route.api_doc.clone();
2612        doc.api_version = route.api_version;
2613        doc.sunset_opt_out = route.sunset_opt_out;
2614        docs.push(doc);
2615    }
2616    for group in scoped_groups {
2617        // Extract `{name}` captures from the scope prefix so parameters
2618        // declared in the prefix (e.g. `/orgs/{org_id}`) show up on the
2619        // generated operation alongside the child route's own params.
2620        let prefix_params = extract_path_params(&group.prefix);
2621        for route in &group.routes {
2622            let mut doc = route.api_doc.clone();
2623            doc.api_version = route.api_version;
2624            doc.sunset_opt_out = route.sunset_opt_out;
2625            // Leak the combined path so it fits the `&'static str` shape of
2626            // ApiDoc. The spec is built once per process; the leak is
2627            // bounded by the route table size. Using the same
2628            // normalization as `join_nested_path` keeps the spec's
2629            // paths aligned with the URLs axum actually routes.
2630            let full = join_nested_path(&group.prefix, route.api_doc.path);
2631            doc.path = Box::leak(full.into_boxed_str());
2632
2633            if !prefix_params.is_empty() {
2634                let mut merged: Vec<&'static str> = prefix_params
2635                    .iter()
2636                    .map(|p| &*Box::leak(p.clone().into_boxed_str()))
2637                    .collect();
2638                for existing in route.api_doc.path_params {
2639                    if !merged.iter().any(|n| n == existing) {
2640                        merged.push(existing);
2641                    }
2642                }
2643                doc.path_params = Box::leak(merged.into_boxed_slice());
2644            }
2645
2646            docs.push(doc);
2647        }
2648    }
2649    docs
2650}
2651
2652#[cfg(feature = "openapi")]
2653fn mount_swagger_ui_routes(
2654    mut router: axum::Router<AppState>,
2655    path: &str,
2656    title: &str,
2657    json_path: &str,
2658) -> axum::Router<AppState> {
2659    let [css_path, bundle_path, initializer_path] = crate::openapi::swagger_ui_asset_paths(path);
2660    let html_body = Arc::new(crate::openapi::swagger_ui_html(
2661        title,
2662        &css_path,
2663        &bundle_path,
2664        &initializer_path,
2665    ));
2666    let initializer_body = Arc::new(crate::openapi::swagger_ui_initializer_js(json_path));
2667    router = router.route(
2668        path,
2669        axum::routing::get(move || {
2670            let html = html_body.clone();
2671            async move {
2672                use axum::response::IntoResponse;
2673                (
2674                    [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
2675                    (*html).clone(),
2676                )
2677                    .into_response()
2678            }
2679        }),
2680    );
2681    router = router.route(
2682        &css_path,
2683        axum::routing::get(|| async move {
2684            use axum::response::IntoResponse;
2685            (
2686                [(http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
2687                crate::openapi::SWAGGER_UI_CSS,
2688            )
2689                .into_response()
2690        }),
2691    );
2692    router = router.route(
2693        &bundle_path,
2694        axum::routing::get(|| async move {
2695            use axum::body::Bytes;
2696            use axum::response::IntoResponse;
2697            (
2698                [(
2699                    http::header::CONTENT_TYPE,
2700                    "application/javascript; charset=utf-8",
2701                )],
2702                Bytes::from_static(crate::openapi::SWAGGER_UI_BUNDLE),
2703            )
2704                .into_response()
2705        }),
2706    );
2707    router = router.route(
2708        &initializer_path,
2709        axum::routing::get(move || {
2710            let js = initializer_body.clone();
2711            async move {
2712                use axum::response::IntoResponse;
2713                (
2714                    [(
2715                        http::header::CONTENT_TYPE,
2716                        "application/javascript; charset=utf-8",
2717                    )],
2718                    (*js).clone(),
2719                )
2720                    .into_response()
2721            }
2722        }),
2723    );
2724    router
2725}
2726
2727#[cfg(feature = "oauth2")]
2728async fn http_interceptor_middleware(
2729    state: axum::extract::State<AppState>,
2730    req: axum::extract::Request,
2731    next: axum::middleware::Next,
2732) -> axum::response::Response {
2733    use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
2734    if let Some(interceptor_arc) = state.extension::<Arc<dyn HttpInterceptor>>() {
2735        let interceptor = (*interceptor_arc).clone();
2736        let interceptors = vec![interceptor];
2737        ACTIVE_HTTP_INTERCEPTORS
2738            .scope(interceptors, async move { next.run(req).await })
2739            .await
2740    } else {
2741        next.run(req).await
2742    }
2743}
2744
2745#[cfg(test)]
2746mod tests {
2747    use super::*;
2748    use axum::body::Body;
2749    use axum::http::{Request, StatusCode};
2750    use tower::ServiceExt;
2751
2752    fn test_state() -> AppState {
2753        AppState {
2754            extensions: std::sync::Arc::new(std::sync::RwLock::new(
2755                std::collections::HashMap::new(),
2756            )),
2757            #[cfg(feature = "db")]
2758            pool: None,
2759            #[cfg(feature = "db")]
2760            replica_pool: None,
2761            profile: Some("test".to_owned()),
2762            started_at: std::time::Instant::now(),
2763            health_detailed: false,
2764            probes: crate::probe::ProbeState::ready_for_test(),
2765            metrics: crate::middleware::MetricsCollector::new(),
2766            log_levels: crate::actuator::LogLevels::new("info"),
2767            task_registry: crate::actuator::TaskRegistry::new(),
2768            job_registry: crate::actuator::JobRegistry::new(),
2769            config_props: crate::actuator::ConfigProperties::default(),
2770            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
2771            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
2772            #[cfg(feature = "ws")]
2773            channels: crate::channels::Channels::new(32),
2774            #[cfg(feature = "presence")]
2775            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
2776            #[cfg(feature = "ws")]
2777            shutdown: tokio_util::sync::CancellationToken::new(),
2778            policy_registry: crate::authorization::PolicyRegistry::default(),
2779            forbidden_response: crate::authorization::ForbiddenResponse::default(),
2780            auth_session_key: "user_id".to_owned(),
2781            shared_cache: None,
2782            clock: std::sync::Arc::new(crate::time::SystemClock),
2783        }
2784    }
2785
2786    #[tokio::test]
2787    async fn build_router_mounts_actuator_at_configured_prefix() {
2788        let mut config = AutumnConfig::default();
2789        config.actuator.prefix = "/ops".to_owned();
2790        config.actuator.sensitive = true;
2791
2792        let app = build_router(Vec::new(), &config, test_state());
2793
2794        let prefixed = app
2795            .clone()
2796            .oneshot(
2797                Request::builder()
2798                    .uri("/ops/health")
2799                    .body(Body::empty())
2800                    .unwrap(),
2801            )
2802            .await
2803            .unwrap();
2804        assert_eq!(prefixed.status(), StatusCode::OK);
2805
2806        let legacy = app
2807            .oneshot(
2808                Request::builder()
2809                    .uri("/actuator/health")
2810                    .body(Body::empty())
2811                    .unwrap(),
2812            )
2813            .await
2814            .unwrap();
2815        assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
2816    }
2817
2818    /// Pins the production access-log wiring (#999): the layer is applied in
2819    /// `apply_startup_barrier`, outside the barrier itself, so even requests
2820    /// rejected with 503 before the app router runs emit one access event
2821    /// carrying the status the client receives.
2822    #[test]
2823    fn startup_barrier_503s_are_access_logged() {
2824        use tracing_subscriber::layer::SubscriberExt as _;
2825
2826        #[derive(Clone, Default)]
2827        struct Capture {
2828            events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
2829        }
2830        struct Visitor<'a>(&'a mut std::collections::BTreeMap<String, String>);
2831        impl tracing::field::Visit for Visitor<'_> {
2832            fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
2833                self.0.insert(field.name().to_owned(), format!("{value:?}"));
2834            }
2835            fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
2836                self.0.insert(field.name().to_owned(), value.to_string());
2837            }
2838        }
2839        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
2840            fn on_event(
2841                &self,
2842                event: &tracing::Event<'_>,
2843                _ctx: tracing_subscriber::layer::Context<'_, S>,
2844            ) {
2845                if event.metadata().target() != crate::middleware::ACCESS_LOG_TARGET {
2846                    return;
2847                }
2848                let mut fields = std::collections::BTreeMap::new();
2849                event.record(&mut Visitor(&mut fields));
2850                self.events.lock().unwrap().push(fields);
2851            }
2852        }
2853
2854        let capture = Capture::default();
2855        let events = Arc::clone(&capture.events);
2856        let subscriber = tracing_subscriber::registry().with(capture);
2857
2858        tracing::subscriber::with_default(subscriber, || {
2859            // With startup incomplete, the barrier rejects non-probe requests
2860            // with 503 before the app router runs.
2861            let state = AppState::for_test()
2862                .with_profile("test")
2863                .with_startup_complete(false);
2864            let app = build_router(Vec::new(), &AutumnConfig::default(), state);
2865            let rt = tokio::runtime::Builder::new_current_thread()
2866                .enable_all()
2867                .build()
2868                .unwrap();
2869            let response = rt.block_on(async {
2870                app.oneshot(
2871                    Request::builder()
2872                        .uri("/not-a-probe")
2873                        .body(Body::empty())
2874                        .unwrap(),
2875                )
2876                .await
2877                .unwrap()
2878            });
2879            assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
2880        });
2881
2882        let events = events.lock().unwrap().clone();
2883        assert_eq!(
2884            events.len(),
2885            1,
2886            "a barrier-rejected request should emit one access event: {events:?}"
2887        );
2888        assert_eq!(events[0].get("status").map(String::as_str), Some("503"));
2889        assert!(
2890            !events[0].contains_key("request_id"),
2891            "barrier short-circuits before RequestIdLayer, so no request id"
2892        );
2893    }
2894
2895    #[test]
2896    fn try_build_router_rejects_invalid_session_backend_config() {
2897        let mut config = AutumnConfig::default();
2898        config.session.backend = crate::session::SessionBackend::Redis;
2899
2900        let error = try_build_router(Vec::new(), &config, test_state())
2901            .expect_err("missing redis config should fail checked router build");
2902
2903        assert!(matches!(
2904            error,
2905            RouterBuildError::InvalidSessionBackend(
2906                crate::session::SessionBackendConfigError::MissingRedisUrl
2907            )
2908        ));
2909    }
2910
2911    #[test]
2912    fn try_build_router_with_static_rejects_invalid_session_backend_config() {
2913        let mut config = AutumnConfig::default();
2914        config.session.backend = crate::session::SessionBackend::Redis;
2915
2916        let error = try_build_router_with_static(Vec::new(), &config, test_state(), None)
2917            .expect_err("missing redis config should fail checked static router build");
2918
2919        assert!(matches!(
2920            error,
2921            RouterBuildError::InvalidSessionBackend(
2922                crate::session::SessionBackendConfigError::MissingRedisUrl
2923            )
2924        ));
2925    }
2926
2927    #[test]
2928    fn try_build_router_returns_error_for_probe_actuator_path_overlap() {
2929        let mut config = AutumnConfig::default();
2930        config.actuator.prefix = "/".to_owned();
2931
2932        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2933            try_build_router(Vec::new(), &config, test_state())
2934        }));
2935
2936        assert!(result.is_ok(), "try_build_router panicked on route overlap");
2937        assert!(
2938            result.unwrap().is_err(),
2939            "route overlap should be reported as a checked router build error"
2940        );
2941    }
2942
2943    #[tokio::test]
2944    async fn apply_cors_middleware_skipped_when_no_origins() {
2945        let config = AutumnConfig::default();
2946        assert!(config.cors.allowed_origins.is_empty());
2947
2948        let base: axum::Router<AppState> =
2949            axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
2950        let router = apply_cors_middleware(base, &config).with_state(test_state());
2951
2952        let response = router
2953            .oneshot(
2954                Request::builder()
2955                    .uri("/test")
2956                    .header("Origin", "https://example.com")
2957                    .body(Body::empty())
2958                    .unwrap(),
2959            )
2960            .await
2961            .unwrap();
2962
2963        assert_eq!(response.status(), StatusCode::OK);
2964        assert!(
2965            response
2966                .headers()
2967                .get("access-control-allow-origin")
2968                .is_none(),
2969            "CORS header must be absent when no origins are configured"
2970        );
2971    }
2972
2973    #[tokio::test]
2974    async fn apply_cors_middleware_present_when_origins_configured() {
2975        let mut config = AutumnConfig::default();
2976        config.cors.allowed_origins = vec!["https://example.com".to_owned()];
2977
2978        let base: axum::Router<AppState> =
2979            axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
2980        let router = apply_cors_middleware(base, &config).with_state(test_state());
2981
2982        let response = router
2983            .oneshot(
2984                Request::builder()
2985                    .uri("/test")
2986                    .header("Origin", "https://example.com")
2987                    .body(Body::empty())
2988                    .unwrap(),
2989            )
2990            .await
2991            .unwrap();
2992
2993        assert_eq!(response.status(), StatusCode::OK);
2994        assert!(
2995            response
2996                .headers()
2997                .get("access-control-allow-origin")
2998                .is_some(),
2999            "CORS header must be present when origins are configured"
3000        );
3001    }
3002
3003    #[tokio::test]
3004    async fn apply_cors_middleware_handles_preflight_request() {
3005        let mut config = AutumnConfig::default();
3006        config.cors.allowed_origins = vec!["https://example.com".to_owned()];
3007
3008        let base: axum::Router<AppState> =
3009            axum::Router::new().route("/api/widgets", axum::routing::post(|| async { "ok" }));
3010        let router = apply_cors_middleware(base, &config).with_state(test_state());
3011
3012        let response = router
3013            .oneshot(
3014                Request::builder()
3015                    .method("OPTIONS")
3016                    .uri("/api/widgets")
3017                    .header("Origin", "https://example.com")
3018                    .header("Access-Control-Request-Method", "POST")
3019                    .header("Access-Control-Request-Headers", "Content-Type")
3020                    .body(Body::empty())
3021                    .unwrap(),
3022            )
3023            .await
3024            .unwrap();
3025
3026        let headers = response.headers();
3027        assert_eq!(
3028            headers
3029                .get("access-control-allow-origin")
3030                .and_then(|v| v.to_str().ok()),
3031            Some("https://example.com"),
3032            "preflight must echo the allowed origin"
3033        );
3034        assert!(
3035            headers.get("access-control-allow-methods").is_some(),
3036            "preflight must advertise allowed methods"
3037        );
3038        assert!(
3039            headers.get("access-control-allow-headers").is_some(),
3040            "preflight must advertise allowed headers"
3041        );
3042        assert!(
3043            headers.get("access-control-max-age").is_some(),
3044            "preflight must advertise max-age so browsers can cache it"
3045        );
3046    }
3047
3048    #[tokio::test]
3049    async fn apply_csrf_middleware_skipped_when_disabled() {
3050        let config = AutumnConfig::default();
3051        assert!(!config.security.csrf.enabled);
3052
3053        let base: axum::Router<AppState> =
3054            axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
3055        let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
3056
3057        // Without CSRF the POST should pass through with no CSRF-specific response
3058        let response = router
3059            .oneshot(
3060                Request::builder()
3061                    .method("POST")
3062                    .uri("/form")
3063                    .body(Body::empty())
3064                    .unwrap(),
3065            )
3066            .await
3067            .unwrap();
3068
3069        assert_eq!(response.status(), StatusCode::OK);
3070    }
3071
3072    #[tokio::test]
3073    async fn apply_rate_limit_middleware_skipped_when_disabled() {
3074        let config = AutumnConfig::default();
3075        assert!(!config.security.rate_limit.enabled);
3076
3077        let base: axum::Router<AppState> =
3078            axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
3079        let state = test_state();
3080        let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
3081
3082        // Fire several rapid requests; none should be throttled.
3083        for _ in 0..5 {
3084            let response = router
3085                .clone()
3086                .oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
3087                .await
3088                .unwrap();
3089            assert_eq!(response.status(), StatusCode::OK);
3090        }
3091    }
3092
3093    #[tokio::test]
3094    async fn apply_rate_limit_middleware_returns_429_when_exhausted() {
3095        let mut config = AutumnConfig::default();
3096        config.security.rate_limit.enabled = true;
3097        config.security.rate_limit.requests_per_second = 0.1;
3098        config.security.rate_limit.burst = 1;
3099        config.security.rate_limit.trust_forwarded_headers = true;
3100
3101        let base: axum::Router<AppState> =
3102            axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
3103        let state = test_state();
3104        let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
3105
3106        let ok = router
3107            .clone()
3108            .oneshot(
3109                Request::builder()
3110                    .uri("/ping")
3111                    .header("X-Forwarded-For", "203.0.113.9")
3112                    .body(Body::empty())
3113                    .unwrap(),
3114            )
3115            .await
3116            .unwrap();
3117        assert_eq!(ok.status(), StatusCode::OK);
3118
3119        let blocked = router
3120            .oneshot(
3121                Request::builder()
3122                    .uri("/ping")
3123                    .header("X-Forwarded-For", "203.0.113.9")
3124                    .body(Body::empty())
3125                    .unwrap(),
3126            )
3127            .await
3128            .unwrap();
3129        assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
3130        assert!(blocked.headers().get("retry-after").is_some());
3131    }
3132
3133    #[cfg(feature = "mcp")]
3134    #[tokio::test]
3135    async fn mcp_envelope_is_gated_during_maintenance() {
3136        use crate::maintenance::{MaintenanceConfig, MaintenanceState};
3137
3138        // Trust the host the control request sends so that, with maintenance
3139        // off, the envelope's host guard lets `initialize` through.
3140        let mut config = AutumnConfig::default();
3141        config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
3142
3143        let wiring = crate::mcp::McpWiring {
3144            cors: crate::config::CorsConfig::default(),
3145            trusted_hosts: TrustedHostPolicy::from_config(&config),
3146            tenant_header: None,
3147            csrf_header: "x-csrf-token".to_owned(),
3148            envelope_rate_limited: false,
3149        };
3150        let mcp_router =
3151            crate::mcp::build_mcp_router("/mcp", Vec::new(), axum::Router::new(), wiring, None);
3152
3153        let initialize = || {
3154            Request::builder()
3155                .method("POST")
3156                .uri("/mcp")
3157                .header("host", "app.example")
3158                .header("content-type", "application/json")
3159                .body(Body::from(
3160                    serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize"}).to_string(),
3161                ))
3162                .unwrap()
3163        };
3164
3165        // Maintenance ON: the late-mounted envelope returns the documented 503
3166        // instead of serving the catalog — the gap this layer closes.
3167        let state = test_state();
3168        let maintenance = MaintenanceState::new();
3169        maintenance.enable(MaintenanceConfig::default());
3170        state.insert_extension(maintenance);
3171        let gated = mcp_router
3172            .clone()
3173            .layer(build_maintenance_layer(&config, &state))
3174            .with_state(state);
3175        let resp = gated.oneshot(initialize()).await.unwrap();
3176        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
3177
3178        // Maintenance OFF (no enabled state): the same envelope serves
3179        // `initialize` normally, confirming the gate is the only difference.
3180        let state = test_state();
3181        let open = mcp_router
3182            .layer(build_maintenance_layer(&config, &state))
3183            .with_state(state);
3184        let resp = open.oneshot(initialize()).await.unwrap();
3185        assert_eq!(resp.status(), StatusCode::OK);
3186    }
3187
3188    #[cfg(feature = "mail")]
3189    fn dev_mail_preview_config(dir: &std::path::Path) -> AutumnConfig {
3190        let mut config = AutumnConfig {
3191            profile: Some("dev".to_owned()),
3192            mail: crate::mail::MailConfig {
3193                transport: crate::mail::Transport::File,
3194                file_dir: dir.to_path_buf(),
3195                ..Default::default()
3196            },
3197            ..Default::default()
3198        };
3199        config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
3200        config
3201    }
3202
3203    #[cfg(feature = "mail")]
3204    async fn response_text(response: axum::response::Response) -> String {
3205        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3206            .await
3207            .expect("body should collect");
3208        String::from_utf8(body.to_vec()).expect("body should be utf8")
3209    }
3210
3211    #[cfg(feature = "mail")]
3212    #[tokio::test]
3213    async fn build_router_mounts_dev_mail_preview_empty_state_for_file_transport() {
3214        let dir = tempfile::tempdir().expect("tempdir");
3215        let config = dev_mail_preview_config(dir.path());
3216        let router = build_router(Vec::new(), &config, test_state());
3217
3218        let response = router
3219            .oneshot(
3220                Request::builder()
3221                    .uri("/_autumn/mail")
3222                    .header("host", "example.com")
3223                    .body(Body::empty())
3224                    .unwrap(),
3225            )
3226            .await
3227            .unwrap();
3228
3229        assert_eq!(response.status(), StatusCode::OK);
3230        let body = response_text(response).await;
3231        assert!(
3232            body.contains("No captured emails"),
3233            "missing empty state: {body}"
3234        );
3235        assert!(
3236            body.contains("mail.transport = &quot;file&quot;"),
3237            "empty state should explain capture setup: {body}"
3238        );
3239    }
3240
3241    #[cfg(feature = "mail")]
3242    #[tokio::test]
3243    async fn build_router_lists_captured_mail_newest_first() {
3244        let dir = tempfile::tempdir().expect("tempdir");
3245        let older = dir.path().join("older.eml");
3246        let newer = dir.path().join("newer.eml");
3247        std::fs::write(
3248            &older,
3249            "To: first@example.com\nSubject: First\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <first@example.com>\n\nfirst body\n",
3250        )
3251        .expect("write older eml");
3252        std::fs::write(
3253            &newer,
3254            "To: second@example.com\nSubject: Second\nDate: Tue, 05 May 2026 10:01:00 +0000\nMessage-Id: <second@example.com>\n\nsecond body\n",
3255        )
3256        .expect("write newer eml");
3257        filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(100, 0))
3258            .expect("set older mtime");
3259        filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(200, 0))
3260            .expect("set newer mtime");
3261
3262        let config = dev_mail_preview_config(dir.path());
3263        let router = build_router(Vec::new(), &config, test_state());
3264        let response = router
3265            .oneshot(
3266                Request::builder()
3267                    .uri("/_autumn/mail")
3268                    .header("host", "example.com")
3269                    .body(Body::empty())
3270                    .unwrap(),
3271            )
3272            .await
3273            .unwrap();
3274
3275        assert_eq!(response.status(), StatusCode::OK);
3276        let body = response_text(response).await;
3277        let second = body.find("Second").expect("newer subject should render");
3278        let first = body.find("First").expect("older subject should render");
3279        assert!(second < first, "newest message should render first: {body}");
3280        assert!(
3281            body.contains("second@example.com"),
3282            "missing To column: {body}"
3283        );
3284        assert!(
3285            body.contains("Timestamp"),
3286            "missing timestamp column: {body}"
3287        );
3288    }
3289
3290    #[cfg(feature = "mail")]
3291    #[tokio::test]
3292    async fn build_router_mail_preview_detail_renders_html_in_sandboxed_iframe() {
3293        let dir = tempfile::tempdir().expect("tempdir");
3294        std::fs::write(
3295            dir.path().join("detail.eml"),
3296            "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",
3297        )
3298        .expect("write detail eml");
3299
3300        let config = dev_mail_preview_config(dir.path());
3301        let router = build_router(Vec::new(), &config, test_state());
3302        let response = router
3303            .oneshot(
3304                Request::builder()
3305                    .uri("/_autumn/mail/messages/detail.eml")
3306                    .header("host", "example.com")
3307                    .body(Body::empty())
3308                    .unwrap(),
3309            )
3310            .await
3311            .unwrap();
3312
3313        assert_eq!(response.status(), StatusCode::OK);
3314        let body = response_text(response).await;
3315        assert!(body.contains("<iframe"), "missing iframe: {body}");
3316        assert!(body.contains("sandbox"), "iframe must be sandboxed: {body}");
3317        assert!(body.contains("Hello iframe"), "missing html body: {body}");
3318        assert!(body.contains("Plain text"), "missing text toggle: {body}");
3319        assert!(body.contains("Headers"), "missing headers toggle: {body}");
3320        assert!(
3321            body.contains("Raw .eml"),
3322            "missing raw source toggle: {body}"
3323        );
3324        assert!(
3325            body.contains("Message-Id"),
3326            "missing message id header: {body}"
3327        );
3328    }
3329
3330    #[cfg(feature = "mail")]
3331    #[tokio::test]
3332    async fn build_router_does_not_mount_mail_preview_outside_dev() {
3333        let dir = tempfile::tempdir().expect("tempdir");
3334        let mut config = dev_mail_preview_config(dir.path());
3335        config.profile = Some("prod".to_owned());
3336        let router = build_router(Vec::new(), &config, test_state());
3337
3338        let response = router
3339            .oneshot(
3340                Request::builder()
3341                    .uri("/_autumn/mail")
3342                    .header("host", "example.com")
3343                    .body(Body::empty())
3344                    .unwrap(),
3345            )
3346            .await
3347            .unwrap();
3348
3349        assert_eq!(response.status(), StatusCode::NOT_FOUND);
3350    }
3351
3352    #[tokio::test]
3353    async fn apply_csrf_middleware_blocks_without_token_when_enabled() {
3354        let mut config = AutumnConfig::default();
3355        config.security.csrf.enabled = true;
3356
3357        let base: axum::Router<AppState> =
3358            axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
3359        let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
3360
3361        // POST without CSRF token should be rejected
3362        let response = router
3363            .oneshot(
3364                Request::builder()
3365                    .method("POST")
3366                    .uri("/form")
3367                    .body(Body::empty())
3368                    .unwrap(),
3369            )
3370            .await
3371            .unwrap();
3372
3373        assert_ne!(
3374            response.status(),
3375            StatusCode::OK,
3376            "POST without CSRF token should be rejected when CSRF is enabled"
3377        );
3378    }
3379
3380    #[test]
3381    fn join_nested_path_normalizes_like_axum() {
3382        // Reviewer's reported case: scope "/api" + child "/" must
3383        // produce "/api", not "/api/" — otherwise a user-configured
3384        // openapi_json_path("/api") won't match the effective mount
3385        // point and the collision check is unreliable.
3386        assert_eq!(super::join_nested_path("/api", "/"), "/api");
3387        // Trailing slash on prefix is stripped.
3388        assert_eq!(super::join_nested_path("/api/", "/"), "/api");
3389        // Normal case: prefix + child.
3390        assert_eq!(super::join_nested_path("/api", "/users"), "/api/users");
3391        // Trailing slash on prefix + child starting with slash doesn't
3392        // produce doubled slashes.
3393        assert_eq!(super::join_nested_path("/api/", "/users"), "/api/users");
3394        // Root prefix handles sensibly.
3395        assert_eq!(super::join_nested_path("", "/"), "/");
3396        assert_eq!(super::join_nested_path("", "/users"), "/users");
3397    }
3398
3399    #[cfg(feature = "openapi")]
3400    #[tokio::test]
3401    async fn try_build_router_detects_scoped_root_collision() {
3402        // Scope "/api" + child "/" mounts axum's handler at "/api"
3403        // (not "/api/"). The collision check must use the same
3404        // normalization or we'd miss this overlap.
3405        use crate::openapi::{ApiDoc, OpenApiConfig};
3406        async fn child() -> &'static str {
3407            "inner"
3408        }
3409        let group = crate::app::ScopedGroup {
3410            prefix: "/api".to_owned(),
3411            routes: vec![Route {
3412                method: http::Method::GET,
3413                path: "/",
3414                handler: axum::routing::get(child),
3415                name: "root",
3416                api_doc: ApiDoc {
3417                    method: "GET",
3418                    path: "/",
3419                    operation_id: "root",
3420                    success_status: 200,
3421                    ..Default::default()
3422                },
3423                repository: None,
3424                idempotency: crate::route::RouteIdempotency::Direct,
3425                api_version: None,
3426                sunset_opt_out: false,
3427            }],
3428            source: crate::route_listing::RouteSource::User,
3429            apply_layer: Box::new(|r| r),
3430        };
3431
3432        let openapi = OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api");
3433        let config = AutumnConfig::default();
3434        let ctx = RouterContext {
3435            exception_filters: Vec::new(),
3436            scoped_groups: vec![group],
3437            merge_routers: Vec::new(),
3438            nest_routers: Vec::new(),
3439            custom_layers: Vec::new(),
3440            error_page_renderer: None,
3441            session_store: None,
3442            openapi: Some(openapi),
3443            #[cfg(feature = "mcp")]
3444            mcp: None,
3445        };
3446        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3447            .expect_err("scope '/api' + child '/' should collide with openapi path '/api'");
3448        assert!(matches!(
3449            err,
3450            RouterBuildError::OpenApiPathCollision {
3451                field: "openapi_json_path",
3452                ..
3453            }
3454        ));
3455    }
3456
3457    #[cfg(feature = "openapi")]
3458    #[test]
3459    fn extract_path_params_matches_macro_behavior() {
3460        assert_eq!(
3461            super::extract_path_params("/orgs/{org_id}/users/{id}"),
3462            vec!["org_id".to_owned(), "id".to_owned()]
3463        );
3464        assert!(super::extract_path_params("/static").is_empty());
3465        assert_eq!(
3466            super::extract_path_params("/users/{id:[0-9]+}"),
3467            vec!["id".to_owned()]
3468        );
3469    }
3470
3471    #[cfg(feature = "openapi")]
3472    #[tokio::test]
3473    async fn openapi_merges_scoped_prefix_path_params() {
3474        use crate::openapi::{ApiDoc, OpenApiConfig};
3475
3476        // Scope prefix has `{org_id}`; the child route has `{id}`. The
3477        // generated ApiDoc must declare BOTH parameters, or Swagger
3478        // validators reject the document for referencing undeclared
3479        // path params.
3480        async fn handler() -> &'static str {
3481            "ok"
3482        }
3483        let child = Route {
3484            method: http::Method::GET,
3485            path: "/users/{id}",
3486            handler: axum::routing::get(handler),
3487            name: "child",
3488            api_doc: ApiDoc {
3489                method: "GET",
3490                path: "/users/{id}",
3491                operation_id: "child",
3492                path_params: &["id"],
3493                success_status: 200,
3494                ..Default::default()
3495            },
3496            repository: None,
3497            idempotency: crate::route::RouteIdempotency::Direct,
3498            api_version: None,
3499            sunset_opt_out: false,
3500        };
3501        let group = crate::app::ScopedGroup {
3502            prefix: "/orgs/{org_id}".to_owned(),
3503            routes: vec![child],
3504            source: crate::route_listing::RouteSource::User,
3505            apply_layer: Box::new(|r| r),
3506        };
3507
3508        let config = OpenApiConfig::new("Demo", "1.0.0");
3509        let router = super::build_openapi_router(&[], &[group], Some(&config), "autumn.sid", &[])
3510            .expect("openapi sub-router builds")
3511            .expect("openapi sub-router present when config is Some");
3512        let state = test_state();
3513        let router = router.with_state(state);
3514
3515        let response = router
3516            .oneshot(
3517                Request::builder()
3518                    .uri("/openapi.json")
3519                    .body(Body::empty())
3520                    .unwrap(),
3521            )
3522            .await
3523            .unwrap();
3524        assert_eq!(response.status(), StatusCode::OK);
3525        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3526            .await
3527            .unwrap();
3528        let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
3529        let params = &spec["paths"]["/orgs/{org_id}/users/{id}"]["get"]["parameters"];
3530        let names: Vec<&str> = params
3531            .as_array()
3532            .unwrap()
3533            .iter()
3534            .map(|p| p["name"].as_str().unwrap())
3535            .collect();
3536        assert!(names.contains(&"org_id"), "missing org_id: {names:?}");
3537        assert!(names.contains(&"id"), "missing id: {names:?}");
3538    }
3539
3540    #[cfg(feature = "openapi")]
3541    #[tokio::test]
3542    async fn openapi_documents_configured_session_cookie_name() {
3543        use crate::openapi::{ApiDoc, OpenApiConfig};
3544
3545        async fn handler() -> &'static str {
3546            "ok"
3547        }
3548
3549        let route = Route {
3550            method: http::Method::GET,
3551            path: "/protected",
3552            handler: axum::routing::get(handler),
3553            name: "protected",
3554            api_doc: ApiDoc {
3555                method: "GET",
3556                path: "/protected",
3557                operation_id: "protected",
3558                success_status: 200,
3559                secured: true,
3560                ..Default::default()
3561            },
3562            repository: None,
3563            idempotency: crate::route::RouteIdempotency::Direct,
3564            api_version: None,
3565            sunset_opt_out: false,
3566        };
3567
3568        let protected_routes = vec![route];
3569        let config = OpenApiConfig::new("Demo", "1.0.0");
3570        let docs_router =
3571            super::build_openapi_router(&protected_routes, &[], Some(&config), "demo.sid", &[])
3572                .expect("openapi sub-router builds")
3573                .expect("openapi sub-router present when config is Some");
3574        let docs_router = docs_router.with_state(test_state());
3575
3576        let response = docs_router
3577            .oneshot(
3578                Request::builder()
3579                    .uri("/openapi.json")
3580                    .body(Body::empty())
3581                    .unwrap(),
3582            )
3583            .await
3584            .unwrap();
3585        assert_eq!(response.status(), StatusCode::OK);
3586        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3587            .await
3588            .unwrap();
3589        let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
3590        let schemes = &spec["components"]["securitySchemes"];
3591
3592        assert_eq!(schemes["SessionAuth"]["type"], "apiKey");
3593        assert_eq!(schemes["SessionAuth"]["in"], "cookie");
3594        assert_eq!(schemes["SessionAuth"]["name"], "demo.sid");
3595        assert!(
3596            schemes.get("BearerAuth").is_none(),
3597            "secured routes must not be documented as bearer JWT routes"
3598        );
3599    }
3600
3601    #[cfg(feature = "openapi")]
3602    #[test]
3603    fn openapi_rejects_json_path_without_leading_slash() {
3604        let config =
3605            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("openapi.json");
3606        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3607            .expect_err("non-slash path should be rejected");
3608        assert!(matches!(
3609            err,
3610            RouterBuildError::InvalidOpenApiPath {
3611                field: "openapi_json_path",
3612                ..
3613            }
3614        ));
3615    }
3616
3617    #[cfg(feature = "openapi")]
3618    #[test]
3619    fn openapi_rejects_path_with_captures() {
3620        // `{id}` captures would be a typo for a mount path — the
3621        // endpoints are static. Catch it before axum panics.
3622        let config =
3623            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id}");
3624        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3625            .expect_err("captures should be rejected");
3626        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3627    }
3628
3629    #[cfg(feature = "openapi")]
3630    #[test]
3631    fn openapi_rejects_path_with_unbalanced_brace() {
3632        let config =
3633            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id");
3634        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3635            .expect_err("unbalanced brace should be rejected");
3636        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3637    }
3638
3639    #[cfg(feature = "openapi")]
3640    #[test]
3641    fn openapi_rejects_path_with_wildcard() {
3642        let config =
3643            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/*rest");
3644        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3645            .expect_err("wildcard should be rejected");
3646        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3647    }
3648
3649    #[cfg(feature = "openapi")]
3650    #[test]
3651    fn openapi_rejects_path_with_double_slash() {
3652        let config =
3653            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("//docs");
3654        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3655            .expect_err("double-slash should be rejected");
3656        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3657    }
3658
3659    #[cfg(feature = "openapi")]
3660    #[test]
3661    fn openapi_rejects_swagger_ui_path_without_leading_slash() {
3662        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3663            .swagger_ui_path(Some("docs".to_owned()));
3664        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3665            .expect_err("non-slash path should be rejected");
3666        assert!(matches!(
3667            err,
3668            RouterBuildError::InvalidOpenApiPath {
3669                field: "swagger_ui_path",
3670                ..
3671            }
3672        ));
3673    }
3674
3675    #[cfg(feature = "openapi")]
3676    #[test]
3677    fn openapi_rejects_empty_json_path() {
3678        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("");
3679        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3680            .expect_err("empty path should be rejected");
3681        assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3682    }
3683
3684    #[cfg(feature = "openapi")]
3685    #[test]
3686    fn openapi_accepts_valid_paths() {
3687        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3688            .openapi_json_path("/api-docs")
3689            .swagger_ui_path(Some("/ui".to_owned()));
3690        let out = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3691            .expect("valid paths must not error");
3692        assert!(out.is_some());
3693    }
3694
3695    #[cfg(feature = "openapi")]
3696    #[test]
3697    fn openapi_rejects_duplicate_json_and_swagger_paths() {
3698        let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3699            .openapi_json_path("/docs")
3700            .swagger_ui_path(Some("/docs".to_owned()));
3701        let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3702            .expect_err("colliding paths should be rejected before axum panics");
3703        assert!(matches!(
3704            err,
3705            RouterBuildError::DuplicateOpenApiPath { ref path } if path == "/docs"
3706        ));
3707    }
3708
3709    #[cfg(feature = "openapi")]
3710    async fn collision_test_handler() -> &'static str {
3711        "user"
3712    }
3713
3714    #[cfg(feature = "openapi")]
3715    #[tokio::test]
3716    async fn try_build_router_rejects_openapi_path_colliding_with_user_route() {
3717        let mut config = AutumnConfig::default();
3718        config.actuator.prefix = "/ops".to_owned();
3719        let openapi =
3720            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/my-api-docs");
3721
3722        let user_route = Route {
3723            method: http::Method::GET,
3724            path: "/my-api-docs",
3725            handler: axum::routing::get(collision_test_handler),
3726            name: "collides",
3727            api_doc: crate::openapi::ApiDoc {
3728                method: "GET",
3729                path: "/my-api-docs",
3730                operation_id: "collides",
3731                success_status: 200,
3732                ..Default::default()
3733            },
3734            repository: None,
3735            idempotency: crate::route::RouteIdempotency::Direct,
3736            api_version: None,
3737            sunset_opt_out: false,
3738        };
3739
3740        let ctx = RouterContext {
3741            exception_filters: Vec::new(),
3742            scoped_groups: Vec::new(),
3743            merge_routers: Vec::new(),
3744            nest_routers: Vec::new(),
3745            custom_layers: Vec::new(),
3746            error_page_renderer: None,
3747            session_store: None,
3748            openapi: Some(openapi),
3749            #[cfg(feature = "mcp")]
3750            mcp: None,
3751        };
3752        let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
3753            .expect_err("user-owned path should prevent OpenAPI mount");
3754        assert!(matches!(
3755            err,
3756            RouterBuildError::OpenApiPathCollision { field: "openapi_json_path", ref path } if path == "/my-api-docs"
3757        ));
3758    }
3759
3760    #[cfg(feature = "openapi")]
3761    #[tokio::test]
3762    async fn try_build_router_rejects_openapi_path_colliding_with_framework_route() {
3763        let config = AutumnConfig::default(); // /actuator/health is a GET by default
3764        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3765            .openapi_json_path("/actuator/health");
3766        let ctx = RouterContext {
3767            exception_filters: Vec::new(),
3768            scoped_groups: Vec::new(),
3769            merge_routers: Vec::new(),
3770            nest_routers: Vec::new(),
3771            custom_layers: Vec::new(),
3772            error_page_renderer: None,
3773            session_store: None,
3774            openapi: Some(openapi),
3775            #[cfg(feature = "mcp")]
3776            mcp: None,
3777        };
3778        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3779            .expect_err("framework-owned path should prevent OpenAPI mount");
3780        assert!(matches!(
3781            err,
3782            RouterBuildError::OpenApiPathCollision {
3783                field: "openapi_json_path",
3784                ..
3785            }
3786        ));
3787    }
3788
3789    #[cfg(feature = "openapi")]
3790    #[tokio::test]
3791    async fn try_build_router_rejects_swagger_ui_asset_path_colliding_with_user_route() {
3792        let config = AutumnConfig::default();
3793        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0");
3794
3795        let user_route = Route {
3796            method: http::Method::GET,
3797            path: "/swagger-ui/swagger-ui.css",
3798            handler: axum::routing::get(collision_test_handler),
3799            name: "swagger-ui-asset-collides",
3800            api_doc: crate::openapi::ApiDoc {
3801                method: "GET",
3802                path: "/swagger-ui/swagger-ui.css",
3803                operation_id: "swagger_ui_asset_collides",
3804                success_status: 200,
3805                ..Default::default()
3806            },
3807            repository: None,
3808            idempotency: crate::route::RouteIdempotency::Direct,
3809            api_version: None,
3810            sunset_opt_out: false,
3811        };
3812
3813        let ctx = RouterContext {
3814            exception_filters: Vec::new(),
3815            scoped_groups: Vec::new(),
3816            merge_routers: Vec::new(),
3817            nest_routers: Vec::new(),
3818            custom_layers: Vec::new(),
3819            error_page_renderer: None,
3820            session_store: None,
3821            openapi: Some(openapi),
3822            #[cfg(feature = "mcp")]
3823            mcp: None,
3824        };
3825        let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
3826            .expect_err("swagger ui asset path should be reserved");
3827        assert!(matches!(
3828            err,
3829            RouterBuildError::OpenApiPathCollision {
3830                field: "swagger_ui_path",
3831                ref path,
3832            } if path == "/swagger-ui/swagger-ui.css"
3833        ));
3834    }
3835
3836    #[cfg(all(feature = "openapi", feature = "htmx"))]
3837    #[tokio::test]
3838    async fn try_build_router_rejects_openapi_path_colliding_with_htmx_csrf_route() {
3839        let config = AutumnConfig::default();
3840        let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3841            .openapi_json_path(crate::htmx::HTMX_CSRF_JS_PATH);
3842        let ctx = RouterContext {
3843            exception_filters: Vec::new(),
3844            scoped_groups: Vec::new(),
3845            merge_routers: Vec::new(),
3846            nest_routers: Vec::new(),
3847            custom_layers: Vec::new(),
3848            error_page_renderer: None,
3849            session_store: None,
3850            openapi: Some(openapi),
3851            #[cfg(feature = "mcp")]
3852            mcp: None,
3853        };
3854        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3855            .expect_err("htmx csrf helper path should be reserved");
3856        assert!(matches!(
3857            err,
3858            RouterBuildError::OpenApiPathCollision {
3859                field: "openapi_json_path",
3860                ref path,
3861            } if path == crate::htmx::HTMX_CSRF_JS_PATH
3862        ));
3863    }
3864
3865    #[cfg(feature = "openapi")]
3866    #[tokio::test]
3867    async fn try_build_router_rejects_openapi_path_under_nest_prefix() {
3868        // Nesting `/api` means that router owns everything under
3869        // `/api/...`. Mounting OpenAPI at `/api/docs` would either
3870        // panic on merge or silently lose one of the routes, so the
3871        // collision check rejects it.
3872        let config = AutumnConfig::default();
3873        let openapi =
3874            crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api/docs");
3875        let nested = axum::Router::<AppState>::new()
3876            .route("/inner", axum::routing::get(|| async { "inner" }));
3877        let ctx = RouterContext {
3878            exception_filters: Vec::new(),
3879            scoped_groups: Vec::new(),
3880            merge_routers: Vec::new(),
3881            nest_routers: vec![("/api".to_owned(), nested)],
3882            custom_layers: Vec::new(),
3883            error_page_renderer: None,
3884            session_store: None,
3885            openapi: Some(openapi),
3886            #[cfg(feature = "mcp")]
3887            mcp: None,
3888        };
3889        let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3890            .expect_err("OpenAPI path under a nest prefix should collide");
3891        assert!(matches!(
3892            err,
3893            RouterBuildError::OpenApiPathCollision {
3894                field: "openapi_json_path",
3895                ref path,
3896            } if path == "/api/docs"
3897        ));
3898    }
3899
3900    #[cfg(feature = "openapi")]
3901    #[test]
3902    fn try_build_router_rejects_openapi_path_on_dev_live_reload() {
3903        temp_env::with_vars(
3904            [
3905                ("AUTUMN_DEV_RELOAD", Some("1")),
3906                ("AUTUMN_DEV_RELOAD_STATE", Some("/tmp/autumn-reload-test")),
3907            ],
3908            || {
3909                let config = AutumnConfig::default();
3910                let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3911                    .openapi_json_path("/__autumn/live-reload");
3912                let ctx = RouterContext {
3913                    exception_filters: Vec::new(),
3914                    scoped_groups: Vec::new(),
3915                    merge_routers: Vec::new(),
3916                    nest_routers: Vec::new(),
3917                    custom_layers: Vec::new(),
3918                    error_page_renderer: None,
3919                    session_store: None,
3920                    openapi: Some(openapi),
3921                    #[cfg(feature = "mcp")]
3922                    mcp: None,
3923                };
3924                let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3925                    .expect_err("dev reload path should be reserved");
3926                assert!(matches!(
3927                    err,
3928                    RouterBuildError::OpenApiPathCollision {
3929                        field: "openapi_json_path",
3930                        ..
3931                    }
3932                ));
3933            },
3934        );
3935    }
3936
3937    // --- Static file serving (SSG/ISG) tests ---
3938
3939    fn create_static_dist(revalidate: Option<u64>) -> tempfile::TempDir {
3940        let dir = tempfile::tempdir().expect("tempdir");
3941        let dist = dir.path().join("dist");
3942        std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
3943        std::fs::write(dist.join("index.html"), b"<h1>Home</h1>").expect("write index");
3944        std::fs::write(dist.join("about/index.html"), b"<h1>About</h1>").expect("write about");
3945
3946        let mut routes = std::collections::HashMap::new();
3947        routes.insert(
3948            "/".to_owned(),
3949            crate::static_gen::ManifestEntry {
3950                file: "index.html".to_owned(),
3951                revalidate: None,
3952            },
3953        );
3954        routes.insert(
3955            "/about".to_owned(),
3956            crate::static_gen::ManifestEntry {
3957                file: "about/index.html".to_owned(),
3958                revalidate,
3959            },
3960        );
3961
3962        let manifest = crate::static_gen::StaticManifest {
3963            generated_at: "2026-05-18T00:00:00Z".to_owned(),
3964            autumn_version: "0.5.0".to_owned(),
3965            routes,
3966        };
3967        let json = serde_json::to_string(&manifest).expect("serialize manifest");
3968        std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
3969        dir
3970    }
3971
3972    #[tokio::test]
3973    async fn static_serving_serves_get_request_inside_user_layers() {
3974        let tmp = create_static_dist(None);
3975        let dist = tmp.path().join("dist");
3976        let config = AutumnConfig::default();
3977
3978        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
3979            .expect("router builds");
3980
3981        let response = router
3982            .oneshot(
3983                Request::builder()
3984                    .uri("/about")
3985                    .body(Body::empty())
3986                    .unwrap(),
3987            )
3988            .await
3989            .unwrap();
3990
3991        assert_eq!(response.status(), StatusCode::OK);
3992        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3993            .await
3994            .unwrap();
3995        assert_eq!(body.as_ref(), b"<h1>About</h1>");
3996    }
3997
3998    #[tokio::test]
3999    async fn static_serving_serves_head_request() {
4000        let tmp = create_static_dist(None);
4001        let dist = tmp.path().join("dist");
4002        let config = AutumnConfig::default();
4003
4004        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4005            .expect("router builds");
4006
4007        let response = router
4008            .oneshot(
4009                Request::builder()
4010                    .method("HEAD")
4011                    .uri("/about")
4012                    .body(Body::empty())
4013                    .unwrap(),
4014            )
4015            .await
4016            .unwrap();
4017
4018        assert_eq!(response.status(), StatusCode::OK);
4019        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4020            .await
4021            .unwrap();
4022        assert!(body.is_empty(), "HEAD response body should be empty");
4023    }
4024
4025    #[tokio::test]
4026    async fn static_serving_normalizes_trailing_slash() {
4027        let tmp = create_static_dist(None);
4028        let dist = tmp.path().join("dist");
4029        let config = AutumnConfig::default();
4030
4031        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4032            .expect("router builds");
4033
4034        let response = router
4035            .oneshot(
4036                Request::builder()
4037                    .uri("/about/")
4038                    .body(Body::empty())
4039                    .unwrap(),
4040            )
4041            .await
4042            .unwrap();
4043
4044        assert_eq!(response.status(), StatusCode::OK);
4045    }
4046
4047    #[tokio::test]
4048    async fn static_serving_falls_through_for_unknown_route() {
4049        let tmp = create_static_dist(None);
4050        let dist = tmp.path().join("dist");
4051        let config = AutumnConfig::default();
4052
4053        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4054            .expect("router builds");
4055
4056        let response = router
4057            .oneshot(
4058                Request::builder()
4059                    .uri("/not-in-manifest")
4060                    .body(Body::empty())
4061                    .unwrap(),
4062            )
4063            .await
4064            .unwrap();
4065
4066        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4067    }
4068
4069    #[tokio::test]
4070    async fn static_serving_skipped_when_no_manifest() {
4071        let tmp = tempfile::tempdir().expect("tempdir");
4072        let dist = tmp.path().join("dist");
4073        std::fs::create_dir_all(&dist).expect("mkdir dist");
4074        let config = AutumnConfig::default();
4075
4076        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4077            .expect("router builds even without manifest");
4078
4079        let response = router
4080            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4081            .await
4082            .unwrap();
4083
4084        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4085    }
4086
4087    #[tokio::test]
4088    async fn static_serving_with_isr_manifest_builds_successfully() {
4089        let tmp = create_static_dist(Some(3600));
4090        let dist = tmp.path().join("dist");
4091        let config = AutumnConfig::default();
4092
4093        let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4094            .expect("router with ISR manifest should build");
4095
4096        let response = router
4097            .oneshot(
4098                Request::builder()
4099                    .uri("/about")
4100                    .body(Body::empty())
4101                    .unwrap(),
4102            )
4103            .await
4104            .unwrap();
4105
4106        assert_eq!(response.status(), StatusCode::OK);
4107    }
4108}
4109
4110#[cfg(test)]
4111mod trusted_host_tests {
4112    use super::*;
4113    use axum::body::Body;
4114    use http::Request;
4115    use tower::util::ServiceExt;
4116
4117    #[tokio::test]
4118    async fn trusted_host_allows_matching_and_blocks_nonmatching() {
4119        let mut cfg = AutumnConfig::default();
4120        cfg.security.trusted_hosts.hosts = vec!["example.com".into(), ".example.com".into()];
4121        let state = crate::state::AppState::for_test();
4122        let router = build_router(vec![], &cfg, state);
4123
4124        let ok = router
4125            .clone()
4126            .oneshot(
4127                Request::builder()
4128                    .uri("/nope")
4129                    .header("host", "api.example.com")
4130                    .body(Body::empty())
4131                    .unwrap(),
4132            )
4133            .await
4134            .unwrap();
4135        assert_eq!(ok.status(), StatusCode::NOT_FOUND);
4136
4137        let blocked = router
4138            .oneshot(
4139                Request::builder()
4140                    .uri("/nope")
4141                    .header("host", "evil.com")
4142                    .body(Body::empty())
4143                    .unwrap(),
4144            )
4145            .await
4146            .unwrap();
4147        assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
4148    }
4149
4150    #[tokio::test]
4151    async fn trusted_host_wildcard_allows_any_host() {
4152        let mut cfg = AutumnConfig::default();
4153        cfg.security.trusted_hosts.hosts = vec!["*".into()];
4154        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4155        let response = router
4156            .oneshot(
4157                Request::builder()
4158                    .uri("/nope")
4159                    .header("host", "anything.example")
4160                    .body(Body::empty())
4161                    .expect("request should build"),
4162            )
4163            .await
4164            .expect("request should complete");
4165        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4166    }
4167
4168    #[tokio::test]
4169    async fn trusted_host_bypasses_probe_paths() {
4170        let mut cfg = AutumnConfig::default();
4171        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4172        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4173        let response = router
4174            .oneshot(
4175                Request::builder()
4176                    .uri("/actuator/health")
4177                    .header("host", "evil.com")
4178                    .body(Body::empty())
4179                    .expect("request should build"),
4180            )
4181            .await
4182            .expect("request should complete");
4183        assert_eq!(response.status(), StatusCode::OK);
4184    }
4185
4186    #[tokio::test]
4187    async fn trusted_host_bypasses_actuator_health_path() {
4188        let mut cfg = AutumnConfig::default();
4189        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4190        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4191        let response = router
4192            .oneshot(
4193                Request::builder()
4194                    .uri("/actuator/health")
4195                    .header("host", "evil.com")
4196                    .body(Body::empty())
4197                    .expect("request should build"),
4198            )
4199            .await
4200            .expect("request should complete");
4201        assert_eq!(response.status(), StatusCode::OK);
4202    }
4203
4204    #[tokio::test]
4205    async fn trusted_host_release_rejects_loopback_unless_listed() {
4206        let mut cfg = AutumnConfig {
4207            profile: Some("prod".into()),
4208            ..AutumnConfig::default()
4209        };
4210        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4211        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4212        let response = router
4213            .oneshot(
4214                Request::builder()
4215                    .uri("/nope")
4216                    .header("host", "localhost")
4217                    .body(Body::empty())
4218                    .expect("request should build"),
4219            )
4220            .await
4221            .expect("request should complete");
4222        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4223    }
4224
4225    #[tokio::test]
4226    async fn trusted_host_uses_uri_authority_when_host_header_missing() {
4227        let mut cfg = AutumnConfig::default();
4228        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4229        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4230        let response = router
4231            .oneshot(
4232                Request::builder()
4233                    .uri("http://EXAMPLE.COM/nope")
4234                    .body(Body::empty())
4235                    .expect("request should build"),
4236            )
4237            .await
4238            .expect("request should complete");
4239        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4240    }
4241
4242    #[tokio::test]
4243    async fn trusted_host_accepts_bracketed_ipv6_loopback_in_dev() {
4244        let cfg = AutumnConfig::default();
4245        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4246        let response = router
4247            .oneshot(
4248                Request::builder()
4249                    .uri("/nope")
4250                    .header("host", "[::1]:3000")
4251                    .body(Body::empty())
4252                    .expect("request should build"),
4253            )
4254            .await
4255            .expect("request should complete");
4256        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4257    }
4258
4259    #[tokio::test]
4260    async fn trusted_host_matching_is_case_insensitive() {
4261        let mut cfg = AutumnConfig::default();
4262        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4263        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4264        let response = router
4265            .oneshot(
4266                Request::builder()
4267                    .uri("/nope")
4268                    .header("host", "EXAMPLE.COM")
4269                    .body(Body::empty())
4270                    .expect("request should build"),
4271            )
4272            .await
4273            .expect("request should complete");
4274        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4275    }
4276
4277    #[tokio::test]
4278    async fn trusted_host_rejects_malformed_port() {
4279        let mut cfg = AutumnConfig::default();
4280        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4281        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4282        let response = router
4283            .oneshot(
4284                Request::builder()
4285                    .uri("/nope")
4286                    .header("host", "example.com:abc")
4287                    .body(Body::empty())
4288                    .expect("request should build"),
4289            )
4290            .await
4291            .expect("request should complete");
4292        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4293    }
4294
4295    #[tokio::test]
4296    async fn trusted_host_rejects_empty_port_suffix() {
4297        let mut cfg = AutumnConfig::default();
4298        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4299        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4300        let response = router
4301            .oneshot(
4302                Request::builder()
4303                    .uri("/nope")
4304                    .header("host", "example.com:")
4305                    .body(Body::empty())
4306                    .expect("request should build"),
4307            )
4308            .await
4309            .expect("request should complete");
4310        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4311    }
4312
4313    #[tokio::test]
4314    async fn trusted_host_rejects_bracketed_reg_name() {
4315        let mut cfg = AutumnConfig::default();
4316        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4317        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4318        let response = router
4319            .oneshot(
4320                Request::builder()
4321                    .uri("/nope")
4322                    .header("host", "[example.com]")
4323                    .body(Body::empty())
4324                    .expect("request should build"),
4325            )
4326            .await
4327            .expect("request should complete");
4328        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4329    }
4330    #[tokio::test]
4331    async fn trusted_host_configured_trailing_dot_matches_normalized_host() {
4332        let mut cfg = AutumnConfig::default();
4333        cfg.security.trusted_hosts.hosts = vec!["example.com.".into()];
4334        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4335        let response = router
4336            .oneshot(
4337                Request::builder()
4338                    .uri("/nope")
4339                    .header("host", "example.com")
4340                    .body(Body::empty())
4341                    .expect("request should build"),
4342            )
4343            .await
4344            .expect("request should complete");
4345        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4346    }
4347
4348    #[tokio::test]
4349    async fn trusted_host_accepts_trailing_dot_fqdn() {
4350        let mut cfg = AutumnConfig::default();
4351        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4352        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4353        let response = router
4354            .oneshot(
4355                Request::builder()
4356                    .uri("/nope")
4357                    .header("host", "example.com.")
4358                    .body(Body::empty())
4359                    .expect("request should build"),
4360            )
4361            .await
4362            .expect("request should complete");
4363        assert_eq!(response.status(), StatusCode::NOT_FOUND);
4364    }
4365
4366    #[tokio::test]
4367    async fn trusted_host_bypasses_custom_probe_path_only() {
4368        let mut cfg = AutumnConfig::default();
4369        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4370        cfg.health.path = "/healthz".into();
4371        cfg.health.startup_path = "/startupz".into();
4372        cfg.health.ready_path = "/readyz".into();
4373        cfg.health.live_path = "/livez".into();
4374        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4375
4376        let bypassed = router
4377            .clone()
4378            .oneshot(
4379                Request::builder()
4380                    .uri("/healthz")
4381                    .header("host", "evil.com")
4382                    .body(Body::empty())
4383                    .expect("request should build"),
4384            )
4385            .await
4386            .expect("request should complete");
4387        assert_eq!(bypassed.status(), StatusCode::OK);
4388
4389        let not_bypassed = router
4390            .oneshot(
4391                Request::builder()
4392                    .uri("/health")
4393                    .header("host", "evil.com")
4394                    .body(Body::empty())
4395                    .expect("request should build"),
4396            )
4397            .await
4398            .expect("request should complete");
4399        assert_eq!(not_bypassed.status(), StatusCode::BAD_REQUEST);
4400    }
4401
4402    #[tokio::test]
4403    async fn trusted_host_does_not_bypass_non_get_probe_path_requests() {
4404        let mut cfg = AutumnConfig::default();
4405        cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4406        let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4407        let response = router
4408            .oneshot(
4409                Request::builder()
4410                    .method("POST")
4411                    .uri("/health")
4412                    .header("host", "evil.com")
4413                    .body(Body::empty())
4414                    .expect("request should build"),
4415            )
4416            .await
4417            .expect("request should complete");
4418        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4419    }
4420
4421    // ── Global body-size limit (AC: DefaultBodyLimit covers all content types) ──
4422
4423    #[tokio::test]
4424    async fn apply_upload_middleware_rejects_oversized_json_body() {
4425        let mut config = AutumnConfig::default();
4426        config.security.upload.max_request_size_bytes = 100; // 100-byte limit
4427
4428        let base: axum::Router<AppState> = axum::Router::new().route(
4429            "/data",
4430            axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
4431        );
4432        let router =
4433            apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
4434
4435        // 200 bytes of JSON-shaped content exceeds the 100-byte cap.
4436        let big_body = "x".repeat(200);
4437        let response = router
4438            .oneshot(
4439                Request::builder()
4440                    .method("POST")
4441                    .uri("/data")
4442                    .header("content-type", "application/json")
4443                    .body(Body::from(big_body))
4444                    .unwrap(),
4445            )
4446            .await
4447            .unwrap();
4448
4449        assert_eq!(
4450            response.status(),
4451            StatusCode::PAYLOAD_TOO_LARGE,
4452            "oversized body must be rejected with 413 regardless of content type"
4453        );
4454    }
4455
4456    #[tokio::test]
4457    async fn apply_upload_middleware_accepts_body_within_limit() {
4458        let mut config = AutumnConfig::default();
4459        config.security.upload.max_request_size_bytes = 1024;
4460
4461        let base: axum::Router<AppState> = axum::Router::new().route(
4462            "/data",
4463            axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
4464        );
4465        let router =
4466            apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
4467
4468        let response = router
4469            .oneshot(
4470                Request::builder()
4471                    .method("POST")
4472                    .uri("/data")
4473                    .header("content-type", "application/json")
4474                    .body(Body::from("hello"))
4475                    .unwrap(),
4476            )
4477            .await
4478            .unwrap();
4479
4480        assert_eq!(response.status(), StatusCode::OK);
4481    }
4482
4483    // ── Per-request timeout (AC: 408 on timeout, metrics, WARN log) ──────────
4484
4485    #[tokio::test(start_paused = true)]
4486    async fn request_timeout_returns_408_when_exceeded() {
4487        let mut config = AutumnConfig::default();
4488        config.server.timeouts.request_timeout_ms = Some(100);
4489
4490        let state = crate::state::AppState::for_test();
4491        let router: axum::Router<AppState> = axum::Router::new().route(
4492            "/slow",
4493            axum::routing::get(|| async {
4494                // This sleep is much longer than the 100ms timeout.
4495                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
4496                "ok"
4497            }),
4498        );
4499
4500        // Place timeout inner to RequestIdLayer (matches apply_middleware ordering).
4501        let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4502            .layer(RequestIdLayer)
4503            .with_state(state);
4504
4505        let response = router
4506            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
4507            .await
4508            .unwrap();
4509
4510        assert_eq!(
4511            response.status(),
4512            StatusCode::REQUEST_TIMEOUT,
4513            "a slow handler must trigger 408"
4514        );
4515        assert_eq!(
4516            response
4517                .headers()
4518                .get("content-type")
4519                .and_then(|v| v.to_str().ok()),
4520            Some("application/problem+json"),
4521            "timeout response must use Problem Details content type"
4522        );
4523    }
4524
4525    #[tokio::test(start_paused = true)]
4526    async fn request_timeout_increments_metric() {
4527        let mut config = AutumnConfig::default();
4528        config.server.timeouts.request_timeout_ms = Some(100);
4529
4530        let state = crate::state::AppState::for_test();
4531        let router: axum::Router<AppState> = axum::Router::new().route(
4532            "/slow",
4533            axum::routing::get(|| async {
4534                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
4535                "ok"
4536            }),
4537        );
4538
4539        let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4540            .layer(RequestIdLayer)
4541            .with_state(state.clone());
4542
4543        router
4544            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
4545            .await
4546            .unwrap();
4547
4548        let snap = state.metrics.snapshot();
4549        assert_eq!(
4550            snap.http.request_timeouts_total, 1,
4551            "autumn_request_timeouts_total must be incremented on timeout"
4552        );
4553    }
4554
4555    #[tokio::test(start_paused = true)]
4556    async fn request_timeout_response_includes_request_id() {
4557        let mut config = AutumnConfig::default();
4558        config.server.timeouts.request_timeout_ms = Some(100);
4559
4560        let state = crate::state::AppState::for_test();
4561        let router: axum::Router<AppState> = axum::Router::new().route(
4562            "/slow",
4563            axum::routing::get(|| async {
4564                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
4565                "ok"
4566            }),
4567        );
4568
4569        let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4570            .layer(RequestIdLayer)
4571            .with_state(state);
4572
4573        let response = router
4574            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
4575            .await
4576            .unwrap();
4577
4578        assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT);
4579        // X-Request-Id is added by RequestIdLayer on the egress path.
4580        assert!(
4581            response.headers().contains_key("x-request-id"),
4582            "408 response must carry the X-Request-Id header"
4583        );
4584
4585        // The body must be valid JSON with a request_id field.
4586        let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
4587            .await
4588            .unwrap();
4589        let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
4590        assert_eq!(body["status"], 408);
4591    }
4592
4593    #[tokio::test]
4594    async fn request_timeout_disabled_when_none() {
4595        let config = AutumnConfig::default(); // request_timeout_ms = None
4596
4597        let state = crate::state::AppState::for_test();
4598        let router: axum::Router<AppState> =
4599            axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
4600
4601        let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4602            .with_state(state);
4603
4604        let response = router
4605            .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
4606            .await
4607            .unwrap();
4608
4609        assert_eq!(response.status(), StatusCode::OK);
4610    }
4611
4612    #[tokio::test]
4613    async fn request_timeout_zero_treated_as_disabled() {
4614        let mut config = AutumnConfig::default();
4615        config.server.timeouts.request_timeout_ms = Some(0); // 0 = disabled
4616
4617        let state = crate::state::AppState::for_test();
4618        let router: axum::Router<AppState> =
4619            axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
4620
4621        let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4622            .with_state(state);
4623
4624        let response = router
4625            .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
4626            .await
4627            .unwrap();
4628
4629        assert_eq!(response.status(), StatusCode::OK);
4630    }
4631
4632    // Exercises the warn!("Request timed out") branch when no RequestIdLayer
4633    // is present (no request_id extension), keeping coverage of the else arm.
4634    #[tokio::test(start_paused = true)]
4635    async fn request_timeout_408_without_request_id_layer() {
4636        let mut config = AutumnConfig::default();
4637        config.server.timeouts.request_timeout_ms = Some(100);
4638
4639        let state = crate::state::AppState::for_test();
4640        let router: axum::Router<AppState> = axum::Router::new().route(
4641            "/slow",
4642            axum::routing::get(|| async {
4643                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
4644                "ok"
4645            }),
4646        );
4647
4648        // No RequestIdLayer — exercises the else branch in request_timeout_handler.
4649        let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4650            .with_state(state);
4651
4652        let response = router
4653            .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
4654            .await
4655            .unwrap();
4656
4657        assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT);
4658    }
4659}
4660#[derive(Clone, Debug)]
4661pub struct TrustedHostPolicy {
4662    rules: Arc<Vec<String>>,
4663    allow_any: bool,
4664    allow_missing_host: bool,
4665    probe_bypass_paths: Arc<std::collections::HashSet<String>>,
4666}
4667
4668impl TrustedHostPolicy {
4669    pub fn from_config(config: &AutumnConfig) -> Self {
4670        let mut rules: Vec<String> = config
4671            .security
4672            .trusted_hosts
4673            .hosts
4674            .iter()
4675            .map(|h| h.trim().to_ascii_lowercase())
4676            .map(|h| h.trim_end_matches('.').to_owned())
4677            .filter(|h| !h.is_empty())
4678            .collect();
4679        let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
4680        if !is_production {
4681            rules.extend(
4682                ["localhost", "127.0.0.1", "::1"]
4683                    .into_iter()
4684                    .map(std::borrow::ToOwned::to_owned),
4685            );
4686        }
4687        let allow_any = rules.iter().any(|h| h == "*");
4688        let probe_bypass_paths = std::collections::HashSet::from([
4689            config.health.path.clone(),
4690            config.health.live_path.clone(),
4691            config.health.ready_path.clone(),
4692            config.health.startup_path.clone(),
4693            crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
4694        ]);
4695        Self {
4696            rules: Arc::new(rules),
4697            allow_any,
4698            allow_missing_host: !is_production,
4699            probe_bypass_paths: Arc::new(probe_bypass_paths),
4700        }
4701    }
4702
4703    /// Whether a request carrying no usable `Host` is allowed through. Mirrors
4704    /// `trusted_host_middleware`'s missing-host branch for callers (e.g. the MCP
4705    /// envelope) that enforce the policy outside that middleware.
4706    ///
4707    /// Only the `mcp` feature consumes this today; gated so default-feature
4708    /// builds don't flag it as dead code.
4709    #[cfg(feature = "mcp")]
4710    pub const fn allows_missing_host(&self) -> bool {
4711        self.allow_missing_host
4712    }
4713
4714    pub fn allows_host(&self, host: &str) -> bool {
4715        if self.allow_any {
4716            return true;
4717        }
4718        self.rules.iter().any(|rule| {
4719            rule.strip_prefix('.').map_or_else(
4720                || host == rule,
4721                |suffix| {
4722                    host == suffix
4723                        || host
4724                            .strip_suffix(suffix)
4725                            .is_some_and(|prefix| prefix.ends_with('.'))
4726                },
4727            )
4728        })
4729    }
4730}
4731
4732/// Metadata carrying API version, sunset opt-out, and security configuration for a route.
4733#[derive(Clone, Debug)]
4734pub struct RouteVersionMetadata {
4735    pub version: String,
4736    pub sunset_opt_out: bool,
4737    pub secured: bool,
4738    pub required_roles: &'static [&'static str],
4739    pub has_policy: bool,
4740}
4741
4742/// Middleware that handles API deprecation, sunsets, and Gone responses.
4743async fn api_versioning_middleware(
4744    state: axum::extract::State<AppState>,
4745    route_version: Option<axum::extract::Extension<RouteVersionMetadata>>,
4746    request: axum::http::Request<axum::body::Body>,
4747    next: axum::middleware::Next,
4748) -> axum::response::Response {
4749    let Some(axum::extract::Extension(meta)) = route_version else {
4750        return next.run(request).await;
4751    };
4752
4753    let clock = state.clock();
4754    let now = clock.now();
4755
4756    let versions = state.extension::<crate::app::RegisteredApiVersions>();
4757    let matching_version = versions
4758        .as_ref()
4759        .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
4760
4761    let Some(version) = matching_version else {
4762        return next.run(request).await;
4763    };
4764
4765    let is_deprecated = version.deprecated_at.is_some_and(|d| now >= d);
4766    let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
4767
4768    if is_sunset && !meta.sunset_opt_out {
4769        if meta.has_policy {
4770            return next.run(request).await;
4771        }
4772        if meta.secured {
4773            let session = request.extensions().get::<crate::session::Session>();
4774            let mut auth_failed = false;
4775            let mut auth_error = None;
4776            if let Some(session) = session {
4777                if let Err(err) = crate::auth::__check_secured_with_key(
4778                    session,
4779                    state.auth_session_key(),
4780                    meta.required_roles,
4781                )
4782                .await
4783                {
4784                    auth_failed = true;
4785                    auth_error = Some(err);
4786                }
4787            } else {
4788                auth_failed = true;
4789                auth_error = Some(crate::error::AutumnError::unauthorized_msg(
4790                    "authentication required",
4791                ));
4792            }
4793            if auth_failed {
4794                return auth_error.unwrap().into_response();
4795            }
4796        }
4797
4798        let err = crate::error::AutumnError::gone_msg(format!(
4799            "API version '{}' has been sunsetted.",
4800            meta.version
4801        ));
4802        let mut response = err.into_response();
4803        if let Some(sunset) = version.sunset_at {
4804            let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
4805            if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
4806                response.headers_mut().insert("Sunset", val);
4807            }
4808        }
4809        let deprecation_date = match (version.deprecated_at, version.sunset_at) {
4810            (Some(d), Some(s)) => Some(d.min(s)),
4811            (d, s) => d.or(s),
4812        };
4813        if let Some(date) = deprecation_date {
4814            let timestamp = date.timestamp();
4815            if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
4816                response.headers_mut().insert("Deprecation", val);
4817            }
4818        }
4819        return response;
4820    }
4821
4822    let mut response = next.run(request).await;
4823
4824    if is_deprecated || is_sunset {
4825        let deprecation_date = match (version.deprecated_at, version.sunset_at) {
4826            (Some(d), Some(s)) => Some(d.min(s)),
4827            (d, s) => d.or(s),
4828        };
4829        if let Some(date) = deprecation_date {
4830            let timestamp = date.timestamp();
4831            if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
4832                response.headers_mut().insert("Deprecation", val);
4833            }
4834        }
4835    }
4836    if let Some(sunset) = version.sunset_at.filter(|_| is_deprecated || is_sunset) {
4837        let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
4838        if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
4839            response.headers_mut().insert("Sunset", val);
4840        }
4841    }
4842
4843    response
4844}
4845
4846/// Helper function to perform a sunset check during dynamic handler execution.
4847/// Returns a `410 Gone` response if the route version has sunsetted.
4848#[must_use]
4849pub fn check_sunset(
4850    state: &crate::state::AppState,
4851    meta: &RouteVersionMetadata,
4852) -> Option<axum::response::Response> {
4853    let clock = state.clock();
4854    let now = clock.now();
4855
4856    let versions = state.extension::<crate::app::RegisteredApiVersions>();
4857    let matching_version = versions
4858        .as_ref()
4859        .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
4860
4861    let version = matching_version?;
4862    let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
4863
4864    if is_sunset && !meta.sunset_opt_out {
4865        let err = crate::error::AutumnError::gone_msg(format!(
4866            "API version '{}' has been sunsetted.",
4867            meta.version
4868        ));
4869        let mut response = axum::response::IntoResponse::into_response(err);
4870        if let Some(sunset) = version.sunset_at {
4871            let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
4872            if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
4873                response.headers_mut().insert("Sunset", val);
4874            }
4875        }
4876        let deprecation_date = match (version.deprecated_at, version.sunset_at) {
4877            (Some(d), Some(s)) => Some(d.min(s)),
4878            (d, s) => d.or(s),
4879        };
4880        if let Some(date) = deprecation_date {
4881            let timestamp = date.timestamp();
4882            if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
4883                response.headers_mut().insert("Deprecation", val);
4884            }
4885        }
4886        return Some(response);
4887    }
4888
4889    None
4890}