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