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