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 let need_not_found_fallback = self.not_found_template.is_some() || self.default_error_pages;
1723 if need_not_found_fallback || self.slash_redirect != crate::slash::SlashRedirect::Off {
1724 let fallback = crate::errors::not_found_fallback(self.not_found_template.clone());
1725 router = router.fallback(fallback);
1726 }
1727 if self.slash_redirect != crate::slash::SlashRedirect::Off {
1728 let snapshot = router.clone();
1729 router = router.layer(axum::middleware::from_fn(
1730 crate::slash::slash_redirect_probe(snapshot, self.slash_redirect),
1731 ));
1732 }
1733
1734 // Phase 5.65 — framework middleware stack (feature #68). App-level
1735 // middleware first, then every plugin's contribution in topological
1736 // order, collected into one stack and installed as a single layer.
1737 // Placed AFTER the 404 fallback so middleware sees misses too, and
1738 // BEFORE the panic / compression / CORS / host layers so those stay
1739 // the outermost wrappers (security and content-encoding run before
1740 // user middleware ever touches the request).
1741 let mut middleware_stack = crate::middleware::MiddlewareStack::new();
1742 middleware_stack.extend(std::mem::take(&mut self.middleware));
1743 for plugin in &sorted_plugins {
1744 middleware_stack.extend(plugin.middleware());
1745 }
1746 router = middleware_stack.apply(router);
1747
1748 // Phase 5.66 — request-scoped routing context (DatabaseRouter
1749 // foundation). When a resolver is registered, wrap the whole
1750 // downstream future in `route_context::scope`. Installed OUTSIDE the
1751 // middleware stack above so the task-local is established before any
1752 // middleware or handler runs — every `.await` in the request,
1753 // including ORM calls that read `route_context::current()`, then sees
1754 // the resolved context. A `from_fn` layer is the only mechanism that
1755 // can wrap `next.run(req)` in a scope; the `Middleware` contract's
1756 // `before_request(req) -> req` cannot.
1757 if let Some(resolver) = self.route_context_resolver.take() {
1758 router = router.layer(axum::middleware::from_fn_with_state(
1759 resolver,
1760 route_context_scope_layer,
1761 ));
1762 }
1763
1764 // Phase 5.7 — wrap with the panic-catch layer. Comes AFTER the
1765 // fallback wiring so a panicking fallback handler is also caught
1766 // (the panic-catch layer wraps the entire router).
1767 //
1768 // Always installed when: a user-supplied server_error_template is
1769 // set, OR default pages are enabled (the embedded default_500 fires
1770 // in that case), OR an on_server_error hook is registered.
1771 let need_panic_layer = self.server_error_template.is_some()
1772 || self.default_error_pages
1773 || self.server_error_hook.is_some();
1774 if need_panic_layer {
1775 let handler = crate::errors::server_error_panic_handler(
1776 self.server_error_template.clone(),
1777 self.server_error_hook.clone(),
1778 );
1779 router = router.layer(tower_http::catch_panic::CatchPanicLayer::custom(handler));
1780
1781 // Phase 5.8 — wrap with the response-rendering middleware so
1782 // any 500 produced by a handler (not just a panic) gets
1783 // re-rendered through the configured 500 template. The
1784 // middleware checks Content-Type: HTML responses (from the
1785 // panic handler above, or from a handler that rendered its
1786 // own template) pass through; plain-text 500s get re-rendered.
1787 // Also fires `on_server_error` for handler-Err paths.
1788 let render_state = crate::errors::Render500State {
1789 template: self.server_error_template.clone(),
1790 hook: self.server_error_hook.clone(),
1791 };
1792 router = router.layer(axum::middleware::from_fn_with_state(
1793 render_state,
1794 crate::errors::render_500_middleware,
1795 ));
1796 }
1797
1798 // General custom error pages: style any registered status code
1799 // (429/403/410/…) the way the 500 path does, for handler-Err
1800 // responses — rendering each through its template while preserving the
1801 // status. Already-HTML and unregistered statuses pass through; this is
1802 // independent of the 500 layer above (different status codes).
1803 if !self.error_templates.is_empty() {
1804 let state = crate::errors::RenderErrorState {
1805 templates: std::sync::Arc::new(std::mem::take(&mut self.error_templates)),
1806 };
1807 router = router.layer(axum::middleware::from_fn_with_state(
1808 state,
1809 crate::errors::render_error_middleware,
1810 ));
1811 }
1812
1813 // Optional response compression (gzip / brotli), opt-in via
1814 // `AppBuilder::compression`. tower-http chooses the algorithm from
1815 // `Accept-Encoding` and skips already-encoded / non-compressible
1816 // bodies. Applied here so it wraps handler responses; CORS + host
1817 // checks layer outside it.
1818 if self.compress {
1819 router = router.layer(tower_http::compression::CompressionLayer::new());
1820 }
1821
1822 // Phase 5.9 — CORS, applied last so it's the outermost
1823 // wrapper. Preflight `OPTIONS` is answered before any
1824 // plugin/handler sees the request; response headers are
1825 // added on the way back out regardless of which downstream
1826 // layer produced the body.
1827 if let Some(cors) = self.cors.take() {
1828 router = router.layer(cors.into_layer());
1829 }
1830 // Path-scoped CORS (e.g. `/api`) — layered after the global one so each
1831 // only touches responses for requests under its prefix.
1832 for (prefix, config) in std::mem::take(&mut self.cors_scoped) {
1833 router = router.layer(crate::cors::ScopedCorsLayer::new(
1834 prefix,
1835 config.into_layer(),
1836 ));
1837 }
1838
1839 // Phase 5.95 — Host-header validation (allowed-hosts allowlist). Applied
1840 // outermost so a forged `Host` is rejected with a 400 before any
1841 // handler, plugin, or CORS logic runs. Enforced only in
1842 // `Environment::Prod`; dev passes through. Allowlist is
1843 // `settings.allowed_hosts` (`"*"` disables; `.example.com` = subdomain).
1844 let host_policy = crate::hosts::HostPolicy::new(
1845 &settings.allowed_hosts,
1846 matches!(settings.environment, crate::settings::Environment::Prod),
1847 );
1848 router = router.layer(axum::middleware::from_fn_with_state(
1849 host_policy,
1850 crate::hosts::host_guard,
1851 ));
1852
1853 // Request hardening (audit_2 core-web H11) — a framework-wide body-size
1854 // cap and a per-request timeout, both safe-by-default and opt-out-able
1855 // via `AppBuilder::max_request_body` / `request_timeout`. Layered
1856 // outermost (just under the trace span) so they bound EVERY request —
1857 // including host-guard rejections — before an inner extractor or the
1858 // multipart parser can buffer an oversized body or a hung handler can
1859 // pin a task. `RequestBodyLimitLayer` returns 413; `TimeoutLayer`
1860 // returns 408.
1861 if let Some(limit) = self.max_request_body_bytes {
1862 router = router.layer(tower_http::limit::RequestBodyLimitLayer::new(limit));
1863 }
1864 if let Some(timeout) = self.request_timeout {
1865 router = router.layer(tower_http::timeout::TimeoutLayer::with_status_code(
1866 axum::http::StatusCode::REQUEST_TIMEOUT,
1867 timeout,
1868 ));
1869 }
1870
1871 // audit_2 H10 — minimal hardening response headers from core, so a
1872 // default app that forgot SecurityPlugin is still not clickjackable /
1873 // MIME-sniffable. Set ONLY if absent (SecurityPlugin's configured
1874 // values win), and applied outer to the host/limit/timeout layers so
1875 // their 4xx responses carry them too. HSTS is deliberately NOT set here
1876 // — it's sticky and subdomain-scoped, so it stays SecurityPlugin's
1877 // configurable responsibility.
1878 if self.default_security_headers {
1879 router = router.layer(axum::middleware::from_fn(default_security_headers_layer));
1880 }
1881
1882 // Phase 5.99 — request tracing span. Applied outermost so every request
1883 // (including host-guard rejections) runs inside a span. The span
1884 // carries `http.method`, `http.route`/`uri`, and the response
1885 // `http.status_code`; this is what an OpenTelemetry layer (installed by
1886 // an app via `umbral_logs::observability::init`) exports as one span per
1887 // request. Without an OTel layer attached it's a cheap `tracing` span
1888 // that the fmt subscriber can surface under `RUST_LOG=tower_http=debug`.
1889 // W3C `traceparent` propagation (extracting an upstream trace context
1890 // from the inbound header) is a noted follow-up; this layer creates the
1891 // local request span.
1892 router = router.layer(
1893 tower_http::trace::TraceLayer::new_for_http().make_span_with(
1894 |request: &axum::http::Request<axum::body::Body>| {
1895 tracing::info_span!(
1896 "http.request",
1897 http.method = %request.method(),
1898 http.route = %request.uri().path(),
1899 http.status_code = tracing::field::Empty,
1900 )
1901 },
1902 ),
1903 );
1904
1905 // Phase 6 — `on_ready` USED to fire here. It doesn't any more: the hooks
1906 // seed content and backfill rows, and `build()` runs before the CLI has
1907 // parsed argv, so `migrate` against a fresh database ran every seed
1908 // before a single table existed (gaps3 #41). The caller now decides when
1909 // the app is ready; see [`App::ready`], which `serve()` and
1910 // `umbral_cli::dispatch` call at the right moment.
1911 Ok(App {
1912 router,
1913 plugins: sorted_plugins,
1914 commands: self.commands,
1915 auto_migrate_on_serve: self.auto_migrate_on_serve,
1916 seed_on_serve: self.seed_on_serve.take(),
1917 drain_delay: self.drain_delay,
1918 ready_fired: std::sync::atomic::AtomicBool::new(false),
1919 })
1920 }
1921}
1922
1923/// The axum middleware fn installed by [`AppBuilder::route_context`]: run the
1924/// resolver against the incoming request to build a [`crate::db::RouteContext`],
1925/// then drive the ENTIRE downstream future inside
1926/// [`crate::db::route_context::scope`]. Scoping `next.run(req)` (rather than
1927/// just a prefix of it) is what keeps the task-local alive across every
1928/// `.await` the handler performs, so ambient ORM calls route per the resolved
1929/// context.
1930async fn route_context_scope_layer(
1931 axum::extract::State(resolver): axum::extract::State<RouteContextResolver>,
1932 req: crate::web::Request,
1933 next: axum::middleware::Next,
1934) -> crate::web::Response {
1935 let ctx = resolver(&req);
1936 crate::db::route_context::scope(ctx, next.run(req)).await
1937}
1938
1939/// Resolve when the process receives a shutdown signal — `SIGTERM` (the deploy
1940/// / container-stop signal) or `SIGINT` (Ctrl-C). Drives `serve`'s graceful
1941/// shutdown (audit_2 core-app-config #13). On non-Unix only Ctrl-C is wired.
1942async fn shutdown_signal() {
1943 let ctrl_c = async {
1944 let _ = tokio::signal::ctrl_c().await;
1945 };
1946
1947 #[cfg(unix)]
1948 let terminate = async {
1949 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
1950 Ok(mut sig) => {
1951 sig.recv().await;
1952 }
1953 // If the handler can't be installed, never fire this arm.
1954 Err(_) => std::future::pending::<()>().await,
1955 }
1956 };
1957 #[cfg(not(unix))]
1958 let terminate = std::future::pending::<()>();
1959
1960 tokio::select! {
1961 _ = ctrl_c => {}
1962 _ = terminate => {}
1963 }
1964 tracing::info!("umbral: shutdown signal received; finishing in-flight requests");
1965}
1966
1967/// The future `serve` hands to axum's `with_graceful_shutdown` (Kikosi #5).
1968///
1969/// Awaits `signal`, marks the process draining so readiness probes go 503, then
1970/// — if `drain_delay` is non-zero — keeps serving for that long before resolving,
1971/// which is when axum stops accepting new connections. The delay is the window a
1972/// load balancer uses to observe the 503 and route new traffic elsewhere.
1973///
1974/// Generic over the signal future so the drain sequencing is testable without
1975/// actually delivering a `SIGTERM`.
1976async fn drain_after<F: std::future::Future>(signal: F, drain_delay: std::time::Duration) {
1977 signal.await;
1978 crate::shutdown::begin_drain();
1979 if !drain_delay.is_zero() {
1980 tracing::info!(
1981 drain_secs = drain_delay.as_secs_f64(),
1982 "umbral: draining — readiness now reports 503; holding before stop"
1983 );
1984 tokio::time::sleep(drain_delay).await;
1985 }
1986}
1987
1988/// audit_2 H19 — warn about the app's own mutating routes that carry no
1989/// recorded permission. A default-DENY router is a future-major change; this
1990/// boot Warning is the non-breaking default: it makes a forgotten
1991/// authorization gate visible at boot instead of shipping as an open endpoint.
1992/// [`AppBuilder::deny_ungated_mutations`] promotes the same finding to a hard
1993/// [`BuildError::UngatedMutatingRoutes`] for apps that want it enforced.
1994///
1995/// Scope + honesty: only routes registered through `Routes` (the app's
1996/// `.routes(...)`) are checked — plugin routes gate via their own conventions.
1997/// A route gated by a hand-applied `.layer(permission_required(...))` is opaque
1998/// to `RouteSpec`, so it can't be distinguished from an ungated one; the
1999/// warning says so and points at the `require_permission(...)` builder (which
2000/// records the permission). An intentionally-public route is a false positive
2001/// the operator ignores.
2002fn warn_ungated_mutating_routes(ungated: &[String]) {
2003 tracing::warn!(
2004 "audit_2 H19: {} app mutating route(s) have no recorded permission: [{}]. \
2005 Gate them with the umbral-permissions `Routes::require_permission(...)` builder \
2006 so the framework records the permission (a hand-applied \
2007 `.layer(permission_required(...))` is NOT visible to this audit — prefer the \
2008 builder). If a route is intentionally public, ignore this. To make this a hard \
2009 boot error instead, call `App::builder().deny_ungated_mutations()`.",
2010 ungated.len(),
2011 ungated.join(", ")
2012 );
2013}
2014
2015/// The pure core of the H19 audit: the `"METHOD /path"`
2016/// labels of every route with a mutating method and no recorded permission.
2017/// Split out so the audit's selection logic is unit-testable without a live
2018/// `App::build()` / tracing subscriber.
2019fn ungated_mutating_routes(specs: &[crate::routes::RouteSpec]) -> Vec<String> {
2020 const MUTATING: [&str; 4] = ["POST", "PUT", "PATCH", "DELETE"];
2021 specs
2022 .iter()
2023 .filter(|s| s.permission.is_none() && s.methods.iter().any(|m| MUTATING.contains(m)))
2024 .map(|s| format!("{} {}", s.methods.join("/"), s.path))
2025 .collect()
2026}
2027
2028/// Set minimal hardening response headers, each ONLY if the response doesn't
2029/// already carry it — so `SecurityPlugin` (or a handler) can override, and no
2030/// header is ever duplicated (audit_2 H10).
2031async fn default_security_headers_layer(
2032 req: crate::web::Request,
2033 next: axum::middleware::Next,
2034) -> crate::web::Response {
2035 use axum::http::HeaderValue;
2036 use axum::http::header::{
2037 HeaderName, REFERRER_POLICY, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
2038 };
2039
2040 let mut resp = next.run(req).await;
2041 let headers = resp.headers_mut();
2042 let mut set_if_absent = |name: HeaderName, value: &'static str| {
2043 if !headers.contains_key(&name) {
2044 headers.insert(name, HeaderValue::from_static(value));
2045 }
2046 };
2047 set_if_absent(X_CONTENT_TYPE_OPTIONS, "nosniff");
2048 set_if_absent(X_FRAME_OPTIONS, "DENY");
2049 set_if_absent(REFERRER_POLICY, "strict-origin-when-cross-origin");
2050 resp
2051}
2052
2053/// One cross-plugin foreign-key edge derived from the model registry.
2054///
2055/// `plugin`'s table `table` carries a physical `REFERENCES "<fk_target>"`, and
2056/// `fk_target` is owned by `depends_on`. Reported on
2057/// [`BuildError::ForeignKeyCycle`] so the operator sees the *column* that
2058/// forced the ordering, not just two plugin names.
2059#[derive(Debug, Clone, PartialEq, Eq)]
2060pub struct FkEdge {
2061 /// The plugin whose table holds the foreign key.
2062 pub plugin: &'static str,
2063 /// The plugin that owns the referenced table, and so must migrate first.
2064 pub depends_on: &'static str,
2065 /// The referencing table (`Model::TABLE` of the model holding the FK).
2066 pub table: String,
2067 /// The referenced table (`fk_target` of the FK column).
2068 pub fk_target: String,
2069}
2070
2071/// Derive the cross-plugin ordering edges the *schema already states*.
2072///
2073/// `Plugin::dependencies()` is the edge set an author declares by hand. This is
2074/// the edge set the models spell out: a `ForeignKey<T>` field renders
2075/// `REFERENCES "<T::TABLE>"` inside `CREATE TABLE`, so the plugin owning
2076/// `T::TABLE` must create it first. Postgres enforces the target's existence at
2077/// `CREATE TABLE` time; SQLite silently accepts the dangling reference, which is
2078/// why omitting the edge only ever failed on a fresh Postgres (gaps3 #40).
2079///
2080/// Two exclusions, both deliberate:
2081///
2082/// - **Same-plugin FKs** impose no *plugin* ordering. Column order inside one
2083/// plugin's migration is the diff engine's problem, not the sort's.
2084/// - **`#[umbral(db_constraint = false)]`** renders no `REFERENCES` clause (the
2085/// only valid shape for a cross-database FK, gaps2 #22), so it creates no DDL
2086/// ordering obligation.
2087///
2088/// An FK whose target table belongs to no registered plugin — an app-owned model
2089/// from `.model::<T>()` — yields no edge: the implicit `"app"` plugin is pinned
2090/// last by `App::build()` precisely because app models FK *into* plugin tables.
2091fn fk_plugin_edges(plugins: &[Box<dyn Plugin>]) -> Vec<FkEdge> {
2092 use std::collections::BTreeMap;
2093
2094 // table -> owning plugin. Built across every registered plugin first so a
2095 // forward reference (a plugin FK-ing a table owned by a plugin registered
2096 // later) still resolves.
2097 let mut owner_of_table: BTreeMap<String, &'static str> = BTreeMap::new();
2098 for plugin in plugins {
2099 for model in plugin.models() {
2100 owner_of_table.insert(model.table, plugin.name());
2101 }
2102 }
2103
2104 let mut edges: Vec<FkEdge> = Vec::new();
2105 for plugin in plugins {
2106 let name = plugin.name();
2107 for model in plugin.models() {
2108 for column in &model.fields {
2109 let Some(target) = column.fk_target.as_deref() else {
2110 continue;
2111 };
2112 if !column.db_constraint {
2113 continue;
2114 }
2115 let Some(&owner) = owner_of_table.get(target) else {
2116 continue;
2117 };
2118 if owner == name {
2119 continue;
2120 }
2121 edges.push(FkEdge {
2122 plugin: name,
2123 depends_on: owner,
2124 table: model.table.clone(),
2125 fk_target: target.to_string(),
2126 });
2127 }
2128 }
2129 }
2130 edges
2131}
2132
2133/// Kahn's algorithm over `deps` (plugin -> the set it waits on), with a
2134/// name-sorted ready queue so ties resolve deterministically. Returns the
2135/// topological order, or the still-unsorted names when the graph has a cycle.
2136fn toposort(
2137 mut remaining_deps: std::collections::BTreeMap<
2138 &'static str,
2139 std::collections::BTreeSet<&'static str>,
2140 >,
2141 rank: &std::collections::BTreeMap<&'static str, usize>,
2142) -> Result<Vec<&'static str>, Vec<&'static str>> {
2143 use std::collections::BTreeSet;
2144
2145 // The ready queue is keyed by REGISTRATION rank (gaps4 #44): among
2146 // plugins whose dependencies are satisfied, the one the app registered
2147 // first runs first. Ties used to break alphabetically (a bare name
2148 // BTreeSet), which made the visually meaningful ordering in every
2149 // main.rs decorative — while the docs told users to "mount X after Y".
2150 // Now the builder order IS the tie-break, so the documented mental
2151 // model is true; dependencies (declared + FK-derived) still outrank it.
2152 let mut ready: BTreeSet<(usize, &'static str)> = remaining_deps
2153 .iter()
2154 .filter_map(|(name, deps)| deps.is_empty().then(|| (rank[name], *name)))
2155 .collect();
2156
2157 let mut order: Vec<&'static str> = Vec::with_capacity(remaining_deps.len());
2158 while let Some(&(r, name)) = ready.iter().next() {
2159 ready.remove(&(r, name));
2160 remaining_deps.remove(&name);
2161 order.push(name);
2162 for (other_name, deps) in remaining_deps.iter_mut() {
2163 if deps.remove(&name) && deps.is_empty() {
2164 ready.insert((rank[other_name], *other_name));
2165 }
2166 }
2167 }
2168
2169 if remaining_deps.is_empty() {
2170 Ok(order)
2171 } else {
2172 Err(remaining_deps.keys().copied().collect())
2173 }
2174}
2175
2176/// Validate the registered plugins and return them in a stable
2177/// topological order. Standard Kahn's algorithm with a ready queue keyed by
2178/// REGISTRATION order (gaps4 #44), so the order plugins appear in the
2179/// builder is honored wherever dependencies leave a choice.
2180///
2181/// The edge set is the union of two sources:
2182///
2183/// 1. `Plugin::dependencies()` — what the author declared.
2184/// 2. Cross-plugin foreign keys read off the models ([`fk_plugin_edges`]) — what
2185/// the schema already states.
2186///
2187/// Before gaps3 #40 only (1) fed the sort, so an app where *no* plugin declared
2188/// a dependency had every plugin at in-degree 0 and the "topological" order
2189/// collapsed to alphabetical. `"accounts"` sorts before `"auth"`, and its
2190/// `CREATE TABLE ... REFERENCES "auth_user"` ran against a database with no
2191/// `auth_user`. Declaring your own dependencies is still the plugin author's job;
2192/// the framework just no longer lets the omission reach production silently.
2193///
2194/// Rejects:
2195///
2196/// - A plugin claiming the reserved `"app"` name.
2197/// - Two plugins reporting the same `name()`.
2198/// - A `dependencies()` entry that doesn't name a registered plugin.
2199/// - A declared dependency cycle (`BuildError::PluginCycle`).
2200/// - A cycle introduced by the foreign keys themselves
2201/// (`BuildError::ForeignKeyCycle`, which names the offending columns).
2202fn sort_plugins(plugins: Vec<Box<dyn Plugin>>) -> Result<Vec<Box<dyn Plugin>>, BuildError> {
2203 use std::collections::{BTreeMap, BTreeSet};
2204
2205 // Reserved + duplicate-name checks. The implicit `"app"` plugin is
2206 // not counted toward duplicates; only the user's plugin list is.
2207 let mut seen: BTreeSet<&'static str> = BTreeSet::new();
2208 for plugin in &plugins {
2209 let name = plugin.name();
2210 if name == crate::migrate::APP_PLUGIN_NAME {
2211 return Err(BuildError::ReservedPluginName);
2212 }
2213 if !seen.insert(name) {
2214 return Err(BuildError::DuplicatePluginName { name });
2215 }
2216 }
2217
2218 // Index plugins by name for the dependency lookups + the
2219 // sort-by-name traversal below. We pull the boxes out of the
2220 // input vec by index later, so the index table stays alongside.
2221 let by_name: BTreeMap<&'static str, usize> = plugins
2222 .iter()
2223 .enumerate()
2224 .map(|(i, p)| (p.name(), i))
2225 .collect();
2226
2227 // Dependency-exists check. Done before the toposort so a missing
2228 // dep surfaces with the asking plugin's name attached, not as a
2229 // cycle false-positive.
2230 for plugin in &plugins {
2231 for dep in plugin.dependencies() {
2232 if !by_name.contains_key(dep) {
2233 return Err(BuildError::DependencyNotFound {
2234 plugin: plugin.name(),
2235 missing: dep,
2236 });
2237 }
2238 }
2239 }
2240
2241 let declared: BTreeMap<&'static str, BTreeSet<&'static str>> = plugins
2242 .iter()
2243 .map(|p| (p.name(), p.dependencies().iter().copied().collect()))
2244 .collect();
2245
2246 let fk_edges = fk_plugin_edges(&plugins);
2247 let mut combined = declared.clone();
2248 for edge in &fk_edges {
2249 combined
2250 .get_mut(edge.plugin)
2251 .expect("every FK edge names a registered plugin")
2252 .insert(edge.depends_on);
2253 }
2254
2255 let order = match toposort(combined, &by_name) {
2256 Ok(order) => order,
2257 Err(stuck) => {
2258 // The combined graph cycles. Re-run on the declared edges alone to
2259 // find out who is to blame. If the declared graph is acyclic, the
2260 // foreign keys introduced the cycle — report the columns that did
2261 // it rather than a bare `PluginCycle` the author never wrote.
2262 //
2263 // Across crates this is unreachable: `ForeignKey<T>` needs `T` in
2264 // scope, so mutually-referencing plugin crates would be a circular
2265 // Cargo dependency. Two plugins defined in ONE crate can still do
2266 // it, and a cross-plugin FK cycle has no valid `CREATE TABLE` order
2267 // on a fresh database either way.
2268 if toposort(declared, &by_name).is_ok() {
2269 let stuck: BTreeSet<&'static str> = stuck.into_iter().collect();
2270 let edges: Vec<FkEdge> = fk_edges
2271 .into_iter()
2272 .filter(|e| stuck.contains(e.plugin) && stuck.contains(e.depends_on))
2273 .collect();
2274 return Err(BuildError::ForeignKeyCycle { edges });
2275 }
2276 return Err(BuildError::PluginCycle { names: stuck });
2277 }
2278 };
2279
2280 // Reorder the owned boxes into topological order. We pull each
2281 // plugin out of an `Option` slot so the move is statically
2282 // tracked; every slot is taken exactly once because the toposort
2283 // produced one entry per plugin.
2284 let mut slots: Vec<Option<Box<dyn Plugin>>> = plugins.into_iter().map(Some).collect();
2285 let mut sorted: Vec<Box<dyn Plugin>> = Vec::with_capacity(order.len());
2286 for name in order {
2287 let idx = by_name[&name];
2288 sorted.push(
2289 slots[idx]
2290 .take()
2291 .expect("toposort produced one entry per plugin"),
2292 );
2293 }
2294 Ok(sorted)
2295}
2296
2297/// Errors that can occur during `AppBuilder::build()`.
2298#[derive(Debug)]
2299pub enum BuildError {
2300 /// `.settings(Settings)` wasn't called on the builder.
2301 SettingsMissing,
2302 /// `.database("default", pool)` wasn't called on the builder.
2303 DefaultPoolMissing,
2304 /// The URL scheme in `settings.database_url` doesn't match any
2305 /// shipped backend.
2306 BackendDetect(crate::backend::BackendDetectError),
2307 /// One or more system checks failed with `Severity::Error`. The
2308 /// full list of findings is in the variant.
2309 SystemCheckFailed {
2310 findings: Vec<crate::check::SystemCheckFinding>,
2311 },
2312 /// A plugin's `dependencies()` lists a plugin that was never
2313 /// registered with `.plugin(...)`. Carries the unmet name plus
2314 /// the plugin that asked for it.
2315 DependencyNotFound {
2316 plugin: &'static str,
2317 missing: &'static str,
2318 },
2319 /// The dependency graph has a cycle. Carries the plugin names that
2320 /// form it (in any cyclic order; the diagnostic is "these N plugins
2321 /// reference each other").
2322 PluginCycle { names: Vec<&'static str> },
2323 /// The plugins' *foreign keys* form a cycle, so no `CREATE TABLE` order
2324 /// satisfies every `REFERENCES` clause on a fresh database. Distinct from
2325 /// [`BuildError::PluginCycle`], which reports a cycle the author declared
2326 /// via `dependencies()`; here nothing was declared and the cycle is implied
2327 /// by the models. Carries the FK edges that close the loop so the message
2328 /// can name the columns, not just the plugins.
2329 ForeignKeyCycle { edges: Vec<FkEdge> },
2330 /// Two registered plugins share a `name()`. Plugin names are keys
2331 /// in the migration tracking table and the dependency graph; a
2332 /// collision would break both.
2333 DuplicatePluginName { name: &'static str },
2334 /// A plugin claimed the reserved `"app"` name (used by the
2335 /// implicit plugin that owns `.model::<T>()` registrations).
2336 ReservedPluginName,
2337 /// A plugin's `on_ready` returned an error. Carries the plugin's
2338 /// name plus the underlying error.
2339 PluginOnReady {
2340 plugin: &'static str,
2341 source: Box<dyn std::error::Error + Send + Sync>,
2342 },
2343 /// The templates engine failed to initialise. Carries the
2344 /// underlying `TemplateError` (an IO error reading a template
2345 /// file, or a syntax error in one of the loaded templates).
2346 TemplatesInit(crate::templates::TemplateError),
2347 /// A plugin's `database()` returned an alias that isn't in the
2348 /// registered pool set. Surfaces a typo at boot with a clear
2349 /// "register the pool first" diagnostic instead of letting
2350 /// `db::pool_for` panic at first query.
2351 PluginDatabaseAlias {
2352 plugin: &'static str,
2353 alias: &'static str,
2354 },
2355 /// A `settings.databases` entry could not be opened as a lazy pool at boot
2356 /// (audit_2 H17) — e.g. an unsupported URL scheme. Carries the alias and the
2357 /// sqlx error.
2358 SettingsDatabasePool { alias: String, error: sqlx::Error },
2359 /// The URL-derived backend (from `settings.database_url`) doesn't
2360 /// match the runtime type of the default pool passed to
2361 /// `.database("default", ...)`. Catches the case where the URL
2362 /// says `postgres://` but a `SqlitePool` was registered, or vice
2363 /// versa.
2364 DatabaseBackendMismatch {
2365 url_backend: &'static str,
2366 pool_backend: &'static str,
2367 },
2368 /// A foreign key targets a model on a different database than the
2369 /// model that declares it, and the field has NOT opted out of the
2370 /// physical constraint. A `REFERENCES` clause can't span databases,
2371 /// so this would emit invalid DDL. Fix by either routing both
2372 /// models to the same database, or marking the FK
2373 /// `#[umbral(db_constraint = false)]` to keep it a logical-only
2374 /// relation. Closes gaps2 #22.
2375 CrossDatabaseForeignKey {
2376 model: &'static str,
2377 field: &'static str,
2378 model_db: &'static str,
2379 target_db: &'static str,
2380 },
2381 /// Two plugins declared the same static namespace via
2382 /// `Plugin::static_dirs()`. Namespaces are the per-plugin URL/disk
2383 /// segment under `static_url` / `static_root`; a collision would
2384 /// silently shadow one plugin's assets with another's, so the build
2385 /// fails loudly and names both plugins.
2386 DuplicateStaticNamespace {
2387 namespace: &'static str,
2388 first_plugin: &'static str,
2389 second_plugin: &'static str,
2390 },
2391 /// `.deny_ungated_mutations()` was set and one or more app-level mutating
2392 /// routes (POST/PUT/PATCH/DELETE registered via `.routes(...)`) carry no
2393 /// recorded permission (gaps3 #28 P1, enforcing the audit_2 H19 audit).
2394 /// Carries the `"METHOD /path"` label of each offending route. Fix by gating
2395 /// them with the umbral-permissions `Routes::require_permission(...)` builder
2396 /// (which records the permission), or drop the strict flag if a route is
2397 /// intentionally public.
2398 UngatedMutatingRoutes { routes: Vec<String> },
2399}
2400
2401impl std::fmt::Display for BuildError {
2402 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2403 match self {
2404 BuildError::SettingsMissing => write!(
2405 f,
2406 "umbral: App::builder() requires Settings; call .settings(Settings::from_env()?) before .build()"
2407 ),
2408 BuildError::BackendDetect(err) => write!(f, "{err}"),
2409 BuildError::SystemCheckFailed { findings } => {
2410 writeln!(f, "umbral: {} system check(s) failed:", findings.len())?;
2411 for finding in findings {
2412 write!(f, " - [{}] {}", finding.check_id, finding.message)?;
2413 if let Some(hint) = &finding.hint {
2414 write!(f, " (hint: {hint})")?;
2415 }
2416 writeln!(f)?;
2417 }
2418 Ok(())
2419 }
2420 BuildError::DefaultPoolMissing => write!(
2421 f,
2422 "umbral: App::builder() requires a default DB pool; call .database(\"default\", umbral::db::connect(&url).await?) before .build()"
2423 ),
2424 BuildError::DependencyNotFound { plugin, missing } => write!(
2425 f,
2426 "umbral: plugin `{plugin}` depends on `{missing}`, which isn't registered; \
2427 call .plugin({missing}::default()) on the builder"
2428 ),
2429 BuildError::PluginCycle { names } => {
2430 write!(f, "umbral: plugin dependency cycle: {}", names.join(" -> "))
2431 }
2432 BuildError::ForeignKeyCycle { edges } => {
2433 writeln!(
2434 f,
2435 "umbral: the plugins' foreign keys form a cycle, so no CREATE TABLE order \
2436 satisfies every REFERENCES clause on a fresh database:"
2437 )?;
2438 for edge in edges {
2439 writeln!(
2440 f,
2441 " `{}`.\"{}\" REFERENCES \"{}\", owned by `{}`",
2442 edge.plugin, edge.table, edge.fk_target, edge.depends_on
2443 )?;
2444 }
2445 write!(
2446 f,
2447 "break the cycle by making one side a nullable FK added in a later \
2448 migration, or by opting that column out of the physical constraint with \
2449 #[umbral(db_constraint = false)]"
2450 )
2451 }
2452 BuildError::DuplicatePluginName { name } => write!(
2453 f,
2454 "umbral: two plugins both report name `{name}`; plugin names are unique keys \
2455 (migration tracking, dependency graph)"
2456 ),
2457 BuildError::SettingsDatabasePool { alias, error } => write!(
2458 f,
2459 "umbral: could not open the `settings.databases` pool for alias `{alias}`: \
2460 {error}"
2461 ),
2462 BuildError::ReservedPluginName => write!(
2463 f,
2464 "umbral: the plugin name `app` is reserved for models registered via \
2465 .model::<T>(); pick a different name"
2466 ),
2467 BuildError::PluginOnReady { plugin, source } => {
2468 write!(f, "umbral: plugin `{plugin}`'s on_ready failed: {source}")
2469 }
2470 BuildError::TemplatesInit(err) => {
2471 write!(f, "umbral: templates engine failed to initialise: {err}")
2472 }
2473 BuildError::PluginDatabaseAlias { plugin, alias } => write!(
2474 f,
2475 "umbral: plugin `{plugin}` requested database alias `{alias}`, which isn't \
2476 registered; call .database(\"{alias}\", pool) on the builder before .build()"
2477 ),
2478 BuildError::CrossDatabaseForeignKey {
2479 model,
2480 field,
2481 model_db,
2482 target_db,
2483 } => write!(
2484 f,
2485 "umbral: model `{model}` (database `{model_db}`) has a foreign key \
2486 `{field}` to a model on database `{target_db}`. A FOREIGN KEY \
2487 constraint can't span databases. Either route both models to the \
2488 same database, or mark the field `#[umbral(db_constraint = false)]` \
2489 to keep it a logical-only relation (joins / select_related still \
2490 work; no physical constraint is emitted)."
2491 ),
2492 BuildError::DatabaseBackendMismatch {
2493 url_backend,
2494 pool_backend,
2495 } => write!(
2496 f,
2497 "umbral: settings.database_url names backend `{url_backend}`, but the \
2498 default pool passed to .database(...) is a `{pool_backend}` pool. \
2499 Either change UMBRAL_DATABASE_URL to match the pool, or open the pool \
2500 against a URL whose scheme matches umbral::db::connect."
2501 ),
2502 BuildError::DuplicateStaticNamespace {
2503 namespace,
2504 first_plugin,
2505 second_plugin,
2506 } => write!(
2507 f,
2508 "umbral: plugins `{first_plugin}` and `{second_plugin}` both declare the static \
2509 namespace `{namespace}` via static_dirs(); namespaces must be unique \
2510 (they key the /static/<namespace>/ URL and the static_root/<namespace>/ \
2511 collected-asset dir). Rename one plugin's namespace."
2512 ),
2513 BuildError::UngatedMutatingRoutes { routes } => write!(
2514 f,
2515 "umbral: deny_ungated_mutations() is set and {} app mutating route(s) have no \
2516 recorded permission: [{}]. Gate each with the umbral-permissions \
2517 `Routes::require_permission(...)` builder so the framework records the \
2518 permission (a hand-applied `.layer(permission_required(...))` is NOT visible \
2519 to this audit — prefer the builder). If a route is intentionally public, \
2520 register it through a permission-aware builder or drop the strict flag.",
2521 routes.len(),
2522 routes.join(", ")
2523 ),
2524 }
2525 }
2526}
2527
2528impl std::error::Error for BuildError {}
2529
2530#[cfg(test)]
2531mod audit_tests {
2532 use super::ungated_mutating_routes;
2533 use crate::routes::RouteSpec;
2534
2535 fn spec(methods: Vec<&'static str>, path: &str, perm: Option<&str>) -> RouteSpec {
2536 RouteSpec {
2537 path: path.to_string(),
2538 methods,
2539 permission: perm.map(str::to_string),
2540 }
2541 }
2542
2543 #[test]
2544 fn flags_ungated_mutating_routes_only() {
2545 let specs = vec![
2546 spec(vec!["GET"], "/", None), // read → ignored
2547 spec(vec!["POST"], "/contact", None), // ungated mutating → flagged
2548 spec(vec!["POST"], "/posts", Some("blog.add")), // gated → ignored
2549 spec(vec!["DELETE"], "/posts/{id}", None), // ungated mutating → flagged
2550 spec(vec!["GET", "POST"], "/api/comments", None), // has a mutating verb → flagged
2551 ];
2552 let flagged = ungated_mutating_routes(&specs);
2553 assert_eq!(
2554 flagged,
2555 vec![
2556 "POST /contact".to_string(),
2557 "DELETE /posts/{id}".to_string(),
2558 "GET/POST /api/comments".to_string(),
2559 ]
2560 );
2561 }
2562
2563 #[test]
2564 fn no_warning_when_all_mutating_routes_are_gated_or_read_only() {
2565 let specs = vec![
2566 spec(vec!["GET"], "/", None),
2567 spec(vec!["POST"], "/posts", Some("blog.add")),
2568 ];
2569 assert!(ungated_mutating_routes(&specs).is_empty());
2570 }
2571}
2572
2573#[cfg(test)]
2574mod drain_tests {
2575 use super::drain_after;
2576 use std::time::{Duration, Instant};
2577
2578 /// `drain_after` awaits its signal, flips the process to draining, then holds
2579 /// for the delay before resolving — the sequence that lets `/readyz` report
2580 /// 503 while the server keeps accepting during the drain window (Kikosi #5).
2581 ///
2582 /// One test, walked in sequence: the draining flag is a process-global that
2583 /// `begin_drain` only ever sets, so splitting the zero-delay and with-delay
2584 /// cases into separate concurrent tests would race on it. A ready signal
2585 /// (`async {}`) exercises the drain logic without delivering a real SIGTERM.
2586 #[tokio::test]
2587 async fn signals_draining_and_holds_for_the_delay() {
2588 assert!(
2589 !crate::shutdown::is_draining(),
2590 "draining must start false — nothing has signalled shutdown yet",
2591 );
2592
2593 // Zero delay: marks draining, does not sleep (the historical
2594 // instant-shutdown behaviour).
2595 let started = Instant::now();
2596 drain_after(async {}, Duration::ZERO).await;
2597 assert!(
2598 crate::shutdown::is_draining(),
2599 "the signal must mark the process draining so /readyz goes 503",
2600 );
2601 assert!(
2602 started.elapsed() < Duration::from_millis(50),
2603 "zero delay must not sleep; took {:?}",
2604 started.elapsed(),
2605 );
2606
2607 // A non-zero delay holds before resolving, even though the process is
2608 // already draining (begin_drain is idempotent; the hold still applies).
2609 let started = Instant::now();
2610 drain_after(async {}, Duration::from_millis(120)).await;
2611 assert!(
2612 started.elapsed() >= Duration::from_millis(100),
2613 "must hold for ~the drain delay before resolving; held {:?}",
2614 started.elapsed(),
2615 );
2616 }
2617}
2618
2619#[cfg(test)]
2620mod sort_plugins_tests {
2621 use super::sort_plugins;
2622 use crate::plugin::Plugin;
2623
2624 struct Named {
2625 name: &'static str,
2626 deps: &'static [&'static str],
2627 }
2628 impl Plugin for Named {
2629 fn name(&self) -> &'static str {
2630 self.name
2631 }
2632 fn dependencies(&self) -> &'static [&'static str] {
2633 self.deps
2634 }
2635 }
2636
2637 fn names(order: &[Box<dyn Plugin>]) -> Vec<&'static str> {
2638 order.iter().map(|p| p.name()).collect()
2639 }
2640
2641 /// gaps4 #44: with no dependencies at all, the sort preserves the
2642 /// builder's registration order — it must NOT collapse to alphabetical
2643 /// (which is what a name-keyed ready queue silently did, making the
2644 /// visually meaningful ordering in every main.rs decorative).
2645 #[test]
2646 fn ties_break_by_registration_order_not_alphabetically() {
2647 let plugins: Vec<Box<dyn Plugin>> = vec![
2648 Box::new(Named {
2649 name: "zeta",
2650 deps: &[],
2651 }),
2652 Box::new(Named {
2653 name: "midway",
2654 deps: &[],
2655 }),
2656 Box::new(Named {
2657 name: "alpha",
2658 deps: &[],
2659 }),
2660 ];
2661 let sorted = sort_plugins(plugins).expect("acyclic");
2662 assert_eq!(
2663 names(&sorted),
2664 vec!["zeta", "midway", "alpha"],
2665 "registration order is the tie-break"
2666 );
2667 }
2668
2669 /// ...and a declared dependency still outranks registration order: the
2670 /// dependency graph is authoritative, the builder order only settles
2671 /// what the graph leaves open.
2672 #[test]
2673 fn dependencies_outrank_registration_order() {
2674 let plugins: Vec<Box<dyn Plugin>> = vec![
2675 Box::new(Named {
2676 name: "zeta",
2677 deps: &["alpha"],
2678 }),
2679 Box::new(Named {
2680 name: "midway",
2681 deps: &[],
2682 }),
2683 Box::new(Named {
2684 name: "alpha",
2685 deps: &[],
2686 }),
2687 ];
2688 let sorted = sort_plugins(plugins).expect("acyclic");
2689 assert_eq!(
2690 names(&sorted),
2691 vec!["midway", "alpha", "zeta"],
2692 "zeta waits for alpha; midway keeps its registration slot"
2693 );
2694 }
2695}