Skip to main content

umbral_core/
plugin.rs

1//! The Plugin trait — umbral's only extension mechanism.
2//!
3//! Auth, sessions, admin, tasks, REST, and OpenAPI are all plugins; so
4//! is every third-party crate that ships models, routes, or commands.
5//! This module defines the contract, the `AppContext` plugins receive,
6//! and the `BuildError` variants topological-sort issues surface as.
7//!
8//! See `docs/specs/02-plugin-contract.md` for the eventual target
9//! shape; this file ships the M7 v1 subset (no middleware, no commands,
10//! no inventory auto-registration).
11//!
12//! ## The trait
13//!
14//! ```ignore
15//! use umbral::prelude::*;
16//!
17//! pub struct BlogPlugin;
18//!
19//! impl Plugin for BlogPlugin {
20//!     fn name(&self) -> &'static str { "blog" }
21//!
22//!     fn dependencies(&self) -> &'static [&'static str] { &["auth"] }
23//!
24//!     fn models(&self) -> Vec<umbral::migrate::ModelMeta> {
25//!         vec![umbral::migrate::ModelMeta::for_::<Post>()]
26//!     }
27//!
28//!     fn routes(&self) -> Router {
29//!         Router::new().route("/posts", get(list))
30//!     }
31//! }
32//! ```
33//!
34//! `AppBuilder::plugin(BlogPlugin)` registers it; `App::build()`
35//! topologically sorts the registered plugins, walks every plugin's
36//! routes / models / system_checks, and fires `on_ready` in dependency
37//! order.
38
39use std::path::PathBuf;
40
41use crate::db::DbPool;
42use axum::Router;
43
44use crate::check::SystemCheck;
45use crate::migrate::ModelMeta;
46use crate::settings::Settings;
47
48/// Run an async future to completion from inside a synchronous
49/// `Plugin::on_ready` implementation.
50///
51/// `Plugin::on_ready` is a sync trait method (the trait has to be
52/// object-safe for `Vec<Box<dyn Plugin>>`), but most real-world async
53/// work — schema DDL via sqlx, policy setup, initial seeding — needs
54/// to await. This helper bridges that gap safely under every runtime
55/// configuration that umbral encounters in practice:
56///
57/// | Caller context | Bridge used |
58/// |---|---|
59/// | Multi-thread tokio runtime (`#[tokio::main]`, prod binaries) | `tokio::task::block_in_place` + `Handle::block_on` — parks the OS thread, doesn't block the executor |
60/// | Current-thread tokio runtime (`#[tokio::test]` default) | Spawns a dedicated OS thread with its own `Runtime`; `block_in_place` would panic here |
61/// | No ambient runtime (bare `main`, exotic callers) | Creates a temporary `Runtime` and `block_on`s |
62///
63/// ## Why not just `Handle::current().block_on(fut)`?
64///
65/// `block_on` on a `Handle` panics when called from within a
66/// current-thread runtime (which is the default for `#[tokio::test]`).
67/// The multi-thread path requires `block_in_place` to hand control
68/// back to the executor; the current-thread path requires moving to a
69/// different OS thread entirely.
70///
71/// ## Usage
72///
73/// ```rust,ignore
74/// fn on_ready(&self, ctx: &AppContext) -> Result<(), PluginError> {
75///     umbral::plugin::block_on_ready(self.do_async_setup(&ctx.pool))?;
76///     Ok(())
77/// }
78/// ```
79pub fn block_on_ready<F>(fut: F) -> F::Output
80where
81    F: std::future::Future + Send,
82    F::Output: Send,
83{
84    match tokio::runtime::Handle::try_current() {
85        Ok(handle) => {
86            // We are inside a tokio runtime. The safe bridging path
87            // depends on the runtime flavor:
88            //
89            // - Multi-thread: `block_in_place` parks the current OS
90            //   thread and yields it to the executor so other tasks
91            //   keep running. The `Handle::block_on` call inside then
92            //   drives the future to completion on that parked thread.
93            //
94            // - Current-thread: `block_in_place` panics because a
95            //   single-threaded executor can't lend the thread to sync
96            //   work while simultaneously needing it to drive the
97            //   reactor. The only safe path is to escape to a new OS
98            //   thread. We use `std::thread::scope` (stable since
99            //   Rust 1.63, our MSRV is 1.85) so non-`'static`
100            //   borrows from the call frame can cross the thread
101            //   boundary safely — the scope join guarantees the
102            //   spawned thread exits before the frame does.
103            if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
104                tokio::task::block_in_place(|| handle.block_on(fut))
105            } else {
106                // Current-thread (or unknown flavor): escape to a
107                // scoped thread with its own single-thread runtime.
108                std::thread::scope(|s| {
109                    s.spawn(|| {
110                        tokio::runtime::Builder::new_current_thread()
111                            .enable_all()
112                            .build()
113                            .expect("block_on_ready: failed to build current-thread runtime")
114                            .block_on(fut)
115                    })
116                    .join()
117                    .expect("block_on_ready: scoped thread panicked")
118                })
119            }
120        }
121        Err(_) => {
122            // No ambient runtime. Build a temporary one for this call.
123            tokio::runtime::Builder::new_current_thread()
124                .enable_all()
125                .build()
126                .expect("block_on_ready: failed to build runtime")
127                .block_on(fut)
128        }
129    }
130}
131
132/// The contract every umbral extension implements.
133///
134/// Every method except `name()` has a default that returns the empty
135/// contribution. A plugin opts in only to what it contributes: a
136/// pure-route plugin overrides `routes()`; a pure-data plugin
137/// overrides `models()`; the auth plugin overrides almost all of them.
138///
139/// The trait is `Send + Sync + 'static` so `App::builder()` can store a
140/// homogeneous `Vec<Box<dyn Plugin>>` and the runtime can hand the
141/// plugin reference to threads (e.g. for background tasks spawned in
142/// `on_ready`). The bounds are deliberately permissive: any
143/// reasonable Rust struct meets them by default.
144pub trait Plugin: Send + Sync + 'static {
145    /// A stable identifier. Used as the key in the migration tracking
146    /// table, in dependency lists, and as the directory name under
147    /// `migrations/`. Plugin names live in the same namespace as
148    /// `migrate::APP_PLUGIN_NAME` (`"app"`), so user crates must not
149    /// pick the name `"app"`.
150    fn name(&self) -> &'static str;
151
152    /// Names of plugins that must load before this one. The
153    /// `App::builder()` topological sort uses this; cycles surface as
154    /// `BuildError::PluginCycle`. The default is no dependencies.
155    fn dependencies(&self) -> &'static [&'static str] {
156        &[]
157    }
158
159    /// The plugin's models, in declaration order. The M7 migration
160    /// engine collects these across every registered plugin and uses
161    /// them as the diff target for `makemigrations`.
162    ///
163    /// Default: no models. A pure-route or pure-middleware plugin
164    /// leaves this alone.
165    fn models(&self) -> Vec<ModelMeta> {
166        Vec::new()
167    }
168
169    /// The plugin's HTTP routes. Merged into the app router after the
170    /// hand-written one passed to `AppBuilder::routes()`. Plugins
171    /// choose their own path prefixes (spec 02 §"What a plugin can
172    /// contribute": routes are flat, not auto-prefixed).
173    ///
174    /// **Drift warning.** This method and [`route_paths`] are two
175    /// independent lists; nothing forces them to agree, so a route
176    /// mounted here but not declared in `route_paths()` is invisible to
177    /// every audit / discovery surface (the dev 404 page, the ungated-
178    /// route audit, future OpenAPI security annotations). For routes
179    /// whose accuracy matters to those surfaces, implement
180    /// [`routes_builder`] instead — it records each path AS you mount
181    /// it, so the registry cannot drift from what's actually served.
182    ///
183    /// [`route_paths`]: Plugin::route_paths
184    /// [`routes_builder`]: Plugin::routes_builder
185    fn routes(&self) -> Router {
186        Router::new()
187    }
188
189    /// The drift-free alternative to [`routes`] + [`route_paths`]
190    /// (gaps4 #31): mount routes through the recording [`Routes`]
191    /// builder and the framework takes BOTH the axum router and the
192    /// declared [`RouteSpec`]s from that ONE source, so the route
193    /// registry can never fall out of sync with what's mounted.
194    ///
195    /// ```ignore
196    /// fn routes_builder(&self) -> Option<umbral::routes::Routes> {
197    ///     Some(
198    ///         Routes::new()
199    ///             .get("/health", health)          // path recorded as it mounts
200    ///             .post("/api/thing", create_thing) //   "        "       "
201    ///     )
202    /// }
203    /// ```
204    ///
205    /// When this returns `Some`, the framework uses the builder's
206    /// router for merging AND its specs for the registry, and it
207    /// **ignores** this plugin's [`routes`] / [`route_paths`] entirely
208    /// — implement one mechanism or the other, never both. Returning
209    /// `None` (the default) keeps the legacy two-method pair.
210    ///
211    /// The one residual: paths inside a router merged via
212    /// [`Routes::with_router`] (or an axum `nest`) still aren't
213    /// recorded — axum exposes no route-table introspection — so those
214    /// escape-hatch paths carry the same drift caveat as `routes()`.
215    /// Everything mounted through the builder's own `get/post/route/…`
216    /// methods is recorded, including per-route `.layer(...)`.
217    ///
218    /// [`routes`]: Plugin::routes
219    /// [`route_paths`]: Plugin::route_paths
220    /// [`Routes`]: crate::routes::Routes
221    /// [`Routes::with_router`]: crate::routes::Routes::with_router
222    /// [`RouteSpec`]: crate::routes::RouteSpec
223    fn routes_builder(&self) -> Option<crate::routes::Routes> {
224        None
225    }
226
227    /// Declared URL routes this plugin contributes — a companion to
228    /// [`routes`] used for surfacing route lists outside the request
229    /// flow (currently: the dev-mode default 404 page). axum doesn't
230    /// expose its internal route table, so plugins report what they
231    /// declare here; the framework treats this as informational only
232    /// — not a source of truth for routing.
233    ///
234    /// Each entry carries a path pattern and the HTTP methods it
235    /// accepts; the dev-mode 404 page renders method badges so a
236    /// developer can tell at a glance which verb to use. Conversions
237    /// (see [`RouteSpec`]'s `From` impls) cover the ergonomic shapes:
238    /// `"/admin/login".into()`, `("GET", "/articles").into()`,
239    /// `(&["GET", "POST"][..], "/api/post").into()`.
240    ///
241    /// Default empty. Mismatch with the real `routes()` is a stale-
242    /// list bug, not a correctness bug — but if you'd rather it be
243    /// impossible than merely benign, implement [`routes_builder`]
244    /// (gaps4 #31), which derives this list from the routes you mount.
245    ///
246    /// [`routes`]: Plugin::routes
247    /// [`routes_builder`]: Plugin::routes_builder
248    /// [`RouteSpec`]: crate::routes::RouteSpec
249    fn route_paths(&self) -> Vec<crate::routes::RouteSpec> {
250        Vec::new()
251    }
252
253    /// OpenAPI path items the plugin contributes. Returned as a
254    /// `Vec<(path, value)>` where `path` is the URL template
255    /// (`/api/auth/login`, `/api/foo/{id}`) and `value` is the
256    /// matching OpenAPI 3.0 [Path Item Object][1] serialised as
257    /// a `serde_json::Value`.
258    ///
259    /// [`umbral-openapi`] walks every registered plugin's
260    /// contribution at spec-build time and merges them into the
261    /// emitted document's `paths` object. Closes BUG-20 from
262    /// `bugs/tests/testBugs.md` — auto-generated CRUD routes were
263    /// the only thing the spec described before; plugin-
264    /// contributed routes (auth, custom actions) were invisible
265    /// to Swagger UI.
266    ///
267    /// Plugins that don't ship OpenAPI documentation leave this
268    /// alone. The umbral-openapi plugin's own routes (the
269    /// `/openapi.json` and Swagger UI mount) are not in the
270    /// generated spec — they're delivery, not API.
271    ///
272    /// [1]: https://spec.openapis.org/oas/v3.0.3#path-item-object
273    /// [`umbral-openapi`]: https://docs.rs/umbral-openapi
274    fn openapi_paths(&self) -> Vec<(String, serde_json::Value)> {
275        Vec::new()
276    }
277
278    /// Boot-time checks the plugin needs to pass. Run in phase 4 of
279    /// `App::build()` alongside the framework's built-in checks.
280    /// `Severity::Error` blocks boot; `Severity::Warning` logs and
281    /// continues.
282    fn system_checks(&self) -> Vec<SystemCheck> {
283        Vec::new()
284    }
285
286    /// `true` if this plugin registers a [`Storage`](crate::storage::Storage)
287    /// backend (e.g. `StoragePlugin`, which calls
288    /// [`crate::storage::set_storage`] in [`Plugin::on_ready`]).
289    ///
290    /// The boot system check `field.storage_backend` reads this flag to
291    /// decide whether a model that declares a `FileField` / `ImageField`
292    /// has somewhere to resolve its uploads. It checks the *capability
293    /// flag* rather than the ambient `storage_opt()` because storage is
294    /// registered in `on_ready`, which runs *after* the system-check
295    /// phase — at check time the ambient backend isn't published yet, but
296    /// the declared capability is knowable from the plugin list. Override
297    /// this (return `true`) in any plugin whose `on_ready` registers a
298    /// backend.
299    fn provides_storage(&self) -> bool {
300        false
301    }
302
303    /// The database alias every model this plugin contributes should
304    /// be read from and written to. Returns `None` to use the
305    /// `"default"` pool (the same one `umbral::db::pool()` returns).
306    ///
307    /// This is umbral's per-plugin database routing hook. The
308    /// builder reads it during phase 3 and the QuerySet's
309    /// `resolve_pool` defers to it when no `.on(&pool)` override is
310    /// set on the chain. Per-plugin granularity (every model the
311    /// plugin owns goes to one database) is the v1 shape; per-model
312    /// overrides via attribute lands when a real workload needs it.
313    ///
314    /// The named alias must have been registered via
315    /// `AppBuilder::database(alias, pool)` before `App::build()`. A
316    /// reference to an unregistered alias surfaces as
317    /// `BuildError::PluginDatabaseAlias` at boot.
318    ///
319    /// Note: `Settings.databases[alias]` does **not** register a pool on
320    /// its own today — it is parsed config, but nothing opens a pool from
321    /// it (audit_2 core-app-config #4). Open the pool yourself
322    /// (`umbral::db::connect(&url).await?`) and pass it to
323    /// `AppBuilder::database(alias, pool)`.
324    fn database(&self) -> Option<&'static str> {
325        None
326    }
327
328    /// Template directories this plugin contributes.
329    ///
330    /// Each path is added to the global template search list in plugin
331    /// registration order. The app-level `templates_dir` (set via
332    /// `AppBuilder::templates_dir`) is always searched first; plugin
333    /// directories follow in topological dependency order so a plugin
334    /// with no dependencies appears before its dependents.
335    ///
336    /// When two plugins (or the app directory and a plugin) ship a
337    /// template with the same name, the first directory in the list wins
338    /// and a tracing warning is emitted at boot so the collision is
339    /// visible. First-match-wins across all template directories.
340    ///
341    /// Default: no directories. A plugin that renders no HTML leaves
342    /// this alone.
343    fn templates_dirs(&self) -> Vec<PathBuf> {
344        Vec::new()
345    }
346
347    /// Custom template tags / filters this plugin contributes
348    /// (feature #67 - a loadable template tag/filter library).
349    ///
350    /// Each returned [`TemplateRegistrar`] is a closure that mutates the
351    /// minijinja [`Environment`](minijinja::Environment) at engine-build
352    /// time — `env.add_filter(...)`, `env.add_function(...)`,
353    /// `env.add_global(...)`. They are collected across all plugins in
354    /// topological order and applied *after* the framework built-ins
355    /// (`static`, `media_url`, `markdown`, `now`, `currency`, …), so a
356    /// plugin may deliberately override a built-in by re-registering the
357    /// same name.
358    ///
359    /// The closures must be owned and `'static` (no borrow of `self`) so
360    /// the framework can stash them and re-run them on every dev-mode
361    /// hot-reload rebuild. Capture any per-plugin config by value.
362    ///
363    /// ```ignore
364    /// fn template_registrars(&self) -> Vec<TemplateRegistrar> {
365    ///     vec![Box::new(|env| {
366    ///         env.add_filter("shout", |s: String| s.to_uppercase());
367    ///     })]
368    /// }
369    /// ```
370    ///
371    /// Default: no custom tags. A plugin that ships none leaves this alone.
372    fn template_registrars(&self) -> Vec<crate::templates::TemplateRegistrar> {
373        Vec::new()
374    }
375
376    /// Wrap the app router with the plugin's middleware layers.
377    ///
378    /// Called once per plugin during `App::build`'s phase 5, in
379    /// topological dependency order. The plugin receives the router
380    /// after its routes have already been merged in, applies any
381    /// `.layer(...)` calls it needs (tower layers, axum's middleware
382    /// fn helpers, etc.), and returns the wrapped router.
383    ///
384    /// Returning the router shape (instead of a `Vec<Layer>` like
385    /// the spec sketched) sidesteps the trait-object lifetime
386    /// problem Layer's generics produce. Plugins keep full access
387    /// to the axum / tower API at the call site.
388    ///
389    /// Default: return the router unchanged. A pure-data plugin
390    /// (models only) inherits this and never touches the router.
391    fn wrap_router(&self, router: Router) -> Router {
392        router
393    }
394
395    /// Framework-level request/response middleware this plugin contributes
396    /// (feature #68).
397    ///
398    /// Where [`wrap_router`](Plugin::wrap_router) hands you the raw axum
399    /// `Router` for arbitrary tower `Layer`s, this is the ergonomic
400    /// surface: each [`Middleware`](crate::middleware::Middleware) gets a
401    /// `before_request` / `after_response` hook and nothing else to wire.
402    /// All plugins' middleware (plus the app's) are collected into one
403    /// stack and installed as a single layer at `App::build`, in plugin
404    /// topological order — a plugin's `before_request` runs after those of
405    /// the plugins it depends on, and its `after_response` runs before
406    /// them (onion order).
407    ///
408    /// Reach for `wrap_router` when you need a real tower `Layer` (timeouts,
409    /// tracing spans, body-limit); reach for this when you just want to
410    /// look at the request or response.
411    ///
412    /// Default: no middleware.
413    fn middleware(&self) -> Vec<std::sync::Arc<dyn crate::middleware::Middleware>> {
414        Vec::new()
415    }
416
417    /// Static files the plugin ships baked into its binary.
418    ///
419    /// Each entry produces one `GET <url_path>` route that returns the
420    /// file body with the supplied `Content-Type` and `Cache-Control`.
421    /// Bodies are `&'static [u8]` — typically `include_bytes!` —
422    /// because the canonical use is "the binary ships its own CSS / JS
423    /// / fonts."
424    ///
425    /// Use cases:
426    ///   - `umbral-admin` ships its precompiled Tailwind CSS this way.
427    ///   - A plugin that adds an HTMX page can ship an icon or font.
428    ///   - User code can register arbitrary embedded assets.
429    ///
430    /// Conflicts across plugins (two plugins claiming the same
431    /// `url_path`) are **not** silently resolved — axum's
432    /// `Router::route` panics at `App::build` time with an "overlapping
433    /// method route" error naming the path. The build fails loudly; fix
434    /// the collision by giving each plugin a distinct `url_path`
435    /// (namespacing under the plugin name is the convention).
436    ///
437    /// Default: no files. Plugins that ship no embedded assets leave
438    /// this alone.
439    fn static_files(&self) -> Vec<StaticFile> {
440        Vec::new()
441    }
442
443    /// On-disk source directories this plugin contributes to the
444    /// unified static pipeline.
445    ///
446    /// Where [`static_files`] bakes assets into the binary (zero-config,
447    /// always available), `static_dirs` declares a *filesystem* source
448    /// the framework's static handler serves live. Each entry pairs a
449    /// `namespace` (the per-plugin URL/disk segment that prevents
450    /// collisions — `"admin"`, `"playground"`) with the absolute
451    /// `source_dir` holding that plugin's source assets (plugins
452    /// typically compute it from `env!("CARGO_MANIFEST_DIR")`).
453    ///
454    /// At `App::build()` the framework walks every plugin's
455    /// `static_dirs()` into a `namespace -> source_dir` registry and
456    /// mounts one handler at the configured `static_url` (default
457    /// `/static/`). A request `/static/<namespace>/<rest>` resolves:
458    ///
459    /// - **Dev** — `<source_dir>/<rest>` first (live source serving: drop
460    ///   a rebuilt file and it's served on the next request), falling
461    ///   back to `<static_root>/<namespace>/<rest>` when the namespace
462    ///   isn't registered or the file is missing.
463    /// - **Prod / Test** — `<static_root>/<namespace>/<rest>` only.
464    ///
465    /// Two plugins declaring the same `namespace` is a boot-time error
466    /// ([`BuildError::DuplicateStaticNamespace`]) — collisions fail
467    /// loudly, never silently shadow.
468    ///
469    /// Default: no directories. A plugin that ships no filesystem assets
470    /// leaves this alone.
471    ///
472    /// [`static_files`]: Plugin::static_files
473    /// [`BuildError::DuplicateStaticNamespace`]: crate::app::BuildError::DuplicateStaticNamespace
474    fn static_dirs(&self) -> Vec<StaticDir> {
475        Vec::new()
476    }
477
478    /// On-disk directories served at the **root** of `static_url` — with
479    /// no namespace segment.
480    ///
481    /// Where [`static_dirs`] serves a plugin's assets under a namespaced
482    /// path (`/static/<namespace>/<file>`), these directories back the
483    /// bare `/static/<file>` space for app/site-level static (a project's
484    /// own CSS, images, favicon). The framework's single static handler
485    /// resolves a request by trying registered namespaces first, then
486    /// these root directories with the full request path.
487    ///
488    /// This is the seam that lets the framework own `static_url` as a
489    /// single mount: a `StoragePlugin`'s static side pointed at the configured
490    /// `static_url` contributes its directory here instead of nesting its
491    /// own (conflicting) catch-all route. A plugin serving its directory
492    /// at a *different* mount returns nothing here and nests as usual.
493    ///
494    /// Default: none.
495    ///
496    /// [`static_dirs`]: Plugin::static_dirs
497    fn static_root_dirs(&self) -> Vec<std::path::PathBuf> {
498        Vec::new()
499    }
500
501    /// CLI subcommands the plugin contributes.
502    ///
503    /// Each command implements [`crate::cli::PluginCommand`] and ships
504    /// a `clap::Command` plus an async `run` handler. The framework's
505    /// binary (or any user-written one) calls
506    /// [`crate::cli::dispatch`] with the App's plugin list to wire
507    /// these into a single CLI tree.
508    ///
509    /// Default: no commands. Plugins that only contribute models,
510    /// routes, or middleware leave this alone.
511    fn commands(&self) -> Vec<Box<dyn crate::cli::PluginCommand>> {
512        Vec::new()
513    }
514
515    /// Callable HTTP endpoints this plugin wants advertised in a
516    /// machine-readable index (e.g. a REST API root, or a client's
517    /// service-discovery fetch).
518    ///
519    /// This is *not* how a plugin mounts routes — that's [`routes`].
520    /// It's a declaration of which of those routes are worth surfacing
521    /// to an API client, with a human label and a grouping key. The
522    /// framework collects every plugin's list at `App::build()` into a
523    /// global readable via [`crate::migrate::registered_api_endpoints`];
524    /// a plugin like `umbral-rest` reads that global to render an API
525    /// root without ever naming the plugins that contributed.
526    ///
527    /// Paths are relative (`/oauth/google/login`) — the core type stays
528    /// origin-agnostic; a consumer joins its own origin when it needs an
529    /// absolute URL.
530    ///
531    /// Default: nothing advertised. Plugins that don't expose a
532    /// client-facing API leave this alone.
533    ///
534    /// [`routes`]: Plugin::routes
535    fn api_endpoints(&self) -> Vec<ApiEndpoint> {
536        Vec::new()
537    }
538
539    /// Wire signals, start background work, seal admin registrations.
540    /// Called after phase 4 (system checks) passes, in topological
541    /// dependency order. Sync, on purpose; spawn async work via
542    /// `ctx.runtime()` when the runtime handle lands.
543    fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {
544        Ok(())
545    }
546}
547
548/// One static file a plugin ships baked into its binary. Returned
549/// from [`Plugin::static_files`].
550///
551/// The body is a `&'static [u8]` (usually from `include_bytes!`) so
552/// the file ships with the binary; no on-disk asset directory needs
553/// to exist at runtime. `cache_control` defaults to one day if left
554/// `None`.
555#[derive(Debug, Clone)]
556pub struct StaticFile {
557    /// URL path the asset is served at, e.g. `/admin/static/admin.css`.
558    pub url_path: &'static str,
559    /// `Content-Type` header value, e.g. `text/css; charset=utf-8`.
560    pub content_type: &'static str,
561    /// File body. Usually `include_bytes!("relative/path")`.
562    pub body: &'static [u8],
563    /// Optional `Cache-Control` header. `None` → `public, max-age=86400`.
564    pub cache_control: Option<&'static str>,
565}
566
567/// One on-disk source directory a plugin contributes to the unified
568/// static pipeline. Returned from [`Plugin::static_dirs`].
569///
570/// `namespace` is the URL/disk segment that isolates this plugin's
571/// assets from every other plugin's — a request `/static/<namespace>/…`
572/// and the collected output dir `<static_root>/<namespace>/…` both key
573/// off it. It is a `&'static str` because plugins declare it as a
574/// literal.
575///
576/// `source_dir` is the absolute on-disk directory holding the plugin's
577/// source assets, served live in dev. It is a `PathBuf` (not a
578/// `&'static str`) because plugins compute it at runtime — typically
579/// `PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static")`.
580#[derive(Debug, Clone)]
581pub struct StaticDir {
582    /// Per-plugin URL/disk segment, e.g. `"admin"` or `"playground"`.
583    pub namespace: &'static str,
584    /// Absolute on-disk directory holding the plugin's source assets.
585    pub source_dir: PathBuf,
586}
587
588impl StaticDir {
589    /// Build a [`StaticDir`] from a namespace literal and any
590    /// `Into<PathBuf>` source (a `PathBuf`, `&Path`, or `String`/`&str`
591    /// computed from `env!("CARGO_MANIFEST_DIR")`).
592    pub fn new(namespace: &'static str, source_dir: impl Into<PathBuf>) -> Self {
593        Self {
594            namespace,
595            source_dir: source_dir.into(),
596        }
597    }
598}
599
600/// One callable endpoint a plugin advertises for service discovery.
601/// Returned from [`Plugin::api_endpoints`] and collected at
602/// `App::build()` into [`crate::migrate::registered_api_endpoints`].
603///
604/// The shape is deliberately minimal and origin-agnostic: `path` is
605/// relative, so the type carries no assumption about the public host.
606/// A consumer (a REST API root, a SPA) joins its own origin to build an
607/// absolute URL. `group` lets a consumer bucket endpoints by source
608/// (`"oauth"`, `"tasks"`); `name` is a stable machine key within the
609/// group (`"google.login"`); `label` is the human string a UI renders.
610#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
611pub struct ApiEndpoint {
612    /// Grouping key, e.g. `"oauth"`. Lets a consumer bucket endpoints
613    /// by the plugin/area that contributed them.
614    pub group: String,
615    /// Stable machine name within the group, e.g. `"google.login"`.
616    pub name: String,
617    /// HTTP method, uppercase: `"GET"`, `"POST"`, …
618    pub method: String,
619    /// Relative path, e.g. `"/oauth/google/login"`. No origin.
620    pub path: String,
621    /// Human label a UI renders, e.g. `"Sign in with Google"`.
622    pub label: String,
623}
624
625/// The handle plugins receive in `on_ready`.
626///
627/// Carries clones of the ambient state so a plugin can spawn background
628/// work or seal late registrations without touching globals. M7 v1
629/// surfaces the default pool and a settings snapshot; the runtime
630/// handle lands when the first plugin needs it (likely `umbral-tasks`
631/// at M9).
632#[derive(Debug, Clone)]
633pub struct AppContext {
634    /// The default connection pool, typed by backend. Same value as
635    /// `umbral::db::pool_dispatched().clone()` returns. Plugin code
636    /// that needs the pool typically goes through the ORM instead
637    /// (`Model::objects()…`); this field is the escape hatch for
638    /// schema-DDL bootstrap (the documented exception in CLAUDE.md)
639    /// and backend-specific features like Postgres RLS.
640    pub pool: DbPool,
641    /// A clone of the active settings.
642    pub settings: Settings,
643}
644
645/// Errors a plugin's `on_ready` can return. Boxed under
646/// `BuildError::PluginOnReady` so the build phase surfaces them with
647/// the plugin name attached.
648pub type PluginError = Box<dyn std::error::Error + Send + Sync>;