Skip to main content

umbral_core/
app.rs

1use axum::Router;
2use std::collections::HashMap;
3use std::net::SocketAddr;
4
5use crate::db::{self, DbPool};
6use crate::migrate::ModelMeta;
7use crate::orm::Model;
8use crate::plugin::Plugin;
9use crate::settings::Settings;
10
11/// A per-request resolver that builds the request-scoped
12/// [`crate::db::RouteContext`] from the incoming request. Installed via
13/// [`AppBuilder::route_context`] and driven by [`route_context_scope_layer`].
14type RouteContextResolver =
15    std::sync::Arc<dyn Fn(&crate::web::Request) -> crate::db::RouteContext + Send + Sync>;
16
17/// A built and ready-to-serve umbral application.
18///
19/// Created via `App::builder().build()`. Owns the merged router that
20/// carries every registered plugin's routes plus the user-binary
21/// routes passed to `AppBuilder::routes()`.
22pub struct App {
23    router: Router,
24    plugins: Vec<Box<dyn Plugin>>,
25    /// gaps3 #23: when true, `umbral_cli::dispatch` applies pending migrations
26    /// before starting the server (the `serve` command only) — so a fresh DB
27    /// "just works" WITHOUT running migrate during `makemigrations`/`migrate`
28    /// or any other subcommand. Opt in via [`AppBuilder::auto_migrate_on_serve`].
29    auto_migrate_on_serve: bool,
30    /// Kikosi #5 — how long [`App::serve`] keeps serving after a shutdown signal
31    /// before it stops accepting, so a load balancer observes `/readyz` flip to
32    /// 503 and drains this instance. `Duration::ZERO` (the default) skips the
33    /// drain — the historical behaviour. Set via [`AppBuilder::shutdown_drain`].
34    drain_delay: std::time::Duration,
35    /// Set the first time [`App::ready`] fires the `on_ready` hooks, so the
36    /// second call is a no-op. `serve()` and `into_router()` both call it, and
37    /// `umbral_cli::dispatch` may have called it already.
38    ready_fired: std::sync::atomic::AtomicBool,
39}
40
41impl App {
42    /// Whether the app opted into auto-migrate on `serve` (gaps3 #23). Read by
43    /// `umbral_cli`'s serve path; see [`AppBuilder::auto_migrate_on_serve`].
44    pub fn auto_migrate_on_serve_enabled(&self) -> bool {
45        self.auto_migrate_on_serve
46    }
47
48    /// Fire every plugin's [`Plugin::on_ready`] hook, in topological order.
49    /// Idempotent: the second and later calls do nothing.
50    ///
51    /// # Why this is not part of `build()`
52    ///
53    /// `on_ready` means *the application is up*. Plugins use it to seed content,
54    /// backfill rows, install RLS policies, and (in `umbral-permissions`) create
55    /// the standard permission rows for every registered model. All of that
56    /// needs a migrated schema.
57    ///
58    /// [`AppBuilder::build`] still calls this for you, so a test or an embedder
59    /// that holds an `App` sees no change. What changed is `umbral_cli::dispatch`:
60    /// it takes the *builder*, calls [`AppBuilder::build_deferred`], resolves
61    /// argv, and only then calls `ready()` — skipping it entirely for the schema
62    /// commands (`migrate`, `makemigrations`, `inspectdb`, …).
63    ///
64    /// Before that, the generated `main.rs` was
65    /// `let app = App::builder()…build()?; umbral_cli::dispatch(app)`, so the
66    /// hooks ran before `dispatch` had even parsed argv — including when argv
67    /// said `migrate`. Against a fresh database that produced a wall of
68    /// `relation "…" does not exist` before the migration engine had created a
69    /// single table (gaps3 #41, seen on the first umbralrs.dev deploy). Nothing
70    /// crashed only because those seeds log-and-swallow; a plugin that propagated
71    /// the error made `migrate` unrunnable, and one that wrote rows silently
72    /// skipped the write.
73    ///
74    /// [`App::serve`] calls this too, so a hand-rolled `main` that builds with
75    /// `build_deferred()` and serves directly still gets its hooks.
76    /// Whether [`App::ready`] has already run the `on_ready` hooks.
77    ///
78    /// `umbral_cli::dispatch` reads this to warn when a binary still builds with
79    /// `App::build()` — the hooks fired before argv was parsed, so a schema
80    /// command has already run every plugin's seed (gaps3 #41).
81    pub fn ready_already_fired(&self) -> bool {
82        self.ready_fired.load(std::sync::atomic::Ordering::SeqCst)
83    }
84
85    pub fn ready(&self) -> Result<(), BuildError> {
86        use std::sync::atomic::Ordering;
87        if self.ready_fired.swap(true, Ordering::SeqCst) {
88            return Ok(());
89        }
90        let ctx = crate::plugin::AppContext {
91            pool: crate::db::pool_dispatched().clone(),
92            settings: crate::settings::get().clone(),
93        };
94        for plugin in &self.plugins {
95            plugin
96                .on_ready(&ctx)
97                .map_err(|source| BuildError::PluginOnReady {
98                    plugin: plugin.name(),
99                    source,
100                })?;
101        }
102        Ok(())
103    }
104
105    /// Create a new [`AppBuilder`].
106    pub fn builder() -> AppBuilder {
107        // Load `.env` into the *process* environment so plain
108        // `std::env::var(...)` code sees it — most importantly a plugin's
109        // `from_env()` credential loader (e.g. the OAuth providers reading
110        // `UMBRAL_OAUTH_*`). This runs before the `.plugin(...)` arguments
111        // are evaluated, so those loaders find the values.
112        //
113        // We read `.env` the *same* CWD-relative way figment's settings
114        // loader does (`from_filename_iter(".env")`) rather than
115        // `dotenvy::dotenv()`, whose parent-directory search resolves the
116        // file differently and missed it in practice. Each key is set only
117        // when it isn't already present, so real environment vars keep
118        // precedence. No-op when there's no `.env`.
119        if let Ok(iter) = dotenvy::from_filename_iter(".env") {
120            for (key, value) in iter.flatten() {
121                if std::env::var_os(&key).is_none() {
122                    // SAFETY: runs at startup (App::builder), before the
123                    // server spawns request handlers that read the
124                    // environment — the same operation `dotenvy::dotenv()`
125                    // performs internally.
126                    unsafe { std::env::set_var(&key, &value) };
127                }
128            }
129        }
130        AppBuilder::default()
131    }
132
133    /// Bind the axum listener and serve requests.
134    ///
135    /// Fires [`App::ready`] first (idempotent, so `umbral_cli::dispatch` having
136    /// already called it is fine): a server that is about to accept requests is
137    /// by definition ready, and a plugin's `on_ready` may install the ambient
138    /// state its handlers read. A hook that fails surfaces as an
139    /// [`std::io::ErrorKind::Other`] carrying the `BuildError`'s message —
140    /// `serve` has always returned `io::Error`, and a plugin that can't start is
141    /// as fatal as a port that won't bind.
142    ///
143    /// This call blocks until the server stops. At M0 there is no graceful
144    /// shutdown hook; that lands with the signal-handling work in a later
145    /// milestone.
146    pub async fn serve(self, addr: impl Into<SocketAddr>) -> Result<(), std::io::Error> {
147        self.ready().map_err(std::io::Error::other)?;
148
149        let listener = tokio::net::TcpListener::bind(addr.into()).await?;
150
151        tracing::info!("umbral serving on {}", listener.local_addr()?);
152
153        // Serve via `into_make_service()` rather than passing the router
154        // directly. `axum::serve(listener, router)` drives the `Router` as
155        // its own connection-maker, whose per-connection `call` runs
156        // `self.clone().with_state(())` — and `with_state` finalizes EVERY
157        // route eagerly, an O(route-count) cost paid once per new TCP
158        // connection. With keep-alive that's amortized over all requests on
159        // the connection; WITHOUT keep-alive (one connection per request) it
160        // is paid on every request, capping throughput at ~1/with_state-cost
161        // regardless of the handler. For an app with hundreds of routes (a
162        // full admin + REST surface) that throttled no-keep-alive throughput
163        // by ~4x or worse. `IntoMakeService` instead hands each connection a
164        // cheap `Router::clone()` (an `Arc` bump) and lets routing finalize
165        // lazily per request — measurably faster on fresh connections and no
166        // slower with keep-alive. No `ConnectInfo` regression: the direct
167        // path didn't provide it either (that needs
168        // `into_make_service_with_connect_info`).
169        // audit_2 core-app-config #13: graceful shutdown. Without it, a deploy
170        // (SIGTERM) drops every in-flight request and never drains the pools —
171        // Postgres logs abrupt terminations, SQLite skips its WAL checkpoint.
172        // `with_graceful_shutdown` stops accepting new connections on the
173        // signal and waits for in-flight requests to finish; then we close the
174        // pools so connections shut down cleanly.
175        // Kikosi #5: when a drain delay is configured, the shutdown future flips
176        // readiness to draining and holds for the delay BEFORE resolving, so the
177        // server keeps accepting during the window the load balancer needs to
178        // notice `/readyz` = 503 and stop routing here. With ZERO delay this is
179        // the plain signal wait — the historical behaviour.
180        let drain_delay = self.drain_delay;
181        axum::serve(listener, self.router.into_make_service())
182            .with_graceful_shutdown(drain_after(shutdown_signal(), drain_delay))
183            .await?;
184        tracing::info!("umbral: server stopped accepting; draining DB pools");
185        crate::db::close().await;
186        Ok(())
187    }
188
189    /// Consume the [`App`] and return its merged axum router.
190    ///
191    /// Useful when the caller wants to drive the router themselves: an
192    /// integration test that sends synthetic requests via
193    /// `tower::ServiceExt::oneshot`, an embedding scenario that nests
194    /// umbral under another axum tree, or any other path that doesn't
195    /// want `serve()`'s opinionated listener.
196    pub fn into_router(self) -> Router {
197        self.router
198    }
199
200    /// Borrow the registered plugins in topological dependency order.
201    ///
202    /// Used by [`crate::cli::dispatch`] to walk every plugin's
203    /// `commands()` contribution at CLI dispatch time. Borrowed (not
204    /// moved) so the App stays usable after a dispatch call returns.
205    pub fn plugins(&self) -> &[Box<dyn Plugin>] {
206        &self.plugins
207    }
208}
209
210/// The fluent entry point for constructing an [`App`].
211///
212/// Collects settings, database pools, and routes, then locks everything
213/// into place at [`build`](AppBuilder::build).
214pub struct AppBuilder {
215    settings: Option<Settings>,
216    databases: HashMap<String, DbPool>,
217    router: Option<Router>,
218    /// Companion path list for `router` — surfaces the user's hand-
219    /// registered routes in the dev-mode 404 page. The builder can't
220    /// peek inside an axum `Router`, so the caller declares its paths
221    /// here. Empty by default; production deployments don't need to
222    /// fill it.
223    route_paths: Vec<crate::routes::RouteSpec>,
224    models: Vec<ModelMeta>,
225    /// gaps3 #46 — collect link-registered models at build time.
226    auto_models: bool,
227    plugins: Vec<Box<dyn Plugin>>,
228    templates_dir: Option<std::path::PathBuf>,
229    slash_redirect: crate::slash::SlashRedirect,
230    not_found_template: Option<String>,
231    server_error_template: Option<String>,
232    /// Custom template per status code for general error pages (429, 403, …),
233    /// styled like the 404/500 pages. See [`Self::error_template`].
234    error_templates: HashMap<axum::http::StatusCode, String>,
235    /// Optional hook called before the 500 template is rendered.
236    server_error_hook: Option<crate::errors::ServerErrorHook>,
237    /// When `true` (the default), the embedded default 404/500 templates
238    /// are used as fallbacks when the user hasn't supplied their own.
239    default_error_pages: bool,
240    /// gaps3 #23: apply pending migrations on `serve` (opt-in).
241    auto_migrate_on_serve: bool,
242    /// Kikosi #5: shutdown drain delay. `Duration::ZERO` = no drain.
243    drain_delay: std::time::Duration,
244    /// Path-scoped cross-origin policies (prefix → config), applied via
245    /// [`AppBuilder::cors_for`]. Each is layered only onto requests whose
246    /// path starts with the prefix (e.g. `"/api"`).
247    cors_scoped: Vec<(String, crate::cors::CorsConfig)>,
248    /// Optional cross-origin policy. `None` means no `CorsLayer`
249    /// is installed at all and browsers apply the same-origin
250    /// default. Configure via [`AppBuilder::cors`].
251    cors: Option<crate::cors::CorsConfig>,
252    /// When `Some(true)`, every ORM write terminal that supports
253    /// `.atomic()` / `.non_atomic()` runs inside a transaction by
254    /// default. Per-call `.non_atomic()` overrides. `None` keeps the
255    /// pre-flag behaviour (no auto-wrapping). See
256    /// [`AppBuilder::atomic_transactions`].
257    atomic_transactions: Option<bool>,
258    /// When `true`, a `tower-http` gzip/brotli compression layer wraps the
259    /// router. Off by default — a reverse proxy usually owns compression,
260    /// and double-compressing behind one is wasteful. Enable via
261    /// [`AppBuilder::compression`].
262    compress: bool,
263    /// Framework-wide request-body size cap (bytes). `build()` installs a
264    /// `tower-http` `RequestBodyLimitLayer` so any body over the cap is
265    /// rejected with `413` before a handler buffers it (audit_2 core-web H11).
266    /// Defaults to 32 MiB; `None` disables the global limit. Set via
267    /// [`AppBuilder::max_request_body`].
268    max_request_body_bytes: Option<usize>,
269    /// Per-request timeout. `build()` installs a `tower-http` `TimeoutLayer`
270    /// so a hung/slowloris request is aborted with `408` instead of pinning a
271    /// task forever (audit_2 core-web H11/#3). Defaults to 30s; `None`
272    /// disables. Set via [`AppBuilder::request_timeout`].
273    request_timeout: Option<std::time::Duration>,
274    /// Ship minimal hardening response headers from core (audit_2 H10):
275    /// `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
276    /// `Referrer-Policy: strict-origin-when-cross-origin` — set ONLY if not
277    /// already present, so `SecurityPlugin` (which owns the configurable values
278    /// + CSRF + HSTS) wins when mounted. Default `true`; opt out via
279    /// [`AppBuilder::default_security_headers`].
280    default_security_headers: bool,
281    /// App-level framework middleware (feature #68), prepended to the
282    /// plugins' contributions in the final stack. Added via
283    /// [`AppBuilder::middleware`].
284    middleware: Vec<std::sync::Arc<dyn crate::middleware::Middleware>>,
285    /// Optional custom [`crate::db::DatabaseRouter`]. `None` uses
286    /// `DefaultRouter` (today's static per-model routing). Installed
287    /// during `build()` via [`crate::db::router::install_router`].
288    db_router: Option<std::sync::Arc<dyn crate::db::DatabaseRouter>>,
289    /// Optional per-request resolver that builds the request-scoped
290    /// [`crate::db::RouteContext`]. When set, `build()` installs a layer that
291    /// runs the resolver on each request and scopes the ENTIRE downstream
292    /// future (handler plus every `.await`, including ORM calls) inside
293    /// [`crate::db::route_context::scope`], so the ambient
294    /// `umbral::db::route_context()` accessor — and thus the `DatabaseRouter`
295    /// — sees the context this resolver set. Added via
296    /// [`AppBuilder::route_context`].
297    route_context_resolver: Option<RouteContextResolver>,
298    /// When `true`, `build()` FAILS (not just warns) if any app-level mutating
299    /// route (POST/PUT/PATCH/DELETE) registered via `.routes(...)` carries no
300    /// recorded permission (gaps3 #28 P1 — enforces the audit_2 H19 audit).
301    /// Opt-in "gated by construction": a forgotten authorization gate becomes a
302    /// boot error instead of a silently-open endpoint. Default `false` (warn
303    /// only). Set via [`AppBuilder::deny_ungated_mutations`].
304    deny_ungated_mutations: bool,
305}
306
307impl Default for AppBuilder {
308    fn default() -> Self {
309        Self {
310            settings: None,
311            databases: HashMap::new(),
312            router: None,
313            route_paths: Vec::new(),
314            models: Vec::new(),
315            auto_models: false,
316            plugins: Vec::new(),
317            templates_dir: None,
318            slash_redirect: crate::slash::SlashRedirect::default(),
319            not_found_template: None,
320            server_error_template: None,
321            error_templates: HashMap::new(),
322            server_error_hook: None,
323            default_error_pages: true,
324            auto_migrate_on_serve: false,
325            drain_delay: std::time::Duration::ZERO,
326            cors: None,
327            cors_scoped: Vec::new(),
328            atomic_transactions: None,
329            deny_ungated_mutations: false,
330            compress: false,
331            // Safe-by-default request hardening (audit_2 core-web H11): a 32
332            // MiB body ceiling and a 30s timeout, both opt-out-able.
333            max_request_body_bytes: Some(32 * 1024 * 1024),
334            request_timeout: Some(std::time::Duration::from_secs(30)),
335            default_security_headers: true,
336            middleware: Vec::new(),
337            db_router: None,
338            route_context_resolver: None,
339        }
340    }
341}
342
343impl AppBuilder {
344    /// Set the application settings.
345    pub fn settings(mut self, settings: Settings) -> Self {
346        self.settings = Some(settings);
347        self
348    }
349
350    /// Register a database pool under the given alias.
351    ///
352    /// The `"default"` pool is the one returned by `umbral::db::pool()`
353    /// and is required: `build()` fails with `BuildError::
354    /// DefaultPoolMissing` if it isn't registered. The caller opens
355    /// the pool via `umbral::db::connect(&url).await` and passes it
356    /// here.
357    ///
358    /// Accepts anything that converts into a [`DbPool`]: a typed
359    /// [`sqlx::SqlitePool`], a typed [`sqlx::PgPool`], or an already-
360    /// built `DbPool`. The [`From`] impls on `DbPool` make plain
361    /// SqlitePool callers (every test, every plugin example) work
362    /// unchanged.
363    pub fn database(mut self, alias: &str, pool: impl Into<DbPool>) -> Self {
364        self.databases.insert(alias.to_owned(), pool.into());
365        self
366    }
367
368    /// Install a custom [`crate::db::DatabaseRouter`]. Omit to use
369    /// `DefaultRouter` (today's static per-model routing).
370    pub fn router<R: crate::db::DatabaseRouter + 'static>(mut self, router: R) -> Self {
371        self.db_router = Some(std::sync::Arc::new(router));
372        self
373    }
374
375    /// Install a per-request [`crate::db::RouteContext`] resolver.
376    ///
377    /// The resolver runs once per request, builds a `RouteContext` (typically
378    /// reading a tenant header or subdomain), and `build()` wraps the entire
379    /// downstream future in [`crate::db::route_context::scope`]. Because the
380    /// scope spans the whole handler — including every `.await` and every ORM
381    /// call — the ambient `umbral::db::route_context()` accessor inside the
382    /// handler, and the active [`crate::db::DatabaseRouter`], see exactly the
383    /// context this resolver returned. A request the resolver maps to a
384    /// default `RouteContext` runs with no tenant (no silent inheritance from
385    /// a prior request).
386    ///
387    /// ```ignore
388    /// use umbral::prelude::*;
389    /// use umbral::db::{RouteContext, TenantKey};
390    ///
391    /// App::builder()
392    ///     .route_context(|req| match req.headers().get("x-tenant") {
393    ///         Some(v) => RouteContext::new()
394    ///             .with_tenant(TenantKey::new(v.to_str().unwrap_or_default())),
395    ///         None => RouteContext::new(),
396    ///     })
397    ///     .build()?;
398    /// ```
399    pub fn route_context<F>(mut self, resolver: F) -> Self
400    where
401        F: Fn(&crate::web::Request) -> crate::db::RouteContext + Send + Sync + 'static,
402    {
403        self.route_context_resolver = Some(std::sync::Arc::new(resolver));
404        self
405    }
406
407    /// Register a model with the app's migration engine.
408    ///
409    /// Called once per model the user wants the M5 `makemigrations` /
410    /// `migrate` commands to track. Captures the model's `NAME` /
411    /// `TABLE` / `FIELDS` constants into an owned `ModelMeta` so the
412    /// migration code can iterate without naming concrete `T` at the
413    /// call site. M7's Plugin contract will replace this with
414    /// `Plugin::models()` discovered through the plugin registry.
415    /// Register every `#[derive(Model)]` type the binary links, instead of naming
416    /// each one (gaps3 #46).
417    ///
418    /// ```ignore
419    /// App::builder()
420    ///     .settings(settings)
421    ///     .database("default", pool)
422    ///     .auto_models()          // replaces .model::<Post>().model::<Tag>()....
423    ///     .plugin(RestPlugin::default())
424    ///     .build()?
425    /// ```
426    ///
427    /// Explicit `.model::<T>()` still works and composes with this — the two are
428    /// merged and de-duplicated by table, so adding it to an existing app is safe.
429    ///
430    /// # Why this is opt-in
431    ///
432    /// Discovery is link-time. A model in your **binary** crate is always linked
433    /// and always found. A model in a **library** crate that nothing else
434    /// references can be dropped by the linker, and it would then be missing from
435    /// the registry — which means missing from `makemigrations`, i.e. a table that
436    /// silently never gets created. Making this the default would trade a little
437    /// typing for a failure mode that is invisible until production.
438    ///
439    /// If your models live in a library crate, either `use` it from `main.rs` (so
440    /// the linker keeps it) or keep naming them with `.model::<T>()`.
441    pub fn auto_models(mut self) -> Self {
442        self.auto_models = true;
443        self
444    }
445
446    pub fn model<T: Model>(mut self) -> Self {
447        self.models.push(ModelMeta::for_::<T>());
448        self
449    }
450
451    /// Register a plugin (M7).
452    ///
453    /// Plugins contribute models, routes, system_checks, and an
454    /// `on_ready` hook. `App::build()` topologically sorts the
455    /// registered set by `Plugin::dependencies()` and walks every
456    /// plugin's contributions. The plugin name `"app"` is reserved
457    /// for the implicit plugin that owns models registered via
458    /// `.model::<T>()`; a plugin claiming that name causes
459    /// `BuildError::ReservedPluginName`.
460    pub fn plugin<P: Plugin>(mut self, plugin: P) -> Self {
461        self.plugins.push(Box::new(plugin));
462        self
463    }
464
465    /// Attach a [`Routes`](crate::routes::Routes) bundle of
466    /// hand-registered routes.
467    ///
468    /// Each `.get(...) / .post(...) / .put(...) / .patch(...) /
469    /// .delete(...) / .head(...) / .options(...)` call on `Routes`
470    /// records the path *and* registers the handler, so the framework
471    /// surfaces declared routes in the dev-mode 404 page without a
472    /// parallel declaration list.
473    ///
474    /// Multi-method routes go through [`Routes::route`] (explicit
475    /// method list + `axum::routing::MethodRouter`). Routes that need
476    /// axum features the per-method shorthands don't expose (typed
477    /// `State`, middleware layers, `nest`, fallback handlers, etc.)
478    /// go through [`Routes::with_router`] — that escape hatch merges
479    /// an external `axum::Router` and its paths stay opaque to the
480    /// framework (won't appear in the dev 404 page).
481    ///
482    /// Calling this more than once merges the router and concatenates
483    /// the specs.
484    ///
485    /// ```ignore
486    /// use umbral::prelude::*;
487    ///
488    /// App::builder()
489    ///     .routes(
490    ///         Routes::new()
491    ///             .get("/", home)
492    ///             .get("/articles", list_articles_html)
493    ///             .post("/api/articles", create_article),
494    ///     )
495    ///     .build()?;
496    /// ```
497    pub fn routes(mut self, routes: crate::routes::Routes) -> Self {
498        let (router, specs) = routes.into_parts();
499        self.router = Some(match self.router.take() {
500            Some(prior) => prior.merge(router),
501            None => router,
502        });
503        self.route_paths.extend(specs);
504        self
505    }
506
507    /// Set the project-level templates directory.
508    ///
509    /// Defaults to `./templates` (relative to the binary's cwd) when
510    /// the builder method isn't called. If the resolved path doesn't
511    /// exist, the engine still publishes — calls to
512    /// `umbral::templates::render` then return `TemplateError::Missing`
513    /// with a clear diagnostic, which matches the "absence isn't an
514    /// error unless something tries to render" rule from the spec.
515    ///
516    /// This directory is searched first (highest priority). Plugin
517    /// directories contributed via `Plugin::templates_dirs()` are
518    /// appended in topological order and searched afterwards. To
519    /// override a plugin's template, drop a same-named file here.
520    pub fn templates_dir<P: Into<std::path::PathBuf>>(mut self, path: P) -> Self {
521        self.templates_dir = Some(path.into());
522        self
523    }
524
525    /// Set the trailing-slash redirect policy. See
526    /// [`crate::slash::SlashRedirect`].
527    ///
528    /// Default is `Off` (axum's strict matching). Most apps want
529    /// `Append` (`/foo` 404 → 308 → `/foo/`) so that
530    /// the same URL works with or without the trailing slash.
531    ///
532    /// ```ignore
533    /// use umbral::prelude::*;
534    /// use umbral::web::SlashRedirect;
535    ///
536    /// App::builder()
537    ///     .slash_redirect(SlashRedirect::Append)
538    ///     .build()?;
539    /// ```
540    pub fn slash_redirect(mut self, policy: crate::slash::SlashRedirect) -> Self {
541        self.slash_redirect = policy;
542        self
543    }
544
545    /// Set the template rendered on a 404. Follows the
546    /// `404.html` convention.
547    ///
548    /// The template gets `{ path }` in scope — the request path that
549    /// missed — so you can render `The page {{ path }} doesn't
550    /// exist.` without wiring extractors. When unset, 404s return
551    /// plain-text "Not Found". When set but the template fails to
552    /// render (missing file, parse error), the framework falls back
553    /// to the plain-text response and logs the render error.
554    ///
555    /// Composes with [`Self::slash_redirect`] — if a slash-redirect
556    /// probe finds the alternate, it 308s before the not-found
557    /// template fires.
558    pub fn not_found_template(mut self, name: impl Into<String>) -> Self {
559        self.not_found_template = Some(name.into());
560        self
561    }
562
563    /// Set the template rendered on a panicking handler. Follows
564    /// the `500.html` convention.
565    ///
566    /// Installs a `tower-http` `CatchPanic` layer around the router.
567    /// A panic in any handler is caught, logged via `tracing::error`,
568    /// and replaced with a 500 response carrying the rendered
569    /// template. When unset, panics use tower-http's default
570    /// behaviour (log + empty 500 body).
571    ///
572    /// In dev mode (`settings.environment == Dev`), the template receives
573    /// `dev_mode`, `error_display`, `error_chain`, and `request_path`
574    /// context variables. In prod those variables are empty.
575    ///
576    /// See [`Self::on_server_error`] for a hook that fires before the
577    /// template renders.
578    pub fn server_error_template(mut self, name: impl Into<String>) -> Self {
579        self.server_error_template = Some(name.into());
580        self
581    }
582
583    /// Register a custom template for error responses with `status` (e.g.
584    /// `429`, `403`, `410`). When a handler returns `Err((status, message))`
585    /// (or any non-HTML error response with this status), the template is
586    /// rendered in its place — styled like the 404/500 pages — preserving the
587    /// status code. The template receives `{ status, status_text, message,
588    /// request_path, dev_mode }`. Repeatable for multiple codes.
589    ///
590    /// 404 and 500 have dedicated methods ([`Self::not_found_template`] /
591    /// [`Self::server_error_template`]); use this for everything else.
592    ///
593    /// ```ignore
594    /// App::builder()
595    ///     .error_template(StatusCode::TOO_MANY_REQUESTS, "errors/429.html")
596    ///     .error_template(StatusCode::FORBIDDEN, "errors/403.html")
597    /// ```
598    pub fn error_template(
599        mut self,
600        status: axum::http::StatusCode,
601        name: impl Into<String>,
602    ) -> Self {
603        self.error_templates.insert(status, name.into());
604        self
605    }
606
607    /// Register a hook that fires on every internal server error (500).
608    ///
609    /// The closure receives:
610    /// - `error_display: &str` — the `Display` form of the error or the
611    ///   stringified panic payload.
612    /// - `request_path: &str` — the URI path of the failing request (empty
613    ///   for panic-path errors where path isn't yet available).
614    ///
615    /// The hook runs synchronously before the 500 template is rendered. It
616    /// cannot change the response — use it to log to an external service
617    /// (Sentry, Datadog, a file, etc.).
618    ///
619    /// ```ignore
620    /// App::builder()
621    ///     .on_server_error(|err, path| {
622    ///         tracing::error!(err, path, "500 error");
623    ///     })
624    ///     .build()?
625    /// ```
626    pub fn on_server_error<F>(mut self, hook: F) -> Self
627    where
628        F: Fn(&str, &str) + Send + Sync + 'static,
629    {
630        self.server_error_hook = Some(std::sync::Arc::new(hook));
631        self
632    }
633
634    /// Disable the built-in default 404/500 templates.
635    ///
636    /// By default, when the user hasn't called `.not_found_template(...)` or
637    /// `.server_error_template(...)`, umbral renders its own embedded Tailwind
638    /// error pages. Call this method to revert to axum's built-in behaviour:
639    /// a plain-text "Not Found" on 404 and an empty 500 body on panic.
640    ///
641    /// ```ignore
642    /// App::builder()
643    ///     .disable_default_error_pages()
644    ///     .build()?
645    /// ```
646    /// gaps3 #23: apply pending migrations automatically when the app is
647    /// STARTED (`umbral_cli::dispatch` → the `serve` command), and NEVER during
648    /// `makemigrations` / `migrate` / any other subcommand. This replaces the
649    /// argv-sniffing guard consumers hand-rolled in `main.rs` to avoid
650    /// auto-migrating during CLI commands:
651    ///
652    /// ```ignore
653    /// let app = App::builder().auto_migrate_on_serve().plugin(...).build()?;
654    /// umbral_cli::dispatch(app).await   // migrate runs iff this serves
655    /// ```
656    ///
657    /// A convenience for demos / small apps; a large deploy still runs
658    /// `migrate` as an explicit release step. Seeding stays app-owned (a
659    /// plugin's `on_ready` or an explicit call).
660    pub fn auto_migrate_on_serve(mut self) -> Self {
661        self.auto_migrate_on_serve = true;
662        self
663    }
664
665    /// Drain for `delay` on shutdown before the server stops accepting
666    /// connections (Kikosi #5 — the zero-downtime rollout piece).
667    ///
668    /// On `SIGTERM` / Ctrl-C, [`App::serve`] marks the process draining
669    /// ([`crate::shutdown::is_draining`]) so `umbral-health`'s `/readyz` returns
670    /// 503 at once, keeps serving for `delay`, and only then lets the graceful
671    /// shutdown proceed (stop accepting, finish in-flight, close pools). The
672    /// delay is the window in which a load balancer polls `/readyz`, sees the
673    /// 503, and pulls this instance out of rotation — so the requests it *would*
674    /// have routed here go elsewhere instead of hitting a socket that is about
675    /// to close.
676    ///
677    /// Pick a delay a little longer than your LB's readiness probe interval
678    /// (k8s default 10s; a `HEALTHCHECK --interval=10s` the same). `5`–`15s` is
679    /// typical. `Duration::ZERO` (the default) skips the drain entirely — right
680    /// for a single-instance app or local dev, where there is no LB to notify
681    /// and an instant Ctrl-C is what you want.
682    ///
683    /// Only meaningful alongside a readiness probe (mount `HealthPlugin`); with
684    /// no `/readyz` for the LB to poll, the delay is dead time on shutdown.
685    pub fn shutdown_drain(mut self, delay: std::time::Duration) -> Self {
686        self.drain_delay = delay;
687        self
688    }
689
690    pub fn disable_default_error_pages(mut self) -> Self {
691        self.default_error_pages = false;
692        self
693    }
694
695    /// Install a CORS policy as the outermost middleware.
696    ///
697    /// The framework doesn't install a `CorsLayer` by default —
698    /// same-origin requests need no policy, and CORS is too
699    /// security-sensitive to enable implicitly. Pass a
700    /// [`crate::cors::CorsConfig`] (start from
701    /// [`CorsConfig::strict`](crate::cors::CorsConfig::strict) for
702    /// production or [`CorsConfig::permissive`](crate::cors::CorsConfig::permissive)
703    /// for dev).
704    ///
705    /// ```ignore
706    /// use umbral::prelude::*;
707    /// use umbral::cors::CorsConfig;
708    ///
709    /// App::builder()
710    ///     .cors(CorsConfig::strict()
711    ///         .allow_origin("https://app.example.com")
712    ///         .allow_credentials(true))
713    ///     .build()
714    ///     .await?
715    /// ```
716    ///
717    /// The layer is applied LAST in the middleware chain so it
718    /// becomes the outermost wrapper — preflight `OPTIONS` is
719    /// answered before any plugin / handler sees the request, and
720    /// the response headers are added on the way back out
721    /// regardless of which downstream layer produced the body.
722    pub fn cors(mut self, config: crate::cors::CorsConfig) -> Self {
723        self.cors = Some(config);
724        self
725    }
726
727    /// Apply a CORS policy scoped to requests whose path starts with `prefix`
728    /// (e.g. `"/api"`), leaving every other route's responses untouched. The
729    /// path-scoped counterpart to [`cors`](Self::cors) — the shape you want for
730    /// "CORS on the REST API, not the HTML pages." Call repeatedly for several
731    /// prefixes. Scoped policies are applied after (outside) the global one.
732    ///
733    /// ```ignore
734    /// use umbral::cors::CorsConfig;
735    ///
736    /// App::builder()
737    ///     .cors_for("/api", CorsConfig::strict()
738    ///         .allow_origins(vec!["https://app.example.com"])
739    ///         .allow_credentials(true))
740    ///     .build()
741    ///     .await?
742    /// ```
743    pub fn cors_for(mut self, prefix: impl Into<String>, config: crate::cors::CorsConfig) -> Self {
744        self.cors_scoped.push((prefix.into(), config));
745        self
746    }
747
748    /// Default every ORM write to run inside its own transaction.
749    ///
750    /// When `enabled = true`, terminals that opt into the contract
751    /// (`Manager::create`, `Manager::bulk_create`,
752    /// `Manager::get_or_create`, `QuerySet::update_values`,
753    /// `QuerySet::delete`) wrap their work in a BEGIN / COMMIT pair
754    /// unless the caller explicitly opts out with `.non_atomic()`.
755    ///
756    /// This is the safe-by-default posture: a framework that claims
757    /// "secure by default" should also be "transaction-safe by
758    /// default." Opting out matters mostly for high-throughput seed
759    /// scripts that already wrap an outer transaction themselves.
760    ///
761    /// Without this flag the framework's behaviour is unchanged —
762    /// writes run with whatever transaction the caller arranges. The
763    /// per-call `.atomic()` / `.non_atomic()` overrides still work.
764    pub fn atomic_transactions(mut self, enabled: bool) -> Self {
765        self.atomic_transactions = Some(enabled);
766        self
767    }
768
769    /// Make a forgotten authorization gate a **boot error** instead of a
770    /// warning (gaps3 #28 P1). With this set, `build()` fails with
771    /// [`BuildError::UngatedMutatingRoutes`] if any app-level mutating route
772    /// (POST/PUT/PATCH/DELETE) registered via [`Self::routes`] carries no
773    /// recorded permission — i.e. it wasn't gated through the umbral-permissions
774    /// `Routes::*_gated(...)` builders (a hand-applied
775    /// `.layer(permission_required(...))` is opaque to the audit, so prefer the
776    /// builder). This is the opt-in "gated by construction" posture: authorization
777    /// on every mutating route is enforced at boot rather than trusted to review.
778    ///
779    /// Default off — `build()` only *warns*. An intentionally-public mutating
780    /// route (a webhook receiver, a health `POST`) must be registered through a
781    /// permission-aware builder anyway (or kept out of `.routes(...)`) once this
782    /// is on, so the decision is explicit.
783    pub fn deny_ungated_mutations(mut self) -> Self {
784        self.deny_ungated_mutations = true;
785        self
786    }
787
788    /// Compress responses with gzip / brotli (a `tower-http`
789    /// `CompressionLayer`). The algorithm is chosen from the request's
790    /// `Accept-Encoding`; already-encoded or non-compressible content types
791    /// are skipped automatically.
792    ///
793    /// Off by default: in most deployments the reverse proxy (nginx, a CDN)
794    /// already compresses, and doing it twice is wasted CPU. Enable this
795    /// when you serve directly (a single binary with no proxy in front).
796    pub fn compression(mut self) -> Self {
797        self.compress = true;
798        self
799    }
800
801    /// Set (or disable) the framework-wide request-body size cap.
802    ///
803    /// `build()` installs a `tower-http` `RequestBodyLimitLayer` with this
804    /// ceiling, so any request whose body exceeds it is rejected with `413
805    /// Payload Too Large` before a handler (or the multipart parser) buffers
806    /// it — the memory-exhaustion backstop axum's per-extractor default does
807    /// NOT give streaming/multipart consumers (audit_2 core-web H11).
808    ///
809    /// Defaults to **32 MiB**. Pass `Some(bytes)` to raise/lower it, or `None`
810    /// to remove the global limit entirely (appropriate when a reverse proxy
811    /// already caps body size).
812    ///
813    /// ```ignore
814    /// App::builder()
815    ///     .max_request_body(Some(8 * 1024 * 1024)) // 8 MiB
816    ///     .build().await?;
817    /// ```
818    pub fn max_request_body(mut self, limit: Option<usize>) -> Self {
819        self.max_request_body_bytes = limit;
820        self
821    }
822
823    /// Set (or disable) the default per-request timeout.
824    ///
825    /// `build()` installs a `tower-http` `TimeoutLayer` so a request that runs
826    /// longer than this is aborted with `408 Request Timeout`, freeing the
827    /// task/connection instead of letting a hung handler or slowloris client
828    /// pin it indefinitely (audit_2 core-web H11/#3).
829    ///
830    /// Defaults to **30 seconds**. Pass `Some(duration)` to change it, or
831    /// `None` to disable — do that for legitimately long-lived streaming/SSE
832    /// routes, or when a proxy owns request timeouts.
833    ///
834    /// ```ignore
835    /// use std::time::Duration;
836    /// App::builder()
837    ///     .request_timeout(Some(Duration::from_secs(10)))
838    ///     .build().await?;
839    /// ```
840    pub fn request_timeout(mut self, timeout: Option<std::time::Duration>) -> Self {
841        self.request_timeout = timeout;
842        self
843    }
844
845    /// Toggle the core-shipped hardening response headers (audit_2 H10):
846    /// `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and
847    /// `Referrer-Policy: strict-origin-when-cross-origin`. On by default and
848    /// applied only when the header isn't already set, so `SecurityPlugin`'s
849    /// configured values win. Pass `false` to fully own response headers
850    /// yourself (e.g. an API behind a gateway that adds them at the edge).
851    pub fn default_security_headers(mut self, enabled: bool) -> Self {
852        self.default_security_headers = enabled;
853        self
854    }
855
856    /// Register a framework-level [`Middleware`](crate::middleware::Middleware)
857    /// (feature #68) with `before_request` / `after_response` hooks.
858    ///
859    /// App-level middleware is added to the stack *before* any plugin's
860    /// contribution, so its `before_request` runs first and its
861    /// `after_response` runs last (it's the outermost layer of the onion).
862    /// Call this multiple times to register several, in order.
863    ///
864    /// Use this for the common "look at every request / response" case.
865    /// For a real tower `Layer` (timeouts, body limits) reach for the
866    /// router directly via a plugin's `wrap_router`.
867    pub fn middleware<M: crate::middleware::Middleware>(mut self, mw: M) -> Self {
868        self.middleware.push(std::sync::Arc::new(mw));
869        self
870    }
871
872    /// Finalize the application.
873    ///
874    /// Phases (see spec 01 §Mechanics and invariants and spec 02
875    /// §Dependency ordering):
876    ///
877    /// 1. **Collect.** Gather settings, databases, and router from
878    ///    builder-local state. Settings must be set explicitly via
879    ///    `.settings(...)`; the "default" database pool must be
880    ///    registered via `.database("default", pool)`. The caller
881    ///    opens the pool first (with `umbral::db::connect(...).await`)
882    ///    and hands it to the builder. This matches the canonical
883    ///    pattern in spec 01-app-and-settings.md.
884    /// 2. **Validate plugins.** Reject the reserved `"app"` name,
885    ///    reject duplicate `Plugin::name()`s, verify every entry in a
886    ///    `dependencies()` list points at a registered plugin, and
887    ///    compute a stable topological order. Cycles surface as
888    ///    `BuildError::PluginCycle`.
889    /// 3. **Detect backend.** `backend::detect(&settings.database_url)`
890    ///    picks one of the shipped `DatabaseBackend` impls (M4
891    ///    abstraction). An unknown URL scheme (mysql / oracle / etc.)
892    ///    fails here, before any system check runs.
893    /// 4. **Publish ambient state.** Write settings, pools, and the
894    ///    active backend into their `OnceLock`s. The model registry
895    ///    carries one entry per plugin (the implicit `"app"` plus every
896    ///    registered plugin's `Plugin::models()`).
897    /// 5. **System check.** Run framework-built-in checks plus every
898    ///    plugin's `system_checks()` (concatenated in topological order)
899    ///    against the just-published context. Errors block boot;
900    ///    warnings log and continue.
901    /// 6. **Build router.** Start from the hand-written router (or a
902    ///    fallback handler), then merge every plugin's `routes()` in
903    ///    topological order. axum's `Router::merge` panics on
904    ///    duplicate routes with a clear message.
905    /// 7. **Fire `on_ready`.** Call each plugin's `on_ready(&AppContext)`
906    ///    in topological order. A failure here surfaces as
907    ///    `BuildError::PluginOnReady`. Phases 1-6 are
908    ///    [`AppBuilder::build_deferred`]; this last phase is [`App::ready`],
909    ///    and a CLI binary lets `umbral_cli::dispatch` decide when it fires
910    ///    (gaps3 #41).
911    ///
912    /// `build()` is intentionally sync. Earlier iterations auto-opened
913    /// the default pool from `settings.database_url` by spinning up a
914    /// throwaway tokio runtime to drive `db::connect`. That panicked
915    /// when called from inside any caller that was already in a tokio
916    /// runtime ("Cannot start a runtime from within a runtime"), which
917    /// is every realistic case. Requiring an explicit `.database(...)`
918    /// is both spec-correct and avoids the trap.
919    pub fn build(self) -> Result<App, BuildError> {
920        let app = self.build_deferred()?;
921        app.ready()?;
922        Ok(app)
923    }
924
925    /// Everything [`AppBuilder::build`] does *except* firing `on_ready`.
926    ///
927    /// The app is fully wired — pools open, registry published, router merged,
928    /// system checks passed — but no plugin has been told the app is up. Call
929    /// [`App::ready`] when it actually is.
930    ///
931    /// This exists for `umbral_cli::dispatch`, which has to build the app in
932    /// order to read its plugins' `commands()` and *then* decide what argv asked
933    /// for. A schema command (`migrate`, `makemigrations`, `inspectdb`) must not
934    /// fire hooks that seed content into a schema that doesn't exist yet
935    /// (gaps3 #41). Reach for it directly only if you are writing your own
936    /// dispatcher; otherwise `build()` is the one you want.
937    pub fn build_deferred(mut self) -> Result<App, BuildError> {
938        // Phase 1 — collect
939        let settings = self.settings.take().ok_or(BuildError::SettingsMissing)?;
940
941        if !self.databases.contains_key("default") {
942            return Err(BuildError::DefaultPoolMissing);
943        }
944
945        // Phase 1.4 — audit_2 H17: open the pools declared in `settings.databases`.
946        // Each `[databases] <alias> = "<url>"` entry that a builder `.database()`
947        // call didn't already register is opened LAZILY (sync; connects on first
948        // use) and added to the pool set, so a model/router routed to that alias
949        // resolves instead of panicking at query time — and the documented
950        // `settings.databases` config actually does something. A builder-registered
951        // alias wins (an explicitly-built pool overrides the settings URL).
952        for (alias, url) in &settings.databases {
953            if self.databases.contains_key(alias) {
954                continue;
955            }
956            let pool =
957                crate::db::connect_lazy(url).map_err(|error| BuildError::SettingsDatabasePool {
958                    alias: alias.clone(),
959                    error,
960                })?;
961            self.databases.insert(alias.clone(), pool);
962        }
963
964        // Phase 1.5 — validate plugins and compute a stable topological
965        // order. Reserved-name and duplicate-name checks reject the
966        // build before any ambient state gets published; the toposort
967        // surfaces both missing deps and cycles as `BuildError`. The
968        // sorted vec is reused in phases 3 / 4 / 5 / 6 so every plugin
969        // walk reads from one canonical order, then handed to `App` so
970        // post-build callers (notably `umbral::cli::dispatch`) can walk
971        // the same list.
972        let sorted_plugins = sort_plugins(std::mem::take(&mut self.plugins))?;
973
974        // Phase 2 — detect backend from the configured URL.
975        let backend =
976            crate::backend::detect(&settings.database_url).map_err(BuildError::BackendDetect)?;
977
978        // Phase 2.1 — cross-check the registered default pool's
979        // backend against the URL-derived one. A mismatch (e.g. the
980        // URL says `sqlite://` but the caller passed in a `PgPool`)
981        // surfaces here with a clear name pair rather than as a
982        // confusing query-time error.
983        let default_pool = self
984            .databases
985            .get("default")
986            .expect("contains_key check above");
987        if default_pool.backend_name() != backend.name() {
988            return Err(BuildError::DatabaseBackendMismatch {
989                url_backend: backend.name(),
990                pool_backend: default_pool.backend_name(),
991            });
992        }
993
994        // Phase 2.5 — validate every plugin's `database()` alias
995        // against the registered pool set BEFORE phase 3 moves
996        // `self.databases` into the ambient registry. Lets a typo
997        // surface at boot with a clear diagnostic instead of as a
998        // runtime "no pool registered" panic from `db::pool_for`.
999        // Also collect the per-model alias map for `init_model_aliases`
1000        // below. Two layers: plugin-level (`Plugin::database()`) and
1001        // per-model (`#[umbral(database = "alias")]` → `Model::DATABASE`,
1002        // surfaced via `ModelMeta::database`). Per-model wins when both
1003        // are set — useful for a plugin that owns one model on the
1004        // primary DB and another on an analytics/archive DB. Same alias
1005        // validation: a typo surfaces at boot, not at runtime.
1006        let mut model_aliases: HashMap<String, String> = HashMap::new();
1007        for plugin in &sorted_plugins {
1008            // Plugin-level default for every model this plugin contributes.
1009            if let Some(alias) = plugin.database() {
1010                if !self.databases.contains_key(alias) {
1011                    return Err(BuildError::PluginDatabaseAlias {
1012                        plugin: plugin.name(),
1013                        alias,
1014                    });
1015                }
1016                for model in plugin.models() {
1017                    model_aliases.insert(model.name, alias.to_string());
1018                }
1019            }
1020            // Per-model overrides — walked AFTER the plugin pass so they
1021            // can supersede the plugin's choice.
1022            for model in plugin.models() {
1023                if let Some(alias) = &model.database {
1024                    if !self.databases.contains_key(alias) {
1025                        return Err(BuildError::PluginDatabaseAlias {
1026                            plugin: plugin.name(),
1027                            alias: Box::leak(alias.clone().into_boxed_str()),
1028                        });
1029                    }
1030                    model_aliases.insert(model.name.clone(), alias.clone());
1031                }
1032            }
1033        }
1034        // Same per-model walk for the implicit `"app"` plugin's
1035        // user-registered models, which don't have a `Plugin::database()`
1036        // wrapper to inherit from.
1037        for model in &self.models {
1038            if let Some(alias) = &model.database {
1039                if !self.databases.contains_key(alias) {
1040                    return Err(BuildError::PluginDatabaseAlias {
1041                        plugin: crate::migrate::APP_PLUGIN_NAME,
1042                        alias: Box::leak(alias.clone().into_boxed_str()),
1043                    });
1044                }
1045                model_aliases.insert(model.name.clone(), alias.clone());
1046            }
1047        }
1048
1049        // (audit_2 H17: `settings.databases` pools were opened lazily in Phase 1.4
1050        // above, so every declared alias is now a registered pool — the earlier
1051        // "not auto-opened" boot warning is gone.)
1052
1053        // Phase 2.5b — cross-database foreign-key guard (gaps2 #22).
1054        //
1055        // A foreign key whose target model lives on a DIFFERENT database
1056        // can't be a real DB constraint — `REFERENCES` can't span pools.
1057        // We resolve each model's effective alias (plugin default, then
1058        // per-model override, else "default") into a table→alias map,
1059        // then check every FK column: if the column's target table
1060        // routes to a different alias than the model AND the field has
1061        // not opted out via `#[umbral(db_constraint = false)]`, the build
1062        // fails loudly here rather than emitting an invalid `FOREIGN KEY`
1063        // line at migration time.
1064        //
1065        // Build the table→alias map with the same precedence as
1066        // `model_aliases` above: plugin default first, per-model override
1067        // wins, the implicit "app" models last. Any table not mentioned
1068        // routes to "default".
1069        let mut table_alias: HashMap<String, String> = HashMap::new();
1070        for plugin in &sorted_plugins {
1071            let plugin_default = plugin.database();
1072            for model in plugin.models() {
1073                let alias = model
1074                    .database
1075                    .clone()
1076                    .or_else(|| plugin_default.map(|s| s.to_string()))
1077                    .unwrap_or_else(|| "default".to_string());
1078                table_alias.insert(model.table.clone(), alias);
1079            }
1080        }
1081        for model in &self.models {
1082            let alias = model
1083                .database
1084                .clone()
1085                .unwrap_or_else(|| "default".to_string());
1086            table_alias.insert(model.table.clone(), alias);
1087        }
1088        // Helper to resolve a table's alias, defaulting to "default".
1089        let alias_of = |table: &str| -> String {
1090            table_alias
1091                .get(table)
1092                .cloned()
1093                .unwrap_or_else(|| "default".to_string())
1094        };
1095        // Walk every model's FK fields and check each FK relation. The
1096        // default (no custom router) path keeps today's build-time local
1097        // alias equality (`alias_of(a) == alias_of(b)`): the trait's
1098        // DEFAULT `allow_relation` reads the GLOBAL `model_alias`, which is
1099        // still unpublished at this Phase 2.5b point, so routing the
1100        // default case through the trait would compare "default" == "default"
1101        // for everything and silently disable the #22 guard. A CUSTOM router
1102        // is asked directly via `allow_relation`.
1103        //
1104        // gaps3 #46: pull in every model the binary link-registered. Merged with
1105        // (not instead of) the explicit `.model::<T>()` list, and de-duplicated by
1106        // table, so the two compose and adding `auto_models()` to an existing app
1107        // can't double-register anything.
1108        if self.auto_models {
1109            let known: std::collections::HashSet<String> = self
1110                .models
1111                .iter()
1112                .map(|m| m.table.clone())
1113                .chain(
1114                    sorted_plugins
1115                        .iter()
1116                        .flat_map(|p| p.models())
1117                        .map(|m| m.table),
1118                )
1119                .collect();
1120            for meta in crate::migrate::link_registered_models() {
1121                if !known.contains(&meta.table) {
1122                    self.models.push(meta);
1123                }
1124            }
1125        }
1126
1127        // gaps3 #54: an `#[umbral(audited)]` model implies the audit table. Register
1128        // it automatically so `makemigrations` creates it through the normal
1129        // declare→migrate loop — no special-cased DDL, and no ceremony for the app.
1130        let any_audited = sorted_plugins
1131            .iter()
1132            .flat_map(|p| p.models())
1133            .chain(self.models.iter().cloned())
1134            .any(|m| m.audited);
1135        if any_audited
1136            && !self
1137                .models
1138                .iter()
1139                .any(|m| m.table == crate::orm::audit::AUDIT_TABLE)
1140        {
1141            self.models.push(crate::orm::audit::audit_meta());
1142        }
1143
1144        // Materialize the models into a Vec so we can both build a
1145        // table→meta lookup AND iterate them.
1146        let all_models: Vec<ModelMeta> = sorted_plugins
1147            .iter()
1148            .flat_map(|p| p.models())
1149            .chain(self.models.iter().cloned())
1150            .collect();
1151        let meta_by_table: HashMap<&str, &ModelMeta> =
1152            all_models.iter().map(|m| (m.table.as_str(), m)).collect();
1153        // Clone the candidate router — install still happens at Phase 3, so
1154        // we must NOT take/consume `self.db_router` here.
1155        let candidate_router = self.db_router.clone();
1156        for model in &all_models {
1157            for field in &model.fields {
1158                let Some(target_table) = field.fk_target.as_deref() else {
1159                    continue;
1160                };
1161                if !field.db_constraint {
1162                    continue;
1163                }
1164                let allowed = match &candidate_router {
1165                    Some(r) => match meta_by_table.get(target_table) {
1166                        Some(target_meta) => r.allow_relation(model, target_meta),
1167                        // Target isn't a registered model (shouldn't happen
1168                        // for a real FK); don't false-reject — fall back to
1169                        // the local alias check.
1170                        None => alias_of(&model.table) == alias_of(target_table),
1171                    },
1172                    // No custom router: today's build-time local alias
1173                    // equality (#22).
1174                    None => alias_of(&model.table) == alias_of(target_table),
1175                };
1176                if !allowed {
1177                    let model_db = alias_of(&model.table);
1178                    let target_db = alias_of(target_table);
1179                    return Err(BuildError::CrossDatabaseForeignKey {
1180                        model: Box::leak(model.name.clone().into_boxed_str()),
1181                        field: Box::leak(field.name.clone().into_boxed_str()),
1182                        model_db: Box::leak(model_db.into_boxed_str()),
1183                        target_db: Box::leak(target_db.into_boxed_str()),
1184                    });
1185                }
1186            }
1187        }
1188
1189        // Phase 2.6 — publish the default-error-pages flag before the
1190        // templates engine starts so `errors::default_pages_enabled()` is
1191        // correct the moment any 404/500 helper is called.
1192        crate::errors::init_default_pages(self.default_error_pages);
1193
1194        // Phase 3 — publish ambient state. The model registry now carries
1195        // one entry per registered plugin (the implicit `"app"` plugin
1196        // for `.model::<T>()` registrations, plus every `.plugin(...)`
1197        // contribution). Plugins that contribute zero models still get a
1198        // map entry; the flattening in `migrate::init_plugins` collapses
1199        // them to nothing in the registry but the per-plugin model walk
1200        // stays deterministic.
1201        crate::settings::init(&settings);
1202        db::init(self.databases);
1203        if let Some(router) = self.db_router {
1204            crate::db::router::install_router(router);
1205        }
1206        crate::backend::init(backend);
1207        if let Some(enabled) = self.atomic_transactions {
1208            db::init_atomic_default(enabled);
1209        }
1210
1211        let mut per_plugin: HashMap<String, Vec<ModelMeta>> = HashMap::new();
1212        per_plugin.insert(
1213            crate::migrate::APP_PLUGIN_NAME.to_string(),
1214            std::mem::take(&mut self.models),
1215        );
1216        for plugin in &sorted_plugins {
1217            per_plugin.insert(plugin.name().to_string(), plugin.models());
1218        }
1219        crate::migrate::init_plugins(per_plugin);
1220
1221        // Publish the topological plugin order so the migration engine
1222        // walks plugins in dependency order. The implicit "app" plugin
1223        // (owner of `.model::<T>()` registrations) lands LAST: app models
1224        // typically hold ForeignKeys INTO plugin-owned tables (e.g.
1225        // `Post.author -> auth_user`), so those tables must be created
1226        // first. Postgres enforces FK targets at CREATE TABLE, so ordering
1227        // "app" first made app-model migrations fail there with
1228        // `relation "auth_user" does not exist` (SQLite silently allowed
1229        // the dangling FK, hiding the bug in local dev).
1230        let mut order: Vec<String> = Vec::with_capacity(sorted_plugins.len() + 1);
1231        for plugin in &sorted_plugins {
1232            order.push(plugin.name().to_string());
1233        }
1234        order.push(crate::migrate::APP_PLUGIN_NAME.to_string());
1235        crate::migrate::init_plugin_order(order);
1236
1237        // Collect every plugin's advertised API endpoints into a global
1238        // so a discovery surface (umbral-rest's API root) can list them
1239        // without depending on the contributing plugins' crates. In
1240        // registration order; plugins that advertise nothing contribute
1241        // nothing.
1242        let mut api_endpoints = Vec::new();
1243        for plugin in &sorted_plugins {
1244            api_endpoints.extend(plugin.api_endpoints());
1245        }
1246        crate::migrate::init_api_endpoints(api_endpoints);
1247
1248        // Publish the per-plugin model alias map collected in phase
1249        // 2.5. Done after `migrate::init_plugins` so the migration
1250        // registry is alive when QuerySet's resolve_pool starts
1251        // looking up by `Model::NAME`.
1252        crate::migrate::init_model_aliases(model_aliases);
1253
1254        // audit_2 H19: surface at boot the app's own mutating routes
1255        // (POST/PUT/PATCH/DELETE) that carry no RECORDED permission, so a
1256        // forgotten authorization gate surfaces here instead of as a silently
1257        // open endpoint. Only `.routes(...)` (the app's hand-written routes)
1258        // are audited — plugin routes gate via their own conventions and are
1259        // merged separately. Runs before `self.route_paths` is moved below.
1260        // With `.deny_ungated_mutations()` (gaps3 #28 P1) the same finding is a
1261        // hard `BuildError` instead of a warning: authorization on every
1262        // mutating route is enforced by construction.
1263        let ungated = ungated_mutating_routes(&self.route_paths);
1264        if !ungated.is_empty() {
1265            if self.deny_ungated_mutations {
1266                return Err(BuildError::UngatedMutatingRoutes { routes: ungated });
1267            }
1268            warn_ungated_mutating_routes(&ungated);
1269        }
1270
1271        // Snapshot the declared route paths into the registry so the
1272        // dev-mode 404 page can surface them. The implicit `"app"`
1273        // plugin holds whatever `.route_paths([...])` declared on the
1274        // builder; each registered plugin contributes its own list.
1275        // Empty entries are kept so the listing distinguishes "plugin
1276        // present, no routes" from "plugin absent".
1277        let mut route_registry = crate::routes::RouteRegistry::default();
1278        route_registry.by_plugin.insert(
1279            crate::migrate::APP_PLUGIN_NAME.to_string(),
1280            std::mem::take(&mut self.route_paths),
1281        );
1282        for plugin in &sorted_plugins {
1283            route_registry
1284                .by_plugin
1285                .insert(plugin.name().to_string(), plugin.route_paths());
1286        }
1287        crate::routes::init(route_registry);
1288
1289        // BUG-20: publish every plugin's OpenAPI path contribution
1290        // so umbral-openapi can merge them into the emitted spec.
1291        // Flat (path, value) list — multiple plugins contributing
1292        // the same path produce duplicate entries; umbral-openapi's
1293        // merge step picks the first.
1294        let mut openapi_entries: Vec<(String, serde_json::Value)> = Vec::new();
1295        for plugin in &sorted_plugins {
1296            openapi_entries.extend(plugin.openapi_paths());
1297        }
1298        crate::routes::init_openapi(openapi_entries);
1299
1300        // Templates engine — published before phase 4 so a future
1301        // plugin system_check that wants to inspect the loaded
1302        // templates can.
1303        //
1304        // Search order (first-match-wins across all template directories):
1305        //   1. App-level dir: set via `.templates_dir(...)` or `./templates`.
1306        //   2. Plugin dirs: each plugin's `templates_dirs()` contributions,
1307        //      in topological dependency order.
1308        //
1309        // The engine warns (via tracing) when two directories ship a
1310        // template with the same name — the first-registered copy wins.
1311        let app_templates_dir = self
1312            .templates_dir
1313            .take()
1314            .unwrap_or_else(|| std::path::PathBuf::from("templates"));
1315        let mut all_template_dirs: Vec<std::path::PathBuf> = vec![app_templates_dir];
1316        for plugin in &sorted_plugins {
1317            all_template_dirs.extend(plugin.templates_dirs());
1318        }
1319        // features.md #67 — collect every plugin's custom tags/filters in
1320        // topological order so a dependency's registrar runs before its
1321        // dependent's (and a later plugin can override an earlier one).
1322        let mut template_registrars: Vec<crate::templates::TemplateRegistrar> = Vec::new();
1323        for plugin in &sorted_plugins {
1324            template_registrars.extend(plugin.template_registrars());
1325        }
1326        // `init_with` returns the list of collision names (templates present
1327        // in more than one directory). We log each one via tracing here so
1328        // the `App::build()` phase is the single point that handles warnings;
1329        // `templates::init` itself also emits tracing::warn! for each, but
1330        // returning the list lets callers (tests) assert without a subscriber.
1331        let _collisions = crate::templates::init_with(&all_template_dirs, template_registrars)
1332            .map_err(BuildError::TemplatesInit)?;
1333
1334        // Phase 4 — system check. Build the context against ambient
1335        // state, run the framework checks plus every plugin's
1336        // contribution in topological order, partition into errors vs
1337        // warnings, log the warnings, fail the build on any errors.
1338        // Whether any registered plugin declares a Storage backend. Read
1339        // by the `field.storage_backend` check; computed from the
1340        // capability flag (not the ambient `storage_opt()`) because
1341        // backends register in `on_ready`, which runs *after* this phase.
1342        let provides_storage = sorted_plugins.iter().any(|p| p.provides_storage());
1343        let plugin_names: Vec<&str> = sorted_plugins.iter().map(|p| p.name()).collect();
1344        let ctx = crate::check::CheckContext {
1345            backend,
1346            settings: crate::settings::get(),
1347            provides_storage,
1348            registered_plugin_names: &plugin_names,
1349        };
1350        let mut checks = crate::check::framework_checks();
1351        for plugin in &sorted_plugins {
1352            checks.extend(plugin.system_checks());
1353        }
1354        let findings = crate::check::run_all(&ctx, &checks);
1355        let mut errors = Vec::new();
1356        for finding in findings {
1357            match finding.severity {
1358                crate::check::Severity::Error => errors.push(finding),
1359                crate::check::Severity::Warning => {
1360                    tracing::warn!(
1361                        check = finding.check_id,
1362                        "umbral system check warning: {}",
1363                        finding.message
1364                    );
1365                }
1366            }
1367        }
1368        if !errors.is_empty() {
1369            return Err(BuildError::SystemCheckFailed { findings: errors });
1370        }
1371
1372        // Phase 5 — build the merged router. Start from the hand-written
1373        // router (or a fallback handler if none was registered), then
1374        // merge every plugin's routes in topological order. axum's
1375        // `Router::merge` composes path tables; conflicts panic with a
1376        // clear message.
1377        let mut router = self.router.unwrap_or_else(|| {
1378            Router::new().fallback(|| async { "umbral is running, but no routes are registered." })
1379        });
1380        for plugin in &sorted_plugins {
1381            router = router.merge(plugin.routes());
1382            // Phase 5.4 — mount the plugin's `include_bytes!`-embedded
1383            // assets. Each StaticFile becomes a GET route serving the
1384            // body with the supplied content-type + cache-control.
1385            for file in plugin.static_files() {
1386                router = router.route(
1387                    file.url_path,
1388                    axum::routing::get(move || async move {
1389                        use axum::response::IntoResponse;
1390                        let cc = file.cache_control.unwrap_or("public, max-age=86400");
1391                        axum::http::Response::builder()
1392                            .status(axum::http::StatusCode::OK)
1393                            .header(axum::http::header::CONTENT_TYPE, file.content_type)
1394                            .header(axum::http::header::CACHE_CONTROL, cc)
1395                            .body(axum::body::Body::from(file.body))
1396                            .unwrap_or_else(|_| {
1397                                axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
1398                            })
1399                    }),
1400                );
1401            }
1402        }
1403
1404        // Phase 5.45 — mount the unified static pipeline handler. Walk
1405        // every plugin's `static_dirs()` into a namespace -> source_dir
1406        // registry (a duplicate namespace fails the build loudly), then
1407        // nest ONE handler at the configured `static_url` base. It
1408        // resolves `/static/<ns>/<rest>` live-from-source in dev and
1409        // from `static_root` in prod (see `crate::static_files`).
1410        //
1411        // This coexists with the `StaticFile` embedded routes mounted in
1412        // Phase 5.4 above — embedded assets stay the zero-config default;
1413        // the filesystem handler is additive.
1414        //
1415        // A CDN-style `static_url` (an absolute http(s):// origin) can't
1416        // be nested as a local route prefix; in that mode assets are
1417        // served off the CDN and the local handler is intentionally not
1418        // mounted — the `static()` template helper still emits the
1419        // absolute URLs.
1420        let settings = crate::settings::get();
1421        let static_base = settings.static_url.trim_end_matches('/');
1422        let is_cdn_url = settings.static_url.starts_with("http://")
1423            || settings.static_url.starts_with("https://")
1424            || settings.static_url.starts_with("//");
1425
1426        // App/site-level static dirs served at the bare `static_url` root.
1427        // A `StoragePlugin`'s static side mounted AT `static_url` contributes its
1428        // directory here (and skips nesting its own catch-all), so the
1429        // framework owns `static_url` as ONE mount — a second
1430        // `/static/{*rest}` nest is exactly the conflict this avoids.
1431        let root_dirs = crate::static_files::StaticContribution::collect_root_dirs(&sorted_plugins);
1432
1433        // Publish the static contributions ambiently for `collectstatic`
1434        // (the `StoragePlugin` CLI command). Published UNCONDITIONALLY —
1435        // before the serving-mode gate below — because `collectstatic`
1436        // copies assets to disk regardless of serving mode (a CDN-mode
1437        // app still needs the disk tree built for upload). Mirrors the
1438        // `settings` ambient OnceLock: read-only config set once at build.
1439        crate::static_files::publish_static(crate::static_files::PublishedStatic {
1440            contributions: crate::static_files::StaticContribution::collect(&sorted_plugins),
1441            root_dirs: root_dirs.clone(),
1442        });
1443
1444        // Load the hashed-asset manifest (`<static_root>/staticfiles.json`)
1445        // if `collectstatic --hashed` has produced one. With a manifest
1446        // present, `resolve_static_url` / the `static()` template global
1447        // emit content-hashed URLs so prod assets carry far-future cache
1448        // headers. Absent (no `--hashed` run), this is a no-op and URLs
1449        // stay plain. Loaded unconditionally — the URL resolution applies
1450        // whether or not this app serves the bytes itself.
1451        crate::static_files::load_manifest(&settings.static_root);
1452
1453        if !is_cdn_url && !static_base.is_empty() {
1454            let registry = crate::static_files::StaticRegistry::from_plugins(&sorted_plugins)
1455                .map_err(|c| BuildError::DuplicateStaticNamespace {
1456                    namespace: c.namespace,
1457                    first_plugin: c.first_plugin,
1458                    second_plugin: c.second_plugin,
1459                })?;
1460            // Nothing to serve and no app static dirs — don't claim the
1461            // `static_url` path at all, so a consumer that wants to mount
1462            // their own router there can.
1463            if !registry.is_empty() || !root_dirs.is_empty() {
1464                let state = crate::static_files::StaticHandlerState {
1465                    registry,
1466                    static_root: std::path::PathBuf::from(&settings.static_root),
1467                    root_dirs,
1468                    dev: matches!(settings.environment, crate::settings::Environment::Dev),
1469                };
1470                let static_router = Router::new()
1471                    .fallback(crate::static_files::static_handler)
1472                    .with_state(state);
1473                router = router.nest_service(static_base, static_router);
1474            }
1475        }
1476
1477        // Phase 5.5 — apply each plugin's middleware in topological
1478        // order. Later plugins wrap earlier ones, so a security
1479        // plugin declared after the auth plugin sees the auth-
1480        // augmented router and can add its own layer on top. This
1481        // is the M7 deferral being lifted now that umbral-security
1482        // needs it.
1483        for plugin in &sorted_plugins {
1484            router = plugin.wrap_router(router);
1485        }
1486
1487        // Phase 5.6 — install the 404 fallback. Four cases:
1488        //
1489        // 1. slash_redirect = Off, not_found_template = None, default pages off:
1490        //    no-op. axum's built-in empty 404 is what users see.
1491        // 2. slash_redirect = Off, not_found_template = None, default pages ON:
1492        //    install the not-found fallback; render_not_found will use the
1493        //    embedded default_404 template.
1494        // 3. slash_redirect = Off, not_found_template = Some(name):
1495        //    install the not-found fallback directly. Renders the
1496        //    template on every miss.
1497        // 4. slash_redirect != Off:
1498        //    install the slash-redirect fallback. It handles its own
1499        //    404 path internally — when no alternate matches, it
1500        //    renders the configured not-found template (or the default
1501        //    if enabled, or plain text if both are absent).
1502        //
1503        // The slash-redirect fallback ALWAYS captures a router
1504        // snapshot taken BEFORE the fallback is installed, so the
1505        // alternate-path probe can't recursively re-hit the fallback.
1506        let need_not_found_fallback = self.not_found_template.is_some() || self.default_error_pages;
1507        match (self.slash_redirect, need_not_found_fallback) {
1508            (crate::slash::SlashRedirect::Off, false) => {
1509                // axum's default 404 — nothing to do.
1510            }
1511            (crate::slash::SlashRedirect::Off, true) => {
1512                let fallback = crate::errors::not_found_fallback(self.not_found_template.clone());
1513                router = router.fallback(fallback);
1514            }
1515            (policy, _) => {
1516                let snapshot = router.clone();
1517                let fallback = crate::slash::slash_redirect_fallback(
1518                    snapshot,
1519                    policy,
1520                    self.not_found_template.clone(),
1521                );
1522                router = router.fallback(fallback);
1523            }
1524        }
1525
1526        // Phase 5.65 — framework middleware stack (feature #68). App-level
1527        // middleware first, then every plugin's contribution in topological
1528        // order, collected into one stack and installed as a single layer.
1529        // Placed AFTER the 404 fallback so middleware sees misses too, and
1530        // BEFORE the panic / compression / CORS / host layers so those stay
1531        // the outermost wrappers (security and content-encoding run before
1532        // user middleware ever touches the request).
1533        let mut middleware_stack = crate::middleware::MiddlewareStack::new();
1534        middleware_stack.extend(std::mem::take(&mut self.middleware));
1535        for plugin in &sorted_plugins {
1536            middleware_stack.extend(plugin.middleware());
1537        }
1538        router = middleware_stack.apply(router);
1539
1540        // Phase 5.66 — request-scoped routing context (DatabaseRouter
1541        // foundation). When a resolver is registered, wrap the whole
1542        // downstream future in `route_context::scope`. Installed OUTSIDE the
1543        // middleware stack above so the task-local is established before any
1544        // middleware or handler runs — every `.await` in the request,
1545        // including ORM calls that read `route_context::current()`, then sees
1546        // the resolved context. A `from_fn` layer is the only mechanism that
1547        // can wrap `next.run(req)` in a scope; the `Middleware` contract's
1548        // `before_request(req) -> req` cannot.
1549        if let Some(resolver) = self.route_context_resolver.take() {
1550            router = router.layer(axum::middleware::from_fn_with_state(
1551                resolver,
1552                route_context_scope_layer,
1553            ));
1554        }
1555
1556        // Phase 5.7 — wrap with the panic-catch layer. Comes AFTER the
1557        // fallback wiring so a panicking fallback handler is also caught
1558        // (the panic-catch layer wraps the entire router).
1559        //
1560        // Always installed when: a user-supplied server_error_template is
1561        // set, OR default pages are enabled (the embedded default_500 fires
1562        // in that case), OR an on_server_error hook is registered.
1563        let need_panic_layer = self.server_error_template.is_some()
1564            || self.default_error_pages
1565            || self.server_error_hook.is_some();
1566        if need_panic_layer {
1567            let handler = crate::errors::server_error_panic_handler(
1568                self.server_error_template.clone(),
1569                self.server_error_hook.clone(),
1570            );
1571            router = router.layer(tower_http::catch_panic::CatchPanicLayer::custom(handler));
1572
1573            // Phase 5.8 — wrap with the response-rendering middleware so
1574            // any 500 produced by a handler (not just a panic) gets
1575            // re-rendered through the configured 500 template. The
1576            // middleware checks Content-Type: HTML responses (from the
1577            // panic handler above, or from a handler that rendered its
1578            // own template) pass through; plain-text 500s get re-rendered.
1579            // Also fires `on_server_error` for handler-Err paths.
1580            let render_state = crate::errors::Render500State {
1581                template: self.server_error_template.clone(),
1582                hook: self.server_error_hook.clone(),
1583            };
1584            router = router.layer(axum::middleware::from_fn_with_state(
1585                render_state,
1586                crate::errors::render_500_middleware,
1587            ));
1588        }
1589
1590        // General custom error pages: style any registered status code
1591        // (429/403/410/…) the way the 500 path does, for handler-Err
1592        // responses — rendering each through its template while preserving the
1593        // status. Already-HTML and unregistered statuses pass through; this is
1594        // independent of the 500 layer above (different status codes).
1595        if !self.error_templates.is_empty() {
1596            let state = crate::errors::RenderErrorState {
1597                templates: std::sync::Arc::new(std::mem::take(&mut self.error_templates)),
1598            };
1599            router = router.layer(axum::middleware::from_fn_with_state(
1600                state,
1601                crate::errors::render_error_middleware,
1602            ));
1603        }
1604
1605        // Optional response compression (gzip / brotli), opt-in via
1606        // `AppBuilder::compression`. tower-http chooses the algorithm from
1607        // `Accept-Encoding` and skips already-encoded / non-compressible
1608        // bodies. Applied here so it wraps handler responses; CORS + host
1609        // checks layer outside it.
1610        if self.compress {
1611            router = router.layer(tower_http::compression::CompressionLayer::new());
1612        }
1613
1614        // Phase 5.9 — CORS, applied last so it's the outermost
1615        // wrapper. Preflight `OPTIONS` is answered before any
1616        // plugin/handler sees the request; response headers are
1617        // added on the way back out regardless of which downstream
1618        // layer produced the body.
1619        if let Some(cors) = self.cors.take() {
1620            router = router.layer(cors.into_layer());
1621        }
1622        // Path-scoped CORS (e.g. `/api`) — layered after the global one so each
1623        // only touches responses for requests under its prefix.
1624        for (prefix, config) in std::mem::take(&mut self.cors_scoped) {
1625            router = router.layer(crate::cors::ScopedCorsLayer::new(
1626                prefix,
1627                config.into_layer(),
1628            ));
1629        }
1630
1631        // Phase 5.95 — Host-header validation (allowed-hosts allowlist). Applied
1632        // outermost so a forged `Host` is rejected with a 400 before any
1633        // handler, plugin, or CORS logic runs. Enforced only in
1634        // `Environment::Prod`; dev passes through. Allowlist is
1635        // `settings.allowed_hosts` (`"*"` disables; `.example.com` = subdomain).
1636        let host_policy = crate::hosts::HostPolicy::new(
1637            &settings.allowed_hosts,
1638            matches!(settings.environment, crate::settings::Environment::Prod),
1639        );
1640        router = router.layer(axum::middleware::from_fn_with_state(
1641            host_policy,
1642            crate::hosts::host_guard,
1643        ));
1644
1645        // Request hardening (audit_2 core-web H11) — a framework-wide body-size
1646        // cap and a per-request timeout, both safe-by-default and opt-out-able
1647        // via `AppBuilder::max_request_body` / `request_timeout`. Layered
1648        // outermost (just under the trace span) so they bound EVERY request —
1649        // including host-guard rejections — before an inner extractor or the
1650        // multipart parser can buffer an oversized body or a hung handler can
1651        // pin a task. `RequestBodyLimitLayer` returns 413; `TimeoutLayer`
1652        // returns 408.
1653        if let Some(limit) = self.max_request_body_bytes {
1654            router = router.layer(tower_http::limit::RequestBodyLimitLayer::new(limit));
1655        }
1656        if let Some(timeout) = self.request_timeout {
1657            router = router.layer(tower_http::timeout::TimeoutLayer::with_status_code(
1658                axum::http::StatusCode::REQUEST_TIMEOUT,
1659                timeout,
1660            ));
1661        }
1662
1663        // audit_2 H10 — minimal hardening response headers from core, so a
1664        // default app that forgot SecurityPlugin is still not clickjackable /
1665        // MIME-sniffable. Set ONLY if absent (SecurityPlugin's configured
1666        // values win), and applied outer to the host/limit/timeout layers so
1667        // their 4xx responses carry them too. HSTS is deliberately NOT set here
1668        // — it's sticky and subdomain-scoped, so it stays SecurityPlugin's
1669        // configurable responsibility.
1670        if self.default_security_headers {
1671            router = router.layer(axum::middleware::from_fn(default_security_headers_layer));
1672        }
1673
1674        // Phase 5.99 — request tracing span. Applied outermost so every request
1675        // (including host-guard rejections) runs inside a span. The span
1676        // carries `http.method`, `http.route`/`uri`, and the response
1677        // `http.status_code`; this is what an OpenTelemetry layer (installed by
1678        // an app via `umbral_logs::observability::init`) exports as one span per
1679        // request. Without an OTel layer attached it's a cheap `tracing` span
1680        // that the fmt subscriber can surface under `RUST_LOG=tower_http=debug`.
1681        // W3C `traceparent` propagation (extracting an upstream trace context
1682        // from the inbound header) is a noted follow-up; this layer creates the
1683        // local request span.
1684        router = router.layer(
1685            tower_http::trace::TraceLayer::new_for_http().make_span_with(
1686                |request: &axum::http::Request<axum::body::Body>| {
1687                    tracing::info_span!(
1688                        "http.request",
1689                        http.method = %request.method(),
1690                        http.route = %request.uri().path(),
1691                        http.status_code = tracing::field::Empty,
1692                    )
1693                },
1694            ),
1695        );
1696
1697        // Phase 6 — `on_ready` USED to fire here. It doesn't any more: the hooks
1698        // seed content and backfill rows, and `build()` runs before the CLI has
1699        // parsed argv, so `migrate` against a fresh database ran every seed
1700        // before a single table existed (gaps3 #41). The caller now decides when
1701        // the app is ready; see [`App::ready`], which `serve()` and
1702        // `umbral_cli::dispatch` call at the right moment.
1703        Ok(App {
1704            router,
1705            plugins: sorted_plugins,
1706            auto_migrate_on_serve: self.auto_migrate_on_serve,
1707            drain_delay: self.drain_delay,
1708            ready_fired: std::sync::atomic::AtomicBool::new(false),
1709        })
1710    }
1711}
1712
1713/// The axum middleware fn installed by [`AppBuilder::route_context`]: run the
1714/// resolver against the incoming request to build a [`crate::db::RouteContext`],
1715/// then drive the ENTIRE downstream future inside
1716/// [`crate::db::route_context::scope`]. Scoping `next.run(req)` (rather than
1717/// just a prefix of it) is what keeps the task-local alive across every
1718/// `.await` the handler performs, so ambient ORM calls route per the resolved
1719/// context.
1720async fn route_context_scope_layer(
1721    axum::extract::State(resolver): axum::extract::State<RouteContextResolver>,
1722    req: crate::web::Request,
1723    next: axum::middleware::Next,
1724) -> crate::web::Response {
1725    let ctx = resolver(&req);
1726    crate::db::route_context::scope(ctx, next.run(req)).await
1727}
1728
1729/// Resolve when the process receives a shutdown signal — `SIGTERM` (the deploy
1730/// / container-stop signal) or `SIGINT` (Ctrl-C). Drives `serve`'s graceful
1731/// shutdown (audit_2 core-app-config #13). On non-Unix only Ctrl-C is wired.
1732async fn shutdown_signal() {
1733    let ctrl_c = async {
1734        let _ = tokio::signal::ctrl_c().await;
1735    };
1736
1737    #[cfg(unix)]
1738    let terminate = async {
1739        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
1740            Ok(mut sig) => {
1741                sig.recv().await;
1742            }
1743            // If the handler can't be installed, never fire this arm.
1744            Err(_) => std::future::pending::<()>().await,
1745        }
1746    };
1747    #[cfg(not(unix))]
1748    let terminate = std::future::pending::<()>();
1749
1750    tokio::select! {
1751        _ = ctrl_c => {}
1752        _ = terminate => {}
1753    }
1754    tracing::info!("umbral: shutdown signal received; finishing in-flight requests");
1755}
1756
1757/// The future `serve` hands to axum's `with_graceful_shutdown` (Kikosi #5).
1758///
1759/// Awaits `signal`, marks the process draining so readiness probes go 503, then
1760/// — if `drain_delay` is non-zero — keeps serving for that long before resolving,
1761/// which is when axum stops accepting new connections. The delay is the window a
1762/// load balancer uses to observe the 503 and route new traffic elsewhere.
1763///
1764/// Generic over the signal future so the drain sequencing is testable without
1765/// actually delivering a `SIGTERM`.
1766async fn drain_after<F: std::future::Future>(signal: F, drain_delay: std::time::Duration) {
1767    signal.await;
1768    crate::shutdown::begin_drain();
1769    if !drain_delay.is_zero() {
1770        tracing::info!(
1771            drain_secs = drain_delay.as_secs_f64(),
1772            "umbral: draining — readiness now reports 503; holding before stop"
1773        );
1774        tokio::time::sleep(drain_delay).await;
1775    }
1776}
1777
1778/// audit_2 H19 — warn about the app's own mutating routes that carry no
1779/// recorded permission. A default-DENY router is a future-major change; this
1780/// boot Warning is the non-breaking default: it makes a forgotten
1781/// authorization gate visible at boot instead of shipping as an open endpoint.
1782/// [`AppBuilder::deny_ungated_mutations`] promotes the same finding to a hard
1783/// [`BuildError::UngatedMutatingRoutes`] for apps that want it enforced.
1784///
1785/// Scope + honesty: only routes registered through `Routes` (the app's
1786/// `.routes(...)`) are checked — plugin routes gate via their own conventions.
1787/// A route gated by a hand-applied `.layer(permission_required(...))` is opaque
1788/// to `RouteSpec`, so it can't be distinguished from an ungated one; the
1789/// warning says so and points at the `require_permission(...)` builder (which
1790/// records the permission). An intentionally-public route is a false positive
1791/// the operator ignores.
1792fn warn_ungated_mutating_routes(ungated: &[String]) {
1793    tracing::warn!(
1794        "audit_2 H19: {} app mutating route(s) have no recorded permission: [{}]. \
1795         Gate them with the umbral-permissions `Routes::require_permission(...)` builder \
1796         so the framework records the permission (a hand-applied \
1797         `.layer(permission_required(...))` is NOT visible to this audit — prefer the \
1798         builder). If a route is intentionally public, ignore this. To make this a hard \
1799         boot error instead, call `App::builder().deny_ungated_mutations()`.",
1800        ungated.len(),
1801        ungated.join(", ")
1802    );
1803}
1804
1805/// The pure core of the H19 audit: the `"METHOD /path"`
1806/// labels of every route with a mutating method and no recorded permission.
1807/// Split out so the audit's selection logic is unit-testable without a live
1808/// `App::build()` / tracing subscriber.
1809fn ungated_mutating_routes(specs: &[crate::routes::RouteSpec]) -> Vec<String> {
1810    const MUTATING: [&str; 4] = ["POST", "PUT", "PATCH", "DELETE"];
1811    specs
1812        .iter()
1813        .filter(|s| s.permission.is_none() && s.methods.iter().any(|m| MUTATING.contains(m)))
1814        .map(|s| format!("{} {}", s.methods.join("/"), s.path))
1815        .collect()
1816}
1817
1818/// Set minimal hardening response headers, each ONLY if the response doesn't
1819/// already carry it — so `SecurityPlugin` (or a handler) can override, and no
1820/// header is ever duplicated (audit_2 H10).
1821async fn default_security_headers_layer(
1822    req: crate::web::Request,
1823    next: axum::middleware::Next,
1824) -> crate::web::Response {
1825    use axum::http::HeaderValue;
1826    use axum::http::header::{
1827        HeaderName, REFERRER_POLICY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
1828    };
1829
1830    let mut resp = next.run(req).await;
1831    let headers = resp.headers_mut();
1832    let mut set_if_absent = |name: HeaderName, value: &'static str| {
1833        if !headers.contains_key(&name) {
1834            headers.insert(name, HeaderValue::from_static(value));
1835        }
1836    };
1837    set_if_absent(X_CONTENT_TYPE_OPTIONS, "nosniff");
1838    set_if_absent(X_FRAME_OPTIONS, "DENY");
1839    set_if_absent(REFERRER_POLICY, "strict-origin-when-cross-origin");
1840    resp
1841}
1842
1843/// One cross-plugin foreign-key edge derived from the model registry.
1844///
1845/// `plugin`'s table `table` carries a physical `REFERENCES "<fk_target>"`, and
1846/// `fk_target` is owned by `depends_on`. Reported on
1847/// [`BuildError::ForeignKeyCycle`] so the operator sees the *column* that
1848/// forced the ordering, not just two plugin names.
1849#[derive(Debug, Clone, PartialEq, Eq)]
1850pub struct FkEdge {
1851    /// The plugin whose table holds the foreign key.
1852    pub plugin: &'static str,
1853    /// The plugin that owns the referenced table, and so must migrate first.
1854    pub depends_on: &'static str,
1855    /// The referencing table (`Model::TABLE` of the model holding the FK).
1856    pub table: String,
1857    /// The referenced table (`fk_target` of the FK column).
1858    pub fk_target: String,
1859}
1860
1861/// Derive the cross-plugin ordering edges the *schema already states*.
1862///
1863/// `Plugin::dependencies()` is the edge set an author declares by hand. This is
1864/// the edge set the models spell out: a `ForeignKey<T>` field renders
1865/// `REFERENCES "<T::TABLE>"` inside `CREATE TABLE`, so the plugin owning
1866/// `T::TABLE` must create it first. Postgres enforces the target's existence at
1867/// `CREATE TABLE` time; SQLite silently accepts the dangling reference, which is
1868/// why omitting the edge only ever failed on a fresh Postgres (gaps3 #40).
1869///
1870/// Two exclusions, both deliberate:
1871///
1872/// - **Same-plugin FKs** impose no *plugin* ordering. Column order inside one
1873///   plugin's migration is the diff engine's problem, not the sort's.
1874/// - **`#[umbral(db_constraint = false)]`** renders no `REFERENCES` clause (the
1875///   only valid shape for a cross-database FK, gaps2 #22), so it creates no DDL
1876///   ordering obligation.
1877///
1878/// An FK whose target table belongs to no registered plugin — an app-owned model
1879/// from `.model::<T>()` — yields no edge: the implicit `"app"` plugin is pinned
1880/// last by `App::build()` precisely because app models FK *into* plugin tables.
1881fn fk_plugin_edges(plugins: &[Box<dyn Plugin>]) -> Vec<FkEdge> {
1882    use std::collections::BTreeMap;
1883
1884    // table -> owning plugin. Built across every registered plugin first so a
1885    // forward reference (a plugin FK-ing a table owned by a plugin registered
1886    // later) still resolves.
1887    let mut owner_of_table: BTreeMap<String, &'static str> = BTreeMap::new();
1888    for plugin in plugins {
1889        for model in plugin.models() {
1890            owner_of_table.insert(model.table, plugin.name());
1891        }
1892    }
1893
1894    let mut edges: Vec<FkEdge> = Vec::new();
1895    for plugin in plugins {
1896        let name = plugin.name();
1897        for model in plugin.models() {
1898            for column in &model.fields {
1899                let Some(target) = column.fk_target.as_deref() else {
1900                    continue;
1901                };
1902                if !column.db_constraint {
1903                    continue;
1904                }
1905                let Some(&owner) = owner_of_table.get(target) else {
1906                    continue;
1907                };
1908                if owner == name {
1909                    continue;
1910                }
1911                edges.push(FkEdge {
1912                    plugin: name,
1913                    depends_on: owner,
1914                    table: model.table.clone(),
1915                    fk_target: target.to_string(),
1916                });
1917            }
1918        }
1919    }
1920    edges
1921}
1922
1923/// Kahn's algorithm over `deps` (plugin -> the set it waits on), with a
1924/// name-sorted ready queue so ties resolve deterministically. Returns the
1925/// topological order, or the still-unsorted names when the graph has a cycle.
1926fn toposort(
1927    mut remaining_deps: std::collections::BTreeMap<
1928        &'static str,
1929        std::collections::BTreeSet<&'static str>,
1930    >,
1931) -> Result<Vec<&'static str>, Vec<&'static str>> {
1932    use std::collections::BTreeSet;
1933
1934    let mut ready: BTreeSet<&'static str> = remaining_deps
1935        .iter()
1936        .filter_map(|(name, deps)| if deps.is_empty() { Some(*name) } else { None })
1937        .collect();
1938
1939    let mut order: Vec<&'static str> = Vec::with_capacity(remaining_deps.len());
1940    while let Some(name) = ready.iter().next().copied() {
1941        ready.remove(&name);
1942        remaining_deps.remove(&name);
1943        order.push(name);
1944        for (other_name, deps) in remaining_deps.iter_mut() {
1945            if deps.remove(&name) && deps.is_empty() {
1946                ready.insert(*other_name);
1947            }
1948        }
1949    }
1950
1951    if remaining_deps.is_empty() {
1952        Ok(order)
1953    } else {
1954        Err(remaining_deps.keys().copied().collect())
1955    }
1956}
1957
1958/// Validate the registered plugins and return them in a stable
1959/// topological order. Standard Kahn's algorithm with a name-sorted ready queue
1960/// so ties resolve deterministically.
1961///
1962/// The edge set is the union of two sources:
1963///
1964/// 1. `Plugin::dependencies()` — what the author declared.
1965/// 2. Cross-plugin foreign keys read off the models ([`fk_plugin_edges`]) — what
1966///    the schema already states.
1967///
1968/// Before gaps3 #40 only (1) fed the sort, so an app where *no* plugin declared
1969/// a dependency had every plugin at in-degree 0 and the "topological" order
1970/// collapsed to alphabetical. `"accounts"` sorts before `"auth"`, and its
1971/// `CREATE TABLE ... REFERENCES "auth_user"` ran against a database with no
1972/// `auth_user`. Declaring your own dependencies is still the plugin author's job;
1973/// the framework just no longer lets the omission reach production silently.
1974///
1975/// Rejects:
1976///
1977/// - A plugin claiming the reserved `"app"` name.
1978/// - Two plugins reporting the same `name()`.
1979/// - A `dependencies()` entry that doesn't name a registered plugin.
1980/// - A declared dependency cycle (`BuildError::PluginCycle`).
1981/// - A cycle introduced by the foreign keys themselves
1982///   (`BuildError::ForeignKeyCycle`, which names the offending columns).
1983fn sort_plugins(plugins: Vec<Box<dyn Plugin>>) -> Result<Vec<Box<dyn Plugin>>, BuildError> {
1984    use std::collections::{BTreeMap, BTreeSet};
1985
1986    // Reserved + duplicate-name checks. The implicit `"app"` plugin is
1987    // not counted toward duplicates; only the user's plugin list is.
1988    let mut seen: BTreeSet<&'static str> = BTreeSet::new();
1989    for plugin in &plugins {
1990        let name = plugin.name();
1991        if name == crate::migrate::APP_PLUGIN_NAME {
1992            return Err(BuildError::ReservedPluginName);
1993        }
1994        if !seen.insert(name) {
1995            return Err(BuildError::DuplicatePluginName { name });
1996        }
1997    }
1998
1999    // Index plugins by name for the dependency lookups + the
2000    // sort-by-name traversal below. We pull the boxes out of the
2001    // input vec by index later, so the index table stays alongside.
2002    let by_name: BTreeMap<&'static str, usize> = plugins
2003        .iter()
2004        .enumerate()
2005        .map(|(i, p)| (p.name(), i))
2006        .collect();
2007
2008    // Dependency-exists check. Done before the toposort so a missing
2009    // dep surfaces with the asking plugin's name attached, not as a
2010    // cycle false-positive.
2011    for plugin in &plugins {
2012        for dep in plugin.dependencies() {
2013            if !by_name.contains_key(dep) {
2014                return Err(BuildError::DependencyNotFound {
2015                    plugin: plugin.name(),
2016                    missing: dep,
2017                });
2018            }
2019        }
2020    }
2021
2022    let declared: BTreeMap<&'static str, BTreeSet<&'static str>> = plugins
2023        .iter()
2024        .map(|p| (p.name(), p.dependencies().iter().copied().collect()))
2025        .collect();
2026
2027    let fk_edges = fk_plugin_edges(&plugins);
2028    let mut combined = declared.clone();
2029    for edge in &fk_edges {
2030        combined
2031            .get_mut(edge.plugin)
2032            .expect("every FK edge names a registered plugin")
2033            .insert(edge.depends_on);
2034    }
2035
2036    let order = match toposort(combined) {
2037        Ok(order) => order,
2038        Err(stuck) => {
2039            // The combined graph cycles. Re-run on the declared edges alone to
2040            // find out who is to blame. If the declared graph is acyclic, the
2041            // foreign keys introduced the cycle — report the columns that did
2042            // it rather than a bare `PluginCycle` the author never wrote.
2043            //
2044            // Across crates this is unreachable: `ForeignKey<T>` needs `T` in
2045            // scope, so mutually-referencing plugin crates would be a circular
2046            // Cargo dependency. Two plugins defined in ONE crate can still do
2047            // it, and a cross-plugin FK cycle has no valid `CREATE TABLE` order
2048            // on a fresh database either way.
2049            if toposort(declared).is_ok() {
2050                let stuck: BTreeSet<&'static str> = stuck.into_iter().collect();
2051                let edges: Vec<FkEdge> = fk_edges
2052                    .into_iter()
2053                    .filter(|e| stuck.contains(e.plugin) && stuck.contains(e.depends_on))
2054                    .collect();
2055                return Err(BuildError::ForeignKeyCycle { edges });
2056            }
2057            return Err(BuildError::PluginCycle { names: stuck });
2058        }
2059    };
2060
2061    // Reorder the owned boxes into topological order. We pull each
2062    // plugin out of an `Option` slot so the move is statically
2063    // tracked; every slot is taken exactly once because the toposort
2064    // produced one entry per plugin.
2065    let mut slots: Vec<Option<Box<dyn Plugin>>> = plugins.into_iter().map(Some).collect();
2066    let mut sorted: Vec<Box<dyn Plugin>> = Vec::with_capacity(order.len());
2067    for name in order {
2068        let idx = by_name[&name];
2069        sorted.push(
2070            slots[idx]
2071                .take()
2072                .expect("toposort produced one entry per plugin"),
2073        );
2074    }
2075    Ok(sorted)
2076}
2077
2078/// Errors that can occur during `AppBuilder::build()`.
2079#[derive(Debug)]
2080pub enum BuildError {
2081    /// `.settings(Settings)` wasn't called on the builder.
2082    SettingsMissing,
2083    /// `.database("default", pool)` wasn't called on the builder.
2084    DefaultPoolMissing,
2085    /// The URL scheme in `settings.database_url` doesn't match any
2086    /// shipped backend.
2087    BackendDetect(crate::backend::BackendDetectError),
2088    /// One or more system checks failed with `Severity::Error`. The
2089    /// full list of findings is in the variant.
2090    SystemCheckFailed {
2091        findings: Vec<crate::check::SystemCheckFinding>,
2092    },
2093    /// A plugin's `dependencies()` lists a plugin that was never
2094    /// registered with `.plugin(...)`. Carries the unmet name plus
2095    /// the plugin that asked for it.
2096    DependencyNotFound {
2097        plugin: &'static str,
2098        missing: &'static str,
2099    },
2100    /// The dependency graph has a cycle. Carries the plugin names that
2101    /// form it (in any cyclic order; the diagnostic is "these N plugins
2102    /// reference each other").
2103    PluginCycle { names: Vec<&'static str> },
2104    /// The plugins' *foreign keys* form a cycle, so no `CREATE TABLE` order
2105    /// satisfies every `REFERENCES` clause on a fresh database. Distinct from
2106    /// [`BuildError::PluginCycle`], which reports a cycle the author declared
2107    /// via `dependencies()`; here nothing was declared and the cycle is implied
2108    /// by the models. Carries the FK edges that close the loop so the message
2109    /// can name the columns, not just the plugins.
2110    ForeignKeyCycle { edges: Vec<FkEdge> },
2111    /// Two registered plugins share a `name()`. Plugin names are keys
2112    /// in the migration tracking table and the dependency graph; a
2113    /// collision would break both.
2114    DuplicatePluginName { name: &'static str },
2115    /// A plugin claimed the reserved `"app"` name (used by the
2116    /// implicit plugin that owns `.model::<T>()` registrations).
2117    ReservedPluginName,
2118    /// A plugin's `on_ready` returned an error. Carries the plugin's
2119    /// name plus the underlying error.
2120    PluginOnReady {
2121        plugin: &'static str,
2122        source: Box<dyn std::error::Error + Send + Sync>,
2123    },
2124    /// The templates engine failed to initialise. Carries the
2125    /// underlying `TemplateError` (an IO error reading a template
2126    /// file, or a syntax error in one of the loaded templates).
2127    TemplatesInit(crate::templates::TemplateError),
2128    /// A plugin's `database()` returned an alias that isn't in the
2129    /// registered pool set. Surfaces a typo at boot with a clear
2130    /// "register the pool first" diagnostic instead of letting
2131    /// `db::pool_for` panic at first query.
2132    PluginDatabaseAlias {
2133        plugin: &'static str,
2134        alias: &'static str,
2135    },
2136    /// A `settings.databases` entry could not be opened as a lazy pool at boot
2137    /// (audit_2 H17) — e.g. an unsupported URL scheme. Carries the alias and the
2138    /// sqlx error.
2139    SettingsDatabasePool { alias: String, error: sqlx::Error },
2140    /// The URL-derived backend (from `settings.database_url`) doesn't
2141    /// match the runtime type of the default pool passed to
2142    /// `.database("default", ...)`. Catches the case where the URL
2143    /// says `postgres://` but a `SqlitePool` was registered, or vice
2144    /// versa.
2145    DatabaseBackendMismatch {
2146        url_backend: &'static str,
2147        pool_backend: &'static str,
2148    },
2149    /// A foreign key targets a model on a different database than the
2150    /// model that declares it, and the field has NOT opted out of the
2151    /// physical constraint. A `REFERENCES` clause can't span databases,
2152    /// so this would emit invalid DDL. Fix by either routing both
2153    /// models to the same database, or marking the FK
2154    /// `#[umbral(db_constraint = false)]` to keep it a logical-only
2155    /// relation. Closes gaps2 #22.
2156    CrossDatabaseForeignKey {
2157        model: &'static str,
2158        field: &'static str,
2159        model_db: &'static str,
2160        target_db: &'static str,
2161    },
2162    /// Two plugins declared the same static namespace via
2163    /// `Plugin::static_dirs()`. Namespaces are the per-plugin URL/disk
2164    /// segment under `static_url` / `static_root`; a collision would
2165    /// silently shadow one plugin's assets with another's, so the build
2166    /// fails loudly and names both plugins.
2167    DuplicateStaticNamespace {
2168        namespace: &'static str,
2169        first_plugin: &'static str,
2170        second_plugin: &'static str,
2171    },
2172    /// `.deny_ungated_mutations()` was set and one or more app-level mutating
2173    /// routes (POST/PUT/PATCH/DELETE registered via `.routes(...)`) carry no
2174    /// recorded permission (gaps3 #28 P1, enforcing the audit_2 H19 audit).
2175    /// Carries the `"METHOD /path"` label of each offending route. Fix by gating
2176    /// them with the umbral-permissions `Routes::require_permission(...)` builder
2177    /// (which records the permission), or drop the strict flag if a route is
2178    /// intentionally public.
2179    UngatedMutatingRoutes { routes: Vec<String> },
2180}
2181
2182impl std::fmt::Display for BuildError {
2183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2184        match self {
2185            BuildError::SettingsMissing => write!(
2186                f,
2187                "umbral: App::builder() requires Settings; call .settings(Settings::from_env()?) before .build()"
2188            ),
2189            BuildError::BackendDetect(err) => write!(f, "{err}"),
2190            BuildError::SystemCheckFailed { findings } => {
2191                writeln!(f, "umbral: {} system check(s) failed:", findings.len())?;
2192                for finding in findings {
2193                    write!(f, "  - [{}] {}", finding.check_id, finding.message)?;
2194                    if let Some(hint) = &finding.hint {
2195                        write!(f, " (hint: {hint})")?;
2196                    }
2197                    writeln!(f)?;
2198                }
2199                Ok(())
2200            }
2201            BuildError::DefaultPoolMissing => write!(
2202                f,
2203                "umbral: App::builder() requires a default DB pool; call .database(\"default\", umbral::db::connect(&url).await?) before .build()"
2204            ),
2205            BuildError::DependencyNotFound { plugin, missing } => write!(
2206                f,
2207                "umbral: plugin `{plugin}` depends on `{missing}`, which isn't registered; \
2208                 call .plugin({missing}::default()) on the builder"
2209            ),
2210            BuildError::PluginCycle { names } => {
2211                write!(f, "umbral: plugin dependency cycle: {}", names.join(" -> "))
2212            }
2213            BuildError::ForeignKeyCycle { edges } => {
2214                writeln!(
2215                    f,
2216                    "umbral: the plugins' foreign keys form a cycle, so no CREATE TABLE order \
2217                     satisfies every REFERENCES clause on a fresh database:"
2218                )?;
2219                for edge in edges {
2220                    writeln!(
2221                        f,
2222                        "  `{}`.\"{}\" REFERENCES \"{}\", owned by `{}`",
2223                        edge.plugin, edge.table, edge.fk_target, edge.depends_on
2224                    )?;
2225                }
2226                write!(
2227                    f,
2228                    "break the cycle by making one side a nullable FK added in a later \
2229                     migration, or by opting that column out of the physical constraint with \
2230                     #[umbral(db_constraint = false)]"
2231                )
2232            }
2233            BuildError::DuplicatePluginName { name } => write!(
2234                f,
2235                "umbral: two plugins both report name `{name}`; plugin names are unique keys \
2236                 (migration tracking, dependency graph)"
2237            ),
2238            BuildError::SettingsDatabasePool { alias, error } => write!(
2239                f,
2240                "umbral: could not open the `settings.databases` pool for alias `{alias}`: \
2241                 {error}"
2242            ),
2243            BuildError::ReservedPluginName => write!(
2244                f,
2245                "umbral: the plugin name `app` is reserved for models registered via \
2246                 .model::<T>(); pick a different name"
2247            ),
2248            BuildError::PluginOnReady { plugin, source } => {
2249                write!(f, "umbral: plugin `{plugin}`'s on_ready failed: {source}")
2250            }
2251            BuildError::TemplatesInit(err) => {
2252                write!(f, "umbral: templates engine failed to initialise: {err}")
2253            }
2254            BuildError::PluginDatabaseAlias { plugin, alias } => write!(
2255                f,
2256                "umbral: plugin `{plugin}` requested database alias `{alias}`, which isn't \
2257                 registered; call .database(\"{alias}\", pool) on the builder before .build()"
2258            ),
2259            BuildError::CrossDatabaseForeignKey {
2260                model,
2261                field,
2262                model_db,
2263                target_db,
2264            } => write!(
2265                f,
2266                "umbral: model `{model}` (database `{model_db}`) has a foreign key \
2267                 `{field}` to a model on database `{target_db}`. A FOREIGN KEY \
2268                 constraint can't span databases. Either route both models to the \
2269                 same database, or mark the field `#[umbral(db_constraint = false)]` \
2270                 to keep it a logical-only relation (joins / select_related still \
2271                 work; no physical constraint is emitted)."
2272            ),
2273            BuildError::DatabaseBackendMismatch {
2274                url_backend,
2275                pool_backend,
2276            } => write!(
2277                f,
2278                "umbral: settings.database_url names backend `{url_backend}`, but the \
2279                 default pool passed to .database(...) is a `{pool_backend}` pool. \
2280                 Either change UMBRAL_DATABASE_URL to match the pool, or open the pool \
2281                 against a URL whose scheme matches umbral::db::connect."
2282            ),
2283            BuildError::DuplicateStaticNamespace {
2284                namespace,
2285                first_plugin,
2286                second_plugin,
2287            } => write!(
2288                f,
2289                "umbral: plugins `{first_plugin}` and `{second_plugin}` both declare the static \
2290                 namespace `{namespace}` via static_dirs(); namespaces must be unique \
2291                 (they key the /static/<namespace>/ URL and the static_root/<namespace>/ \
2292                 collected-asset dir). Rename one plugin's namespace."
2293            ),
2294            BuildError::UngatedMutatingRoutes { routes } => write!(
2295                f,
2296                "umbral: deny_ungated_mutations() is set and {} app mutating route(s) have no \
2297                 recorded permission: [{}]. Gate each with the umbral-permissions \
2298                 `Routes::require_permission(...)` builder so the framework records the \
2299                 permission (a hand-applied `.layer(permission_required(...))` is NOT visible \
2300                 to this audit — prefer the builder). If a route is intentionally public, \
2301                 register it through a permission-aware builder or drop the strict flag.",
2302                routes.len(),
2303                routes.join(", ")
2304            ),
2305        }
2306    }
2307}
2308
2309impl std::error::Error for BuildError {}
2310
2311#[cfg(test)]
2312mod audit_tests {
2313    use super::ungated_mutating_routes;
2314    use crate::routes::RouteSpec;
2315
2316    fn spec(methods: Vec<&'static str>, path: &str, perm: Option<&str>) -> RouteSpec {
2317        RouteSpec {
2318            path: path.to_string(),
2319            methods,
2320            permission: perm.map(str::to_string),
2321        }
2322    }
2323
2324    #[test]
2325    fn flags_ungated_mutating_routes_only() {
2326        let specs = vec![
2327            spec(vec!["GET"], "/", None),                     // read → ignored
2328            spec(vec!["POST"], "/contact", None),             // ungated mutating → flagged
2329            spec(vec!["POST"], "/posts", Some("blog.add")),   // gated → ignored
2330            spec(vec!["DELETE"], "/posts/{id}", None),        // ungated mutating → flagged
2331            spec(vec!["GET", "POST"], "/api/comments", None), // has a mutating verb → flagged
2332        ];
2333        let flagged = ungated_mutating_routes(&specs);
2334        assert_eq!(
2335            flagged,
2336            vec![
2337                "POST /contact".to_string(),
2338                "DELETE /posts/{id}".to_string(),
2339                "GET/POST /api/comments".to_string(),
2340            ]
2341        );
2342    }
2343
2344    #[test]
2345    fn no_warning_when_all_mutating_routes_are_gated_or_read_only() {
2346        let specs = vec![
2347            spec(vec!["GET"], "/", None),
2348            spec(vec!["POST"], "/posts", Some("blog.add")),
2349        ];
2350        assert!(ungated_mutating_routes(&specs).is_empty());
2351    }
2352}
2353
2354#[cfg(test)]
2355mod drain_tests {
2356    use super::drain_after;
2357    use std::time::{Duration, Instant};
2358
2359    /// `drain_after` awaits its signal, flips the process to draining, then holds
2360    /// for the delay before resolving — the sequence that lets `/readyz` report
2361    /// 503 while the server keeps accepting during the drain window (Kikosi #5).
2362    ///
2363    /// One test, walked in sequence: the draining flag is a process-global that
2364    /// `begin_drain` only ever sets, so splitting the zero-delay and with-delay
2365    /// cases into separate concurrent tests would race on it. A ready signal
2366    /// (`async {}`) exercises the drain logic without delivering a real SIGTERM.
2367    #[tokio::test]
2368    async fn signals_draining_and_holds_for_the_delay() {
2369        assert!(
2370            !crate::shutdown::is_draining(),
2371            "draining must start false — nothing has signalled shutdown yet",
2372        );
2373
2374        // Zero delay: marks draining, does not sleep (the historical
2375        // instant-shutdown behaviour).
2376        let started = Instant::now();
2377        drain_after(async {}, Duration::ZERO).await;
2378        assert!(
2379            crate::shutdown::is_draining(),
2380            "the signal must mark the process draining so /readyz goes 503",
2381        );
2382        assert!(
2383            started.elapsed() < Duration::from_millis(50),
2384            "zero delay must not sleep; took {:?}",
2385            started.elapsed(),
2386        );
2387
2388        // A non-zero delay holds before resolving, even though the process is
2389        // already draining (begin_drain is idempotent; the hold still applies).
2390        let started = Instant::now();
2391        drain_after(async {}, Duration::from_millis(120)).await;
2392        assert!(
2393            started.elapsed() >= Duration::from_millis(100),
2394            "must hold for ~the drain delay before resolving; held {:?}",
2395            started.elapsed(),
2396        );
2397    }
2398}