Skip to main content

autumn_web/
app.rs

1//! Application builder -- the entry point for configuring and running
2//! an Autumn server.
3//!
4//! Every Autumn application follows the same pattern:
5//!
6//! 1. Call [`app()`] to create an [`AppBuilder`].
7//! 2. Register routes with [`.routes()`](AppBuilder::routes).
8//! 3. Call [`.run()`](AppBuilder::run) to start serving.
9//!
10//! # Example
11//!
12//! ```rust,no_run
13//! use autumn_web::prelude::*;
14//!
15//! #[get("/hello")]
16//! async fn hello() -> &'static str { "Hello!" }
17//!
18//! #[autumn_web::main]
19//! async fn main() {
20//!     autumn_web::app()
21//!         .routes(routes![hello])
22//!         .run()
23//!         .await;
24//! }
25//! ```
26
27use std::any::{Any, TypeId};
28use std::collections::{BTreeSet, HashMap, HashSet};
29use std::future::Future;
30use std::pin::Pin;
31use std::sync::Arc;
32
33use futures::FutureExt as _;
34use tracing::Instrument as _;
35
36use crate::config::{AutumnConfig, ConfigLoader};
37#[cfg(feature = "maud")]
38use crate::error_pages::{ErrorPageRenderer, SharedRenderer};
39use crate::middleware::exception_filter::ExceptionFilter;
40#[cfg(feature = "db")]
41use crate::migrate;
42use crate::route::Route;
43use crate::state::AppState;
44
45/// Create a new [`AppBuilder`].
46///
47/// This is the primary entry point for constructing an Autumn application.
48/// Chain [`.routes()`](AppBuilder::routes) calls to register handlers, then
49/// call [`.run()`](AppBuilder::run) to start the server.
50///
51/// # Examples
52///
53/// ```rust,no_run
54/// use autumn_web::prelude::*;
55///
56/// #[get("/")]
57/// async fn index() -> &'static str { "hi" }
58///
59/// #[autumn_web::main]
60/// async fn main() {
61///     autumn_web::app()
62///         .routes(routes![index])
63///         .run()
64///         .await;
65/// }
66/// ```
67#[must_use]
68pub fn app() -> AppBuilder {
69    AppBuilder {
70        routes: Vec::new(),
71        api_versions: Vec::new(),
72        route_sources: Vec::new(),
73        current_plugin: None,
74        tasks: Vec::new(),
75        one_off_tasks: Vec::new(),
76        jobs: Vec::new(),
77        listeners: Vec::new(),
78        static_metas: Vec::new(),
79        exception_filters: Vec::new(),
80        scoped_groups: Vec::new(),
81        merge_routers: Vec::new(),
82        nest_routers: Vec::new(),
83        custom_layers: Vec::new(),
84        static_gate_layers: Vec::new(),
85        startup_hooks: Vec::new(),
86        state_initializers: Vec::new(),
87        shutdown_hooks: Vec::new(),
88        extensions: HashMap::new(),
89        registered_plugins: HashSet::new(),
90        plugin_config_roots: BTreeSet::new(),
91        #[cfg(feature = "maud")]
92        error_page_renderer: None,
93        #[cfg(feature = "db")]
94        migrations: Vec::new(),
95        config_loader_factory: None,
96        #[cfg(feature = "db")]
97        pool_provider_factory: None,
98        #[cfg(feature = "db")]
99        shard_provider_factory: None,
100        #[cfg(feature = "db")]
101        shard_router: None,
102        #[cfg(feature = "db")]
103        directory_shard_router: false,
104        telemetry_provider: None,
105        session_store: None,
106        #[cfg(feature = "ws")]
107        channels_backend: None,
108        #[cfg(feature = "storage")]
109        blob_store: None,
110        cache_backend: None,
111        #[cfg(feature = "reporting")]
112        error_reporters: Vec::new(),
113        alert_channels: Vec::new(),
114        #[cfg(feature = "openapi")]
115        openapi: None,
116        #[cfg(feature = "mcp")]
117        mcp: None,
118        audit_logger: None,
119        #[cfg(feature = "i18n")]
120        i18n_bundle: None,
121        #[cfg(feature = "i18n")]
122        i18n_auto_load: false,
123        #[cfg(feature = "embed-assets")]
124        embedded_static: None,
125        #[cfg(all(feature = "embed-assets", feature = "i18n"))]
126        embedded_locales: None,
127        policy_registrations: Vec::new(),
128        #[cfg(feature = "mail")]
129        mail_delivery_queue_factory: None,
130        #[cfg(feature = "mail")]
131        suppression_store: None,
132        #[cfg(feature = "mail")]
133        mail_suppression_store: None,
134        #[cfg(feature = "mail")]
135        mount_unsubscribe_endpoint: false,
136        #[cfg(feature = "mail")]
137        mail_previews: Vec::new(),
138        #[cfg(feature = "maud")]
139        story_gallery: None,
140        declared_routes: Vec::new(),
141        idempotency_enabled: false,
142        #[cfg(feature = "mail")]
143        mail_interceptor: None,
144        job_interceptor: None,
145        #[cfg(feature = "db")]
146        db_interceptor: None,
147        #[cfg(feature = "ws")]
148        channels_interceptor: None,
149        #[cfg(feature = "oauth2")]
150        http_interceptor: None,
151        seo_sources: Vec::new(),
152        metrics_sources: Vec::new(),
153        health_indicators: Vec::new(),
154        #[cfg(feature = "inbound-mail")]
155        inbound_mail_router: None,
156    }
157}
158
159/// Count the raw routers omitted from `autumn routes` output because their
160/// endpoints can't be enumerated — the value `autumn routes audit` treats as a
161/// hard failure (an unprovable route defeats the coverage guarantee).
162///
163/// Every `.merge()` router is rootless — it has no mount prefix to match
164/// declarations against — so it is always opaque and always counts. A `.nest()`
165/// router carries a mount prefix, so it is treated as **covered** (enumerable,
166/// not omitted) when at least one declared route (from
167/// [`declare_plugin_routes`](AppBuilder::declare_plugin_routes)) has a path that
168/// falls under that prefix. This makes the documented
169/// `app.nest(prefix, router).declare_plugin_routes(routes)` pattern audit-clean
170/// without any dedicated bookkeeping: the declared routes prove the mount.
171///
172/// Soundness (fail-closed) is preserved: a bare `nest(prefix, raw_router)` with
173/// no declared route under `prefix` stays uncovered and counts, and every
174/// `merge()` counts unconditionally.
175fn omitted_router_count<'a>(
176    merge_routers: usize,
177    nest_prefixes: impl IntoIterator<Item = &'a str>,
178    declared_routes: &[crate::route_listing::RouteInfo],
179) -> usize {
180    let uncovered_nests = nest_prefixes
181        .into_iter()
182        .filter(|prefix| !nest_prefix_is_covered(prefix, declared_routes))
183        .count();
184    merge_routers + uncovered_nests
185}
186
187/// A nested mount at `prefix` is "covered" when at least one declared route's
188/// path falls under that prefix, proving the nested router's endpoints are
189/// enumerable in the `autumn routes` listing.
190fn nest_prefix_is_covered(
191    prefix: &str,
192    declared_routes: &[crate::route_listing::RouteInfo],
193) -> bool {
194    declared_routes
195        .iter()
196        .any(|route| path_is_under_prefix(&route.path, prefix))
197}
198
199/// Whether `path` is mounted under `prefix` — i.e. equal to the prefix or a
200/// descendant of it at a path-segment boundary (`/admin` covers `/admin` and
201/// `/admin/users`, but not `/administrators`). A root prefix (`/` or empty)
202/// covers everything.
203fn path_is_under_prefix(path: &str, prefix: &str) -> bool {
204    let prefix = prefix.trim_end_matches('/');
205    if prefix.is_empty() {
206        return true;
207    }
208    path == prefix
209        || path
210            .strip_prefix(prefix)
211            .is_some_and(|rest| rest.starts_with('/'))
212}
213
214type StartupHookFuture = Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send>>;
215type StartupHook = Box<dyn Fn(AppState) -> StartupHookFuture + Send + Sync>;
216type StateInitializer = Box<dyn FnOnce(&AppState) + Send>;
217type ShutdownHookFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
218type ShutdownHook = Box<dyn Fn() -> ShutdownHookFuture + Send + Sync>;
219
220// ── Tier-1 subsystem factories ────────────────────────────────
221//
222// `ConfigLoader` and `DatabasePoolProvider` use RPIT (`-> impl Future + Send`)
223// in their trait methods, so `Box<dyn Trait>` is not dyn-compatible. We store
224// boxed factory closures that capture the concrete impl at the call site and
225// erase its future type via `Pin<Box<dyn Future>>`. `TelemetryProvider`'s
226// `init` is sync, so it's stored as a normal `Box<dyn>`.
227type ConfigLoaderFactory = Box<
228    dyn FnOnce() -> Pin<
229            Box<dyn Future<Output = Result<AutumnConfig, crate::config::ConfigError>> + Send>,
230        > + Send,
231>;
232#[cfg(feature = "db")]
233type PoolProviderFactory = Box<
234    dyn FnOnce(
235            crate::config::DatabaseConfig,
236        ) -> Pin<
237            Box<
238                dyn Future<
239                        Output = Result<Option<crate::db::DatabaseTopology>, crate::db::PoolError>,
240                    > + Send,
241            >,
242        > + Send,
243>;
244/// Captured [`DatabasePoolProvider::create_shard_topology`] calls: builds
245/// one topology per configured shard, in declaration order.
246#[cfg(feature = "db")]
247type ShardProviderFactory = Box<
248    dyn FnOnce(
249            crate::config::DatabaseConfig,
250        ) -> Pin<
251            Box<
252                dyn Future<Output = Result<Vec<crate::db::DatabaseTopology>, crate::db::PoolError>>
253                    + Send,
254            >,
255        > + Send,
256>;
257
258/// Closure that registers a policy or scope on the runtime
259/// [`PolicyRegistry`](crate::authorization::PolicyRegistry).
260type PolicyRegistration = Box<dyn FnOnce(&crate::authorization::PolicyRegistry) + Send>;
261
262/// Represents an API version registration.
263#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
264pub struct ApiVersion {
265    /// The version name (e.g. "v1", "v2").
266    pub version: String,
267    /// When this version was deprecated.
268    pub deprecated_at: Option<chrono::DateTime<chrono::Utc>>,
269    /// When this version was sunsetted.
270    pub sunset_at: Option<chrono::DateTime<chrono::Utc>>,
271}
272
273/// A wrapper for registered API versions in the app state.
274#[derive(Clone, Debug)]
275pub struct RegisteredApiVersions(pub Vec<ApiVersion>);
276
277/// Builder for configuring and launching an Autumn application.
278///
279/// Created by [`app()`]. Collect routes with [`.routes()`](Self::routes),
280/// then call [`.run()`](Self::run) to start the HTTP server.
281///
282/// The builder follows the **builder pattern**: each method consumes `self`
283/// and returns a new `AppBuilder`, allowing chained calls.
284///
285/// # Examples
286///
287/// ```rust,no_run
288/// use autumn_web::prelude::*;
289///
290/// #[get("/a")]
291/// async fn route_a() -> &'static str { "a" }
292///
293/// #[get("/b")]
294/// async fn route_b() -> &'static str { "b" }
295///
296/// #[autumn_web::main]
297/// async fn main() {
298///     autumn_web::app()
299///         .routes(routes![route_a])
300///         .routes(routes![route_b])
301///         .run()
302///         .await;
303/// }
304/// ```
305#[allow(clippy::struct_excessive_bools)]
306pub struct AppBuilder {
307    pub(crate) routes: Vec<Route>,
308    /// Registered API versions.
309    pub api_versions: Vec<ApiVersion>,
310    /// Parallel to `routes`: registration origin for each route.
311    route_sources: Vec<crate::route_listing::RouteSource>,
312    /// Non-None while a plugin's `build()` is executing; routes and scoped
313    /// groups added during that window are attributed to this plugin.
314    current_plugin: Option<String>,
315    tasks: Vec<crate::task::TaskInfo>,
316    one_off_tasks: Vec<crate::task::OneOffTaskInfo>,
317    pub(crate) jobs: Vec<crate::job::JobInfo>,
318    /// Registered event listeners; durable ones are synthesized into jobs at
319    /// build time and the rest dispatch synchronously via the event registry.
320    pub(crate) listeners: Vec<crate::events::ListenerInfo>,
321    pub(crate) static_metas: Vec<crate::static_gen::StaticRouteMeta>,
322    pub(crate) exception_filters: Vec<Arc<dyn ExceptionFilter>>,
323    pub(crate) scoped_groups: Vec<ScopedGroup>,
324    pub(crate) merge_routers: Vec<axum::Router<AppState>>,
325    pub(crate) nest_routers: Vec<(String, axum::Router<AppState>)>,
326    /// Custom Tower layers registered via [`AppBuilder::layer`], applied
327    /// inside `RequestIdLayer` on ingress so they observe the request ID.
328    pub(crate) custom_layers: Vec<CustomLayerRegistration>,
329    /// Pre-static gate layers registered via [`AppBuilder::static_gate`],
330    /// applied outermost (outside session and before the static cache lookup)
331    /// so they can auth-gate / redirect requests before a cached SSG/ISG page
332    /// is served.
333    pub(crate) static_gate_layers: Vec<CustomLayerRegistration>,
334    pub(crate) startup_hooks: Vec<StartupHook>,
335    pub(crate) state_initializers: Vec<StateInitializer>,
336    pub(crate) shutdown_hooks: Vec<ShutdownHook>,
337    pub(crate) extensions: HashMap<TypeId, Box<dyn Any + Send>>,
338    /// Plugin names that have already been applied, for duplicate detection.
339    pub(crate) registered_plugins: HashSet<String>,
340    /// Top-level config roots plugins have declared as their own opaque config
341    /// sections via [`config_section`](AppBuilder::config_section). Threaded into
342    /// the default config loader so `server.strict_config` treats them as
343    /// known-and-opaque instead of unknown-key hard errors.
344    pub(crate) plugin_config_roots: BTreeSet<String>,
345    /// Custom error page renderer (overrides built-in pages).
346    #[cfg(feature = "maud")]
347    error_page_renderer: Option<SharedRenderer>,
348    /// Embedded Diesel migrations, registered via `.migrations()`.
349    #[cfg(feature = "db")]
350    migrations: Vec<migrate::EmbeddedMigrations>,
351    /// Custom config loader (tier-1 subsystem replacement). When `None`, the
352    /// default [`TomlEnvConfigLoader`](crate::config::TomlEnvConfigLoader) runs.
353    config_loader_factory: Option<ConfigLoaderFactory>,
354    /// Custom DB pool provider (tier-1 subsystem replacement). When `None`,
355    /// the default [`DieselDeadpoolPoolProvider`](crate::db::DieselDeadpoolPoolProvider) runs.
356    #[cfg(feature = "db")]
357    pool_provider_factory: Option<PoolProviderFactory>,
358    /// Companion to `pool_provider_factory` for `[[database.shards]]`
359    /// topologies; captured from the same provider in `with_pool_provider`.
360    #[cfg(feature = "db")]
361    shard_provider_factory: Option<ShardProviderFactory>,
362    /// Custom shard routing strategy. When `None` and shards are
363    /// configured, the default [`HashShardRouter`](crate::sharding::HashShardRouter)
364    /// is used.
365    #[cfg(feature = "db")]
366    shard_router: Option<Arc<dyn crate::sharding::ShardRouter>>,
367    /// Builder opt-in for the control-DB [`DirectoryShardRouter`](crate::sharding::DirectoryShardRouter),
368    /// applied to `config.database.directory_shard_router` at build time.
369    #[cfg(feature = "db")]
370    directory_shard_router: bool,
371    /// Custom telemetry provider (tier-1 subsystem replacement). When `None`,
372    /// the default [`TracingOtlpTelemetryProvider`](crate::telemetry::TracingOtlpTelemetryProvider) runs.
373    telemetry_provider: Option<Box<dyn crate::telemetry::TelemetryProvider>>,
374    /// Custom session store (tier-1 subsystem replacement). When `Some`,
375    /// `apply_session_layer` skips the config-driven `memory`/`redis` selection
376    /// and uses this store directly.
377    session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
378    /// Custom channel backend (tier-1 subsystem replacement). When `Some`,
379    /// `AppState` skips config-driven `in_process`/`redis` channel selection.
380    #[cfg(feature = "ws")]
381    channels_backend: Option<Arc<dyn crate::channels::ChannelsBackend>>,
382    /// Custom blob store installed via
383    /// [`AppBuilder::with_blob_store`]. When `Some`, `preflight_storage`
384    /// is skipped and this store is installed directly onto `AppState`.
385    #[cfg(feature = "storage")]
386    blob_store: Option<crate::storage::SharedBlobStore>,
387    /// Shared cache backend installed via [`AppBuilder::with_cache_backend`].
388    /// When `Some`, installed onto `AppState` as `shared_cache` before startup
389    /// hooks run.
390    cache_backend: Option<Arc<dyn crate::cache::Cache>>,
391    /// Error reporters registered via [`AppBuilder::with_error_reporter`].
392    /// Installed onto `AppState` so the
393    /// [`ReportingLayer`](crate::reporting::ReportingLayer) delivers panic and
394    /// 5xx [`ErrorEvent`](crate::reporting::ErrorEvent)s to each. Empty means
395    /// the built-in [`LogReporter`](crate::reporting::LogReporter) is used.
396    #[cfg(feature = "reporting")]
397    pub(crate) error_reporters: Vec<Arc<dyn crate::reporting::ErrorReporter>>,
398    /// Operator-alert channels registered via [`AppBuilder::with_alert_channel`].
399    /// Combined with the built-in mail/webhook channels derived from
400    /// `[alerts]` config and installed onto `AppState` so the built-in
401    /// condition hooks can fan out to each. Empty means only config-derived
402    /// destinations are used. See [`crate::alerts`].
403    pub(crate) alert_channels: Vec<Arc<dyn crate::alerts::AlertChannel>>,
404    /// `OpenAPI` generation configuration. When `Some`, the router mounts
405    /// `/v3/api-docs` (serving `openapi.json`) and `/swagger-ui` (if the
406    /// Swagger UI path is set). When `None`, no docs endpoints are mounted.
407    ///
408    /// Gated behind the `openapi` feature: apps that don't need a
409    /// served `OpenAPI` document shouldn't pay for the spec types or the
410    /// runtime collision-check machinery.
411    #[cfg(feature = "openapi")]
412    openapi: Option<crate::openapi::OpenApiConfig>,
413    /// MCP (Model Context Protocol) runtime config. `Some` once
414    /// [`AppBuilder::mount_mcp`] is called; the contained `expose_all` flag is
415    /// flipped by [`AppBuilder::expose_all_as_mcp`]. Gated behind the `mcp`
416    /// feature (which implies `openapi`).
417    #[cfg(feature = "mcp")]
418    mcp: Option<crate::mcp::McpRuntime>,
419    /// Shared audit logger used for append-only compliance events.
420    audit_logger: Option<Arc<crate::audit::AuditLogger>>,
421    /// Loaded i18n translation bundle. When `Some`, an `axum::Extension`
422    /// layer publishing this bundle is added at `run()` time so the
423    /// [`Locale`](crate::i18n::Locale) extractor can resolve translations.
424    #[cfg(feature = "i18n")]
425    i18n_bundle: Option<Arc<crate::i18n::Bundle>>,
426    /// Whether to load the i18n bundle after the active config loader resolves
427    /// [`AutumnConfig`]. This keeps `.i18n_auto()` aligned with
428    /// `.with_config_loader(...)`.
429    #[cfg(feature = "i18n")]
430    i18n_auto_load: bool,
431    /// Embedded `static/` tree (incl. the fingerprint manifest) registered via
432    /// [`embedded_static`](AppBuilder::embedded_static). When set, `/static/*`
433    /// is served from the binary and `asset_url()` resolves against the embedded
434    /// manifest — no `static/` sidecar directory is read at runtime.
435    #[cfg(feature = "embed-assets")]
436    embedded_static: Option<crate::assets::EmbeddedStaticDir>,
437    /// Embedded i18n locale bundles registered via
438    /// [`embedded_locales`](AppBuilder::embedded_locales). When set (and no
439    /// explicit bundle was provided), the bundle is loaded from the binary
440    /// instead of the `i18n/` directory on disk.
441    #[cfg(all(feature = "embed-assets", feature = "i18n"))]
442    embedded_locales: Option<&'static include_dir::Dir<'static>>,
443    /// Deferred [`Policy`](crate::authorization::Policy) and
444    /// [`Scope`](crate::authorization::Scope) registrations applied
445    /// to [`AppState::policy_registry`] just before the router is
446    /// built. Stored as boxed closures so we can carry the
447    /// generic type parameters across the builder boundary.
448    policy_registrations: Vec<PolicyRegistration>,
449    /// Durable mail delivery queue factory registered at builder time. Invoked
450    /// with the freshly-built [`AppState`] before `install_mailer` runs so it
451    /// can capture framework-managed resources (DB pool, channels, etc.).
452    #[cfg(feature = "mail")]
453    mail_delivery_queue_factory: Option<MailDeliveryQueueFactory>,
454    #[cfg(feature = "mail")]
455    pub(crate) suppression_store: Option<crate::mail::SuppressionStoreHandle>,
456    #[cfg(feature = "mail")]
457    pub(crate) mail_suppression_store: Option<crate::mail::suppression::SuppressionStoreHandle>,
458    #[cfg(feature = "mail")]
459    pub(crate) mount_unsubscribe_endpoint: bool,
460    /// Mail template previews registered for the dev preview UI.
461    #[cfg(feature = "mail")]
462    mail_previews: Vec<crate::mail::MailPreview>,
463    /// Widget story gallery registered for the `/_stories` UI (#1526).
464    #[cfg(feature = "maud")]
465    story_gallery: Option<crate::stories::StoryGallery>,
466    /// Routes explicitly declared by plugins for listing purposes, to complement
467    /// opaque `nest_routers`. Included in `autumn routes` output even though
468    /// the underlying Axum router is not enumerable.
469    declared_routes: Vec<crate::route_listing::RouteInfo>,
470    /// Whether `.idempotent()` was called on this builder. Applied to the
471    /// loaded `AutumnConfig` before router assembly so that startup validation
472    /// and `apply_middleware` both see `config.idempotency.enabled = true`.
473    idempotency_enabled: bool,
474    #[cfg(feature = "mail")]
475    mail_interceptor: Option<Arc<dyn crate::interceptor::MailInterceptor>>,
476    job_interceptor: Option<Arc<dyn crate::interceptor::JobInterceptor>>,
477    #[cfg(feature = "db")]
478    db_interceptor: Option<Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
479    #[cfg(feature = "ws")]
480    channels_interceptor: Option<Arc<dyn crate::interceptor::ChannelsInterceptor>>,
481    #[cfg(feature = "oauth2")]
482    http_interceptor: Option<Arc<dyn crate::interceptor::HttpInterceptor>>,
483    /// Sitemap sources registered via [`AppBuilder::seo_source`].
484    /// Each source provides dynamic URL entries for `/sitemap.xml`.
485    seo_sources: Vec<Arc<dyn crate::seo::SitemapSource>>,
486
487    /// Plugin-contributed metrics sources registered via [`AppBuilder::metrics_source`].
488    pub(crate) metrics_sources: Vec<(String, Arc<dyn crate::actuator::MetricsSource>)>,
489    /// Custom health indicators registered via [`AppBuilder::health_indicator`].
490    pub(crate) health_indicators: Vec<(
491        String,
492        crate::actuator::IndicatorGroup,
493        Arc<dyn crate::actuator::HealthIndicator>,
494    )>,
495    /// Inbound mail router registered via [`AppBuilder::inbound_mail_router`].
496    /// HTTP webhook routes are derived from the router's endpoint configs and
497    /// merged into the Axum router at startup.
498    #[cfg(feature = "inbound-mail")]
499    pub(crate) inbound_mail_router: Option<Arc<crate::inbound_mail::InboundMailRouter>>,
500}
501
502/// Boxed builder closure that constructs a durable
503/// [`MailDeliveryQueue`](crate::mail::MailDeliveryQueue) from the live
504/// [`AppState`].
505#[cfg(feature = "mail")]
506pub(crate) type MailDeliveryQueueFactory = Box<
507    dyn FnOnce(&AppState) -> crate::AutumnResult<Arc<dyn crate::mail::MailDeliveryQueue>> + Send,
508>;
509
510/// A group of routes sharing a common path prefix and middleware layer.
511///
512/// Created by [`AppBuilder::scoped`]. The routes are mounted under the
513/// prefix with the middleware applied only to this group.
514pub struct ScopedGroup {
515    pub prefix: String,
516    pub routes: Vec<Route>,
517    /// Registration origin: user application or a named plugin.
518    pub source: crate::route_listing::RouteSource,
519    /// Closure that applies the layer to a sub-router.
520    pub apply_layer: Box<dyn FnOnce(axum::Router<AppState>) -> axum::Router<AppState> + Send>,
521}
522
523/// A deferred router mutator that applies a user-registered
524/// [`tower::Layer`] to the app-wide router.
525///
526/// Stored on [`AppBuilder`] by [`AppBuilder::layer`] and drained inside
527/// `apply_middleware` where the final layer stack is assembled.
528pub(crate) type CustomLayerApplier =
529    Box<dyn FnOnce(axum::Router<AppState>) -> axum::Router<AppState> + Send>;
530
531/// Metadata and deferred application closure for a user-registered layer.
532pub(crate) struct CustomLayerRegistration {
533    /// Concrete type for the registered layer.
534    pub(crate) type_id: TypeId,
535    /// Concrete type name for generic layer families that need router-time
536    /// classification without unstable specialization.
537    pub(crate) type_name: &'static str,
538    /// Deferred router mutation that applies the layer.
539    pub(crate) apply: CustomLayerApplier,
540}
541
542mod sealed {
543    pub trait Sealed {}
544}
545
546/// Marker trait for types that can be registered with
547/// [`AppBuilder::layer`] as an app-wide Tower middleware.
548///
549/// Any [`tower::Layer`] whose produced service is a compatible axum
550/// service (i.e. `Service<Request, Response = Response, Error = Infallible>`,
551/// plus the usual `Clone + Send + Sync + 'static` bounds and a `Send`
552/// future) implements this trait automatically via a blanket impl.
553///
554/// The trait is **sealed**: it exists only to surface a clean
555/// `IntoAppLayer is not implemented for YourType` error message when a
556/// candidate layer fails to meet axum's service bounds, instead of a
557/// 40-line associated-type wall. You cannot implement it manually, and
558/// you should not need to — just bring your own `tower::Layer`.
559#[diagnostic::on_unimplemented(
560    message = "`{Self}` is not a usable Autumn app-wide Tower layer",
561    label = "this type does not implement `tower::Layer<axum::routing::Route>` with the required service bounds",
562    note = "`AppBuilder::layer(..)` requires:\n    L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,\n    L::Service: Service<axum::extract::Request, Response = axum::response::Response, Error = Infallible> + Clone + Send + Sync + 'static,\n    <L::Service as Service<axum::extract::Request>>::Future: Send + 'static\nSee docs/guide/middleware.md for common patterns and how to wrap raw-error layers (e.g. TimeoutLayer) with HandleErrorLayer."
563)]
564pub trait IntoAppLayer: sealed::Sealed + Send + Sync + 'static {
565    /// Apply this layer to the given router. Not intended for direct use.
566    #[doc(hidden)]
567    fn apply_to(self, router: axum::Router<AppState>) -> axum::Router<AppState>;
568}
569
570impl<L> sealed::Sealed for L
571where
572    L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
573    L::Service: tower::Service<
574            axum::extract::Request,
575            Response = axum::response::Response,
576            Error = std::convert::Infallible,
577        > + Clone
578        + Send
579        + Sync
580        + 'static,
581    <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
582{
583}
584
585impl<L> IntoAppLayer for L
586where
587    L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
588    L::Service: tower::Service<
589            axum::extract::Request,
590            Response = axum::response::Response,
591            Error = std::convert::Infallible,
592        > + Clone
593        + Send
594        + Sync
595        + 'static,
596    <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
597{
598    fn apply_to(self, router: axum::Router<AppState>) -> axum::Router<AppState> {
599        router.layer(self)
600    }
601}
602
603impl AppBuilder {
604    /// Register a collection of routes with the application.
605    ///
606    /// Can be called multiple times -- routes are combined additively.
607    /// Use the [`routes!`](crate::routes) macro to collect annotated
608    /// handlers into the expected `Vec<Route>`.
609    ///
610    /// # Examples
611    ///
612    /// ```rust,no_run
613    /// # use autumn_web::prelude::*;
614    /// # #[get("/users")] async fn list_users() -> &'static str { "" }
615    /// # #[get("/posts")] async fn list_posts() -> &'static str { "" }
616    /// # #[autumn_web::main]
617    /// # async fn main() {
618    /// autumn_web::app()
619    ///     .routes(routes![list_users])
620    ///     .routes(routes![list_posts])
621    ///     .run()
622    ///     .await;
623    /// # }
624    /// ```
625    #[must_use]
626    pub fn routes(mut self, routes: Vec<Route>) -> Self {
627        let source = self
628            .current_plugin
629            .as_ref()
630            .map_or(crate::route_listing::RouteSource::User, |name| {
631                crate::route_listing::RouteSource::Plugin(name.clone())
632            });
633        for _ in &routes {
634            self.route_sources.push(source.clone());
635        }
636        self.routes.extend(routes);
637        self
638    }
639
640    /// Register scheduled background tasks with the application.
641    ///
642    /// Tasks run alongside the HTTP server and are stopped during
643    /// graceful shutdown. Use the [`tasks!`](crate::tasks) macro
644    /// to collect `#[scheduled]` handlers.
645    #[must_use]
646    pub fn tasks(mut self, tasks: Vec<crate::task::TaskInfo>) -> Self {
647        self.tasks.extend(tasks);
648        self
649    }
650
651    /// Register one-off operational tasks runnable with `autumn task <name>`.
652    ///
653    /// Use the [`one_off_tasks!`](crate::one_off_tasks) macro to collect
654    /// `#[task]` handlers.
655    #[must_use]
656    pub fn one_off_tasks(mut self, tasks: Vec<crate::task::OneOffTaskInfo>) -> Self {
657        self.one_off_tasks.extend(tasks);
658        self
659    }
660
661    /// Register ad-hoc background jobs with the application.
662    #[must_use]
663    pub fn jobs(mut self, jobs: Vec<crate::job::JobInfo>) -> Self {
664        self.jobs.extend(jobs);
665        self
666    }
667
668    /// Register event listeners with the application.
669    ///
670    /// Collect them with `listeners![..]`. Durable listeners are wired onto the
671    /// job runtime automatically (no separate `jobs![..]` entry needed); sync
672    /// listeners run in-request when their event is published. Decoupled from
673    /// emitters: adding a listener never touches the code that publishes.
674    #[must_use]
675    pub fn listeners(mut self, listeners: Vec<crate::events::ListenerInfo>) -> Self {
676        self.listeners.extend(listeners);
677        self
678    }
679
680    /// Register static route metadata for build-time rendering.
681    ///
682    /// Use the [`static_routes!`](crate::static_routes) macro to collect
683    /// `#[static_get]` handlers' metadata.
684    #[must_use]
685    pub fn static_routes(mut self, metas: Vec<crate::static_gen::StaticRouteMeta>) -> Self {
686        self.static_metas.extend(metas);
687        self
688    }
689
690    /// Register a [`SitemapSource`](crate::seo::SitemapSource) for dynamic sitemap entries.
691    ///
692    /// When called at least once, the framework automatically serves `/sitemap.xml` and
693    /// `/robots.txt`. Dynamic sources (e.g. blog posts from a database) produce entries
694    /// collected at request time.
695    ///
696    /// Combine with `[seo] base_url` in `autumn.toml` to auto-inject the `Sitemap:`
697    /// directive in `robots.txt` and compute canonical URLs.
698    ///
699    /// # Example
700    ///
701    /// ```rust,no_run
702    /// use autumn_web::prelude::*;
703    /// use autumn_web::seo::{SitemapEntry, SitemapSource};
704    /// use std::pin::Pin;
705    /// use std::future::Future;
706    ///
707    /// struct PostsSitemap;
708    ///
709    /// impl SitemapSource for PostsSitemap {
710    ///     fn entries(&self) -> Pin<Box<dyn Future<Output = Vec<SitemapEntry>> + Send>> {
711    ///         Box::pin(async {
712    ///             vec![SitemapEntry::new("https://example.com/posts/hello")]
713    ///         })
714    ///     }
715    /// }
716    ///
717    /// # #[autumn_web::main]
718    /// # async fn main() {
719    /// # #[get("/")] async fn index() -> &'static str { "" }
720    /// autumn_web::app()
721    ///     .routes(routes![index])
722    ///     .seo_source(PostsSitemap)
723    ///     .run()
724    ///     .await;
725    /// # }
726    /// ```
727    #[must_use]
728    pub fn seo_source<S: crate::seo::SitemapSource + 'static>(mut self, source: S) -> Self {
729        self.seo_sources.push(Arc::new(source));
730        self
731    }
732
733    /// Enable `OpenAPI` (Swagger) spec auto-generation.
734    ///
735    /// When called, the framework inspects every registered route's
736    /// [`ApiDoc`](crate::openapi::ApiDoc) metadata — inferred at compile
737    /// time from the route path, HTTP method, extractor types, and any
738    /// [`#[api_doc(...)]`](crate::api_doc) overrides — and serves an
739    /// `OpenAPI` 3.0 JSON document at `OpenApiConfig::openapi_json_path`
740    /// (default `/v3/api-docs`). If
741    /// `OpenApiConfig::swagger_ui_path` is set (default `/swagger-ui`),
742    /// a Swagger UI HTML page is served there too.
743    ///
744    /// Routes marked `#[api_doc(hidden)]` are excluded.
745    ///
746    /// **Gated behind the `openapi` Cargo feature.** Add
747    /// `features = ["openapi"]` to your `autumn-web` dependency to
748    /// enable it; the default build excludes the runtime spec types
749    /// and endpoints to keep the binary small.
750    ///
751    /// # Examples
752    ///
753    /// Zero-config:
754    ///
755    /// ```rust,ignore
756    /// use autumn_web::prelude::*;
757    /// use autumn_web::openapi::OpenApiConfig;
758    ///
759    /// # #[get("/hello")] async fn hello() -> &'static str { "hi" }
760    /// # #[autumn_web::main]
761    /// # async fn main() {
762    /// autumn_web::app()
763    ///     .routes(routes![hello])
764    ///     .openapi(OpenApiConfig::new("My API", "1.0.0"))
765    ///     .run()
766    ///     .await;
767    /// # }
768    /// ```
769    ///
770    /// With custom paths:
771    ///
772    /// ```rust,ignore
773    /// use autumn_web::openapi::OpenApiConfig;
774    ///
775    /// let config = OpenApiConfig::new("My API", "1.0.0")
776    ///     .description("Full product API")
777    ///     .openapi_json_path("/openapi.json")
778    ///     .swagger_ui_path(Some("/docs".to_owned()));
779    /// ```
780    #[cfg(feature = "openapi")]
781    #[must_use]
782    pub fn openapi(mut self, config: crate::openapi::OpenApiConfig) -> Self {
783        self.openapi = Some(config);
784        self
785    }
786
787    /// Mount a Model Context Protocol (MCP) endpoint at `path` (e.g. `/mcp`).
788    ///
789    /// Projects opted-in routes — those tagged `#[api_doc(mcp)]` — as
790    /// agent-callable MCP tools over Streamable HTTP, handling `initialize`,
791    /// `tools/list`, and `tools/call`. A tool's `name`, `description`, and
792    /// `inputSchema` are derived from the handler's existing
793    /// [`ApiDoc`](crate::openapi::ApiDoc), so the tool catalog cannot drift
794    /// from the handler's typed contract. `tools/call` dispatches through the
795    /// real handler pipeline, so `#[secured]`, authorization, rate limits, and
796    /// validation apply identically to agent and HTTP calls.
797    ///
798    /// Opt-in is per-endpoint; nothing is exposed implicitly. Use
799    /// [`expose_all_as_mcp`](Self::expose_all_as_mcp) for the whole-API hatch.
800    ///
801    /// Only **JSON** endpoints are projected: a route is eligible when it
802    /// returns `Json<T>` (the structural signal for a JSON response). The
803    /// generated tool's `body` input is derived solely from a `Json<T>`
804    /// request extractor, so a handler that returns `Json<T>` but reads its
805    /// body via `Form<T>`, `Multipart`, `Bytes`, or `String` should **not** be
806    /// opted in — the tool would carry no body input and replay an empty
807    /// request. Use JSON request bodies for endpoints exposed as MCP tools.
808    ///
809    /// `tools/call` replays through the same pipeline as a direct HTTP request,
810    /// so `#[secured]`, route guards, rate limits, and validation apply
811    /// identically. One caveat applies only in **static/ISR mode** (an app with
812    /// a `dist` manifest): a global [`layer`](Self::layer) is applied outside
813    /// the static-first middleware and is therefore *not* traversed by MCP
814    /// `tools/call` replays. Prefer `#[secured]` or route-level guards (which do
815    /// apply) for MCP-exposed handlers in that mode.
816    ///
817    /// Requires the `mcp` Cargo feature.
818    ///
819    /// ```rust,ignore
820    /// autumn_web::app()
821    ///     .routes(routes![list_todos, create_todo])
822    ///     .mount_mcp("/mcp")
823    ///     .run()
824    ///     .await;
825    /// ```
826    #[cfg(feature = "mcp")]
827    #[must_use]
828    pub fn mount_mcp(mut self, path: impl Into<String>) -> Self {
829        let path = path.into();
830        if let Some(rt) = self.mcp.as_mut() {
831            rt.mount_path = path;
832        } else {
833            self.mcp = Some(crate::mcp::McpRuntime::new(path));
834        }
835        self
836    }
837
838    /// Whole-API escape hatch: expose **every** eligible read (`GET`) endpoint
839    /// as an MCP tool without per-endpoint tags.
840    ///
841    /// This is an explicit, separate opt-in — never the default. It still
842    /// honors per-endpoint exclusions (`#[api_doc(mcp = false)]`) and the
843    /// JSON-only rule, and **mutating verbs (`POST`/`PUT`/`PATCH`/`DELETE`)
844    /// still require an explicit `#[api_doc(mcp)]` opt-in** even under the
845    /// hatch.
846    ///
847    /// On its own this mounts the endpoint at the default `/mcp`; chain
848    /// [`mount_mcp`](Self::mount_mcp) to serve it at a different path.
849    ///
850    /// Requires the `mcp` Cargo feature.
851    #[cfg(feature = "mcp")]
852    #[must_use]
853    pub fn expose_all_as_mcp(mut self) -> Self {
854        if let Some(rt) = self.mcp.as_mut() {
855            rt.expose_all = true;
856        } else {
857            let mut rt = crate::mcp::McpRuntime::new("/mcp");
858            rt.expose_all = true;
859            self.mcp = Some(rt);
860        }
861        self
862    }
863
864    /// Gate the **entire** MCP endpoint — the catalog (`initialize`/
865    /// `tools/list`) as well as tool dispatch — behind a tower `layer`.
866    ///
867    /// The `/mcp` envelope is otherwise reachable without the app's global
868    /// middleware. Pass an auth layer (e.g.
869    /// [`RequireApiToken`](crate::auth::RequireApiToken)) here to require a
870    /// credential for the whole endpoint, the way you'd protect a normal
871    /// route group. Combine with [`mount_mcp`](Self::mount_mcp); the MCP
872    /// transport's spec-required `Origin` validation (sourced from your CORS
873    /// `allowed_origins`) always applies regardless of this layer.
874    ///
875    /// Requires the `mcp` Cargo feature.
876    #[cfg(feature = "mcp")]
877    #[must_use]
878    pub fn secure_mcp<L>(mut self, layer: L) -> Self
879    where
880        L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
881        L::Service: tower::Service<
882                axum::http::Request<axum::body::Body>,
883                Response = axum::http::Response<axum::body::Body>,
884                Error = std::convert::Infallible,
885            > + Clone
886            + Send
887            + Sync
888            + 'static,
889        <L::Service as tower::Service<axum::http::Request<axum::body::Body>>>::Future:
890            Send + 'static,
891    {
892        let applier: crate::mcp::McpEndpointLayer = Box::new(move |router| router.layer(layer));
893        if let Some(rt) = self.mcp.as_mut() {
894            rt.endpoint_layer = Some(applier);
895        } else {
896            let mut rt = crate::mcp::McpRuntime::new("/mcp");
897            rt.endpoint_layer = Some(applier);
898            self.mcp = Some(rt);
899        }
900        self
901    }
902
903    /// Register a global exception filter.
904    ///
905    /// Exception filters intercept error responses produced by
906    /// [`AutumnError`](crate::AutumnError) before they are sent to the
907    /// client. Filters run in registration order.
908    ///
909    /// # Examples
910    ///
911    /// ```rust,no_run
912    /// use autumn_web::middleware::{ExceptionFilter, AutumnErrorInfo};
913    /// use axum::response::Response;
914    ///
915    /// struct LogFilter;
916    /// impl ExceptionFilter for LogFilter {
917    ///     fn filter(&self, error: &AutumnErrorInfo, response: Response) -> Response {
918    ///         eprintln!("Error: {}", error.message);
919    ///         response
920    ///     }
921    /// }
922    ///
923    /// # use autumn_web::prelude::*;
924    /// # #[get("/")] async fn index() -> &'static str { "" }
925    /// # #[autumn_web::main]
926    /// # async fn main() {
927    /// autumn_web::app()
928    ///     .exception_filter(LogFilter)
929    ///     .routes(routes![index])
930    ///     .run()
931    ///     .await;
932    /// # }
933    /// ```
934    #[must_use]
935    pub fn exception_filter(mut self, filter: impl ExceptionFilter) -> Self {
936        self.exception_filters.push(Arc::new(filter));
937        self
938    }
939
940    /// Register a custom error page renderer.
941    ///
942    /// The renderer replaces the built-in default error pages (404, 422, 500,
943    /// and generic errors). Implement [`ErrorPageRenderer`] to provide your
944    /// own branded error pages.
945    ///
946    /// Only one renderer can be active. Calling this method multiple times
947    /// replaces the previous renderer.
948    ///
949    /// Requires the `maud` feature.
950    ///
951    /// # Examples
952    ///
953    /// ```rust,no_run
954    /// use autumn_web::error_pages::{ErrorPageRenderer, ErrorContext};
955    /// use maud::{Markup, html};
956    ///
957    /// struct MyErrors;
958    ///
959    /// impl ErrorPageRenderer for MyErrors {
960    ///     fn render_error(&self, ctx: &ErrorContext) -> Markup {
961    ///         html! {
962    ///             h1 { (ctx.status.as_u16()) " - Custom error page" }
963    ///         }
964    ///     }
965    /// }
966    ///
967    /// # use autumn_web::prelude::*;
968    /// # #[get("/")] async fn index() -> &'static str { "" }
969    /// # #[autumn_web::main]
970    /// # async fn main() {
971    /// autumn_web::app()
972    ///     .error_pages(MyErrors)
973    ///     .routes(routes![index])
974    ///     .run()
975    ///     .await;
976    /// # }
977    /// ```
978    #[must_use]
979    #[cfg(feature = "maud")]
980    pub fn error_pages(mut self, renderer: impl ErrorPageRenderer) -> Self {
981        self.error_page_renderer = Some(Arc::new(renderer));
982        self
983    }
984
985    /// Register a group of routes with a shared path prefix and middleware.
986    ///
987    /// The `layer` is applied only to routes within this group, not to the
988    /// rest of the application. The routes are mounted under `prefix`.
989    ///
990    /// # Examples
991    ///
992    /// ```rust,no_run
993    /// use autumn_web::prelude::*;
994    /// use autumn_web::middleware::RequestIdLayer; // any Tower Layer
995    ///
996    /// # #[get("/")]  async fn index() -> &'static str { "" }
997    /// # #[get("/users")] async fn list_users() -> &'static str { "" }
998    /// # #[autumn_web::main]
999    /// # async fn main() {
1000    /// autumn_web::app()
1001    ///     .routes(routes![index])
1002    ///     .scoped("/api", RequestIdLayer, routes![list_users])
1003    ///     .run()
1004    ///     .await;
1005    /// # }
1006    /// ```
1007    #[must_use]
1008    pub fn scoped<L>(mut self, prefix: &str, layer: L, routes: Vec<Route>) -> Self
1009    where
1010        L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
1011        L::Service: tower::Service<
1012                axum::http::Request<axum::body::Body>,
1013                Response = axum::http::Response<axum::body::Body>,
1014                Error = std::convert::Infallible,
1015            > + Clone
1016            + Send
1017            + Sync
1018            + 'static,
1019        <L::Service as tower::Service<axum::http::Request<axum::body::Body>>>::Future:
1020            Send + 'static,
1021    {
1022        let source = self
1023            .current_plugin
1024            .as_ref()
1025            .map_or(crate::route_listing::RouteSource::User, |name| {
1026                crate::route_listing::RouteSource::Plugin(name.clone())
1027            });
1028        self.scoped_groups.push(ScopedGroup {
1029            prefix: prefix.to_owned(),
1030            routes,
1031            source,
1032            apply_layer: Box::new(move |router| router.layer(layer)),
1033        });
1034        self
1035    }
1036
1037    /// Apply a custom [`tower::Layer`] to the entire application.
1038    ///
1039    /// This is the escape hatch for integrating any middleware from the
1040    /// Tower / Tower-HTTP ecosystem (timeouts, rate limiting, bespoke
1041    /// tracing, request signing, etc.) without forking the framework.
1042    ///
1043    /// The generic bound is [`IntoAppLayer`], a sealed trait with a blanket
1044    /// impl for every `tower::Layer` that meets axum's service requirements
1045    /// — in practice this means any standard Tower layer whose service
1046    /// produces `Infallible` errors. If your layer produces real errors
1047    /// (like `TimeoutLayer`'s `BoxError`), wrap it with
1048    /// [`axum::error_handling::HandleErrorLayer`] before passing it here.
1049    ///
1050    /// # Ordering
1051    ///
1052    /// User layers are applied **inside** Autumn's request-ID layer on the
1053    /// ingress path, which means your middleware always sees the generated
1054    /// `RequestId` in the request extensions. The full stack (outermost to
1055    /// innermost on ingress) is:
1056    ///
1057    /// `Metrics -> ExceptionFilter -> ErrorPageContext -> Session ->`
1058    /// `SecurityHeaders -> RequestId -> [user layers, registration order]`
1059    /// `-> CSRF -> CORS -> route handler`
1060    ///
1061    /// When `.layer()` is called multiple times, the **first** call becomes
1062    /// the outermost user layer on ingress (matching `tower::ServiceBuilder`
1063    /// semantics): the layer from the first `.layer(...)` call sees the
1064    /// request first on the way in and the response last on the way out.
1065    ///
1066    /// # Scope
1067    ///
1068    /// This layer applies **globally** to every route in the app, including
1069    /// routes added later by plugins, routes mounted via `.merge` / `.nest`,
1070    /// and the built-in `404` fallback. Use [`AppBuilder::scoped`] when you
1071    /// need middleware scoped to a group of routes.
1072    ///
1073    /// Shared state (pools, metrics registries, rate-limit stores, etc.)
1074    /// should be wrapped in `Arc` so the layer can satisfy the
1075    /// `Clone + Send + Sync + 'static` bounds without moving the state.
1076    ///
1077    /// See [the middleware guide](https://github.com/madmax983/autumn/blob/trunk/docs/guide/middleware.md)
1078    /// for ready-made recipes.
1079    ///
1080    /// # Examples
1081    ///
1082    /// Adding a Tower timeout layer in one line (Tower's `TimeoutLayer`
1083    /// returns `BoxError`, so it must be paired with `HandleErrorLayer` to
1084    /// satisfy axum's `Infallible` error requirement):
1085    ///
1086    /// ```rust,no_run
1087    /// use std::time::Duration;
1088    /// use autumn_web::prelude::*;
1089    /// use axum::{error_handling::HandleErrorLayer, http::StatusCode};
1090    /// use tower::{ServiceBuilder, timeout::TimeoutLayer};
1091    ///
1092    /// # #[get("/")] async fn index() -> &'static str { "ok" }
1093    /// # #[autumn_web::main]
1094    /// # async fn main() {
1095    /// autumn_web::app()
1096    ///     .routes(routes![index])
1097    ///     .layer(
1098    ///         ServiceBuilder::new()
1099    ///             .layer(HandleErrorLayer::new(|_| async {
1100    ///                 StatusCode::REQUEST_TIMEOUT
1101    ///             }))
1102    ///             .layer(TimeoutLayer::new(Duration::from_secs(5))),
1103    ///     )
1104    ///     .run()
1105    ///     .await;
1106    /// # }
1107    /// ```
1108    #[must_use]
1109    pub fn layer<L: IntoAppLayer>(mut self, layer: L) -> Self {
1110        self.custom_layers.push(CustomLayerRegistration {
1111            type_id: TypeId::of::<L>(),
1112            type_name: std::any::type_name::<L>(),
1113            apply: Box::new(move |router| layer.apply_to(router)),
1114        });
1115        self
1116    }
1117
1118    /// Returns `true` when a custom layer of type `L` has already been
1119    /// registered via [`AppBuilder::layer`].
1120    ///
1121    /// Intended for plugin pre-flight validation before the app is started.
1122    #[must_use]
1123    pub fn has_layer<L: 'static>(&self) -> bool {
1124        let layer_type = TypeId::of::<L>();
1125        self.custom_layers
1126            .iter()
1127            .any(|registered| registered.type_id == layer_type)
1128    }
1129
1130    /// Enable the HTTP idempotency-key middleware for this application.
1131    ///
1132    /// Mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) that carry an
1133    /// `Idempotency-Key` header are deduplicated: the first response is cached
1134    /// and replayed byte-for-byte on subsequent identical requests.
1135    /// Session-mutating responses are cached after the outer session middleware
1136    /// has finalized `Set-Cookie`, so retries can observe the successful
1137    /// mutation without re-entering the handler.
1138    ///
1139    /// Raw Axum routers registered with [`merge`](Self::merge) or
1140    /// [`nest`](Self::nest) are opaque to Autumn. They are protected from
1141    /// duplicate mutating retries by failing closed on cache hits; install
1142    /// idempotency and replay-stop layers inside those routers when raw routes
1143    /// need successful cached-response replay after their own route-local
1144    /// checks.
1145    ///
1146    /// The storage backend and TTL are taken from the `[idempotency]` block in
1147    /// `autumn.toml` (defaulting to in-process memory with a 24 h TTL).
1148    /// For multi-replica deployments set `backend = "redis"` and configure
1149    /// `[idempotency.redis]`.
1150    ///
1151    /// # Startup validation
1152    ///
1153    /// In production (`AUTUMN_PROFILE=production`) the memory backend is
1154    /// rejected unless `allow_memory_in_production = true` is set explicitly.
1155    #[must_use]
1156    pub const fn idempotent(mut self) -> Self {
1157        self.idempotency_enabled = true;
1158        self
1159    }
1160
1161    /// Returns the registered custom layer types in registration order.
1162    ///
1163    /// This includes only user-installed layers from
1164    /// [`AppBuilder::layer`], not framework-managed middleware.
1165    #[must_use]
1166    pub fn get_layer_types(&self) -> Vec<TypeId> {
1167        self.custom_layers
1168            .iter()
1169            .map(|registered| registered.type_id)
1170            .collect()
1171    }
1172
1173    /// Register a Tower layer that runs **before** the static file middleware
1174    /// and the static cache lookup — Autumn's equivalent of Next.js *Edge
1175    /// Middleware*.
1176    ///
1177    /// Cached SSG/ISG pages are served by the static-first middleware before
1178    /// the inner router (session, auth) is ever reached, so framework auth
1179    /// layers cannot gate pre-rendered responses. A `static_gate` layer runs
1180    /// outermost — outside the session layer and ahead of the static cache —
1181    /// so it can redirect or reject a request before a cached page is served.
1182    ///
1183    /// This is the right place for auth gating that protects pre-rendered
1184    /// routes: redirect unauthenticated visitors to a login page while leaving
1185    /// the cached HTML free of user-specific content. Personalised content
1186    /// still requires a fully dynamic route or client-side fetching.
1187    ///
1188    /// # Position and limitations
1189    ///
1190    /// * Runs as the **outermost** user middleware in *both* SSG/ISG and
1191    ///   fully-dynamic modes, so the same gate behaves identically regardless
1192    ///   of whether static generation is active.
1193    /// * Has access to request **headers and cookies**, but **NOT** the
1194    ///   session [`Extension`](axum::Extension) — the session layer runs inside
1195    ///   it. Verify a signed/JWT session cookie directly (e.g. with the same
1196    ///   signing key configured for the session) rather than relying on
1197    ///   session-populated extensions.
1198    /// * Like [`layer`](Self::layer), it applies globally to every route.
1199    /// * **Page-cache gate, not API auth.** The gate guards GET/HEAD page
1200    ///   serving and acts by issuing a browser redirect/reject. It is **not**
1201    ///   applied to MCP `tools/call` dispatch (a JSON-RPC call, where a redirect
1202    ///   is meaningless) in *either* mode: the gate is applied after the MCP
1203    ///   dispatch clone is taken. Gate MCP tools and JSON APIs with route-level
1204    ///   guards / `#[secured]` / session auth, which always traverse the
1205    ///   dispatch path. A well-behaved gate should therefore no-op on non-GET
1206    ///   requests (such as the `/mcp` JSON-RPC POST transport).
1207    /// * Short-circuit responses (the redirect/reject) are wrapped by the
1208    ///   framework's security-header layer, so they still carry HSTS/CSP, etc.
1209    /// * Because the gate runs **outside** the request stack (it must run before
1210    ///   session and the static cache), a gate short-circuit does **not** pass
1211    ///   through trusted-host validation or the per-request timeout — same as any
1212    ///   middleware registered with [`layer`](Self::layer) that runs before
1213    ///   those framework layers. Keep gate work bounded (prefer local
1214    ///   cookie/JWT checks over unbounded remote calls), and rely on the
1215    ///   framework's trusted-host policy for the routes the gate forwards to.
1216    ///
1217    /// Layers are wrapped in registration order with the first-registered gate
1218    /// outermost, matching [`tower::ServiceBuilder`] semantics.
1219    ///
1220    /// # Examples
1221    ///
1222    /// ```rust,no_run
1223    /// use autumn_web::prelude::*;
1224    /// use axum::{
1225    ///     extract::Request,
1226    ///     http::{header, Method, StatusCode},
1227    ///     middleware::Next,
1228    ///     response::Response,
1229    /// };
1230    ///
1231    /// async fn require_auth(req: Request, next: Next) -> Response {
1232    ///     // Only gate page navigation. Pass non-GET/HEAD requests (JSON APIs,
1233    ///     // form POSTs, the `/mcp` JSON-RPC transport, CORS preflights) straight
1234    ///     // through so a browser redirect never turns them into a 302.
1235    ///     let is_page = matches!(req.method(), &Method::GET | &Method::HEAD);
1236    ///     // Inspect a signed session cookie directly — no session Extension
1237    ///     // is available this far out in the stack.
1238    ///     if !is_page || req.headers().contains_key("x-authed") {
1239    ///         next.run(req).await
1240    ///     } else {
1241    ///         Response::builder()
1242    ///             .status(StatusCode::FOUND)
1243    ///             .header(header::LOCATION, "/login")
1244    ///             .body(axum::body::Body::empty())
1245    ///             .unwrap()
1246    ///     }
1247    /// }
1248    ///
1249    /// # #[get("/")] async fn index() -> &'static str { "ok" }
1250    /// # #[autumn_web::main]
1251    /// # async fn main() {
1252    /// autumn_web::app()
1253    ///     .routes(routes![index])
1254    ///     .static_gate(axum::middleware::from_fn(require_auth))
1255    ///     .run()
1256    ///     .await;
1257    /// # }
1258    /// ```
1259    #[must_use]
1260    pub fn static_gate<L: IntoAppLayer>(mut self, layer: L) -> Self {
1261        self.static_gate_layers.push(CustomLayerRegistration {
1262            type_id: TypeId::of::<L>(),
1263            type_name: std::any::type_name::<L>(),
1264            apply: Box::new(move |router| layer.apply_to(router)),
1265        });
1266        self
1267    }
1268
1269    /// Returns `true` when a pre-static gate layer of type `L` has already
1270    /// been registered via [`AppBuilder::static_gate`].
1271    ///
1272    /// Intended for plugin pre-flight validation before the app is started.
1273    #[must_use]
1274    pub fn has_static_gate<L: 'static>(&self) -> bool {
1275        let layer_type = TypeId::of::<L>();
1276        self.static_gate_layers
1277            .iter()
1278            .any(|registered| registered.type_id == layer_type)
1279    }
1280
1281    /// Returns the registered pre-static gate layer types in registration
1282    /// order.
1283    ///
1284    /// This includes only user-installed gates from
1285    /// [`AppBuilder::static_gate`], not regular layers or framework
1286    /// middleware.
1287    #[must_use]
1288    pub fn get_static_gate_types(&self) -> Vec<TypeId> {
1289        self.static_gate_layers
1290            .iter()
1291            .map(|registered| registered.type_id)
1292            .collect()
1293    }
1294
1295    /// Merge a raw Axum router into the application.
1296    ///
1297    /// This is an escape hatch for when Autumn's route macros are not
1298    /// sufficient -- for example, when integrating a third-party Axum
1299    /// middleware crate or mounting a hand-built WebSocket handler.
1300    ///
1301    /// The merged router shares the same [`AppState`] (database pool,
1302    /// config, etc.) and Autumn's global middleware (request IDs,
1303    /// security headers, session management) applies to its routes.
1304    /// When `.idempotent()` is enabled, retries that hit an existing raw-route
1305    /// idempotency record fail closed instead of rerunning the raw handler or
1306    /// replaying around opaque route-local checks. Install idempotency and
1307    /// replay-stop layers inside the raw router when successful replay is
1308    /// required.
1309    ///
1310    /// Merged routes are added **after** Autumn's annotated routes.
1311    /// If both define the same method+path pair, Axum treats that as an
1312    /// overlap and router construction will fail.
1313    ///
1314    /// Can be called multiple times -- routers are accumulated.
1315    ///
1316    /// # Examples
1317    ///
1318    /// ```rust,no_run
1319    /// use autumn_web::prelude::*;
1320    /// use autumn_web::AppState;
1321    ///
1322    /// #[get("/")]
1323    /// async fn index() -> &'static str { "hi" }
1324    ///
1325    /// #[autumn_web::main]
1326    /// async fn main() {
1327    ///     let raw = axum::Router::<AppState>::new()
1328    ///         .route("/ws", axum::routing::get(|| async { "websocket" }));
1329    ///
1330    ///     autumn_web::app()
1331    ///         .routes(routes![index])
1332    ///         .merge(raw)
1333    ///         .run()
1334    ///         .await;
1335    /// }
1336    /// ```
1337    #[must_use]
1338    pub fn merge(mut self, router: axum::Router<AppState>) -> Self {
1339        self.merge_routers.push(router);
1340        self
1341    }
1342
1343    /// Mount a raw Axum router under a path prefix.
1344    ///
1345    /// This is an escape hatch similar to [`merge`](Self::merge), but the
1346    /// router's routes are nested under the given `path` prefix. Useful
1347    /// for mounting a self-contained API version or third-party router.
1348    ///
1349    /// The nested router shares the same [`AppState`] and Autumn's global
1350    /// middleware applies to its routes. When `.idempotent()` is enabled,
1351    /// retries that hit an existing raw-route idempotency record fail closed
1352    /// instead of rerunning the raw handler or replaying around opaque
1353    /// route-local checks. Install idempotency and replay-stop layers inside
1354    /// the raw router when successful replay is required.
1355    ///
1356    /// Can be called multiple times with different prefixes.
1357    ///
1358    /// # Examples
1359    ///
1360    /// ```rust,no_run
1361    /// use autumn_web::prelude::*;
1362    /// use autumn_web::AppState;
1363    ///
1364    /// #[get("/")]
1365    /// async fn index() -> &'static str { "hi" }
1366    ///
1367    /// #[autumn_web::main]
1368    /// async fn main() {
1369    ///     let v2 = axum::Router::<AppState>::new()
1370    ///         .route("/users", axum::routing::get(|| async { "v2 users" }));
1371    ///
1372    ///     autumn_web::app()
1373    ///         .routes(routes![index])
1374    ///         .nest("/api/v2", v2)
1375    ///         .run()
1376    ///         .await;
1377    /// }
1378    /// ```
1379    #[must_use]
1380    pub fn nest(mut self, path: &str, router: axum::Router<AppState>) -> Self {
1381        self.nest_routers.push((path.to_owned(), router));
1382        self
1383    }
1384
1385    /// Explicitly register route metadata for listing via `autumn routes`.
1386    ///
1387    /// Plugins that mount routes via [`AppBuilder::nest`] (which is opaque to
1388    /// the route listing) can call this method so that `autumn routes --format json`
1389    /// shows their routes with the correct plugin attribution.
1390    ///
1391    /// Routes are automatically attributed to the current plugin when called from
1392    /// within a plugin's `build()` method. The `source` field of each supplied
1393    /// `RouteInfo` is overwritten with that attribution.
1394    ///
1395    /// Declaring routes also makes a [`nest`](Self::nest) mount *coverage-clean*
1396    /// for `autumn routes audit`: a nested router is normally opaque and counts
1397    /// as an omitted, unprovable router that hard-fails the gate, but when at
1398    /// least one declared route's path falls under the nest's prefix, the mount
1399    /// is treated as enumerable and no longer counts. So the documented
1400    /// `app.nest(prefix, router).declare_plugin_routes(routes)` pattern — with
1401    /// `routes` covering everything the raw router serves under `prefix` — passes
1402    /// the audit. A bare `nest`/`merge` with no covering declaration stays
1403    /// opaque and still fails closed.
1404    #[must_use]
1405    pub fn declare_plugin_routes(
1406        mut self,
1407        routes: impl IntoIterator<Item = crate::route_listing::RouteInfo>,
1408    ) -> Self {
1409        let source = self
1410            .current_plugin
1411            .as_deref()
1412            .map_or(crate::route_listing::RouteSource::User, |name| {
1413                crate::route_listing::RouteSource::Plugin(name.to_owned())
1414            });
1415        for mut route in routes {
1416            route.source = source.clone();
1417            self.declared_routes.push(route);
1418        }
1419        self
1420    }
1421
1422    /// Register an async startup hook that runs after [`AppState`] exists and
1423    /// before the server begins accepting requests.
1424    ///
1425    /// This is intended for background runtimes that need the fully built app
1426    /// state, such as workers or pollers that share the database pool.
1427    #[must_use]
1428    pub fn on_startup<F, Fut>(mut self, hook: F) -> Self
1429    where
1430        F: Fn(AppState) -> Fut + Send + Sync + 'static,
1431        Fut: Future<Output = crate::AutumnResult<()>> + Send + 'static,
1432    {
1433        self.startup_hooks
1434            .push(Box::new(move |state| Box::pin(hook(state))));
1435        self
1436    }
1437
1438    /// Register a synchronous initializer that mutates [`AppState`] after
1439    /// framework-managed extensions are installed and before job workers start.
1440    #[must_use]
1441    pub fn state_initializer<F>(mut self, initializer: F) -> Self
1442    where
1443        F: FnOnce(&AppState) + Send + 'static,
1444    {
1445        self.state_initializers.push(Box::new(initializer));
1446        self
1447    }
1448
1449    /// Register an async shutdown hook that runs during graceful shutdown.
1450    ///
1451    /// Hooks execute in reverse registration order so later-added runtimes
1452    /// shut down before earlier infrastructure they might depend on.
1453    #[must_use]
1454    pub fn on_shutdown<F, Fut>(mut self, hook: F) -> Self
1455    where
1456        F: Fn() -> Fut + Send + Sync + 'static,
1457        Fut: Future<Output = ()> + Send + 'static,
1458    {
1459        self.shutdown_hooks.push(Box::new(move || Box::pin(hook())));
1460        self
1461    }
1462
1463    /// Register a single API version. If a version with the same name already exists, it is updated.
1464    #[must_use]
1465    pub fn api_version(mut self, version: ApiVersion) -> Self {
1466        if let Some(pos) = self
1467            .api_versions
1468            .iter()
1469            .position(|v| v.version == version.version)
1470        {
1471            self.api_versions[pos] = version;
1472        } else {
1473            self.api_versions.push(version);
1474        }
1475        self
1476    }
1477
1478    /// Register multiple API versions, replacing duplicates.
1479    #[must_use]
1480    pub fn api_versions(mut self, versions: impl IntoIterator<Item = ApiVersion>) -> Self {
1481        for version in versions {
1482            if let Some(pos) = self
1483                .api_versions
1484                .iter()
1485                .position(|v| v.version == version.version)
1486            {
1487                self.api_versions[pos] = version;
1488            } else {
1489                self.api_versions.push(version);
1490            }
1491        }
1492        self
1493    }
1494
1495    /// Store or replace a typed builder extension.
1496    ///
1497    /// External crates use this to accumulate configuration across fluent
1498    /// extension-trait calls without Autumn needing to know the concrete type.
1499    #[must_use]
1500    pub fn with_extension<T>(mut self, value: T) -> Self
1501    where
1502        T: Any + Send + 'static,
1503    {
1504        self.extensions.insert(TypeId::of::<T>(), Box::new(value));
1505        self
1506    }
1507
1508    /// Mutate a typed builder extension, inserting a default value first when
1509    /// the extension has not been registered yet.
1510    ///
1511    /// # Panics
1512    ///
1513    /// Panics if the internal extension type map is corrupted and the value
1514    /// stored under `T`'s [`TypeId`] cannot be downcast back to `T`.
1515    #[must_use]
1516    pub fn update_extension<T, Init, Update>(mut self, init: Init, update: Update) -> Self
1517    where
1518        T: Any + Send + 'static,
1519        Init: FnOnce() -> T,
1520        Update: FnOnce(&mut T),
1521    {
1522        let type_id = TypeId::of::<T>();
1523        let entry = self
1524            .extensions
1525            .entry(type_id)
1526            .or_insert_with(|| Box::new(init()));
1527        let typed = entry
1528            .downcast_mut::<T>()
1529            .expect("extension type map corrupted");
1530        update(typed);
1531        self
1532    }
1533
1534    /// Borrow a typed builder extension if it has been registered.
1535    #[must_use]
1536    pub fn extension<T>(&self) -> Option<&T>
1537    where
1538        T: Any + Send + 'static,
1539    {
1540        self.extensions.get(&TypeId::of::<T>())?.downcast_ref::<T>()
1541    }
1542
1543    #[cfg(feature = "mail")]
1544    #[must_use]
1545    pub fn with_mail_interceptor(
1546        mut self,
1547        interceptor: impl crate::interceptor::MailInterceptor,
1548    ) -> Self {
1549        self.mail_interceptor = Some(Arc::new(interceptor));
1550        self
1551    }
1552
1553    #[must_use]
1554    pub fn with_job_interceptor(
1555        mut self,
1556        interceptor: impl crate::interceptor::JobInterceptor,
1557    ) -> Self {
1558        self.job_interceptor = Some(Arc::new(interceptor));
1559        self
1560    }
1561
1562    #[cfg(feature = "db")]
1563    #[must_use]
1564    pub fn with_db_interceptor(
1565        mut self,
1566        interceptor: impl crate::interceptor::DbConnectionInterceptor,
1567    ) -> Self {
1568        self.db_interceptor = Some(Arc::new(interceptor));
1569        self
1570    }
1571
1572    #[cfg(feature = "ws")]
1573    #[must_use]
1574    pub fn with_channels_interceptor(
1575        mut self,
1576        interceptor: impl crate::interceptor::ChannelsInterceptor,
1577    ) -> Self {
1578        self.channels_interceptor = Some(Arc::new(interceptor));
1579        self
1580    }
1581
1582    #[cfg(feature = "oauth2")]
1583    #[must_use]
1584    pub fn with_http_interceptor(
1585        mut self,
1586        interceptor: impl crate::interceptor::HttpInterceptor,
1587    ) -> Self {
1588        self.http_interceptor = Some(Arc::new(interceptor));
1589        self
1590    }
1591
1592    /// Register a pre-loaded i18n translation bundle.
1593    ///
1594    /// Most apps prefer [`Self::i18n_auto`] which loads from the
1595    /// `i18n/` directory using the configured `[i18n]` block. Use this
1596    /// directly when you need to construct a [`Bundle`](crate::i18n::Bundle)
1597    /// from non-filesystem sources (in-memory tests, embedded `.ftl` files,
1598    /// translation-management-system clients, etc.).
1599    #[cfg(feature = "i18n")]
1600    #[must_use]
1601    pub fn i18n(mut self, bundle: crate::i18n::Bundle) -> Self {
1602        self.i18n_bundle = Some(Arc::new(bundle));
1603        self.i18n_auto_load = false;
1604        self
1605    }
1606
1607    /// Auto-load the i18n translation bundle from the configured directory
1608    /// (`i18n/` by default), reading the `[i18n]` block from the active
1609    /// [`AutumnConfig`].
1610    ///
1611    /// Fails fast during [`Self::run`] if the configured default locale's file is
1612    /// missing — the spec calls out this as the desired behaviour: a
1613    /// half-localized app is worse than a clearly-broken one. The error
1614    /// path here panics with the typed [`LoadError`](crate::i18n::LoadError)
1615    /// formatted as a string so it surfaces in the same banner as other
1616    /// fatal startup errors.
1617    ///
1618    /// # Panics
1619    ///
1620    /// Panics when configuration cannot be loaded, the configured i18n
1621    /// directory is unreadable, or the default locale bundle is missing or
1622    /// invalid.
1623    ///
1624    /// # Examples
1625    ///
1626    /// ```rust,no_run
1627    /// use autumn_web::prelude::*;
1628    ///
1629    /// #[get("/")]
1630    /// async fn index() -> &'static str { "ok" }
1631    ///
1632    /// #[autumn_web::main]
1633    /// async fn main() {
1634    ///     # #[cfg(feature = "i18n")]
1635    ///     autumn_web::app()
1636    ///         .i18n_auto()
1637    ///         .routes(routes![index])
1638    ///         .run()
1639    ///         .await;
1640    /// }
1641    /// ```
1642    #[cfg(feature = "i18n")]
1643    #[must_use]
1644    pub fn i18n_auto(mut self) -> Self {
1645        self.i18n_bundle = None;
1646        self.i18n_auto_load = true;
1647        self
1648    }
1649
1650    // ── Tier-1 subsystem replacement hooks ─────────────────────
1651    //
1652    // Each `with_*` method swaps a framework-default subsystem for a
1653    // user-provided trait impl. The defaults preserve current behaviour, so
1654    // applications that don't customize see no change. Plugins typically chain
1655    // these in their `build()` body to ship a subsystem (e.g. an
1656    // `AwsSecretsConfigPlugin` that calls `app.with_config_loader(...)`).
1657    // See `docs/guides/extensibility.md`.
1658
1659    /// Install a custom [`ConfigLoader`],
1660    /// replacing the default TOML + env loader.
1661    ///
1662    /// Useful when your config lives somewhere other than `autumn.toml` —
1663    /// AWS Secrets Manager, Vault, a JSON file, an HTTP fetch, etc. Emits a
1664    /// `tracing::warn!` if a loader was already installed.
1665    #[must_use]
1666    pub fn with_config_loader<L>(mut self, loader: L) -> Self
1667    where
1668        L: crate::config::ConfigLoader,
1669    {
1670        if self.config_loader_factory.is_some() {
1671            tracing::warn!(
1672                "config loader replaced; the previously-installed loader was overwritten"
1673            );
1674        }
1675        self.config_loader_factory = Some(Box::new(move || {
1676            Box::pin(async move { loader.load().await })
1677        }));
1678        self
1679    }
1680
1681    /// Install a custom [`crate::db::DatabasePoolProvider`],
1682    /// replacing the default `deadpool + diesel-async` pool factory.
1683    ///
1684    /// Useful for adding metrics/circuit-breaker wrappers, switching to a
1685    /// per-shard pool, or driving a non-default backend at the same
1686    /// `Pool<AsyncPgConnection>` interface. Emits a `tracing::warn!` if a
1687    /// provider was already installed.
1688    #[cfg(feature = "db")]
1689    #[must_use]
1690    pub fn with_pool_provider<P>(mut self, provider: P) -> Self
1691    where
1692        P: crate::db::DatabasePoolProvider,
1693    {
1694        if self.pool_provider_factory.is_some() {
1695            tracing::warn!(
1696                "database pool provider replaced; the previously-installed provider was overwritten"
1697            );
1698        }
1699        // The provider serves both the control topology and any configured
1700        // shard topologies; share it between the two captured closures.
1701        let provider = Arc::new(provider);
1702        let shard_provider = Arc::clone(&provider);
1703        self.pool_provider_factory =
1704            Some(Box::new(move |config: crate::config::DatabaseConfig| {
1705                Box::pin(async move { provider.create_topology(&config).await })
1706            }));
1707        self.shard_provider_factory =
1708            Some(Box::new(move |config: crate::config::DatabaseConfig| {
1709                Box::pin(async move {
1710                    let mut topologies = Vec::with_capacity(config.shards.len());
1711                    for shard in &config.shards {
1712                        topologies
1713                            .push(shard_provider.create_shard_topology(shard, &config).await?);
1714                    }
1715                    Ok(topologies)
1716                })
1717            }));
1718        self
1719    }
1720
1721    /// Install a custom [`ShardRouter`](crate::sharding::ShardRouter),
1722    /// replacing the default slot-hash router for `[[database.shards]]`
1723    /// routing.
1724    ///
1725    /// Useful for directory/lookup routing — e.g. a control-plane table
1726    /// that pins hot tenants to dedicated shards. Custom routers can
1727    /// still compose with the deterministic hash via
1728    /// [`ShardSet::slot_for_key`](crate::sharding::ShardSet::slot_for_key)
1729    /// and
1730    /// [`ShardSet::shard_for_slot`](crate::sharding::ShardSet::shard_for_slot).
1731    #[cfg(feature = "db")]
1732    #[must_use]
1733    pub fn with_shard_router<R>(mut self, router: R) -> Self
1734    where
1735        R: crate::sharding::ShardRouter,
1736    {
1737        if self.shard_router.is_some() {
1738            tracing::warn!(
1739                "shard router replaced; the previously-installed router was overwritten"
1740            );
1741        }
1742        self.shard_router = Some(Arc::new(router));
1743        self
1744    }
1745
1746    /// Route tenants through the control-plane `_autumn_shard_directory` table
1747    /// via a [`DirectoryShardRouter`](crate::sharding::DirectoryShardRouter).
1748    ///
1749    /// The router is bound to the control primary pool at build time. Tenants
1750    /// with a directory row are pinned to the named shard; everyone else falls
1751    /// back to the slot-hash router. Apply the framework migrations to the
1752    /// control database (`autumn migrate`) so `_autumn_shard_directory` exists.
1753    ///
1754    /// An explicit [`with_shard_router`](Self::with_shard_router) takes
1755    /// precedence over this flag.
1756    #[cfg(feature = "db")]
1757    #[must_use]
1758    pub const fn with_directory_shard_router(mut self) -> Self {
1759        self.directory_shard_router = true;
1760        self
1761    }
1762
1763    /// Install a custom [`TelemetryProvider`](crate::telemetry::TelemetryProvider),
1764    /// replacing the default `tracing-subscriber + OTLP` initializer.
1765    ///
1766    /// Useful for shipping a Datadog tracer, Honeycomb beeline, Sentry
1767    /// integration, or any other observability backend. Emits a
1768    /// `tracing::warn!` if a provider was already installed.
1769    #[must_use]
1770    pub fn with_telemetry_provider<T>(mut self, provider: T) -> Self
1771    where
1772        T: crate::telemetry::TelemetryProvider,
1773    {
1774        if self.telemetry_provider.is_some() {
1775            tracing::warn!(
1776                "telemetry provider replaced; the previously-installed provider was overwritten"
1777            );
1778        }
1779        self.telemetry_provider = Some(Box::new(provider));
1780        self
1781    }
1782
1783    /// Install a custom [`SessionStore`](crate::session::SessionStore),
1784    /// bypassing the config-driven `memory`/`redis` backend selection.
1785    ///
1786    /// Useful for backing sessions with a database, encrypted cookie store,
1787    /// or enterprise SSO bridge. Emits a `tracing::warn!` if a store was
1788    /// already installed.
1789    #[must_use]
1790    pub fn with_session_store<S>(mut self, store: S) -> Self
1791    where
1792        S: crate::session::SessionStore,
1793    {
1794        if self.session_store.is_some() {
1795            tracing::warn!(
1796                "session store replaced; the previously-installed store was overwritten"
1797            );
1798        }
1799        self.session_store = Some(Arc::new(store));
1800        self
1801    }
1802
1803    /// Install a custom [`ChannelsBackend`](crate::channels::ChannelsBackend),
1804    /// bypassing the config-driven `in_process`/`redis` backend selection.
1805    ///
1806    /// Useful for NATS, Postgres `LISTEN/NOTIFY`, test harnesses, or a
1807    /// sharded pub/sub fabric. Emits a `tracing::warn!` if a backend was
1808    /// already installed.
1809    #[cfg(feature = "ws")]
1810    #[must_use]
1811    pub fn with_channels_backend<B>(mut self, backend: B) -> Self
1812    where
1813        B: crate::channels::ChannelsBackend,
1814    {
1815        if self.channels_backend.is_some() {
1816            tracing::warn!(
1817                "channels backend replaced; the previously-installed backend was overwritten"
1818            );
1819        }
1820        self.channels_backend = Some(Arc::new(backend));
1821        self
1822    }
1823
1824    /// Install a custom [`BlobStore`](crate::storage::BlobStore),
1825    /// bypassing the config-driven `local`/`s3` backend selection.
1826    ///
1827    /// The typical use case is the `autumn-storage-s3` plugin:
1828    ///
1829    /// ```rust,ignore
1830    /// use autumn_storage_s3::S3BlobStore;
1831    ///
1832    /// # async fn example() {
1833    /// let config = autumn_web::config::TomlEnvConfigLoader::new()
1834    ///     .load().await.unwrap();
1835    /// let store = S3BlobStore::from_config(&config.storage.s3)
1836    ///     .await.unwrap();
1837    /// autumn_web::app()
1838    ///     .with_blob_store(store)
1839    ///     .run()
1840    ///     .await;
1841    /// # }
1842    /// ```
1843    ///
1844    /// Emits a `tracing::warn!` if a store was already installed (last
1845    /// call wins).
1846    ///
1847    /// # Note on `LocalBlobStore`
1848    ///
1849    /// **Do not** pass a [`LocalBlobStore`](crate::storage::LocalBlobStore)
1850    /// here. The local backend requires the framework to mount a `/_blobs`
1851    /// serving route (for HMAC-signed presigned URLs); that route is only
1852    /// wired up when the store is provisioned through the config-driven path
1853    /// (`backend = "local"` in `autumn.toml`). Calling
1854    /// `.with_blob_store(LocalBlobStore::new(...))` will silently succeed but
1855    /// presigned URLs will return 404. Use the `[storage]` config section for
1856    /// local storage.
1857    #[cfg(feature = "storage")]
1858    #[must_use]
1859    pub fn with_blob_store<B>(mut self, store: B) -> Self
1860    where
1861        B: crate::storage::BlobStore,
1862    {
1863        if self.blob_store.is_some() {
1864            tracing::warn!("blob store replaced; the previously-installed store was overwritten");
1865        }
1866        self.blob_store = Some(std::sync::Arc::new(store));
1867        self
1868    }
1869
1870    /// Register a shared cache backend for the application.
1871    ///
1872    /// Once registered, `#[cached]` functions will use this backend as their
1873    /// primary store (falling back to their per-function Moka cache only if the
1874    /// global backend is absent). `CacheResponseLayer::from_app` returns a layer
1875    /// wired to this same backend.
1876    ///
1877    /// # Example
1878    ///
1879    /// ```rust,ignore
1880    /// use autumn_cache_redis::RedisCache;
1881    ///
1882    /// let cache = RedisCache::connect("redis://redis:6379", "myapp:cache").await?;
1883    /// autumn_web::app()
1884    ///     .with_cache_backend(cache)
1885    ///     .run()
1886    ///     .await;
1887    /// ```
1888    #[must_use]
1889    pub fn with_cache_backend<C: crate::cache::Cache>(mut self, cache: C) -> Self {
1890        if self.cache_backend.is_some() {
1891            tracing::warn!(
1892                "cache backend replaced; the previously-installed backend was overwritten"
1893            );
1894        }
1895        self.cache_backend = Some(Arc::new(cache) as Arc<dyn crate::cache::Cache>);
1896        self
1897    }
1898
1899    /// Register an [`ErrorReporter`](crate::reporting::ErrorReporter) for
1900    /// unhandled panics and 5xx responses.
1901    ///
1902    /// Reporters receive a structured
1903    /// [`ErrorEvent`](crate::reporting::ErrorEvent) for every caught handler
1904    /// panic and every server-error response, carrying request context (route,
1905    /// method, request id, status) and — for panics — the panic payload and a
1906    /// backtrace (when `RUST_BACKTRACE` is set). Call this multiple times to
1907    /// chain reporters; each receives every event. When none are registered,
1908    /// the built-in [`LogReporter`](crate::reporting::LogReporter) is used.
1909    ///
1910    /// Mirrors [`with_blob_store`](Self::with_blob_store) /
1911    /// [`with_cache_backend`](Self::with_cache_backend).
1912    ///
1913    /// # Examples
1914    ///
1915    /// ```rust,no_run
1916    /// use autumn_web::reporting::{ErrorEvent, ErrorReporter, ReportFuture};
1917    ///
1918    /// struct MyReporter;
1919    /// impl ErrorReporter for MyReporter {
1920    ///     fn report<'a>(&'a self, event: &'a ErrorEvent) -> ReportFuture<'a> {
1921    ///         Box::pin(async move { eprintln!("error: {} {}", event.status, event.message); })
1922    ///     }
1923    /// }
1924    ///
1925    /// # #[autumn_web::main]
1926    /// # async fn main() {
1927    /// autumn_web::app()
1928    ///     .with_error_reporter(MyReporter)
1929    /// #   .routes(vec![])
1930    /// #   ;
1931    /// # }
1932    /// ```
1933    #[cfg(feature = "reporting")]
1934    #[must_use]
1935    pub fn with_error_reporter<R: crate::reporting::ErrorReporter>(mut self, reporter: R) -> Self {
1936        self.error_reporters
1937            .push(Arc::new(reporter) as Arc<dyn crate::reporting::ErrorReporter>);
1938        self
1939    }
1940
1941    /// Register an operator-alert delivery channel.
1942    ///
1943    /// Alerts for the built-in conditions (dead-lettered jobs, Down health
1944    /// indicators, 5xx-rate spikes, scheduled-task failures) are delivered to
1945    /// every registered [`AlertChannel`](crate::alerts::AlertChannel) **plus**
1946    /// the built-in mail/webhook channels derived from `[alerts]` config.
1947    ///
1948    /// This is the extension seam for additional transports (`PagerDuty`, Slack,
1949    /// Discord — follow-up #1630): implement
1950    /// [`AlertChannel`](crate::alerts::AlertChannel) and register it here. The
1951    /// framework core never changes. Most apps need no code at all — configuring
1952    /// an `email` and/or `webhook_url` under `[alerts]` is sufficient.
1953    ///
1954    /// ```rust,no_run
1955    /// use autumn_web::alerts::{Alert, AlertChannel, AlertDeliveryError, AlertDeliveryFuture};
1956    ///
1957    /// struct PagerDuty;
1958    /// impl AlertChannel for PagerDuty {
1959    ///     fn name(&self) -> &'static str { "pagerduty" }
1960    ///     fn deliver<'a>(&'a self, alert: &'a Alert) -> AlertDeliveryFuture<'a> {
1961    ///         Box::pin(async move {
1962    ///             let _ = (&alert.dedup_key, alert.severity);
1963    ///             Ok::<(), AlertDeliveryError>(())
1964    ///         })
1965    ///     }
1966    /// }
1967    ///
1968    /// # #[autumn_web::main]
1969    /// # async fn main() {
1970    /// autumn_web::app()
1971    ///     .with_alert_channel(PagerDuty)
1972    /// #   .routes(vec![])
1973    /// #   ;
1974    /// # }
1975    /// ```
1976    #[must_use]
1977    pub fn with_alert_channel<C: crate::alerts::AlertChannel>(mut self, channel: C) -> Self {
1978        self.alert_channels
1979            .push(Arc::new(channel) as Arc<dyn crate::alerts::AlertChannel>);
1980        self
1981    }
1982
1983    /// Register a [`FlagStore`](crate::feature_flags::FlagStore) backend for
1984    /// feature-flag evaluation.
1985    ///
1986    /// After registration, the [`Flags`](crate::feature_flags::Flags) extractor
1987    /// and `#[feature_flag]` macro are available in route handlers. Without a
1988    /// registered store, both return `500 Internal Server Error`.
1989    ///
1990    /// For tests use [`InMemoryFlagStore`](crate::feature_flags::InMemoryFlagStore);
1991    /// in production use the Postgres-backed
1992    /// `autumn_web::feature_flags::pg::PgFlagStore`.
1993    ///
1994    /// # Sharing the store with the poll listener
1995    ///
1996    /// When using `PgFlagStore` in a multi-replica deployment, pass an `Arc`
1997    /// clone so the app service and the poll listener share the **same** cache:
1998    ///
1999    /// ```rust,ignore
2000    /// use std::sync::Arc;
2001    /// use std::time::Duration;
2002    /// use autumn_web::feature_flags::pg::PgFlagStore;
2003    ///
2004    /// let store = Arc::new(PgFlagStore::new(&config.database.primary_url));
2005    /// PgFlagStore::spawn_poll_listener(Arc::clone(&store), Duration::from_secs(1));
2006    /// autumn_web::app()
2007    ///     .with_flag_store(Arc::clone(&store))
2008    ///     .run()
2009    ///     .await;
2010    /// ```
2011    ///
2012    /// `Arc<PgFlagStore>` implements `FlagStore`, so the same `Arc` is
2013    /// accepted directly without creating a separate cache instance.
2014    ///
2015    /// # Basic example
2016    ///
2017    /// ```rust,ignore
2018    /// use autumn_web::feature_flags::InMemoryFlagStore;
2019    /// use std::sync::Arc;
2020    ///
2021    /// autumn_web::app()
2022    ///     .with_flag_store(InMemoryFlagStore::new())
2023    ///     .run()
2024    ///     .await;
2025    /// ```
2026    #[must_use]
2027    pub fn with_flag_store<S>(self, store: S) -> Self
2028    where
2029        S: crate::feature_flags::FlagStore,
2030    {
2031        let service = crate::feature_flags::FeatureFlagService::new(Arc::new(store) as Arc<_>);
2032        self.state_initializer(move |state| {
2033            state.insert_extension(service);
2034        })
2035    }
2036
2037    /// Register a feature-flag store with a group-membership resolver.
2038    ///
2039    /// The resolver is called during flag evaluation to check whether an actor
2040    /// belongs to a named group listed in a flag's `group_allowlist`. Without
2041    /// registering a resolver, group gates are silently ignored.
2042    ///
2043    /// # Example
2044    ///
2045    /// ```rust,ignore
2046    /// use autumn_web::feature_flags::{InMemoryFlagStore, GroupResolver};
2047    /// use std::sync::Arc;
2048    ///
2049    /// let resolver: GroupResolver = Arc::new(|actor_id, group| {
2050    ///     group == "staff" && actor_id.starts_with("staff:")
2051    /// });
2052    ///
2053    /// autumn_web::app()
2054    ///     .with_flag_store_and_resolver(InMemoryFlagStore::new(), resolver)
2055    ///     .run()
2056    ///     .await;
2057    /// ```
2058    #[must_use]
2059    pub fn with_flag_store_and_resolver<S>(
2060        self,
2061        store: S,
2062        resolver: crate::feature_flags::GroupResolver,
2063    ) -> Self
2064    where
2065        S: crate::feature_flags::FlagStore,
2066    {
2067        let service = crate::feature_flags::FeatureFlagService::new(Arc::new(store) as Arc<_>)
2068            .with_group_resolver(resolver);
2069        self.state_initializer(move |state| {
2070            state.insert_extension(service);
2071        })
2072    }
2073
2074    /// Register an experiment store, enabling the [`Experiments`] extractor.
2075    ///
2076    /// Wrap any [`ExperimentStore`] implementation. Use [`InMemoryExperimentStore`]
2077    /// for development and tests; use
2078    /// [`pg::PgExperimentStore`](crate::experiments::pg::PgExperimentStore)
2079    /// for production against the `autumn_experiments` tables.
2080    ///
2081    /// # Production example (Postgres-backed)
2082    ///
2083    /// ```rust,ignore
2084    /// use std::sync::Arc;
2085    /// use std::time::Duration;
2086    /// use autumn_web::experiments::pg::PgExperimentStore;
2087    ///
2088    /// let store = Arc::new(PgExperimentStore::new(&config.database.primary_url));
2089    /// PgExperimentStore::spawn_poll_listener(Arc::clone(&store), Duration::from_secs(5));
2090    /// autumn_web::app()
2091    ///     .with_experiment_store(Arc::clone(&store))
2092    ///     .run()
2093    ///     .await;
2094    /// ```
2095    ///
2096    /// # Development / test example
2097    ///
2098    /// ```rust,ignore
2099    /// use autumn_web::experiments::InMemoryExperimentStore;
2100    ///
2101    /// autumn_web::app()
2102    ///     .with_experiment_store(InMemoryExperimentStore::new())
2103    ///     .run()
2104    ///     .await;
2105    /// ```
2106    ///
2107    /// [`Experiments`]: crate::experiments::Experiments
2108    /// [`ExperimentStore`]: crate::experiments::ExperimentStore
2109    /// [`InMemoryExperimentStore`]: crate::experiments::InMemoryExperimentStore
2110    #[must_use]
2111    pub fn with_experiment_store<S>(self, store: S) -> Self
2112    where
2113        S: crate::experiments::ExperimentStore,
2114    {
2115        let service = crate::experiments::ExperimentService::new(Arc::new(store) as Arc<_>);
2116        self.state_initializer(move |state| {
2117            state.insert_extension(service);
2118        })
2119    }
2120
2121    /// Register an experiment store with a custom [`ExposureSink`].
2122    ///
2123    /// Use when you want to forward exposure events to an analytics pipeline
2124    /// rather than the default `tracing` log.
2125    ///
2126    /// # Example
2127    ///
2128    /// ```rust,ignore
2129    /// use autumn_web::experiments::{InMemoryExperimentStore, NoOpExposureSink};
2130    /// use std::sync::Arc;
2131    ///
2132    /// autumn_web::app()
2133    ///     .with_experiment_store_and_sink(
2134    ///         InMemoryExperimentStore::new(),
2135    ///         Arc::new(NoOpExposureSink),
2136    ///     )
2137    ///     .run()
2138    ///     .await;
2139    /// ```
2140    ///
2141    /// [`ExposureSink`]: crate::experiments::ExposureSink
2142    #[must_use]
2143    pub fn with_experiment_store_and_sink<S>(
2144        self,
2145        store: S,
2146        sink: Arc<dyn crate::experiments::ExposureSink>,
2147    ) -> Self
2148    where
2149        S: crate::experiments::ExperimentStore,
2150    {
2151        let service = crate::experiments::ExperimentService::new(Arc::new(store) as Arc<_>)
2152            .with_exposure_sink(sink);
2153        self.state_initializer(move |state| {
2154            state.insert_extension(service);
2155        })
2156    }
2157
2158    /// Register a durable [`MailDeliveryQueue`](crate::mail::MailDeliveryQueue) for
2159    /// [`Mailer::deliver_later`](crate::mail::Mailer::deliver_later).
2160    ///
2161    /// Must be called before [`run`](Self::run). Plugins call this inside their
2162    /// `apply` implementation to satisfy the production delivery guard without
2163    /// requiring `mail.allow_in_process_deliver_later_in_production`.
2164    ///
2165    /// Use [`Self::with_mail_delivery_queue_factory`] when the queue needs
2166    /// framework-managed resources (the DB pool, channels, etc.) that only
2167    /// exist after the [`AppState`] is constructed.
2168    #[cfg(feature = "mail")]
2169    #[must_use]
2170    pub fn with_mail_delivery_queue(
2171        mut self,
2172        queue: impl crate::mail::MailDeliveryQueue + 'static,
2173    ) -> Self {
2174        let arc: Arc<dyn crate::mail::MailDeliveryQueue> = Arc::new(queue);
2175        self.mail_delivery_queue_factory = Some(Box::new(move |_state| Ok(arc)));
2176        self
2177    }
2178
2179    /// Register a factory that builds the durable
2180    /// [`MailDeliveryQueue`](crate::mail::MailDeliveryQueue) from the
2181    /// fully-built [`AppState`].
2182    ///
2183    /// Use this when the queue captures framework-managed resources — for
2184    /// example a DB-outbox queue that needs the connection pool returned by
2185    /// [`AppState::pool`]. The factory runs once, immediately before
2186    /// `install_mailer`, with the live `AppState`. Returning `Err` aborts
2187    /// startup with the propagated error.
2188    #[cfg(feature = "mail")]
2189    #[must_use]
2190    pub fn with_mail_delivery_queue_factory<F, Q>(mut self, factory: F) -> Self
2191    where
2192        F: FnOnce(&AppState) -> crate::AutumnResult<Q> + Send + 'static,
2193        Q: crate::mail::MailDeliveryQueue + 'static,
2194    {
2195        self.mail_delivery_queue_factory = Some(Box::new(move |state| {
2196            factory(state).map(|q| Arc::new(q) as Arc<dyn crate::mail::MailDeliveryQueue>)
2197        }));
2198        self
2199    }
2200
2201    /// Register a [`SuppressionStore`](crate::mail::SuppressionStore) used by
2202    /// List-Unsubscribe sends to skip opted-out recipients and by the default
2203    /// unsubscribe endpoint to record opt-outs.
2204    ///
2205    /// When the `db` feature is enabled and a connection pool is configured, a
2206    /// Diesel-backed store is auto-wired, so most apps never call this — use it
2207    /// to plug a custom backend. Mirrors
2208    /// [`Self::with_mail_delivery_queue`].
2209    #[cfg(feature = "mail")]
2210    #[must_use]
2211    pub fn with_suppression_store(
2212        mut self,
2213        store: impl crate::mail::SuppressionStore + 'static,
2214    ) -> Self {
2215        self.suppression_store = Some(crate::mail::SuppressionStoreHandle::new(store));
2216        self
2217    }
2218
2219    /// Register a bounce/complaint
2220    /// [`SuppressionStore`](crate::mail::suppression::SuppressionStore) so
2221    /// [`Mailer::send`](crate::mail::Mailer::send) skips addresses that have
2222    /// hard-bounced or complained (issue #1247).
2223    ///
2224    /// Zero-config apps need not call this: the framework wires an in-memory
2225    /// default store automatically. Use this to plug the durable
2226    /// [`PgSuppressionStore`](crate::mail::suppression::PgSuppressionStore) (or
2227    /// a custom backend) for multi-instance deploys that must share suppression
2228    /// across replicas. Mirrors [`Self::with_suppression_store`].
2229    #[cfg(feature = "mail")]
2230    #[must_use]
2231    pub fn with_mail_suppression_store(
2232        mut self,
2233        store: impl crate::mail::suppression::SuppressionStore + 'static,
2234    ) -> Self {
2235        self.mail_suppression_store =
2236            Some(crate::mail::suppression::SuppressionStoreHandle::new(store));
2237        self
2238    }
2239
2240    /// Mount the framework's default RFC 8058 one-click unsubscribe endpoint at
2241    /// `/_autumn/unsubscribe` (`GET` confirmation page + `POST` one-click).
2242    ///
2243    /// Opt-in: a plain JSON API never gets an HTML endpoint it didn't ask for.
2244    /// Requires `mail.unsubscribe_base_url` to be configured. When mounted, the
2245    /// path is automatically exempted from CSRF and CAPTCHA (mailbox-provider
2246    /// POSTs carry neither token). To serve a custom unsubscribe page instead,
2247    /// skip this and register your own route at the path.
2248    #[cfg(feature = "mail")]
2249    #[must_use]
2250    pub const fn mount_unsubscribe_endpoint(mut self) -> Self {
2251        self.mount_unsubscribe_endpoint = true;
2252        self
2253    }
2254
2255    /// Register an inbound mail router that creates webhook HTTP endpoints and
2256    /// dispatches parsed [`InboundEmail`](crate::inbound_mail::InboundEmail)
2257    /// values to registered handlers.
2258    ///
2259    /// Calling this method twice replaces the previously registered router.
2260    ///
2261    /// # Example
2262    ///
2263    /// ```rust,ignore
2264    /// use autumn_web::inbound_mail::{
2265    ///     InboundMailRouter, InboundMailEndpointConfig,
2266    ///     InboundMailHandlerInfo, ProcessingMode, RecipientPattern,
2267    /// };
2268    ///
2269    /// autumn_web::app()
2270    ///     .inbound_mail_router(
2271    ///         InboundMailRouter::new()
2272    ///             .endpoint(InboundMailEndpointConfig::mailgun("/inbound/mailgun", "key"))
2273    ///             .handler(InboundMailHandlerInfo {
2274    ///                 name: "support",
2275    ///                 pattern: RecipientPattern::Exact("support@company.com".to_string()),
2276    ///                 processing: ProcessingMode::Background,
2277    ///                 handler: handle_support,
2278    ///             })
2279    ///     )
2280    ///     .routes(routes![...])
2281    ///     .run()
2282    ///     .await;
2283    /// ```
2284    #[cfg(feature = "inbound-mail")]
2285    #[must_use]
2286    pub fn inbound_mail_router(mut self, router: crate::inbound_mail::InboundMailRouter) -> Self {
2287        self.inbound_mail_router = Some(Arc::new(router));
2288        self
2289    }
2290
2291    /// Register mail template previews for the dev mail preview UI.
2292    ///
2293    /// Pair this with `#[mailer_preview]` and `mail_previews![...]`.
2294    #[cfg(feature = "mail")]
2295    #[must_use]
2296    pub fn mail_previews(
2297        mut self,
2298        previews: impl IntoIterator<Item = crate::mail::MailPreview>,
2299    ) -> Self {
2300        self.mail_previews.extend(previews);
2301        self
2302    }
2303
2304    /// Register the widget story gallery served at `/_stories` (#1526).
2305    ///
2306    /// Routes mount only when the resolved config has `[stories] enabled =
2307    /// true` (off by default, opt-in per profile). Start from
2308    /// [`StoryGallery::builtin`](crate::stories::StoryGallery::builtin) for
2309    /// the framework widget set and
2310    /// [`extend`](crate::stories::StoryGallery::extend) it with your app's
2311    /// own `story!{...}` entries. See `docs/guide/stories.md`.
2312    #[cfg(feature = "maud")]
2313    #[must_use]
2314    pub fn with_story_gallery(mut self, gallery: crate::stories::StoryGallery) -> Self {
2315        self.story_gallery = Some(gallery);
2316        self
2317    }
2318
2319    /// Register an additional audit sink for structured audit events.
2320    ///
2321    /// Multiple calls accumulate sinks. Logged events are fanned out to all
2322    /// configured sinks.
2323    #[must_use]
2324    pub fn with_audit_sink<S>(mut self, sink: S) -> Self
2325    where
2326        S: crate::audit::AuditSink,
2327    {
2328        let logger = self
2329            .audit_logger
2330            .take()
2331            .map_or_else(crate::audit::AuditLogger::new, |logger| (*logger).clone())
2332            .with_sink(Arc::new(sink));
2333        self.audit_logger = Some(Arc::new(logger));
2334        self
2335    }
2336
2337    /// Register a [`Policy`](crate::authorization::Policy)
2338    /// implementation for resource type `R`.
2339    ///
2340    /// Multiple policies per resource are not supported: registering
2341    /// `R` twice causes a startup-time panic with a clear error
2342    /// message.
2343    ///
2344    /// # Examples
2345    ///
2346    /// ```rust,ignore
2347    /// use autumn_web::authorization::{Policy, PolicyContext};
2348    ///
2349    /// #[derive(Default)]
2350    /// struct PostPolicy;
2351    /// impl Policy<Post> for PostPolicy { /* ... */ }
2352    ///
2353    /// autumn_web::app()
2354    ///     .routes(routes![...])
2355    ///     .policy::<Post, _>(PostPolicy)
2356    ///     .run()
2357    ///     .await;
2358    /// ```
2359    #[must_use]
2360    pub fn policy<R, P>(mut self, policy: P) -> Self
2361    where
2362        R: Send + Sync + 'static,
2363        P: crate::authorization::Policy<R>,
2364    {
2365        self.policy_registrations.push(Box::new(move |registry| {
2366            registry.register_policy::<R, _>(policy);
2367        }));
2368        self
2369    }
2370
2371    /// Register a [`Scope`](crate::authorization::Scope) implementation
2372    /// for resource type `R`. The scope filters list endpoints
2373    /// (`GET /<api>` for `#[repository(api = "...", scope = ...)]`)
2374    /// to records the current user is allowed to read.
2375    ///
2376    /// Default impls return an empty list so a missing scope opt-in
2377    /// fails closed.
2378    #[must_use]
2379    pub fn scope<R, S>(mut self, scope: S) -> Self
2380    where
2381        R: Send + Sync + 'static,
2382        S: crate::authorization::Scope<R>,
2383    {
2384        self.policy_registrations.push(Box::new(move |registry| {
2385            registry.register_scope::<R, _>(scope);
2386        }));
2387        self
2388    }
2389
2390    /// Apply a [`Plugin`](crate::plugin::Plugin) to the builder.
2391    ///
2392    /// The plugin's [`build`](crate::plugin::Plugin::build) runs exactly once
2393    /// per [`AppBuilder`]. Registering two plugins that share a
2394    /// [`name`](crate::plugin::Plugin::name) is a no-op after the first: the
2395    /// duplicate emits a `tracing::warn!` and the builder is returned
2396    /// unchanged.
2397    #[must_use]
2398    #[track_caller]
2399    pub fn plugin<P>(mut self, plugin: P) -> Self
2400    where
2401        P: crate::plugin::Plugin,
2402    {
2403        let name = plugin.name();
2404        if self.registered_plugins.contains(name.as_ref()) {
2405            tracing::warn!(
2406                plugin = name.as_ref(),
2407                "plugin already registered; skipping duplicate"
2408            );
2409            return self;
2410        }
2411        let name_str = name.into_owned();
2412        self.registered_plugins.insert(name_str.clone());
2413        // Save outer plugin context so nested plugin() calls don't permanently
2414        // clear it; restore it after this plugin's build() returns.
2415        let outer_plugin = self.current_plugin.replace(name_str);
2416        let mut result = plugin.build(self);
2417        result.current_plugin = outer_plugin;
2418        result
2419    }
2420
2421    /// Apply a [`Plugins`](crate::plugin::Plugins) bundle (a plugin or tuple
2422    /// of plugins) to the builder, in declaration order.
2423    #[must_use]
2424    pub fn plugins<P>(self, plugins: P) -> Self
2425    where
2426        P: crate::plugin::Plugins,
2427    {
2428        plugins.apply(self)
2429    }
2430
2431    /// Return `true` if a plugin with the given [`Plugin::name`](crate::plugin::Plugin::name)
2432    /// has already been applied to this builder.
2433    #[must_use]
2434    pub fn has_plugin(&self, name: &str) -> bool {
2435        self.registered_plugins.contains(name)
2436    }
2437
2438    /// Declare a plugin-owned top-level config section so it coexists with
2439    /// `server.strict_config = true`.
2440    ///
2441    /// Core's [`AutumnConfig`](crate::config::AutumnConfig) schema is closed: any
2442    /// top-level `[root]` table it does not know about is an unknown key. Under
2443    /// `strict_config`, an unknown root is a **hard** boot error. A plugin that
2444    /// reads its own top-level table — for example `autumn-media-plugin` reading
2445    /// `[media]` via raw TOML — would therefore make a `strict_config` app fail
2446    /// at boot with `unknown key "media"`.
2447    ///
2448    /// Calling `config_section("media")` registers `[media]` as a **known,
2449    /// opaque** section: the strict unknown-key check accepts the root and does
2450    /// **not** validate its contents (the plugin owns that — core has no schema
2451    /// for it). The seam is **fail-closed**: only the roots a plugin explicitly
2452    /// declares are exempt; every other unknown top-level root still hard-fails,
2453    /// so a typo like `[medai]` is still caught.
2454    ///
2455    /// Call this from your [`Plugin::build`](crate::plugin::Plugin::build)
2456    /// implementation, where the plugin is applied to the builder:
2457    ///
2458    /// ```ignore
2459    /// impl Plugin for MediaPlugin {
2460    ///     fn build(self, app: AppBuilder) -> AppBuilder {
2461    ///         app.config_section("media") // `[media]` is now strict-config-safe
2462    ///         // … register routes, jobs, startup hooks, …
2463    ///     }
2464    /// }
2465    /// ```
2466    ///
2467    /// The declared roots are threaded into the default config loader
2468    /// ([`TomlEnvConfigLoader`](crate::config::TomlEnvConfigLoader)); a fully
2469    /// custom loader installed via
2470    /// [`with_config_loader`](AppBuilder::with_config_loader) owns its own
2471    /// strict-config handling and is unaffected.
2472    ///
2473    /// Future: an optional eager per-section validation hook (handing each
2474    /// plugin its raw `[root]` table at boot to validate uniformly) could be
2475    /// layered on top of this registry later. It is deliberately deferred — the
2476    /// media plugin already fail-fast-validates its own `[media]` config in its
2477    /// startup hook, and an eager hook adds callback-storage and error-surface
2478    /// plumbing this declarative seam does not need.
2479    #[must_use]
2480    pub fn config_section(mut self, root: impl Into<String>) -> Self {
2481        self.plugin_config_roots.insert(root.into());
2482        self
2483    }
2484
2485    /// Return `true` if the given top-level config root has been declared as a
2486    /// plugin config section via [`config_section`](AppBuilder::config_section).
2487    ///
2488    /// Mirrors [`has_plugin`](AppBuilder::has_plugin); useful for tests and
2489    /// builder introspection.
2490    #[must_use]
2491    pub fn has_config_section(&self, root: &str) -> bool {
2492        self.plugin_config_roots.contains(root)
2493    }
2494
2495    /// Register a named [`MetricsSource`](crate::actuator::MetricsSource) that contributes
2496    /// metric families to `/actuator/prometheus` and `/actuator/metrics`.
2497    ///
2498    /// The `name` is a stable identifier used for:
2499    /// - Duplicate-registration detection (same behaviour as duplicate plugins: a
2500    ///   `tracing::warn!` is emitted and the second registration is skipped).
2501    /// - The `source` label in the `autumn_metrics_source_errors_total` counter
2502    ///   that increments when a source panics during a scrape.
2503    ///
2504    /// `Plugin::build` implementations can call this to wire a source with no
2505    /// extra application-level glue code.
2506    ///
2507    /// # Examples
2508    ///
2509    /// ```rust,no_run
2510    /// use autumn_web::actuator::{MetricsSource, MetricFamily, MetricKind, MetricSample};
2511    /// use autumn_web::app::AppBuilder;
2512    /// use std::sync::Arc;
2513    ///
2514    /// struct QueueMetrics;
2515    ///
2516    /// impl MetricsSource for QueueMetrics {
2517    ///     fn collect(&self) -> Vec<MetricFamily> {
2518    ///         vec![MetricFamily {
2519    ///             name: "myapp_queue_depth".to_string(),
2520    ///             help: "Current queue depth".to_string(),
2521    ///             kind: MetricKind::Gauge,
2522    ///             samples: vec![MetricSample { labels: vec![], value: 42.0 }],
2523    ///         }]
2524    ///     }
2525    /// }
2526    ///
2527    /// autumn_web::app()
2528    ///     .metrics_source("myapp_queue", Arc::new(QueueMetrics));
2529    /// ```
2530    #[must_use]
2531    pub fn metrics_source(
2532        mut self,
2533        name: impl Into<String>,
2534        source: Arc<dyn crate::actuator::MetricsSource>,
2535    ) -> Self {
2536        let name = name.into();
2537        if self.metrics_sources.iter().any(|(n, _)| n == &name) {
2538            tracing::warn!(
2539                source_name = %name,
2540                "MetricsSource '{}' is already registered; skipping duplicate",
2541                name
2542            );
2543            return self;
2544        }
2545        self.metrics_sources.push((name, source));
2546        self
2547    }
2548
2549    /// Register a custom [`HealthIndicator`](crate::actuator::HealthIndicator) with the application.
2550    ///
2551    /// The indicator's [`check`](crate::actuator::HealthIndicator::check) method is called on every
2552    /// `/actuator/health` request (and on `/ready` for `Readiness`-group indicators).
2553    ///
2554    /// Duplicate registration names are silently ignored (a warning is logged).
2555    ///
2556    /// # Examples
2557    ///
2558    /// ```rust,no_run
2559    /// use std::sync::Arc;
2560    /// use autumn_web::actuator::{HealthCheckOutput, HealthIndicator};
2561    ///
2562    /// struct StripeIndicator;
2563    /// impl HealthIndicator for StripeIndicator {
2564    ///     fn check(&self) -> futures::future::BoxFuture<'_, HealthCheckOutput> {
2565    ///         Box::pin(async move { HealthCheckOutput::up() })
2566    ///     }
2567    /// }
2568    ///
2569    /// autumn_web::app()
2570    ///     .health_indicator("stripe", Arc::new(StripeIndicator));
2571    /// ```
2572    #[must_use]
2573    pub fn health_indicator(
2574        mut self,
2575        name: impl Into<String>,
2576        indicator: Arc<dyn crate::actuator::HealthIndicator>,
2577    ) -> Self {
2578        let name = name.into();
2579        // "db" is a reserved built-in component name. Allowing a custom indicator
2580        // under this name would produce an inconsistent response: the custom result
2581        // would still gate the aggregate status while the built-in pool check owns
2582        // the components.db / checks.database display. The "db:shard:" prefix is
2583        // reserved for the framework's per-shard indicators for the same reason.
2584        #[cfg(feature = "db")]
2585        if name == "db" || name.starts_with("db:shard:") {
2586            tracing::warn!(
2587                indicator_name = %name,
2588                "\"db\" and \"db:shard:*\" are reserved built-in health indicator names; \
2589                 registration skipped. Use a different name for your custom indicator."
2590            );
2591            return self;
2592        }
2593        if self.health_indicators.iter().any(|(n, _, _)| n == &name) {
2594            tracing::warn!(
2595                indicator_name = %name,
2596                "HealthIndicator '{}' is already registered; skipping duplicate",
2597                name
2598            );
2599            return self;
2600        }
2601        let group = indicator.group();
2602        self.health_indicators.push((name, group, indicator));
2603        self
2604    }
2605
2606    /// Register embedded Diesel migrations with the application.
2607    ///
2608    /// When migrations are registered:
2609    /// - They always target the primary/write database role
2610    ///   (`database.primary_url`, falling back to legacy `database.url`).
2611    /// - In **dev** mode, pending migrations run automatically on startup.
2612    /// - In **prod** mode, pending migrations are logged as warnings but
2613    ///   not applied -- use a one-shot `autumn migrate` job before rolling web
2614    ///   replicas.
2615    ///
2616    /// # Examples
2617    ///
2618    /// ```rust,ignore
2619    /// use autumn_web::migrate::{EmbeddedMigrations, embed_migrations};
2620    ///
2621    /// const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
2622    ///
2623    /// #[autumn_web::main]
2624    /// async fn main() {
2625    ///     autumn_web::app()
2626    ///         .routes(routes![...])
2627    ///         .migrations(MIGRATIONS)
2628    ///         .run()
2629    ///         .await;
2630    /// }
2631    /// ```
2632    #[cfg(feature = "db")]
2633    #[must_use]
2634    pub fn migrations(mut self, migrations: migrate::EmbeddedMigrations) -> Self {
2635        self.migrations.push(migrations);
2636        self
2637    }
2638
2639    /// Embed the app's `static/` tree into the binary for single-binary deploys.
2640    ///
2641    /// Pass the directory produced by [`embed_static!`](crate::embed_static)
2642    /// (requires the `embed-assets` feature). When set, `/static/*` is served
2643    /// from the binary and `asset_url()` resolves against the embedded
2644    /// fingerprint manifest — copying only the release binary into an empty
2645    /// directory serves every referenced asset with no `static/` sidecar.
2646    /// Because the manifest and the files are baked from the same build,
2647    /// fingerprint-vs-manifest drift is impossible.
2648    ///
2649    /// This is a release-time concern: leave it unset in development so CSS/JS
2650    /// hot-reload keeps serving from disk.
2651    ///
2652    /// ```rust,ignore
2653    /// static STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();
2654    ///
2655    /// #[autumn_web::main]
2656    /// async fn main() {
2657    ///     autumn_web::app().embedded_static(&STATIC).run().await;
2658    /// }
2659    /// ```
2660    #[cfg(feature = "embed-assets")]
2661    #[must_use]
2662    pub const fn embedded_static(mut self, dir: &'static include_dir::Dir<'static>) -> Self {
2663        self.embedded_static = Some(crate::assets::EmbeddedStaticDir(dir));
2664        self
2665    }
2666
2667    /// Embed the app's i18n locale bundles into the binary.
2668    ///
2669    /// Pass the directory produced by [`embed_locales!`](crate::embed_locales)
2670    /// (requires the `embed-assets` and `i18n` features). When set (and no
2671    /// explicit [`i18n`](AppBuilder::i18n) bundle was provided), all configured
2672    /// locales render from the binary with no `i18n/` sidecar directory.
2673    ///
2674    /// ```rust,ignore
2675    /// static LOCALES: autumn_web::include_dir::Dir = autumn_web::embed_locales!();
2676    ///
2677    /// #[autumn_web::main]
2678    /// async fn main() {
2679    ///     autumn_web::app().embedded_locales(&LOCALES).run().await;
2680    /// }
2681    /// ```
2682    #[cfg(all(feature = "embed-assets", feature = "i18n"))]
2683    #[must_use]
2684    pub const fn embedded_locales(mut self, dir: &'static include_dir::Dir<'static>) -> Self {
2685        self.embedded_locales = Some(dir);
2686        self
2687    }
2688
2689    /// Start the HTTP server.
2690    ///
2691    /// This method performs the full application lifecycle:
2692    ///
2693    /// 1. Loads configuration from `autumn.toml` (or defaults).
2694    /// 2. Initializes the tracing subscriber.
2695    /// 3. Validates that at least one route is registered.
2696    /// 4. Creates the database connection pool (if configured).
2697    /// 5. Builds the Axum router from collected routes.
2698    /// 6. Mounts built-in routes (health check, htmx JS, static files).
2699    /// 7. Binds to the configured address and port.
2700    /// 8. Serves requests with graceful shutdown on Ctrl+C (or `SIGTERM`
2701    ///    on Unix).
2702    ///
2703    /// # Panics
2704    ///
2705    /// Panics if no routes have been registered via [`.routes()`](Self::routes).
2706    /// This is intentional -- an application with no routes is always a
2707    /// developer error.
2708    #[allow(clippy::too_many_lines)]
2709    #[allow(clippy::cognitive_complexity)]
2710    pub async fn run(self) {
2711        // ── Build mode ─────────────────────────────────────────────────
2712        // When AUTUMN_BUILD_STATIC=1, render static routes to dist/ and exit
2713        // instead of starting the HTTP server. This is triggered by `autumn build`.
2714        if is_static_build_mode() {
2715            self.run_build_mode().await;
2716            return;
2717        }
2718
2719        // ── Route dump mode ────────────────────────────────────────────
2720        // When AUTUMN_DUMP_ROUTES=1, print the route listing JSON and exit.
2721        // This is triggered by `autumn routes` to introspect the app's
2722        // route table without booting the server or connecting to a database.
2723        if is_dump_routes_mode() {
2724            self.run_dump_routes_mode().await;
2725            return;
2726        }
2727
2728        // ── Jobs manifest dump mode ────────────────────────────────────
2729        // When AUTUMN_DUMP_JOBS=1, print the effective drained-queue manifest
2730        // (TOML `queues = [...]`) and exit. Triggered by `autumn jobs manifest`
2731        // so a topology-aware `autumn doctor` sees exactly what the runtime
2732        // drains without booting the server or connecting to a database.
2733        if is_dump_jobs_mode() {
2734            self.run_dump_jobs_mode().await;
2735            return;
2736        }
2737
2738        if is_list_one_off_tasks_mode() {
2739            self.run_list_one_off_tasks_mode();
2740            return;
2741        }
2742
2743        if let Some(task_name) = one_off_task_name_from_env() {
2744            self.run_one_off_task_mode(task_name).await;
2745            return;
2746        }
2747
2748        // ── Migrate one-shot mode ──────────────────────────────────────
2749        // When AUTUMN_MIGRATE=1, apply pending embedded migrations to the
2750        // configured database(s) and EXIT — never start the HTTP server or bind
2751        // a port. Triggered by `autumn deploy`'s redeploy cutover, which runs
2752        // migrations BEFORE flipping traffic (issue #1607): a non-zero exit here
2753        // aborts the deploy with the old release still serving (AC-3). Unlike the
2754        // startup auto-migration path it applies regardless of profile, because
2755        // the deploy invokes it explicitly.
2756        if is_migrate_only_mode() {
2757            self.run_migrate_only_mode().await;
2758            return;
2759        }
2760
2761        let Self {
2762            routes,
2763            api_versions,
2764            route_sources: _,
2765            current_plugin: _,
2766            tasks,
2767            one_off_tasks: _,
2768            mut jobs,
2769            listeners,
2770            static_metas,
2771            exception_filters,
2772            scoped_groups,
2773            merge_routers,
2774            nest_routers,
2775            custom_layers,
2776            static_gate_layers,
2777            startup_hooks,
2778            state_initializers,
2779            shutdown_hooks,
2780            extensions: _,
2781            registered_plugins: _,
2782            plugin_config_roots,
2783            #[cfg(feature = "maud")]
2784            error_page_renderer,
2785            #[cfg(feature = "db")]
2786            migrations,
2787            config_loader_factory,
2788            #[cfg(feature = "db")]
2789            pool_provider_factory,
2790            #[cfg(feature = "db")]
2791            shard_provider_factory,
2792            #[cfg(feature = "db")]
2793            shard_router,
2794            #[cfg(feature = "db")]
2795            directory_shard_router,
2796            telemetry_provider,
2797            session_store,
2798            #[cfg(feature = "ws")]
2799            channels_backend,
2800            #[cfg(feature = "storage")]
2801            blob_store,
2802            cache_backend,
2803            #[cfg(feature = "reporting")]
2804            error_reporters,
2805            alert_channels,
2806            #[cfg(feature = "openapi")]
2807            openapi,
2808            #[cfg(feature = "mcp")]
2809            mcp,
2810            audit_logger,
2811            #[cfg(feature = "i18n")]
2812            i18n_bundle,
2813            #[cfg(feature = "i18n")]
2814            i18n_auto_load,
2815            #[cfg(feature = "embed-assets")]
2816            embedded_static,
2817            #[cfg(all(feature = "embed-assets", feature = "i18n"))]
2818            embedded_locales,
2819            policy_registrations,
2820            #[cfg(feature = "mail")]
2821            mail_delivery_queue_factory,
2822            #[cfg(feature = "mail")]
2823            suppression_store,
2824            #[cfg(feature = "mail")]
2825            mail_suppression_store,
2826            #[cfg(feature = "mail")]
2827            mount_unsubscribe_endpoint,
2828            #[cfg(feature = "mail")]
2829            mail_previews,
2830            #[cfg(feature = "maud")]
2831            story_gallery,
2832            declared_routes: _,
2833            idempotency_enabled,
2834            #[cfg(feature = "mail")]
2835            mail_interceptor,
2836            job_interceptor,
2837            #[cfg(feature = "db")]
2838            db_interceptor,
2839            #[cfg(feature = "ws")]
2840            channels_interceptor,
2841            #[cfg(feature = "oauth2")]
2842            http_interceptor,
2843            seo_sources,
2844            metrics_sources,
2845            health_indicators,
2846            #[cfg(feature = "inbound-mail")]
2847            inbound_mail_router,
2848        } = self;
2849
2850        let all_routes = routes;
2851
2852        // 1 & 2. Load configuration and initialize logging/telemetry
2853        let (mut config, telemetry_guard) = load_config_and_telemetry(
2854            config_loader_factory,
2855            telemetry_provider,
2856            plugin_config_roots,
2857        )
2858        .await;
2859
2860        // Process role selects which slice of the runtime this replica runs. A
2861        // split role (web/worker) requires a durable jobs backend the separate
2862        // HTTP and worker processes can share. Any backend that isn't a
2863        // recognized durable one (`postgres`/`redis`) — the in-process `local`
2864        // queue, a typo, or a blank value — falls through to the per-process
2865        // local runtime: the web replica would enqueue into an in-memory queue no
2866        // worker can drain, and a worker replica's queue starts empty. Reject it
2867        // here — before any boot work — rather than in `validate()` so the doctor
2868        // can still load the config. Combined role is always fine.
2869        let role = config.role;
2870        if crate::config::split_role_requires_durable_backend(role, &config.jobs.backend) {
2871            tracing::error!(
2872                role = role.as_str(),
2873                jobs_backend = %config.jobs.backend,
2874                "process role '{}' requires a durable jobs backend: backend '{}' is not \
2875                 a recognized durable backend and falls through to the in-process 'local' \
2876                 runtime, which cannot be shared across a split web/worker topology. \
2877                 Set jobs.backend = \"postgres\" or \"redis\", or run the combined role.",
2878                role.as_str(),
2879                config.jobs.backend,
2880            );
2881            #[cfg(feature = "managed-pg")]
2882            crate::managed_pg::emergency_stop_async().await;
2883            std::process::exit(1);
2884        }
2885
2886        #[cfg(feature = "mail")]
2887        if mount_unsubscribe_endpoint {
2888            config.mail.mount_unsubscribe_endpoint = true;
2889        }
2890
2891        // Apply builder-level flag: `.idempotent()` enables the middleware when
2892        // neither `autumn.toml` nor the environment explicitly disable it.
2893        // The env var `AUTUMN_IDEMPOTENCY__ENABLED` is re-checked here so
2894        // operators can disable idempotency at runtime (e.g. during a Redis
2895        // incident) without code changes, even when `.idempotent()` is called.
2896        if idempotency_enabled {
2897            let env_disabled = std::env::var("AUTUMN_IDEMPOTENCY__ENABLED")
2898                .is_ok_and(|v| matches!(v.to_lowercase().as_str(), "false" | "0" | "no" | "off"));
2899            // Only apply the builder default when neither the env var nor the
2900            // loaded config file explicitly sets enabled = false.
2901            if !env_disabled && config.idempotency.enabled != Some(false) {
2902                config.idempotency.enabled = Some(true);
2903            }
2904        }
2905
2906        // Register the embedded `static/` tree (if any) before the router is
2907        // built so `/static/*` serves from the binary and `asset_url()` resolves
2908        // against the embedded manifest, then prefer embedded locales over disk
2909        // auto-loading when no explicit bundle was provided.
2910        #[cfg(feature = "embed-assets")]
2911        register_embedded_static_dir(embedded_static);
2912
2913        #[cfg(all(feature = "embed-assets", feature = "i18n"))]
2914        let i18n_bundle = embedded_i18n_bundle(i18n_bundle, embedded_locales, &config);
2915
2916        #[cfg(feature = "i18n")]
2917        let i18n_bundle =
2918            resolve_i18n_bundle(i18n_bundle, i18n_auto_load, &config, &crate::config::OsEnv);
2919
2920        // 3. Validate routes
2921        assert!(
2922            !all_routes.is_empty(),
2923            "No routes registered. Did you forget to call .routes()?"
2924        );
2925
2926        // 4. Log banner with profile info
2927        let profile_display = config.profile.as_deref().unwrap_or("none");
2928        tracing::info!(
2929            version = env!("CARGO_PKG_VERSION"),
2930            profile = profile_display,
2931            "Autumn starting"
2932        );
2933
2934        // 4b. Startup transparency log (AUTUMN_SHOW_CONFIG=1 or log level <= DEBUG)
2935        let show_config = std::env::var("AUTUMN_SHOW_CONFIG").as_deref() == Ok("1");
2936        if show_config {
2937            log_startup_transparency(&all_routes, &tasks, &scoped_groups, &config);
2938        }
2939
2940        // 4c. Fail-fast on invalid session config — but only when no custom
2941        // SessionStore was installed via with_session_store(...). Done before
2942        // setup_database so a doomed boot doesn't run migrations first.
2943        fail_fast_on_invalid_session_config(&config, session_store.is_some());
2944
2945        // 4d. Validate signing secret — production must have a stable, private,
2946        // entropy-meeting secret before the server binds. Dev/test are exempt.
2947        fail_fast_on_invalid_signing_secret(&config);
2948        fail_fast_on_missing_encryption_keys(&config);
2949        fail_fast_on_invalid_trusted_hosts(&config);
2950
2951        // 4e. Signed webhook configs must resolve to usable key material
2952        // before the app binds. Missing secrets should fail before a real
2953        // provider retry loop starts hammering a broken endpoint.
2954        fail_fast_on_invalid_webhook_config(&config);
2955
2956        // 4f. Idempotency backend must be production-ready when enabled.
2957        fail_fast_on_invalid_idempotency_config(&config);
2958
2959        // 4f. Provision the configured BlobStore *before* `setup_database`.
2960        // `LocalBlobStore::new` does real IO (creates + canonicalizes the
2961        // root) and the storage code may `process::exit(1)` on failure
2962        // (unwritable root, or `storage.backend = "s3"` with no plugin).
2963        // Doing it before migrations means a doomed boot can't mutate
2964        // the DB schema first.
2965        // A custom store installed via `.with_blob_store(...)` bypasses
2966        // config-driven instantiation entirely (no IO, no fail-fast).
2967        #[cfg(feature = "storage")]
2968        let storage_bootstrap = blob_store.map_or_else(
2969            || preflight_storage(&config),
2970            |store| {
2971                Some(StorageBootstrap {
2972                    store,
2973                    serving: None,
2974                })
2975            },
2976        );
2977
2978        // 5. Create database pool and run migrations (if configured)
2979        #[cfg(feature = "db")]
2980        let database = setup_database(
2981            &config,
2982            migrations,
2983            pool_provider_factory,
2984            shard_provider_factory,
2985            shard_router,
2986            directory_shard_router,
2987            RepositoryCommitHookQueueMigrationMode::Runtime,
2988        )
2989        .await
2990        .unwrap_or_else(|e| {
2991            tracing::error!("{e}");
2992            std::process::exit(1);
2993        });
2994        #[cfg(feature = "db")]
2995        let pool = database.topology;
2996        #[cfg(feature = "db")]
2997        let shards = database.shards;
2998        #[cfg(feature = "db")]
2999        let replica_readiness = database.replica_readiness;
3000        #[cfg(feature = "db")]
3001        let replica_migration_check = database.replica_migration_check;
3002
3003        #[cfg(feature = "db")]
3004        if pool.is_some() || shards.is_some() {
3005            // Pool sizes multiply across shards: surface the total so
3006            // N-shard deployments notice the aggregate connection count.
3007            let shard_max_connections = shards
3008                .as_ref()
3009                .map_or(0, crate::sharding::ShardSet::total_max_connections);
3010            let control_max_connections = pool.as_ref().map_or(0, |topology| {
3011                topology.primary().status().max_size
3012                    + topology.replica().map_or(0, |p| p.status().max_size)
3013            });
3014            let total_max_connections = control_max_connections + shard_max_connections;
3015            tracing::info!(
3016                primary_max_connections = config.database.effective_primary_pool_size(),
3017                replica_configured = config.database.replica_url.is_some(),
3018                replica_max_connections = config.database.effective_replica_pool_size(),
3019                shard_count = shards.as_ref().map_or(0, crate::sharding::ShardSet::len),
3020                total_max_connections,
3021                "Database topology configured"
3022            );
3023            // Pool sizes multiply across shards; warn before the aggregate
3024            // silently exhausts Postgres's server-side `max_connections`.
3025            let warn_threshold = config.database.max_connections_warn_threshold;
3026            if crate::config::should_warn_total_connections(total_max_connections, warn_threshold) {
3027                tracing::warn!(
3028                    total_max_connections,
3029                    warn_threshold,
3030                    "Aggregate database connection count is high: the control \
3031                     topology and all shard pools together may open \
3032                     {total_max_connections} connections (warn threshold \
3033                     {warn_threshold}). Ensure each Postgres server's \
3034                     max_connections (plus headroom for migrations and \
3035                     psql) exceeds the pools that target it, or lower \
3036                     database.pool_size. Set \
3037                     database.max_connections_warn_threshold = 0 to silence."
3038                );
3039            }
3040        } else {
3041            tracing::info!("Database not configured");
3042        }
3043
3044        // 5b. Fail-fast on `#[repository(api = ...)]` endpoints that
3045        // were mounted without a paired `policy = ...` argument when
3046        // running in `prod` profile and the explicit escape hatch is
3047        // off. Hides exactly the footgun called out in the issue:
3048        // "a developer who flips the `api =` switch on a
3049        // `#[repository]` exposes mutate endpoints that any
3050        // authenticated user can call against any record."
3051        validate_repository_api_policies(&all_routes, &scoped_groups, &config);
3052
3053        // 6. Build the router (with optional static-file layer)
3054        let mut state = build_state(
3055            &config,
3056            #[cfg(feature = "db")]
3057            pool.as_ref(),
3058            #[cfg(feature = "db")]
3059            shards,
3060            #[cfg(feature = "ws")]
3061            channels_backend,
3062        );
3063
3064        // Wire the in-memory log capture buffer from the telemetry guard into the
3065        // app state so the `/actuator/logfile` endpoint can serve it.
3066        if let Some(buf) = telemetry_guard.log_buffer.clone() {
3067            state.insert_extension(buf);
3068        }
3069        // Wire the live-subscriber reload handle into the loggers actuator so
3070        // `PUT /actuator/loggers/{name}` affects the running subscriber, not
3071        // just an in-memory map (issue #1044).
3072        if let Some(handle) = telemetry_guard.filter_reload.clone() {
3073            state.log_levels().attach_reload_handle(handle);
3074        }
3075
3076        // Instantiate MaintenanceState, load flag synchronously at startup, insert as extension, and start background poller task
3077        let maintenance_state = crate::maintenance::MaintenanceState::new();
3078        let flag_path = std::path::Path::new(crate::maintenance::MAINTENANCE_FLAG_FILE);
3079        if let Ok(Some(cfg)) = crate::maintenance::MaintenanceState::load_from_file(flag_path) {
3080            maintenance_state.enable(cfg);
3081        }
3082        state.insert_extension(maintenance_state.clone());
3083
3084        let poller_state = maintenance_state.clone();
3085        tokio::spawn(async move {
3086            let path = std::path::Path::new(crate::maintenance::MAINTENANCE_FLAG_FILE);
3087            let interval = std::time::Duration::from_millis(500);
3088            loop {
3089                let load_res = tokio::task::spawn_blocking(move || {
3090                    crate::maintenance::MaintenanceState::load_from_file(path)
3091                })
3092                .await;
3093
3094                match load_res {
3095                    Ok(Ok(Some(cfg))) => {
3096                        if poller_state.get() != Some(cfg.clone()) {
3097                            poller_state.enable(cfg);
3098                        }
3099                    }
3100                    Ok(Ok(None)) => {
3101                        if poller_state.is_active() {
3102                            poller_state.disable();
3103                        }
3104                    }
3105                    Ok(Err(e)) => {
3106                        tracing::error!(error = %e, "failed to load maintenance flag file");
3107                    }
3108                    Err(e) => {
3109                        tracing::error!(error = %e, "maintenance poller task panicked");
3110                    }
3111                }
3112                tokio::time::sleep(interval).await;
3113            }
3114        });
3115
3116        // Resolve the canary deploy-version label (AUTUMN_DEPLOY_VERSION /
3117        // AUTUMN_CANARY) once at startup and publish it so the actuator metrics
3118        // endpoint can tag every metric family with version="stable|canary".
3119        let canary_state = crate::canary::CanaryState::from_env();
3120        if canary_state.is_canary() {
3121            tracing::info!(
3122                version = canary_state.version(),
3123                "canary: replica labelled as canary cohort"
3124            );
3125        }
3126        state.insert_extension(canary_state);
3127
3128        // A rollback flag present at startup means a controller already retired
3129        // this replica. Flip /ready to draining immediately so a supervisor
3130        // restart cannot put a rolled-back replica back into the canary cohort;
3131        // `canary_rollback_signal` then drives the clean drain → exit.
3132        if crate::canary::CanaryState::rollback_flag_present(std::path::Path::new(
3133            crate::canary::CANARY_ROLLBACK_FLAG_FILE,
3134        )) {
3135            tracing::warn!(
3136                "canary: rollback flag present at startup; /ready will report draining until \
3137                 the flag is cleared (`autumn canary promote`)"
3138            );
3139            state.begin_shutdown();
3140        }
3141
3142        #[cfg(feature = "mail")]
3143        if let Some(interceptor) = mail_interceptor {
3144            state.insert_extension(interceptor);
3145        }
3146        if let Some(interceptor) = job_interceptor {
3147            state.insert_extension(interceptor);
3148        }
3149        #[cfg(feature = "db")]
3150        if let Some(interceptor) = db_interceptor {
3151            state.insert_extension(interceptor);
3152        }
3153        #[cfg(feature = "ws")]
3154        if let Some(interceptor) = channels_interceptor {
3155            state.insert_extension(interceptor.clone());
3156            state.channels = crate::channels::Channels::with_shared_backend(std::sync::Arc::new(
3157                crate::channels::InterceptedChannelsBackend::new(
3158                    state.channels.backend().clone(),
3159                    vec![interceptor],
3160                ),
3161            ));
3162            #[cfg(feature = "presence")]
3163            {
3164                state.presence = crate::presence::Presence::new(state.channels.clone());
3165            }
3166        }
3167        #[cfg(feature = "oauth2")]
3168        if let Some(interceptor) = http_interceptor {
3169            state.insert_extension(interceptor);
3170        }
3171
3172        // Populate the metrics source registry from builder registrations.
3173        // Duplicate names were already rejected in `metrics_source()`, so
3174        // all entries here are unique.
3175        for (name, source) in metrics_sources {
3176            if let Err(e) = state.metrics_source_registry.register(name, source) {
3177                tracing::warn!("{e}");
3178            }
3179        }
3180
3181        // Populate the health indicator registry from builder registrations.
3182        for (name, group, indicator) in health_indicators {
3183            if let Err(e) = state
3184                .health_indicator_registry
3185                .register(name, group, indicator)
3186            {
3187                tracing::warn!("{e}");
3188            }
3189        }
3190
3191        // When ACME is configured, register a `HealthOnly` indicator backed by a
3192        // shared status the renewal task writes. Built here (before the router)
3193        // so it is baked into `/actuator/health`; the same `AcmeStatus` handle is
3194        // reused by the renewal task spawned at bind time below.
3195        #[cfg(feature = "acme")]
3196        let acme_status: Option<crate::acme::renewal::AcmeStatus> = if let Some(acme_cfg) =
3197            config.server.tls.as_ref().and_then(|t| t.acme.as_ref())
3198        {
3199            let status = crate::acme::renewal::AcmeStatus::new();
3200            let indicator = std::sync::Arc::new(crate::acme::renewal::AcmeHealthIndicator::new(
3201                status.clone(),
3202                acme_cfg.renew_before_days,
3203            ));
3204            if let Err(e) = state.health_indicator_registry.register(
3205                "acme",
3206                crate::actuator::IndicatorGroup::HealthOnly,
3207                indicator,
3208            ) {
3209                tracing::warn!("{e}");
3210            }
3211            Some(status)
3212        } else {
3213            None
3214        };
3215
3216        #[cfg(feature = "db")]
3217        configure_replica_migration_check(&state, replica_migration_check);
3218        #[cfg(feature = "db")]
3219        apply_replica_migration_readiness(&state, replica_readiness);
3220        if let Some(cache) = cache_backend {
3221            crate::cache::set_global_cache(cache.clone());
3222            state.shared_cache = Some(cache);
3223        } else {
3224            crate::cache::clear_global_cache();
3225        }
3226        state.insert_extension(RegisteredApiVersions(api_versions));
3227
3228        // Capture a clone of the registered reporter chain for the ACME renewal
3229        // task (spawned below) so each renewal failure reaches the same
3230        // Sentry/etc. sinks a request-path 5xx would. Empty is fine — failures
3231        // still log via `tracing` inside the loop.
3232        #[cfg(all(feature = "acme", feature = "reporting"))]
3233        let acme_reporters = error_reporters.clone();
3234
3235        // Install registered error reporters so the reporting layer (wired in
3236        // `apply_middleware`) can deliver panic + 5xx events. Empty is fine —
3237        // the layer falls back to the built-in `LogReporter`.
3238        #[cfg(feature = "reporting")]
3239        if !error_reporters.is_empty() {
3240            state.insert_extension(crate::reporting::RegisteredReporters(error_reporters));
3241        }
3242        // Apply deferred policy / scope registrations onto the live
3243        // app state. Done before the router is built so any panic
3244        // from double-registration surfaces during startup, not
3245        // mid-request.
3246        for register in policy_registrations {
3247            register(state.policy_registry());
3248        }
3249        // Now that registrations have been applied, verify that
3250        // every `#[repository(policy = X)]`-annotated route has
3251        // an X actually registered on the live registry. Catches
3252        // the "wired the macro arg, forgot the `.policy(...)`
3253        // builder call" footgun before any 500 lands.
3254        validate_repository_policies_registered(&all_routes, &scoped_groups, &state, &config);
3255        #[cfg(feature = "mail")]
3256        if let Some(handle) = suppression_store {
3257            state.insert_extension(handle);
3258        }
3259        #[cfg(feature = "mail")]
3260        if let Some(handle) = mail_suppression_store {
3261            state.insert_extension(handle);
3262        }
3263        #[cfg(feature = "mail")]
3264        crate::mail::install_mailer_with_factory(
3265            &state,
3266            &config.mail,
3267            mail_delivery_queue_factory,
3268            true,
3269        )
3270        .unwrap_or_else(|error| {
3271            tracing::error!(error = %error, "Failed to configure mailer");
3272            exit_stop_managed_pg();
3273            std::process::exit(1);
3274        });
3275        #[cfg(feature = "mail")]
3276        state.insert_extension(crate::mail::MailPreviewRegistry::new(mail_previews));
3277        #[cfg(feature = "maud")]
3278        install_story_registry(&state, story_gallery);
3279        // Operator alerts: build the built-in mail/webhook channels from
3280        // `[alerts]` config, combine with any builder-registered channels, and
3281        // start the background evaluation loop. No-op when nothing is
3282        // configured. Installed after the mailer so the mail channel can bind
3283        // to the live `Mailer` extension.
3284        crate::alerts::install_from_config(&state, &config.alerts, alert_channels);
3285        if let Some(logger) = audit_logger {
3286            state.insert_extension::<crate::audit::AuditLogger>((*logger).clone());
3287        }
3288        #[cfg(feature = "i18n")]
3289        let custom_layers = install_i18n_bundle_layer(custom_layers, &state, i18n_bundle);
3290
3291        // Install the preflighted blob store on the freshly-built
3292        // AppState, and remember the serving router so it gets merged
3293        // into the user's router below.
3294        #[cfg(feature = "storage")]
3295        let storage_router = storage_bootstrap.and_then(|b| b.install(&state));
3296        install_webhook_registry(&state, &config);
3297        run_state_initializers(state_initializers, &state);
3298        finalize_event_bus(listeners, &mut jobs, &state);
3299
3300        let env = crate::config::OsEnv;
3301        let dist_dir = project_dir("dist", &env);
3302        let dist_ref = if dist_dir.exists() {
3303            Some(dist_dir.as_path())
3304        } else {
3305            None
3306        };
3307        #[cfg_attr(
3308            not(any(feature = "storage", feature = "inbound-mail")),
3309            allow(unused_mut)
3310        )]
3311        let mut merge_routers = merge_routers;
3312        #[cfg(feature = "storage")]
3313        if let Some(router) = storage_router {
3314            merge_routers.push(router);
3315        }
3316
3317        // Register SEO routes (/robots.txt and /sitemap.xml) when any SEO
3318        // configuration is present or dynamic sources are registered.
3319        if !seo_sources.is_empty() || crate::seo::has_seo_config(&config.seo) {
3320            let seo_cfg = &config.seo;
3321            let raw_profile = config.profile.as_deref().unwrap_or("dev");
3322            let profile = crate::seo::effective_seo_profile(raw_profile, seo_cfg.robots.allow_all);
3323            let static_paths: Vec<&str> = static_metas.iter().map(|m| m.path).collect();
3324            let (robots_body, sitemap_body) = crate::seo::assemble_seo_bodies(
3325                profile,
3326                seo_cfg.base_url.as_deref(),
3327                seo_cfg.robots.sitemap_url.as_deref(),
3328                &seo_cfg.robots.additional_rules,
3329                &seo_sources,
3330                &static_paths,
3331            )
3332            .await;
3333            let seo_router = crate::seo::build_seo_router_from_bodies(robots_body, sitemap_body);
3334            let is_seo_path = |p: &str| p == "/robots.txt" || p == "/sitemap.xml";
3335            let seo_collision = all_routes.iter().any(|r| is_seo_path(r.path))
3336                || static_metas.iter().any(|m| is_seo_path(m.path))
3337                || scoped_groups.iter().any(|g| {
3338                    let prefix = g.prefix.trim_end_matches('/');
3339                    g.routes
3340                        .iter()
3341                        .any(|r| is_seo_path(&format!("{prefix}{}", r.path)))
3342                });
3343            if seo_collision {
3344                tracing::warn!(
3345                    "seo: /robots.txt or /sitemap.xml is already registered by the application; \
3346                     skipping automatic SEO routes to prevent a startup panic"
3347                );
3348            } else {
3349                merge_routers.push(seo_router);
3350            }
3351        }
3352
3353        #[cfg(feature = "inbound-mail")]
3354        if let Some(ref im_router) = inbound_mail_router {
3355            let mut registered_inbound: std::collections::HashSet<String> =
3356                std::collections::HashSet::new();
3357            for (path, axum_router) in crate::inbound_mail::build_routes(im_router) {
3358                // Preflight collision check: if an annotated POST route already
3359                // claims this path, merging an opaque router at the same path
3360                // would cause Axum to panic at startup.  Warn and skip instead
3361                // so the application can still start and the conflict is visible.
3362                if all_routes
3363                    .iter()
3364                    .any(|r| r.method == http::Method::POST && r.path == path)
3365                    || scoped_groups.iter().any(|g| {
3366                        g.routes.iter().any(|r| {
3367                            r.method == http::Method::POST
3368                                && crate::router::join_nested_path(&g.prefix, r.path)
3369                                    == path.as_str()
3370                        })
3371                    })
3372                    || nest_routers.iter().any(|(nest_path, _)| {
3373                        let p = nest_path.as_str();
3374                        path.as_str() == p
3375                            || path.starts_with(p)
3376                                && (p.ends_with('/') || path.as_bytes().get(p.len()) == Some(&b'/'))
3377                    })
3378                {
3379                    tracing::warn!(
3380                        path = %path,
3381                        "inbound_mail: skipping webhook route — a POST handler is \
3382                         already registered at this path by the application"
3383                    );
3384                    continue;
3385                }
3386                // Also guard against two inbound endpoints sharing the same path,
3387                // which would cause the same Axum merge panic.
3388                if !registered_inbound.insert(path.clone()) {
3389                    tracing::warn!(
3390                        path = %path,
3391                        "inbound_mail: skipping duplicate inbound webhook path"
3392                    );
3393                    continue;
3394                }
3395                // Exempt each inbound webhook path from both CSRF and CAPTCHA:
3396                // these routes receive provider-signed POST requests that never
3397                // carry a CSRF or CAPTCHA token.
3398                config.security.csrf.exempt_paths.push(path.clone());
3399                config.security.captcha_exempt_paths.push(path);
3400                merge_routers.push(axum_router);
3401            }
3402        }
3403        // Worker role does not serve user routes: build a probe-only router that
3404        // exposes just the framework liveness/readiness probes and the actuator,
3405        // so orchestrators can supervise the process and `/actuator/jobs` works.
3406        // Web and combined roles build the full application router. All the
3407        // route/router-context inputs assembled above are simply dropped in the
3408        // worker branch.
3409        let router_build = if role.serves_http() {
3410            crate::router::try_build_router_with_static_inner(
3411                all_routes,
3412                &config,
3413                state.clone(),
3414                dist_ref,
3415                crate::router::RouterContext {
3416                    exception_filters,
3417                    scoped_groups,
3418                    merge_routers,
3419                    nest_routers,
3420                    custom_layers,
3421                    static_gate_layers,
3422                    #[cfg(feature = "maud")]
3423                    error_page_renderer,
3424                    session_store,
3425                    // Respect the [openapi] profile gate: if disabled in config,
3426                    // suppress the endpoint even when .openapi(...) was called.
3427                    #[cfg(feature = "openapi")]
3428                    openapi: if config.openapi_runtime.enabled {
3429                        openapi
3430                    } else {
3431                        None
3432                    },
3433                    #[cfg(feature = "mcp")]
3434                    mcp,
3435                },
3436            )
3437        } else {
3438            crate::router::try_build_probe_only_router(&config, state.clone())
3439        };
3440        let router = router_build.unwrap_or_else(|error| {
3441            tracing::error!(error = %error, "Failed to build router");
3442            exit_stop_managed_pg();
3443            std::process::exit(1);
3444        });
3445
3446        // 7. Bind and initialize pre-serve runtime dependencies. Once those
3447        // are ready, start listening before startup hooks finish so `/startup`
3448        // can honestly report startup progress.
3449        // Bind the configured transport. A `server.unix_socket` path selects a
3450        // Unix domain socket (local daemon mode); otherwise bind TCP on
3451        // `host:port` as before. `bound_desc` is the human/log description and
3452        // `unix_socket_cleanup` is the socket to unlink on clean exit (axum does
3453        // not remove it for us), as `(path, dev, inode)` so cleanup can confirm
3454        // the file is still the one *this* process bound before removing it.
3455        // Validate `[server.tls]` wiring before we bind anything, so a
3456        // misconfiguration is a clear pre-bind failure. Two cases fail fast:
3457        // (1) the section is present but this binary was built without the
3458        // `tls` feature — otherwise it would be silently ignored and the app
3459        // would serve plain HTTP on a port operators expect to be HTTPS;
3460        // (2) TLS is combined with a Unix socket, which the direct-HTTPS path
3461        // does not serve over (TLS terminates on `host:port`).
3462        if let Some(tls_cfg) = config.server.tls.as_ref() {
3463            // Reject an incoherent `[server.tls]` (both static + ACME, neither,
3464            // a half-set cert pair, an empty/wildcard ACME domain set, …) before
3465            // binding, with a named message. Pure config validation, so it runs
3466            // regardless of build features.
3467            if let Err(msg) = tls_cfg.validate() {
3468                tracing::error!("Invalid [server.tls] configuration: {msg}");
3469                #[cfg(feature = "managed-pg")]
3470                crate::managed_pg::emergency_stop_async().await;
3471                std::process::exit(1);
3472            }
3473            #[cfg(not(feature = "tls"))]
3474            {
3475                tracing::error!(
3476                    "[server.tls] is configured but this binary was built without the `tls` \
3477                     feature; rebuild with `--features tls`, or remove [server.tls] to serve \
3478                     plain HTTP"
3479                );
3480                #[cfg(feature = "managed-pg")]
3481                crate::managed_pg::emergency_stop_async().await;
3482                std::process::exit(1);
3483            }
3484            // `[server.tls.acme]` needs the `acme` feature; otherwise it would be
3485            // silently ignored and the app would serve a self-signed placeholder
3486            // (or fail) on a port operators expect to serve a real ACME cert.
3487            #[cfg(all(feature = "tls", not(feature = "acme")))]
3488            if tls_cfg.acme.is_some() {
3489                tracing::error!(
3490                    "[server.tls.acme] is configured but this binary was built without the \
3491                     `acme` feature; rebuild with `--features acme`, or configure a static \
3492                     cert_path/key_path instead"
3493                );
3494                #[cfg(feature = "managed-pg")]
3495                crate::managed_pg::emergency_stop_async().await;
3496                std::process::exit(1);
3497            }
3498            #[cfg(feature = "tls")]
3499            if config.server.unix_socket.is_some() {
3500                tracing::error!(
3501                    "[server.tls] cannot be combined with server.unix_socket; direct TLS \
3502                     terminates on host:port. Unset one of them"
3503                );
3504                #[cfg(feature = "managed-pg")]
3505                crate::managed_pg::emergency_stop_async().await;
3506                std::process::exit(1);
3507            }
3508        }
3509
3510        // Root shutdown token for all background tasks. Created before the bind
3511        // block so the TLS listener's background acceptor task can take a child
3512        // token and stop cleanly on shutdown (issue #1603).
3513        let server_shutdown = tokio_util::sync::CancellationToken::new();
3514
3515        // Carries the cert/key reload wiring from the TLS bind path to the
3516        // background reload task spawned once `server_shutdown` exists.
3517        #[cfg(feature = "tls")]
3518        let mut tls_reload_state: Option<TlsReloadState> = None;
3519
3520        // Carries the ACME challenge listener + renewal task wiring from the TLS
3521        // bind path to the sibling tasks spawned once `server_shutdown` exists.
3522        #[cfg(feature = "acme")]
3523        let mut acme_bind_state: Option<AcmeBindState> = None;
3524
3525        let (bound_listener, bound_desc, unix_socket_cleanup): (
3526            BoundListener,
3527            String,
3528            Option<(std::path::PathBuf, u64, u64)>,
3529        ) = if let Some(socket_path) = config.server.unix_socket.as_deref() {
3530            let _ = socket_path;
3531            #[cfg(unix)]
3532            {
3533                use std::os::unix::fs::PermissionsExt;
3534
3535                let path = std::path::Path::new(socket_path);
3536                if let Err(e) = prepare_unix_socket_path(path) {
3537                    tracing::error!(socket = %socket_path, "Failed to prepare unix socket: {e}");
3538                    // `setup_database` already started the managed Postgres child;
3539                    // `process::exit` skips `on_shutdown`, so stop it first.
3540                    #[cfg(feature = "managed-pg")]
3541                    crate::managed_pg::emergency_stop_async().await;
3542                    std::process::exit(1);
3543                }
3544                // Bind under an owner-only umask so the socket is created `0600`
3545                // from the start — a plain bind would briefly leave it
3546                // group/other-connectable (umask-dependent), and `chmod` afterward
3547                // does not revoke a connection already established in that window.
3548                // This matters for a user-configured `server.unix_socket` in a
3549                // shared dir; the CLI's own socket also sits in a `0700` parent.
3550                // `umask` is process-wide, so serialize the save/bind/restore: a
3551                // concurrent UDS bind in the same process (integration tests, or an
3552                // app running several servers) could otherwise interleave these
3553                // pairs and either bind under the wrong umask — reopening the
3554                // bind→chmod window this closes — or leave `0177` set permanently.
3555                // The guard is released before the `.await` in the error arm below.
3556                let bind_result = {
3557                    static UMASK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3558                    let _umask_guard = UMASK_LOCK
3559                        .lock()
3560                        .unwrap_or_else(std::sync::PoisonError::into_inner);
3561                    let prev_umask =
3562                        nix::sys::stat::umask(nix::sys::stat::Mode::from_bits_truncate(0o177));
3563                    let result = tokio::net::UnixListener::bind(path);
3564                    nix::sys::stat::umask(prev_umask);
3565                    result
3566                };
3567                let listener = match bind_result {
3568                    Ok(listener) => listener,
3569                    Err(e) => {
3570                        tracing::error!(socket = %socket_path, "Failed to bind unix socket: {e}");
3571                        #[cfg(feature = "managed-pg")]
3572                        crate::managed_pg::emergency_stop_async().await;
3573                        std::process::exit(1);
3574                    }
3575                };
3576                // Owner-only access, belt-and-suspenders after the umask bind.
3577                // Fail *closed* — if we cannot enforce `0600` (chmod error, an ACL
3578                // /filesystem that rejects it), refuse to serve rather than expose
3579                // a reachable control socket. Remove the socket we just bound so
3580                // nothing keeps listening on it.
3581                if let Err(e) =
3582                    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
3583                {
3584                    tracing::error!(socket = %socket_path, "Failed to enforce owner-only permissions on unix socket: {e}");
3585                    let _ = std::fs::remove_file(path);
3586                    #[cfg(feature = "managed-pg")]
3587                    crate::managed_pg::emergency_stop_async().await;
3588                    std::process::exit(1);
3589                }
3590                // Capture the bound socket's identity so a later successor that
3591                // rebinds the same path isn't unlinked by our shutdown.
3592                let (dev, ino) = {
3593                    use std::os::unix::fs::MetadataExt;
3594                    std::fs::metadata(path).map_or((0, 0), |m| (m.dev(), m.ino()))
3595                };
3596                (
3597                    BoundListener::Unix(listener),
3598                    format!("unix:{socket_path}"),
3599                    Some((path.to_path_buf(), dev, ino)),
3600                )
3601            }
3602            #[cfg(not(unix))]
3603            {
3604                tracing::error!(
3605                    "server.unix_socket is only supported on Unix platforms; \
3606                     unset it or use server.host/server.port"
3607                );
3608                std::process::exit(1);
3609            }
3610        } else {
3611            let addr = format!("{}:{}", config.server.host, config.server.port);
3612            let listener = match tokio::net::TcpListener::bind(&addr).await {
3613                Ok(listener) => listener,
3614                Err(e) => {
3615                    tracing::error!(addr = %addr, "Failed to bind: {e}");
3616                    // Stop the managed Postgres child started by `setup_database`
3617                    // before bailing; `process::exit` skips `on_shutdown`.
3618                    #[cfg(feature = "managed-pg")]
3619                    crate::managed_pg::emergency_stop_async().await;
3620                    std::process::exit(1);
3621                }
3622            };
3623            // When `[server.tls]` is set (and the `tls` feature is built in),
3624            // wrap the just-bound TCP listener in a rustls acceptor so the same
3625            // host:port serves HTTPS. Fail fast on any cert/key problem — the
3626            // pre-bind guard already rejected a Unix-socket combination and a
3627            // feature-less build, so reaching here with `tls = Some` means the
3628            // feature is on.
3629            #[cfg(feature = "tls")]
3630            {
3631                if let Some(tls_cfg) = config.server.tls.as_ref() {
3632                    // ACME mode: build the resolver from a stored cert if present,
3633                    // else a self-signed placeholder so `:443` binds immediately;
3634                    // the renewal task swaps the real cert in once issued.
3635                    #[cfg(feature = "acme")]
3636                    if let Some(acme_cfg) = tls_cfg.acme.as_ref() {
3637                        let https_port = config.server.port;
3638                        match build_acme_tls_listener(
3639                            listener,
3640                            tls_cfg,
3641                            acme_cfg,
3642                            https_port,
3643                            acme_status.clone(),
3644                            server_shutdown.child_token(),
3645                        )
3646                        .await
3647                        {
3648                            Ok((tls_listener, bind_state)) => {
3649                                acme_bind_state = Some(bind_state);
3650                                (
3651                                    BoundListener::Tls(tls_listener),
3652                                    format!("https://{addr} (ACME)"),
3653                                    None,
3654                                )
3655                            }
3656                            Err(e) => {
3657                                tracing::error!(error = %e, "Failed to configure [server.tls.acme]");
3658                                #[cfg(feature = "managed-pg")]
3659                                crate::managed_pg::emergency_stop_async().await;
3660                                std::process::exit(1);
3661                            }
3662                        }
3663                    } else {
3664                        match build_tls_listener(listener, tls_cfg, server_shutdown.child_token()) {
3665                            Ok((tls_listener, reload)) => {
3666                                tls_reload_state = Some(reload);
3667                                (
3668                                    BoundListener::Tls(tls_listener),
3669                                    format!("https://{addr}"),
3670                                    None,
3671                                )
3672                            }
3673                            Err(e) => {
3674                                tracing::error!(error = %e, "Failed to configure [server.tls]");
3675                                #[cfg(feature = "managed-pg")]
3676                                crate::managed_pg::emergency_stop_async().await;
3677                                std::process::exit(1);
3678                            }
3679                        }
3680                    }
3681                    #[cfg(not(feature = "acme"))]
3682                    match build_tls_listener(listener, tls_cfg, server_shutdown.child_token()) {
3683                        Ok((tls_listener, reload)) => {
3684                            tls_reload_state = Some(reload);
3685                            (
3686                                BoundListener::Tls(tls_listener),
3687                                format!("https://{addr}"),
3688                                None,
3689                            )
3690                        }
3691                        Err(e) => {
3692                            tracing::error!(error = %e, "Failed to configure [server.tls]");
3693                            #[cfg(feature = "managed-pg")]
3694                            crate::managed_pg::emergency_stop_async().await;
3695                            std::process::exit(1);
3696                        }
3697                    }
3698                } else {
3699                    (BoundListener::Tcp(listener), addr, None)
3700                }
3701            }
3702            #[cfg(not(feature = "tls"))]
3703            {
3704                (BoundListener::Tcp(listener), addr, None)
3705            }
3706        };
3707
3708        let shutdown_timeout = config.server.shutdown_timeout_secs;
3709        let prestop_grace = config.server.prestop_grace_secs;
3710
3711        if let Err(error) = initialize_job_runtime(
3712            jobs,
3713            &state,
3714            &server_shutdown,
3715            &config.jobs,
3716            role.runs_workers(),
3717        ) {
3718            tracing::error!(error = %error, "job runtime initialization failed");
3719            // Post-DB failure: `process::exit` skips `on_shutdown`, so stop any
3720            // managed Postgres before bailing.
3721            #[cfg(feature = "managed-pg")]
3722            crate::managed_pg::emergency_stop_async().await;
3723            std::process::exit(1);
3724        }
3725
3726        #[cfg(feature = "db")]
3727        {
3728            #[cfg(feature = "ws")]
3729            crate::repository_commit_hooks::set_global_channels(state.channels().clone());
3730        }
3731
3732        // Draining durable after-commit hook rows is background execution, so gate
3733        // it on the process role exactly like the `#[job]` runtime above: a `web`
3734        // replica must not claim/execute hook rows (that work belongs to the
3735        // worker tier), while `worker`/`combined` replicas keep running it.
3736        // The durable commit-hook worker drains rows via a Postgres queue
3737        // (LISTEN/NOTIFY + row-locked claiming); under the `sqlite` feature the
3738        // runtime pool is a SQLite pool the Postgres worker cannot drive, so
3739        // the worker is not spawned. (SQLite single-node boot does not run the
3740        // durable-hook worker tier.)
3741        #[cfg(all(feature = "db", not(feature = "sqlite")))]
3742        if role.runs_workers()
3743            && let Some(pool) = state.pool().cloned()
3744        {
3745            #[cfg(feature = "ws")]
3746            {
3747                let channels = state.channels().clone();
3748                crate::repository_commit_hooks::start_repository_commit_hook_worker(
3749                    pool,
3750                    Some(channels),
3751                    server_shutdown.child_token(),
3752                );
3753            }
3754            #[cfg(not(feature = "ws"))]
3755            crate::repository_commit_hooks::start_repository_commit_hook_worker(
3756                pool,
3757                server_shutdown.child_token(),
3758            );
3759        }
3760        // Repositories built over a shard pool (`with_pool`) enqueue durable
3761        // commit hooks into that shard's queue table; drain each one too — again
3762        // only on a role that runs workers, so a web replica leaves shard hook
3763        // rows for the worker tier.
3764        #[cfg(all(feature = "db", not(feature = "sqlite")))]
3765        if role.runs_workers()
3766            && let Some(shards) = state.shards()
3767        {
3768            for shard in shards.iter() {
3769                #[cfg(feature = "ws")]
3770                crate::repository_commit_hooks::start_repository_commit_hook_worker(
3771                    shard.primary_pool().clone(),
3772                    Some(state.channels().clone()),
3773                    server_shutdown.child_token(),
3774                );
3775                #[cfg(not(feature = "ws"))]
3776                crate::repository_commit_hooks::start_repository_commit_hook_worker(
3777                    shard.primary_pool().clone(),
3778                    server_shutdown.child_token(),
3779                );
3780            }
3781        }
3782        // SQLite durable commit-hook worker (#1996 item 5): the runtime pool is a
3783        // single-node SQLite pool the Postgres queue worker cannot drive, so the
3784        // SQLite worker (BEGIN IMMEDIATE claim + in-process Notify kick + poll
3785        // fallback) is spawned instead — same `role.runs_workers()` gate and same
3786        // `server_shutdown.child_token()` graceful-drain wiring as the Postgres tier.
3787        #[cfg(all(feature = "db", feature = "sqlite"))]
3788        if role.runs_workers()
3789            && let Some(pool) = state.pool().cloned()
3790        {
3791            #[cfg(feature = "ws")]
3792            {
3793                let channels = state.channels().clone();
3794                crate::repository_commit_hooks::start_repository_commit_hook_worker(
3795                    pool,
3796                    Some(channels),
3797                    server_shutdown.child_token(),
3798                );
3799            }
3800            #[cfg(not(feature = "ws"))]
3801            crate::repository_commit_hooks::start_repository_commit_hook_worker(
3802                pool,
3803                server_shutdown.child_token(),
3804            );
3805        }
3806
3807        #[cfg(feature = "presence")]
3808        {
3809            let presence = state.presence().clone();
3810            let sweep_shutdown = server_shutdown.child_token();
3811            tokio::spawn(async move {
3812                let interval = std::time::Duration::from_secs(15);
3813                loop {
3814                    tokio::select! {
3815                        () = tokio::time::sleep(interval) => {
3816                            presence.sweep_expired();
3817                        }
3818                        () = sweep_shutdown.cancelled() => break,
3819                    }
3820                }
3821            });
3822        }
3823
3824        // TLS certificate hot-reload: poll the cert/key file mtimes on an
3825        // interval and swap the served certificate in place on change (e.g.
3826        // after a `certbot`/ACME renewal), so a renewal is picked up WITHOUT a
3827        // restart. A child of `server_shutdown`, exactly like the presence
3828        // sweep above, so it stops cleanly on shutdown. A failed reload logs an
3829        // error and keeps serving the previously loaded certificate — a bad
3830        // renewal never breaks the listener.
3831        #[cfg(feature = "tls")]
3832        if let Some(reload) = tls_reload_state.take() {
3833            let reload_shutdown = server_shutdown.child_token();
3834            tokio::spawn(async move {
3835                run_tls_cert_reload(reload, reload_shutdown).await;
3836            });
3837        }
3838
3839        // ACME (issue #1608): bind the `:80` HTTP-01 challenge + HTTP→HTTPS
3840        // redirect listener and spawn the renewal loop, each a child of
3841        // `server_shutdown` so they tear down with the main server. The renewal
3842        // loop runs on every replica (a pure `web` replica must renew its own
3843        // cert) and leader-elects through the scheduler coordinator so only one
3844        // replica orders per certificate.
3845        #[cfg(feature = "acme")]
3846        if let Some(bind_state) = acme_bind_state.take() {
3847            let AcmeBindState {
3848                mut renewal_task,
3849                tokens,
3850                http_challenge_port,
3851                https_port,
3852            } = bind_state;
3853
3854            // The `:80` challenge/redirect listener, bound DUAL-STACK so the CA
3855            // can validate HTTP-01 over both IPv4 and IPv6 (an AAAA-only host is
3856            // otherwise unreachable on `:80`). Preferred: one `[::]` socket with
3857            // IPV6_V6ONLY=false; on a platform that refuses it, a separate
3858            // IPv4 + IPv6 listener pair (each served below). Fail-fast on a bind
3859            // error: `:80` needs privilege (CAP_NET_BIND_SERVICE) and ACME
3860            // validation cannot succeed without it.
3861            let challenge_listeners =
3862                match crate::acme::challenge::bind_challenge_listeners(http_challenge_port).await {
3863                    Ok(listeners) => listeners,
3864                    Err(e) => {
3865                        tracing::error!(
3866                            port = http_challenge_port,
3867                            "Failed to bind the ACME HTTP-01 challenge listener: {e}. Port \
3868                             {http_challenge_port} typically needs privilege (grant \
3869                             CAP_NET_BIND_SERVICE), or set [server.tls.acme] http_challenge_port \
3870                             to a port a front-end forwards :80 to"
3871                        );
3872                        #[cfg(feature = "managed-pg")]
3873                        crate::managed_pg::emergency_stop_async().await;
3874                        std::process::exit(1);
3875                    }
3876                };
3877            let challenge_router = crate::acme::challenge::challenge_router(tokens, https_port);
3878            // Serve every bound listener (one for dual-stack, two for the split
3879            // fallback), each a child of `server_shutdown` so they tear down with
3880            // the main server. The router is cheap to clone (shared Arc state).
3881            for challenge_listener in challenge_listeners {
3882                let router = challenge_router.clone();
3883                let challenge_shutdown = server_shutdown.child_token();
3884                tokio::spawn(async move {
3885                    if let Err(e) = axum::serve(challenge_listener, router)
3886                        .with_graceful_shutdown(async move {
3887                            challenge_shutdown.cancelled().await;
3888                        })
3889                        .await
3890                    {
3891                        tracing::error!(
3892                            error = %e,
3893                            "ACME challenge listener stopped with an error"
3894                        );
3895                    }
3896                });
3897            }
3898
3899            // Build the coordinator for leader election (regardless of role) and
3900            // the reporter callback, then spawn the renewal loop.
3901            //
3902            // `leadership_degraded` captures the dangerous case: a DISTRIBUTED
3903            // backend was configured (multi-replica intent) but
3904            // `coordinator_from_config` could not build the distributed
3905            // coordinator (no DB pool / `db` feature absent in this process) and
3906            // we fell back to a per-process in-process one. Keyed off the
3907            // configured backend AND the actual fallback so it never fires for a
3908            // genuinely single-replica `in_process` deployment. When set, the
3909            // renewal loop refuses to order (see `AcmeRenewalTask`) rather than
3910            // letting every replica grab its own local lease and race the CA.
3911            let mut leadership_degraded = false;
3912            let coordinator =
3913                match crate::scheduler::coordinator_from_config(&config.scheduler, &state) {
3914                    Ok(c) => c,
3915                    Err(e) => {
3916                        tracing::warn!(
3917                            error = %e,
3918                            "ACME renewal: falling back to an in-process coordinator"
3919                        );
3920                        leadership_degraded = !matches!(
3921                            config.scheduler.backend,
3922                            crate::config::SchedulerBackend::InProcess
3923                        );
3924                        std::sync::Arc::new(crate::scheduler::InProcessSchedulerCoordinator::new(
3925                            config.scheduler.resolved_replica_id(),
3926                        ))
3927                    }
3928                };
3929            renewal_task.leadership_degraded = leadership_degraded;
3930
3931            // HTTP-01 ACME is single-host in this slice: the token map is
3932            // per-process and the store is local disk. A distributed scheduler
3933            // backend means a multi-replica deployment, where the CA's :80
3934            // validation can hit a replica without the token (404) and
3935            // non-leaders cannot adopt certs from the non-shared store. Warn
3936            // loudly rather than silently mis-serving. See #1620.
3937            //
3938            // Keyed off the configured backend (operator intent) rather than the
3939            // built coordinator, so the warning still fires when
3940            // `coordinator_from_config` fell back to in-process after a Postgres
3941            // error — exactly the case where the fleet is multi-replica but this
3942            // process degraded. Exhaustive `matches!` is compiler-enforced if a
3943            // new distributed backend variant is added.
3944            if !matches!(
3945                config.scheduler.backend,
3946                crate::config::SchedulerBackend::InProcess
3947            ) {
3948                tracing::warn!(
3949                    scheduler_backend = coordinator.backend(),
3950                    "ACME HTTP-01 validation is not fleet-safe with the local on-disk token \
3951                     store: behind a load balancer the CA's :80 challenge may reach a replica \
3952                     without the token (404), and non-leader replicas cannot adopt issued \
3953                     certificates from a non-shared store. Run ACME on a single host, or use a \
3954                     shared token store / DNS-01 (#1620)"
3955                );
3956            }
3957
3958            #[cfg(feature = "reporting")]
3959            let reporter = make_acme_reporter(acme_reporters);
3960            #[cfg(not(feature = "reporting"))]
3961            let reporter = make_acme_reporter();
3962            let renewal_shutdown = server_shutdown.child_token();
3963            tokio::spawn(async move {
3964                renewal_task
3965                    .run(coordinator, reporter, renewal_shutdown)
3966                    .await;
3967            });
3968        }
3969
3970        tracing::info!(bound = %bound_desc, "Listening");
3971
3972        let server_shutdown_wait = server_shutdown.clone();
3973        // Wrap the built router with the HTML form method-override layer at
3974        // the very edge — outside path and method routing — so a plain
3975        // browser `<form method="post">` carrying `_method=PUT|PATCH|DELETE`
3976        // can reach the declared PUT/PATCH/DELETE handler. `Router::layer`
3977        // applies middleware per registered method handler in axum 0.8,
3978        // which is too late: the inner `MethodRouter` returns `405` before
3979        // a layered service ever runs. Wrapping the whole router as a
3980        // tower::Service is the documented way to run middleware before
3981        // route matching.
3982        // TrustedProxiesLayer must be outermost (stamped before MethodOverrideLayer
3983        // reads ResolvedClientIdentity for its same-origin form check).
3984        let after_method = tower::Layer::layer(
3985            &crate::middleware::MethodOverrideLayer::new()
3986                .with_max_scan_bytes(config.security.upload.max_request_size_bytes),
3987            router,
3988        );
3989        let service = tower::Layer::layer(
3990            &crate::security::TrustedProxiesLayer::from_config(&config.security.trusted_proxies),
3991            after_method,
3992        );
3993        // Spawn the serve task per transport. The two arms differ only in the
3994        // connect-info type baked into the make-service (`SocketAddr` for TCP,
3995        // `UdsConnectInfo` for Unix sockets); the graceful-shutdown wiring and
3996        // the resulting `JoinHandle<io::Result<()>>` are identical. Handlers
3997        // extracting `ConnectInfo<SocketAddr>` are unsupported under a Unix
3998        // socket (acceptable: daemon mode is loopback-equivalent and local).
3999        let server_task = match bound_listener {
4000            BoundListener::Tcp(listener) => {
4001                let make_service =
4002                    axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<
4003                        std::net::SocketAddr,
4004                    >(service);
4005                tokio::spawn(async move {
4006                    axum::serve(listener, make_service)
4007                        .with_graceful_shutdown(async move {
4008                            server_shutdown_wait.cancelled().await;
4009                        })
4010                        .await
4011                })
4012            }
4013            #[cfg(unix)]
4014            BoundListener::Unix(listener) => {
4015                // UDS requests carry no TCP peer, so stamp a loopback identity
4016                // before `TrustedProxiesLayer` runs — local daemon requests then
4017                // resolve a `ClientAddr` (and IP-based maintenance/rate-limit
4018                // behavior works) exactly like a localhost TCP connection.
4019                let service = tower::Layer::layer(
4020                    &axum::middleware::from_fn(stamp_loopback_connect_info),
4021                    service,
4022                );
4023                let make_service =
4024                    axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<
4025                        UdsConnectInfo,
4026                    >(service);
4027                tokio::spawn(async move {
4028                    axum::serve(listener, make_service)
4029                        .with_graceful_shutdown(async move {
4030                            server_shutdown_wait.cancelled().await;
4031                        })
4032                        .await
4033                })
4034            }
4035            // HTTPS arm: mirrors the TCP arm exactly. The peer is a real TCP
4036            // `SocketAddr`, so the SAME `ConnectInfo<SocketAddr>` connect-info,
4037            // `TrustedProxiesLayer`/`ClientAddr` resolution, SSE/WebSocket(wss)
4038            // streaming, and graceful-shutdown wiring apply unchanged — the only
4039            // difference is the rustls handshake performed inside the listener's
4040            // `accept`. The no-op `tap_io` wrapper lets axum's blanket
4041            // `Connected<IncomingStream<TapIo<L, F>>> for L::Addr` supply the
4042            // peer `SocketAddr`, since the concrete `SocketAddr: Connected`
4043            // impl is provided only for `tokio::net::TcpListener`.
4044            #[cfg(feature = "tls")]
4045            BoundListener::Tls(listener) => {
4046                use axum::serve::ListenerExt as _;
4047                let listener = listener.tap_io(|_io| {});
4048                let make_service =
4049                    axum::ServiceExt::<axum::extract::Request>::into_make_service_with_connect_info::<
4050                        std::net::SocketAddr,
4051                    >(service);
4052                tokio::spawn(async move {
4053                    axum::serve(listener, make_service)
4054                        .with_graceful_shutdown(async move {
4055                            server_shutdown_wait.cancelled().await;
4056                        })
4057                        .await
4058                })
4059            }
4060        };
4061
4062        let shutdown_state = state.clone();
4063        let shutdown_signal_token = server_shutdown.clone();
4064        #[cfg(feature = "ws")]
4065        let websocket_shutdown = state.shutdown.clone();
4066        // Clone metrics so the drain-watchdog can record aborted requests.
4067        let shutdown_metrics = state.metrics.clone();
4068
4069        // Shared timestamp: set by shutdown_task when the listener is cancelled
4070        // (phase 5). Main reads it after server_task completes to measure only
4071        // actual drain time for hook budget — not the app's full uptime.
4072        let drain_started_at: std::sync::Arc<std::sync::OnceLock<std::time::Instant>> =
4073            std::sync::Arc::new(std::sync::OnceLock::new());
4074        let drain_started_clone = std::sync::Arc::clone(&drain_started_at);
4075
4076        // Notified by main just before server_task.await (after startup hooks
4077        // complete). If SIGTERM arrives during startup hooks the watchdog waits
4078        // here so the drain deadline is always measured from when drain starts.
4079        let drain_phase_notify = std::sync::Arc::new(tokio::sync::Notify::new());
4080        let drain_phase_notify_for_watchdog = std::sync::Arc::clone(&drain_phase_notify);
4081        // Boolean companion so the watchdog can skip the wait when SIGTERM arrives
4082        // after startup has already finished (the common case).
4083        let server_entered_drain = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4084        let server_entered_drain_for_watchdog = std::sync::Arc::clone(&server_entered_drain);
4085
4086        // Shutdown task: handles the rolling-deploy lifecycle phases.
4087        //
4088        // Phases:
4089        //   1. SIGTERM / Ctrl-C received
4090        //   2. /ready → 503  (probe flips before listener closes)
4091        //   3. prestop_grace elapses  (load-balancer deregistration window)
4092        //   4. WebSocket sessions receive close frame
4093        //   5. TCP listener stops accepting new connections; jobs/scheduler
4094        //      stop dequeuing (they share server_shutdown CancellationToken)
4095        //   6. In-flight requests drain within shutdown_timeout_secs; if the
4096        //      deadline is exceeded the watchdog exits with code 1 and
4097        //      records autumn_shutdown_aborted_requests_total.
4098        //
4099        // Phases 7-9 (on_shutdown hooks, telemetry flush, DB pool close) run
4100        // in main after server_task completes — within the remaining portion
4101        // of the same shutdown_timeout_secs budget, not an additional window.
4102        let shutdown_task = tokio::spawn(async move {
4103            // Phase 1: Wait for OS signal.
4104            shutdown_signal().await;
4105            tracing::info!(
4106                phase = "signal_received",
4107                prestop_grace_secs = prestop_grace,
4108                shutdown_timeout_secs = shutdown_timeout,
4109                "shutdown: graceful shutdown initiated"
4110            );
4111
4112            // Phase 2: flip /ready → 503 strictly before the listener closes.
4113            shutdown_state.begin_shutdown();
4114            tracing::info!(phase = "ready_draining", "shutdown: /ready now 503");
4115
4116            // Phase 3: prestop grace — wait for load balancers to deregister.
4117            if prestop_grace > 0 {
4118                tokio::time::sleep(std::time::Duration::from_secs(prestop_grace)).await;
4119            }
4120            tracing::info!(phase = "listener_stopping", "shutdown: stopping listener");
4121
4122            // Phase 4: send WebSocket close frames.
4123            #[cfg(feature = "ws")]
4124            websocket_shutdown.cancel();
4125
4126            // Phase 5: stop listener and signal jobs/scheduler to stop dequeuing.
4127            // Record drain-start before cancelling so main gets the right hook
4128            // budget even in the startup-overlap case.
4129            let _ = drain_started_clone.set(std::time::Instant::now());
4130            shutdown_signal_token.cancel();
4131
4132            // Phase 6: drain watchdog — if in-flight drain exceeds the budget,
4133            // record aborted count and force non-zero exit before hooks run.
4134            //
4135            // Always measure the deadline from when drain actually starts so that
4136            // in-flight requests always get the full shutdown_timeout_secs window:
4137            //
4138            //   Normal (SIGTERM after startup): server_entered_drain is already
4139            //   true, skip the wait, sleep the full budget.
4140            //
4141            //   Startup-overlap (SIGTERM during hooks): wait for notify, then
4142            //   sleep the full budget. Without this, hooks completing just before
4143            //   the watchdog fires would let it exit(1) immediately with no fresh
4144            //   drain window for requests that arrived after hooks completed.
4145            if !server_entered_drain_for_watchdog.load(std::sync::atomic::Ordering::Acquire) {
4146                tracing::warn!(
4147                    phase = "signal_during_startup",
4148                    "shutdown: SIGTERM during startup hooks; waiting for drain phase \
4149                     to begin before enforcing the drain deadline"
4150                );
4151                // Suspend until main fires notify_one() at drain start.
4152                // Orchestrator hard-kill backstop: if hooks never complete, the
4153                // orchestrator's kill_timeout / terminationGracePeriodSeconds kills us.
4154                drain_phase_notify_for_watchdog.notified().await;
4155            }
4156            tokio::time::sleep(std::time::Duration::from_secs(shutdown_timeout)).await;
4157            // Guard against the boundary race where server_task completes at
4158            // exactly the deadline before main has called shutdown_task.abort().
4159            // Zero active requests means drain completed cleanly; return and let
4160            // main complete the cleanup path.
4161            if shutdown_metrics.snapshot().http.requests_active == 0 {
4162                return;
4163            }
4164            let aborted = shutdown_metrics.snapshot().http.requests_active;
4165            shutdown_metrics.record_shutdown_aborted(aborted);
4166            tracing::error!(
4167                phase = "in_flight_drain",
4168                timeout_secs = shutdown_timeout,
4169                autumn_shutdown_aborted_requests_total = aborted,
4170                exit_code = 1,
4171                "shutdown: in_flight_drain phase exceeded deadline; terminating"
4172            );
4173            // The watchdog's `process::exit` skips the remaining `on_shutdown`
4174            // hooks — including a managed-Postgres `stop()` — so a drain that
4175            // overruns its budget would orphan the postmaster. Stop it here too.
4176            #[cfg(feature = "managed-pg")]
4177            crate::managed_pg::emergency_stop_async().await;
4178            std::process::exit(1);
4179        });
4180
4181        if let Err(error) = run_startup_hooks(&startup_hooks, state.clone()).await {
4182            tracing::error!(error = %error, "startup hook failed");
4183            server_shutdown.cancel();
4184            server_task.abort();
4185            // `process::exit` skips `on_shutdown`; stop any managed Postgres.
4186            #[cfg(feature = "managed-pg")]
4187            crate::managed_pg::emergency_stop_async().await;
4188            std::process::exit(1);
4189        }
4190
4191        if !state.probes().is_shutting_down() {
4192            // Web role runs no cron scheduler (workers/combined only). Skipping
4193            // the scheduler must not regress readiness: mark_startup_complete and
4194            // signal_serve_ready below still run.
4195            if role.runs_workers() && !tasks.is_empty() {
4196                let res = start_task_scheduler_with_config(
4197                    tasks,
4198                    &state,
4199                    &server_shutdown,
4200                    &config.scheduler,
4201                );
4202                if let Err(err) = res {
4203                    tracing::error!(error = %err, "scheduled task runtime initialization failed");
4204                    server_shutdown.cancel();
4205                    server_task.abort();
4206                    // `process::exit` skips `on_shutdown`; stop any managed Postgres.
4207                    #[cfg(feature = "managed-pg")]
4208                    crate::managed_pg::emergency_stop_async().await;
4209                    std::process::exit(1);
4210                }
4211            }
4212            state.probes().mark_startup_complete();
4213            signal_serve_ready(
4214                config
4215                    .server
4216                    .prestop_grace_secs
4217                    .saturating_add(config.server.shutdown_timeout_secs),
4218            );
4219        }
4220
4221        // Signal the drain phase. The watchdog checks the flag for the common
4222        // case (SIGTERM arrives after startup) and waits on the notify for the
4223        // rare case (SIGTERM arrived during startup hooks). Both must be set so
4224        // the watchdog never re-enforces the deadline before drain actually starts.
4225        server_entered_drain.store(true, std::sync::atomic::Ordering::Release);
4226        drain_phase_notify.notify_one();
4227
4228        // Wait for the server to drain all in-flight requests.  The drain
4229        // watchdog in shutdown_task will force-exit if drain takes too long.
4230        let server_result = server_task.await.unwrap_or_else(|e| {
4231            tracing::error!("Server task join error: {e}");
4232            // `process::exit` skips the `on_shutdown` hooks, so stop a managed
4233            // Postgres child here to avoid orphaning it on an accept-loop/join
4234            // failure (direct/foreground runs have no CLI reaper).
4235            exit_stop_managed_pg();
4236            std::process::exit(1);
4237        });
4238        // Drain completed within the deadline; abort the watchdog.
4239        shutdown_task.abort();
4240        server_result.unwrap_or_else(|e| {
4241            tracing::error!("Server error: {e}");
4242            exit_stop_managed_pg();
4243            std::process::exit(1);
4244        });
4245
4246        // Phase 7: run on_shutdown hooks within the *remaining* portion of
4247        // shutdown_timeout_secs (drain + hooks share one budget, not two).
4248        // Plugin ordering: plugins register during build() before app hooks,
4249        // so app hooks run before plugin hooks (LIFO = last-registered first).
4250        let drain_elapsed = drain_started_at
4251            .get()
4252            .map_or(std::time::Duration::ZERO, std::time::Instant::elapsed);
4253        let hook_budget =
4254            std::time::Duration::from_secs(shutdown_timeout).saturating_sub(drain_elapsed);
4255        run_shutdown_hooks_with_timeout(&shutdown_hooks, hook_budget, hook_budget).await;
4256        // If request drain consumed the whole `shutdown_timeout_secs`, the
4257        // managed-Postgres `on_shutdown` hook may have been budgeted away above.
4258        // Stop the cluster directly here (idempotent — a no-op once the hook
4259        // already stopped it) so a direct/foreground run, which has no CLI
4260        // reaper, never leaves the postmaster holding the data dir/port.
4261        #[cfg(feature = "managed-pg")]
4262        crate::managed_pg::emergency_stop_async().await;
4263
4264        // Remove the Unix socket file on clean exit; axum does not unlink it.
4265        // (An abnormal force-exit may leave it behind, but the next bind's
4266        // `prepare_unix_socket_path` reclaims a stale socket.) Only unlink if the
4267        // socket is still the one we bound — a successor that rebound the same
4268        // path after we closed has a different inode, and removing it would make
4269        // the new server unreachable.
4270        #[cfg(unix)]
4271        if let Some((path, dev, ino)) = &unix_socket_cleanup {
4272            use std::os::unix::fs::MetadataExt;
4273            let still_ours =
4274                std::fs::metadata(path).is_ok_and(|m| m.dev() == *dev && m.ino() == *ino);
4275            if still_ours {
4276                let _ = std::fs::remove_file(path);
4277            }
4278        }
4279        #[cfg(not(unix))]
4280        let _ = &unix_socket_cleanup;
4281
4282        tracing::info!(exit_code = 0, "shutdown: all phases completed cleanly");
4283    }
4284
4285    /// Render all registered static routes to `dist/` and exit.
4286    ///
4287    /// Triggered when `AUTUMN_BUILD_STATIC=1` is set (by `autumn build`).
4288    /// Builds the Axum router, renders each static route through it, and
4289    /// writes HTML + manifest to the `dist/` directory.
4290    #[allow(clippy::too_many_lines)]
4291    async fn run_build_mode(self) {
4292        let Self {
4293            routes,
4294            api_versions,
4295            route_sources: _,
4296            current_plugin: _,
4297            tasks: _,
4298            one_off_tasks: _,
4299            jobs: _,
4300            listeners,
4301            static_metas,
4302            exception_filters: _,
4303            scoped_groups,
4304            merge_routers: _,
4305            nest_routers: _,
4306            custom_layers,
4307            static_gate_layers: _,
4308            startup_hooks: _,
4309            state_initializers,
4310            shutdown_hooks: _,
4311            extensions: _,
4312            registered_plugins: _,
4313            plugin_config_roots,
4314            #[cfg(feature = "maud")]
4315                error_page_renderer: _,
4316            #[cfg(feature = "db")]
4317                migrations: _,
4318            config_loader_factory,
4319            #[cfg(feature = "db")]
4320            pool_provider_factory,
4321            #[cfg(feature = "db")]
4322            shard_provider_factory,
4323            #[cfg(feature = "db")]
4324            shard_router,
4325            #[cfg(feature = "db")]
4326            directory_shard_router,
4327            telemetry_provider,
4328            session_store,
4329            #[cfg(feature = "ws")]
4330            channels_backend,
4331            #[cfg(feature = "storage")]
4332            blob_store,
4333            cache_backend,
4334            #[cfg(feature = "reporting")]
4335            error_reporters,
4336            alert_channels: _,
4337            #[cfg(feature = "openapi")]
4338            openapi,
4339            #[cfg(feature = "mcp")]
4340                mcp: _,
4341            audit_logger: _,
4342            #[cfg(feature = "i18n")]
4343            i18n_bundle,
4344            #[cfg(feature = "i18n")]
4345            i18n_auto_load,
4346            #[cfg(feature = "embed-assets")]
4347            embedded_static,
4348            #[cfg(all(feature = "embed-assets", feature = "i18n"))]
4349            embedded_locales,
4350            policy_registrations,
4351            #[cfg(feature = "mail")]
4352            mail_delivery_queue_factory,
4353            #[cfg(feature = "mail")]
4354            suppression_store,
4355            #[cfg(feature = "mail")]
4356            mail_suppression_store,
4357            #[cfg(feature = "mail")]
4358            mount_unsubscribe_endpoint,
4359            #[cfg(feature = "mail")]
4360            mail_previews,
4361            #[cfg(feature = "maud")]
4362            story_gallery,
4363            declared_routes: _,
4364            idempotency_enabled,
4365            #[cfg(feature = "mail")]
4366            mail_interceptor,
4367            job_interceptor,
4368            #[cfg(feature = "db")]
4369            db_interceptor,
4370            #[cfg(feature = "ws")]
4371            channels_interceptor,
4372            #[cfg(feature = "oauth2")]
4373            http_interceptor,
4374            seo_sources,
4375            metrics_sources,
4376            health_indicators,
4377            #[cfg(feature = "inbound-mail")]
4378                inbound_mail_router: _,
4379        } = self;
4380
4381        let _ = &api_versions;
4382        let _ = &metrics_sources;
4383        let _ = &health_indicators;
4384        let all_routes = routes;
4385
4386        // Load config (same as normal startup)
4387        let (mut config, telemetry_guard) = load_config_and_telemetry(
4388            config_loader_factory,
4389            telemetry_provider,
4390            plugin_config_roots,
4391        )
4392        .await;
4393
4394        #[cfg(feature = "mail")]
4395        if mount_unsubscribe_endpoint {
4396            config.mail.mount_unsubscribe_endpoint = true;
4397        }
4398        if idempotency_enabled {
4399            let env_disabled = std::env::var("AUTUMN_IDEMPOTENCY__ENABLED")
4400                .is_ok_and(|v| matches!(v.to_lowercase().as_str(), "false" | "0" | "no" | "off"));
4401            // Only apply the builder default when neither the env var nor the
4402            // loaded config file explicitly sets enabled = false.
4403            if !env_disabled && config.idempotency.enabled != Some(false) {
4404                config.idempotency.enabled = Some(true);
4405            }
4406        }
4407
4408        // Register the embedded `static/` tree (if any) before the router is
4409        // built so `/static/*` serves from the binary and `asset_url()` resolves
4410        // against the embedded manifest, then prefer embedded locales over disk
4411        // auto-loading when no explicit bundle was provided.
4412        #[cfg(feature = "embed-assets")]
4413        register_embedded_static_dir(embedded_static);
4414
4415        #[cfg(all(feature = "embed-assets", feature = "i18n"))]
4416        let i18n_bundle = embedded_i18n_bundle(i18n_bundle, embedded_locales, &config);
4417
4418        #[cfg(feature = "i18n")]
4419        let i18n_bundle =
4420            resolve_i18n_bundle(i18n_bundle, i18n_auto_load, &config, &crate::config::OsEnv);
4421
4422        // Snapshot ApiDocs before all_routes is moved into the router builder.
4423        // Includes top-level routes and scoped groups (with prefixed paths) so
4424        // the emitted dist/openapi.json matches what the runtime spec serves.
4425        #[cfg(feature = "openapi")]
4426        let api_docs_snapshot: Vec<crate::openapi::ApiDoc> = {
4427            let mut docs: Vec<crate::openapi::ApiDoc> = all_routes
4428                .iter()
4429                .map(|r| {
4430                    let mut doc = r.api_doc.clone();
4431                    doc.api_version = r.api_version;
4432                    doc.sunset_opt_out = r.sunset_opt_out;
4433                    doc
4434                })
4435                .collect();
4436            for group in &scoped_groups {
4437                // Mirror the same normalization as the runtime OpenAPI builder:
4438                // use join_nested_path for correct trailing-slash handling, and
4439                // merge prefix path params so they appear in the operation.
4440                let prefix_params = crate::router::extract_path_params(&group.prefix);
4441                for route in &group.routes {
4442                    let mut doc = route.api_doc.clone();
4443                    doc.api_version = route.api_version;
4444                    doc.sunset_opt_out = route.sunset_opt_out;
4445                    let full = crate::router::join_nested_path(&group.prefix, route.api_doc.path);
4446                    doc.path = Box::leak(full.into_boxed_str());
4447                    if !prefix_params.is_empty() {
4448                        let mut merged: Vec<&'static str> = prefix_params
4449                            .iter()
4450                            .map(|p| &*Box::leak(p.clone().into_boxed_str()))
4451                            .collect();
4452                        merged.extend_from_slice(doc.path_params);
4453                        doc.path_params = Box::leak(merged.into_boxed_slice());
4454                    }
4455                    docs.push(doc);
4456                }
4457            }
4458            docs
4459        };
4460
4461        if static_metas.is_empty() {
4462            eprintln!("No static routes registered. Nothing to build.");
4463            eprintln!("Hint: use .static_routes(static_routes![...]) on your AppBuilder.");
4464            std::process::exit(1);
4465        }
4466
4467        // Fail-fast on invalid session config — only when no custom store
4468        // was installed. Symmetrical to the same check in run() so static
4469        // builds don't run migrations against a doomed boot either.
4470        fail_fast_on_invalid_session_config(&config, session_store.is_some());
4471        fail_fast_on_invalid_signing_secret(&config);
4472        fail_fast_on_missing_encryption_keys(&config);
4473        fail_fast_on_invalid_trusted_hosts(&config);
4474
4475        // Preflight the configured BlobStore the same way `run()` does.
4476        // Static routes can read presigned URLs out of `BlobStoreState`
4477        // during pre-rendering (e.g. `<img src=blob.url()>`); without
4478        // the bootstrap they'd 500 during `autumn build` even though
4479        // the server path works. A custom store from `.with_blob_store()`
4480        // bypasses config-driven instantiation.
4481        #[cfg(feature = "storage")]
4482        let storage_bootstrap = blob_store.map_or_else(
4483            || preflight_storage(&config),
4484            |store| {
4485                Some(StorageBootstrap {
4486                    store,
4487                    serving: None,
4488                })
4489            },
4490        );
4491
4492        // Build state (with DB if configured)
4493        #[cfg(feature = "db")]
4494        let database = setup_database(
4495            &config,
4496            vec![],
4497            pool_provider_factory,
4498            shard_provider_factory,
4499            shard_router,
4500            directory_shard_router,
4501            RepositoryCommitHookQueueMigrationMode::StaticBuild,
4502        )
4503        .await
4504        .unwrap_or_else(|e| {
4505            eprintln!("{e}");
4506            std::process::exit(1);
4507        });
4508        #[cfg(feature = "db")]
4509        let pool = database.topology;
4510        #[cfg(feature = "db")]
4511        let shards = database.shards;
4512        #[cfg(feature = "db")]
4513        let replica_readiness = database.replica_readiness;
4514        #[cfg(feature = "db")]
4515        let replica_migration_check = database.replica_migration_check;
4516
4517        let mut state = build_state(
4518            &config,
4519            #[cfg(feature = "db")]
4520            pool.as_ref(),
4521            #[cfg(feature = "db")]
4522            shards,
4523            #[cfg(feature = "ws")]
4524            channels_backend,
4525        );
4526        if let Some(buf) = telemetry_guard.log_buffer.clone() {
4527            state.insert_extension(buf);
4528        }
4529        // Wire the live-subscriber reload handle into the loggers actuator so
4530        // `PUT /actuator/loggers/{name}` affects the running subscriber, not
4531        // just an in-memory map (issue #1044).
4532        if let Some(handle) = telemetry_guard.filter_reload.clone() {
4533            state.log_levels().attach_reload_handle(handle);
4534        }
4535        state.insert_extension(RegisteredApiVersions(api_versions.clone()));
4536        #[cfg(feature = "mail")]
4537        if let Some(interceptor) = mail_interceptor {
4538            state.insert_extension(interceptor);
4539        }
4540        if let Some(interceptor) = job_interceptor {
4541            state.insert_extension(interceptor);
4542        }
4543        #[cfg(feature = "db")]
4544        if let Some(interceptor) = db_interceptor {
4545            state.insert_extension(interceptor);
4546        }
4547        #[cfg(feature = "ws")]
4548        if let Some(interceptor) = channels_interceptor {
4549            state.insert_extension(interceptor.clone());
4550            state.channels = crate::channels::Channels::with_shared_backend(std::sync::Arc::new(
4551                crate::channels::InterceptedChannelsBackend::new(
4552                    state.channels.backend().clone(),
4553                    vec![interceptor],
4554                ),
4555            ));
4556            #[cfg(feature = "presence")]
4557            {
4558                state.presence = crate::presence::Presence::new(state.channels.clone());
4559            }
4560        }
4561        #[cfg(feature = "oauth2")]
4562        if let Some(interceptor) = http_interceptor {
4563            state.insert_extension(interceptor);
4564        }
4565        #[cfg(feature = "db")]
4566        configure_replica_migration_check(&state, replica_migration_check);
4567        #[cfg(feature = "db")]
4568        apply_replica_migration_readiness(&state, replica_readiness);
4569        if let Some(cache) = cache_backend {
4570            crate::cache::set_global_cache(cache.clone());
4571            state.shared_cache = Some(cache);
4572        } else {
4573            crate::cache::clear_global_cache();
4574        }
4575        #[cfg(feature = "reporting")]
4576        if !error_reporters.is_empty() {
4577            state.insert_extension(crate::reporting::RegisteredReporters(error_reporters));
4578        }
4579        // Static-site builds are short-lived and don't run the request loop,
4580        // so deliver_later is never invoked. install_mailer_with_factory skips
4581        // the queue factory when enforce_durable_guard is false (the factory
4582        // may open Redis/Harvest connections unavailable here), and the guard
4583        // itself is bypassed too — the Mailer is still installed so static
4584        // routes that extract `Mailer` for immediate `send` calls resolve.
4585        #[cfg(feature = "mail")]
4586        if let Some(handle) = suppression_store {
4587            state.insert_extension(handle);
4588        }
4589        #[cfg(feature = "mail")]
4590        if let Some(handle) = mail_suppression_store {
4591            state.insert_extension(handle);
4592        }
4593        #[cfg(feature = "mail")]
4594        crate::mail::install_mailer_with_factory(
4595            &state,
4596            &config.mail,
4597            mail_delivery_queue_factory,
4598            false,
4599        )
4600        .unwrap_or_else(|error| {
4601            eprintln!("Failed to configure mailer: {error}");
4602            exit_stop_managed_pg();
4603            std::process::exit(1);
4604        });
4605        #[cfg(feature = "mail")]
4606        state.insert_extension(crate::mail::MailPreviewRegistry::new(mail_previews));
4607        #[cfg(feature = "maud")]
4608        install_story_registry(&state, story_gallery);
4609        // run_build_mode used ProbeState::default(), which does not start as pending
4610        state.probes = crate::probe::ProbeState::default();
4611
4612        // Apply deferred policy / scope registrations onto the live
4613        // app state — same as `run()`. Static routes can carry
4614        // `#[authorize]` checks or live behind `#[repository(policy =
4615        // ..., scope = ...)]` index endpoints; without registering
4616        // here, every such pre-render call would 500 at build time
4617        // with `no policy/scope registered`, and `render_static_routes`
4618        // would treat that as a build failure even though
4619        // `.policy(...)` / `.scope(...)` was configured on the
4620        // builder.
4621        for register in policy_registrations {
4622            register(state.policy_registry());
4623        }
4624
4625        #[cfg(feature = "i18n")]
4626        let custom_layers = install_i18n_bundle_layer(custom_layers, &state, i18n_bundle);
4627
4628        // Install the preflighted storage and remember the serving
4629        // router so static generation hits the same `/_blobs/...`
4630        // routes the server path serves.
4631        #[cfg(feature = "storage")]
4632        let storage_router = storage_bootstrap.and_then(|b| b.install(&state));
4633        install_webhook_registry(&state, &config);
4634        run_state_initializers(state_initializers, &state);
4635        // Static generation has no job runtime, so register only sync listeners.
4636        // Durable listeners are dropped entirely (not just their jobs) so a
4637        // static route publishing such an event is a clean no-op for the durable
4638        // side effect rather than a "job runtime not initialized" error.
4639        let sync_listeners: Vec<_> = listeners
4640            .into_iter()
4641            .filter(|listener| listener.mode == crate::events::DispatchMode::Sync)
4642            .collect();
4643        finalize_event_bus(sync_listeners, &mut Vec::new(), &state);
4644
4645        // Build the full router (same as production). Use the inner builder
4646        // so the custom session store installed via with_session_store(...)
4647        // is honored during static generation — apps that swap in a custom
4648        // store specifically to avoid Redis/external backends at build time
4649        // would otherwise silently fall back to the config-driven backend.
4650        // Custom Tower layers registered via .layer(...) are likewise
4651        // applied so static output matches the production response pipeline.
4652        #[cfg_attr(not(feature = "storage"), allow(unused_mut))]
4653        let mut merge_routers: Vec<axum::Router<AppState>> = Vec::new();
4654        #[cfg(feature = "storage")]
4655        if let Some(router) = storage_router {
4656            merge_routers.push(router);
4657        }
4658        let router = crate::router::try_build_router_inner(
4659            all_routes,
4660            &config,
4661            state,
4662            crate::router::RouterContext {
4663                exception_filters: Vec::new(),
4664                scoped_groups,
4665                merge_routers,
4666                nest_routers: Vec::new(),
4667                custom_layers,
4668                static_gate_layers: Vec::new(),
4669                #[cfg(feature = "maud")]
4670                error_page_renderer: None,
4671                session_store,
4672                #[cfg(feature = "openapi")]
4673                openapi: None,
4674                #[cfg(feature = "mcp")]
4675                mcp: None,
4676            },
4677        )
4678        .unwrap_or_else(|error| {
4679            eprintln!("Failed to build router: {error}");
4680            exit_stop_managed_pg();
4681            std::process::exit(1);
4682        });
4683
4684        let env = crate::config::OsEnv;
4685        let dist_dir = project_dir("dist", &env);
4686
4687        eprintln!("Building {} static route(s)...", static_metas.len());
4688
4689        match crate::static_gen::render_static_routes(router, &static_metas, &dist_dir).await {
4690            Ok(()) => {
4691                eprintln!(
4692                    "\n  \u{2713} Static build complete \u{2192} {}",
4693                    dist_dir.display()
4694                );
4695            }
4696            Err(e) => {
4697                eprintln!("\n  \u{2717} Static build failed: {e}");
4698                exit_stop_managed_pg();
4699                std::process::exit(1);
4700            }
4701        }
4702
4703        // When OpenAPI is configured, write the spec to dist/ so consumers
4704        // can retrieve a machine-readable API contract alongside the HTML.
4705        #[cfg(feature = "openapi")]
4706        if let Some(mut openapi_config) = openapi {
4707            openapi_config.api_versions = api_versions;
4708            let openapi_config =
4709                openapi_config.session_cookie_name(config.session.cookie_name.clone());
4710            let docs: Vec<&crate::openapi::ApiDoc> = api_docs_snapshot.iter().collect();
4711            let spec = crate::openapi::generate_spec(&openapi_config, &docs);
4712            match crate::openapi::write_openapi_spec_to_dist(&spec, &dist_dir) {
4713                Ok(()) => {
4714                    eprintln!(
4715                        "  \u{2713} OpenAPI spec written \u{2192} {}/openapi.json",
4716                        dist_dir.display()
4717                    );
4718                }
4719                Err(e) => {
4720                    eprintln!("  \u{26A0} Failed to write OpenAPI spec: {e}");
4721                }
4722            }
4723        }
4724
4725        // Write robots.txt and sitemap.xml to dist/ — only when SEO is explicitly
4726        // configured or dynamic sources are registered, and never overwrite files
4727        // already produced by a custom #[static_get("/robots.txt")] route.
4728        if !seo_sources.is_empty() || crate::seo::has_seo_config(&config.seo) {
4729            let seo_cfg = &config.seo;
4730            let raw_profile = config.profile.as_deref().unwrap_or("dev");
4731            let profile = crate::seo::effective_seo_profile(raw_profile, seo_cfg.robots.allow_all);
4732            let static_paths: Vec<&str> = static_metas.iter().map(|m| m.path).collect();
4733            let (robots_body, sitemap_body) = crate::seo::assemble_seo_bodies(
4734                profile,
4735                seo_cfg.base_url.as_deref(),
4736                seo_cfg.robots.sitemap_url.as_deref(),
4737                &seo_cfg.robots.additional_rules,
4738                &seo_sources,
4739                &static_paths,
4740            )
4741            .await;
4742            // Write each file only if it wasn't already produced by a
4743            // custom #[static_get] route.
4744            let robots_path = dist_dir.join("robots.txt");
4745            let sitemap_path = dist_dir.join("sitemap.xml");
4746            if robots_path.exists() {
4747                eprintln!(
4748                    "  \u{2713} SEO: robots.txt already present (custom static route), skipping"
4749                );
4750            } else {
4751                match tokio::fs::write(&robots_path, robots_body).await {
4752                    Ok(()) => eprintln!(
4753                        "  \u{2713} SEO: robots.txt written \u{2192} {}",
4754                        robots_path.display()
4755                    ),
4756                    Err(e) => eprintln!("  \u{26A0} Failed to write robots.txt: {e}"),
4757                }
4758            }
4759            if sitemap_path.exists() {
4760                eprintln!(
4761                    "  \u{2713} SEO: sitemap.xml already present (custom static route), skipping"
4762                );
4763            } else {
4764                match tokio::fs::write(&sitemap_path, sitemap_body).await {
4765                    Ok(()) => eprintln!(
4766                        "  \u{2713} SEO: sitemap.xml written \u{2192} {}",
4767                        sitemap_path.display()
4768                    ),
4769                    Err(e) => eprintln!("  \u{26A0} Failed to write sitemap.xml: {e}"),
4770                }
4771            }
4772        }
4773
4774        // Build finished: stop the managed Postgres child `setup_database` may
4775        // have started. Build mode discards the app's `on_shutdown` hooks, so
4776        // without this even a *successful* `autumn build` would leak the cluster.
4777        #[cfg(feature = "managed-pg")]
4778        crate::managed_pg::emergency_stop_async().await;
4779    }
4780
4781    /// Dump the application's route listing as JSON and exit.
4782    ///
4783    /// Triggered when `AUTUMN_DUMP_ROUTES=1` is set (by `autumn routes`).
4784    /// Exits with code 0 on success, code 1 on JSON serialization failure.
4785    /// Does not connect to a database or bind a TCP port.
4786    #[allow(clippy::too_many_lines)]
4787    async fn run_dump_routes_mode(self) {
4788        let Self {
4789            routes,
4790            api_versions,
4791            route_sources,
4792            scoped_groups,
4793            merge_routers,
4794            nest_routers,
4795            declared_routes,
4796            config_loader_factory,
4797            telemetry_provider,
4798            #[cfg(feature = "openapi")]
4799            openapi,
4800            plugin_config_roots,
4801            ..
4802        } = self;
4803
4804        // Validate that all versioned routes use a registered API version
4805        let registered_versions: std::collections::HashSet<&str> =
4806            api_versions.iter().map(|av| av.version.as_str()).collect();
4807
4808        for route in &routes {
4809            if let Some(ver) = route
4810                .api_version
4811                .filter(|ver| !registered_versions.contains(*ver))
4812            {
4813                eprintln!(
4814                    "Failed to build router: route '{}' uses unregistered API version '{}'",
4815                    route.name, ver
4816                );
4817                std::process::exit(1);
4818            }
4819        }
4820
4821        for group in &scoped_groups {
4822            for route in &group.routes {
4823                if let Some(ver) = route
4824                    .api_version
4825                    .filter(|ver| !registered_versions.contains(*ver))
4826                {
4827                    eprintln!(
4828                        "Failed to build router: route '{}' uses unregistered API version '{}'",
4829                        route.name, ver
4830                    );
4831                    std::process::exit(1);
4832                }
4833            }
4834        }
4835
4836        // Raw Axum routers registered via .merge()/.nest() are opaque: there is
4837        // no public API to enumerate their routes, so they are omitted from the
4838        // listing and hard-fail `autumn routes audit` (their auth posture can't
4839        // be proven). The exception is a `.nest(prefix, router)` whose endpoints
4840        // were declared via `declare_plugin_routes` — when a declared route's
4841        // path falls under the nest prefix, those endpoints ARE enumerable
4842        // (folded into `declared_routes`) and must not be counted as omitted.
4843        // Every `.merge()` is rootless and always counts; a bare `.nest()` with
4844        // no covering declaration stays opaque and counts.
4845        let hidden = omitted_router_count(
4846            merge_routers.len(),
4847            nest_routers.iter().map(|(prefix, _)| prefix.as_str()),
4848            &declared_routes,
4849        );
4850        if hidden > 0 {
4851            eprintln!(
4852                "[autumn routes] warning: {hidden} raw router(s) added via \
4853                 .merge()/.nest() are not enumerable and are omitted from this listing"
4854            );
4855            // Machine-readable marker consumed by `autumn routes audit` to
4856            // hard-fail the coverage gate: omitted routes can't be proven.
4857            eprintln!(
4858                "{marker}{hidden}",
4859                marker = crate::route_listing::OMITTED_ROUTES_MARKER
4860            );
4861        }
4862
4863        let (config, _telemetry_guard) = load_config_and_telemetry(
4864            config_loader_factory,
4865            telemetry_provider,
4866            plugin_config_roots,
4867        )
4868        .await;
4869
4870        // Emit the resolved security configuration for the manifest's `declared`
4871        // dimensions (CSRF, security headers). Gated on `AUTUMN_DUMP_SECURITY`
4872        // so only `autumn routes audit` sees it — kept off stdout so the
4873        // routes-only JSON parse path stays byte-compatible.
4874        if is_dump_security_mode() {
4875            let security = crate::route_listing::SecurityDump::from_config(&config);
4876            match serde_json::to_string(&security) {
4877                Ok(json) => eprintln!(
4878                    "{marker}{json}",
4879                    marker = crate::route_listing::SECURITY_CONFIG_MARKER
4880                ),
4881                Err(e) => eprintln!("Failed to serialize security config: {e}"),
4882            }
4883        }
4884
4885        let mut infos = match crate::route_listing::collect_route_infos(
4886            &routes,
4887            &route_sources,
4888            &scoped_groups,
4889            &api_versions,
4890        ) {
4891            Ok(infos) => infos,
4892            Err(e) => {
4893                eprintln!("Failed to build router: {e}");
4894                std::process::exit(1);
4895            }
4896        };
4897        infos.extend(declared_routes);
4898        crate::route_listing::append_framework_routes(&mut infos, &config);
4899        #[cfg(feature = "openapi")]
4900        if let Some(ref oa) = openapi {
4901            crate::route_listing::append_openapi_routes(&mut infos, oa);
4902        }
4903        crate::route_listing::append_dev_reload_routes(&mut infos);
4904        crate::route_listing::sort_route_infos(&mut infos);
4905
4906        let json = serde_json::to_string_pretty(&infos).unwrap_or_else(|e| {
4907            eprintln!("Failed to serialize route listing: {e}");
4908            std::process::exit(1);
4909        });
4910        println!("{json}");
4911        std::process::exit(0);
4912    }
4913
4914    /// Dump the effective drained-queue manifest as TOML and exit.
4915    ///
4916    /// Triggered when `AUTUMN_DUMP_JOBS=1` is set (by `autumn jobs manifest`).
4917    /// Emits a single top-level `queues = [...]` array — the configured
4918    /// `[jobs.queues]` set unioned with every `#[job(queue = "…")]`-declared
4919    /// queue, ordered highest priority first exactly as the runtime drains — so a
4920    /// topology-aware `autumn doctor` consumes the ground-truth set the app runs
4921    /// with. Does not connect to a database or bind a TCP port. Always exits 0.
4922    async fn run_dump_jobs_mode(self) {
4923        let Self {
4924            jobs,
4925            listeners,
4926            config_loader_factory,
4927            telemetry_provider,
4928            plugin_config_roots,
4929            ..
4930        } = self;
4931
4932        let (config, _telemetry_guard) = load_config_and_telemetry(
4933            config_loader_factory,
4934            telemetry_provider,
4935            plugin_config_roots,
4936        )
4937        .await;
4938
4939        // Fold in the synthesized durable-listener jobs exactly as the boot path
4940        // does, so the manifest reflects the same effective drained-queue set the
4941        // runtime drains (including the `default` queue those jobs land on).
4942        let manifest = dump_jobs_manifest(&config.jobs.queues, jobs, listeners);
4943        print!("{manifest}");
4944        std::process::exit(0);
4945    }
4946
4947    /// Dump registered one-off tasks as JSON and exit.
4948    ///
4949    /// Triggered by `AUTUMN_LIST_TASKS=1` from `autumn task --list`.
4950    fn run_list_one_off_tasks_mode(self) {
4951        let Self { one_off_tasks, .. } = self;
4952
4953        if let Err(error) = crate::task::validate_unique_one_off_task_names(&one_off_tasks) {
4954            eprintln!("Invalid task registration: {error}");
4955            std::process::exit(1);
4956        }
4957
4958        let listing = crate::task::list_one_off_tasks(&one_off_tasks);
4959        let json = serde_json::to_string_pretty(&listing).unwrap_or_else(|error| {
4960            eprintln!("Failed to serialize task listing: {error}");
4961            std::process::exit(1);
4962        });
4963        println!("{json}");
4964        std::process::exit(0);
4965    }
4966
4967    /// Apply pending embedded migrations and exit (the `AUTUMN_MIGRATE=1`
4968    /// one-shot), WITHOUT starting the HTTP server or binding a port.
4969    ///
4970    /// Reuses the exact applier the startup auto-migration path uses
4971    /// ([`run_pending_locked`](crate::migrate::run_pending_locked), the public
4972    /// wrapper over the same locked engine `auto_migrate` drives) and the same
4973    /// framework-migration fold ([`migrations_with_repository_framework_migrations`]),
4974    /// so the applied set matches a normal boot. Unlike that path it applies
4975    /// regardless of profile — the deploy invokes it explicitly — and it targets
4976    /// the writable primary(ies) only (control primary + each shard primary),
4977    /// exactly like `autumn migrate` / the deploy DB preflight; replicas are never
4978    /// migration targets. The framework-internal directory / shard-map guard
4979    /// tables are deliberately NOT applied here: the app applies them
4980    /// unconditionally at startup, so the candidate's own boot creates them.
4981    ///
4982    /// Exits 0 after applying (printing a redacted count — never a URL or secret)
4983    /// and 1 on the first failure, so a failed migration aborts the deploy before
4984    /// cutover with the old release still serving (AC-3).
4985    #[cfg(feature = "db")]
4986    async fn run_migrate_only_mode(self) {
4987        let Self {
4988            migrations,
4989            config_loader_factory,
4990            telemetry_provider,
4991            plugin_config_roots,
4992            ..
4993        } = self;
4994
4995        // The telemetry guard is dropped at end of scope; a migrate-only run does
4996        // not need tracing wired, but loading config the same way keeps env/profile
4997        // resolution identical to a normal boot.
4998        let (config, _telemetry_guard) = load_config_and_telemetry(
4999            config_loader_factory,
5000            telemetry_provider,
5001            plugin_config_roots,
5002        )
5003        .await;
5004
5005        // Fold in the framework migration sets a normal boot would apply, using the
5006        // SAME helper as `setup_database`, so the applied set is identical.
5007        let migrations = migrations_with_repository_framework_migrations(
5008            migrations,
5009            crate::repository_commit_hooks::has_repository_commit_hook_descriptors(),
5010            crate::version_history::has_versioned_repository_descriptors(),
5011            RepositoryCommitHookQueueMigrationMode::Runtime,
5012        );
5013
5014        // Writable targets only: the control primary, then each shard primary.
5015        let control_url = config.database.effective_primary_url().map(str::to_owned);
5016        let shard_targets: Vec<(String, String)> = config
5017            .database
5018            .shards
5019            .iter()
5020            .map(|shard| (format!("shard:{}", shard.name), shard.primary_url.clone()))
5021            .collect();
5022
5023        if migrations.is_empty() || (control_url.is_none() && shard_targets.is_empty()) {
5024            eprintln!(
5025                "autumn migrate: no database configured or no migrations registered — nothing to apply"
5026            );
5027            std::process::exit(0);
5028        }
5029
5030        // SQLite migrate-only guard (issue #1614, PR3): sharding is Postgres-only,
5031        // so a `sqlite:` control target with shards configured, or any `sqlite:`
5032        // shard target, fails fast here with the actionable sharding error — the
5033        // SAME `sqlite_sharding_unsupported_guard` normal boot applies, so the two
5034        // paths cannot drift. A plain `sqlite:` control target (no shards) is NOT
5035        // gated: its migrations are applied by the SQLite apply path in the loop
5036        // below. An all-Postgres / empty-shard configuration is never gated,
5037        // leaving the Postgres path byte-identical.
5038        #[cfg(feature = "sqlite")]
5039        {
5040            let sqlite_guard_shard_urls: Vec<&str> =
5041                shard_targets.iter().map(|(_, url)| url.as_str()).collect();
5042            if let Err(e) = sqlite_sharding_unsupported_guard(
5043                control_url.as_deref(),
5044                !shard_targets.is_empty(),
5045                &sqlite_guard_shard_urls,
5046            ) {
5047                eprintln!("autumn migrate: {e}");
5048                // `process::exit` skips `on_shutdown`/`Drop`; stop any managed
5049                // Postgres child first, mirroring `apply_pending_or_exit`.
5050                #[cfg(feature = "managed-pg")]
5051                crate::managed_pg::emergency_stop();
5052                std::process::exit(1);
5053            }
5054        }
5055
5056        // The diesel harness and the advisory-lock poll block, so apply off the
5057        // Tokio worker threads. Each target's failure exits non-zero from inside.
5058        let applied_total = tokio::task::spawn_blocking(move || {
5059            let mut total = 0_usize;
5060            if let Some(url) = &control_url {
5061                // SQLite single-writer control target (issue #1614, PR3): apply with
5062                // NO advisory lock via the SQLite harness. Sharding is rejected above,
5063                // so a SQLite control target here is always unsharded (shard_targets
5064                // is empty). Every non-SQLite target keeps the byte-identical locked
5065                // Postgres applier.
5066                #[cfg(feature = "sqlite")]
5067                let is_sqlite_control = crate::config::DatabaseBackend::detect(url)
5068                    == Some(crate::config::DatabaseBackend::Sqlite);
5069                #[cfg(not(feature = "sqlite"))]
5070                let is_sqlite_control = false;
5071                if is_sqlite_control {
5072                    #[cfg(feature = "sqlite")]
5073                    for mig in &migrations {
5074                        total += apply_pending_sqlite_or_exit(url, mig, "control");
5075                    }
5076                } else {
5077                    for mig in &migrations {
5078                        total += apply_pending_or_exit(url, mig, "control");
5079                    }
5080                }
5081            }
5082            // Shards hold tenant data, not the control-plane schema; skip the
5083            // control framework set for shard targets (mirrors `run_startup_migrations`).
5084            // A `sqlite:` shard is rejected by the guard above, so every shard here
5085            // is Postgres.
5086            for (label, url) in &shard_targets {
5087                for mig in migrations
5088                    .iter()
5089                    .filter(|mig| !migration_set_is_control_framework(mig))
5090                {
5091                    total += apply_pending_or_exit(url, mig, label);
5092                }
5093            }
5094            total
5095        })
5096        .await
5097        .unwrap_or_else(|error| {
5098            eprintln!("autumn migrate: migration task panicked: {error}");
5099            std::process::exit(1);
5100        });
5101
5102        eprintln!(
5103            "autumn migrate: applied {applied_total} pending migration(s); database is up to date"
5104        );
5105        std::process::exit(0);
5106    }
5107
5108    /// The `AUTUMN_MIGRATE=1` one-shot on a build compiled WITHOUT database
5109    /// support: there is nothing to migrate, so report and exit 0 (never starting
5110    /// the server) so a DB-free app's deploy still runs the step harmlessly.
5111    #[cfg(not(feature = "db"))]
5112    #[allow(clippy::unused_async)]
5113    async fn run_migrate_only_mode(self) {
5114        eprintln!("autumn migrate: this build has no database support — nothing to migrate");
5115        std::process::exit(0);
5116    }
5117
5118    /// Run a registered one-off task with full application context and exit.
5119    ///
5120    /// Triggered by `AUTUMN_RUN_TASK=<name>` from `autumn task <name>`.
5121    #[allow(clippy::too_many_lines)]
5122    #[allow(clippy::cognitive_complexity)]
5123    async fn run_one_off_task_mode(self, requested_name: String) {
5124        let Self {
5125            one_off_tasks,
5126            mut jobs,
5127            listeners,
5128            #[cfg(feature = "i18n")]
5129            custom_layers,
5130            #[cfg(not(feature = "i18n"))]
5131                custom_layers: _,
5132            startup_hooks,
5133            state_initializers,
5134            shutdown_hooks,
5135            config_loader_factory,
5136            #[cfg(feature = "db")]
5137            migrations,
5138            #[cfg(feature = "db")]
5139            pool_provider_factory,
5140            #[cfg(feature = "db")]
5141            shard_provider_factory,
5142            #[cfg(feature = "db")]
5143            shard_router,
5144            #[cfg(feature = "db")]
5145            directory_shard_router,
5146            telemetry_provider,
5147            session_store,
5148            #[cfg(feature = "ws")]
5149            channels_backend,
5150            #[cfg(feature = "storage")]
5151            blob_store,
5152            audit_logger,
5153            #[cfg(feature = "i18n")]
5154            i18n_bundle,
5155            #[cfg(feature = "i18n")]
5156            i18n_auto_load,
5157            #[cfg(feature = "embed-assets")]
5158            embedded_static,
5159            #[cfg(all(feature = "embed-assets", feature = "i18n"))]
5160            embedded_locales,
5161            policy_registrations,
5162            cache_backend,
5163            #[cfg(feature = "mail")]
5164            mail_delivery_queue_factory,
5165            #[cfg(feature = "mail")]
5166            suppression_store,
5167            #[cfg(feature = "mail")]
5168            mail_suppression_store,
5169            #[cfg(feature = "mail")]
5170                mount_unsubscribe_endpoint: _,
5171            #[cfg(feature = "mail")]
5172            mail_interceptor,
5173            job_interceptor,
5174            #[cfg(feature = "db")]
5175            db_interceptor,
5176            #[cfg(feature = "ws")]
5177            channels_interceptor,
5178            #[cfg(feature = "oauth2")]
5179            http_interceptor,
5180            plugin_config_roots,
5181            ..
5182        } = self;
5183
5184        if let Err(error) = crate::task::validate_unique_one_off_task_names(&one_off_tasks) {
5185            eprintln!("Invalid task registration: {error}");
5186            std::process::exit(1);
5187        }
5188
5189        let Some((task_name, task_handler)) = one_off_tasks
5190            .iter()
5191            .find(|task| task.name == requested_name)
5192            .map(|task| (task.name.clone(), task.handler))
5193        else {
5194            eprintln!("No one-off task named '{requested_name}' is registered.");
5195            print_available_one_off_tasks(&one_off_tasks);
5196            std::process::exit(1);
5197        };
5198
5199        let args = one_off_task_args_from_env().unwrap_or_else(|error| {
5200            eprintln!("Invalid task args: {error}");
5201            std::process::exit(1);
5202        });
5203
5204        let (config, telemetry_guard) = load_config_and_telemetry(
5205            config_loader_factory,
5206            telemetry_provider,
5207            plugin_config_roots,
5208        )
5209        .await;
5210
5211        // Register the embedded `static/` tree (if any) before the router is
5212        // built so `/static/*` serves from the binary and `asset_url()` resolves
5213        // against the embedded manifest, then prefer embedded locales over disk
5214        // auto-loading when no explicit bundle was provided.
5215        #[cfg(feature = "embed-assets")]
5216        register_embedded_static_dir(embedded_static);
5217
5218        #[cfg(all(feature = "embed-assets", feature = "i18n"))]
5219        let i18n_bundle = embedded_i18n_bundle(i18n_bundle, embedded_locales, &config);
5220
5221        #[cfg(feature = "i18n")]
5222        let i18n_bundle =
5223            resolve_i18n_bundle(i18n_bundle, i18n_auto_load, &config, &crate::config::OsEnv);
5224
5225        fail_fast_on_invalid_session_config(&config, session_store.is_some());
5226        fail_fast_on_invalid_signing_secret(&config);
5227        fail_fast_on_missing_encryption_keys(&config);
5228        fail_fast_on_invalid_trusted_hosts(&config);
5229
5230        #[cfg(feature = "storage")]
5231        let storage_bootstrap = blob_store.map_or_else(
5232            || preflight_storage(&config),
5233            |store| {
5234                Some(StorageBootstrap {
5235                    store,
5236                    serving: None,
5237                })
5238            },
5239        );
5240
5241        #[cfg(feature = "db")]
5242        let database = setup_database(
5243            &config,
5244            migrations,
5245            pool_provider_factory,
5246            shard_provider_factory,
5247            shard_router,
5248            directory_shard_router,
5249            RepositoryCommitHookQueueMigrationMode::Runtime,
5250        )
5251        .await
5252        .unwrap_or_else(|error| {
5253            eprintln!("{error}");
5254            std::process::exit(1);
5255        });
5256        #[cfg(feature = "db")]
5257        let pool = database.topology;
5258        #[cfg(feature = "db")]
5259        let shards = database.shards;
5260        #[cfg(feature = "db")]
5261        let replica_readiness = database.replica_readiness;
5262        #[cfg(feature = "db")]
5263        let replica_migration_check = database.replica_migration_check;
5264
5265        let mut state = build_state(
5266            &config,
5267            #[cfg(feature = "db")]
5268            pool.as_ref(),
5269            #[cfg(feature = "db")]
5270            shards,
5271            #[cfg(feature = "ws")]
5272            channels_backend,
5273        );
5274        if let Some(buf) = telemetry_guard.log_buffer.clone() {
5275            state.insert_extension(buf);
5276        }
5277        // Wire the live-subscriber reload handle into the loggers actuator so
5278        // `PUT /actuator/loggers/{name}` affects the running subscriber, not
5279        // just an in-memory map (issue #1044).
5280        if let Some(handle) = telemetry_guard.filter_reload.clone() {
5281            state.log_levels().attach_reload_handle(handle);
5282        }
5283        #[cfg(feature = "mail")]
5284        if let Some(interceptor) = mail_interceptor {
5285            state.insert_extension(interceptor);
5286        }
5287        if let Some(interceptor) = job_interceptor {
5288            state.insert_extension(interceptor);
5289        }
5290        #[cfg(feature = "db")]
5291        if let Some(interceptor) = db_interceptor {
5292            state.insert_extension(interceptor);
5293        }
5294        #[cfg(feature = "ws")]
5295        if let Some(interceptor) = channels_interceptor {
5296            state.insert_extension(interceptor.clone());
5297            state.channels = crate::channels::Channels::with_shared_backend(std::sync::Arc::new(
5298                crate::channels::InterceptedChannelsBackend::new(
5299                    state.channels.backend().clone(),
5300                    vec![interceptor],
5301                ),
5302            ));
5303            #[cfg(feature = "presence")]
5304            {
5305                state.presence = crate::presence::Presence::new(state.channels.clone());
5306            }
5307        }
5308        #[cfg(feature = "oauth2")]
5309        if let Some(interceptor) = http_interceptor {
5310            state.insert_extension(interceptor);
5311        }
5312        #[cfg(feature = "db")]
5313        configure_replica_migration_check(&state, replica_migration_check);
5314        #[cfg(feature = "db")]
5315        apply_replica_migration_readiness(&state, replica_readiness);
5316        if let Some(cache) = cache_backend {
5317            crate::cache::set_global_cache(cache.clone());
5318            state.shared_cache = Some(cache);
5319        } else {
5320            crate::cache::clear_global_cache();
5321        }
5322
5323        for register in policy_registrations {
5324            register(state.policy_registry());
5325        }
5326
5327        #[cfg(feature = "mail")]
5328        if let Some(handle) = suppression_store {
5329            state.insert_extension(handle);
5330        }
5331        #[cfg(feature = "mail")]
5332        if let Some(handle) = mail_suppression_store {
5333            state.insert_extension(handle);
5334        }
5335        #[cfg(feature = "mail")]
5336        crate::mail::install_mailer_with_factory(
5337            &state,
5338            &config.mail,
5339            mail_delivery_queue_factory,
5340            true,
5341        )
5342        .unwrap_or_else(|error| {
5343            eprintln!("Failed to configure mailer: {error}");
5344            exit_stop_managed_pg();
5345            std::process::exit(1);
5346        });
5347
5348        if let Some(logger) = audit_logger {
5349            state.insert_extension::<crate::audit::AuditLogger>((*logger).clone());
5350        }
5351
5352        #[cfg(feature = "i18n")]
5353        let _custom_layers = install_i18n_bundle_layer(custom_layers, &state, i18n_bundle);
5354
5355        #[cfg(feature = "storage")]
5356        let _storage_router = storage_bootstrap.and_then(|bootstrap| bootstrap.install(&state));
5357        run_state_initializers(state_initializers, &state);
5358        finalize_event_bus(listeners, &mut jobs, &state);
5359
5360        let task_shutdown = tokio_util::sync::CancellationToken::new();
5361        if let Err(error) = initialize_job_runtime(jobs, &state, &task_shutdown, &config.jobs, true)
5362        {
5363            eprintln!("job runtime initialization failed: {error}");
5364            #[cfg(feature = "managed-pg")]
5365            crate::managed_pg::emergency_stop_async().await;
5366            std::process::exit(1);
5367        }
5368
5369        #[cfg(feature = "db")]
5370        {
5371            #[cfg(feature = "ws")]
5372            crate::repository_commit_hooks::set_global_channels(state.channels().clone());
5373        }
5374
5375        // Postgres-only durable commit-hook worker; not spawned under sqlite
5376        // (the runtime pool is a SQLite pool the Postgres worker cannot drive).
5377        #[cfg(all(feature = "db", not(feature = "sqlite")))]
5378        if let Some(pool) = state.pool().cloned() {
5379            #[cfg(feature = "ws")]
5380            {
5381                let channels = state.channels().clone();
5382                crate::repository_commit_hooks::start_repository_commit_hook_worker(
5383                    pool,
5384                    Some(channels),
5385                    task_shutdown.child_token(),
5386                );
5387            }
5388            #[cfg(not(feature = "ws"))]
5389            crate::repository_commit_hooks::start_repository_commit_hook_worker(
5390                pool,
5391                task_shutdown.child_token(),
5392            );
5393        }
5394        // Repositories built over a shard pool (`with_pool`) enqueue durable
5395        // commit hooks into that shard's queue table; drain each one too.
5396        #[cfg(all(feature = "db", not(feature = "sqlite")))]
5397        if let Some(shards) = state.shards() {
5398            for shard in shards.iter() {
5399                #[cfg(feature = "ws")]
5400                crate::repository_commit_hooks::start_repository_commit_hook_worker(
5401                    shard.primary_pool().clone(),
5402                    Some(state.channels().clone()),
5403                    task_shutdown.child_token(),
5404                );
5405                #[cfg(not(feature = "ws"))]
5406                crate::repository_commit_hooks::start_repository_commit_hook_worker(
5407                    shard.primary_pool().clone(),
5408                    task_shutdown.child_token(),
5409                );
5410            }
5411        }
5412        // SQLite durable commit-hook worker on the one-off task-runner path
5413        // (#1996 item 5); see the server-path spawn above for the rationale.
5414        #[cfg(all(feature = "db", feature = "sqlite"))]
5415        if let Some(pool) = state.pool().cloned() {
5416            #[cfg(feature = "ws")]
5417            {
5418                let channels = state.channels().clone();
5419                crate::repository_commit_hooks::start_repository_commit_hook_worker(
5420                    pool,
5421                    Some(channels),
5422                    task_shutdown.child_token(),
5423                );
5424            }
5425            #[cfg(not(feature = "ws"))]
5426            crate::repository_commit_hooks::start_repository_commit_hook_worker(
5427                pool,
5428                task_shutdown.child_token(),
5429            );
5430        }
5431
5432        if let Err(error) = run_startup_hooks(&startup_hooks, state.clone()).await {
5433            eprintln!("startup hook failed: {error}");
5434            task_shutdown.cancel();
5435            #[cfg(feature = "managed-pg")]
5436            crate::managed_pg::emergency_stop_async().await;
5437            std::process::exit(1);
5438        }
5439        state.probes().mark_startup_complete();
5440
5441        tracing::info!(task = %task_name, "Running one-off task");
5442        let span = tracing::info_span!("one_off_task", task = %task_name);
5443        #[cfg(feature = "oauth2")]
5444        let result = {
5445            use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
5446            let interceptors: Vec<std::sync::Arc<dyn HttpInterceptor>> = state
5447                .extension::<std::sync::Arc<dyn HttpInterceptor>>()
5448                .map(|interceptor_arc| vec![(*interceptor_arc).clone()])
5449                .unwrap_or_default();
5450            ACTIVE_HTTP_INTERCEPTORS
5451                .scope(
5452                    interceptors,
5453                    (task_handler)(state.clone(), args).instrument(span),
5454                )
5455                .await
5456        };
5457        #[cfg(not(feature = "oauth2"))]
5458        let result = (task_handler)(state.clone(), args).instrument(span).await;
5459
5460        task_shutdown.cancel();
5461        run_shutdown_hooks(&shutdown_hooks).await;
5462        // If the generated `pg.stop()` hook errored/timed out it keeps the
5463        // handle for a retry, but a one-off task then exits — so retry the stop
5464        // here (idempotent; a no-op once the hook stopped it cleanly) to avoid
5465        // orphaning the postmaster on the data dir/port.
5466        #[cfg(feature = "managed-pg")]
5467        crate::managed_pg::emergency_stop_async().await;
5468
5469        match result {
5470            Ok(()) => {
5471                tracing::info!(task = %task_name, "One-off task completed");
5472            }
5473            Err(error) => {
5474                tracing::error!(task = %task_name, error = %error, "One-off task failed");
5475                eprintln!("Task '{task_name}' failed: {error}");
5476                for cause in error.source_chain() {
5477                    eprintln!("Caused by: {cause}");
5478                }
5479                std::process::exit(1);
5480            }
5481        }
5482    }
5483}
5484
5485pub(crate) fn is_static_build_mode() -> bool {
5486    std::env::var("AUTUMN_BUILD_STATIC").as_deref() == Ok("1")
5487}
5488
5489/// Stop a managed Postgres child from a synchronous `process::exit` path in a
5490/// non-server entrypoint (static build, one-off task). Those modes don't run
5491/// `on_shutdown` before their failure exits, and `process::exit` skips `Drop`,
5492/// so a managed cluster started by `setup_database` would otherwise be orphaned
5493/// on the data dir/port.
5494///
5495/// These call sites run on a Tokio worker thread; the (blocking, own-runtime)
5496/// `emergency_stop` would panic if entered there, so run it on a fresh thread
5497/// with no ambient runtime. No-op unless the `managed-pg` feature is active.
5498// The body is empty without `managed-pg` (so it can't be `const` with it).
5499#[allow(clippy::missing_const_for_fn)]
5500fn exit_stop_managed_pg() {
5501    #[cfg(feature = "managed-pg")]
5502    {
5503        let _ = std::thread::spawn(crate::managed_pg::emergency_stop).join();
5504    }
5505}
5506
5507pub(crate) fn is_dump_routes_mode() -> bool {
5508    std::env::var("AUTUMN_DUMP_ROUTES").as_deref() == Ok("1")
5509}
5510
5511/// Whether the dump should also emit the resolved security configuration
5512/// ([`SECURITY_CONFIG_MARKER`](crate::route_listing::SECURITY_CONFIG_MARKER)).
5513///
5514/// Set by `autumn routes audit` (which needs the CSRF / headers config to build
5515/// the `declared` manifest dimensions) but not by the plain `autumn routes`
5516/// listing, so that command's stderr stays free of the marker line.
5517pub(crate) fn is_dump_security_mode() -> bool {
5518    std::env::var("AUTUMN_DUMP_SECURITY").as_deref() == Ok("1")
5519}
5520
5521pub(crate) fn is_dump_jobs_mode() -> bool {
5522    std::env::var("AUTUMN_DUMP_JOBS").as_deref() == Ok("1")
5523}
5524
5525pub(crate) fn is_list_one_off_tasks_mode() -> bool {
5526    std::env::var("AUTUMN_LIST_TASKS").as_deref() == Ok("1")
5527}
5528
5529/// Whether `AUTUMN_MIGRATE=1` requests the migrate-only one-shot: apply pending
5530/// embedded migrations and exit without starting the HTTP server. Set by
5531/// `autumn deploy`'s redeploy cutover (issue #1607) so migrations land before
5532/// traffic is flipped to the new release.
5533pub(crate) fn is_migrate_only_mode() -> bool {
5534    std::env::var("AUTUMN_MIGRATE").as_deref() == Ok("1")
5535}
5536
5537fn one_off_task_name_from_env() -> Option<String> {
5538    std::env::var("AUTUMN_RUN_TASK")
5539        .ok()
5540        .map(|value| value.trim().to_owned())
5541        .filter(|value| !value.is_empty())
5542}
5543
5544fn one_off_task_args_from_env() -> Result<Vec<String>, String> {
5545    match std::env::var("AUTUMN_TASK_ARGS_JSON") {
5546        Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw)
5547            .map_err(|error| format!("AUTUMN_TASK_ARGS_JSON must be a JSON string array: {error}")),
5548        _ => Ok(Vec::new()),
5549    }
5550}
5551
5552fn print_available_one_off_tasks(tasks: &[crate::task::OneOffTaskInfo]) {
5553    let listing = crate::task::list_one_off_tasks(tasks);
5554    if listing.is_empty() {
5555        eprintln!("No one-off tasks are registered. Add .one_off_tasks(one_off_tasks![...]).");
5556        return;
5557    }
5558
5559    eprintln!("Available tasks:");
5560    for task in listing {
5561        if task.description.is_empty() {
5562            eprintln!("  {}", task.name);
5563        } else {
5564            eprintln!("  {:<24} {}", task.name, task.description);
5565        }
5566    }
5567}
5568
5569/// Start scheduled tasks in background Tokio tasks.
5570///
5571/// Each task runs in its own spawned task with error logging.
5572/// Uses `tokio::time` for fixed-delay scheduling and `croner` for cron-based
5573/// scheduling. The `shutdown` token is used to stop cron loops gracefully when
5574/// the server receives a termination signal.
5575#[allow(clippy::cast_possible_truncation)]
5576#[allow(clippy::cognitive_complexity)]
5577#[allow(dead_code)]
5578fn start_task_scheduler(
5579    tasks: Vec<crate::task::TaskInfo>,
5580    state: &AppState,
5581    shutdown: &tokio_util::sync::CancellationToken,
5582) {
5583    if let Err(error) = start_task_scheduler_with_config(
5584        tasks,
5585        state,
5586        shutdown,
5587        &crate::config::SchedulerConfig::default(),
5588    ) {
5589        tracing::error!(error = %error, "scheduled task runtime initialization failed");
5590    }
5591}
5592
5593#[allow(clippy::cast_possible_truncation)]
5594#[allow(clippy::cognitive_complexity)]
5595fn start_task_scheduler_with_config(
5596    tasks: Vec<crate::task::TaskInfo>,
5597    state: &AppState,
5598    shutdown: &tokio_util::sync::CancellationToken,
5599    scheduler_config: &crate::config::SchedulerConfig,
5600) -> crate::AutumnResult<()> {
5601    tracing::info!(count = tasks.len(), "Starting scheduled tasks");
5602    let coordinator = crate::scheduler::coordinator_from_config(scheduler_config, state)?;
5603    let lease_ttl = std::time::Duration::from_secs(scheduler_config.lease_ttl_secs);
5604    for task_info in &tasks {
5605        let schedule_desc = task_info.schedule.to_string();
5606        tracing::info!(
5607            name = %task_info.name,
5608            schedule = %schedule_desc,
5609            coordination = %task_info.coordination,
5610            scheduler_backend = coordinator.backend(),
5611            replica_id = coordinator.replica_id(),
5612            lease_ttl_secs = scheduler_config.lease_ttl_secs,
5613            "Registered task"
5614        );
5615    }
5616
5617    let mut cron_tasks: Vec<CronTaskSpec> = Vec::new();
5618
5619    for task_info in tasks {
5620        let state = state.clone();
5621        let name = task_info.name.clone();
5622        let handler = task_info.handler;
5623        let coordination = task_info.coordination;
5624        let schedule_desc = task_info.schedule.to_string();
5625        state.task_registry.register_scheduled(
5626            &name,
5627            &schedule_desc,
5628            coordination,
5629            coordinator.backend(),
5630            coordinator.replica_id(),
5631        );
5632
5633        match task_info.schedule {
5634            crate::task::Schedule::FixedDelay(delay) => {
5635                let coordinator = Arc::clone(&coordinator);
5636                let shutdown = shutdown.child_token();
5637                tokio::spawn(async move {
5638                    loop {
5639                        state
5640                            .task_registry
5641                            .record_next_run_at(&name, &format_next_task_run_after(delay));
5642                        tokio::select! {
5643                            () = shutdown.cancelled() => break,
5644                            () = tokio::time::sleep(delay) => {
5645                                execute_fixed_delay_task(
5646                                    name.clone(),
5647                                    state.clone(),
5648                                    handler,
5649                                    delay,
5650                                    coordination,
5651                                    Arc::clone(&coordinator),
5652                                    lease_ttl,
5653                                )
5654                                .await;
5655                            }
5656                        }
5657                    }
5658                });
5659            }
5660            crate::task::Schedule::Cron {
5661                expression,
5662                timezone,
5663            } => {
5664                cron_tasks.push(CronTaskSpec {
5665                    name,
5666                    expression,
5667                    timezone,
5668                    coordination,
5669                    handler,
5670                });
5671            }
5672        }
5673    }
5674
5675    run_cron_scheduler(cron_tasks, state, shutdown, &coordinator, lease_ttl);
5676
5677    Ok(())
5678}
5679
5680#[allow(unused_variables, clippy::needless_pass_by_value)]
5681fn send_ws_sys_task_msg(
5682    state: &AppState,
5683    event: &str,
5684    name: &str,
5685    extra: Vec<(&str, serde_json::Value)>,
5686) {
5687    #[cfg(feature = "ws")]
5688    {
5689        // ⚡ Bolt Optimization:
5690        // Use serde_json::json! to avoid multiple String allocations (`.to_string()`)
5691        // and repetitive `Map::insert` calls for `sys:tasks` websocket messages.
5692        let mut msg = serde_json::json!({
5693            "event": event,
5694            "task": name,
5695            "timestamp": chrono::Utc::now().to_rfc3339(),
5696        });
5697        if let Some(map) = msg.as_object_mut() {
5698            for (k, v) in extra {
5699                map.insert(k.to_string(), v);
5700            }
5701        }
5702        let _ = state.channels().sender("sys:tasks").send(msg.to_string());
5703    }
5704}
5705
5706async fn execute_task_result(
5707    state: &AppState,
5708    handler: crate::task::TaskHandler,
5709    start: std::time::Instant,
5710    name: &str,
5711    schedule: &'static str,
5712) -> Result<u64, (u64, String)> {
5713    // A fresh span per run so OTLP-enabled deployments see each invocation
5714    // as its own trace rather than inheriting whatever was current on the
5715    // scheduler thread.
5716    let task_span = tracing::info_span!(
5717        parent: None,
5718        "scheduled_task",
5719        otel.kind = "internal",
5720        task = %name,
5721        schedule = schedule,
5722    );
5723    let future = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5724        (handler)(state.clone()).instrument(task_span)
5725    })) {
5726        Ok(future) => future,
5727        Err(panic) => {
5728            let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
5729            return Err((duration_ms, format_scheduled_task_panic(panic.as_ref())));
5730        }
5731    };
5732    let result = std::panic::AssertUnwindSafe(future).catch_unwind().await;
5733    let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
5734
5735    match result {
5736        Ok(Ok(())) => Ok(duration_ms),
5737        Ok(Err(e)) => Err((duration_ms, e.to_string())),
5738        Err(panic) => Err((duration_ms, format_scheduled_task_panic(panic.as_ref()))),
5739    }
5740}
5741
5742fn format_scheduled_task_panic(panic: &(dyn Any + Send)) -> String {
5743    let detail = panic
5744        .downcast_ref::<String>()
5745        .map(String::as_str)
5746        .or_else(|| panic.downcast_ref::<&'static str>().copied())
5747        .unwrap_or("non-string panic payload");
5748    format!("scheduled task handler panicked: {detail}")
5749}
5750
5751async fn execute_task_result_with_optional_lease_ttl(
5752    state: &AppState,
5753    handler: crate::task::TaskHandler,
5754    start: std::time::Instant,
5755    name: &str,
5756    schedule: &'static str,
5757    lease_ttl: Option<std::time::Duration>,
5758) -> Result<u64, (u64, String)> {
5759    let Some(lease_ttl) = lease_ttl else {
5760        return execute_task_result(state, handler, start, name, schedule).await;
5761    };
5762
5763    tokio::time::timeout(
5764        lease_ttl,
5765        execute_task_result(state, handler, start, name, schedule),
5766    )
5767    .await
5768    .unwrap_or_else(|_| {
5769        let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
5770        Err((
5771            duration_ms,
5772            format!(
5773                "scheduled task exceeded lease TTL of {}s",
5774                lease_ttl.as_secs()
5775            ),
5776        ))
5777    })
5778}
5779
5780/// Handle the execution of a single fixed-delay task.
5781#[allow(clippy::cognitive_complexity)]
5782async fn execute_fixed_delay_task(
5783    name: String,
5784    state: AppState,
5785    handler: crate::task::TaskHandler,
5786    delay: std::time::Duration,
5787    coordination: crate::task::TaskCoordination,
5788    coordinator: Arc<dyn crate::scheduler::SchedulerCoordinator>,
5789    lease_ttl: std::time::Duration,
5790) {
5791    let tick_key = crate::scheduler::fixed_delay_tick_key(
5792        &name,
5793        delay,
5794        crate::time::clock_unix_duration(state.clock()),
5795    );
5796    let lease = match coordinator
5797        .try_acquire(&name, &tick_key, coordination)
5798        .await
5799    {
5800        Ok(Some(lease)) => lease,
5801        Ok(None) => {
5802            tracing::debug!(task = %name, tick = %tick_key, "Scheduled task tick already claimed");
5803            return;
5804        }
5805        Err(error) => {
5806            tracing::warn!(task = %name, tick = %tick_key, error = %error, "Failed to acquire scheduled task lease");
5807            return;
5808        }
5809    };
5810    state
5811        .task_registry
5812        .record_leader(&name, lease.leader_id(), &tick_key);
5813    tracing::debug!(task = %name, "Running scheduled task");
5814    state.task_registry.record_start(&name);
5815
5816    send_ws_sys_task_msg(&state, "started", &name, vec![]);
5817
5818    let start = std::time::Instant::now();
5819    let lease_ttl = lease_ttl_for_run(&lease, coordination, lease_ttl);
5820    match execute_task_result_with_optional_lease_ttl(
5821        &state,
5822        handler,
5823        start,
5824        &name,
5825        "fixed_delay",
5826        lease_ttl,
5827    )
5828    .await
5829    {
5830        Ok(duration_ms) => {
5831            state.task_registry.record_success(&name, duration_ms);
5832            crate::alerts::notify_scheduled_task_recovered(&state, &name);
5833            tracing::debug!(task = %name, "Task completed");
5834            send_ws_sys_task_msg(
5835                &state,
5836                "success",
5837                &name,
5838                vec![("duration_ms", serde_json::json!(duration_ms))],
5839            );
5840        }
5841        Err((duration_ms, error_str)) => {
5842            state
5843                .task_registry
5844                .record_failure(&name, duration_ms, &error_str);
5845            crate::alerts::notify_scheduled_task_failure(&state, &name, &error_str);
5846            tracing::warn!(task = %name, error = %error_str, "Task failed");
5847            send_ws_sys_task_msg(
5848                &state,
5849                "failure",
5850                &name,
5851                vec![
5852                    ("duration_ms", serde_json::json!(duration_ms)),
5853                    ("error", serde_json::json!(error_str)),
5854                ],
5855            );
5856        }
5857    }
5858
5859    if let Err(error) = lease.release().await {
5860        tracing::warn!(task = %name, tick = %tick_key, error = %error, "Failed to release scheduled task lease");
5861    }
5862}
5863
5864/// Handle the execution of a single cron task.
5865#[allow(clippy::cognitive_complexity)]
5866async fn execute_cron_task(
5867    name: String,
5868    state: AppState,
5869    handler: crate::task::TaskHandler,
5870    coordination: crate::task::TaskCoordination,
5871    coordinator: Arc<dyn crate::scheduler::SchedulerCoordinator>,
5872    lease_ttl: std::time::Duration,
5873    scheduled_unix_secs: u64,
5874) {
5875    let tick_key = crate::scheduler::cron_tick_key(&name, scheduled_unix_secs);
5876    let lease = match coordinator
5877        .try_acquire(&name, &tick_key, coordination)
5878        .await
5879    {
5880        Ok(Some(lease)) => lease,
5881        Ok(None) => {
5882            tracing::debug!(task = %name, tick = %tick_key, "Cron task tick already claimed");
5883            return;
5884        }
5885        Err(error) => {
5886            tracing::warn!(task = %name, tick = %tick_key, error = %error, "Failed to acquire cron task lease");
5887            return;
5888        }
5889    };
5890    state
5891        .task_registry
5892        .record_leader(&name, lease.leader_id(), &tick_key);
5893    tracing::debug!(task = %name, "Running cron task");
5894    state.task_registry.record_start(&name);
5895
5896    send_ws_sys_task_msg(&state, "started", &name, vec![]);
5897
5898    let start = std::time::Instant::now();
5899    let lease_ttl = lease_ttl_for_run(&lease, coordination, lease_ttl);
5900    match execute_task_result_with_optional_lease_ttl(
5901        &state, handler, start, &name, "cron", lease_ttl,
5902    )
5903    .await
5904    {
5905        Ok(duration_ms) => {
5906            state.task_registry.record_success(&name, duration_ms);
5907            crate::alerts::notify_scheduled_task_recovered(&state, &name);
5908            tracing::debug!(task = %name, "Cron task completed");
5909            send_ws_sys_task_msg(
5910                &state,
5911                "success",
5912                &name,
5913                vec![("duration_ms", serde_json::json!(duration_ms))],
5914            );
5915        }
5916        Err((duration_ms, error_str)) => {
5917            state
5918                .task_registry
5919                .record_failure(&name, duration_ms, &error_str);
5920            crate::alerts::notify_scheduled_task_failure(&state, &name, &error_str);
5921            tracing::warn!(task = %name, error = %error_str, "Cron task failed");
5922            send_ws_sys_task_msg(
5923                &state,
5924                "failure",
5925                &name,
5926                vec![
5927                    ("duration_ms", serde_json::json!(duration_ms)),
5928                    ("error", serde_json::json!(error_str)),
5929                ],
5930            );
5931        }
5932    }
5933
5934    if let Err(error) = lease.release().await {
5935        tracing::warn!(task = %name, tick = %tick_key, error = %error, "Failed to release cron task lease");
5936    }
5937}
5938
5939struct CronTaskSpec {
5940    name: String,
5941    expression: String,
5942    timezone: Option<String>,
5943    coordination: crate::task::TaskCoordination,
5944    handler: crate::task::TaskHandler,
5945}
5946
5947fn lease_ttl_for_run(
5948    lease: &crate::scheduler::SchedulerLease,
5949    coordination: crate::task::TaskCoordination,
5950    lease_ttl: std::time::Duration,
5951) -> Option<std::time::Duration> {
5952    (coordination == crate::task::TaskCoordination::Fleet && lease.backend() == "postgres")
5953        .then_some(lease_ttl)
5954}
5955
5956fn run_cron_scheduler(
5957    tasks: Vec<CronTaskSpec>,
5958    state: &AppState,
5959    shutdown: &tokio_util::sync::CancellationToken,
5960    coordinator: &Arc<dyn crate::scheduler::SchedulerCoordinator>,
5961    lease_ttl: std::time::Duration,
5962) {
5963    if tasks.is_empty() {
5964        return;
5965    }
5966
5967    tracing::info!(count = tasks.len(), "Cron scheduler started");
5968    for task in tasks {
5969        let state = state.clone();
5970        let coordinator = Arc::clone(coordinator);
5971        let shutdown = shutdown.child_token();
5972        tokio::spawn(async move {
5973            run_cron_task_loop(task, state, shutdown, coordinator, lease_ttl).await;
5974        });
5975    }
5976}
5977
5978#[allow(clippy::cognitive_complexity)]
5979async fn run_cron_task_loop(
5980    task: CronTaskSpec,
5981    state: AppState,
5982    shutdown: tokio_util::sync::CancellationToken,
5983    coordinator: Arc<dyn crate::scheduler::SchedulerCoordinator>,
5984    lease_ttl: std::time::Duration,
5985) {
5986    let CronTaskSpec {
5987        name,
5988        expression,
5989        timezone,
5990        coordination,
5991        handler,
5992    } = task;
5993
5994    let cron = match expression.parse::<croner::Cron>() {
5995        Ok(cron) => cron,
5996        Err(error) => {
5997            tracing::error!(task = %name, expression = %expression, error = %error, "Failed to create cron job");
5998            return;
5999        }
6000    };
6001    let timezone = timezone
6002        .as_deref()
6003        .and_then(|timezone| {
6004            timezone.parse::<chrono_tz::Tz>().map_or_else(
6005                |_| {
6006                    tracing::warn!(task = %name, timezone = %timezone, "Unrecognized timezone; falling back to UTC");
6007                    None
6008                },
6009                Some,
6010            )
6011        })
6012        .unwrap_or(chrono_tz::UTC);
6013    let mut cursor = chrono::Utc::now().with_timezone(&timezone);
6014
6015    loop {
6016        let now = chrono::Utc::now().with_timezone(&timezone);
6017        let scheduled_at = match next_cron_occurrence_after(&cron, &cursor, &now) {
6018            Ok(scheduled_at) => scheduled_at,
6019            Err(error) => {
6020                tracing::error!(task = %name, expression = %expression, error = %error, "Failed to compute next cron tick");
6021                return;
6022            }
6023        };
6024        state.task_registry.record_next_run_at(
6025            &name,
6026            &scheduled_at.with_timezone(&chrono::Utc).to_rfc3339(),
6027        );
6028        let sleep_for = cron_sleep_duration_until(&scheduled_at);
6029        tokio::select! {
6030            () = shutdown.cancelled() => break,
6031            () = tokio::time::sleep(sleep_for) => {
6032                let woke_at = chrono::Utc::now().with_timezone(&timezone);
6033                match cron_occurrence_is_overdue(&cron, &scheduled_at, &woke_at) {
6034                    Ok(true) => {
6035                        tracing::warn!(
6036                            task = %name,
6037                            scheduled_at = %scheduled_at,
6038                            woke_at = %woke_at,
6039                            "Skipping overdue cron task tick"
6040                        );
6041                        cursor = woke_at;
6042                        continue;
6043                    }
6044                    Ok(false) => {}
6045                    Err(error) => {
6046                        tracing::error!(task = %name, expression = %expression, error = %error, "Failed to evaluate cron tick lateness");
6047                        return;
6048                    }
6049                }
6050                let scheduled_unix_secs = u64::try_from(scheduled_at.timestamp()).unwrap_or_default();
6051                tokio::spawn(execute_cron_task(
6052                    name.clone(),
6053                    state.clone(),
6054                    handler,
6055                    coordination,
6056                    Arc::clone(&coordinator),
6057                    lease_ttl,
6058                    scheduled_unix_secs,
6059                ));
6060                cursor = scheduled_at;
6061            }
6062        }
6063    }
6064}
6065
6066fn format_next_task_run_after(delay: std::time::Duration) -> String {
6067    let now = chrono::Utc::now();
6068    let Ok(delay) = chrono::TimeDelta::from_std(delay) else {
6069        return now.to_rfc3339();
6070    };
6071    (now + delay).to_rfc3339()
6072}
6073
6074fn next_cron_occurrence_after<Tz: chrono::TimeZone>(
6075    cron: &croner::Cron,
6076    cursor: &chrono::DateTime<Tz>,
6077    now: &chrono::DateTime<Tz>,
6078) -> Result<chrono::DateTime<Tz>, croner::errors::CronError> {
6079    let anchor = if cursor < now { now } else { cursor };
6080    cron.find_next_occurrence(anchor, false)
6081}
6082
6083fn cron_occurrence_is_overdue<Tz: chrono::TimeZone>(
6084    cron: &croner::Cron,
6085    scheduled_at: &chrono::DateTime<Tz>,
6086    now: &chrono::DateTime<Tz>,
6087) -> Result<bool, croner::errors::CronError> {
6088    let next_after_scheduled = cron.find_next_occurrence(scheduled_at, false)?;
6089    Ok(&next_after_scheduled <= now)
6090}
6091
6092fn cron_sleep_duration_until<Tz: chrono::TimeZone>(
6093    scheduled_at: &chrono::DateTime<Tz>,
6094) -> std::time::Duration {
6095    scheduled_at
6096        .with_timezone(&chrono::Utc)
6097        .signed_duration_since(chrono::Utc::now())
6098        .to_std()
6099        .unwrap_or_default()
6100}
6101
6102async fn run_startup_hooks(hooks: &[StartupHook], state: AppState) -> crate::AutumnResult<()> {
6103    for hook in hooks {
6104        hook(state.clone()).await?;
6105    }
6106    Ok(())
6107}
6108
6109fn run_state_initializers(initializers: Vec<StateInitializer>, state: &AppState) {
6110    for initializer in initializers {
6111        initializer(state);
6112    }
6113}
6114
6115/// Wire the typed event bus into the app at build time.
6116///
6117/// Builds the [`EventRegistry`](crate::events::EventRegistry) from registered
6118/// listeners, installs it onto `state` for the [`Events`](crate::events::Events)
6119/// extractor, appends a job per durable listener so they ride the job runtime
6120/// (retry + DLQ + restart-safety), and initializes the process-global bus used
6121/// by the module-level `events::publish`.
6122/// Build the [`EventRegistry`](crate::events::EventRegistry) from `listeners` and
6123/// append the synthesized `default`-queue [`JobInfo`](crate::job::JobInfo) for
6124/// each durable listener to `jobs`, returning the registry.
6125///
6126/// This is the pure, DB-free half of [`finalize_event_bus`]: it needs no live
6127/// `AppState` or database, only the listener set. Both the boot path (through
6128/// `finalize_event_bus`, which additionally wires the global bus onto live state)
6129/// and the deliberately DB-free `AUTUMN_DUMP_JOBS=1` dump path
6130/// ([`dump_jobs_manifest`]) funnel through here, so the emitted manifest can
6131/// never omit the durable-listener jobs the runtime actually drains.
6132fn synthesize_durable_listener_jobs(
6133    listeners: Vec<crate::events::ListenerInfo>,
6134    jobs: &mut Vec<crate::job::JobInfo>,
6135) -> crate::events::EventRegistry {
6136    let registry = crate::events::EventRegistry::from_listeners(listeners);
6137    jobs.extend(registry.durable_job_infos());
6138    registry
6139}
6140
6141fn finalize_event_bus(
6142    listeners: Vec<crate::events::ListenerInfo>,
6143    jobs: &mut Vec<crate::job::JobInfo>,
6144    state: &AppState,
6145) {
6146    let registry = synthesize_durable_listener_jobs(listeners, jobs);
6147    state.insert_extension(registry.clone());
6148    crate::events::init_global_event_bus(&registry, state, None);
6149}
6150
6151/// Compute the `AUTUMN_DUMP_JOBS=1` jobs manifest for the dump path.
6152///
6153/// Mirrors the boot path's job set: the builder's registered `jobs` PLUS the
6154/// synthesized `default`-queue jobs that [`finalize_event_bus`] appends for
6155/// durable listeners before the runtime starts. Without folding those in, an app
6156/// that registers a durable listener and configures `[jobs.queues]` without
6157/// `default` would emit a manifest omitting `default`, letting a topology-aware
6158/// `autumn doctor` accept a fleet where no tier drains the durable-listener jobs.
6159///
6160/// Only the pure job-synthesis half of `finalize_event_bus` runs here
6161/// (via [`synthesize_durable_listener_jobs`]); the dump path is deliberately
6162/// DB-free, so the live-state/global-bus wiring is skipped. Factored out so the
6163/// boot and dump paths share one job-preparation seam and so it is unit testable
6164/// without the process-exiting dump entrypoint.
6165fn dump_jobs_manifest(
6166    cfg: &crate::config::JobQueuesConfig,
6167    mut jobs: Vec<crate::job::JobInfo>,
6168    listeners: Vec<crate::events::ListenerInfo>,
6169) -> String {
6170    synthesize_durable_listener_jobs(listeners, &mut jobs);
6171    crate::job::render_jobs_manifest(cfg, &jobs)
6172}
6173
6174fn initialize_job_runtime(
6175    jobs: Vec<crate::job::JobInfo>,
6176    state: &AppState,
6177    shutdown: &tokio_util::sync::CancellationToken,
6178    config: &crate::config::JobConfig,
6179    run_workers: bool,
6180) -> crate::AutumnResult<()> {
6181    crate::job::clear_global_job_client();
6182    if jobs.is_empty() {
6183        Ok(())
6184    } else {
6185        crate::job::start_runtime(jobs, state, shutdown, config, run_workers)
6186    }
6187}
6188
6189/// A bound network listener for the server, abstracting over the transport.
6190///
6191/// `run()` binds one of these based on `config.server.unix_socket`: a TCP
6192/// listener on `host:port` (the default) or a Unix domain socket (local
6193/// daemon mode). The two carry different connect-info types, so the serve
6194/// task is spawned per-variant.
6195enum BoundListener {
6196    /// TCP listener on `host:port`.
6197    Tcp(tokio::net::TcpListener),
6198    /// Unix domain socket listener (local daemon transport).
6199    #[cfg(unix)]
6200    Unix(tokio::net::UnixListener),
6201    /// TLS-terminating listener on `host:port` (direct HTTPS, issue #1603).
6202    #[cfg(feature = "tls")]
6203    Tls(crate::tls::TlsListener),
6204}
6205
6206/// Current UNIX time in seconds, saturating on the (impossible) pre-epoch case.
6207#[cfg(feature = "tls")]
6208fn now_unix() -> i64 {
6209    std::time::SystemTime::now()
6210        .duration_since(std::time::UNIX_EPOCH)
6211        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
6212}
6213
6214/// State carried from the TLS bind path to the background reload task.
6215#[cfg(feature = "tls")]
6216struct TlsReloadState {
6217    resolver: std::sync::Arc<crate::tls::ReloadableCertResolver>,
6218    provider: std::sync::Arc<rustls::crypto::CryptoProvider>,
6219    cert_path: std::path::PathBuf,
6220    key_path: std::path::PathBuf,
6221    interval: std::time::Duration,
6222}
6223
6224/// Bind a TLS-terminating listener over `tcp`, loading and validating the
6225/// configured certificate and key (fail-fast on any problem).
6226#[cfg(feature = "tls")]
6227fn build_tls_listener(
6228    tcp: tokio::net::TcpListener,
6229    cfg: &crate::config::TlsConfig,
6230    shutdown: tokio_util::sync::CancellationToken,
6231) -> Result<(crate::tls::TlsListener, TlsReloadState), crate::tls::TlsError> {
6232    let provider = crate::tls::crypto_provider();
6233    // The pre-bind `TlsConfig::validate()` guarantees both paths are set in
6234    // static-cert mode (the only mode that reaches this function; ACME mode is
6235    // handled separately), so unwrapping here is validated, not hopeful.
6236    let cert_path = cfg
6237        .cert_path
6238        .as_deref()
6239        .expect("validated: static [server.tls] sets cert_path");
6240    let key_path = cfg
6241        .key_path
6242        .as_deref()
6243        .expect("validated: static [server.tls] sets key_path");
6244    let certified = crate::tls::load_certified_key(cert_path, key_path, &provider, now_unix())?;
6245    let resolver = std::sync::Arc::new(crate::tls::ReloadableCertResolver::new(certified));
6246    let server_config = crate::tls::build_server_config(
6247        std::sync::Arc::clone(&provider),
6248        std::sync::Arc::clone(&resolver),
6249    )?;
6250    // A zero handshake timeout would drop every connection instantly; clamp to
6251    // at least one second, mirroring the reload-interval clamp below.
6252    let handshake_timeout = std::time::Duration::from_secs(cfg.handshake_timeout_secs.max(1));
6253    let listener = crate::tls::TlsListener::new(tcp, server_config, handshake_timeout, shutdown);
6254    let reload = TlsReloadState {
6255        resolver,
6256        provider,
6257        cert_path: cert_path.to_path_buf(),
6258        key_path: key_path.to_path_buf(),
6259        // A zero interval would busy-loop; clamp to at least one second.
6260        interval: std::time::Duration::from_secs(cfg.reload_interval_secs.max(1)),
6261    };
6262    Ok((listener, reload))
6263}
6264
6265/// Carries the ACME challenge-listener + renewal-task wiring from the bind path
6266/// to the sibling tasks spawned once `server_shutdown` exists.
6267#[cfg(feature = "acme")]
6268struct AcmeBindState {
6269    renewal_task: crate::acme::renewal::AcmeRenewalTask,
6270    tokens: crate::acme::challenge::Http01Tokens,
6271    http_challenge_port: u16,
6272    https_port: u16,
6273}
6274
6275/// Build a TLS listener for ACME mode: serve a stored certificate if one is
6276/// present, else a self-signed placeholder so `:443` binds immediately. The
6277/// returned [`AcmeBindState`] carries everything the renewal task and challenge
6278/// listener need.
6279#[cfg(feature = "acme")]
6280async fn build_acme_tls_listener(
6281    tcp: tokio::net::TcpListener,
6282    tls_cfg: &crate::config::TlsConfig,
6283    acme_cfg: &crate::config::AcmeConfig,
6284    https_port: u16,
6285    status: Option<crate::acme::renewal::AcmeStatus>,
6286    shutdown: tokio_util::sync::CancellationToken,
6287) -> Result<(crate::tls::TlsListener, AcmeBindState), String> {
6288    use crate::acme::store::{AcmeStore, CertId, FsAcmeStore};
6289
6290    let provider = crate::tls::crypto_provider();
6291    let cert_id = CertId::from_domains(&acme_cfg.domains);
6292    let directory_label = crate::acme::directory_label(&acme_cfg.directory);
6293    let store: std::sync::Arc<dyn AcmeStore> = std::sync::Arc::new(FsAcmeStore::new(
6294        acme_cfg.cache_dir.clone(),
6295        directory_label,
6296    ));
6297    let status = status.unwrap_or_default();
6298
6299    // Prefer a valid stored certificate; fall back to a self-signed placeholder
6300    // so the port comes up while the first issuance runs in the background.
6301    let (initial, serving_stored_cert) = match store.load_cert(&cert_id).await {
6302        Ok(Some(stored)) => match crate::tls::certified_key_from_pem(
6303            stored.chain_pem.as_bytes(),
6304            stored.key_pem.as_bytes(),
6305            &provider,
6306        ) {
6307            Ok(ck) => {
6308                if let Ok(not_after) =
6309                    crate::tls::leaf_not_after_from_pem(stored.chain_pem.as_bytes())
6310                {
6311                    status.set_cert_not_after(not_after);
6312                }
6313                (ck, true)
6314            }
6315            Err(e) => {
6316                tracing::warn!(
6317                    "stored ACME certificate is unusable ({e}); serving a self-signed \
6318                     placeholder until the renewal task issues a real one"
6319                );
6320                (acme_placeholder_key(&acme_cfg.domains, &provider)?, false)
6321            }
6322        },
6323        Ok(None) => (acme_placeholder_key(&acme_cfg.domains, &provider)?, false),
6324        Err(e) => {
6325            tracing::warn!(
6326                "failed to read the stored ACME certificate ({e}); serving a self-signed \
6327                 placeholder"
6328            );
6329            (acme_placeholder_key(&acme_cfg.domains, &provider)?, false)
6330        }
6331    };
6332
6333    let resolver = std::sync::Arc::new(crate::tls::ReloadableCertResolver::new(initial));
6334    let server_config = crate::tls::build_server_config(
6335        std::sync::Arc::clone(&provider),
6336        std::sync::Arc::clone(&resolver),
6337    )
6338    .map_err(|e| e.to_string())?;
6339    let handshake_timeout = std::time::Duration::from_secs(tls_cfg.handshake_timeout_secs.max(1));
6340    let listener = crate::tls::TlsListener::new(tcp, server_config, handshake_timeout, shutdown);
6341
6342    let tokens = crate::acme::challenge::Http01Tokens::new();
6343    let renewal_task = crate::acme::renewal::AcmeRenewalTask {
6344        resolver,
6345        provider,
6346        store,
6347        cert_id,
6348        tokens: tokens.clone(),
6349        status,
6350        config: acme_cfg.clone(),
6351        serving_stored_cert,
6352        // Filled in at the renewal spawn site once the scheduler coordinator has
6353        // been built and any distributed → in-process fallback is known.
6354        leadership_degraded: false,
6355        renew_window_misconfigured: std::sync::atomic::AtomicBool::new(false),
6356    };
6357    Ok((
6358        listener,
6359        AcmeBindState {
6360            renewal_task,
6361            tokens,
6362            http_challenge_port: acme_cfg.http_challenge_port,
6363            https_port,
6364        },
6365    ))
6366}
6367
6368/// Build a `CertifiedKey` from a fresh self-signed placeholder for `domains`.
6369#[cfg(feature = "acme")]
6370fn acme_placeholder_key(
6371    domains: &[String],
6372    provider: &rustls::crypto::CryptoProvider,
6373) -> Result<std::sync::Arc<rustls::sign::CertifiedKey>, String> {
6374    let placeholder = crate::acme::renewal::self_signed_placeholder(domains)?;
6375    crate::tls::certified_key_from_pem(
6376        placeholder.chain_pem.as_bytes(),
6377        placeholder.key_pem.as_bytes(),
6378        provider,
6379    )
6380}
6381
6382/// Build the ACME renewal failure reporter: dispatch each failure as an
6383/// [`ErrorEvent`](crate::reporting::ErrorEvent) to the registered reporter chain
6384/// on a detached task, so failures reach Sentry/etc. when configured (failures
6385/// also always log via `tracing` inside the loop).
6386#[cfg(all(feature = "acme", feature = "reporting"))]
6387fn make_acme_reporter(
6388    reporters: Vec<std::sync::Arc<dyn crate::reporting::ErrorReporter>>,
6389) -> crate::acme::renewal::ReporterFn {
6390    std::sync::Arc::new(move |message: String| {
6391        if reporters.is_empty() {
6392            return;
6393        }
6394        let reporters = reporters.clone();
6395        tokio::spawn(async move {
6396            let event = crate::reporting::ErrorEvent {
6397                status: axum::http::StatusCode::INTERNAL_SERVER_ERROR,
6398                message,
6399                problem_type: None,
6400                request_id: None,
6401                route: Some("acme-renewal".to_owned()),
6402                method: None,
6403                panic: None,
6404            };
6405            for reporter in &reporters {
6406                reporter.report(&event).await;
6407            }
6408        });
6409    })
6410}
6411
6412/// The no-op ACME reporter used when the `reporting` feature is off (failures
6413/// still log via `tracing`).
6414#[cfg(all(feature = "acme", not(feature = "reporting")))]
6415fn make_acme_reporter() -> crate::acme::renewal::ReporterFn {
6416    std::sync::Arc::new(|_message: String| {})
6417}
6418
6419/// Modification times of the cert and key files, `None` for a file that could
6420/// not be stat'd. Reloads trigger on any change to this pair.
6421#[cfg(feature = "tls")]
6422fn tls_file_mtimes(
6423    cert: &std::path::Path,
6424    key: &std::path::Path,
6425) -> (Option<std::time::SystemTime>, Option<std::time::SystemTime>) {
6426    let mtime = |p: &std::path::Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
6427    (mtime(cert), mtime(key))
6428}
6429
6430/// Background task: poll the cert/key file mtimes and hot-swap the served
6431/// certificate on change. Never breaks the listener — a failed reload keeps the
6432/// previously loaded certificate and retries on the next tick.
6433#[cfg(feature = "tls")]
6434async fn run_tls_cert_reload(state: TlsReloadState, shutdown: tokio_util::sync::CancellationToken) {
6435    // Stat and PEM-read the cert/key on a blocking thread — both touch the
6436    // filesystem and must not run on a tokio worker. On a `JoinError` (the
6437    // blocking pool shutting down) just skip the tick and retry next time.
6438    let stat_mtimes = |cert: std::path::PathBuf, key: std::path::PathBuf| {
6439        tokio::task::spawn_blocking(move || tls_file_mtimes(&cert, &key))
6440    };
6441
6442    let mut last = match stat_mtimes(state.cert_path.clone(), state.key_path.clone()).await {
6443        Ok(mtimes) => mtimes,
6444        Err(e) => {
6445            tracing::warn!(error = %e, "TLS reload: initial mtime read failed; assuming unknown");
6446            (None, None)
6447        }
6448    };
6449    loop {
6450        tokio::select! {
6451            () = tokio::time::sleep(state.interval) => {}
6452            () = shutdown.cancelled() => break,
6453        }
6454
6455        let current = match stat_mtimes(state.cert_path.clone(), state.key_path.clone()).await {
6456            Ok(mtimes) => mtimes,
6457            Err(e) => {
6458                tracing::warn!(error = %e, "TLS reload: mtime read task failed; skipping tick");
6459                continue;
6460            }
6461        };
6462        if current == last {
6463            continue;
6464        }
6465
6466        let cert_path = state.cert_path.clone();
6467        let key_path = state.key_path.clone();
6468        let provider = std::sync::Arc::clone(&state.provider);
6469        let loaded = tokio::task::spawn_blocking(move || {
6470            crate::tls::load_certified_key(&cert_path, &key_path, &provider, now_unix())
6471        })
6472        .await;
6473        let loaded = match loaded {
6474            Ok(result) => result,
6475            Err(e) => {
6476                tracing::warn!(error = %e, "TLS reload: load task failed; skipping tick");
6477                continue;
6478            }
6479        };
6480
6481        match loaded {
6482            Ok(next) => {
6483                state.resolver.store(next);
6484                // Only advance the baseline on a successful load, so a partial
6485                // write observed mid-renewal is retried on the next tick.
6486                last = current;
6487                tracing::info!(
6488                    cert = %state.cert_path.display(),
6489                    "Reloaded TLS certificate after detecting a change on disk"
6490                );
6491            }
6492            Err(e) => {
6493                tracing::error!(
6494                    error = %e,
6495                    cert = %state.cert_path.display(),
6496                    "TLS certificate reload failed; keeping the previously loaded certificate"
6497                );
6498            }
6499        }
6500    }
6501}
6502
6503/// Connection info for a Unix-domain-socket request.
6504///
6505/// axum's `into_make_service_with_connect_info::<C>` requires `C:
6506/// Connected<IncomingStream>`. Unlike TCP there is no peer `SocketAddr` for a
6507/// Unix socket, so this carries no data — it exists purely to satisfy the
6508/// connect-info bound on the UDS serve path.
6509#[cfg(unix)]
6510#[derive(Clone, Debug)]
6511struct UdsConnectInfo;
6512
6513#[cfg(unix)]
6514impl
6515    axum::extract::connect_info::Connected<
6516        axum::serve::IncomingStream<'_, tokio::net::UnixListener>,
6517    > for UdsConnectInfo
6518{
6519    fn connect_info(_stream: axum::serve::IncomingStream<'_, tokio::net::UnixListener>) -> Self {
6520        Self
6521    }
6522}
6523
6524/// Stamp a loopback peer (`127.0.0.1`) on Unix-domain-socket requests.
6525///
6526/// A UDS connection has no TCP peer `SocketAddr`, so without this the
6527/// trusted-proxy resolver and the [`ClientAddr`](crate::extract::ClientAddr)
6528/// extractor resolve no client address — breaking any route or middleware that
6529/// requires `ClientAddr` and any IP-based maintenance/rate-limit behavior. Local
6530/// daemon requests are loopback-equivalent, so present them as a `127.0.0.1`
6531/// connection (matching how an equivalent localhost TCP request is treated).
6532/// Installed before `TrustedProxiesLayer` on the UDS serve path only.
6533#[cfg(unix)]
6534async fn stamp_loopback_connect_info(
6535    mut req: axum::extract::Request,
6536    next: axum::middleware::Next,
6537) -> axum::response::Response {
6538    if req
6539        .extensions()
6540        .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
6541        .is_none()
6542    {
6543        let loopback =
6544            std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 0);
6545        req.extensions_mut()
6546            .insert(axum::extract::ConnectInfo(loopback));
6547    }
6548    next.run(req).await
6549}
6550
6551/// Signal `autumn serve --daemon`'s supervisor that startup is complete.
6552///
6553/// The CLI passes a path via `AUTUMN_SERVE_READY_FILE` and polls for it; we
6554/// create it here, immediately after [`mark_startup_complete`], so the
6555/// supervisor's notion of "ready" means the socket is bound and serving *and*
6556/// startup hooks/migrations have finished — with no dependence on the app's HTTP
6557/// middleware (the startup barrier, maintenance mode, rate limiting, or custom
6558/// health paths, which an HTTP readiness probe would all have to thread).
6559///
6560/// The file's contents are the app's *resolved* graceful-drain budget in seconds
6561/// (`prestop_grace_secs + shutdown_timeout_secs`). The supervisor records this so
6562/// `autumn serve stop` waits for the budget the app will actually drain for —
6563/// even when a custom `with_config_loader` set it — instead of reconstructing it
6564/// from TOML/env and risking a premature `SIGKILL`.
6565///
6566/// Best-effort: a write failure only delays readiness detection until the
6567/// supervisor's timeout, and a non-daemon run leaves the variable unset (no-op).
6568///
6569/// [`mark_startup_complete`]: crate::probe::ProbeState::mark_startup_complete
6570fn signal_serve_ready(drain_budget_secs: u64) {
6571    let Some(path) = std::env::var_os("AUTUMN_SERVE_READY_FILE") else {
6572        return;
6573    };
6574    if path.is_empty() {
6575        return;
6576    }
6577    let path = std::path::PathBuf::from(path);
6578    // Write to a temp sibling and rename into place so the supervisor — which
6579    // polls for the file's existence and then reads the budget from it — never
6580    // observes a half-written file: it appears atomically with its full
6581    // contents. A plain `write` would make the path exist before the bytes land.
6582    let mut tmp = path.clone();
6583    tmp.as_mut_os_string().push(".tmp");
6584    if let Err(e) = std::fs::write(&tmp, drain_budget_secs.to_string())
6585        .and_then(|()| std::fs::rename(&tmp, &path))
6586    {
6587        let _ = std::fs::remove_file(&tmp);
6588        tracing::warn!(error = %e, path = %path.display(),
6589            "could not write serve readiness file");
6590    }
6591}
6592
6593/// Prepare a Unix-socket path for binding: remove a *stale* socket left by a
6594/// previous run, but refuse to touch a non-socket file (guards against
6595/// clobbering a regular file) or a socket with a **live** listener (probed via
6596/// `connect`; clobbering it would silently make that service unreachable —
6597/// instead we fail like a TCP `EADDRINUSE`). A missing path is fine.
6598///
6599/// # Errors
6600///
6601/// Returns an error if the path exists and is not a socket, names a live
6602/// listener, or the stale socket cannot be removed.
6603#[cfg(unix)]
6604fn prepare_unix_socket_path(path: &std::path::Path) -> std::io::Result<()> {
6605    use std::os::unix::fs::FileTypeExt;
6606    match std::fs::symlink_metadata(path) {
6607        Ok(meta) if meta.file_type().is_socket() => {
6608            match std::os::unix::net::UnixStream::connect(path) {
6609                // A successful connect means another process is listening here.
6610                Ok(_) => Err(std::io::Error::new(
6611                    std::io::ErrorKind::AddrInUse,
6612                    format!(
6613                        "refusing to bind unix socket: {} is already in use by a \
6614                         live listener",
6615                        path.display()
6616                    ),
6617                )),
6618                // `ECONNREFUSED` (no listener) — or the path vanishing — means the
6619                // socket is stale; reclaim it.
6620                Err(e)
6621                    if matches!(
6622                        e.kind(),
6623                        std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
6624                    ) =>
6625                {
6626                    std::fs::remove_file(path)
6627                }
6628                // `EACCES`/`EPERM` (or any other error): the socket may be a live,
6629                // operator-managed listener whose mode/ACL denies us. Connecting
6630                // failed, but liveness is unproven — refuse rather than clobber a
6631                // possibly-live service.
6632                Err(e) => Err(std::io::Error::new(
6633                    std::io::ErrorKind::AddrInUse,
6634                    format!(
6635                        "refusing to bind unix socket: cannot determine whether {} \
6636                         is live ({e}); not removing it",
6637                        path.display()
6638                    ),
6639                )),
6640            }
6641        }
6642        Ok(_) => Err(std::io::Error::new(
6643            std::io::ErrorKind::AlreadyExists,
6644            format!(
6645                "refusing to bind unix socket: {} exists and is not a socket",
6646                path.display()
6647            ),
6648        )),
6649        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
6650        Err(e) => Err(e),
6651    }
6652}
6653
6654async fn run_shutdown_hooks(hooks: &[ShutdownHook]) {
6655    for hook in hooks.iter().rev() {
6656        hook().await;
6657    }
6658}
6659
6660/// Run shutdown hooks in reverse-registration order (LIFO), enforcing a
6661/// per-hook timeout and a hard total-budget ceiling.
6662///
6663/// Plugin ordering rule: plugins register hooks during `build()`, which is
6664/// called before any app `on_shutdown` calls, so app hooks run **before**
6665/// plugin hooks (LIFO means last-registered runs first).
6666///
6667/// Overruns are logged at WARN but do not block the remaining budget.
6668async fn run_shutdown_hooks_with_timeout(
6669    hooks: &[ShutdownHook],
6670    per_hook_budget: std::time::Duration,
6671    total_budget: std::time::Duration,
6672) {
6673    let deadline = tokio::time::Instant::now() + total_budget;
6674    for hook in hooks.iter().rev() {
6675        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
6676        if remaining.is_zero() {
6677            tracing::warn!("shutdown: total hook budget exhausted; skipping remaining hooks");
6678            break;
6679        }
6680        let timeout = remaining.min(per_hook_budget);
6681        // Hook overruns are intentionally non-fatal (exit 0 per ADR addendum).
6682        // Only drain deadline exhaustion (phase 6) triggers exit(1).
6683        if tokio::time::timeout(timeout, hook()).await.is_err() {
6684            tracing::warn!(
6685                per_hook_budget_ms = timeout.as_millis(),
6686                "shutdown: hook overran per-hook timeout; continuing with remaining budget"
6687            );
6688        }
6689    }
6690}
6691
6692/// Log a structured startup transparency report.
6693///
6694/// Activated by setting `AUTUMN_SHOW_CONFIG=1` (or `autumn dev --show-config`).
6695/// Prints all registered routes, scheduled tasks, active middleware, and
6696/// resolved configuration to the `INFO` log so developers can see exactly
6697/// what the macros and conventions configured.
6698#[allow(clippy::cognitive_complexity)]
6699fn log_startup_transparency(
6700    routes: &[Route],
6701    tasks: &[crate::task::TaskInfo],
6702    scoped_groups: &[ScopedGroup],
6703    config: &AutumnConfig,
6704) {
6705    tracing::info!(
6706        "Registered routes:{}",
6707        format_route_lines(routes, scoped_groups, config)
6708    );
6709
6710    if let Some(task_lines) = format_task_lines(tasks) {
6711        tracing::info!("Scheduled tasks:{task_lines}");
6712    }
6713
6714    tracing::info!("Active middleware: {}", format_middleware_list(config));
6715
6716    tracing::info!("Configuration:{}", format_config_summary(config));
6717}
6718
6719/// Fail the boot fast (before any DB side effects) when the default
6720/// session backend is misconfigured.
6721///
6722/// `AutumnConfig::validate()` is intentionally session-agnostic so that a
6723/// custom [`SessionStore`](crate::session::SessionStore) installed via
6724/// [`AppBuilder::with_session_store`] can override an otherwise-invalid
6725/// `session.backend = "redis"`-without-`redis.url` config. But when no
6726/// custom store is installed, the config-driven path will fail later in
6727/// `apply_session_layer` — and by then, `setup_database` has already run
6728/// migrations, leaving DB side effects from a doomed boot. This helper
6729/// runs the same `backend_plan` check `apply_session_layer` does, but
6730/// before any side effects, and only when the override path is inactive.
6731fn fail_fast_on_invalid_session_config(config: &AutumnConfig, has_custom_session_store: bool) {
6732    if has_custom_session_store {
6733        return;
6734    }
6735    if let Err(error) = config.session.backend_plan(config.profile.as_deref()) {
6736        eprintln!("Invalid session backend config: {error}");
6737        std::process::exit(1);
6738    }
6739}
6740
6741/// Resolve at-rest column-encryption keys at boot (#805).
6742///
6743/// On success this installs the process-global key ring. When encrypted columns
6744/// are registered but the key material under `active_record_encryption` is
6745/// missing or malformed, the behaviour mirrors the signing-secret check (#597):
6746/// a **hard failure in production** (the server must not bind with unusable
6747/// encryption), but only a **warning in dev/test** so zero-config local
6748/// development and the example apps continue to run. Apps that do not opt into
6749/// encrypted columns are unaffected (no registered columns -> no-op).
6750fn fail_fast_on_missing_encryption_keys(config: &AutumnConfig) {
6751    if let Err(diagnostic) = crate::encryption::init_attribute_encryption(config.credentials()) {
6752        let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6753        if is_production {
6754            eprintln!("Attribute encryption misconfiguration: {diagnostic}");
6755            std::process::exit(1);
6756        }
6757        eprintln!(
6758            "warning: attribute encryption is not fully configured (dev): {diagnostic}\n  \
6759             note: encrypted-column reads/writes will fail until keys are set; \
6760             this is a hard error in production."
6761        );
6762    }
6763}
6764
6765/// Fail immediately if the signing secret is misconfigured for the active profile.
6766///
6767/// In production, a missing, too-short, or demo-valued signing secret is a
6768/// hard failure — the server must not bind. In dev/test the check is skipped
6769/// so zero-config local development continues to work.
6770fn fail_fast_on_invalid_signing_secret(config: &AutumnConfig) {
6771    use crate::security::config::validate_signing_secret;
6772
6773    let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6774    let secret = config.security.signing_secret.secret.as_deref();
6775
6776    if let Err(error) = validate_signing_secret(secret, is_production) {
6777        eprintln!("Invalid signing secret configuration: {error}");
6778        eprintln!(
6779            "  hint: generate a secret with `openssl rand -hex 32` and set \
6780             AUTUMN_SECURITY__SIGNING_SECRET"
6781        );
6782        std::process::exit(1);
6783    }
6784
6785    // Previous secrets accepted during rotation must meet the same bar as the
6786    // current secret — a weak previous key can still be used to forge tokens.
6787    if is_production {
6788        for (i, prev) in config
6789            .security
6790            .signing_secret
6791            .previous_secrets
6792            .iter()
6793            .enumerate()
6794        {
6795            if let Err(error) = validate_signing_secret(Some(prev.as_str()), true) {
6796                eprintln!("Invalid signing secret configuration: previous_secrets[{i}]: {error}");
6797                eprintln!(
6798                    "  hint: every previous secret must meet the same entropy requirement \
6799                     as the current secret"
6800                );
6801                std::process::exit(1);
6802            }
6803        }
6804    }
6805}
6806
6807fn fail_fast_on_invalid_webhook_config(config: &AutumnConfig) {
6808    let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6809    if let Err(error) = config.security.webhooks.validate(is_production) {
6810        eprintln!("Invalid signed webhook configuration: {error}");
6811        std::process::exit(1);
6812    }
6813}
6814
6815fn fail_fast_on_invalid_trusted_hosts(config: &AutumnConfig) {
6816    let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6817    if !is_production {
6818        return;
6819    }
6820    let hosts: Vec<String> = config
6821        .security
6822        .trusted_hosts
6823        .hosts
6824        .iter()
6825        .map(|h| h.trim().to_owned())
6826        .filter(|h| !h.is_empty())
6827        .collect();
6828    if hosts.is_empty() {
6829        eprintln!(
6830            "[security.trusted_hosts] is required in production; set hosts = [\"example.com\"] or explicit entries"
6831        );
6832        std::process::exit(1);
6833    }
6834    if hosts.iter().any(|h| h == "*") {
6835        tracing::warn!("trusted host validation disabled via wildcard '*' in production");
6836    }
6837}
6838
6839fn fail_fast_on_invalid_idempotency_config(config: &AutumnConfig) {
6840    if !config.idempotency.enabled.unwrap_or(false) {
6841        return;
6842    }
6843    let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
6844    if is_production
6845        && config.idempotency.backend == crate::config::IdempotencyBackend::Memory
6846        && !config.idempotency.allow_memory_in_production
6847    {
6848        eprintln!(
6849            "The in-memory idempotency backend is not safe for multi-replica production use.\n\
6850             Set `[idempotency] backend = \"redis\"` in autumn.toml, or set \
6851             `allow_memory_in_production = true` to suppress this check."
6852        );
6853        std::process::exit(1);
6854    }
6855    #[cfg(feature = "redis")]
6856    if config.idempotency.backend == crate::config::IdempotencyBackend::Redis {
6857        let url_missing = config
6858            .idempotency
6859            .redis
6860            .url
6861            .as_deref()
6862            .is_none_or(|u| u.trim().is_empty());
6863        if url_missing {
6864            eprintln!(
6865                "Redis idempotency backend requires a connection URL.\n\
6866                 Set AUTUMN_IDEMPOTENCY__REDIS__URL or `[idempotency.redis] url` in autumn.toml."
6867            );
6868            std::process::exit(1);
6869        }
6870    }
6871}
6872
6873pub(crate) fn install_webhook_registry(state: &AppState, config: &AutumnConfig) {
6874    if let Err(error) =
6875        crate::webhook::install_registry_from_config(state, &config.security.webhooks)
6876    {
6877        eprintln!("Invalid signed webhook configuration: {error}");
6878        std::process::exit(1);
6879    }
6880}
6881
6882/// Constructed [`BlobStore`](crate::storage::BlobStore) plus the
6883/// optional axum router that serves signed URLs for the Local backend.
6884/// Returned by [`preflight_storage`] before any DB side effects so a
6885/// doomed boot can't run migrations first; installed onto
6886/// [`AppState`] later via [`StorageBootstrap::install`].
6887#[cfg(feature = "storage")]
6888struct StorageBootstrap {
6889    store: crate::storage::SharedBlobStore,
6890    serving: Option<axum::Router<AppState>>,
6891}
6892
6893#[cfg(feature = "storage")]
6894impl StorageBootstrap {
6895    /// Install the preflighted store on `AppState` and return the
6896    /// optional serving router so the caller can merge it into the
6897    /// app router.
6898    fn install(self, state: &AppState) -> Option<axum::Router<AppState>> {
6899        state.insert_extension::<crate::storage::BlobStoreState>(
6900            crate::storage::BlobStoreState::new(self.store),
6901        );
6902        self.serving
6903    }
6904}
6905
6906/// Provision the configured [`BlobStore`](crate::storage::BlobStore)
6907/// before any database side effects. Construction is the side-effecting
6908/// step (creates + canonicalizes the storage root, may
6909/// `process::exit(1)` on a misconfiguration); we deliberately run it
6910/// before `setup_database` so a doomed boot doesn't apply migrations
6911/// first. Installation onto `AppState` happens later via
6912/// [`StorageBootstrap::install`].
6913#[cfg(feature = "storage")]
6914#[allow(clippy::too_many_lines)] // Single switch over backend variants reads as one unit.
6915fn preflight_storage(config: &AutumnConfig) -> Option<StorageBootstrap> {
6916    use crate::storage::StorageBackendPlan;
6917
6918    let plan = config
6919        .storage
6920        .backend_plan(config.profile.as_deref())
6921        .unwrap_or_else(|error| {
6922            // Cover the cases `backend_plan` rejects up front:
6923            // `LocalInProduction` (prod + local without ack),
6924            // `MissingS3Bucket`/`MissingS3Region`/`S3FeatureDisabled`.
6925            // Each is a configuration mistake — fail the boot loudly
6926            // rather than running migrations and then dying.
6927            tracing::error!(%error, "invalid storage backend config; aborting startup");
6928            std::process::exit(1);
6929        });
6930
6931    match plan {
6932        StorageBackendPlan::Disabled => None,
6933        StorageBackendPlan::Local {
6934            provider_id,
6935            root,
6936            mount_path,
6937            default_url_expiry_secs,
6938            warn_in_production,
6939        } => Some(bootstrap_local_storage(
6940            config,
6941            &provider_id,
6942            &root,
6943            &mount_path,
6944            default_url_expiry_secs,
6945            warn_in_production,
6946        )),
6947        StorageBackendPlan::S3 { .. } => {
6948            // `storage.backend = "s3"` requires the `autumn-storage-s3` plugin.
6949            // Construct an `S3BlobStore` and register it with `.with_blob_store()`
6950            // before calling `.run()` — when you do, the custom store bypasses
6951            // this path entirely and `preflight_storage` is never called.
6952            tracing::error!(
6953                "storage.backend=s3 requires the `autumn-storage-s3` plugin. \
6954                 Add it to your Cargo.toml, build an S3BlobStore from your config, \
6955                 and call `.with_blob_store(store)` on your AppBuilder. \
6956                 Aborting startup."
6957            );
6958            std::process::exit(1);
6959        }
6960    }
6961}
6962
6963#[cfg(feature = "storage")]
6964fn bootstrap_local_storage(
6965    config: &AutumnConfig,
6966    provider_id: &str,
6967    root: &std::path::Path,
6968    mount_path: &str,
6969    default_url_expiry_secs: u64,
6970    warn_in_production: bool,
6971) -> StorageBootstrap {
6972    use crate::storage::{LocalBlobStore, SharedBlobStore, local::SigningKey};
6973
6974    if warn_in_production {
6975        tracing::warn!(
6976            "prod profile is using the local-disk blob store; \
6977             bytes won't survive replica turnover. Set \
6978             storage.backend=s3 or storage.allow_local_in_production=true \
6979             to acknowledge"
6980        );
6981    }
6982
6983    // Signing key precedence:
6984    // 1. security.signing_secret (canonical, shared with session/CSRF)
6985    // 2. storage.local.signing_key (legacy override — still respected)
6986    // 3. Random ephemeral key (dev only — warns in prod)
6987    let (signing_key, previous_signing_keys) = config
6988        .security
6989        .signing_secret
6990        .secret
6991        .as_deref()
6992        .filter(|s| !s.is_empty())
6993        .map_or_else(
6994            || {
6995                config
6996                    .storage
6997                    .local
6998                    .signing_key
6999                    .as_deref()
7000                    .filter(|s| !s.is_empty())
7001                    .map_or_else(
7002                        || {
7003                            if matches!(config.profile.as_deref(), Some("prod" | "production")) {
7004                                tracing::warn!(
7005                                    "no signing secret configured in prod; blob URL signatures \
7006                                     won't survive a process restart. Set \
7007                                     AUTUMN_SECURITY__SIGNING_SECRET."
7008                                );
7009                            }
7010                            (SigningKey::random(), vec![])
7011                        },
7012                        |legacy| (SigningKey::new(legacy.as_bytes().to_vec()), vec![]),
7013                    )
7014            },
7015            |secret| {
7016                let current = SigningKey::new(secret.as_bytes().to_vec());
7017                let previous = config
7018                    .security
7019                    .signing_secret
7020                    .previous_secrets
7021                    .iter()
7022                    .map(|s| SigningKey::new(s.as_bytes().to_vec()))
7023                    .collect::<Vec<_>>();
7024                (current, previous)
7025            },
7026        );
7027
7028    let store = match LocalBlobStore::new(
7029        provider_id.to_string(),
7030        root.to_path_buf(),
7031        mount_path.to_string(),
7032        std::time::Duration::from_secs(default_url_expiry_secs),
7033        signing_key,
7034        previous_signing_keys,
7035    ) {
7036        Ok(store) => store,
7037        Err(err) => {
7038            // The operator explicitly chose `storage.backend = "local"`
7039            // — a non-writable root means uploads can't possibly
7040            // work, so abort the boot rather than letting upload
7041            // handlers serve 500s after deploy.
7042            tracing::error!(
7043                error = %err,
7044                root = %root.display(),
7045                "failed to initialize local blob store; aborting startup"
7046            );
7047            std::process::exit(1);
7048        }
7049    };
7050
7051    let serving = crate::storage::local::serve_router(&store);
7052    let arc: SharedBlobStore = std::sync::Arc::new(store);
7053
7054    tracing::info!(
7055        provider = %provider_id,
7056        root = %root.display(),
7057        mount = %mount_path,
7058        "Local blob store mounted"
7059    );
7060
7061    StorageBootstrap {
7062        store: arc,
7063        serving: Some(serving),
7064    }
7065}
7066async fn load_config_and_telemetry(
7067    config_loader: Option<ConfigLoaderFactory>,
7068    telemetry_provider: Option<Box<dyn crate::telemetry::TelemetryProvider>>,
7069    plugin_config_roots: BTreeSet<String>,
7070) -> (AutumnConfig, crate::telemetry::TelemetryGuard) {
7071    // 1. Load configuration via the installed loader, falling back to the
7072    //    five-layer TOML + env default.
7073    //
7074    // A custom `config_loader` factory owns its entire load + strict-config
7075    // handling (it bypasses the default TOML path), so it does not receive the
7076    // declared plugin config roots — such a loader is responsible for accepting
7077    // its own plugin-owned sections. The default `TomlEnvConfigLoader` is handed
7078    // the roots so `server.strict_config` treats each plugin-declared `[root]`
7079    // (e.g. `[media]`) as known-and-opaque instead of an unknown-key hard error.
7080    let mut config = match config_loader {
7081        Some(factory) => factory().await,
7082        None => {
7083            crate::config::TomlEnvConfigLoader::new()
7084                .with_plugin_config_roots(plugin_config_roots)
7085                .load()
7086                .await
7087        }
7088    }
7089    .unwrap_or_else(|e| {
7090        eprintln!("Failed to load configuration: {e}");
7091        std::process::exit(1);
7092    });
7093
7094    // `autumn serve --daemon` binds the app on a private Unix socket and then
7095    // discovers/health-probes it by path. A custom `with_config_loader` can
7096    // construct its `ServerConfig` from scratch and silently drop the
7097    // `AUTUMN_SERVER__UNIX_SOCKET` env override, leaving the daemon on TCP where
7098    // the supervisor can't reach it. The CLI therefore also passes the socket
7099    // out-of-band via `AUTUMN_SERVE_FORCE_UNIX_SOCKET`, applied here *after* the
7100    // loader runs so no loader can drop it.
7101    if let Ok(forced) = std::env::var("AUTUMN_SERVE_FORCE_UNIX_SOCKET")
7102        && !forced.is_empty()
7103    {
7104        config.server.unix_socket = Some(forced);
7105    }
7106
7107    // 2. Initialize logging/telemetry via the installed provider, falling
7108    //    back to the default `tracing-subscriber + OTLP` initializer.
7109    let provider: Box<dyn crate::telemetry::TelemetryProvider> = telemetry_provider
7110        .unwrap_or_else(|| Box::new(crate::telemetry::TracingOtlpTelemetryProvider::new()));
7111    let telemetry_guard = provider
7112        .init(&config.log, &config.telemetry, config.profile.as_deref())
7113        .unwrap_or_else(|error| {
7114            eprintln!("Failed to initialize telemetry: {error}");
7115            std::process::exit(1);
7116        });
7117
7118    (config, telemetry_guard)
7119}
7120
7121/// Register the embedded `static/` tree (if any) as the process-wide asset
7122/// source. Called by each `run` path before the router is built so `/static/*`
7123/// serves from the binary and `asset_url()` resolves against the embedded
7124/// manifest.
7125#[cfg(feature = "embed-assets")]
7126fn register_embedded_static_dir(embedded_static: Option<crate::assets::EmbeddedStaticDir>) {
7127    if let Some(dir) = embedded_static {
7128        crate::assets::register_embedded_static(dir);
7129    }
7130}
7131
7132/// Prefer an embedded locale bundle over disk auto-loading when no explicit
7133/// bundle was provided. Returns `explicit` unchanged when it is `Some` or when
7134/// no embedded locales were registered.
7135#[cfg(all(feature = "embed-assets", feature = "i18n"))]
7136fn embedded_i18n_bundle(
7137    explicit: Option<Arc<crate::i18n::Bundle>>,
7138    embedded_locales: Option<&'static include_dir::Dir<'static>>,
7139    config: &AutumnConfig,
7140) -> Option<Arc<crate::i18n::Bundle>> {
7141    explicit.or_else(|| {
7142        embedded_locales.map(|dir| {
7143            Arc::new(
7144                crate::i18n::Bundle::load_from_embedded(dir, &config.i18n)
7145                    .unwrap_or_else(|e| panic!("embedded_locales: {e}")),
7146            )
7147        })
7148    })
7149}
7150
7151#[cfg(feature = "i18n")]
7152fn resolve_i18n_bundle(
7153    explicit_bundle: Option<Arc<crate::i18n::Bundle>>,
7154    auto_load: bool,
7155    config: &AutumnConfig,
7156    env: &dyn crate::config::Env,
7157) -> Option<Arc<crate::i18n::Bundle>> {
7158    if explicit_bundle.is_some() {
7159        return explicit_bundle;
7160    }
7161    if !auto_load {
7162        return None;
7163    }
7164
7165    let dir = project_dir(&config.i18n.dir, env);
7166    Some(Arc::new(
7167        crate::i18n::Bundle::load_from_dir(&dir, &config.i18n)
7168            .unwrap_or_else(|e| panic!("i18n_auto: {e}")),
7169    ))
7170}
7171
7172#[cfg(feature = "i18n")]
7173fn install_i18n_bundle_layer(
7174    mut custom_layers: Vec<CustomLayerRegistration>,
7175    state: &AppState,
7176    bundle: Option<Arc<crate::i18n::Bundle>>,
7177) -> Vec<CustomLayerRegistration> {
7178    let Some(bundle) = bundle else {
7179        return custom_layers;
7180    };
7181
7182    tracing::info!(
7183        locales = ?bundle.locales(),
7184        default = bundle.default_locale(),
7185        "i18n bundle loaded"
7186    );
7187    state.insert_extension::<Arc<crate::i18n::Bundle>>(bundle.clone());
7188    // Use the existing IntoAppLayer plumbing so the Extension is visible to
7189    // every request. axum::Extension<T> is itself a tower::Layer when T:
7190    // Clone + Send + Sync + 'static.
7191    let ext_layer = axum::Extension(bundle);
7192    custom_layers.push(CustomLayerRegistration {
7193        type_id: TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
7194        type_name: std::any::type_name::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
7195        apply: Box::new(move |router| router.layer(ext_layer)),
7196    });
7197    custom_layers
7198}
7199
7200#[cfg(feature = "db")]
7201struct DatabaseBootstrap {
7202    topology: Option<crate::db::DatabaseTopology>,
7203    shards: Option<crate::sharding::ShardSet>,
7204    replica_readiness: Option<crate::migrate::ReplicaMigrationReadiness>,
7205    replica_migration_check: Option<(String, String)>,
7206}
7207
7208/// Build the `ShardSet` for a sharded app (or `None` when no `[[database.shards]]`
7209/// are configured). Resolves the shard router first: an explicit
7210/// `with_shard_router` wins; otherwise `directory_routing_enabled` opts into the
7211/// control-DB directory router (bound to the just-built control primary pool);
7212/// otherwise the hash router. The directory flag is documented as having no
7213/// effect without shards, so a shardless profile that leaves it enabled must not
7214/// fail startup — hence the early `None` return.
7215///
7216/// `spawn_directory_listener` gates the directory-router cache-invalidation
7217/// listener: it opens control-DB connections, so it is spawned only at real
7218/// runtime, never during a static build (`autumn build`) which must not touch
7219/// the database.
7220#[cfg(feature = "db")]
7221async fn resolve_shard_set(
7222    config: &AutumnConfig,
7223    shard_router: Option<Arc<dyn crate::sharding::ShardRouter>>,
7224    shard_provider: Option<ShardProviderFactory>,
7225    directory_routing_enabled: bool,
7226    spawn_directory_listener: bool,
7227    topology: Option<&crate::db::DatabaseTopology>,
7228) -> Result<Option<crate::sharding::ShardSet>, String> {
7229    if !config.database.has_shards() {
7230        return Ok(None);
7231    }
7232    let router: Arc<dyn crate::sharding::ShardRouter> = match shard_router {
7233        Some(explicit) => explicit,
7234        None if directory_routing_enabled => {
7235            let control_primary = topology
7236                .map(crate::db::DatabaseTopology::primary)
7237                .ok_or_else(|| {
7238                    "directory_shard_router is enabled but no control database is configured. \
7239                     The directory router needs a control `database.primary_url`/`url` to read \
7240                     the tenant→shard directory. Set one, or disable directory routing to use \
7241                     the hash router."
7242                        .to_owned()
7243                })?;
7244            // Directory routing resolves the tenant→shard key by checking out a
7245            // *second* control connection during extraction. A handler that
7246            // already holds `Db` (or another control checkout) before extracting
7247            // `ShardedDb` / a sharded repository would then deadlock on a control
7248            // pool sized to 1 — the first checkout cannot be released until the
7249            // handler runs. Require at least 2 control connections so these
7250            // mixed control+tenant handlers always make progress.
7251            let control_max = control_primary.status().max_size;
7252            if control_max < 2 {
7253                return Err(format!(
7254                    "directory_shard_router requires a control database pool of at least 2 \
7255                     connections, but the configured maximum is {control_max}. Directory \
7256                     routing checks out a second control connection during extraction to \
7257                     resolve the tenant→shard key, which deadlocks a pool sized to 1 when a \
7258                     handler already holds a control connection (e.g. `Db` + `ShardedDb`). \
7259                     Increase the control pool size (database.pool.max_size), or disable \
7260                     directory routing to use the hash router."
7261                ));
7262            }
7263            // Bound directory lookups with the configured database statement
7264            // timeout (capped to Postgres' i32 millisecond range).
7265            let timeout_ms = config.database.statement_timeout.map_or(0, |d| {
7266                u64::try_from(d.as_millis())
7267                    .unwrap_or(i32::MAX as u64)
7268                    .min(i32::MAX as u64)
7269            });
7270            let dir_router = Arc::new(
7271                crate::sharding::DirectoryShardRouter::new(control_primary.clone())
7272                    .with_statement_timeout_ms(timeout_ms),
7273            );
7274            // Spawn the cache-invalidation listener on the control DB so a re-pin
7275            // (e.g. during a slot move) evicts cached tenant→shard mappings fleet-
7276            // wide the moment it commits (LISTEN/NOTIFY) rather than waiting out
7277            // the TTL. Skipped during a static build (no DB access); needs the
7278            // control URL, without one we silently fall back to TTL-only refresh.
7279            if spawn_directory_listener {
7280                // Prefer the provider-resolved control URL carried on the
7281                // topology (managed Postgres has no `database.primary_url` in
7282                // config); fall back to the configured URL. Without this a
7283                // managed control DB would get no LISTEN/NOTIFY task (absent
7284                // URL) or listen on a stale pre-provider URL.
7285                if let Some(control_url) = topology
7286                    .and_then(crate::db::DatabaseTopology::migration_url)
7287                    .or_else(|| config.database.effective_primary_url())
7288                {
7289                    // Detach: the listener runs for the life of the process;
7290                    // dropping the JoinHandle leaves the task running rather than
7291                    // aborting it.
7292                    drop(
7293                        crate::sharding::DirectoryShardRouter::spawn_invalidation_listener(
7294                            Arc::clone(&dir_router),
7295                            control_url.to_owned(),
7296                            crate::sharding::DEFAULT_DIRECTORY_INVALIDATION_SWEEP_INTERVAL,
7297                        ),
7298                    );
7299                } else {
7300                    // Directory routing is active but there is no control URL to
7301                    // open a dedicated LISTEN connection — e.g. a custom
7302                    // `DatabasePoolProvider` supplied the control pool without
7303                    // `database.primary_url`/`url`. The router still serves
7304                    // lookups from the provided pool, but re-pins won't be
7305                    // invalidated fleet-wide on commit; they only take effect
7306                    // after the cache TTL expires. Warn rather than fall back
7307                    // silently so operators relying on the directory for slot
7308                    // moves can configure a control URL (or accept TTL-only
7309                    // refresh) deliberately.
7310                    tracing::warn!(
7311                        "directory shard routing is enabled but no control database URL is \
7312                         configured (database.primary_url/url is unset, e.g. a custom \
7313                         DatabasePoolProvider supplied the control pool); the cache-\
7314                         invalidation LISTEN/NOTIFY task cannot be started, so directory \
7315                         re-pins will only take effect after the cache TTL expires rather \
7316                         than fleet-wide on commit"
7317                    );
7318                }
7319            }
7320            dir_router
7321        }
7322        None => Arc::new(crate::sharding::HashShardRouter),
7323    };
7324    let set = match shard_provider {
7325        Some(factory) => {
7326            let topologies = factory(config.database.clone())
7327                .await
7328                .map_err(|e| format!("Failed to create shard pools: {e}"))?;
7329            // A custom shard provider established shard pools without routing
7330            // through the built-in `create_shard_topology` factory (which
7331            // validates `database.statement_timeout` internally), so enforce the
7332            // same fail-closed guard here — but only now that pools WERE actually
7333            // established. `resolve_shard_set` already returned early for a
7334            // shardless profile, so this Some-gated check never rejects a
7335            // no-database path; a shard set that establishes SQLite pools under a
7336            // nonzero timeout still fails closed.
7337            #[cfg(feature = "sqlite")]
7338            crate::db::reject_sqlite_statement_timeout(config.database.statement_timeout)
7339                .map_err(|e| format!("Failed to create shard pools: {e}"))?;
7340            crate::sharding::build_shard_set(&config.database, topologies, router)
7341        }
7342        None => crate::sharding::create_shard_set(&config.database, router)
7343            .map(|set| set.expect("has_shards() checked above")),
7344    }
7345    .map_err(|e| format!("Failed to configure shards: {e}"))?;
7346    Ok(Some(set))
7347}
7348
7349#[cfg(feature = "db")]
7350// The `sqlite` feature adds a small fail-fast startup-migration guard block
7351// (issue #1614) that pushes this orchestration fn just over the line limit.
7352#[allow(clippy::too_many_lines)]
7353async fn setup_database(
7354    config: &AutumnConfig,
7355    migrations: Vec<crate::migrate::EmbeddedMigrations>,
7356    pool_provider: Option<PoolProviderFactory>,
7357    shard_provider: Option<ShardProviderFactory>,
7358    shard_router: Option<Arc<dyn crate::sharding::ShardRouter>>,
7359    directory_shard_router: bool,
7360    hook_queue_migration_mode: RepositoryCommitHookQueueMigrationMode,
7361) -> Result<DatabaseBootstrap, String> {
7362    let migrations = migrations_with_repository_framework_migrations(
7363        migrations,
7364        crate::repository_commit_hooks::has_repository_commit_hook_descriptors(),
7365        crate::version_history::has_versioned_repository_descriptors(),
7366        hook_queue_migration_mode,
7367    );
7368    // Directory routing is only actually active when the app did NOT supply an
7369    // explicit shard router: an explicit `with_shard_router(...)` takes
7370    // precedence over `directory_shard_router` in `resolve_shard_set`, so in
7371    // that case the `DirectoryShardRouter` is never constructed and the
7372    // directory table is never consulted. Gate the migration on the same
7373    // condition so an explicit-router app doesn't create `_autumn_shard_directory`
7374    // (or warn about a pending directory migration) for a table it won't use.
7375    let use_directory_router = shard_router.is_none()
7376        && (directory_shard_router || config.database.directory_shard_router);
7377    // The tenant→shard directory table is a CONTROL-plane table: create it at
7378    // startup only when directory routing is active (and shards exist), and
7379    // only on the control target — not via the shared list above, which is also
7380    // applied to every shard. Like the other runtime framework migrations, it is
7381    // suppressed during a static build (`autumn build`, AUTUMN_BUILD_STATIC=1):
7382    // the build only renders assets and must not touch the database, so it must
7383    // not create `_autumn_shard_directory`.
7384    let directory_migration_required = directory_migration_is_required(
7385        use_directory_router,
7386        config.database.has_shards(),
7387        hook_queue_migration_mode,
7388    );
7389    let shard_map_migration_required =
7390        shard_map_migration_is_required(config.database.has_shards(), hook_queue_migration_mode);
7391    let check_replica_migrations = !migrations.is_empty();
7392    let topology = match pool_provider {
7393        Some(factory) => factory(config.database.clone()).await,
7394        None => crate::db::create_topology(&config.database),
7395    }
7396    .map_err(|e| format!("Failed to create database pool: {e}"))?;
7397    // Fail-closed statement-timeout guard — enforced only once a control pool has
7398    // ACTUALLY been established (the provider, built-in or custom, returned
7399    // `Some(..)`). The built-in `create_topology`/`create_shard_topology`
7400    // factories validate `database.statement_timeout` internally, but a custom
7401    // `with_pool_provider` provider can build its own SQLite pool without routing
7402    // through them — the default `DatabasePoolProvider::create_topology` only
7403    // delegates to the provider's `create_pool`, and both
7404    // `create_topology`/`create_shard_topology` are overridable — so a custom
7405    // provider could otherwise silently discard the timeout and break the
7406    // fail-closed guarantee. Under the `sqlite` feature `RuntimeBackend` is always
7407    // SQLite, so an established pool plus a nonzero timeout is exactly the
7408    // fail-closed condition. A provider that returns `Ok(None)` opts into the
7409    // explicitly-supported no-database mode — no pool/statement exists to need a
7410    // timeout — so it must still boot, matching the built-in path (which returns
7411    // `Ok(None)` before reaching its own timeout check). Gating on
7412    // `topology.is_some()` preserves that opt-out for custom providers too. This
7413    // is idempotent with the built-in factories' own checks (double-guard is
7414    // safe); the shard-topology dispatch in `resolve_shard_set` applies the same
7415    // Some-gated guard.
7416    #[cfg(feature = "sqlite")]
7417    if topology.is_some() {
7418        crate::db::reject_sqlite_statement_timeout(config.database.statement_timeout)
7419            .map_err(|e| format!("Failed to create database pool: {e}"))?;
7420    }
7421
7422    // Spawn the directory invalidation listener only at real runtime — a static
7423    // build must not open control-DB connections.
7424    let runtime_boot = hook_queue_migration_mode == RepositoryCommitHookQueueMigrationMode::Runtime;
7425    let shards = match resolve_shard_set(
7426        config,
7427        shard_router,
7428        shard_provider,
7429        use_directory_router,
7430        runtime_boot,
7431        topology.as_ref(),
7432    )
7433    .await
7434    {
7435        Ok(shards) => shards,
7436        Err(e) => {
7437            // The (managed) control topology is already up at this point, so a
7438            // later setup failure — directory control-pool sizing, shard pool
7439            // construction — must stop the managed Postgres child before the
7440            // caller's `process::exit` (which skips `on_shutdown`/`Drop`).
7441            // No-op when no managed cluster was started.
7442            #[cfg(feature = "managed-pg")]
7443            crate::managed_pg::emergency_stop_async().await;
7444            return Err(e);
7445        }
7446    };
7447
7448    // Skip migrations when the provider opted out of a database (returned
7449    // `Ok(None)`) — even if `database.url` is configured. Custom providers
7450    // signal "this app runs without a DB" by returning None; running
7451    // migrations against the URL anyway would defeat the opt-out.
7452    //
7453    // A provider may also resolve its primary URL at runtime (managed Postgres)
7454    // and carry it on the topology; prefer it so migrations target the pool that
7455    // was actually built rather than a stale/absent configured URL.
7456    let provider_migration_url = topology
7457        .as_ref()
7458        .and_then(|t| t.migration_url())
7459        .map(str::to_owned);
7460
7461    // SQLite sharding guard (issue #1614, PR3): the SQLite startup-migration
7462    // path now applies registered migrations to a `sqlite://` control target
7463    // (`run_startup_migrations` routes them through
7464    // `crate::migrate::auto_migrate_sqlite`), so registered migrations no longer
7465    // fail fast here. What remains unsupported on SQLite is **sharding** — the
7466    // directory/shard-map control migrations and per-shard fan-out are
7467    // Postgres/sharding-specific — so a sqlite control target with sharding
7468    // enabled fails fast here, as does any `sqlite:` shard `primary_url` (the
7469    // shard loop routes each shard through the Postgres-only harness). Empty when
7470    // unsharded or when the shard loop won't run, so the Postgres path is
7471    // unaffected. See `sqlite_sharding_unsupported_guard`.
7472    #[cfg(feature = "sqlite")]
7473    let sqlite_guard_shard_urls: Vec<&str> = if shards.is_some() {
7474        config
7475            .database
7476            .shards
7477            .iter()
7478            .map(|shard| shard.primary_url.as_str())
7479            .collect()
7480    } else {
7481        Vec::new()
7482    };
7483    #[cfg(feature = "sqlite")]
7484    #[allow(clippy::question_mark)] // managed-pg child must be stopped before returning
7485    if let Err(e) = sqlite_sharding_unsupported_guard(
7486        if topology.is_some() {
7487            provider_migration_url
7488                .as_deref()
7489                .or_else(|| config.database.effective_primary_url())
7490        } else {
7491            None
7492        },
7493        directory_migration_required
7494            || shard_map_migration_required
7495            || config.database.has_shards(),
7496        &sqlite_guard_shard_urls,
7497    ) {
7498        #[cfg(feature = "managed-pg")]
7499        crate::managed_pg::emergency_stop_async().await;
7500        return Err(e);
7501    }
7502
7503    run_startup_migrations(
7504        config,
7505        topology.is_some(),
7506        shards.is_some(),
7507        provider_migration_url,
7508        migrations,
7509        directory_migration_required,
7510        shard_map_migration_required,
7511    )
7512    .await;
7513
7514    let (replica_readiness, replica_migration_check) = if topology
7515        .as_ref()
7516        .is_some_and(|topology| check_replica_migrations && topology.replica().is_some())
7517    {
7518        match (
7519            config.database.effective_primary_url(),
7520            config.database.replica_url.as_deref(),
7521        ) {
7522            (Some(primary_url), Some(replica_url)) => {
7523                let primary_url = primary_url.to_owned();
7524                let replica_url = replica_url.to_owned();
7525                let readiness = crate::migrate::check_replica_migration_readiness_blocking(
7526                    primary_url.clone(),
7527                    replica_url.clone(),
7528                )
7529                .await;
7530                (Some(readiness), Some((primary_url, replica_url)))
7531            }
7532            _ => (None, None),
7533        }
7534    } else {
7535        (None, None)
7536    };
7537
7538    if check_replica_migrations && let Some(set) = &shards {
7539        check_shard_replica_migration_parity(config, set).await;
7540    }
7541
7542    // Boot-time guard: compare the current auto-split slot map against the map
7543    // persisted on first boot. Refuses to start if they differ, preventing
7544    // silent data misrouting from topology changes. Inert during static builds,
7545    // when no control DB is configured, and in explicit-slot mode.
7546    #[allow(clippy::question_mark)]
7547    if let Err(e) = Box::pin(enforce_shard_map_guard(
7548        config,
7549        topology.as_ref(),
7550        runtime_boot,
7551    ))
7552    .await
7553    {
7554        // Needs explicit `if let` (not `?`) so the managed-pg child can be stopped
7555        // before unwinding — `?` would skip the cfg-gated emergency stop call.
7556        #[cfg(feature = "managed-pg")]
7557        crate::managed_pg::emergency_stop_async().await;
7558        return Err(e);
7559    }
7560
7561    Ok(DatabaseBootstrap {
7562        topology,
7563        shards,
7564        replica_readiness,
7565        replica_migration_check,
7566    })
7567}
7568
7569/// Apply the embedded migration sets control-first, then to each shard in
7570/// declaration order, failing fast on the first apply error: a
7571/// half-migrated fleet that boots is worse than a crashed deploy, and
7572/// already-migrated targets are idempotently skipped on retry.
7573///
7574/// `run_pending_locked` polls with `std::thread::sleep` (up to 60 s under
7575/// contention), so the whole sequence runs off the Tokio worker threads in
7576/// one blocking task that owns the embedded migration sets.
7577/// Apply pending migrations for one target in the `AUTUMN_MIGRATE=1` one-shot,
7578/// returning the number applied — or exiting non-zero on failure.
7579///
7580/// Uses the same locked applier the startup path uses
7581/// ([`run_pending_locked`](crate::migrate::run_pending_locked)). Failure messages
7582/// are REDACTED to a value-free reason plus the target label (`control` /
7583/// `shard:<name>`): the underlying [`MigrationError`](crate::migrate::MigrationError)
7584/// can wrap a driver string that embeds the connection URL, and the deploy path
7585/// must never print a DB URL or secret.
7586#[cfg(feature = "db")]
7587fn apply_pending_or_exit(
7588    database_url: &str,
7589    migrations: &crate::migrate::EmbeddedMigrations,
7590    target: &str,
7591) -> usize {
7592    match crate::migrate::run_pending_locked(
7593        database_url,
7594        crate::migrate::EmbeddedMigrationsRef(migrations),
7595        None,
7596    ) {
7597        Ok(result) => result.applied.len(),
7598        Err(error) => {
7599            let reason = match error {
7600                crate::migrate::MigrationError::Connection(_) => {
7601                    "could not connect to the database"
7602                }
7603                crate::migrate::MigrationError::Migration(_) => "a migration failed to apply",
7604                _ => "migration error",
7605            };
7606            eprintln!("autumn migrate: {reason} (target {target})");
7607            // `process::exit` skips `on_shutdown`/`Drop`; stop any managed
7608            // Postgres child first so a failure doesn't orphan the data dir/port.
7609            #[cfg(feature = "managed-pg")]
7610            crate::managed_pg::emergency_stop();
7611            std::process::exit(1);
7612        }
7613    }
7614}
7615
7616/// Apply pending migrations for one `SQLite` target in the `AUTUMN_MIGRATE=1`
7617/// one-shot, returning the number applied — or exiting non-zero on failure
7618/// (issue #1614, PR3).
7619///
7620/// The `SQLite` counterpart to [`apply_pending_or_exit`]: it uses the unlocked
7621/// [`run_pending_sqlite`](crate::migrate::run_pending_sqlite) harness (`SQLite`
7622/// is single-writer, so there is no advisory lock and no cross-process race to
7623/// serialize) and REDACTS failure messages to a value-free reason plus the
7624/// target label, exactly like the Postgres path, so a driver string embedding
7625/// the database path is never printed.
7626#[cfg(feature = "sqlite")]
7627fn apply_pending_sqlite_or_exit(
7628    database_url: &str,
7629    migrations: &crate::migrate::EmbeddedMigrations,
7630    target: &str,
7631) -> usize {
7632    // Reject ANY in-memory target (private OR shared-cache) with registered
7633    // migrations up front, BEFORE `run_pending_sqlite` (whose `Migration` error
7634    // would be redacted to a value-free reason below, hiding the guidance). The
7635    // migrated schema never survives to the runtime pool — a private `:memory:`
7636    // connection is its own empty database, and a shared in-memory database is
7637    // destroyed when its last connection closes (issue #1614 follow-up).
7638    if let Some(err) = crate::migrate::reject_in_memory_migrations(
7639        database_url,
7640        &crate::migrate::EmbeddedMigrationsRef(migrations),
7641    ) {
7642        eprintln!("autumn migrate: {err} (target {target})");
7643        std::process::exit(1);
7644    }
7645    match crate::migrate::run_pending_sqlite(
7646        database_url,
7647        crate::migrate::EmbeddedMigrationsRef(migrations),
7648    ) {
7649        Ok(result) => result.applied.len(),
7650        Err(error) => {
7651            let reason = match error {
7652                crate::migrate::MigrationError::Connection(_) => {
7653                    "could not connect to the database"
7654                }
7655                crate::migrate::MigrationError::Migration(_) => "a migration failed to apply",
7656                _ => "migration error",
7657            };
7658            eprintln!("autumn migrate: {reason} (target {target})");
7659            std::process::exit(1);
7660        }
7661    }
7662}
7663
7664/// Guard the startup-migration path against a **sharded** `SQLite` deployment.
7665///
7666/// PR3 (#1614) wired a working `SQLite` startup-migration path: registered
7667/// migrations now apply to a `sqlite://` control target through diesel's
7668/// `MigrationHarness` on a `SqliteConnection`, with no advisory lock (see
7669/// [`crate::migrate::run_pending_sqlite`] / [`crate::migrate::auto_migrate_sqlite`],
7670/// routed from [`run_startup_migrations`]). So registered migrations are no
7671/// longer rejected here — a `sqlite://` control target with `.migrations(...)`
7672/// boots and applies its schema.
7673///
7674/// What remains unsupported is **sharding on `SQLite`**: the shard-directory and
7675/// shard-map control tables and the per-shard fan-out are Postgres/sharding
7676/// primitives (advisory locks, Postgres DDL), and a single-node `SQLite`
7677/// deployment has no shards. Two situations are therefore rejected here with an
7678/// actionable message rather than attempting to create Postgres-shaped sharding
7679/// tables on `SQLite`:
7680///
7681///   * a `SQLite` **control** target for which the sharding control migrations
7682///     are required or shards are configured (`control_sharding_required` —
7683///     `directory_migration_required || shard_map_migration_required ||
7684///     config.database.has_shards()`); and
7685///   * any `SQLite` **shard** `primary_url`: the shard loop in
7686///     [`run_startup_migrations`] migrates every shard through the Postgres-only
7687///     harness, so a `sqlite:` shard is sharding-on-`SQLite` regardless of the
7688///     control backend. `shard_urls` is empty when the app is unsharded or the
7689///     shard loop won't run, so the Postgres path is unaffected.
7690///
7691/// Uses the same [`crate::config::DatabaseBackend::detect`] predicate as
7692/// `db::build_pool`, so the gate and the pool routing agree on what "is a
7693/// `SQLite` URL" means.
7694#[cfg(feature = "sqlite")]
7695fn sqlite_sharding_unsupported_guard(
7696    control_url: Option<&str>,
7697    control_sharding_required: bool,
7698    shard_urls: &[&str],
7699) -> Result<(), String> {
7700    fn is_sqlite(url: &str) -> bool {
7701        crate::config::DatabaseBackend::detect(url) == Some(crate::config::DatabaseBackend::Sqlite)
7702    }
7703    if control_url.is_some_and(is_sqlite) && control_sharding_required {
7704        return Err(
7705            "SQLite deployments do not support sharding. The configured sqlite:// control target \
7706             has sharding enabled (shards and/or the directory/shard-map control migrations), \
7707             which is a Postgres-only capability \u{2014} remove the shard configuration to run \
7708             on SQLite, or use a Postgres control database. Tracking: #1614."
7709                .to_owned(),
7710        );
7711    }
7712    if shard_urls.iter().copied().any(is_sqlite) {
7713        return Err(
7714            "SQLite deployments do not support sharding. A configured shard targets a SQLite \
7715             database, and per-shard migration/fan-out is a Postgres-only capability \u{2014} \
7716             remove the SQLite shard configuration to run on SQLite, or use Postgres shard \
7717             targets. Tracking: #1614."
7718                .to_owned(),
7719        );
7720    }
7721    Ok(())
7722}
7723
7724#[cfg(all(test, feature = "sqlite"))]
7725mod sqlite_sharding_unsupported_guard_tests {
7726    use super::sqlite_sharding_unsupported_guard;
7727
7728    #[test]
7729    fn sqlite_control_target_with_sharding_fails_fast() {
7730        // PR3: a sqlite:// control URL with sharding enabled must fail fast with
7731        // an actionable, sharding-named boot error — sharding is Postgres-only.
7732        for url in [
7733            "sqlite:///var/lib/app.db",
7734            "sqlite://./relative.db",
7735            "sqlite::memory:",
7736        ] {
7737            let err = sqlite_sharding_unsupported_guard(Some(url), true, &[])
7738                .expect_err("sqlite control target + sharding must be rejected");
7739            assert!(
7740                err.contains("do not support sharding"),
7741                "message must name the sharding situation clearly: {err}"
7742            );
7743            assert!(
7744                err.contains("#1614"),
7745                "message must point at the tracking issue: {err}"
7746            );
7747        }
7748    }
7749
7750    #[test]
7751    fn sqlite_control_target_without_sharding_boots() {
7752        // PR3 behavior: a sqlite target with registered (non-sharding)
7753        // migrations now boots — they are applied by `auto_migrate_sqlite`.
7754        for url in [
7755            "sqlite:///var/lib/app.db",
7756            "sqlite://./relative.db",
7757            "sqlite::memory:",
7758        ] {
7759            assert!(
7760                sqlite_sharding_unsupported_guard(Some(url), false, &[]).is_ok(),
7761                "sqlite target without sharding must boot (migrations now applied): {url}"
7762            );
7763        }
7764    }
7765
7766    #[test]
7767    fn postgres_target_is_unchanged() {
7768        // The default Postgres path is untouched, sharding required or not.
7769        for url in [
7770            "postgres://u@h/db",
7771            "postgresql://user:pass@db:5432/app",
7772            "host=db user=app sslmode=require",
7773        ] {
7774            assert!(
7775                sqlite_sharding_unsupported_guard(Some(url), true, &[]).is_ok(),
7776                "postgres target must never be gated: {url}"
7777            );
7778            assert!(
7779                sqlite_sharding_unsupported_guard(Some(url), false, &[]).is_ok(),
7780                "postgres target must never be gated: {url}"
7781            );
7782        }
7783    }
7784
7785    #[test]
7786    fn absent_control_url_boots() {
7787        // No control URL (no-DB / opt-out provider): nothing to gate.
7788        assert!(sqlite_sharding_unsupported_guard(None, true, &[]).is_ok());
7789        assert!(sqlite_sharding_unsupported_guard(None, false, &[]).is_ok());
7790    }
7791
7792    #[test]
7793    fn sqlite_shard_target_fails_fast() {
7794        // A `sqlite:` shard `primary_url` is sharding-on-SQLite regardless of the
7795        // control backend and regardless of migrations — the shard loop routes it
7796        // through the Postgres-only harness. It must be rejected with the
7797        // actionable sharding error.
7798        for shards in [
7799            &["sqlite:///var/lib/shard0.db"][..],
7800            &["sqlite:///var/lib/shard0.db", "postgres://u@h/shard1"][..],
7801        ] {
7802            let err = sqlite_sharding_unsupported_guard(None, false, shards)
7803                .expect_err("a sqlite shard target must be rejected");
7804            assert!(
7805                err.contains("do not support sharding") && err.contains("shard"),
7806                "message must name the SQLite shard situation clearly: {err}"
7807            );
7808            assert!(
7809                err.contains("#1614"),
7810                "message must point at the tracking issue: {err}"
7811            );
7812        }
7813    }
7814
7815    #[test]
7816    fn postgres_shard_targets_are_unchanged() {
7817        // All-Postgres shards are never gated, sharding required or not.
7818        assert!(
7819            sqlite_sharding_unsupported_guard(
7820                Some("postgres://u@h/control"),
7821                true,
7822                &["postgres://u@h/shard0", "postgres://u@h/shard1"],
7823            )
7824            .is_ok(),
7825            "all-postgres shard targets must never be gated"
7826        );
7827    }
7828
7829    #[test]
7830    fn migrate_only_mode_reuses_the_boot_guard_for_sqlite_targets() {
7831        // `run_migrate_only_mode` (the `AUTUMN_MIGRATE=1` one-shot) applies the
7832        // SAME guard as normal boot BEFORE its migration loop, so the boot and
7833        // migrate-only paths cannot drift. A sqlite control/shard target with
7834        // sharding still fails fast; a plain sqlite control target now proceeds
7835        // (its migrations are applied by the sqlite apply path).
7836
7837        // A sqlite CONTROL migrate target with sharding → actionable error.
7838        let err = sqlite_sharding_unsupported_guard(Some("sqlite:///var/lib/app.db"), true, &[])
7839            .expect_err("sqlite migrate control target with sharding must be rejected");
7840        assert!(
7841            err.contains("do not support sharding") && err.contains("#1614"),
7842            "migrate-only sqlite control error must be the actionable sharding message: {err}"
7843        );
7844
7845        // A sqlite SHARD migrate target → actionable error.
7846        let shard_err = sqlite_sharding_unsupported_guard(
7847            Some("postgres://u@h/control"),
7848            true,
7849            &["sqlite:///var/lib/shard0.db"],
7850        )
7851        .expect_err("sqlite migrate shard target must be rejected");
7852        assert!(
7853            shard_err.contains("do not support sharding") && shard_err.contains("shard"),
7854            "migrate-only sqlite shard error must be the actionable sharding message: {shard_err}"
7855        );
7856
7857        // An all-Postgres migrate configuration (control + shards) is never gated.
7858        assert!(
7859            sqlite_sharding_unsupported_guard(
7860                Some("postgres://u@h/control"),
7861                true,
7862                &["postgres://u@h/shard0"],
7863            )
7864            .is_ok(),
7865            "an all-postgres migrate configuration must proceed unchanged"
7866        );
7867
7868        // A plain sqlite migrate control target (no sharding) proceeds — its
7869        // migrations are applied by the sqlite apply path.
7870        assert!(
7871            sqlite_sharding_unsupported_guard(Some("sqlite:///var/lib/app.db"), false, &[]).is_ok(),
7872            "sqlite control target without sharding must never be gated"
7873        );
7874    }
7875}
7876
7877#[cfg(feature = "db")]
7878#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
7879async fn run_startup_migrations(
7880    config: &AutumnConfig,
7881    control_configured: bool,
7882    shards_configured: bool,
7883    provider_migration_url: Option<String>,
7884    migrations: Vec<crate::migrate::EmbeddedMigrations>,
7885    directory_migration_required: bool,
7886    shard_map_migration_required: bool,
7887) {
7888    let control_url = if control_configured {
7889        // Prefer a provider-resolved URL (e.g. managed Postgres, whose socket
7890        // URL isn't in config) carried on the topology: the runtime pool is
7891        // built from it, so embedded startup migrations must target it — even if
7892        // a stale `database.url`/`primary_url` is still configured (an existing
7893        // app adopting the provider). Fall back to the configured URL otherwise.
7894        provider_migration_url
7895            .or_else(|| config.database.effective_primary_url().map(str::to_owned))
7896    } else {
7897        None
7898    };
7899    let shard_targets: Vec<(String, String)> = if shards_configured {
7900        config
7901            .database
7902            .shards
7903            .iter()
7904            .map(|shard| (format!("shard:{}", shard.name), shard.primary_url.clone()))
7905            .collect()
7906    } else {
7907        Vec::new()
7908    };
7909    let profile = config.profile.clone();
7910    let auto_in_prod = config.database.auto_migrate_in_production;
7911    let migration_result = tokio::task::spawn_blocking(move || {
7912        // SQLite single-writer startup-migration path (issue #1614, PR3): apply
7913        // the registered migrations to a `sqlite://` control target with NO
7914        // advisory lock. Sharding (directory / shard-map / per-shard fan-out) is
7915        // Postgres-only and is rejected upstream in `setup_database`
7916        // (`sqlite_sharding_unsupported_guard`), so there is nothing shard- or
7917        // directory-related to do on this path — the directory/shard-map framework
7918        // migrations below are skipped for a SQLite control target. The Postgres
7919        // path is left byte-identical for every non-SQLite target.
7920        #[cfg(feature = "sqlite")]
7921        if let Some(url) = control_url.as_deref()
7922            && crate::config::DatabaseBackend::detect(url)
7923                == Some(crate::config::DatabaseBackend::Sqlite)
7924        {
7925            for mig in &migrations {
7926                crate::migrate::auto_migrate_sqlite(
7927                    url,
7928                    profile.as_deref(),
7929                    auto_in_prod,
7930                    mig,
7931                    "control",
7932                );
7933            }
7934            return;
7935        }
7936
7937        if let Some(url) = control_url {
7938            for mig in &migrations {
7939                crate::migrate::auto_migrate(
7940                    &url,
7941                    profile.as_deref(),
7942                    auto_in_prod,
7943                    mig,
7944                    "control",
7945                );
7946            }
7947            // The shard directory table lives on the control plane only, so it
7948            // is applied here and never to the per-shard targets below.
7949            if directory_migration_required {
7950                crate::migrate::auto_migrate(
7951                    &url,
7952                    profile.as_deref(),
7953                    auto_in_prod,
7954                    &crate::sharding::SHARD_DIRECTORY_MIGRATIONS,
7955                    "control",
7956                );
7957            }
7958            // The shard-map guard table also lives on the control plane only.
7959            // Always allow auto-applying this framework-internal table: the guard
7960            // depends on it existing and returns a hard error when it's missing,
7961            // so skipping the migration in production would block startup.
7962            if shard_map_migration_required {
7963                crate::migrate::auto_migrate(
7964                    &url,
7965                    profile.as_deref(),
7966                    true,
7967                    &crate::sharding::SHARD_MAP_MIGRATIONS,
7968                    "control",
7969                );
7970            }
7971        }
7972        // Shards hold tenant data, not the control-plane schema. If the app
7973        // registered the full control `FRAMEWORK_MIGRATIONS` set (as some
7974        // examples do), skip it for shard targets — otherwise startup would
7975        // create the control tables on every shard and (with auto-migrate off)
7976        // keep reporting them as pending, even though `autumn migrate --shard`
7977        // applies only the shard-required framework migrations.
7978        for (target, url) in &shard_targets {
7979            for mig in migrations
7980                .iter()
7981                .filter(|mig| !migration_set_is_control_framework(mig))
7982            {
7983                crate::migrate::auto_migrate(url, profile.as_deref(), auto_in_prod, mig, target);
7984            }
7985        }
7986    })
7987    .await;
7988    if let Err(e) = migration_result {
7989        tracing::error!(error = %e, "Migration task panicked");
7990        // Same orphan hazard as a migration failure: `process::exit` skips
7991        // `on_shutdown`, so stop any managed Postgres before bailing. We are back
7992        // on the Tokio runtime here (after the `spawn_blocking` await), so use the
7993        // async stop — the sync `emergency_stop` would panic nesting a runtime.
7994        #[cfg(feature = "managed-pg")]
7995        crate::managed_pg::emergency_stop_async().await;
7996        std::process::exit(1);
7997    }
7998}
7999
8000/// Per-shard replica migration parity feeds each shard's runtime state
8001/// (the analogue of `ProbeState`'s control-replica dependency), which
8002/// gates that shard's replica reads per its `replica_fallback`.
8003#[cfg(feature = "db")]
8004async fn check_shard_replica_migration_parity(
8005    config: &AutumnConfig,
8006    set: &crate::sharding::ShardSet,
8007) {
8008    for (shard_config, shard) in config.database.shards.iter().zip(set.iter()) {
8009        let Some(replica_url) = shard_config.replica_url.as_deref() else {
8010            continue;
8011        };
8012        // Remember the URLs so the per-shard health indicator can re-run
8013        // the parity comparison on later readiness probes, and claim the
8014        // recheck throttle slot for the check that runs right here.
8015        shard
8016            .runtime()
8017            .configure_migration_check(shard_config.primary_url.clone(), replica_url.to_owned());
8018        let _ = shard.runtime().parity_check_due();
8019        let readiness = crate::migrate::check_replica_migration_readiness_blocking(
8020            shard_config.primary_url.clone(),
8021            replica_url.to_owned(),
8022        )
8023        .await;
8024        if readiness.is_ready() {
8025            shard.runtime().mark_replica_migrations_ready();
8026        } else if let Some(detail) = readiness.detail() {
8027            tracing::warn!(
8028                shard = %shard.name(),
8029                detail = %detail,
8030                "shard replica migrations are not ready"
8031            );
8032            shard.runtime().mark_replica_migrations_unready(detail);
8033        }
8034    }
8035}
8036
8037#[cfg(feature = "db")]
8038const REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION: &str =
8039    "20260515000000_create_repository_commit_hook_queue";
8040
8041#[cfg(feature = "db")]
8042const VERSION_HISTORY_MIGRATION: &str = "20260526000000_create_version_history";
8043
8044/// Whether startup should create the control-plane `_autumn_shard_directory`
8045/// table. It is required only when directory routing is enabled AND shards are
8046/// configured AND we are in a real runtime boot — never during a static build
8047/// (`autumn build`, `AUTUMN_BUILD_STATIC=1`), which renders assets and must not
8048/// touch the database, mirroring how the other runtime framework migrations are
8049/// suppressed in [`migrations_with_repository_framework_migrations`].
8050#[cfg(feature = "db")]
8051const fn directory_migration_is_required(
8052    directory_routing_enabled: bool,
8053    has_shards: bool,
8054    mode: RepositoryCommitHookQueueMigrationMode,
8055) -> bool {
8056    directory_routing_enabled
8057        && has_shards
8058        && matches!(mode, RepositoryCommitHookQueueMigrationMode::Runtime)
8059}
8060
8061/// Whether startup should create the control-plane `_autumn_shard_map` table.
8062/// Required whenever shards are configured and we are in a real runtime boot —
8063/// never during a static build (`autumn build`, `AUTUMN_BUILD_STATIC=1`).
8064/// The guard itself is further gated to auto-split mode inside
8065/// `enforce_shard_map_guard`; the table is always created when shards are
8066/// present so an app can switch from explicit to auto-split later without a
8067/// manual migration.
8068#[cfg(feature = "db")]
8069const fn shard_map_migration_is_required(
8070    has_shards: bool,
8071    mode: RepositoryCommitHookQueueMigrationMode,
8072) -> bool {
8073    has_shards && matches!(mode, RepositoryCommitHookQueueMigrationMode::Runtime)
8074}
8075
8076/// Row type for reading `_autumn_shard_map`.
8077#[cfg(feature = "db")]
8078#[derive(diesel::QueryableByName)]
8079struct ShardMapRow {
8080    #[diesel(sql_type = diesel::sql_types::Text)]
8081    shard_name: String,
8082    #[diesel(sql_type = diesel::sql_types::Text)]
8083    slots: String,
8084}
8085
8086/// Check and persist the shard slot map in `_autumn_shard_map`.
8087///
8088/// This is the DB-backed core of the boot-time guard: it reads existing rows,
8089/// delegates to the pure [`crate::config::check_stored_slot_map`] for the
8090/// comparison, and persists the map on first boot (no rows yet). Factored out
8091/// of `enforce_shard_map_guard` so integration tests can drive it directly
8092/// without a full `AutumnConfig`.
8093///
8094/// # Errors
8095///
8096/// Returns a `String` error when the computed auto-split map differs from the
8097/// stored map, indicating a topology change that would silently misroute data.
8098#[cfg(feature = "db")]
8099pub async fn run_shard_map_guard(
8100    control_pool: &deadpool::managed::Pool<
8101        diesel_async::pooled_connection::AsyncDieselConnectionManager<
8102            diesel_async::AsyncPgConnection,
8103        >,
8104    >,
8105    computed: &[crate::config::ShardSlotAssignment],
8106    auto_split: bool,
8107) -> Result<(), String> {
8108    use diesel_async::RunQueryDsl as _;
8109
8110    if !auto_split {
8111        return Ok(());
8112    }
8113
8114    let mut conn = match control_pool.get().await {
8115        Ok(conn) => conn,
8116        Err(e) => {
8117            return Err(format!(
8118                "shard-map guard could not acquire a control connection: {e} — \
8119                 ensure the control database is reachable to enforce topology \
8120                 change detection"
8121            ));
8122        }
8123    };
8124
8125    let rows: Vec<ShardMapRow> = match diesel::sql_query(
8126        "SELECT shard_name, slots FROM _autumn_shard_map ORDER BY shard_name",
8127    )
8128    .load::<ShardMapRow>(&mut conn)
8129    .await
8130    {
8131        Ok(rows) => rows,
8132        Err(e) => {
8133            return Err(format!(
8134                "shard-map guard could not read _autumn_shard_map: {e} — \
8135                 run `autumn migrate` to create the control schema before \
8136                 starting with auto-split shards"
8137            ));
8138        }
8139    };
8140
8141    let stored: Vec<crate::config::ShardSlotAssignment> = rows
8142        .into_iter()
8143        .map(|r| crate::config::ShardSlotAssignment {
8144            name: r.shard_name,
8145            ranges: r.slots,
8146        })
8147        .collect();
8148    let stored_opt = if stored.is_empty() {
8149        None
8150    } else {
8151        Some(stored.as_slice())
8152    };
8153
8154    crate::config::check_stored_slot_map(auto_split, computed, stored_opt)?;
8155
8156    // First boot: persist the current map so future boots can compare against it.
8157    // Wrapped in a transaction so a mid-loop failure leaves no partial rows —
8158    // partial rows would cause a spurious mismatch error on the next boot attempt.
8159    if stored.is_empty() {
8160        use diesel_async::AsyncConnection as _;
8161        let assignments: Vec<_> = computed.to_vec();
8162        conn.transaction::<(), diesel::result::Error, _>(async move |conn| {
8163            for assignment in &assignments {
8164                diesel::sql_query(
8165                    "INSERT INTO _autumn_shard_map (shard_name, slots) VALUES ($1, $2) \
8166                     ON CONFLICT (shard_name) DO UPDATE \
8167                     SET slots = EXCLUDED.slots, updated_at = NOW()",
8168                )
8169                .bind::<diesel::sql_types::Text, _>(&assignment.name)
8170                .bind::<diesel::sql_types::Text, _>(&assignment.ranges)
8171                .execute(conn)
8172                .await?;
8173            }
8174            Ok(())
8175        })
8176        .await
8177        .map_err(|e| format!("shard-map guard could not persist map: {e}"))?;
8178    }
8179
8180    Ok(())
8181}
8182
8183/// Boot-time shard-map guard: compare the auto-split slot map against the
8184/// persisted map and refuse to start if they differ.
8185///
8186/// No-op when:
8187/// - not a runtime boot (static build),
8188/// - no shards configured,
8189/// - no control database topology, or
8190/// - the slot map uses explicit `slots` declarations (auto-split is inactive).
8191// Sharding (the shard-map guard, auto-split, and control-DB shard map) is a
8192// Postgres-only feature: `run_shard_map_guard` drives Postgres `sql_query`
8193// against a `Pool<AsyncPgConnection>` control pool. Under the `sqlite` feature
8194// the runtime topology's pool is a SQLite pool that cannot feed it, and SQLite
8195// deployments are single-node/unsharded, so the guard is a no-op.
8196#[cfg(all(feature = "db", feature = "sqlite"))]
8197#[allow(clippy::unused_async)]
8198async fn enforce_shard_map_guard(
8199    config: &AutumnConfig,
8200    topology: Option<&crate::db::DatabaseTopology>,
8201    runtime_boot: bool,
8202) -> Result<(), String> {
8203    let _ = (config, topology, runtime_boot);
8204    Ok(())
8205}
8206
8207#[cfg(all(feature = "db", not(feature = "sqlite")))]
8208async fn enforce_shard_map_guard(
8209    config: &AutumnConfig,
8210    topology: Option<&crate::db::DatabaseTopology>,
8211    runtime_boot: bool,
8212) -> Result<(), String> {
8213    if !runtime_boot || !config.database.has_shards() {
8214        return Ok(());
8215    }
8216    let Some(topology) = topology else {
8217        return Ok(());
8218    };
8219    if !config.database.shards_auto_split() {
8220        return Ok(());
8221    }
8222    let computed = config
8223        .database
8224        .resolved_shard_assignments()
8225        .map_err(|e| format!("shard-map guard: {e}"))?;
8226    run_shard_map_guard(topology.primary(), &computed, true).await
8227}
8228
8229#[cfg(feature = "db")]
8230#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8231enum RepositoryCommitHookQueueMigrationMode {
8232    Runtime,
8233    StaticBuild,
8234}
8235
8236#[cfg(feature = "db")]
8237fn migrations_with_repository_framework_migrations(
8238    mut migrations: Vec<crate::migrate::EmbeddedMigrations>,
8239    hook_queue_required: bool,
8240    version_history_required: bool,
8241    mode: RepositoryCommitHookQueueMigrationMode,
8242) -> Vec<crate::migrate::EmbeddedMigrations> {
8243    if hook_queue_required
8244        && mode == RepositoryCommitHookQueueMigrationMode::Runtime
8245        && !shard_applied_sets_include(&migrations, REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION)
8246    {
8247        migrations.push(crate::repository_commit_hooks::REPOSITORY_COMMIT_HOOK_MIGRATIONS);
8248    }
8249    if version_history_required
8250        && mode == RepositoryCommitHookQueueMigrationMode::Runtime
8251        && !shard_applied_sets_include(&migrations, VERSION_HISTORY_MIGRATION)
8252    {
8253        migrations.push(crate::version_history::VERSION_HISTORY_MIGRATIONS);
8254    }
8255    migrations
8256}
8257
8258/// Whether `migration_name` is already present in a set that shard targets will
8259/// actually apply — i.e. a *non*-control-framework set.
8260///
8261/// The full control [`FRAMEWORK_MIGRATIONS`](crate::migrate::FRAMEWORK_MIGRATIONS)
8262/// set is deliberately excluded: `run_startup_migrations` strips it from shard
8263/// targets, so a migration present *only* inside it never reaches the shards. If
8264/// de-duplication counted it, a sharded app that registers `FRAMEWORK_MIGRATIONS`
8265/// (and uses commit hooks / versioning) would skip appending the standalone
8266/// shard-required set yet have the control set filtered out on shards — leaving
8267/// shards without `_autumn_repository_commit_hook_queue` / `_autumn_version_history`.
8268/// Matching only shard-applied sets ensures the standalone set is appended
8269/// whenever the shards would otherwise be missing it. Re-applying it to the
8270/// control target is harmless: it shares the migration version already recorded
8271/// by the control framework set, so Diesel skips it there.
8272#[cfg(feature = "db")]
8273fn shard_applied_sets_include(
8274    migrations: &[crate::migrate::EmbeddedMigrations],
8275    migration_name: &str,
8276) -> bool {
8277    use diesel::migration::{Migration, MigrationSource as _};
8278    use diesel::pg::Pg;
8279
8280    migrations
8281        .iter()
8282        .filter(|set| !migration_set_is_control_framework(set))
8283        .any(|source| {
8284            let Ok(source_migrations): Result<Vec<Box<dyn Migration<Pg>>>, _> = source.migrations()
8285            else {
8286                return false;
8287            };
8288
8289            source_migrations
8290                .iter()
8291                .any(|migration| migration.name().to_string() == migration_name)
8292        })
8293}
8294
8295/// Whether a migration set is the control-plane
8296/// [`FRAMEWORK_MIGRATIONS`](crate::migrate::FRAMEWORK_MIGRATIONS), so it can be
8297/// skipped on shard targets.
8298///
8299/// Identified by containing a *control-only* migration — one in
8300/// `FRAMEWORK_MIGRATIONS` but not in the shard-required version-history /
8301/// commit-hook sets. Those two sets' migrations are duplicated into the control
8302/// `migrations/` directory, so a plain name overlap would also (wrongly) match
8303/// the standalone `VERSION_HISTORY_MIGRATIONS` / `REPOSITORY_COMMIT_HOOK_MIGRATIONS`
8304/// sets and strip them from shards.
8305#[cfg(feature = "db")]
8306fn migration_set_is_control_framework(set: &crate::migrate::EmbeddedMigrations) -> bool {
8307    use diesel::migration::{Migration, MigrationSource as _};
8308    use diesel::pg::Pg;
8309
8310    fn names(set: &crate::migrate::EmbeddedMigrations) -> std::collections::HashSet<String> {
8311        let migrations: Vec<Box<dyn Migration<Pg>>> = set.migrations().unwrap_or_default();
8312        migrations.iter().map(|m| m.name().to_string()).collect()
8313    }
8314
8315    let mut control_only = names(&crate::migrate::FRAMEWORK_MIGRATIONS);
8316    for shard_required in [
8317        &crate::version_history::VERSION_HISTORY_MIGRATIONS,
8318        &crate::repository_commit_hooks::REPOSITORY_COMMIT_HOOK_MIGRATIONS,
8319    ] {
8320        for name in names(shard_required) {
8321            control_only.remove(&name);
8322        }
8323    }
8324
8325    names(set).iter().any(|name| control_only.contains(name))
8326}
8327
8328#[cfg(feature = "db")]
8329fn apply_replica_migration_readiness(
8330    state: &AppState,
8331    readiness: Option<crate::migrate::ReplicaMigrationReadiness>,
8332) {
8333    let Some(readiness) = readiness else {
8334        return;
8335    };
8336
8337    if readiness.is_ready() {
8338        state.probes().mark_replica_migrations_ready();
8339    } else if let Some(detail) = readiness.detail() {
8340        state.probes().mark_replica_migrations_unready(detail);
8341    }
8342}
8343
8344#[cfg(feature = "db")]
8345fn configure_replica_migration_check(state: &AppState, check: Option<(String, String)>) {
8346    let Some((primary_url, replica_url)) = check else {
8347        return;
8348    };
8349
8350    state
8351        .probes()
8352        .configure_replica_migration_check(primary_url, replica_url);
8353}
8354
8355/// Refuse to start when a `#[repository(api = ...)]`-mounted route
8356/// has no paired `policy = ...` argument in `prod` profile builds.
8357///
8358/// The issue text spells out the rationale: silently shipping
8359/// auto-generated CRUD endpoints with no record-level authz is a
8360/// security regression. The escape hatch is
8361/// `[security] allow_unauthorized_repository_api = true`.
8362/// Pure offender-collection logic for
8363/// [`validate_repository_api_policies`].
8364///
8365/// Walks both top-level routes and routes registered under
8366/// `.scoped(prefix, layer, routes)` groups, returning every
8367/// `#[repository(api = ...)]`-mounted *mutating* route that has no
8368/// paired `policy = ...` argument. Read-only mounts (GET
8369/// `*_api_list` / `*_api_get`) are intentionally excluded — they
8370/// don't fit the "any authenticated user can write to any record"
8371/// footgun the issue calls out. Read-leak concerns are handled
8372/// separately by `scope = ...`.
8373///
8374/// Returned in (resource type name, api path) form, deduped per
8375/// `(type, path)` pair so a repository with multiple unguarded
8376/// methods only shows up once.
8377fn collect_unguarded_repository_writes(
8378    routes: &[Route],
8379    scoped_groups: &[ScopedGroup],
8380) -> Vec<(String, String)> {
8381    let mut offenders: Vec<(String, String)> = Vec::new();
8382    let mut seen: std::collections::HashSet<(&'static str, &'static str)> =
8383        std::collections::HashSet::new();
8384    let mut record_route = |route: &Route| {
8385        if let Some(meta) = route.repository
8386            && !meta.has_policy
8387            && is_mutating_method(&route.method)
8388            && seen.insert((meta.resource_type_name, meta.api_path))
8389        {
8390            offenders.push((meta.resource_type_name.to_owned(), meta.api_path.to_owned()));
8391        }
8392    };
8393    for route in routes {
8394        record_route(route);
8395    }
8396    for group in scoped_groups {
8397        for route in &group.routes {
8398            record_route(route);
8399        }
8400    }
8401    offenders
8402}
8403
8404/// Format a list of `(type, path)` offenders into the bulleted
8405/// listing the startup tracing emits. Pure so the format string
8406/// can be unit-tested without going through `tracing` machinery.
8407fn format_unguarded_repository_listing(offenders: &[(String, String)]) -> String {
8408    use std::fmt::Write;
8409    let mut s = String::new();
8410    let mut first = true;
8411    for (name, path) in offenders {
8412        if !first {
8413            s.push('\n');
8414        }
8415        first = false;
8416        write!(s, "  - #[repository({name}, api = \"{path}\")]").unwrap();
8417    }
8418    s
8419}
8420
8421fn validate_repository_api_policies(
8422    routes: &[Route],
8423    scoped_groups: &[ScopedGroup],
8424    config: &AutumnConfig,
8425) {
8426    let profile = config.profile.as_deref().unwrap_or("default");
8427    let strict =
8428        is_production_profile(profile) && !config.security.allow_unauthorized_repository_api;
8429
8430    let offenders = collect_unguarded_repository_writes(routes, scoped_groups);
8431    if offenders.is_empty() {
8432        return;
8433    }
8434
8435    let listing = format_unguarded_repository_listing(&offenders);
8436
8437    if strict {
8438        tracing::error!(
8439            "refusing to start: the following #[repository(api = ...)] mutating endpoints have no paired `policy = ...` argument:\n{listing}\n\
8440             Add `policy = SomePolicy` to each, or set `[security] allow_unauthorized_repository_api = true` to opt out explicitly."
8441        );
8442        std::process::exit(1);
8443    } else {
8444        tracing::warn!(
8445            "the following #[repository(api = ...)] mutating endpoints have no paired `policy = ...` argument; \
8446             auto-generated POST/PUT/PATCH/DELETE handlers will accept writes from any authenticated user:\n{listing}\n\
8447             This will become a startup-time error in `prod` profile builds."
8448        );
8449    }
8450}
8451
8452/// Refuse to start when a `#[repository(policy = X)]`-annotated
8453/// route exists but the corresponding `.policy::<R, _>(X)`
8454/// registration was never actually applied to the live
8455/// [`PolicyRegistry`](crate::authorization::PolicyRegistry).
8456///
8457/// `validate_repository_api_policies` runs *before* the registry is
8458/// populated and only checks the macro-set `has_policy` flag. This
8459/// runs *after* registrations are applied and walks the same routes,
8460/// invoking the macro-emitted `policy_check` probe to confirm the
8461/// policy is really there. Without this, forgetting the
8462/// `.policy::<R, _>(...)` builder call would compile, boot, and
8463/// then 500 on every protected request.
8464/// `(resource_type_name, api_path)` pair identifying a repository
8465/// route that's missing its required runtime registration.
8466type MissingRepositoryRegistration = (String, String);
8467
8468/// Pure offender-collection logic for
8469/// [`validate_repository_policies_registered`].
8470///
8471/// Walks the same routes + scoped groups and invokes the macro-
8472/// emitted `policy_check` / `scope_check` probes against the live
8473/// registry, returning `(missing_policies, missing_scopes)` deduped
8474/// per `(type, path)` pair. Pure so the listing logic can be unit-
8475/// tested without going through the actual `tracing::error!` +
8476/// `std::process::exit(1)` strict path.
8477fn collect_unregistered_repository_handlers(
8478    routes: &[Route],
8479    scoped_groups: &[ScopedGroup],
8480    registry: &crate::authorization::PolicyRegistry,
8481) -> (
8482    Vec<MissingRepositoryRegistration>,
8483    Vec<MissingRepositoryRegistration>,
8484) {
8485    let mut missing_policies: Vec<(String, String)> = Vec::new();
8486    let mut missing_scopes: Vec<(String, String)> = Vec::new();
8487    let mut seen_policies: std::collections::HashSet<(&'static str, &'static str)> =
8488        std::collections::HashSet::new();
8489    let mut seen_scopes: std::collections::HashSet<(&'static str, &'static str)> =
8490        std::collections::HashSet::new();
8491    let mut record_route = |route: &Route| {
8492        if let Some(meta) = route.repository {
8493            if let Some(check) = meta.policy_check
8494                && !check(registry)
8495                && seen_policies.insert((meta.resource_type_name, meta.api_path))
8496            {
8497                missing_policies
8498                    .push((meta.resource_type_name.to_owned(), meta.api_path.to_owned()));
8499            }
8500            if let Some(check) = meta.scope_check
8501                && !check(registry)
8502                && seen_scopes.insert((meta.resource_type_name, meta.api_path))
8503            {
8504                missing_scopes.push((meta.resource_type_name.to_owned(), meta.api_path.to_owned()));
8505            }
8506        }
8507    };
8508    for route in routes {
8509        record_route(route);
8510    }
8511    for group in scoped_groups {
8512        for route in &group.routes {
8513            record_route(route);
8514        }
8515    }
8516    (missing_policies, missing_scopes)
8517}
8518
8519/// Format a `(type, path)` listing for missing-policy startup
8520/// errors. Pure so the format string can be unit-tested.
8521fn format_missing_policy_listing(missing: &[(String, String)]) -> String {
8522    use std::fmt::Write;
8523    let mut s = String::new();
8524    let mut first = true;
8525    for (name, path) in missing {
8526        if !first {
8527            s.push('\n');
8528        }
8529        first = false;
8530        write!(s, "  - #[repository({name}, api = \"{path}\", policy = ...)]: call `.policy::<{name}, _>(...)` on the app builder").unwrap();
8531    }
8532    s
8533}
8534
8535/// Format a `(type, path)` listing for missing-scope startup
8536/// errors. Pure so the format string can be unit-tested.
8537fn format_missing_scope_listing(missing: &[(String, String)]) -> String {
8538    use std::fmt::Write;
8539    let mut s = String::new();
8540    let mut first = true;
8541    for (name, path) in missing {
8542        if !first {
8543            s.push('\n');
8544        }
8545        first = false;
8546        write!(s, "  - #[repository({name}, api = \"{path}\", scope = ...)]: call `.scope::<{name}, _>(...)` on the app builder").unwrap();
8547    }
8548    s
8549}
8550
8551#[allow(clippy::cognitive_complexity)]
8552fn validate_repository_policies_registered(
8553    routes: &[Route],
8554    scoped_groups: &[ScopedGroup],
8555    state: &AppState,
8556    config: &AutumnConfig,
8557) {
8558    let profile = config.profile.as_deref().unwrap_or("default");
8559    let strict = is_production_profile(profile);
8560
8561    let (missing_policies, missing_scopes) =
8562        collect_unregistered_repository_handlers(routes, scoped_groups, state.policy_registry());
8563
8564    if missing_policies.is_empty() && missing_scopes.is_empty() {
8565        return;
8566    }
8567
8568    if !missing_policies.is_empty() {
8569        let listing = format_missing_policy_listing(&missing_policies);
8570
8571        if strict {
8572            tracing::error!(
8573                "refusing to start: the following #[repository] routes declare a `policy = ...` argument, but no policy is registered for the resource type. Without registration, every protected request would fail at runtime with `500 no policy registered`:\n{listing}"
8574            );
8575        } else {
8576            tracing::warn!(
8577                "the following #[repository] routes declare `policy = ...` but no matching `.policy::<R, _>(...)` registration is on the app builder. Protected requests will 500 at runtime:\n{listing}\n\
8578                 This will become a startup-time error in `prod` profile builds."
8579            );
8580        }
8581    }
8582
8583    if !missing_scopes.is_empty() {
8584        let listing = format_missing_scope_listing(&missing_scopes);
8585
8586        if strict {
8587            tracing::error!(
8588                "refusing to start: the following #[repository] routes declare a `scope = ...` argument, but no scope is registered for the resource type. Without registration, every list request would fail at runtime with `500 missing scope registration`:\n{listing}"
8589            );
8590        } else {
8591            tracing::warn!(
8592                "the following #[repository] routes declare `scope = ...` but no matching `.scope::<R, _>(...)` registration is on the app builder. List requests will 500 at runtime:\n{listing}\n\
8593                 This will become a startup-time error in `prod` profile builds."
8594            );
8595        }
8596    }
8597
8598    if strict {
8599        std::process::exit(1);
8600    }
8601}
8602
8603const fn is_mutating_method(method: &http::Method) -> bool {
8604    matches!(
8605        *method,
8606        http::Method::POST | http::Method::PUT | http::Method::PATCH | http::Method::DELETE
8607    )
8608}
8609
8610/// Returns `true` for the framework's accepted production profile
8611/// names. Mirrors the `prod | production` matching used elsewhere
8612/// (`app.rs::run_build_mode`, `migrate.rs::should_auto_apply`,
8613/// etc.) so the repository startup guards don't silently weaken in
8614/// deployments that pick the long-form alias.
8615fn is_production_profile(profile: &str) -> bool {
8616    matches!(profile, "prod" | "production")
8617}
8618
8619#[cfg(test)]
8620mod validate_repository_api_policies_tests {
8621    use super::*;
8622    use crate::RepositoryApiMeta;
8623
8624    fn build_route(
8625        method: http::Method,
8626        path: &'static str,
8627        meta: Option<RepositoryApiMeta>,
8628    ) -> Route {
8629        Route {
8630            method,
8631            path,
8632            handler: axum::routing::any(|| async { "" }),
8633            name: "test_route",
8634            api_doc: crate::openapi::ApiDoc::default(),
8635            repository: meta,
8636            idempotency: crate::route::RouteIdempotency::Direct,
8637            timeout: crate::route::RouteTimeout::Inherit,
8638            api_version: None,
8639            sunset_opt_out: false,
8640        }
8641    }
8642
8643    fn unguarded(path: &'static str, type_name: &'static str) -> RepositoryApiMeta {
8644        RepositoryApiMeta {
8645            resource_type_name: type_name,
8646            api_path: path,
8647            has_policy: false,
8648            policy_check: None,
8649            scope_check: None,
8650        }
8651    }
8652
8653    /// Tests in this module historically used a duplicated copy of
8654    /// the offender-collection logic. Now they call the production
8655    /// helper directly so coverage tracks the real code path.
8656    fn collect_offenders(routes: &[Route]) -> Vec<(String, String)> {
8657        collect_unguarded_repository_writes(routes, &[])
8658    }
8659
8660    #[test]
8661    fn read_only_mount_without_policy_is_not_an_offender() {
8662        let routes = vec![
8663            build_route(
8664                http::Method::GET,
8665                "/api/posts",
8666                Some(unguarded("/api/posts", "Post")),
8667            ),
8668            build_route(
8669                http::Method::GET,
8670                "/api/posts/{id}",
8671                Some(unguarded("/api/posts", "Post")),
8672            ),
8673        ];
8674        let offenders = collect_offenders(&routes);
8675        assert!(
8676            offenders.is_empty(),
8677            "read-only mounts should not trigger the unauthorized-repo guard"
8678        );
8679    }
8680
8681    #[test]
8682    fn write_mount_without_policy_is_an_offender() {
8683        let routes = vec![build_route(
8684            http::Method::POST,
8685            "/api/posts",
8686            Some(unguarded("/api/posts", "Post")),
8687        )];
8688        let offenders = collect_offenders(&routes);
8689        assert_eq!(offenders.len(), 1);
8690        assert_eq!(offenders[0].0, "Post");
8691        assert_eq!(offenders[0].1, "/api/posts");
8692    }
8693
8694    #[test]
8695    fn mixed_mount_only_dedups_one_offender_per_repository() {
8696        let routes = vec![
8697            build_route(
8698                http::Method::GET,
8699                "/api/posts",
8700                Some(unguarded("/api/posts", "Post")),
8701            ),
8702            build_route(
8703                http::Method::POST,
8704                "/api/posts",
8705                Some(unguarded("/api/posts", "Post")),
8706            ),
8707            build_route(
8708                http::Method::PUT,
8709                "/api/posts/{id}",
8710                Some(unguarded("/api/posts", "Post")),
8711            ),
8712            build_route(
8713                http::Method::DELETE,
8714                "/api/posts/{id}",
8715                Some(unguarded("/api/posts", "Post")),
8716            ),
8717        ];
8718        let offenders = collect_offenders(&routes);
8719        assert_eq!(offenders.len(), 1);
8720    }
8721
8722    #[test]
8723    fn is_mutating_method_classifies_methods() {
8724        assert!(is_mutating_method(&http::Method::POST));
8725        assert!(is_mutating_method(&http::Method::PUT));
8726        assert!(is_mutating_method(&http::Method::PATCH));
8727        assert!(is_mutating_method(&http::Method::DELETE));
8728        assert!(!is_mutating_method(&http::Method::GET));
8729        assert!(!is_mutating_method(&http::Method::HEAD));
8730        assert!(!is_mutating_method(&http::Method::OPTIONS));
8731    }
8732
8733    // ── registry-aware validation (post-registration) ─────────────
8734
8735    use crate::authorization::{Policy, PolicyRegistry};
8736
8737    #[derive(Debug, Clone, PartialEq)]
8738    struct TestPost;
8739
8740    #[derive(Default)]
8741    struct TestPostPolicy;
8742    impl Policy<TestPost> for TestPostPolicy {}
8743
8744    fn guarded_with_check(path: &'static str, type_name: &'static str) -> RepositoryApiMeta {
8745        RepositoryApiMeta {
8746            resource_type_name: type_name,
8747            api_path: path,
8748            has_policy: true,
8749            policy_check: Some(|registry: &PolicyRegistry| registry.has_policy::<TestPost>()),
8750            scope_check: None,
8751        }
8752    }
8753
8754    fn collect_missing(routes: &[Route], registry: &PolicyRegistry) -> Vec<(String, String)> {
8755        let (missing_policies, _) = collect_unregistered_repository_handlers(routes, &[], registry);
8756        missing_policies
8757    }
8758
8759    #[test]
8760    fn registry_check_flags_routes_missing_their_policy_registration() {
8761        // Macro emits `policy = X` but no `.policy::<TestPost, _>(...)`
8762        // call on the builder — registry has nothing.
8763        let registry = PolicyRegistry::default();
8764        let routes = vec![build_route(
8765            http::Method::POST,
8766            "/api/posts",
8767            Some(guarded_with_check("/api/posts", "TestPost")),
8768        )];
8769        let missing = collect_missing(&routes, &registry);
8770        assert_eq!(missing.len(), 1);
8771        assert_eq!(missing[0].0, "TestPost");
8772        assert_eq!(missing[0].1, "/api/posts");
8773    }
8774
8775    #[test]
8776    fn registry_check_passes_when_policy_is_registered() {
8777        let registry = PolicyRegistry::default();
8778        registry.register_policy::<TestPost, _>(TestPostPolicy);
8779        let routes = vec![build_route(
8780            http::Method::POST,
8781            "/api/posts",
8782            Some(guarded_with_check("/api/posts", "TestPost")),
8783        )];
8784        let missing = collect_missing(&routes, &registry);
8785        assert!(missing.is_empty(), "policy is registered, no offenders");
8786    }
8787
8788    #[test]
8789    fn registry_check_skips_routes_without_policy_check_fn() {
8790        // Routes mounted without `policy = ...` carry
8791        // `policy_check: None` and are not subject to this check —
8792        // they're handled by `validate_repository_api_policies` which
8793        // looks at `has_policy` instead.
8794        let registry = PolicyRegistry::default();
8795        let routes = vec![build_route(
8796            http::Method::POST,
8797            "/api/posts",
8798            Some(unguarded("/api/posts", "TestPost")),
8799        )];
8800        let missing = collect_missing(&routes, &registry);
8801        assert!(missing.is_empty());
8802    }
8803
8804    #[test]
8805    fn registry_check_dedups_one_offender_per_repository() {
8806        let registry = PolicyRegistry::default();
8807        let routes = vec![
8808            build_route(
8809                http::Method::GET,
8810                "/api/posts",
8811                Some(guarded_with_check("/api/posts", "TestPost")),
8812            ),
8813            build_route(
8814                http::Method::POST,
8815                "/api/posts",
8816                Some(guarded_with_check("/api/posts", "TestPost")),
8817            ),
8818            build_route(
8819                http::Method::DELETE,
8820                "/api/posts/{id}",
8821                Some(guarded_with_check("/api/posts", "TestPost")),
8822            ),
8823        ];
8824        let missing = collect_missing(&routes, &registry);
8825        assert_eq!(missing.len(), 1);
8826    }
8827
8828    // ── Scope registration validation ─────────────────────────────
8829
8830    use crate::authorization::{BoxFuture, PolicyContext, Scope};
8831
8832    #[derive(Default)]
8833    struct TestPostScope;
8834    impl Scope<TestPost> for TestPostScope {
8835        fn list<'a>(
8836            &'a self,
8837            _ctx: &'a PolicyContext,
8838            _conn: &'a mut crate::db::RuntimeConnection,
8839        ) -> BoxFuture<'a, crate::AutumnResult<Vec<TestPost>>> {
8840            Box::pin(async { Ok(Vec::new()) })
8841        }
8842    }
8843
8844    fn scope_only_meta(path: &'static str, type_name: &'static str) -> RepositoryApiMeta {
8845        RepositoryApiMeta {
8846            resource_type_name: type_name,
8847            api_path: path,
8848            has_policy: false,
8849            policy_check: None,
8850            scope_check: Some(|registry: &PolicyRegistry| registry.scope::<TestPost>().is_some()),
8851        }
8852    }
8853
8854    fn collect_missing_scopes(
8855        routes: &[Route],
8856        registry: &PolicyRegistry,
8857    ) -> Vec<(String, String)> {
8858        let (_, missing_scopes) = collect_unregistered_repository_handlers(routes, &[], registry);
8859        missing_scopes
8860    }
8861
8862    #[test]
8863    fn scope_check_flags_unregistered_scope() {
8864        let registry = PolicyRegistry::default();
8865        let routes = vec![build_route(
8866            http::Method::GET,
8867            "/api/posts",
8868            Some(scope_only_meta("/api/posts", "TestPost")),
8869        )];
8870        let missing = collect_missing_scopes(&routes, &registry);
8871        assert_eq!(missing.len(), 1);
8872        assert_eq!(missing[0].0, "TestPost");
8873    }
8874
8875    #[test]
8876    fn scope_check_passes_when_scope_is_registered() {
8877        let registry = PolicyRegistry::default();
8878        registry.register_scope::<TestPost, _>(TestPostScope);
8879        let routes = vec![build_route(
8880            http::Method::GET,
8881            "/api/posts",
8882            Some(scope_only_meta("/api/posts", "TestPost")),
8883        )];
8884        let missing = collect_missing_scopes(&routes, &registry);
8885        assert!(missing.is_empty());
8886    }
8887
8888    #[test]
8889    fn scope_check_skips_routes_without_scope_check_fn() {
8890        let registry = PolicyRegistry::default();
8891        let routes = vec![build_route(
8892            http::Method::POST,
8893            "/api/posts",
8894            Some(unguarded("/api/posts", "TestPost")),
8895        )];
8896        let missing = collect_missing_scopes(&routes, &registry);
8897        assert!(missing.is_empty());
8898    }
8899
8900    // ── prod / production profile parity ────────────────────────
8901
8902    #[test]
8903    fn is_production_profile_matches_both_aliases() {
8904        assert!(is_production_profile("prod"));
8905        assert!(is_production_profile("production"));
8906        assert!(!is_production_profile("dev"));
8907        assert!(!is_production_profile("staging"));
8908        assert!(!is_production_profile("test"));
8909        assert!(!is_production_profile("default"));
8910        // Case-sensitive (matches the framework's elsewhere
8911        // matching pattern in app.rs::run_build_mode and
8912        // migrate.rs).
8913        assert!(!is_production_profile("Prod"));
8914        assert!(!is_production_profile("Production"));
8915    }
8916
8917    // ── Formatter helpers ─────────────────────────────────────────
8918
8919    #[test]
8920    fn format_unguarded_listing_renders_one_bullet_per_offender() {
8921        let offenders = vec![
8922            ("Post".to_owned(), "/api/posts".to_owned()),
8923            ("Comment".to_owned(), "/api/comments".to_owned()),
8924        ];
8925        let listing = format_unguarded_repository_listing(&offenders);
8926        assert!(listing.contains("Post"));
8927        assert!(listing.contains("/api/posts"));
8928        assert!(listing.contains("Comment"));
8929        assert!(listing.contains("/api/comments"));
8930        assert_eq!(listing.matches("\n  - ").count() + 1, 2);
8931    }
8932
8933    #[test]
8934    fn format_unguarded_listing_empty_input_yields_empty_string() {
8935        let listing = format_unguarded_repository_listing(&[]);
8936        assert!(listing.is_empty());
8937    }
8938
8939    #[test]
8940    fn format_missing_policy_listing_includes_policy_call_hint() {
8941        let missing = vec![("Post".to_owned(), "/api/posts".to_owned())];
8942        let listing = format_missing_policy_listing(&missing);
8943        assert!(listing.contains("Post"));
8944        assert!(listing.contains("/api/posts"));
8945        assert!(listing.contains(".policy::<Post, _>"));
8946        assert!(listing.contains("policy = ..."));
8947    }
8948
8949    #[test]
8950    fn format_missing_scope_listing_includes_scope_call_hint() {
8951        let missing = vec![("Post".to_owned(), "/api/posts".to_owned())];
8952        let listing = format_missing_scope_listing(&missing);
8953        assert!(listing.contains("Post"));
8954        assert!(listing.contains("/api/posts"));
8955        assert!(listing.contains(".scope::<Post, _>"));
8956        assert!(listing.contains("scope = ..."));
8957    }
8958
8959    // ── Scoped-groups path coverage ──────────────────────────────
8960
8961    #[test]
8962    fn collect_unguarded_walks_scoped_groups() {
8963        // The scoped-group path catches `#[repository(api = ...)]`
8964        // mounts that live inside `.scoped(prefix, layer, routes)`.
8965        // Without walking them, the prod-mode guard would silently
8966        // miss those routes.
8967        let group_route = build_route(
8968            http::Method::POST,
8969            "/api/posts",
8970            Some(unguarded("/api/posts", "Post")),
8971        );
8972        let group = ScopedGroup {
8973            prefix: "/scoped".to_owned(),
8974            routes: vec![group_route],
8975            source: crate::route_listing::RouteSource::User,
8976            apply_layer: Box::new(|r| r),
8977        };
8978        let offenders = collect_unguarded_repository_writes(&[], std::slice::from_ref(&group));
8979        assert_eq!(offenders.len(), 1);
8980        assert_eq!(offenders[0].0, "Post");
8981    }
8982
8983    #[test]
8984    fn collect_unregistered_walks_scoped_groups() {
8985        let group_route = build_route(
8986            http::Method::POST,
8987            "/api/posts",
8988            Some(guarded_with_check("/api/posts", "TestPost")),
8989        );
8990        let group = ScopedGroup {
8991            prefix: "/scoped".to_owned(),
8992            routes: vec![group_route],
8993            source: crate::route_listing::RouteSource::User,
8994            apply_layer: Box::new(|r| r),
8995        };
8996        let registry = PolicyRegistry::default();
8997        let (missing, _) =
8998            collect_unregistered_repository_handlers(&[], std::slice::from_ref(&group), &registry);
8999        assert_eq!(missing.len(), 1);
9000        assert_eq!(missing[0].0, "TestPost");
9001    }
9002}
9003
9004/// Publish the builder's story gallery (if any) as the [`StoryRegistry`]
9005/// (`crate::stories::StoryRegistry`) `AppState` extension read by the
9006/// `/_stories` handlers. Shared by the `run()` and build/SSG
9007/// state-construction paths so the two stay in lockstep.
9008#[cfg(feature = "maud")]
9009fn install_story_registry(state: &AppState, story_gallery: Option<crate::stories::StoryGallery>) {
9010    if let Some(gallery) = story_gallery {
9011        state.insert_extension(gallery.into_registry());
9012    }
9013}
9014
9015fn build_state(
9016    config: &AutumnConfig,
9017    #[cfg(feature = "db")] database_topology: Option<&crate::db::DatabaseTopology>,
9018    #[cfg(feature = "db")] shards: Option<crate::sharding::ShardSet>,
9019    #[cfg(feature = "ws")] channels_backend: Option<Arc<dyn crate::channels::ChannelsBackend>>,
9020) -> AppState {
9021    #[cfg(feature = "ws")]
9022    let shutdown = tokio_util::sync::CancellationToken::new();
9023    #[cfg(feature = "ws")]
9024    let channels = channels_backend.map_or_else(
9025        || {
9026            crate::channels::Channels::from_config(&config.channels, shutdown.child_token())
9027                .unwrap_or_else(|error| {
9028                    tracing::error!(error = %error, "Failed to configure channels backend");
9029                    std::process::exit(1);
9030                })
9031        },
9032        crate::channels::Channels::with_shared_backend,
9033    );
9034
9035    let state = AppState {
9036        extensions: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
9037        #[cfg(feature = "db")]
9038        pool: database_topology.map(|topology| topology.primary().clone()),
9039        #[cfg(feature = "db")]
9040        replica_pool: database_topology.and_then(|topology| topology.replica().cloned()),
9041        #[cfg(feature = "db")]
9042        shards,
9043        profile: config.profile.clone(),
9044        role: config.role,
9045        started_at: std::time::Instant::now(),
9046        health_detailed: config.health.detailed,
9047        probes: crate::probe::ProbeState::pending_startup(),
9048        metrics: crate::middleware::MetricsCollector::new(),
9049        log_levels: crate::actuator::LogLevels::new(&config.log.level),
9050        task_registry: crate::actuator::TaskRegistry::new(),
9051        job_registry: crate::actuator::JobRegistry::new(),
9052        config_props: crate::actuator::ConfigProperties::from_config(config),
9053        metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
9054        health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
9055        #[cfg(feature = "presence")]
9056        presence: crate::presence::Presence::new(channels.clone()),
9057        #[cfg(feature = "ws")]
9058        channels,
9059        #[cfg(feature = "ws")]
9060        shutdown,
9061        policy_registry: crate::authorization::PolicyRegistry::default(),
9062        forbidden_response: config.security.forbidden_response,
9063        auth_session_key: config.auth.session_key.clone(),
9064        shared_cache: None,
9065        clock: std::sync::Arc::new(crate::time::SystemClock),
9066        app_id: AppState::next_app_id(),
9067    };
9068    #[cfg(feature = "db")]
9069    if state.replica_pool.is_some() {
9070        state
9071            .probes()
9072            .configure_replica_dependency(config.database.replica_fallback);
9073    }
9074    // Surface every shard in /ready and /actuator/health as a
9075    // `db:shard:<name>` component (replica readiness refresh + pool stats).
9076    #[cfg(feature = "db")]
9077    if let Some(set) = state.shards() {
9078        crate::sharding::register_shard_health_indicators(set, &state.health_indicator_registry);
9079    }
9080    state.insert_extension(config.clone());
9081    state.insert_extension(crate::step_up::StepUpGlobalConfig {
9082        default_max_age_secs: config.auth.step_up.default_max_age_secs,
9083    });
9084    #[cfg(feature = "http-client")]
9085    state.insert_extension(crate::http_client::SharedReqwestClient {
9086        client: crate::http_client::Client::build_inner(&config.http.client),
9087        timeout_secs: config.http.client.timeout_secs,
9088    });
9089    state
9090}
9091
9092/// Build the route listing string for the transparency log.
9093fn format_route_lines(
9094    routes: &[Route],
9095    scoped_groups: &[ScopedGroup],
9096    config: &AutumnConfig,
9097) -> String {
9098    use std::fmt::Write as _;
9099
9100    let mut out = String::new();
9101    for route in routes {
9102        let _ = write!(
9103            out,
9104            "\n    {} {:<8} -> {}",
9105            route.path, route.method, route.name
9106        );
9107    }
9108    for group in scoped_groups {
9109        for route in &group.routes {
9110            let _ = write!(
9111                out,
9112                "\n    {}{} {:<8} -> {} (scoped)",
9113                group.prefix, route.path, route.method, route.name
9114            );
9115        }
9116    }
9117    let mut probe_paths = std::collections::HashSet::new();
9118    for (path, name) in [
9119        (config.health.live_path.as_str(), "live"),
9120        (config.health.ready_path.as_str(), "ready"),
9121        (config.health.startup_path.as_str(), "startup"),
9122        (config.health.path.as_str(), "health"),
9123    ] {
9124        if probe_paths.insert(path) {
9125            let _ = write!(out, "\n    {} {:<8} -> {}", path, "GET", name);
9126        }
9127    }
9128    let _ = write!(
9129        out,
9130        "\n    {} {:<8} -> actuator",
9131        crate::actuator::actuator_route_glob(&config.actuator.prefix),
9132        "GET"
9133    );
9134    #[cfg(feature = "htmx")]
9135    {
9136        out.push_str("\n    /static/js/htmx.min.js GET -> htmx");
9137        out.push_str("\n    /static/js/autumn-htmx-csrf.js GET -> htmx csrf");
9138    }
9139    out
9140}
9141
9142/// Build the scheduled task listing string. Returns `None` if there are no tasks.
9143fn format_task_lines(tasks: &[crate::task::TaskInfo]) -> Option<String> {
9144    use std::fmt::Write as _;
9145
9146    if tasks.is_empty() {
9147        return None;
9148    }
9149
9150    let mut out = String::new();
9151    for task in tasks {
9152        let schedule = task.schedule.to_string();
9153        let _ = write!(out, "\n    {} ({schedule})", task.name);
9154    }
9155    Some(out)
9156}
9157
9158/// Build the active middleware listing string.
9159fn format_middleware_list(config: &AutumnConfig) -> String {
9160    let mut items = vec![
9161        "RequestId",
9162        "SecurityHeaders",
9163        "Session (in-memory)",
9164        "ErrorPages",
9165    ];
9166    if !config.cors.allowed_origins.is_empty() {
9167        items.push("CORS");
9168    }
9169    if config.security.csrf.enabled {
9170        items.push("CSRF");
9171    }
9172    items.push("Metrics");
9173    items.join(", ")
9174}
9175
9176/// Mask a database URL password for safe logging.
9177fn mask_database_url(url: &str, pool_size: usize) -> String {
9178    if let Ok(mut parsed_url) = url::Url::parse(url) {
9179        if parsed_url.password().is_some() {
9180            let _ = parsed_url.set_password(Some("****"));
9181            return format!("{parsed_url} (pool_size={pool_size})");
9182        }
9183        format!("{parsed_url} (pool_size={pool_size})")
9184    } else {
9185        // Fallback: If URL parsing fails, mask the entire URL string to prevent any
9186        // potential data exposure (e.g. if the malformed string still contained a password)
9187        format!("**** (pool_size={pool_size})")
9188    }
9189}
9190
9191/// Build the configuration summary string.
9192fn format_config_summary(config: &AutumnConfig) -> String {
9193    let profile = config.profile.as_deref().unwrap_or("none");
9194    let db_status = config.database.effective_primary_url().map_or_else(
9195        || "not configured".to_owned(),
9196        |url| {
9197            let primary = mask_database_url(url, config.database.effective_primary_pool_size());
9198            if config.database.replica_url.is_some() {
9199                format!(
9200                    "primary={primary}, replica=configured (pool_size={})",
9201                    config.database.effective_replica_pool_size()
9202                )
9203            } else {
9204                primary
9205            }
9206        },
9207    );
9208    let telemetry_status = if config.telemetry.enabled {
9209        let endpoint = config
9210            .telemetry
9211            .otlp_endpoint
9212            .as_deref()
9213            .unwrap_or("<missing endpoint>");
9214        format!("{:?} -> {endpoint}", config.telemetry.protocol)
9215    } else {
9216        "disabled".to_owned()
9217    };
9218    format!(
9219        "\
9220        \n    profile:    {profile}\
9221        \n    server:     {}:{}\
9222        \n    database:   {db_status}\
9223        \n    log_level:  {}\
9224        \n    log_format: {:?}\
9225        \n    telemetry:  {telemetry_status}\
9226        \n    health:     {} (detailed={})\
9227        \n    actuator:   sensitive={}\
9228        \n    shutdown:   prestop={}s drain={}s",
9229        config.server.host,
9230        config.server.port,
9231        config.log.level,
9232        config.log.format,
9233        config.health.path,
9234        config.health.detailed,
9235        config.actuator.sensitive,
9236        config.server.prestop_grace_secs,
9237        config.server.shutdown_timeout_secs,
9238    )
9239}
9240
9241/// Resolve a project-relative subdirectory (e.g. `"dist"` or `"static"`)
9242/// against `AUTUMN_MANIFEST_DIR` if set, otherwise use it as-is.
9243pub(crate) fn project_dir(subdir: &str, env: &dyn crate::config::Env) -> std::path::PathBuf {
9244    env.var("AUTUMN_MANIFEST_DIR").map_or_else(
9245        |_| std::path::PathBuf::from(subdir),
9246        |d| std::path::PathBuf::from(d).join(subdir),
9247    )
9248}
9249
9250/// Wait for a shutdown signal (Ctrl+C, SIGTERM on Unix, or a canary rollback
9251/// flag file written by a controller).
9252///
9253/// Returns when any signal is received. Axum's `with_graceful_shutdown`
9254/// then stops accepting new connections and drains in-flight requests.
9255///
9256/// The canary rollback arm lets a progressive-delivery controller drain and
9257/// retire a bad canary replica without sending `SIGTERM` by hand: it writes
9258/// [`crate::canary::CANARY_ROLLBACK_FLAG_FILE`] and Autumn runs the identical
9259/// graceful-shutdown sequence (ready → 503, prestop grace, drain, clean exit).
9260async fn shutdown_signal() {
9261    let ctrl_c = async {
9262        tokio::signal::ctrl_c()
9263            .await
9264            .expect("Failed to install Ctrl+C handler");
9265        tracing::info!("Received Ctrl+C, starting graceful shutdown");
9266    };
9267
9268    #[cfg(unix)]
9269    let terminate = async {
9270        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
9271            .expect("Failed to install SIGTERM handler")
9272            .recv()
9273            .await;
9274        tracing::info!("Received SIGTERM, starting graceful shutdown");
9275    };
9276
9277    #[cfg(not(unix))]
9278    let terminate = std::future::pending::<()>();
9279
9280    let canary_rollback = async {
9281        canary_rollback_signal(std::path::Path::new(
9282            crate::canary::CANARY_ROLLBACK_FLAG_FILE,
9283        ))
9284        .await;
9285        tracing::info!("Canary rollback signalled, starting graceful shutdown");
9286    };
9287
9288    tokio::select! {
9289        () = ctrl_c => {},
9290        () = terminate => {},
9291        () = canary_rollback => {},
9292    }
9293}
9294
9295/// Resolve when the canary rollback flag file is present at `path`.
9296///
9297/// A rollback signal is intentionally **sticky across restarts**: if the flag is
9298/// already present at boot (e.g. a supervisor restarted the process after a
9299/// rollback), this resolves immediately so the replica drains and exits again
9300/// rather than rejoining the canary cohort. The replica keeps draining until a
9301/// controller clears the signal with `autumn canary promote` (or scales the
9302/// replica to zero). At startup the framework also flips `/ready` to draining
9303/// when the flag is present, so a restarted rolled-back replica never serves
9304/// canary traffic.
9305///
9306/// Uses async stat so the 500 ms poll never blocks the executor thread.
9307async fn canary_rollback_signal(path: &std::path::Path) {
9308    let interval = std::time::Duration::from_millis(500);
9309    loop {
9310        if tokio::fs::metadata(path).await.is_ok() {
9311            return;
9312        }
9313        tokio::time::sleep(interval).await;
9314    }
9315}
9316
9317#[cfg(test)]
9318mod tests {
9319    use super::*;
9320    use axum::body::Body;
9321    use axum::http::{Request, StatusCode};
9322    use std::sync::atomic::{AtomicUsize, Ordering};
9323    use tower::ServiceExt;
9324
9325    // ── omitted-router accounting for `autumn routes audit` ──────────────────
9326
9327    // #1974 item 7: a plugin declares a top-level config section from its
9328    // `build()` via `config_section`, so `server.strict_config` treats that root
9329    // as known-and-opaque. Prove the declaration lands on the builder and that
9330    // the registry is fail-closed (an undeclared root is not registered).
9331    #[test]
9332    fn plugin_declares_config_section_via_build() {
9333        struct DummyMediaPlugin;
9334        impl crate::plugin::Plugin for DummyMediaPlugin {
9335            fn build(self, app: AppBuilder) -> AppBuilder {
9336                app.config_section("media")
9337            }
9338        }
9339
9340        let builder = app().plugin(DummyMediaPlugin);
9341        assert!(
9342            builder.has_config_section("media"),
9343            "a plugin's build() must declare its [media] config section"
9344        );
9345        assert!(
9346            !builder.has_config_section("definitely_not_a_root"),
9347            "only explicitly-declared roots are registered — the seam is fail-closed"
9348        );
9349    }
9350
9351    /// Compute the omitted-router count for a builder using the same inputs
9352    /// `run_dump_routes_mode` feeds `omitted_router_count`: the merge count, the
9353    /// nest prefixes, and the declared routes that prove nest coverage.
9354    fn omitted_for(builder: &AppBuilder) -> usize {
9355        omitted_router_count(
9356            builder.merge_routers.len(),
9357            builder
9358                .nest_routers
9359                .iter()
9360                .map(|(prefix, _)| prefix.as_str()),
9361            &builder.declared_routes,
9362        )
9363    }
9364
9365    /// Regression (#1604): the DOCUMENTED plugin pattern —
9366    /// `app.nest(prefix, router).declare_plugin_routes(routes)`, which the
9367    /// first-party `AdminPlugin` uses — declares route metadata whose paths fall
9368    /// under the nest prefix. That coverage makes the nested raw router
9369    /// enumerable, so it must NOT be counted among the opaque, omitted routers
9370    /// that hard-fail the audit gate. Before the fix, prefix coverage was
9371    /// ignored and the mere presence of the nested raw router pushed
9372    /// `hidden > 0`, false-failing the audit even though every admin route was
9373    /// declared and classified.
9374    #[test]
9375    fn documented_nest_then_declare_is_not_counted_as_omitted() {
9376        let raw =
9377            axum::Router::<AppState>::new().route("/ping", axum::routing::get(|| async { "pong" }));
9378        let declared = vec![crate::route_listing::RouteInfo {
9379            method: "GET".to_owned(),
9380            path: "/admin/ping".to_owned(),
9381            handler: "admin::ping".to_owned(),
9382            ..Default::default()
9383        }];
9384
9385        // The plain documented pattern: nest the raw router, then declare its
9386        // covering route metadata. No dedicated `nest_declared` bookkeeping.
9387        let builder = app().nest("/admin", raw).declare_plugin_routes(declared);
9388
9389        // The raw router is still mounted (serving path is unchanged) …
9390        assert_eq!(builder.nest_routers.len(), 1);
9391        // … and its declared metadata was folded into `declared_routes`.
9392        assert_eq!(builder.declared_routes.len(), 1);
9393
9394        // ⇒ zero omitted routers: the declared route `/admin/ping` falls under
9395        // the `/admin` nest prefix, so the mount is covered and the audit gate
9396        // must NOT fire.
9397        assert_eq!(
9398            omitted_for(&builder),
9399            0,
9400            "a nest whose endpoints are declared is enumerable and must not count as omitted",
9401        );
9402    }
9403
9404    /// The soundness guarantee must survive: a raw router mounted via bare
9405    /// `nest()` or `merge()` without covering declarations is unenumerable and
9406    /// must still count as omitted so `autumn routes audit` fails closed.
9407    #[test]
9408    fn undeclared_nest_and_merge_still_count_as_omitted() {
9409        let raw_nest =
9410            axum::Router::<AppState>::new().route("/x", axum::routing::get(|| async { "x" }));
9411        let raw_merge =
9412            axum::Router::<AppState>::new().route("/y", axum::routing::get(|| async { "y" }));
9413
9414        let builder = app().nest("/v2", raw_nest).merge(raw_merge);
9415
9416        assert_eq!(builder.nest_routers.len(), 1);
9417        assert_eq!(builder.merge_routers.len(), 1);
9418        // Nothing was declared, so nothing covers the nest.
9419        assert!(builder.declared_routes.is_empty());
9420
9421        assert_eq!(
9422            omitted_for(&builder),
9423            2,
9424            "an undeclared nest and a merge are both opaque and must be reported",
9425        );
9426    }
9427
9428    /// An undeclared `merge()` is rootless — it cannot be prefix-matched — so it
9429    /// stays omitted even when unrelated declared routes exist. Guards against a
9430    /// declaration for one mount silently covering an unrelated raw `merge()`.
9431    #[test]
9432    fn declared_routes_do_not_cover_a_rootless_merge() {
9433        let raw_merge =
9434            axum::Router::<AppState>::new().route("/y", axum::routing::get(|| async { "y" }));
9435
9436        let builder =
9437            app()
9438                .merge(raw_merge)
9439                .declare_plugin_routes(vec![crate::route_listing::RouteInfo {
9440                    method: "GET".to_owned(),
9441                    path: "/admin/ok".to_owned(),
9442                    handler: "admin::ok".to_owned(),
9443                    ..Default::default()
9444                }]);
9445
9446        assert_eq!(
9447            omitted_for(&builder),
9448            1,
9449            "a merge has no prefix to match declarations against and must always count",
9450        );
9451    }
9452
9453    /// A declared nest alongside an *undeclared* nest: only the undeclared one
9454    /// is omitted. Prefix-matching must cover the `/admin` mount (a declared
9455    /// route falls under it) without spilling onto the unrelated `/raw` mount
9456    /// (no declared route falls under it).
9457    #[test]
9458    fn mixed_declared_and_undeclared_nests_count_only_the_undeclared() {
9459        let declared_raw =
9460            axum::Router::<AppState>::new().route("/ok", axum::routing::get(|| async { "ok" }));
9461        let undeclared_raw = axum::Router::<AppState>::new()
9462            .route("/opaque", axum::routing::get(|| async { "opaque" }));
9463
9464        let builder = app()
9465            .nest("/admin", declared_raw)
9466            .declare_plugin_routes(vec![crate::route_listing::RouteInfo {
9467                method: "GET".to_owned(),
9468                path: "/admin/ok".to_owned(),
9469                handler: "admin::ok".to_owned(),
9470                ..Default::default()
9471            }])
9472            .nest("/raw", undeclared_raw);
9473
9474        assert_eq!(builder.nest_routers.len(), 2);
9475        assert_eq!(builder.declared_routes.len(), 1);
9476        assert_eq!(
9477            omitted_for(&builder),
9478            1,
9479            "only the bare nest() is omitted; the declared mount is covered",
9480        );
9481    }
9482
9483    /// A declared route whose path merely *shares a leading substring* with a
9484    /// nest prefix (`/administrators` vs `/admin`) must NOT cover the nest:
9485    /// prefix-matching honours path-segment boundaries, so this bare nest still
9486    /// counts as omitted and the audit fails closed.
9487    #[test]
9488    fn prefix_match_respects_path_segment_boundaries() {
9489        let raw = axum::Router::<AppState>::new().route("/x", axum::routing::get(|| async { "x" }));
9490
9491        let builder = app().nest("/admin", raw).declare_plugin_routes(vec![
9492            crate::route_listing::RouteInfo {
9493                method: "GET".to_owned(),
9494                path: "/administrators".to_owned(),
9495                handler: "other::index".to_owned(),
9496                ..Default::default()
9497            },
9498        ]);
9499
9500        assert_eq!(
9501            omitted_for(&builder),
9502            1,
9503            "`/administrators` is not under the `/admin` nest prefix; the nest stays omitted",
9504        );
9505    }
9506
9507    #[test]
9508    fn is_dump_jobs_mode_only_true_for_exactly_one() {
9509        // `autumn jobs manifest` sets AUTUMN_DUMP_JOBS=1 to select the manifest
9510        // dump path in `run()`. Any other value (or an unset var) must fall
9511        // through to the normal boot path.
9512        temp_env::with_var("AUTUMN_DUMP_JOBS", Some("1"), || {
9513            assert!(is_dump_jobs_mode(), "`1` must select the jobs-dump path");
9514        });
9515        temp_env::with_var("AUTUMN_DUMP_JOBS", Some("0"), || {
9516            assert!(!is_dump_jobs_mode(), "`0` must not select the dump path");
9517        });
9518        temp_env::with_var("AUTUMN_DUMP_JOBS", Some("true"), || {
9519            assert!(
9520                !is_dump_jobs_mode(),
9521                "only the literal `1` enables the mode"
9522            );
9523        });
9524        temp_env::with_var("AUTUMN_DUMP_JOBS", None::<&str>, || {
9525            assert!(!is_dump_jobs_mode(), "unset must not select the dump path");
9526        });
9527    }
9528
9529    #[test]
9530    fn dump_jobs_manifest_includes_synthesized_durable_listener_default_queue() {
9531        // Regression (#1802, Codex P2): an app that registers a durable listener
9532        // and configures `[jobs.queues]` WITHOUT `default` still drains `default`
9533        // at runtime — `finalize_event_bus` synthesizes a `default`-queue
9534        // `JobInfo` for each durable listener before the runtime starts. The
9535        // `AUTUMN_DUMP_JOBS=1` manifest must reflect that same set through the
9536        // shared `synthesize_durable_listener_jobs` seam, or a topology-aware
9537        // `autumn doctor` would accept a fleet where no tier drains those jobs.
9538        fn listener_handler(
9539            _state: AppState,
9540            _payload: serde_json::Value,
9541        ) -> std::pin::Pin<
9542            Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'static>,
9543        > {
9544            Box::pin(async move { Ok(()) })
9545        }
9546        let durable = crate::events::ListenerInfo {
9547            event_name: "UserSignedUp",
9548            listener_name: "app::send_welcome_email".to_string(),
9549            mode: crate::events::DispatchMode::Durable,
9550            job_name: Some("__event_listener::send_welcome_email".to_string()),
9551            max_attempts: 4,
9552            initial_backoff_ms: 250,
9553            handler: listener_handler,
9554        };
9555        let cfg = crate::config::JobQueuesConfig::strict_list(["critical"]);
9556
9557        // The dump path holds no builder-registered jobs, only the durable
9558        // listener; the manifest must still surface `default` (where that
9559        // listener's synthesized job runs) alongside the configured `critical`.
9560        let manifest = dump_jobs_manifest(&cfg, Vec::new(), vec![durable]);
9561        assert_eq!(manifest, "queues = [\"critical\", \"default\"]\n");
9562    }
9563
9564    #[cfg(feature = "db")]
9565    const APP_TEST_MIGRATIONS: crate::migrate::EmbeddedMigrations =
9566        diesel_migrations::embed_migrations!("test_migrations");
9567
9568    /// Shared no-op `MailDeliveryQueue` used by builder tests so the trait
9569    /// impl body is defined once and exercised by at least one test.
9570    #[cfg(feature = "mail")]
9571    struct MailTestNoopQueue;
9572
9573    #[cfg(feature = "mail")]
9574    impl crate::mail::MailDeliveryQueue for MailTestNoopQueue {
9575        fn enqueue<'a>(
9576            &'a self,
9577            _mail: crate::mail::Mail,
9578        ) -> std::pin::Pin<
9579            Box<dyn std::future::Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>,
9580        > {
9581            Box::pin(async { Ok(()) })
9582        }
9583    }
9584
9585    #[cfg(feature = "mail")]
9586    fn test_mail() -> crate::mail::Mail {
9587        crate::mail::Mail::builder()
9588            .to("test@example.com")
9589            .subject("hi")
9590            .text("hello")
9591            .build()
9592            .expect("test mail should build")
9593    }
9594
9595    /// Helper to build a test router with default config and no database.
9596    pub fn test_router(routes: Vec<Route>) -> axum::Router {
9597        let config = AutumnConfig::default();
9598        let state = AppState {
9599            extensions: std::sync::Arc::new(std::sync::RwLock::new(
9600                std::collections::HashMap::new(),
9601            )),
9602            #[cfg(feature = "db")]
9603            pool: None,
9604            #[cfg(feature = "db")]
9605            replica_pool: None,
9606            #[cfg(feature = "db")]
9607            shards: None,
9608            profile: None,
9609            role: crate::config::ProcessRole::Combined,
9610            started_at: std::time::Instant::now(),
9611            health_detailed: true,
9612            probes: crate::probe::ProbeState::ready_for_test(),
9613            metrics: crate::middleware::MetricsCollector::new(),
9614            log_levels: crate::actuator::LogLevels::new("info"),
9615            task_registry: crate::actuator::TaskRegistry::new(),
9616            job_registry: crate::actuator::JobRegistry::new(),
9617            config_props: crate::actuator::ConfigProperties::default(),
9618            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
9619            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
9620            #[cfg(feature = "ws")]
9621            channels: crate::channels::Channels::new(32),
9622            #[cfg(feature = "presence")]
9623            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
9624            #[cfg(feature = "ws")]
9625            shutdown: tokio_util::sync::CancellationToken::new(),
9626            policy_registry: crate::authorization::PolicyRegistry::default(),
9627            forbidden_response: crate::authorization::ForbiddenResponse::default(),
9628            auth_session_key: "user_id".to_owned(),
9629            shared_cache: None,
9630            clock: std::sync::Arc::new(crate::time::SystemClock),
9631            app_id: AppState::next_app_id(),
9632        };
9633        crate::router::build_router(routes, &config, state)
9634    }
9635
9636    #[tokio::test]
9637    async fn canary_rollback_signal_resolves_when_flag_newly_written() {
9638        let tmp = tempfile::TempDir::new().unwrap();
9639        let path = tmp.path().join("canary-rollback.json");
9640
9641        // Flag is absent at boot; writing it after start must resolve the signal.
9642        let writer_path = path.clone();
9643        let writer = tokio::spawn(async move {
9644            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
9645            crate::canary::CanaryState::write_rollback_flag(
9646                &writer_path,
9647                &crate::canary::RollbackSignal::default(),
9648            )
9649            .unwrap();
9650        });
9651
9652        let signalled = tokio::time::timeout(
9653            std::time::Duration::from_secs(5),
9654            canary_rollback_signal(&path),
9655        )
9656        .await;
9657        assert!(signalled.is_ok(), "rollback signal should resolve");
9658        writer.await.unwrap();
9659    }
9660
9661    #[tokio::test]
9662    async fn canary_rollback_signal_resolves_immediately_when_flag_present_at_boot() {
9663        let tmp = tempfile::TempDir::new().unwrap();
9664        let path = tmp.path().join("canary-rollback.json");
9665        // A rollback flag is sticky across restarts: present at boot must trigger
9666        // again so a supervisor restart cannot rejoin a rolled-back replica.
9667        crate::canary::CanaryState::write_rollback_flag(
9668            &path,
9669            &crate::canary::RollbackSignal::default(),
9670        )
9671        .unwrap();
9672
9673        let signalled = tokio::time::timeout(
9674            std::time::Duration::from_secs(5),
9675            canary_rollback_signal(&path),
9676        )
9677        .await;
9678        assert!(
9679            signalled.is_ok(),
9680            "a flag present at boot must trigger rollback (sticky across restarts)"
9681        );
9682    }
9683
9684    #[cfg(feature = "db")]
9685    #[test]
9686    fn build_state_applies_replica_fallback_policy_to_read_routing() {
9687        let mut config = AutumnConfig::default();
9688        config.database.primary_url = Some("postgres://localhost/primary".to_owned());
9689        config.database.primary_pool_size = Some(5);
9690        config.database.replica_url = Some("postgres://localhost/replica".to_owned());
9691        config.database.replica_pool_size = Some(2);
9692        config.database.replica_fallback = crate::config::ReplicaFallback::Primary;
9693        let topology = crate::db::create_topology(&config.database)
9694            .expect("topology should build")
9695            .expect("database should be configured");
9696
9697        let state = build_state(
9698            &config,
9699            Some(&topology),
9700            None,
9701            #[cfg(feature = "ws")]
9702            None,
9703        );
9704        state
9705            .probes()
9706            .mark_replica_unready("replica migrations lag primary");
9707
9708        assert_eq!(state.read_pool().expect("read pool").status().max_size, 5);
9709    }
9710
9711    #[test]
9712    fn build_state_exposes_resolved_process_role() {
9713        use crate::config::ProcessRole;
9714
9715        // Default config resolves to the combined role: existing single-process
9716        // apps see no behavior change and get both HTTP + workers.
9717        let mut config = AutumnConfig::default();
9718        let state = build_state(
9719            &config,
9720            #[cfg(feature = "db")]
9721            None,
9722            #[cfg(feature = "db")]
9723            None,
9724            #[cfg(feature = "ws")]
9725            None,
9726        );
9727        assert_eq!(state.role(), ProcessRole::Combined);
9728        assert!(state.role().serves_http());
9729        assert!(state.role().runs_workers());
9730
9731        // A worker-role config flows through the exact same resolution the
9732        // framework uses to gate the job runtime, reachable via `state.role()`.
9733        config.role = ProcessRole::Worker;
9734        let state = build_state(
9735            &config,
9736            #[cfg(feature = "db")]
9737            None,
9738            #[cfg(feature = "db")]
9739            None,
9740            #[cfg(feature = "ws")]
9741            None,
9742        );
9743        assert_eq!(state.role(), ProcessRole::Worker);
9744        assert!(state.role().runs_workers());
9745        assert!(!state.role().serves_http());
9746    }
9747
9748    #[cfg(feature = "db")]
9749    #[tokio::test]
9750    async fn custom_pool_provider_preserves_configured_replica_topology() {
9751        struct PassthroughPoolProvider;
9752
9753        impl crate::db::DatabasePoolProvider for PassthroughPoolProvider {
9754            async fn create_pool(
9755                &self,
9756                config: &crate::config::DatabaseConfig,
9757            ) -> Result<
9758                Option<
9759                    diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
9760                >,
9761                crate::db::PoolError,
9762            > {
9763                crate::db::create_pool(config)
9764            }
9765        }
9766
9767        let mut config = AutumnConfig::default();
9768        config.database.primary_url = Some("postgres://localhost/primary".to_owned());
9769        config.database.primary_pool_size = Some(5);
9770        config.database.replica_url = Some("postgres://localhost/replica".to_owned());
9771        config.database.replica_pool_size = Some(2);
9772        config.database.replica_fallback = crate::config::ReplicaFallback::FailReadiness;
9773        let AppBuilder {
9774            pool_provider_factory,
9775            ..
9776        } = app().with_pool_provider(PassthroughPoolProvider);
9777
9778        let database = setup_database(
9779            &config,
9780            Vec::new(),
9781            pool_provider_factory,
9782            None,
9783            None,
9784            false,
9785            RepositoryCommitHookQueueMigrationMode::Runtime,
9786        )
9787        .await
9788        .expect("custom provider should build database topology");
9789        let topology = database.topology.expect("database should be configured");
9790
9791        assert_eq!(topology.primary().status().max_size, 5);
9792        assert_eq!(
9793            topology
9794                .replica()
9795                .expect("custom provider should create replica pool")
9796                .status()
9797                .max_size,
9798            2
9799        );
9800
9801        let state = build_state(
9802            &config,
9803            Some(&topology),
9804            None,
9805            #[cfg(feature = "ws")]
9806            None,
9807        );
9808        state
9809            .probes()
9810            .mark_replica_connection_unready("replica connection failed");
9811
9812        assert!(state.read_pool().is_none());
9813        let (status, _) = crate::probe::readiness_response(&state).await;
9814        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
9815    }
9816
9817    // Finding 2 (Codex P2), corrected: the fail-closed `statement_timeout` guard
9818    // must fire once a custom provider has ACTUALLY established a pool — a custom
9819    // provider can build its own SQLite pool without routing through the built-in
9820    // `create_topology`/`create_pool` factories (the default `create_topology`
9821    // only delegates to the provider's `create_pool`, and both are overridable),
9822    // so `setup_database` enforces the guard at dispatch. But it must run only for
9823    // an established pool (`Some(..)`), NOT before the provider returns: a provider
9824    // that returns `Ok(None)` opts into the explicitly-supported no-database mode
9825    // (no pool/statement to bound), which must still boot even with a nonzero
9826    // `statement_timeout` — matching the built-in path, which returns `Ok(None)`
9827    // before its own timeout check. (CI's sqlite job runs the named integration
9828    // targets, not `--lib`; `setup_database` and the shared guard are
9829    // crate-private, so this boundary is only reachable from a unit test — hence a
9830    // focused `--lib` test rather than an entry in the runtime target.)
9831    //
9832    // Case (a): a custom provider that establishes a real in-memory SQLite pool
9833    // (`Ok(Some(..))`) under a nonzero `statement_timeout` must fail closed with
9834    // the actionable error, so the original F2 bypass stays closed.
9835    #[cfg(feature = "sqlite")]
9836    #[tokio::test]
9837    async fn custom_pool_provider_with_established_sqlite_pool_fails_closed_on_statement_timeout() {
9838        // Builds a real in-memory SQLite pool WITHOUT routing the timeout through
9839        // the built-in factory — the exact F2 bypass a custom provider could use.
9840        struct RealSqlitePoolProvider;
9841
9842        impl crate::db::DatabasePoolProvider for RealSqlitePoolProvider {
9843            async fn create_pool(
9844                &self,
9845                config: &crate::config::DatabaseConfig,
9846            ) -> Result<
9847                Option<
9848                    diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
9849                >,
9850                crate::db::PoolError,
9851            > {
9852                // Clear `statement_timeout` locally so the built-in `create_pool`
9853                // guard does not fire inside the provider — this provider hands
9854                // back a live SQLite pool that silently dropped the timeout, which
9855                // is exactly the fail-closed condition the dispatch guard closes.
9856                let mut relaxed = config.clone();
9857                relaxed.statement_timeout = None;
9858                crate::db::create_pool(&relaxed)
9859            }
9860        }
9861
9862        let mut config = AutumnConfig::default();
9863        config.database.primary_url = Some("sqlite::memory:".to_owned());
9864        config.database.statement_timeout = Some(std::time::Duration::from_secs(30));
9865        let AppBuilder {
9866            pool_provider_factory,
9867            shard_provider_factory,
9868            ..
9869        } = app().with_pool_provider(RealSqlitePoolProvider);
9870
9871        let Err(err) = setup_database(
9872            &config,
9873            Vec::new(),
9874            pool_provider_factory,
9875            shard_provider_factory,
9876            None,
9877            false,
9878            RepositoryCommitHookQueueMigrationMode::Runtime,
9879        )
9880        .await
9881        else {
9882            panic!(
9883                "sqlite + statement_timeout must fail closed once the provider establishes a pool"
9884            );
9885        };
9886        assert!(
9887            err.contains("database.statement_timeout") && err.contains("SQLite"),
9888            "dispatch guard error must name the config key and SQLite, got: {err}"
9889        );
9890    }
9891
9892    // Case (b) — the exact regression Codex flagged: a custom provider that opts
9893    // into no-database mode (`Ok(None)`) must STILL boot even with a nonzero
9894    // `database.statement_timeout`, because no pool/statement exists to bound. The
9895    // Some-gated guard must not reject this the way the pre-dispatch guard did.
9896    #[cfg(feature = "sqlite")]
9897    #[tokio::test]
9898    async fn custom_pool_provider_no_database_mode_boots_with_statement_timeout() {
9899        struct NoDatabaseProvider;
9900
9901        impl crate::db::DatabasePoolProvider for NoDatabaseProvider {
9902            async fn create_pool(
9903                &self,
9904                _config: &crate::config::DatabaseConfig,
9905            ) -> Result<
9906                Option<
9907                    diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
9908                >,
9909                crate::db::PoolError,
9910            > {
9911                // Explicit no-database opt-out.
9912                Ok(None)
9913            }
9914        }
9915
9916        let mut config = AutumnConfig::default();
9917        // A URL may even be configured; the provider's `Ok(None)` still wins.
9918        config.database.primary_url = Some("sqlite::memory:".to_owned());
9919        config.database.statement_timeout = Some(std::time::Duration::from_secs(30));
9920        let AppBuilder {
9921            pool_provider_factory,
9922            shard_provider_factory,
9923            ..
9924        } = app().with_pool_provider(NoDatabaseProvider);
9925
9926        let bootstrap = setup_database(
9927            &config,
9928            Vec::new(),
9929            pool_provider_factory,
9930            shard_provider_factory,
9931            None,
9932            false,
9933            RepositoryCommitHookQueueMigrationMode::Runtime,
9934        )
9935        .await
9936        .expect("no-database provider must boot even with a nonzero statement_timeout");
9937        assert!(
9938            bootstrap.topology.is_none(),
9939            "no-database mode must yield no control topology"
9940        );
9941        assert!(
9942            bootstrap.shards.is_none(),
9943            "no-database mode must yield no shard set"
9944        );
9945    }
9946
9947    #[cfg(feature = "db")]
9948    fn sharded_test_config() -> AutumnConfig {
9949        let mut config = AutumnConfig::default();
9950        config.database.primary_url = Some("postgres://localhost/control".to_owned());
9951        config.database.shards = vec![
9952            crate::config::ShardConfig {
9953                name: "shard0".to_owned(),
9954                primary_url: "postgres://localhost/shard0".to_owned(),
9955                slots: Some(vec![crate::config::SlotSpec::Range("0-8191".to_owned())]),
9956                replica_url: None,
9957                primary_pool_size: Some(3),
9958                replica_pool_size: None,
9959                replica_fallback: None,
9960            },
9961            crate::config::ShardConfig {
9962                name: "shard1".to_owned(),
9963                primary_url: "postgres://localhost/shard1".to_owned(),
9964                slots: Some(vec![crate::config::SlotSpec::Range(
9965                    "8192-16383".to_owned(),
9966                )]),
9967                replica_url: Some("postgres://localhost/shard1_ro".to_owned()),
9968                primary_pool_size: None,
9969                replica_pool_size: Some(2),
9970                replica_fallback: None,
9971            },
9972        ];
9973        config
9974    }
9975
9976    #[cfg(feature = "db")]
9977    #[tokio::test]
9978    async fn setup_database_builds_shard_set_from_config() {
9979        let config = sharded_test_config();
9980
9981        let database = setup_database(
9982            &config,
9983            Vec::new(),
9984            None,
9985            None,
9986            None,
9987            false,
9988            RepositoryCommitHookQueueMigrationMode::Runtime,
9989        )
9990        .await
9991        .expect("sharded config should bootstrap");
9992
9993        assert!(database.topology.is_some(), "control role configured");
9994        let shards = database.shards.expect("shards configured");
9995        assert_eq!(shards.len(), 2);
9996        assert_eq!(
9997            shards
9998                .by_name("shard0")
9999                .expect("shard0")
10000                .primary_pool()
10001                .status()
10002                .max_size,
10003            3
10004        );
10005        assert_eq!(
10006            shards
10007                .by_name("shard1")
10008                .expect("shard1")
10009                .replica_pool()
10010                .expect("shard1 replica")
10011                .status()
10012                .max_size,
10013            2
10014        );
10015
10016        let state = build_state(
10017            &config,
10018            database.topology.as_ref(),
10019            Some(shards),
10020            #[cfg(feature = "ws")]
10021            None,
10022        );
10023        let state_shards = state.shards().expect("state should expose shards");
10024        assert_eq!(state_shards.len(), 2);
10025        // Routing works end-to-end through state-held shards.
10026        let routed = state_shards.route("tenant-1").await.expect("route");
10027        assert!(["shard0", "shard1"].contains(&routed.name()));
10028    }
10029
10030    #[cfg(feature = "db")]
10031    #[tokio::test]
10032    async fn custom_pool_provider_builds_shard_topologies() {
10033        struct CountingProvider(std::sync::Arc<std::sync::atomic::AtomicUsize>);
10034
10035        impl crate::db::DatabasePoolProvider for CountingProvider {
10036            async fn create_pool(
10037                &self,
10038                config: &crate::config::DatabaseConfig,
10039            ) -> Result<
10040                Option<
10041                    diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
10042                >,
10043                crate::db::PoolError,
10044            > {
10045                crate::db::create_pool(config)
10046            }
10047
10048            async fn create_shard_topology(
10049                &self,
10050                shard: &crate::config::ShardConfig,
10051                defaults: &crate::config::DatabaseConfig,
10052            ) -> Result<crate::db::DatabaseTopology, crate::db::PoolError> {
10053                self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10054                crate::db::create_shard_topology(shard, defaults)
10055            }
10056        }
10057
10058        let config = sharded_test_config();
10059        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10060        let AppBuilder {
10061            pool_provider_factory,
10062            shard_provider_factory,
10063            ..
10064        } = app().with_pool_provider(CountingProvider(calls.clone()));
10065
10066        let database = setup_database(
10067            &config,
10068            Vec::new(),
10069            pool_provider_factory,
10070            shard_provider_factory,
10071            None,
10072            false,
10073            RepositoryCommitHookQueueMigrationMode::Runtime,
10074        )
10075        .await
10076        .expect("provider should build shard topologies");
10077
10078        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
10079        assert_eq!(database.shards.expect("shards").len(), 2);
10080    }
10081
10082    #[cfg(feature = "db")]
10083    #[test]
10084    fn repository_commit_hook_worker_starts_after_job_runtime_initialization() {
10085        let source = include_str!("app.rs").replace("\r\n", "\n");
10086        let server_init = "initialize_job_runtime(
10087            jobs,
10088            &state,
10089            &server_shutdown,";
10090        let server_worker = "start_repository_commit_hook_worker(\n                pool,\n                server_shutdown.child_token(),\n            );";
10091        let task_init = "initialize_job_runtime(jobs, &state, &task_shutdown, &config.jobs, true)";
10092        let task_worker = "start_repository_commit_hook_worker(\n                pool,\n                task_shutdown.child_token(),\n            );";
10093
10094        assert!(
10095            source
10096                .find(server_init)
10097                .expect("normal server path should initialize jobs")
10098                < source
10099                    .find(server_worker)
10100                    .expect("normal server path should start repository hook worker"),
10101            "normal server startup must initialize jobs before repository commit hooks can enqueue them"
10102        );
10103        assert!(
10104            source
10105                .find(task_init)
10106                .expect("task runner path should initialize jobs")
10107                < source
10108                    .find(task_worker)
10109                    .expect("task runner path should start repository hook worker"),
10110            "task runner startup must initialize jobs before repository commit hooks can enqueue them"
10111        );
10112    }
10113
10114    #[cfg(feature = "db")]
10115    #[test]
10116    fn repository_commit_hook_workers_are_gated_on_worker_role() {
10117        // Draining durable after-commit hook rows is background execution, so the
10118        // primary-pool and shard commit-hook worker starts on the normal server
10119        // path must both be guarded by `role.runs_workers()` — a web-role replica
10120        // must not claim/execute hook rows (that work belongs to the worker tier).
10121        let source = include_str!("app.rs").replace("\r\n", "\n");
10122        let primary_gate =
10123            "if role.runs_workers()\n            && let Some(pool) = state.pool().cloned()";
10124        let shard_gate = "if role.runs_workers()\n            && let Some(shards) = state.shards()";
10125        assert!(
10126            source.contains(primary_gate),
10127            "primary-pool commit-hook worker must be gated on role.runs_workers()"
10128        );
10129        assert!(
10130            source.contains(shard_gate),
10131            "shard commit-hook workers must be gated on role.runs_workers()"
10132        );
10133    }
10134
10135    #[test]
10136    fn state_initializers_run_before_job_runtime_initialization() {
10137        let source = include_str!("app.rs").replace("\r\n", "\n");
10138        let server_start = source
10139            .find("pub async fn run(self)")
10140            .expect("normal server path should exist");
10141        let build_mode_start = source
10142            .find("async fn run_build_mode(self)")
10143            .expect("static build path should follow server path");
10144        let task_start = source
10145            .find("async fn run_one_off_task_mode(self, requested_name: String)")
10146            .expect("task runner path should exist");
10147        let server_source = &source[server_start..build_mode_start];
10148        let task_source = &source[task_start..];
10149        let server_init = "initialize_job_runtime(
10150            jobs,
10151            &state,
10152            &server_shutdown,";
10153        let task_init = "initialize_job_runtime(jobs, &state, &task_shutdown, &config.jobs, true)";
10154        let server_initializer = server_source
10155            .find("run_state_initializers(state_initializers, &state);")
10156            .expect("normal server path should run state initializers");
10157        let task_initializer = task_source
10158            .find("run_state_initializers(state_initializers, &state);")
10159            .expect("task runner path should run state initializers");
10160        let server_job = server_source
10161            .find(server_init)
10162            .expect("normal server path should initialize jobs");
10163        let task_job = task_source
10164            .find(task_init)
10165            .expect("task runner path should initialize jobs");
10166
10167        assert!(
10168            server_initializer < server_job,
10169            "normal server startup must install state-initialized resources before job workers start"
10170        );
10171        assert!(
10172            task_initializer < task_job,
10173            "task runner startup must install state-initialized resources before job workers start"
10174        );
10175    }
10176
10177    #[test]
10178    fn static_builds_run_state_initializers_before_router_build() {
10179        let source = include_str!("app.rs").replace("\r\n", "\n");
10180        let build_mode_start = source
10181            .find("async fn run_build_mode(self)")
10182            .expect("static build path should exist");
10183        let dump_mode_start = source
10184            .find("async fn run_dump_routes_mode(self)")
10185            .expect("route dump path should follow static build path");
10186        let build_mode_source = &source[build_mode_start..dump_mode_start];
10187        let state_initializer = build_mode_source
10188            .find("run_state_initializers(state_initializers, &state);")
10189            .expect("static build path should run state initializers");
10190        let router_build = build_mode_source
10191            .find("let router = crate::router::try_build_router_inner(")
10192            .expect("static build path should build a router");
10193
10194        assert!(
10195            state_initializer < router_build,
10196            "static builds must install state-initialized resources before rendering routes"
10197        );
10198    }
10199
10200    #[test]
10201    fn migrate_only_one_shot_applies_and_exits_without_serving() {
10202        // The runtime effect (applying against Postgres, exiting without binding a
10203        // port) needs a DB + a subprocess harness because `run()` ends in
10204        // `process::exit`; that live apply is exercised by the shared
10205        // `run_pending_locked` engine's own DB-backed tests. Here we lock the
10206        // *dispatch decision* and the *reuse/exit contract* structurally: with
10207        // AUTUMN_MIGRATE=1 the migrate-and-exit path is chosen and the
10208        // server-start path is NOT taken.
10209        let source = include_str!("app.rs").replace("\r\n", "\n");
10210        let run_start = source.find("pub async fn run(self)").expect("run() exists");
10211        let run_end = source
10212            .find("async fn run_build_mode(self)")
10213            .expect("build mode follows run()");
10214        let run_body = &source[run_start..run_end];
10215
10216        // The AUTUMN_MIGRATE=1 dispatch is an early one-shot: it sits BEFORE the
10217        // server-start machinery (the `let Self {` destructure that begins the
10218        // serving path) and returns, so a migrate run never binds a port.
10219        let dispatch = run_body
10220            .find("if is_migrate_only_mode() {")
10221            .expect("run() dispatches the migrate one-shot");
10222        let server_start = run_body
10223            .find("let Self {")
10224            .expect("run() destructures self to start the server");
10225        assert!(
10226            dispatch < server_start,
10227            "AUTUMN_MIGRATE must be handled before the server-start path"
10228        );
10229        let migrate_branch = &run_body[dispatch..server_start];
10230        assert!(
10231            migrate_branch.contains("self.run_migrate_only_mode().await;")
10232                && migrate_branch.contains("return;"),
10233            "the migrate one-shot must run then return before server start"
10234        );
10235
10236        // The handler applies per target and exits — never starting the server.
10237        let handler_start = source
10238            .find("async fn run_migrate_only_mode(self)")
10239            .expect("migrate handler exists");
10240        let handler_end = source
10241            .find("async fn run_one_off_task_mode(self, requested_name: String)")
10242            .expect("one-off task handler follows the migrate handler");
10243        let handler = &source[handler_start..handler_end];
10244        assert!(
10245            handler.contains("apply_pending_or_exit"),
10246            "the migrate handler applies pending migrations per target"
10247        );
10248        assert!(
10249            handler.contains("std::process::exit(0)"),
10250            "the migrate handler exits after applying"
10251        );
10252
10253        // Issue #1614, PR3: the migrate one-shot must apply the SAME SQLite
10254        // sharding guard as normal boot BEFORE its migration loop, so a sharded
10255        // `sqlite:` target exits with the actionable sharding error instead of a
10256        // generic `PgConnection` failure — and the two paths cannot drift because
10257        // both call `sqlite_sharding_unsupported_guard`.
10258        let guard_call = handler
10259            .find("sqlite_sharding_unsupported_guard(")
10260            .expect("migrate handler applies the SQLite sharding guard");
10261        let first_apply = handler
10262            .find("apply_pending_or_exit")
10263            .expect("migrate handler applies per target");
10264        assert!(
10265            guard_call < first_apply,
10266            "the SQLite guard must run BEFORE the migration loop / apply_pending_or_exit"
10267        );
10268        assert!(
10269            !handler.contains("initialize_job_runtime")
10270                && !handler.contains("try_build_router_inner"),
10271            "the migrate one-shot must not start the server"
10272        );
10273
10274        // The per-target applier reuses `run_pending_locked` (the exact engine
10275        // `auto_migrate` drives — no duplicated migration logic) and exits
10276        // non-zero on failure so a bad migration aborts before cutover (AC-3).
10277        let helper_start = source
10278            .find("fn apply_pending_or_exit(")
10279            .expect("apply_pending_or_exit exists");
10280        let helper = &source[helper_start..helper_start + 1200];
10281        assert!(
10282            helper.contains("crate::migrate::run_pending_locked("),
10283            "must reuse the shared locked applier, not duplicate migration logic"
10284        );
10285        assert!(
10286            helper.contains("std::process::exit(1)"),
10287            "a failed migration must exit non-zero (abort before cutover)"
10288        );
10289    }
10290
10291    #[cfg(feature = "db")]
10292    #[test]
10293    fn hooked_repository_apps_include_hook_queue_framework_migration() {
10294        let migrations = migrations_with_repository_framework_migrations(
10295            vec![APP_TEST_MIGRATIONS],
10296            true,
10297            false,
10298            RepositoryCommitHookQueueMigrationMode::Runtime,
10299        );
10300        let names = migration_names(&migrations);
10301
10302        assert!(
10303            names
10304                .iter()
10305                .any(|name| name == REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION),
10306            "hooked repository apps must auto-register the durable hook queue migration"
10307        );
10308        assert!(
10309            names.iter().all(|name| !name.contains("api_tokens")),
10310            "hooked repository apps must not auto-register unrelated framework migrations: {names:?}"
10311        );
10312    }
10313
10314    #[cfg(feature = "db")]
10315    #[test]
10316    fn runtime_hooked_apps_include_hook_queue_framework_migration_without_app_migrations() {
10317        let migrations = migrations_with_repository_framework_migrations(
10318            Vec::new(),
10319            true,
10320            false,
10321            RepositoryCommitHookQueueMigrationMode::Runtime,
10322        );
10323        let names = migration_names(&migrations);
10324
10325        assert!(
10326            names
10327                .iter()
10328                .any(|name| name == REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION),
10329            "runtime hooked repository apps must install the durable hook queue even when app migrations are managed elsewhere"
10330        );
10331    }
10332
10333    #[cfg(feature = "db")]
10334    #[test]
10335    fn versioned_repository_apps_include_version_history_framework_migration() {
10336        let migrations = migrations_with_repository_framework_migrations(
10337            vec![APP_TEST_MIGRATIONS],
10338            false,
10339            true,
10340            RepositoryCommitHookQueueMigrationMode::Runtime,
10341        );
10342        let names = migration_names(&migrations);
10343
10344        assert!(
10345            names.iter().any(|name| name == VERSION_HISTORY_MIGRATION),
10346            "versioned repository apps must auto-register the version-history migration"
10347        );
10348        assert!(
10349            names
10350                .iter()
10351                .all(|name| !name.contains("repository_commit_hook_queue")),
10352            "versioned-only repository apps must not auto-register the durable hook queue: {names:?}"
10353        );
10354    }
10355
10356    #[cfg(feature = "db")]
10357    #[test]
10358    fn runtime_versioned_apps_include_version_history_framework_migration_without_app_migrations() {
10359        let migrations = migrations_with_repository_framework_migrations(
10360            Vec::new(),
10361            false,
10362            true,
10363            RepositoryCommitHookQueueMigrationMode::Runtime,
10364        );
10365        let names = migration_names(&migrations);
10366
10367        assert!(
10368            names.iter().any(|name| name == VERSION_HISTORY_MIGRATION),
10369            "runtime versioned repository apps must install version history even when app migrations are managed elsewhere"
10370        );
10371    }
10372
10373    #[cfg(feature = "db")]
10374    #[test]
10375    fn static_builds_do_not_auto_add_hook_queue_when_no_migrations_registered() {
10376        let migrations = migrations_with_repository_framework_migrations(
10377            Vec::new(),
10378            true,
10379            true,
10380            RepositoryCommitHookQueueMigrationMode::StaticBuild,
10381        );
10382
10383        assert!(
10384            migrations.is_empty(),
10385            "static/export builds that pass no migrations must not mutate the database"
10386        );
10387    }
10388
10389    #[cfg(feature = "db")]
10390    #[test]
10391    fn directory_migration_required_only_at_runtime_with_shards_and_routing() {
10392        use RepositoryCommitHookQueueMigrationMode::{Runtime, StaticBuild};
10393
10394        // The happy path: routing on, shards present, real runtime boot.
10395        assert!(directory_migration_is_required(true, true, Runtime));
10396
10397        // A static build must never create the directory table, even with
10398        // routing enabled and shards configured.
10399        assert!(!directory_migration_is_required(true, true, StaticBuild));
10400
10401        // Routing disabled, or no shards, means no directory table at all.
10402        assert!(!directory_migration_is_required(false, true, Runtime));
10403        assert!(!directory_migration_is_required(true, false, Runtime));
10404    }
10405
10406    #[test]
10407    fn shard_map_migration_required_only_at_runtime_with_shards() {
10408        use RepositoryCommitHookQueueMigrationMode::{Runtime, StaticBuild};
10409
10410        // The happy path: shards present, real runtime boot.
10411        assert!(shard_map_migration_is_required(true, Runtime));
10412
10413        // A static build must never create the shard-map table.
10414        assert!(!shard_map_migration_is_required(true, StaticBuild));
10415
10416        // No shards means no shard-map table.
10417        assert!(!shard_map_migration_is_required(false, Runtime));
10418    }
10419
10420    #[cfg(feature = "db")]
10421    #[test]
10422    fn unhooked_apps_do_not_auto_add_hook_queue_framework_migration() {
10423        let migrations = migrations_with_repository_framework_migrations(
10424            Vec::new(),
10425            false,
10426            false,
10427            RepositoryCommitHookQueueMigrationMode::Runtime,
10428        );
10429
10430        assert!(
10431            migrations.is_empty(),
10432            "unhooked apps should not get durable hook queue migrations for free"
10433        );
10434    }
10435
10436    #[cfg(feature = "db")]
10437    fn migration_names(migrations: &[crate::migrate::EmbeddedMigrations]) -> Vec<String> {
10438        use diesel::migration::{Migration, MigrationSource as _};
10439        use diesel::pg::Pg;
10440
10441        migrations
10442            .iter()
10443            .flat_map(|source| {
10444                let migrations: Vec<Box<dyn Migration<Pg>>> = source.migrations().unwrap();
10445                migrations
10446            })
10447            .map(|migration| migration.name().to_string())
10448            .collect()
10449    }
10450
10451    #[cfg(feature = "db")]
10452    #[test]
10453    fn control_framework_filter_skips_control_but_keeps_shard_required_sets() {
10454        // The full control set is skipped on shards...
10455        assert!(migration_set_is_control_framework(
10456            &crate::migrate::FRAMEWORK_MIGRATIONS
10457        ));
10458        // ...but the standalone shard-required sets are kept (not flagged),
10459        // even though their migrations are duplicated into the control
10460        // `migrations/` directory.
10461        assert!(!migration_set_is_control_framework(
10462            &crate::version_history::VERSION_HISTORY_MIGRATIONS
10463        ));
10464        assert!(!migration_set_is_control_framework(
10465            &crate::repository_commit_hooks::REPOSITORY_COMMIT_HOOK_MIGRATIONS
10466        ));
10467    }
10468
10469    #[cfg(feature = "db")]
10470    #[test]
10471    fn sharded_app_with_full_framework_still_gets_shard_required_sets() {
10472        use diesel::migration::{Migration, MigrationSource as _};
10473        use diesel::pg::Pg;
10474
10475        // A sharded app that registers the full control FRAMEWORK_MIGRATIONS and
10476        // also uses commit hooks + versioning. The hook-queue / version-history
10477        // migrations are present *inside* the control set, but that set is
10478        // stripped from shard targets by `migration_set_is_control_framework`, so
10479        // the standalone shard-required sets must still be appended — otherwise
10480        // shards never get those tables.
10481        let migrations = migrations_with_repository_framework_migrations(
10482            vec![crate::migrate::FRAMEWORK_MIGRATIONS],
10483            true,
10484            true,
10485            RepositoryCommitHookQueueMigrationMode::Runtime,
10486        );
10487
10488        // The migration names the shard apply loop will actually run: every set
10489        // that is not the control framework set (which gets stripped on shards).
10490        let shard_names: Vec<String> = migrations
10491            .iter()
10492            .filter(|set| !migration_set_is_control_framework(set))
10493            .flat_map(|set| {
10494                let ms: Vec<Box<dyn Migration<Pg>>> = set.migrations().unwrap_or_default();
10495                ms.into_iter()
10496                    .map(|m| m.name().to_string())
10497                    .collect::<Vec<_>>()
10498            })
10499            .collect();
10500
10501        assert!(
10502            shard_names
10503                .iter()
10504                .any(|name| name == REPOSITORY_COMMIT_HOOK_QUEUE_MIGRATION),
10505            "shards must receive the commit-hook queue migration even when the full \
10506             control framework set is also registered: {shard_names:?}"
10507        );
10508        assert!(
10509            shard_names
10510                .iter()
10511                .any(|name| name == VERSION_HISTORY_MIGRATION),
10512            "shards must receive the version-history migration even when the full \
10513             control framework set is also registered: {shard_names:?}"
10514        );
10515    }
10516
10517    #[cfg(feature = "db")]
10518    #[test]
10519    fn configure_replica_migration_check_stores_recheck_urls() {
10520        let mut config = AutumnConfig::default();
10521        config.database.primary_url = Some("postgres://localhost/primary".to_owned());
10522        config.database.replica_url = Some("postgres://localhost/replica".to_owned());
10523        let topology = crate::db::create_topology(&config.database)
10524            .expect("topology should build")
10525            .expect("database should be configured");
10526
10527        let state = build_state(
10528            &config,
10529            Some(&topology),
10530            None,
10531            #[cfg(feature = "ws")]
10532            None,
10533        );
10534
10535        assert!(
10536            state.probes().replica_migration_check().is_none(),
10537            "build_state should not enable migration checks without registered migrations"
10538        );
10539
10540        configure_replica_migration_check(
10541            &state,
10542            Some((
10543                "postgres://localhost/primary".to_owned(),
10544                "postgres://localhost/replica".to_owned(),
10545            )),
10546        );
10547
10548        let check = state
10549            .probes()
10550            .replica_migration_check()
10551            .expect("replica migration check should be configured");
10552
10553        assert_eq!(check.primary_url, "postgres://localhost/primary");
10554        assert_eq!(check.replica_url, "postgres://localhost/replica");
10555    }
10556
10557    #[cfg(feature = "db")]
10558    #[tokio::test]
10559    async fn replica_migration_readiness_marks_ready_endpoint_degraded() {
10560        let mut config = AutumnConfig::default();
10561        config.database.primary_url = Some("postgres://localhost/primary".to_owned());
10562        config.database.primary_pool_size = Some(5);
10563        config.database.replica_url = Some("postgres://localhost/replica".to_owned());
10564        config.database.replica_pool_size = Some(2);
10565        config.database.replica_fallback = crate::config::ReplicaFallback::FailReadiness;
10566        let topology = crate::db::create_topology(&config.database)
10567            .expect("topology should build")
10568            .expect("database should be configured");
10569        let state = build_state(
10570            &config,
10571            Some(&topology),
10572            None,
10573            #[cfg(feature = "ws")]
10574            None,
10575        );
10576
10577        apply_replica_migration_readiness(
10578            &state,
10579            Some(crate::migrate::ReplicaMigrationReadiness::Stale {
10580                primary_latest: Some("00000000000002".to_owned()),
10581                replica_latest: Some("00000000000001".to_owned()),
10582            }),
10583        );
10584
10585        let (status, _) = crate::probe::readiness_response(&state).await;
10586
10587        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
10588    }
10589
10590    #[cfg(feature = "db")]
10591    #[tokio::test]
10592    async fn blocking_replica_migration_readiness_reports_unknown_connection_errors() {
10593        let readiness = crate::migrate::check_replica_migration_readiness_blocking(
10594            "not-a-primary-url".to_owned(),
10595            "not-a-replica-url".to_owned(),
10596        )
10597        .await;
10598
10599        assert!(matches!(
10600            readiness,
10601            crate::migrate::ReplicaMigrationReadiness::Unknown(_)
10602        ));
10603    }
10604
10605    #[cfg(feature = "ws")]
10606    #[test]
10607    fn with_channels_backend_overrides_config_driven_backend_selection() {
10608        let builder = app().with_channels_backend(crate::channels::LocalChannelsBackend::new(4));
10609        let AppBuilder {
10610            channels_backend, ..
10611        } = builder;
10612        assert!(channels_backend.is_some());
10613
10614        let mut config = AutumnConfig::default();
10615        config.channels.backend = crate::config::ChannelBackend::Redis;
10616        config.channels.redis.url = None;
10617
10618        let state = build_state(
10619            &config,
10620            #[cfg(feature = "db")]
10621            None,
10622            #[cfg(feature = "db")]
10623            None,
10624            #[cfg(feature = "ws")]
10625            channels_backend,
10626        );
10627        let mut rx = state.channels().subscribe("override");
10628
10629        state
10630            .broadcast()
10631            .publish("override", "ok")
10632            .expect("custom local backend should publish");
10633
10634        assert_eq!(rx.try_recv().expect("message should arrive").as_str(), "ok");
10635    }
10636
10637    /// Helper to create a simple GET route for testing.
10638    pub fn test_get_route(path: &'static str, name: &'static str) -> Route {
10639        Route {
10640            method: http::Method::GET,
10641            path,
10642            handler: axum::routing::get(|| async { "ok" }),
10643            name,
10644            api_doc: crate::openapi::ApiDoc {
10645                method: "GET",
10646                path,
10647                operation_id: name,
10648                success_status: 200,
10649                ..Default::default()
10650            },
10651            repository: None,
10652            idempotency: crate::route::RouteIdempotency::Direct,
10653            timeout: crate::route::RouteTimeout::Inherit,
10654            api_version: None,
10655            sunset_opt_out: false,
10656        }
10657    }
10658
10659    #[cfg(feature = "i18n")]
10660    fn test_i18n_bundle(key: &str, value: &str) -> Arc<crate::i18n::Bundle> {
10661        let mut messages = std::collections::HashMap::new();
10662        let mut en = std::collections::HashMap::new();
10663        en.insert(key.to_owned(), value.to_owned());
10664        messages.insert("en".to_owned(), en);
10665        Arc::new(crate::i18n::Bundle::from_messages(
10666            messages,
10667            &crate::i18n::I18nConfig::default(),
10668        ))
10669    }
10670
10671    #[cfg(feature = "i18n")]
10672    #[test]
10673    fn i18n_auto_defers_loading_until_runtime_config_is_available() {
10674        let builder = app().i18n_auto();
10675
10676        assert!(builder.i18n_bundle.is_none());
10677        assert!(builder.i18n_auto_load);
10678    }
10679
10680    #[cfg(feature = "i18n")]
10681    #[derive(Clone)]
10682    struct StaticConfigLoader {
10683        config: AutumnConfig,
10684    }
10685
10686    #[cfg(feature = "i18n")]
10687    impl crate::config::ConfigLoader for StaticConfigLoader {
10688        async fn load(&self) -> Result<AutumnConfig, crate::config::ConfigError> {
10689            Ok(self.config.clone())
10690        }
10691    }
10692
10693    #[cfg(feature = "i18n")]
10694    struct NoopTelemetryProvider;
10695
10696    #[cfg(feature = "i18n")]
10697    impl crate::telemetry::TelemetryProvider for NoopTelemetryProvider {
10698        fn init(
10699            &self,
10700            _log: &crate::config::LogConfig,
10701            _telemetry: &crate::config::TelemetryConfig,
10702            _profile: Option<&str>,
10703        ) -> Result<crate::telemetry::TelemetryGuard, crate::telemetry::TelemetryInitError>
10704        {
10705            Ok(crate::telemetry::TelemetryGuard::disabled())
10706        }
10707    }
10708
10709    #[cfg(feature = "i18n")]
10710    #[tokio::test]
10711    async fn i18n_auto_uses_config_loader_output_for_bundle_dir() {
10712        let project = tempfile::tempdir().expect("project dir");
10713        let i18n_dir = project.path().join("custom-i18n");
10714        std::fs::create_dir_all(&i18n_dir).expect("i18n dir");
10715        std::fs::write(i18n_dir.join("en.ftl"), "nav.home = Loader Home\n").expect("bundle");
10716
10717        let mut config = AutumnConfig::default();
10718        config.i18n.dir = "custom-i18n".to_owned();
10719        let builder = app()
10720            .with_config_loader(StaticConfigLoader { config })
10721            .with_telemetry_provider(NoopTelemetryProvider)
10722            .i18n_auto();
10723        let AppBuilder {
10724            config_loader_factory,
10725            telemetry_provider,
10726            i18n_bundle,
10727            i18n_auto_load,
10728            plugin_config_roots,
10729            ..
10730        } = builder;
10731
10732        let (loaded_config, _guard) = load_config_and_telemetry(
10733            config_loader_factory,
10734            telemetry_provider,
10735            plugin_config_roots,
10736        )
10737        .await;
10738        let env = crate::config::MockEnv::new().with(
10739            "AUTUMN_MANIFEST_DIR",
10740            project.path().to_str().expect("utf-8 path"),
10741        );
10742        let bundle = resolve_i18n_bundle(i18n_bundle, i18n_auto_load, &loaded_config, &env)
10743            .expect("bundle loaded from configured dir");
10744
10745        assert_eq!(bundle.translate("en", "nav.home", &[]), "Loader Home");
10746    }
10747
10748    #[cfg(feature = "i18n")]
10749    #[tokio::test]
10750    async fn i18n_bundle_layer_is_applied_to_static_route_rendering() {
10751        async fn localized(locale: crate::i18n::Locale) -> String {
10752            locale.t("nav.home")
10753        }
10754
10755        let config = AutumnConfig::default();
10756        let state = AppState::for_test();
10757        let custom_layers = install_i18n_bundle_layer(
10758            Vec::new(),
10759            &state,
10760            Some(test_i18n_bundle("nav.home", "Home")),
10761        );
10762        let router = crate::router::try_build_router_inner(
10763            vec![Route {
10764                method: http::Method::GET,
10765                path: "/about",
10766                handler: axum::routing::get(localized),
10767                name: "localized",
10768                api_doc: crate::openapi::ApiDoc {
10769                    method: "GET",
10770                    path: "/about",
10771                    operation_id: "localized",
10772                    success_status: 200,
10773                    ..Default::default()
10774                },
10775                repository: None,
10776                idempotency: crate::route::RouteIdempotency::Direct,
10777                timeout: crate::route::RouteTimeout::Inherit,
10778                api_version: None,
10779                sunset_opt_out: false,
10780            }],
10781            &config,
10782            state,
10783            crate::router::RouterContext {
10784                exception_filters: Vec::new(),
10785                scoped_groups: Vec::new(),
10786                merge_routers: Vec::new(),
10787                nest_routers: Vec::new(),
10788                custom_layers,
10789                static_gate_layers: Vec::new(),
10790                #[cfg(feature = "maud")]
10791                error_page_renderer: None,
10792                session_store: None,
10793                #[cfg(feature = "openapi")]
10794                openapi: None,
10795                #[cfg(feature = "mcp")]
10796                mcp: None,
10797            },
10798        )
10799        .expect("router builds");
10800        let tmp = tempfile::tempdir().expect("dist parent");
10801        let dist = tmp.path().join("dist");
10802
10803        crate::static_gen::render_static_routes(
10804            router,
10805            &[crate::static_gen::StaticRouteMeta {
10806                path: "/about",
10807                name: "localized",
10808                revalidate: None,
10809                params_fn: None,
10810            }],
10811            &dist,
10812        )
10813        .await
10814        .expect("static render succeeds");
10815
10816        let html = std::fs::read_to_string(dist.join("about/index.html")).expect("rendered html");
10817        assert_eq!(html, "Home");
10818    }
10819
10820    #[test]
10821    fn app_builder_routes_adds_routes() {
10822        let builder = app();
10823        assert_eq!(builder.routes.len(), 0);
10824
10825        let builder = builder.routes(vec![test_get_route("/1", "route1")]);
10826        assert_eq!(builder.routes.len(), 1);
10827
10828        let builder = builder.routes(vec![
10829            test_get_route("/2", "route2"),
10830            test_get_route("/3", "route3"),
10831        ]);
10832        assert_eq!(builder.routes.len(), 3);
10833
10834        assert_eq!(builder.routes[0].path, "/1");
10835        assert_eq!(builder.routes[1].path, "/2");
10836        assert_eq!(builder.routes[2].path, "/3");
10837    }
10838
10839    #[test]
10840    fn app_builder_extensions_store_and_update_typed_values() {
10841        let builder = app()
10842            .with_extension::<String>("haunted".into())
10843            .update_extension::<String, _, _>(String::new, |value| value.push_str(" harvest"));
10844
10845        let value = builder
10846            .extension::<String>()
10847            .expect("string extension should be present");
10848        assert_eq!(value, "haunted harvest");
10849    }
10850
10851    #[cfg(feature = "mail")]
10852    #[tokio::test]
10853    async fn app_builder_with_mail_delivery_queue_stores_queue_for_install() {
10854        let builder = app().with_mail_delivery_queue(MailTestNoopQueue);
10855        let factory = builder
10856            .mail_delivery_queue_factory
10857            .expect("with_mail_delivery_queue should store a factory on the builder");
10858
10859        // Invoke the trivial wrapper closure built by with_mail_delivery_queue
10860        // and verify it returns the wrapped queue successfully.
10861        let state = AppState::for_test();
10862        let queue = factory(&state).expect("trivial factory should produce the queue");
10863        assert!(Arc::strong_count(&queue) >= 1);
10864        // Cover the enqueue method body by invoking it once.
10865        queue
10866            .enqueue(test_mail())
10867            .await
10868            .expect("noop queue should always succeed");
10869    }
10870
10871    #[cfg(feature = "mail")]
10872    #[test]
10873    fn app_builder_with_mail_delivery_queue_factory_runs_with_app_state() {
10874        let observed_profile: Arc<std::sync::Mutex<Option<String>>> =
10875            Arc::new(std::sync::Mutex::new(None));
10876        let captured = Arc::clone(&observed_profile);
10877        let builder = app().with_mail_delivery_queue_factory(move |state| {
10878            *captured.lock().expect("lock") = Some(state.profile().to_owned());
10879            Ok::<_, crate::AutumnError>(MailTestNoopQueue)
10880        });
10881
10882        let factory = builder
10883            .mail_delivery_queue_factory
10884            .expect("factory should be stored on the builder");
10885        let state = AppState::for_test().with_profile("dev");
10886        let _queue = factory(&state).expect("factory should succeed");
10887
10888        assert_eq!(
10889            observed_profile.lock().expect("lock").as_deref(),
10890            Some("dev"),
10891            "factory must run with the live AppState"
10892        );
10893    }
10894
10895    #[cfg(feature = "mail")]
10896    #[test]
10897    fn app_builder_with_mail_delivery_queue_factory_propagates_errors() {
10898        let builder = app().with_mail_delivery_queue_factory(|_state| {
10899            Err::<MailTestNoopQueue, _>(crate::AutumnError::service_unavailable_msg("factory boom"))
10900        });
10901
10902        let factory = builder
10903            .mail_delivery_queue_factory
10904            .expect("factory present");
10905        let state = AppState::for_test();
10906        match factory(&state) {
10907            Ok(_) => panic!("factory should have errored"),
10908            Err(err) => assert!(err.to_string().contains("factory boom")),
10909        }
10910    }
10911
10912    #[tokio::test]
10913    async fn startup_and_shutdown_hooks_run_in_expected_order() {
10914        let events = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
10915        let startup_events = Arc::clone(&events);
10916        let shutdown_a = Arc::clone(&events);
10917        let shutdown_b = Arc::clone(&events);
10918        let builder = app()
10919            .on_startup(move |_state| {
10920                let startup_events = Arc::clone(&startup_events);
10921                async move {
10922                    startup_events
10923                        .lock()
10924                        .expect("events lock poisoned")
10925                        .push("start");
10926                    Ok(())
10927                }
10928            })
10929            .on_shutdown(move || {
10930                let shutdown_a = Arc::clone(&shutdown_a);
10931                async move {
10932                    shutdown_a
10933                        .lock()
10934                        .expect("events lock poisoned")
10935                        .push("stop-a");
10936                }
10937            })
10938            .on_shutdown(move || {
10939                let shutdown_b = Arc::clone(&shutdown_b);
10940                async move {
10941                    shutdown_b
10942                        .lock()
10943                        .expect("events lock poisoned")
10944                        .push("stop-b");
10945                }
10946            });
10947
10948        run_startup_hooks(&builder.startup_hooks, AppState::for_test())
10949            .await
10950            .expect("startup hooks should succeed");
10951        run_shutdown_hooks(&builder.shutdown_hooks).await;
10952
10953        let recorded_events = events.lock().expect("events lock poisoned").clone();
10954        assert_eq!(recorded_events, vec!["start", "stop-b", "stop-a"]);
10955    }
10956
10957    fn startup_noop_job_handler(
10958        _state: AppState,
10959        _payload: serde_json::Value,
10960    ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'static>> {
10961        Box::pin(async move { Ok(()) })
10962    }
10963
10964    #[tokio::test]
10965    async fn startup_hooks_can_enqueue_jobs_after_runtime_init() {
10966        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
10967        crate::job::clear_global_job_client();
10968
10969        let builder = app()
10970            .jobs(vec![crate::job::JobInfo {
10971                version: 1,
10972                name: "startup-seed".to_string(),
10973                max_attempts: 1,
10974                initial_backoff_ms: 1,
10975                queue: "default".to_string(),
10976                uniqueness: None,
10977                concurrency: None,
10978                handler: startup_noop_job_handler,
10979            }])
10980            .on_startup(|_state| async {
10981                crate::job::enqueue("startup-seed", serde_json::json!({ "kind": "warmup" })).await
10982            });
10983
10984        let state = AppState::for_test().with_profile("dev");
10985        let shutdown = tokio_util::sync::CancellationToken::new();
10986
10987        initialize_job_runtime(
10988            builder.jobs.clone(),
10989            &state,
10990            &shutdown,
10991            &crate::config::JobConfig::default(),
10992            true,
10993        )
10994        .expect("job runtime should initialize before startup hooks");
10995
10996        run_startup_hooks(&builder.startup_hooks, state.clone())
10997            .await
10998            .expect("startup hook should be able to enqueue jobs");
10999
11000        tokio::time::timeout(std::time::Duration::from_secs(1), async {
11001            loop {
11002                let snapshot = state.job_registry().snapshot();
11003                let status = snapshot
11004                    .get("startup-seed")
11005                    .expect("job should be registered before startup hooks run");
11006                if status.total_successes == 1 {
11007                    break;
11008                }
11009                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
11010            }
11011        })
11012        .await
11013        .expect("startup-enqueued job should complete");
11014
11015        shutdown.cancel();
11016        crate::job::clear_global_job_client();
11017    }
11018
11019    #[tokio::test]
11020    async fn initialize_job_runtime_propagates_redis_init_errors() {
11021        let _guard = crate::job::global_job_runtime_test_lock().lock().await;
11022        crate::job::clear_global_job_client();
11023
11024        let state = AppState::for_test().with_profile("dev");
11025        let shutdown = tokio_util::sync::CancellationToken::new();
11026        let config = crate::config::JobConfig {
11027            backend: "redis".to_string(),
11028            ..Default::default()
11029        };
11030
11031        let error = initialize_job_runtime(
11032            vec![crate::job::JobInfo {
11033                version: 1,
11034                name: "startup-seed".to_string(),
11035                max_attempts: 1,
11036                initial_backoff_ms: 1,
11037                queue: "default".to_string(),
11038                uniqueness: None,
11039                concurrency: None,
11040                handler: startup_noop_job_handler,
11041            }],
11042            &state,
11043            &shutdown,
11044            &config,
11045            true,
11046        )
11047        .expect_err("redis init errors should abort startup");
11048
11049        #[cfg(feature = "redis")]
11050        assert!(
11051            error
11052                .to_string()
11053                .contains("jobs.backend=redis requires jobs.redis.url"),
11054            "unexpected error: {error}"
11055        );
11056
11057        #[cfg(not(feature = "redis"))]
11058        assert!(
11059            error
11060                .to_string()
11061                .contains("jobs.backend=redis requested but redis feature is disabled"),
11062            "unexpected error: {error}"
11063        );
11064    }
11065
11066    #[tokio::test]
11067    async fn startup_hook_errors_propagate() {
11068        let builder = app().on_startup(|_state| async {
11069            Err(crate::AutumnError::service_unavailable_msg(
11070                "startup ritual failed",
11071            ))
11072        });
11073
11074        let error = run_startup_hooks(&builder.startup_hooks, AppState::for_test())
11075            .await
11076            .expect_err("startup hook should fail");
11077        assert!(error.to_string().contains("startup ritual failed"));
11078    }
11079
11080    #[tokio::test]
11081    async fn build_router_mounts_user_routes() {
11082        let router = test_router(vec![test_get_route("/test", "test_handler")]);
11083
11084        let response = router
11085            .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
11086            .await
11087            .unwrap();
11088
11089        assert_eq!(response.status(), StatusCode::OK);
11090        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11091            .await
11092            .unwrap();
11093        assert_eq!(&body[..], b"ok");
11094    }
11095
11096    #[tokio::test]
11097    async fn build_router_mounts_health_check_at_default_path() {
11098        let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11099
11100        let response = router
11101            .oneshot(
11102                Request::builder()
11103                    .uri("/health")
11104                    .body(Body::empty())
11105                    .unwrap(),
11106            )
11107            .await
11108            .unwrap();
11109
11110        assert_eq!(response.status(), StatusCode::OK);
11111        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11112            .await
11113            .unwrap();
11114        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
11115        assert_eq!(json["status"], "ok");
11116    }
11117
11118    #[tokio::test]
11119    async fn build_router_mounts_health_check_at_custom_path() {
11120        let mut config = AutumnConfig::default();
11121        config.health.path = "/healthz".to_owned();
11122        let state = AppState {
11123            extensions: std::sync::Arc::new(std::sync::RwLock::new(
11124                std::collections::HashMap::new(),
11125            )),
11126            #[cfg(feature = "db")]
11127            pool: None,
11128            #[cfg(feature = "db")]
11129            replica_pool: None,
11130            #[cfg(feature = "db")]
11131            shards: None,
11132            profile: None,
11133            role: crate::config::ProcessRole::Combined,
11134            started_at: std::time::Instant::now(),
11135            health_detailed: true,
11136            probes: crate::probe::ProbeState::ready_for_test(),
11137            metrics: crate::middleware::MetricsCollector::new(),
11138            log_levels: crate::actuator::LogLevels::new("info"),
11139            task_registry: crate::actuator::TaskRegistry::new(),
11140            job_registry: crate::actuator::JobRegistry::new(),
11141            config_props: crate::actuator::ConfigProperties::default(),
11142            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
11143            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
11144            #[cfg(feature = "ws")]
11145            channels: crate::channels::Channels::new(32),
11146            #[cfg(feature = "presence")]
11147            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
11148            #[cfg(feature = "ws")]
11149            shutdown: tokio_util::sync::CancellationToken::new(),
11150            policy_registry: crate::authorization::PolicyRegistry::default(),
11151            forbidden_response: crate::authorization::ForbiddenResponse::default(),
11152            auth_session_key: "user_id".to_owned(),
11153            shared_cache: None,
11154            clock: std::sync::Arc::new(crate::time::SystemClock),
11155            app_id: AppState::next_app_id(),
11156        };
11157        let router =
11158            crate::router::build_router(vec![test_get_route("/dummy", "dummy")], &config, state);
11159
11160        let response = router
11161            .oneshot(
11162                Request::builder()
11163                    .uri("/healthz")
11164                    .body(Body::empty())
11165                    .unwrap(),
11166            )
11167            .await
11168            .unwrap();
11169
11170        assert_eq!(response.status(), StatusCode::OK);
11171    }
11172
11173    #[tokio::test]
11174    async fn build_router_adds_request_id_header() {
11175        let router = test_router(vec![test_get_route("/test", "test")]);
11176
11177        let response = router
11178            .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
11179            .await
11180            .unwrap();
11181
11182        assert!(response.headers().contains_key("x-request-id"));
11183    }
11184
11185    #[tokio::test]
11186    async fn build_router_unknown_route_returns_404() {
11187        let router = test_router(vec![test_get_route("/exists", "exists")]);
11188
11189        let response = router
11190            .oneshot(Request::builder().uri("/nope").body(Body::empty()).unwrap())
11191            .await
11192            .unwrap();
11193
11194        assert_eq!(response.status(), StatusCode::NOT_FOUND);
11195    }
11196
11197    #[tokio::test]
11198    async fn build_router_multiple_routes() {
11199        let router = test_router(vec![test_get_route("/a", "a"), test_get_route("/b", "b")]);
11200
11201        let resp_a = router
11202            .clone()
11203            .oneshot(Request::builder().uri("/a").body(Body::empty()).unwrap())
11204            .await
11205            .unwrap();
11206        assert_eq!(resp_a.status(), StatusCode::OK);
11207
11208        let resp_b = router
11209            .oneshot(Request::builder().uri("/b").body(Body::empty()).unwrap())
11210            .await
11211            .unwrap();
11212        assert_eq!(resp_b.status(), StatusCode::OK);
11213    }
11214
11215    #[tokio::test]
11216    async fn build_router_post_route() {
11217        let post_routes = vec![Route {
11218            method: http::Method::POST,
11219            path: "/submit",
11220            handler: axum::routing::post(|| async { "posted" }),
11221            name: "submit",
11222            api_doc: crate::openapi::ApiDoc {
11223                method: "POST",
11224                path: "/submit",
11225                operation_id: "submit",
11226                success_status: 200,
11227                ..Default::default()
11228            },
11229            repository: None,
11230            idempotency: crate::route::RouteIdempotency::Direct,
11231            timeout: crate::route::RouteTimeout::Inherit,
11232            api_version: None,
11233            sunset_opt_out: false,
11234        }];
11235        let config = AutumnConfig::default();
11236        let state = AppState {
11237            extensions: std::sync::Arc::new(std::sync::RwLock::new(
11238                std::collections::HashMap::new(),
11239            )),
11240            #[cfg(feature = "db")]
11241            pool: None,
11242            #[cfg(feature = "db")]
11243            replica_pool: None,
11244            #[cfg(feature = "db")]
11245            shards: None,
11246            profile: None,
11247            role: crate::config::ProcessRole::Combined,
11248            started_at: std::time::Instant::now(),
11249            health_detailed: true,
11250            probes: crate::probe::ProbeState::ready_for_test(),
11251            metrics: crate::middleware::MetricsCollector::new(),
11252            log_levels: crate::actuator::LogLevels::new("info"),
11253            task_registry: crate::actuator::TaskRegistry::new(),
11254            job_registry: crate::actuator::JobRegistry::new(),
11255            config_props: crate::actuator::ConfigProperties::default(),
11256            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
11257            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
11258            #[cfg(feature = "ws")]
11259            channels: crate::channels::Channels::new(32),
11260            #[cfg(feature = "presence")]
11261            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
11262            #[cfg(feature = "ws")]
11263            shutdown: tokio_util::sync::CancellationToken::new(),
11264            policy_registry: crate::authorization::PolicyRegistry::default(),
11265            forbidden_response: crate::authorization::ForbiddenResponse::default(),
11266            auth_session_key: "user_id".to_owned(),
11267            shared_cache: None,
11268            clock: std::sync::Arc::new(crate::time::SystemClock),
11269            app_id: AppState::next_app_id(),
11270        };
11271        let router = crate::router::build_router(post_routes, &config, state);
11272
11273        let response = router
11274            .oneshot(
11275                Request::builder()
11276                    .method("POST")
11277                    .uri("/submit")
11278                    .body(Body::empty())
11279                    .unwrap(),
11280            )
11281            .await
11282            .unwrap();
11283
11284        assert_eq!(response.status(), StatusCode::OK);
11285    }
11286
11287    #[tokio::test]
11288    async fn build_router_merges_methods_on_same_path() {
11289        let route_list = vec![
11290            Route {
11291                method: http::Method::GET,
11292                path: "/admin",
11293                handler: axum::routing::get(|| async { "list" }),
11294                name: "admin_list",
11295                api_doc: crate::openapi::ApiDoc {
11296                    method: "GET",
11297                    path: "/admin",
11298                    operation_id: "admin_list",
11299                    success_status: 200,
11300                    ..Default::default()
11301                },
11302                repository: None,
11303                idempotency: crate::route::RouteIdempotency::Direct,
11304                timeout: crate::route::RouteTimeout::Inherit,
11305                api_version: None,
11306                sunset_opt_out: false,
11307            },
11308            Route {
11309                method: http::Method::POST,
11310                path: "/admin",
11311                handler: axum::routing::post(|| async { "created" }),
11312                name: "create",
11313                api_doc: crate::openapi::ApiDoc {
11314                    method: "POST",
11315                    path: "/admin",
11316                    operation_id: "create",
11317                    success_status: 200,
11318                    ..Default::default()
11319                },
11320                repository: None,
11321                idempotency: crate::route::RouteIdempotency::Direct,
11322                timeout: crate::route::RouteTimeout::Inherit,
11323                api_version: None,
11324                sunset_opt_out: false,
11325            },
11326        ];
11327        let config = AutumnConfig::default();
11328        let router = crate::router::build_router(route_list, &config, AppState::for_test());
11329
11330        // GET /admin should return "list"
11331        let resp = router
11332            .clone()
11333            .oneshot(
11334                Request::builder()
11335                    .uri("/admin")
11336                    .body(Body::empty())
11337                    .unwrap(),
11338            )
11339            .await
11340            .unwrap();
11341        assert_eq!(resp.status(), StatusCode::OK);
11342        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11343            .await
11344            .unwrap();
11345        assert_eq!(&body[..], b"list");
11346
11347        // POST /admin should return "created" (not 405!)
11348        let resp = router
11349            .oneshot(
11350                Request::builder()
11351                    .method("POST")
11352                    .uri("/admin")
11353                    .body(Body::empty())
11354                    .unwrap(),
11355            )
11356            .await
11357            .unwrap();
11358        assert_eq!(resp.status(), StatusCode::OK);
11359        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11360            .await
11361            .unwrap();
11362        assert_eq!(&body[..], b"created");
11363    }
11364
11365    #[cfg(feature = "htmx")]
11366    #[tokio::test]
11367    async fn htmx_handler_returns_javascript_with_correct_headers() {
11368        let app = axum::Router::new().route(
11369            crate::htmx::HTMX_JS_PATH,
11370            axum::routing::get(crate::router::htmx_handler),
11371        );
11372
11373        let response = app
11374            .oneshot(
11375                Request::builder()
11376                    .uri(crate::htmx::HTMX_JS_PATH)
11377                    .body(Body::empty())
11378                    .unwrap(),
11379            )
11380            .await
11381            .unwrap();
11382
11383        assert_eq!(response.status(), StatusCode::OK);
11384
11385        let content_type = response
11386            .headers()
11387            .get("content-type")
11388            .unwrap()
11389            .to_str()
11390            .unwrap();
11391        assert!(
11392            content_type.contains("application/javascript"),
11393            "Expected application/javascript, got {content_type}"
11394        );
11395
11396        let cache_control = response
11397            .headers()
11398            .get("cache-control")
11399            .unwrap()
11400            .to_str()
11401            .unwrap();
11402        assert!(
11403            cache_control.contains("immutable"),
11404            "Expected immutable cache, got {cache_control}"
11405        );
11406
11407        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11408            .await
11409            .unwrap();
11410
11411        // Body length matches the embedded file
11412        assert_eq!(body.len(), crate::htmx::HTMX_JS.len());
11413
11414        // Body starts with valid JavaScript
11415        let start = std::str::from_utf8(&body[..50]).expect("htmx should be valid UTF-8");
11416        assert!(
11417            start.contains("htmx") || start.contains("function"),
11418            "Response doesn't look like htmx JavaScript: {start}"
11419        );
11420    }
11421
11422    #[cfg(feature = "htmx")]
11423    #[tokio::test]
11424    async fn htmx_csrf_handler_returns_csp_compatible_javascript() {
11425        let app = axum::Router::new().route(
11426            crate::htmx::HTMX_CSRF_JS_PATH,
11427            axum::routing::get(crate::router::htmx_csrf_handler),
11428        );
11429
11430        let response = app
11431            .oneshot(
11432                Request::builder()
11433                    .uri(crate::htmx::HTMX_CSRF_JS_PATH)
11434                    .body(Body::empty())
11435                    .unwrap(),
11436            )
11437            .await
11438            .unwrap();
11439
11440        assert_eq!(response.status(), StatusCode::OK);
11441        assert_eq!(
11442            response
11443                .headers()
11444                .get("content-type")
11445                .and_then(|value| value.to_str().ok()),
11446            Some("application/javascript")
11447        );
11448
11449        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11450            .await
11451            .unwrap();
11452        let js = std::str::from_utf8(&body).expect("csrf helper should be valid utf-8");
11453
11454        assert!(js.contains("htmx:configRequest"));
11455        assert!(js.contains("X-CSRF-Token"));
11456        assert!(!js.contains("<script"));
11457    }
11458
11459    #[cfg(feature = "htmx")]
11460    #[tokio::test]
11461    async fn build_router_serves_htmx_js() {
11462        let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11463
11464        let response = router
11465            .oneshot(
11466                Request::builder()
11467                    .uri(crate::htmx::HTMX_JS_PATH)
11468                    .body(Body::empty())
11469                    .unwrap(),
11470            )
11471            .await
11472            .unwrap();
11473
11474        assert_eq!(response.status(), StatusCode::OK);
11475        let ct = response
11476            .headers()
11477            .get("content-type")
11478            .unwrap()
11479            .to_str()
11480            .unwrap();
11481        assert!(ct.contains("javascript"));
11482    }
11483
11484    #[cfg(feature = "htmx")]
11485    #[tokio::test]
11486    async fn build_router_serves_htmx_csrf_js() {
11487        let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11488
11489        let response = router
11490            .oneshot(
11491                Request::builder()
11492                    .uri(crate::htmx::HTMX_CSRF_JS_PATH)
11493                    .body(Body::empty())
11494                    .unwrap(),
11495            )
11496            .await
11497            .unwrap();
11498
11499        assert_eq!(response.status(), StatusCode::OK);
11500        let csp = response
11501            .headers()
11502            .get("content-security-policy")
11503            .expect("framework JS should still receive security headers")
11504            .to_str()
11505            .unwrap();
11506        assert!(csp.contains("script-src 'self'"), "csp = {csp}");
11507        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11508            .await
11509            .unwrap();
11510        let js = std::str::from_utf8(&body).expect("csrf helper should be valid utf-8");
11511        assert!(js.contains("htmx:configRequest"));
11512        assert!(js.contains("X-CSRF-Token"));
11513    }
11514
11515    #[tokio::test]
11516    async fn build_router_serves_default_favicon_without_404() {
11517        let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11518
11519        let response = router
11520            .oneshot(
11521                Request::builder()
11522                    .uri(crate::router::DEFAULT_FAVICON_PATH)
11523                    .body(Body::empty())
11524                    .unwrap(),
11525            )
11526            .await
11527            .unwrap();
11528
11529        assert_eq!(response.status(), StatusCode::NO_CONTENT);
11530        assert!(
11531            response.headers().contains_key("content-security-policy"),
11532            "framework fallback responses should still receive security headers"
11533        );
11534        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11535            .await
11536            .unwrap();
11537        assert!(body.is_empty());
11538    }
11539
11540    #[tokio::test]
11541    async fn build_router_does_not_override_user_favicon_route() {
11542        let router = test_router(vec![test_get_route(
11543            crate::router::DEFAULT_FAVICON_PATH,
11544            "favicon",
11545        )]);
11546
11547        let response = router
11548            .oneshot(
11549                Request::builder()
11550                    .uri(crate::router::DEFAULT_FAVICON_PATH)
11551                    .body(Body::empty())
11552                    .unwrap(),
11553            )
11554            .await
11555            .unwrap();
11556
11557        assert_eq!(response.status(), StatusCode::OK);
11558        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11559            .await
11560            .unwrap();
11561        assert_eq!(&body[..], b"ok");
11562    }
11563
11564    #[tokio::test]
11565    async fn build_router_serves_static_files_for_unmatched_paths() {
11566        use std::collections::HashMap;
11567
11568        // Create a temp dist/ with a static page
11569        let tmp = tempfile::tempdir().expect("tempdir");
11570        let dist = tmp.path().join("dist");
11571        std::fs::create_dir_all(dist.join("docs")).expect("mkdir");
11572        std::fs::write(dist.join("docs/index.html"), "<h1>Static Docs</h1>").expect("write");
11573
11574        let manifest = crate::static_gen::StaticManifest {
11575            generated_at: "2026-03-27T00:00:00Z".to_owned(),
11576            autumn_version: "0.2.0".to_owned(),
11577            routes: HashMap::from([(
11578                "/docs".to_owned(),
11579                crate::static_gen::ManifestEntry {
11580                    file: "docs/index.html".to_owned(),
11581                    revalidate: None,
11582                },
11583            )]),
11584        };
11585        let json = serde_json::to_string(&manifest).expect("serialize");
11586        std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
11587
11588        // No dynamic route for /docs — only a static file.
11589        let config = AutumnConfig::default();
11590        let state = AppState {
11591            extensions: std::sync::Arc::new(std::sync::RwLock::new(
11592                std::collections::HashMap::new(),
11593            )),
11594            #[cfg(feature = "db")]
11595            pool: None,
11596            #[cfg(feature = "db")]
11597            replica_pool: None,
11598            #[cfg(feature = "db")]
11599            shards: None,
11600            profile: None,
11601            role: crate::config::ProcessRole::Combined,
11602            started_at: std::time::Instant::now(),
11603            health_detailed: true,
11604            probes: crate::probe::ProbeState::ready_for_test(),
11605            metrics: crate::middleware::MetricsCollector::new(),
11606            log_levels: crate::actuator::LogLevels::new("info"),
11607            task_registry: crate::actuator::TaskRegistry::new(),
11608            job_registry: crate::actuator::JobRegistry::new(),
11609            config_props: crate::actuator::ConfigProperties::default(),
11610            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
11611            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
11612            #[cfg(feature = "ws")]
11613            channels: crate::channels::Channels::new(32),
11614            #[cfg(feature = "presence")]
11615            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
11616            #[cfg(feature = "ws")]
11617            shutdown: tokio_util::sync::CancellationToken::new(),
11618            policy_registry: crate::authorization::PolicyRegistry::default(),
11619            forbidden_response: crate::authorization::ForbiddenResponse::default(),
11620            auth_session_key: "user_id".to_owned(),
11621            shared_cache: None,
11622            clock: std::sync::Arc::new(crate::time::SystemClock),
11623            app_id: AppState::next_app_id(),
11624        };
11625        let router = crate::router::build_router_with_static(
11626            vec![test_get_route("/other", "other_page")],
11627            &config,
11628            state,
11629            Some(dist.as_path()),
11630        );
11631
11632        // GET /docs/ should serve the pre-built HTML via static-first
11633        // middleware (manifest lookup with trailing-slash normalization).
11634        let response = router
11635            .oneshot(
11636                Request::builder()
11637                    .uri("/docs/")
11638                    .body(Body::empty())
11639                    .unwrap(),
11640            )
11641            .await
11642            .unwrap();
11643
11644        assert_eq!(response.status(), StatusCode::OK);
11645        let csp = response
11646            .headers()
11647            .get("content-security-policy")
11648            .expect("static-first HTML should still receive security headers")
11649            .to_str()
11650            .unwrap();
11651        assert!(csp.contains("script-src 'self'"), "csp = {csp}");
11652        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11653            .await
11654            .unwrap();
11655        assert_eq!(std::str::from_utf8(&body).unwrap(), "<h1>Static Docs</h1>");
11656    }
11657
11658    #[tokio::test]
11659    async fn build_mode_static_rendering_bypasses_startup_barrier() {
11660        temp_env::async_with_vars([("AUTUMN_BUILD_STATIC", Some("1"))], async {
11661            let config = AutumnConfig::default();
11662            let state = AppState::for_test().with_startup_complete(false);
11663            let router = crate::router::build_router(
11664                vec![Route {
11665                    method: http::Method::GET,
11666                    path: "/about",
11667                    handler: axum::routing::get(|| async { "About Page Content" }),
11668                    name: "about",
11669                    api_doc: crate::openapi::ApiDoc {
11670                        method: "GET",
11671                        path: "/about",
11672                        operation_id: "about",
11673                        success_status: 200,
11674                        ..Default::default()
11675                    },
11676                    repository: None,
11677                    idempotency: crate::route::RouteIdempotency::Direct,
11678                    timeout: crate::route::RouteTimeout::Inherit,
11679                    api_version: None,
11680                    sunset_opt_out: false,
11681                }],
11682                &config,
11683                state,
11684            );
11685            let tmp = tempfile::tempdir().unwrap();
11686            let dist = tmp.path().join("dist");
11687
11688            let result = crate::static_gen::render_static_routes(
11689                router,
11690                &[crate::static_gen::StaticRouteMeta {
11691                    path: "/about",
11692                    name: "about",
11693                    revalidate: None,
11694                    params_fn: None,
11695                }],
11696                &dist,
11697            )
11698            .await;
11699
11700            assert!(result.is_ok(), "build failed: {:?}", result.err());
11701            let html = std::fs::read_to_string(dist.join("about/index.html")).unwrap();
11702            assert_eq!(html, "About Page Content");
11703        })
11704        .await;
11705    }
11706
11707    #[tokio::test]
11708    async fn build_router_injects_live_reload_script_when_enabled() {
11709        let reload_file = tempfile::NamedTempFile::new().expect("reload state file");
11710        std::fs::write(reload_file.path(), r#"{"version":0,"kind":"full"}"#).expect("write");
11711        temp_env::async_with_vars(
11712            [
11713                ("AUTUMN_DEV_RELOAD", Some("1")),
11714                (
11715                    "AUTUMN_DEV_RELOAD_STATE",
11716                    Some(reload_file.path().to_str().expect("utf-8 path")),
11717                ),
11718            ],
11719            async {
11720                let router = test_router(vec![Route {
11721                    method: http::Method::GET,
11722                    path: "/page",
11723                    handler: axum::routing::get(|| async {
11724                        axum::response::Html("<html><body><main>ok</main></body></html>")
11725                    }),
11726                    name: "page",
11727                    api_doc: crate::openapi::ApiDoc {
11728                        method: "GET",
11729                        path: "/page",
11730                        operation_id: "page",
11731                        success_status: 200,
11732                        ..Default::default()
11733                    },
11734                    repository: None,
11735                    idempotency: crate::route::RouteIdempotency::Direct,
11736                    timeout: crate::route::RouteTimeout::Inherit,
11737                    api_version: None,
11738                    sunset_opt_out: false,
11739                }]);
11740
11741                let response = router
11742                    .oneshot(Request::builder().uri("/page").body(Body::empty()).unwrap())
11743                    .await
11744                    .unwrap();
11745
11746                let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11747                    .await
11748                    .unwrap();
11749                let html = std::str::from_utf8(&body).expect("utf-8");
11750                assert!(html.contains("/__autumn/live-reload"));
11751            },
11752        )
11753        .await;
11754    }
11755
11756    #[tokio::test]
11757    async fn build_router_mounts_dev_reload_script_endpoint_when_enabled() {
11758        // The injected <script src="/__autumn/live-reload.js"> tag only works
11759        // under the default CSP (`script-src 'self'`) if the framework
11760        // actually serves the JS at that path. This guards against the
11761        // regression where the script endpoint is forgotten.
11762        let reload_file = tempfile::NamedTempFile::new().expect("reload state file");
11763        std::fs::write(reload_file.path(), r#"{"version":0,"kind":"full"}"#).expect("write");
11764        temp_env::async_with_vars(
11765            [
11766                ("AUTUMN_DEV_RELOAD", Some("1")),
11767                (
11768                    "AUTUMN_DEV_RELOAD_STATE",
11769                    Some(reload_file.path().to_str().expect("utf-8 path")),
11770                ),
11771            ],
11772            async {
11773                let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11774
11775                let response = router
11776                    .oneshot(
11777                        Request::builder()
11778                            .uri("/__autumn/live-reload.js")
11779                            .body(Body::empty())
11780                            .unwrap(),
11781                    )
11782                    .await
11783                    .unwrap();
11784
11785                assert_eq!(response.status(), StatusCode::OK);
11786                assert_eq!(
11787                    response
11788                        .headers()
11789                        .get("content-type")
11790                        .and_then(|v| v.to_str().ok()),
11791                    Some("application/javascript; charset=utf-8")
11792                );
11793                let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11794                    .await
11795                    .unwrap();
11796                let js = std::str::from_utf8(&body).expect("utf-8");
11797                assert!(js.contains("fetch("), "js body: {js}");
11798            },
11799        )
11800        .await;
11801    }
11802
11803    #[tokio::test]
11804    async fn build_router_mounts_dev_reload_endpoint_when_enabled() {
11805        let reload_file = tempfile::NamedTempFile::new().expect("reload state file");
11806        std::fs::write(reload_file.path(), r#"{"version":7,"kind":"css"}"#).expect("write");
11807        temp_env::async_with_vars(
11808            [
11809                ("AUTUMN_DEV_RELOAD", Some("1")),
11810                (
11811                    "AUTUMN_DEV_RELOAD_STATE",
11812                    Some(reload_file.path().to_str().expect("utf-8 path")),
11813                ),
11814            ],
11815            async {
11816                let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11817
11818                let response = router
11819                    .oneshot(
11820                        Request::builder()
11821                            .uri("/__autumn/live-reload")
11822                            .body(Body::empty())
11823                            .unwrap(),
11824                    )
11825                    .await
11826                    .unwrap();
11827
11828                assert_eq!(response.status(), StatusCode::OK);
11829                assert_eq!(
11830                    response.headers().get("cache-control").unwrap(),
11831                    "no-store, no-cache, must-revalidate"
11832                );
11833                let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11834                    .await
11835                    .unwrap();
11836                assert_eq!(&body[..], br#"{"version":7,"kind":"css"}"#);
11837            },
11838        )
11839        .await;
11840    }
11841
11842    #[tokio::test]
11843    async fn build_router_disables_cache_for_static_assets_in_dev_reload_mode() {
11844        let project = tempfile::tempdir().expect("project dir");
11845        let static_dir = project.path().join("static");
11846        std::fs::create_dir_all(&static_dir).expect("mkdir");
11847        std::fs::write(static_dir.join("demo.txt"), "hello").expect("write static file");
11848        let reload_file = tempfile::NamedTempFile::new().expect("reload state file");
11849        std::fs::write(reload_file.path(), r#"{"version":0,"kind":"full"}"#).expect("write");
11850        temp_env::async_with_vars(
11851            [
11852                (
11853                    "AUTUMN_MANIFEST_DIR",
11854                    Some(project.path().to_str().expect("utf-8 path")),
11855                ),
11856                ("AUTUMN_DEV_RELOAD", Some("1")),
11857                (
11858                    "AUTUMN_DEV_RELOAD_STATE",
11859                    Some(reload_file.path().to_str().expect("utf-8 path")),
11860                ),
11861            ],
11862            async {
11863                let router = test_router(vec![test_get_route("/dummy", "dummy")]);
11864
11865                let response = router
11866                    .oneshot(
11867                        Request::builder()
11868                            .uri("/static/demo.txt")
11869                            .body(Body::empty())
11870                            .unwrap(),
11871                    )
11872                    .await
11873                    .unwrap();
11874
11875                assert_eq!(response.status(), StatusCode::OK);
11876                assert_eq!(
11877                    response.headers().get("cache-control").unwrap(),
11878                    "no-store, no-cache, must-revalidate"
11879                );
11880            },
11881        )
11882        .await;
11883    }
11884
11885    #[test]
11886    fn app_builder_accepts_static_routes() {
11887        use crate::static_gen::StaticRouteMeta;
11888        let metas = vec![StaticRouteMeta {
11889            path: "/about",
11890            name: "about",
11891            revalidate: None,
11892            params_fn: None,
11893        }];
11894        let builder = app().static_routes(metas);
11895        assert_eq!(builder.static_metas.len(), 1);
11896    }
11897
11898    #[test]
11899    fn project_dir_defaults_to_subdir() {
11900        // When AUTUMN_MANIFEST_DIR is not set, project_dir returns the
11901        // subdir name as-is (relative to cwd).
11902        let env = crate::config::MockEnv::new();
11903        let dir = super::project_dir("dist", &env);
11904        assert_eq!(dir, std::path::PathBuf::from("dist"));
11905    }
11906
11907    /// Helper to build a test router with custom config.
11908    pub fn test_router_with_config(routes: Vec<Route>, config: &AutumnConfig) -> axum::Router {
11909        let state = AppState {
11910            extensions: std::sync::Arc::new(std::sync::RwLock::new(
11911                std::collections::HashMap::new(),
11912            )),
11913            #[cfg(feature = "db")]
11914            pool: None,
11915            #[cfg(feature = "db")]
11916            replica_pool: None,
11917            #[cfg(feature = "db")]
11918            shards: None,
11919            profile: None,
11920            role: crate::config::ProcessRole::Combined,
11921            started_at: std::time::Instant::now(),
11922            health_detailed: true,
11923            probes: crate::probe::ProbeState::ready_for_test(),
11924            metrics: crate::middleware::MetricsCollector::new(),
11925            log_levels: crate::actuator::LogLevels::new("info"),
11926            task_registry: crate::actuator::TaskRegistry::new(),
11927            job_registry: crate::actuator::JobRegistry::new(),
11928            config_props: crate::actuator::ConfigProperties::default(),
11929            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
11930            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
11931            #[cfg(feature = "ws")]
11932            channels: crate::channels::Channels::new(32),
11933            #[cfg(feature = "presence")]
11934            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
11935            #[cfg(feature = "ws")]
11936            shutdown: tokio_util::sync::CancellationToken::new(),
11937            policy_registry: crate::authorization::PolicyRegistry::default(),
11938            forbidden_response: crate::authorization::ForbiddenResponse::default(),
11939            auth_session_key: "user_id".to_owned(),
11940            shared_cache: None,
11941            clock: std::sync::Arc::new(crate::time::SystemClock),
11942            app_id: AppState::next_app_id(),
11943        };
11944        crate::router::build_router(routes, config, state)
11945    }
11946
11947    #[tokio::test]
11948    async fn cors_wildcard_allows_any_origin() {
11949        let mut config = AutumnConfig::default();
11950        config.cors.allowed_origins = vec!["*".to_owned()];
11951        let router = test_router_with_config(vec![test_get_route("/test", "test")], &config);
11952
11953        let response = router
11954            .oneshot(
11955                Request::builder()
11956                    .uri("/test")
11957                    .header("Origin", "https://example.com")
11958                    .body(Body::empty())
11959                    .unwrap(),
11960            )
11961            .await
11962            .unwrap();
11963
11964        assert_eq!(response.status(), StatusCode::OK);
11965        assert_eq!(
11966            response
11967                .headers()
11968                .get("access-control-allow-origin")
11969                .unwrap(),
11970            "*"
11971        );
11972    }
11973
11974    #[tokio::test]
11975    async fn cors_specific_origin_reflected() {
11976        let mut config = AutumnConfig::default();
11977        config.cors.allowed_origins = vec!["https://example.com".to_owned()];
11978        let router = test_router_with_config(vec![test_get_route("/test", "test")], &config);
11979
11980        let response = router
11981            .oneshot(
11982                Request::builder()
11983                    .uri("/test")
11984                    .header("Origin", "https://example.com")
11985                    .body(Body::empty())
11986                    .unwrap(),
11987            )
11988            .await
11989            .unwrap();
11990
11991        assert_eq!(response.status(), StatusCode::OK);
11992        assert_eq!(
11993            response
11994                .headers()
11995                .get("access-control-allow-origin")
11996                .unwrap(),
11997            "https://example.com"
11998        );
11999    }
12000
12001    #[tokio::test]
12002    async fn cors_disabled_when_no_origins() {
12003        let config = AutumnConfig::default();
12004        assert!(config.cors.allowed_origins.is_empty());
12005        let router = test_router_with_config(vec![test_get_route("/test", "test")], &config);
12006
12007        let response = router
12008            .oneshot(
12009                Request::builder()
12010                    .uri("/test")
12011                    .header("Origin", "https://example.com")
12012                    .body(Body::empty())
12013                    .unwrap(),
12014            )
12015            .await
12016            .unwrap();
12017
12018        assert_eq!(response.status(), StatusCode::OK);
12019        assert!(
12020            response
12021                .headers()
12022                .get("access-control-allow-origin")
12023                .is_none()
12024        );
12025    }
12026
12027    #[tokio::test]
12028    async fn cors_preflight_returns_204() {
12029        let mut config = AutumnConfig::default();
12030        config.cors.allowed_origins = vec!["https://example.com".to_owned()];
12031        let router = test_router_with_config(vec![test_get_route("/test", "test")], &config);
12032
12033        let response = router
12034            .oneshot(
12035                Request::builder()
12036                    .method("OPTIONS")
12037                    .uri("/test")
12038                    .header("Origin", "https://example.com")
12039                    .header("Access-Control-Request-Method", "GET")
12040                    .body(Body::empty())
12041                    .unwrap(),
12042            )
12043            .await
12044            .unwrap();
12045
12046        assert_eq!(response.status(), StatusCode::OK);
12047        assert!(
12048            response
12049                .headers()
12050                .contains_key("access-control-allow-methods")
12051        );
12052    }
12053
12054    #[tokio::test]
12055    async fn build_router_with_static_skips_without_manifest() {
12056        // When dist/ exists but has no manifest.json, fall back to
12057        // the app router without the static layer.
12058        let tmp = tempfile::tempdir().expect("tempdir");
12059        let dist = tmp.path().join("dist");
12060        std::fs::create_dir_all(&dist).expect("mkdir");
12061        // No manifest.json — just an empty dist/
12062
12063        let config = AutumnConfig::default();
12064        let state = AppState {
12065            extensions: std::sync::Arc::new(std::sync::RwLock::new(
12066                std::collections::HashMap::new(),
12067            )),
12068            #[cfg(feature = "db")]
12069            pool: None,
12070            #[cfg(feature = "db")]
12071            replica_pool: None,
12072            #[cfg(feature = "db")]
12073            shards: None,
12074            profile: None,
12075            role: crate::config::ProcessRole::Combined,
12076            started_at: std::time::Instant::now(),
12077            health_detailed: true,
12078            probes: crate::probe::ProbeState::ready_for_test(),
12079            metrics: crate::middleware::MetricsCollector::new(),
12080            log_levels: crate::actuator::LogLevels::new("info"),
12081            task_registry: crate::actuator::TaskRegistry::new(),
12082            job_registry: crate::actuator::JobRegistry::new(),
12083            config_props: crate::actuator::ConfigProperties::default(),
12084            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
12085            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
12086            #[cfg(feature = "ws")]
12087            channels: crate::channels::Channels::new(32),
12088            #[cfg(feature = "presence")]
12089            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
12090            #[cfg(feature = "ws")]
12091            shutdown: tokio_util::sync::CancellationToken::new(),
12092            policy_registry: crate::authorization::PolicyRegistry::default(),
12093            forbidden_response: crate::authorization::ForbiddenResponse::default(),
12094            auth_session_key: "user_id".to_owned(),
12095            shared_cache: None,
12096            clock: std::sync::Arc::new(crate::time::SystemClock),
12097            app_id: AppState::next_app_id(),
12098        };
12099        let router = crate::router::build_router_with_static(
12100            vec![test_get_route("/test", "test")],
12101            &config,
12102            state,
12103            Some(dist.as_path()),
12104        );
12105
12106        let response = router
12107            .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
12108            .await
12109            .unwrap();
12110        assert_eq!(response.status(), StatusCode::OK);
12111    }
12112
12113    #[tokio::test]
12114    async fn build_router_with_static_none_dist() {
12115        // When dist_dir is None, return the app router directly.
12116        let config = AutumnConfig::default();
12117        let state = AppState {
12118            extensions: std::sync::Arc::new(std::sync::RwLock::new(
12119                std::collections::HashMap::new(),
12120            )),
12121            #[cfg(feature = "db")]
12122            pool: None,
12123            #[cfg(feature = "db")]
12124            replica_pool: None,
12125            #[cfg(feature = "db")]
12126            shards: None,
12127            profile: None,
12128            role: crate::config::ProcessRole::Combined,
12129            started_at: std::time::Instant::now(),
12130            health_detailed: true,
12131            probes: crate::probe::ProbeState::ready_for_test(),
12132            metrics: crate::middleware::MetricsCollector::new(),
12133            log_levels: crate::actuator::LogLevels::new("info"),
12134            task_registry: crate::actuator::TaskRegistry::new(),
12135            job_registry: crate::actuator::JobRegistry::new(),
12136            config_props: crate::actuator::ConfigProperties::default(),
12137            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
12138            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
12139            #[cfg(feature = "ws")]
12140            channels: crate::channels::Channels::new(32),
12141            #[cfg(feature = "presence")]
12142            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
12143            #[cfg(feature = "ws")]
12144            shutdown: tokio_util::sync::CancellationToken::new(),
12145            policy_registry: crate::authorization::PolicyRegistry::default(),
12146            forbidden_response: crate::authorization::ForbiddenResponse::default(),
12147            auth_session_key: "user_id".to_owned(),
12148            shared_cache: None,
12149            clock: std::sync::Arc::new(crate::time::SystemClock),
12150            app_id: AppState::next_app_id(),
12151        };
12152        let router = crate::router::build_router_with_static(
12153            vec![test_get_route("/test", "test")],
12154            &config,
12155            state,
12156            None,
12157        );
12158
12159        let response = router
12160            .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap())
12161            .await
12162            .unwrap();
12163        assert_eq!(response.status(), StatusCode::OK);
12164    }
12165
12166    // ── Startup transparency helper tests ─────────────────────────
12167
12168    #[test]
12169    fn format_route_lines_lists_user_routes() {
12170        let routes = vec![
12171            test_get_route("/", "index"),
12172            test_get_route("/users/{id}", "get_user"),
12173        ];
12174        let config = AutumnConfig::default();
12175        let output = format_route_lines(&routes, &[], &config);
12176        assert!(output.contains("-> index"));
12177        assert!(output.contains("/ GET"));
12178        assert!(output.contains("/users/{id}"));
12179        assert!(output.contains("-> get_user"));
12180    }
12181
12182    #[test]
12183    fn config_runtime_drift_format_route_lines_uses_actuator_prefix() {
12184        let mut config = AutumnConfig::default();
12185        config.actuator.prefix = "/ops".to_owned();
12186        let output = format_route_lines(&[], &[], &config);
12187        assert!(output.contains("-> health"));
12188        assert!(output.contains("/ops/*"));
12189    }
12190
12191    #[test]
12192    fn format_task_lines_none_when_empty() {
12193        assert!(format_task_lines(&[]).is_none());
12194    }
12195
12196    #[test]
12197    fn format_task_lines_fixed_delay() {
12198        let tasks = vec![crate::task::TaskInfo {
12199            name: "cleanup".into(),
12200            schedule: crate::task::Schedule::FixedDelay(std::time::Duration::from_secs(300)),
12201            coordination: crate::task::TaskCoordination::Fleet,
12202            handler: |_| Box::pin(async { Ok(()) }),
12203        }];
12204        let output = format_task_lines(&tasks).unwrap();
12205        assert!(output.contains("cleanup (every 300s)"));
12206    }
12207
12208    #[test]
12209    fn format_task_lines_cron() {
12210        let tasks = vec![crate::task::TaskInfo {
12211            name: "nightly".into(),
12212            schedule: crate::task::Schedule::Cron {
12213                expression: "0 0 * * *".into(),
12214                timezone: None,
12215            },
12216            coordination: crate::task::TaskCoordination::Fleet,
12217            handler: |_| Box::pin(async { Ok(()) }),
12218        }];
12219        let output = format_task_lines(&tasks).unwrap();
12220        assert!(output.contains("nightly (cron 0 0 * * *)"));
12221    }
12222
12223    #[test]
12224    fn format_middleware_list_default() {
12225        let config = AutumnConfig::default();
12226        let output = format_middleware_list(&config);
12227        assert!(output.contains("RequestId"));
12228        assert!(output.contains("SecurityHeaders"));
12229        assert!(output.contains("Session (in-memory)"));
12230        assert!(output.contains("Metrics"));
12231        // CORS and CSRF should not be present with defaults
12232        assert!(!output.contains("CORS"));
12233        assert!(!output.contains("CSRF"));
12234    }
12235
12236    #[test]
12237    fn format_middleware_list_with_cors_and_csrf() {
12238        let config = AutumnConfig {
12239            cors: crate::config::CorsConfig {
12240                allowed_origins: vec!["https://example.com".into()],
12241                ..crate::config::CorsConfig::default()
12242            },
12243            security: crate::security::config::SecurityConfig {
12244                csrf: crate::security::config::CsrfConfig {
12245                    enabled: true,
12246                    ..crate::security::config::CsrfConfig::default()
12247                },
12248                ..crate::security::config::SecurityConfig::default()
12249            },
12250            ..AutumnConfig::default()
12251        };
12252        let output = format_middleware_list(&config);
12253        assert!(output.contains("CORS"));
12254        assert!(output.contains("CSRF"));
12255    }
12256
12257    #[test]
12258    fn mask_database_url_with_password() {
12259        let masked = mask_database_url("postgres://user:secret@localhost:5432/mydb", 10);
12260        assert!(masked.contains("****"));
12261        assert!(!masked.contains("secret"));
12262        assert!(masked.contains("postgres://user:****@localhost:5432/mydb"));
12263        assert!(masked.contains("pool_size=10"));
12264    }
12265
12266    #[test]
12267    fn mask_database_url_without_password() {
12268        let masked = mask_database_url("postgres://localhost/mydb", 5);
12269        assert!(!masked.contains("****"));
12270        assert!(masked.contains("postgres://localhost/mydb"));
12271        assert!(masked.contains("pool_size=5"));
12272    }
12273
12274    #[test]
12275    fn mask_database_url_edge_cases() {
12276        // Special chars in password
12277        // The url crate parses `p@ssw:rd!` where `@` creates problems if unencoded,
12278        // but url crate seems to treat `user:p` as auth and `@ssw:rd!` as host if it's poorly formed,
12279        // let's stick to valid URL formats for testing.
12280
12281        // URL encoded characters
12282        let masked2 = mask_database_url("postgres://user:p%40ssw%3Ard%21@localhost:5432/mydb", 10);
12283        assert!(masked2.contains("****"));
12284        assert!(!masked2.contains("p%40ssw%3Ard%21"));
12285        assert!(masked2.contains("postgres://user:****@localhost:5432/mydb"));
12286
12287        // No user, just password
12288        let masked3 = mask_database_url("postgres://:secret@localhost:5432/mydb", 10);
12289        assert!(masked3.contains("****"));
12290        assert!(!masked3.contains("secret"));
12291        assert!(masked3.contains("postgres://:****@localhost:5432/mydb"));
12292    }
12293    #[test]
12294    fn mask_database_url_invalid_url_fallback() {
12295        let masked = mask_database_url("this is completely invalid as a URL with supersecret", 10);
12296        assert!(masked.contains("****"));
12297        assert!(!masked.contains("supersecret"));
12298        assert!(masked.contains("pool_size=10"));
12299    }
12300
12301    #[test]
12302    fn format_config_summary_defaults() {
12303        let config = AutumnConfig::default();
12304        let output = format_config_summary(&config);
12305        assert!(output.contains("profile:    none"));
12306        assert!(output.contains("server:     127.0.0.1:3000"));
12307        assert!(output.contains("database:   not configured"));
12308        assert!(output.contains("log_level:"));
12309        assert!(output.contains("telemetry:  disabled"));
12310        assert!(output.contains("health:     /health"));
12311    }
12312
12313    #[test]
12314    fn format_config_summary_with_db() {
12315        let config = AutumnConfig {
12316            database: crate::config::DatabaseConfig {
12317                url: Some("postgres://user:pass@host/db".into()),
12318                pool_size: 20,
12319                ..crate::config::DatabaseConfig::default()
12320            },
12321            ..AutumnConfig::default()
12322        };
12323        let output = format_config_summary(&config);
12324        assert!(output.contains("user:****@host/db"));
12325        assert!(output.contains("pool_size=20"));
12326        assert!(!output.contains("pass"));
12327    }
12328
12329    #[test]
12330    fn format_config_summary_with_profile() {
12331        let config = AutumnConfig {
12332            profile: Some("prod".into()),
12333            ..AutumnConfig::default()
12334        };
12335        let output = format_config_summary(&config);
12336        assert!(output.contains("profile:    prod"));
12337    }
12338
12339    #[test]
12340    fn format_config_summary_with_telemetry() {
12341        let config = AutumnConfig {
12342            telemetry: crate::config::TelemetryConfig {
12343                enabled: true,
12344                service_name: "orders-api".into(),
12345                otlp_endpoint: Some("http://otel-collector:4317".into()),
12346                ..crate::config::TelemetryConfig::default()
12347            },
12348            ..AutumnConfig::default()
12349        };
12350        let output = format_config_summary(&config);
12351        assert!(output.contains("telemetry:  Grpc -> http://otel-collector:4317"));
12352    }
12353
12354    #[test]
12355    fn log_startup_transparency_runs_without_panic() {
12356        // Exercises the tracing::info! calls inside log_startup_transparency.
12357        // No subscriber installed, so output is discarded -- we just verify
12358        // the function doesn't panic.
12359        let routes = vec![test_get_route("/", "index")];
12360        let tasks = vec![crate::task::TaskInfo {
12361            name: "cleanup".into(),
12362            schedule: crate::task::Schedule::FixedDelay(std::time::Duration::from_secs(60)),
12363            coordination: crate::task::TaskCoordination::Fleet,
12364            handler: |_| Box::pin(async { Ok(()) }),
12365        }];
12366        let config = AutumnConfig::default();
12367        log_startup_transparency(&routes, &tasks, &[], &config);
12368    }
12369
12370    #[test]
12371    fn log_startup_transparency_no_tasks() {
12372        let routes = vec![test_get_route("/health", "check")];
12373        let config = AutumnConfig::default();
12374        log_startup_transparency(&routes, &[], &[], &config);
12375    }
12376
12377    #[cfg(feature = "ws")]
12378    #[tokio::test]
12379    async fn start_task_scheduler_broadcasts_events() {
12380        let state = AppState {
12381            extensions: std::sync::Arc::new(std::sync::RwLock::new(
12382                std::collections::HashMap::new(),
12383            )),
12384            #[cfg(feature = "db")]
12385            pool: None,
12386            #[cfg(feature = "db")]
12387            replica_pool: None,
12388            #[cfg(feature = "db")]
12389            shards: None,
12390            profile: None,
12391            role: crate::config::ProcessRole::Combined,
12392            started_at: std::time::Instant::now(),
12393            health_detailed: true,
12394            probes: crate::probe::ProbeState::ready_for_test(),
12395            metrics: crate::middleware::MetricsCollector::new(),
12396            log_levels: crate::actuator::LogLevels::new("info"),
12397            task_registry: crate::actuator::TaskRegistry::new(),
12398            job_registry: crate::actuator::JobRegistry::new(),
12399            config_props: crate::actuator::ConfigProperties::default(),
12400            channels: crate::channels::Channels::new(32),
12401            #[cfg(feature = "presence")]
12402            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
12403            shutdown: tokio_util::sync::CancellationToken::new(),
12404            policy_registry: crate::authorization::PolicyRegistry::default(),
12405            forbidden_response: crate::authorization::ForbiddenResponse::default(),
12406            auth_session_key: "user_id".to_owned(),
12407            shared_cache: None,
12408            clock: std::sync::Arc::new(crate::time::SystemClock),
12409            app_id: AppState::next_app_id(),
12410            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
12411            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
12412        };
12413
12414        let mut rx = state.channels().subscribe("sys:tasks");
12415
12416        let task = crate::task::TaskInfo {
12417            name: "test_broadcaster".into(),
12418            // 1ms delay so it fires immediately
12419            schedule: crate::task::Schedule::FixedDelay(std::time::Duration::from_millis(1)),
12420            coordination: crate::task::TaskCoordination::Fleet,
12421            handler: |_| Box::pin(async { Ok(()) }),
12422        };
12423
12424        // Start scheduler in background so we don't block
12425        let state_clone = state.clone();
12426        tokio::spawn(async move {
12427            super::start_task_scheduler(
12428                vec![task],
12429                &state_clone,
12430                &tokio_util::sync::CancellationToken::new(),
12431            );
12432        });
12433
12434        // First message should be "started"
12435        let msg1 = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
12436            .await
12437            .expect("timeout waiting for start event")
12438            .expect("channel closed");
12439        let json1: serde_json::Value = serde_json::from_str(msg1.as_str()).unwrap();
12440        assert_eq!(json1["event"], "started");
12441        assert_eq!(json1["task"], "test_broadcaster");
12442
12443        // Second message should be "success"
12444        let msg2 = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
12445            .await
12446            .expect("timeout waiting for success event")
12447            .expect("channel closed");
12448        let json2: serde_json::Value = serde_json::from_str(msg2.as_str()).unwrap();
12449        assert_eq!(json2["event"], "success");
12450        assert_eq!(json2["task"], "test_broadcaster");
12451        assert!(json2.get("duration_ms").is_some());
12452    }
12453
12454    #[cfg(feature = "ws")]
12455    #[tokio::test]
12456    async fn start_task_scheduler_broadcasts_failure_events() {
12457        let state = AppState {
12458            extensions: std::sync::Arc::new(std::sync::RwLock::new(
12459                std::collections::HashMap::new(),
12460            )),
12461            #[cfg(feature = "db")]
12462            pool: None,
12463            #[cfg(feature = "db")]
12464            replica_pool: None,
12465            #[cfg(feature = "db")]
12466            shards: None,
12467            profile: None,
12468            role: crate::config::ProcessRole::Combined,
12469            started_at: std::time::Instant::now(),
12470            health_detailed: true,
12471            probes: crate::probe::ProbeState::ready_for_test(),
12472            metrics: crate::middleware::MetricsCollector::new(),
12473            log_levels: crate::actuator::LogLevels::new("info"),
12474            task_registry: crate::actuator::TaskRegistry::new(),
12475            job_registry: crate::actuator::JobRegistry::new(),
12476            config_props: crate::actuator::ConfigProperties::default(),
12477            channels: crate::channels::Channels::new(32),
12478            #[cfg(feature = "presence")]
12479            presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
12480            shutdown: tokio_util::sync::CancellationToken::new(),
12481            policy_registry: crate::authorization::PolicyRegistry::default(),
12482            forbidden_response: crate::authorization::ForbiddenResponse::default(),
12483            auth_session_key: "user_id".to_owned(),
12484            shared_cache: None,
12485            clock: std::sync::Arc::new(crate::time::SystemClock),
12486            app_id: AppState::next_app_id(),
12487            metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
12488            health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
12489        };
12490
12491        let mut rx = state.channels().subscribe("sys:tasks");
12492
12493        let task = crate::task::TaskInfo {
12494            name: "test_failing_task".into(),
12495            schedule: crate::task::Schedule::FixedDelay(std::time::Duration::from_millis(1)),
12496            coordination: crate::task::TaskCoordination::Fleet,
12497            handler: |_| {
12498                Box::pin(async { Err(crate::AutumnError::bad_request_msg("forced error")) })
12499            },
12500        };
12501
12502        let state_clone = state.clone();
12503        tokio::spawn(async move {
12504            super::start_task_scheduler(
12505                vec![task],
12506                &state_clone,
12507                &tokio_util::sync::CancellationToken::new(),
12508            );
12509        });
12510
12511        // First message: started
12512        let _ = rx.recv().await.unwrap();
12513
12514        // Second message: failure
12515        let msg2 = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
12516            .await
12517            .expect("timeout waiting for failure event")
12518            .expect("channel closed");
12519        let json2: serde_json::Value = serde_json::from_str(msg2.as_str()).unwrap();
12520        assert_eq!(json2["event"], "failure");
12521        assert_eq!(json2["task"], "test_failing_task");
12522        assert_eq!(json2["error"], "forced error");
12523    }
12524
12525    #[tokio::test]
12526    async fn execute_task_result_ok_returns_duration() {
12527        let state = AppState::for_test();
12528        let handler: crate::task::TaskHandler = |_| Box::pin(async { Ok(()) });
12529        let start = std::time::Instant::now();
12530        let result =
12531            super::execute_task_result(&state, handler, start, "test_task", "fixed_delay").await;
12532        assert!(result.is_ok(), "expected Ok from successful handler");
12533        // duration_ms should be a reasonable value (not MAX)
12534        assert!(result.unwrap() < u64::MAX);
12535    }
12536
12537    #[tokio::test]
12538    async fn execute_task_result_err_returns_duration_and_message() {
12539        let state = AppState::for_test();
12540        let handler: crate::task::TaskHandler =
12541            |_| Box::pin(async { Err(crate::AutumnError::bad_request_msg("test error")) });
12542        let start = std::time::Instant::now();
12543        let result =
12544            super::execute_task_result(&state, handler, start, "test_task", "fixed_delay").await;
12545        assert!(result.is_err(), "expected Err from failing handler");
12546        let (duration_ms, msg) = result.unwrap_err();
12547        assert!(duration_ms < u64::MAX);
12548        assert!(msg.contains("test error"));
12549    }
12550
12551    fn instantly_panicking_scheduled_handler(
12552        _state: AppState,
12553    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send>> {
12554        panic!("panic before scheduled future")
12555    }
12556
12557    #[tokio::test]
12558    async fn execute_task_result_reports_immediate_handler_panics() {
12559        let state = AppState::for_test();
12560        let start = std::time::Instant::now();
12561        let result = super::execute_task_result(
12562            &state,
12563            instantly_panicking_scheduled_handler,
12564            start,
12565            "test_task",
12566            "fixed_delay",
12567        )
12568        .await;
12569
12570        let (duration_ms, msg) = result.expect_err("expected Err from panicking handler");
12571        assert!(duration_ms < u64::MAX);
12572        assert!(msg.contains("scheduled task handler panicked: panic before scheduled future"));
12573    }
12574
12575    #[tokio::test]
12576    async fn execute_fixed_delay_task_does_not_timeout_in_process_runs() {
12577        let state = AppState::for_test();
12578        state.task_registry.register_scheduled(
12579            "slow_task",
12580            "every 1s",
12581            crate::task::TaskCoordination::Fleet,
12582            "in_process",
12583            "replica-a",
12584        );
12585        let handler: crate::task::TaskHandler = |_| {
12586            Box::pin(async {
12587                tokio::time::sleep(std::time::Duration::from_millis(30)).await;
12588                Ok(())
12589            })
12590        };
12591        let coordinator = std::sync::Arc::new(
12592            crate::scheduler::InProcessSchedulerCoordinator::new("replica-a"),
12593        );
12594
12595        super::execute_fixed_delay_task(
12596            "slow_task".to_owned(),
12597            state.clone(),
12598            handler,
12599            std::time::Duration::from_secs(1),
12600            crate::task::TaskCoordination::Fleet,
12601            coordinator,
12602            std::time::Duration::from_millis(10),
12603        )
12604        .await;
12605
12606        let snapshot = state.task_registry.snapshot();
12607        let status = &snapshot["slow_task"];
12608        assert_eq!(status.status, "idle");
12609        assert_eq!(status.last_result.as_deref(), Some("ok"));
12610        assert_eq!(status.total_runs, 1);
12611        assert_eq!(status.total_failures, 0);
12612        assert!(status.last_error.is_none());
12613    }
12614
12615    static SKIPPED_LEASE_HANDLER_CALLS: AtomicUsize = AtomicUsize::new(0);
12616
12617    struct DenyingSchedulerCoordinator;
12618
12619    impl crate::scheduler::SchedulerCoordinator for DenyingSchedulerCoordinator {
12620        fn backend(&self) -> &'static str {
12621            "postgres"
12622        }
12623
12624        fn replica_id(&self) -> &'static str {
12625            "replica-a"
12626        }
12627
12628        fn try_acquire<'a>(
12629            &'a self,
12630            _task_name: &'a str,
12631            _tick_key: &'a str,
12632            _coordination: crate::task::TaskCoordination,
12633        ) -> crate::scheduler::SchedulerFuture<
12634            'a,
12635            crate::AutumnResult<Option<crate::scheduler::SchedulerLease>>,
12636        > {
12637            Box::pin(async { Ok(None) })
12638        }
12639    }
12640
12641    struct GrantingSchedulerCoordinator {
12642        backend: &'static str,
12643        tick_keys: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
12644        release_count: Option<std::sync::Arc<AtomicUsize>>,
12645    }
12646
12647    impl crate::scheduler::SchedulerCoordinator for GrantingSchedulerCoordinator {
12648        fn backend(&self) -> &'static str {
12649            self.backend
12650        }
12651
12652        fn replica_id(&self) -> &'static str {
12653            "replica-a"
12654        }
12655
12656        fn try_acquire<'a>(
12657            &'a self,
12658            _task_name: &'a str,
12659            tick_key: &'a str,
12660            _coordination: crate::task::TaskCoordination,
12661        ) -> crate::scheduler::SchedulerFuture<
12662            'a,
12663            crate::AutumnResult<Option<crate::scheduler::SchedulerLease>>,
12664        > {
12665            Box::pin(async move {
12666                self.tick_keys.lock().unwrap().push(tick_key.to_owned());
12667                let lease = self.release_count.as_ref().map_or_else(
12668                    || crate::scheduler::SchedulerLease::local(self.backend, "replica-a"),
12669                    |release_count| {
12670                        crate::scheduler::SchedulerLease::tracked(
12671                            self.backend,
12672                            "replica-a",
12673                            std::sync::Arc::clone(release_count),
12674                        )
12675                    },
12676                );
12677                Ok(Some(lease))
12678            })
12679        }
12680    }
12681
12682    fn counted_scheduled_handler(
12683        _state: AppState,
12684    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send>> {
12685        Box::pin(async {
12686            SKIPPED_LEASE_HANDLER_CALLS.fetch_add(1, Ordering::SeqCst);
12687            Ok(())
12688        })
12689    }
12690
12691    #[tokio::test]
12692    async fn execute_fixed_delay_task_skips_handler_when_lease_is_not_acquired() {
12693        SKIPPED_LEASE_HANDLER_CALLS.store(0, Ordering::SeqCst);
12694        let state = AppState::for_test();
12695        state.task_registry.register_scheduled(
12696            "claimed_elsewhere",
12697            "every 1s",
12698            crate::task::TaskCoordination::Fleet,
12699            "postgres",
12700            "replica-a",
12701        );
12702        let coordinator = std::sync::Arc::new(DenyingSchedulerCoordinator);
12703
12704        super::execute_fixed_delay_task(
12705            "claimed_elsewhere".to_owned(),
12706            state.clone(),
12707            counted_scheduled_handler,
12708            std::time::Duration::from_secs(1),
12709            crate::task::TaskCoordination::Fleet,
12710            coordinator,
12711            std::time::Duration::from_secs(1),
12712        )
12713        .await;
12714
12715        let snapshot = state.task_registry.snapshot();
12716        let status = &snapshot["claimed_elsewhere"];
12717        assert_eq!(SKIPPED_LEASE_HANDLER_CALLS.load(Ordering::SeqCst), 0);
12718        assert_eq!(status.total_runs, 0);
12719        assert!(status.current_leader.is_none());
12720        assert!(status.last_tick.is_none());
12721    }
12722
12723    #[tokio::test]
12724    async fn execute_fixed_delay_task_records_distributed_lease_ttl_timeout() {
12725        let state = AppState::for_test();
12726        state.task_registry.register_scheduled(
12727            "slow_distributed_task",
12728            "every 1s",
12729            crate::task::TaskCoordination::Fleet,
12730            "postgres",
12731            "replica-a",
12732        );
12733        let handler: crate::task::TaskHandler = |_| {
12734            Box::pin(async {
12735                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
12736                Ok(())
12737            })
12738        };
12739        let coordinator = std::sync::Arc::new(GrantingSchedulerCoordinator {
12740            backend: "postgres",
12741            tick_keys: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
12742            release_count: None,
12743        });
12744
12745        super::execute_fixed_delay_task(
12746            "slow_distributed_task".to_owned(),
12747            state.clone(),
12748            handler,
12749            std::time::Duration::from_secs(1),
12750            crate::task::TaskCoordination::Fleet,
12751            coordinator,
12752            std::time::Duration::from_millis(10),
12753        )
12754        .await;
12755
12756        let snapshot = state.task_registry.snapshot();
12757        let status = &snapshot["slow_distributed_task"];
12758        assert_eq!(status.status, "idle");
12759        assert_eq!(status.last_result.as_deref(), Some("failed"));
12760        assert_eq!(status.total_runs, 1);
12761        assert_eq!(status.total_failures, 1);
12762        assert!(
12763            status
12764                .last_error
12765                .as_deref()
12766                .is_some_and(|error| error.contains("lease TTL"))
12767        );
12768    }
12769
12770    #[tokio::test]
12771    async fn execute_cron_task_uses_scheduled_occurrence_for_tick_key() {
12772        let state = AppState::for_test();
12773        state.task_registry.register_scheduled(
12774            "cron_review_task",
12775            "cron */10 * * * * *",
12776            crate::task::TaskCoordination::Fleet,
12777            "postgres",
12778            "replica-a",
12779        );
12780        let tick_keys = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
12781        let coordinator = std::sync::Arc::new(GrantingSchedulerCoordinator {
12782            backend: "postgres",
12783            tick_keys: std::sync::Arc::clone(&tick_keys),
12784            release_count: None,
12785        });
12786        let handler: crate::task::TaskHandler = |_| Box::pin(async { Ok(()) });
12787        let scheduled_unix_secs = 1_700_000_000;
12788
12789        super::execute_cron_task(
12790            "cron_review_task".to_owned(),
12791            state.clone(),
12792            handler,
12793            crate::task::TaskCoordination::Fleet,
12794            coordinator,
12795            std::time::Duration::from_secs(30),
12796            scheduled_unix_secs,
12797        )
12798        .await;
12799
12800        assert_eq!(
12801            tick_keys.lock().unwrap().as_slice(),
12802            ["cron_review_task:1700000000"]
12803        );
12804    }
12805
12806    #[tokio::test]
12807    async fn execute_fixed_delay_task_releases_lease_when_handler_panics() {
12808        let state = AppState::for_test();
12809        state.task_registry.register_scheduled(
12810            "panic_task",
12811            "every 1s",
12812            crate::task::TaskCoordination::Fleet,
12813            "postgres",
12814            "replica-a",
12815        );
12816        let release_count = std::sync::Arc::new(AtomicUsize::new(0));
12817        let coordinator = std::sync::Arc::new(GrantingSchedulerCoordinator {
12818            backend: "postgres",
12819            tick_keys: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
12820            release_count: Some(std::sync::Arc::clone(&release_count)),
12821        });
12822        let handler: crate::task::TaskHandler = |_| {
12823            Box::pin(async {
12824                panic!("forced scheduled panic");
12825                #[allow(unreachable_code)]
12826                Ok(())
12827            })
12828        };
12829
12830        super::execute_fixed_delay_task(
12831            "panic_task".to_owned(),
12832            state.clone(),
12833            handler,
12834            std::time::Duration::from_secs(1),
12835            crate::task::TaskCoordination::Fleet,
12836            coordinator,
12837            std::time::Duration::from_secs(30),
12838        )
12839        .await;
12840
12841        let snapshot = state.task_registry.snapshot();
12842        let status = &snapshot["panic_task"];
12843        assert_eq!(release_count.load(Ordering::SeqCst), 1);
12844        assert_eq!(status.status, "idle");
12845        assert_eq!(status.last_result.as_deref(), Some("failed"));
12846        assert_eq!(status.total_runs, 1);
12847        assert_eq!(status.total_failures, 1);
12848        assert!(
12849            status
12850                .last_error
12851                .as_deref()
12852                .is_some_and(|error| error.contains("scheduled task handler panicked"))
12853        );
12854    }
12855
12856    #[test]
12857    fn next_cron_occurrence_skips_overdue_slots() {
12858        use chrono::TimeZone as _;
12859
12860        let cron = "0 * * * * *"
12861            .parse::<croner::Cron>()
12862            .expect("cron expression should parse");
12863        let stale_cursor = chrono_tz::UTC
12864            .with_ymd_and_hms(2026, 5, 5, 12, 0, 0)
12865            .unwrap();
12866        let now = chrono_tz::UTC
12867            .with_ymd_and_hms(2026, 5, 5, 12, 30, 5)
12868            .unwrap();
12869        let next = super::next_cron_occurrence_after(&cron, &stale_cursor, &now)
12870            .expect("next cron occurrence should resolve");
12871
12872        assert_eq!(
12873            next,
12874            chrono_tz::UTC
12875                .with_ymd_and_hms(2026, 5, 5, 12, 31, 0)
12876                .unwrap()
12877        );
12878    }
12879
12880    #[test]
12881    fn cron_occurrence_is_overdue_after_later_slot_passed() {
12882        use chrono::TimeZone as _;
12883
12884        let cron = "0 * * * * *"
12885            .parse::<croner::Cron>()
12886            .expect("cron expression should parse");
12887        let scheduled_at = chrono_tz::UTC
12888            .with_ymd_and_hms(2026, 5, 5, 12, 1, 0)
12889            .unwrap();
12890        let slightly_late = chrono_tz::UTC
12891            .with_ymd_and_hms(2026, 5, 5, 12, 1, 5)
12892            .unwrap();
12893        let after_later_slot = chrono_tz::UTC
12894            .with_ymd_and_hms(2026, 5, 5, 12, 30, 5)
12895            .unwrap();
12896
12897        assert!(
12898            !super::cron_occurrence_is_overdue(&cron, &scheduled_at, &slightly_late)
12899                .expect("overdue check should resolve")
12900        );
12901        assert!(
12902            super::cron_occurrence_is_overdue(&cron, &scheduled_at, &after_later_slot)
12903                .expect("overdue check should resolve")
12904        );
12905    }
12906
12907    #[cfg(feature = "storage")]
12908    mod storage_preflight {
12909        use super::super::{StorageBootstrap, preflight_storage};
12910        use crate::AppState;
12911        use crate::config::AutumnConfig;
12912        use crate::storage::{BlobStoreState, StorageBackend, StorageConfig, StorageLocalConfig};
12913
12914        fn config_with_storage(storage: StorageConfig) -> AutumnConfig {
12915            AutumnConfig {
12916                profile: Some("dev".into()),
12917                storage,
12918                ..AutumnConfig::default()
12919            }
12920        }
12921
12922        #[test]
12923        fn preflight_returns_none_when_disabled() {
12924            let cfg = config_with_storage(StorageConfig {
12925                backend: StorageBackend::Disabled,
12926                ..StorageConfig::default()
12927            });
12928            assert!(preflight_storage(&cfg).is_none());
12929        }
12930
12931        #[test]
12932        fn preflight_provisions_local_backend_against_tempdir() {
12933            let dir = tempfile::tempdir().unwrap();
12934            let cfg = config_with_storage(StorageConfig {
12935                backend: StorageBackend::Local,
12936                local: StorageLocalConfig {
12937                    root: dir.path().to_path_buf(),
12938                    ..StorageLocalConfig::default()
12939                },
12940                ..StorageConfig::default()
12941            });
12942            let bootstrap = preflight_storage(&cfg).expect("local backend should provision");
12943            assert_eq!(bootstrap.store.provider_id(), "default");
12944            assert!(bootstrap.serving.is_some(), "local backend mounts a route");
12945        }
12946
12947        #[tokio::test]
12948        async fn install_registers_blob_store_on_state() {
12949            let dir = tempfile::tempdir().unwrap();
12950            let cfg = config_with_storage(StorageConfig {
12951                backend: StorageBackend::Local,
12952                local: StorageLocalConfig {
12953                    root: dir.path().to_path_buf(),
12954                    ..StorageLocalConfig::default()
12955                },
12956                ..StorageConfig::default()
12957            });
12958            let bootstrap: StorageBootstrap = preflight_storage(&cfg).unwrap();
12959
12960            let state = AppState::for_test();
12961            assert!(state.extension::<BlobStoreState>().is_none());
12962            let serving = bootstrap.install(&state);
12963            assert!(serving.is_some());
12964            assert!(state.extension::<BlobStoreState>().is_some());
12965        }
12966
12967        #[test]
12968        fn with_blob_store_stores_custom_store() {
12969            use crate::storage::{
12970                Blob, BlobFuture, BlobMeta, BlobStore, BlobStoreError, ByteStream,
12971            };
12972            use bytes::Bytes;
12973            use std::time::Duration;
12974
12975            struct FakeStore;
12976            impl BlobStore for FakeStore {
12977                fn provider_id(&self) -> &'static str {
12978                    "fake"
12979                }
12980                fn put<'a>(&'a self, _k: &'a str, _ct: &'a str, _b: Bytes) -> BlobFuture<'a, Blob> {
12981                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12982                }
12983                fn put_stream<'a>(
12984                    &'a self,
12985                    _k: &'a str,
12986                    _ct: &'a str,
12987                    _d: ByteStream<'a>,
12988                ) -> BlobFuture<'a, Blob> {
12989                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12990                }
12991                fn get<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, Bytes> {
12992                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12993                }
12994                fn delete<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, ()> {
12995                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12996                }
12997                fn head<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, Option<BlobMeta>> {
12998                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
12999                }
13000                fn presigned_url<'a>(
13001                    &'a self,
13002                    _k: &'a str,
13003                    _e: Duration,
13004                ) -> BlobFuture<'a, String> {
13005                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13006                }
13007            }
13008
13009            let builder = crate::app().with_blob_store(FakeStore);
13010            assert!(builder.blob_store.is_some());
13011        }
13012
13013        #[tokio::test]
13014        async fn with_blob_store_is_installed_on_state() {
13015            use crate::storage::{
13016                Blob, BlobFuture, BlobMeta, BlobStore, BlobStoreError, ByteStream,
13017            };
13018            use bytes::Bytes;
13019            use std::time::Duration;
13020
13021            struct FakeStore;
13022            impl BlobStore for FakeStore {
13023                fn provider_id(&self) -> &'static str {
13024                    "fake-installed"
13025                }
13026                fn put<'a>(&'a self, _k: &'a str, _ct: &'a str, _b: Bytes) -> BlobFuture<'a, Blob> {
13027                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13028                }
13029                fn put_stream<'a>(
13030                    &'a self,
13031                    _k: &'a str,
13032                    _ct: &'a str,
13033                    _d: ByteStream<'a>,
13034                ) -> BlobFuture<'a, Blob> {
13035                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13036                }
13037                fn get<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, Bytes> {
13038                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13039                }
13040                fn delete<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, ()> {
13041                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13042                }
13043                fn head<'a>(&'a self, _k: &'a str) -> BlobFuture<'a, Option<BlobMeta>> {
13044                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13045                }
13046                fn presigned_url<'a>(
13047                    &'a self,
13048                    _k: &'a str,
13049                    _e: Duration,
13050                ) -> BlobFuture<'a, String> {
13051                    Box::pin(async { Err(BlobStoreError::Unsupported("fake".into())) })
13052                }
13053            }
13054
13055            let builder = crate::app().with_blob_store(FakeStore);
13056            let bootstrap = builder.blob_store.map(|store| StorageBootstrap {
13057                store,
13058                serving: None,
13059            });
13060            let state = AppState::for_test();
13061            assert!(state.extension::<BlobStoreState>().is_none());
13062            if let Some(b) = bootstrap {
13063                b.install(&state);
13064            }
13065            let installed = state
13066                .extension::<BlobStoreState>()
13067                .expect("store should be installed");
13068            assert_eq!(installed.store().provider_id(), "fake-installed");
13069        }
13070    }
13071
13072    // ── Route source attribution ───────────────────────────────────────────
13073
13074    /// A minimal plugin that registers one route with a known name.
13075    struct TestPlugin {
13076        name: &'static str,
13077        route: Route,
13078    }
13079
13080    impl crate::plugin::Plugin for TestPlugin {
13081        fn name(&self) -> std::borrow::Cow<'static, str> {
13082            std::borrow::Cow::Borrowed(self.name)
13083        }
13084
13085        fn build(self, app: AppBuilder) -> AppBuilder {
13086            app.routes(vec![self.route])
13087        }
13088    }
13089
13090    #[test]
13091    fn routes_registered_before_plugin_are_user_sourced() {
13092        let user_route = test_get_route("/home", "home");
13093        let builder = app().routes(vec![user_route]);
13094        assert_eq!(builder.route_sources.len(), 1);
13095        assert_eq!(
13096            builder.route_sources[0],
13097            crate::route_listing::RouteSource::User
13098        );
13099    }
13100
13101    #[test]
13102    fn routes_registered_inside_plugin_are_plugin_sourced() {
13103        let plugin_route = test_get_route("/plugin-page", "plugin_page");
13104        let plugin = TestPlugin {
13105            name: "my-plugin",
13106            route: plugin_route,
13107        };
13108        let builder = app().plugin(plugin);
13109        assert_eq!(builder.route_sources.len(), 1);
13110        assert_eq!(
13111            builder.route_sources[0],
13112            crate::route_listing::RouteSource::Plugin("my-plugin".to_owned())
13113        );
13114    }
13115
13116    #[test]
13117    fn routes_registered_after_plugin_revert_to_user_sourced() {
13118        let plugin_route = test_get_route("/plugin-page", "plugin_page");
13119        let user_route = test_get_route("/home", "home");
13120        let plugin = TestPlugin {
13121            name: "my-plugin",
13122            route: plugin_route,
13123        };
13124        let builder = app().plugin(plugin).routes(vec![user_route]);
13125        assert_eq!(builder.route_sources.len(), 2);
13126        assert_eq!(
13127            builder.route_sources[0],
13128            crate::route_listing::RouteSource::Plugin("my-plugin".to_owned())
13129        );
13130        assert_eq!(
13131            builder.route_sources[1],
13132            crate::route_listing::RouteSource::User
13133        );
13134    }
13135
13136    /// A plugin that registers a route and then registers a nested plugin.
13137    struct OuterPlugin;
13138
13139    impl crate::plugin::Plugin for OuterPlugin {
13140        fn name(&self) -> std::borrow::Cow<'static, str> {
13141            "outer".into()
13142        }
13143
13144        fn build(self, app: AppBuilder) -> AppBuilder {
13145            let inner = TestPlugin {
13146                name: "inner",
13147                route: test_get_route("/inner", "inner"),
13148            };
13149            app.plugin(inner)
13150                .routes(vec![test_get_route("/outer-after", "outer_after")])
13151        }
13152    }
13153
13154    #[test]
13155    fn outer_plugin_source_restored_after_nested_plugin() {
13156        let builder = app().plugin(OuterPlugin);
13157        // Routes: [/inner from "inner", /outer-after from "outer"]
13158        assert_eq!(builder.route_sources.len(), 2);
13159        assert_eq!(
13160            builder.route_sources[0],
13161            crate::route_listing::RouteSource::Plugin("inner".to_owned()),
13162            "first route should be attributed to inner plugin"
13163        );
13164        assert_eq!(
13165            builder.route_sources[1],
13166            crate::route_listing::RouteSource::Plugin("outer".to_owned()),
13167            "second route should be re-attributed to outer plugin after nested build"
13168        );
13169    }
13170
13171    // ── shutdown hook timeout tests ───────────────────────────────────────────
13172
13173    #[tokio::test]
13174    async fn shutdown_hooks_with_timeout_runs_all_fast_hooks() {
13175        use std::sync::atomic::{AtomicUsize, Ordering};
13176        let counter = Arc::new(AtomicUsize::new(0));
13177        let c1 = Arc::clone(&counter);
13178        let c2 = Arc::clone(&counter);
13179
13180        let hooks: Vec<ShutdownHook> = vec![
13181            Box::new(move || {
13182                let c = Arc::clone(&c1);
13183                Box::pin(async move {
13184                    c.fetch_add(1, Ordering::SeqCst);
13185                })
13186            }),
13187            Box::new(move || {
13188                let c = Arc::clone(&c2);
13189                Box::pin(async move {
13190                    c.fetch_add(1, Ordering::SeqCst);
13191                })
13192            }),
13193        ];
13194
13195        run_shutdown_hooks_with_timeout(
13196            &hooks,
13197            std::time::Duration::from_secs(2),
13198            std::time::Duration::from_secs(10),
13199        )
13200        .await;
13201
13202        assert_eq!(counter.load(Ordering::SeqCst), 2, "both hooks must run");
13203    }
13204
13205    #[tokio::test]
13206    async fn shutdown_hooks_with_timeout_tolerates_slow_hook_overrun() {
13207        use std::sync::atomic::{AtomicBool, Ordering};
13208        let fast_ran = Arc::new(AtomicBool::new(false));
13209        let fr = Arc::clone(&fast_ran);
13210
13211        let hooks: Vec<ShutdownHook> = vec![
13212            // hook 0 (first registered → runs LAST in LIFO): fast
13213            Box::new(move || {
13214                let fr = Arc::clone(&fr);
13215                Box::pin(async move {
13216                    fr.store(true, Ordering::SeqCst);
13217                })
13218            }),
13219            // hook 1 (last registered → runs FIRST in LIFO): slow, exceeds per-hook budget
13220            Box::new(|| {
13221                Box::pin(async move {
13222                    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
13223                })
13224            }),
13225        ];
13226
13227        // Per-hook budget = 50 ms (hook 0 will overrun).
13228        // Total budget = 1 s (ample for hook 1 after the overrun is cut short).
13229        run_shutdown_hooks_with_timeout(
13230            &hooks,
13231            std::time::Duration::from_millis(50),
13232            std::time::Duration::from_secs(1),
13233        )
13234        .await;
13235
13236        assert!(
13237            fast_ran.load(Ordering::SeqCst),
13238            "fast hook must still run even after slow hook overruns its per-hook budget"
13239        );
13240    }
13241
13242    // Verify that build_state registers a SharedReqwestClient so that
13243    // Client::from_state can reuse the shared connection pool on every request.
13244    #[cfg(feature = "http-client")]
13245    #[test]
13246    fn build_state_registers_shared_reqwest_client() {
13247        let config = AutumnConfig::default();
13248        let state = build_state(
13249            &config,
13250            #[cfg(feature = "db")]
13251            None,
13252            #[cfg(feature = "db")]
13253            None,
13254            #[cfg(feature = "ws")]
13255            None,
13256        );
13257        assert!(
13258            state
13259                .extension::<crate::http_client::SharedReqwestClient>()
13260                .is_some(),
13261            "build_state must register a SharedReqwestClient for connection-pool sharing"
13262        );
13263    }
13264
13265    // AC5 plumbing (#1526): `with_story_gallery` stores the gallery on the
13266    // builder, and `install_story_registry` — the single install step shared
13267    // by both the run and build/SSG state-construction paths — publishes it
13268    // as the StoryRegistry extension the `/_stories` handlers read.
13269    #[cfg(feature = "maud")]
13270    #[test]
13271    fn with_story_gallery_installs_story_registry_extension() {
13272        let builder = crate::app().with_story_gallery(crate::stories::StoryGallery::builtin());
13273        let gallery = builder
13274            .story_gallery
13275            .expect("with_story_gallery must store the gallery on the builder");
13276        let expected_count = gallery.stories().len();
13277        assert!(expected_count > 0, "builtin gallery must not be empty");
13278
13279        let config = AutumnConfig::default();
13280        let state = build_state(
13281            &config,
13282            #[cfg(feature = "db")]
13283            None,
13284            #[cfg(feature = "db")]
13285            None,
13286            #[cfg(feature = "ws")]
13287            None,
13288        );
13289        install_story_registry(&state, Some(gallery));
13290        let registry = state
13291            .extension::<crate::stories::StoryRegistry>()
13292            .expect("install_story_registry must publish the StoryRegistry extension");
13293        assert_eq!(
13294            registry.stories().len(),
13295            expected_count,
13296            "every registered story must reach the state extension"
13297        );
13298
13299        // Without a registered gallery no extension is installed: the
13300        // handlers fall back to the empty default and serve the empty state.
13301        let bare_state = build_state(
13302            &config,
13303            #[cfg(feature = "db")]
13304            None,
13305            #[cfg(feature = "db")]
13306            None,
13307            #[cfg(feature = "ws")]
13308            None,
13309        );
13310        install_story_registry(&bare_state, None);
13311        assert!(
13312            bare_state
13313                .extension::<crate::stories::StoryRegistry>()
13314                .is_none(),
13315            "no gallery registered must mean no StoryRegistry extension"
13316        );
13317    }
13318}
13319
13320#[cfg(all(test, unix))]
13321mod unix_socket_tests {
13322    use super::prepare_unix_socket_path;
13323
13324    #[test]
13325    fn prepare_unix_socket_path_noop_when_absent() {
13326        let dir = tempfile::tempdir().expect("tempdir");
13327        let path = dir.path().join("missing.sock");
13328        prepare_unix_socket_path(&path).expect("absent path is fine");
13329        assert!(!path.exists());
13330    }
13331
13332    #[test]
13333    fn prepare_unix_socket_path_removes_stale_socket() {
13334        let dir = tempfile::tempdir().expect("tempdir");
13335        let path = dir.path().join("stale.sock");
13336        // Bind then drop a real socket to leave a stale socket file behind.
13337        let listener = std::os::unix::net::UnixListener::bind(&path).expect("bind socket");
13338        drop(listener);
13339        assert!(path.exists(), "socket file should exist before prepare");
13340        prepare_unix_socket_path(&path).expect("stale socket should be removed");
13341        assert!(!path.exists(), "stale socket should be unlinked");
13342    }
13343
13344    #[test]
13345    fn prepare_unix_socket_path_refuses_live_socket() {
13346        let dir = tempfile::tempdir().expect("tempdir");
13347        let path = dir.path().join("live.sock");
13348        // Keep the listener bound so a connect probe succeeds.
13349        let _listener = std::os::unix::net::UnixListener::bind(&path).expect("bind socket");
13350        let err = prepare_unix_socket_path(&path).expect_err("must refuse a live socket");
13351        assert_eq!(err.kind(), std::io::ErrorKind::AddrInUse);
13352        assert!(path.exists(), "live socket must not be removed");
13353    }
13354
13355    #[test]
13356    fn prepare_unix_socket_path_errors_on_regular_file() {
13357        let dir = tempfile::tempdir().expect("tempdir");
13358        let path = dir.path().join("not-a-socket");
13359        std::fs::write(&path, b"i am a regular file").expect("write file");
13360        let err = prepare_unix_socket_path(&path).expect_err("must refuse a non-socket file");
13361        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
13362        assert!(path.exists(), "regular file must not be removed");
13363    }
13364}