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