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