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