Skip to main content

rustio_admin/admin/
routes.rs

1//! Admin route registration with permission checks.
2//!
3//! Every admin URL is gated by a specific permission:
4//!   GET  /admin/:model            → posts.view_post
5//!   GET  /admin/:model/new        → posts.add_post
6//!   POST /admin/:model/new        → posts.add_post
7//!   GET  /admin/:model/:id/edit   → posts.change_post
8//!   POST /admin/:model/:id/edit   → posts.change_post
9//!   GET  /admin/:model/:id/delete → posts.delete_post
10//!   POST /admin/:model/:id/delete → posts.delete_post
11//!
12//! Administrator + Developer bypass every check (see
13//! `Role::bypasses_group_checks`). Staff and Supervisor need the
14//! specific permission granted either directly or via a group.
15//!
16//! Slimmed for Tier 1: the legacy file's developer stub routes
17//! (`__schema__`, `__logs__`, `__sql_console__`) and the FK remote-
18//! search endpoint have been dropped. Everything else — `/static/admin.css`
19//! and `/static/admin.js` (P8), login/logout, dashboard,
20//! /admin/users/*, /admin/groups/*, /admin/history,
21//! /admin/password_change, /admin/:model/* CRUD,
22//! /admin/:model/:id/history — is wired below.
23
24use std::sync::Arc;
25
26use crate::auth::{self, Identity, Role};
27use crate::error::{Error, Result};
28use crate::http::{Request, Response};
29use crate::orm::Db;
30use crate::router::Router;
31use crate::templates::Templates;
32
33/// Embedded stylesheet baked into the binary.
34///
35/// The admin stylesheet is authored as a multi-file architecture
36/// under `assets/static/admin/` (tokens → base → layout →
37/// components → pages → responsive → print), modelled on GitHub
38/// Primer / IBM Carbon. The browser still receives one concatenated
39/// bundle so we keep our "self-hosted, single round-trip, no FOUT"
40/// doctrine.
41///
42/// Order below MUST mirror the `@import` manifest in
43/// `assets/static/admin/admin.css` exactly — the two lists are the
44/// source of cascade order, and they must stay in lock-step or the
45/// served bundle will silently drift from what contributors author.
46///
47/// Project overrides happen two ways, both layered *after* this bundle
48/// so they win the cascade without `!important`: `Admin::theme(...)`
49/// emits a small inline `<style>` patch (a handful of `--rio-*` tokens),
50/// and `RUSTIO_TOKENS_CSS=<path>` appends a whole generated `tokens.css`
51/// (see [`admin_css_payload`] / [`load_token_override`]). The latter is
52/// how a `rustio-admin theme generate` palette reaches a running admin
53/// without recompiling the framework.
54const ADMIN_CSS: &str = concat!(
55    // ---- tokens -----------------------------------------------
56    include_str!("../../assets/static/admin/tokens/colors.css"),
57    "\n",
58    include_str!("../../assets/static/admin/tokens/spacing.css"),
59    "\n",
60    include_str!("../../assets/static/admin/tokens/radius.css"),
61    "\n",
62    include_str!("../../assets/static/admin/tokens/shadows.css"),
63    "\n",
64    include_str!("../../assets/static/admin/tokens/typography.css"),
65    "\n",
66    include_str!("../../assets/static/admin/tokens/motion.css"),
67    "\n",
68    include_str!("../../assets/static/admin/tokens/compat.css"),
69    "\n",
70    // ---- base -------------------------------------------------
71    include_str!("../../assets/static/admin/base/fonts.css"),
72    "\n",
73    include_str!("../../assets/static/admin/base/base.css"),
74    "\n",
75    include_str!("../../assets/static/admin/base/typography-i18n.css"),
76    "\n",
77    // ---- components -------------------------------------------
78    include_str!("../../assets/static/admin/components/buttons.css"),
79    "\n",
80    include_str!("../../assets/static/admin/components/forms.css"),
81    "\n",
82    include_str!("../../assets/static/admin/components/data.css"),
83    "\n",
84    include_str!("../../assets/static/admin/components/feedback.css"),
85    "\n",
86    include_str!("../../assets/static/admin/components/navigation.css"),
87    "\n",
88    include_str!("../../assets/static/admin/components/code.css"),
89    "\n",
90    // ---- layout (RustIO Console) ------------------------------
91    include_str!("../../assets/static/admin/layout/console.css"),
92    "\n",
93    // ---- pages ------------------------------------------------
94    include_str!("../../assets/static/admin/pages/dashboard.css"),
95    "\n",
96    include_str!("../../assets/static/admin/pages/list.css"),
97    "\n",
98    include_str!("../../assets/static/admin/pages/form.css"),
99    "\n",
100    include_str!("../../assets/static/admin/pages/auth.css"),
101    "\n",
102    include_str!("../../assets/static/admin/pages/states.css"),
103    "\n",
104    include_str!("../../assets/static/admin/pages/permissions.css"),
105    "\n",
106    include_str!("../../assets/static/admin/pages/detail.css"),
107    "\n",
108    include_str!("../../assets/static/admin/pages/account.css"),
109    "\n",
110    include_str!("../../assets/static/admin/pages/tools.css"),
111    "\n",
112    // ---- print ------------------------------------------------
113    include_str!("../../assets/static/admin/print/print.css"),
114);
115
116/// The CSS served at `/static/admin.css`: the baked [`ADMIN_CSS`] bundle,
117/// with an optional project token override appended.
118///
119/// The override (a generated `tokens.css`) is concatenated *after* the
120/// bundle, so its `:root` block wins the cascade — later `:root` wins,
121/// no `!important` needed. This mirrors how `Admin::theme(...)`'s inline
122/// patch already overrides the baked tokens. The runtime only ever
123/// handles the file's *bytes*: it never links `rio-theme`, so the
124/// "runtime never depends on the theme engine" invariant holds.
125///
126/// Pure on its input so it can be unit-tested without touching the
127/// process-global environment; [`load_token_override`] is the impure
128/// wrapper that reads `RUSTIO_TOKENS_CSS`.
129fn admin_css_payload(override_css: Option<&str>) -> bytes::Bytes {
130    match override_css {
131        Some(extra) => bytes::Bytes::from(format!(
132            "{ADMIN_CSS}\n/* ---- RUSTIO_TOKENS_CSS override ---- */\n{extra}"
133        )),
134        None => bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
135    }
136}
137
138/// Detect the "light-only override" hazard.
139///
140/// A `RUSTIO_TOKENS_CSS` file that defines `:root` color tokens but
141/// carries **no** dark handling (`[data-theme="dark"]` and
142/// `prefers-color-scheme`) is appended *after* the baked bundle, so its
143/// `:root` block ties the framework's `[data-theme="dark"]` on
144/// specificity and wins by source order — leaking light surfaces into
145/// dark mode. This is detection only; the override is never rewritten or
146/// wrapped (Doctrine: the operator's CSS is theirs).
147fn override_is_dark_leak_hazard(css: &str) -> bool {
148    let defines_root_colors = css.contains(":root")
149        && (css.contains("--rio-bg")
150            || css.contains("--rio-surface")
151            || css.contains("--rio-accent"));
152    let handles_dark =
153        css.contains("[data-theme=\"dark\"]") || css.contains("prefers-color-scheme");
154    defines_root_colors && !handles_dark
155}
156
157/// Read the `RUSTIO_TOKENS_CSS` override file, if the env var is set.
158///
159/// A set-but-unreadable path logs a warning and degrades to the baked
160/// bundle rather than failing boot — a theming typo must never take the
161/// admin offline. Returns `None` when the var is unset or the file can't
162/// be read. A readable but light-only override logs a WARN (see
163/// [`override_is_dark_leak_hazard`]) but is still served verbatim.
164fn load_token_override() -> Option<String> {
165    let path = std::env::var("RUSTIO_TOKENS_CSS").ok()?;
166    match std::fs::read_to_string(&path) {
167        Ok(css) => {
168            if override_is_dark_leak_hazard(&css) {
169                log::warn!(
170                    "RUSTIO_TOKENS_CSS={path:?} is a light-only override: it sets :root \
171                     color tokens but has no [data-theme=\"dark\"] or prefers-color-scheme \
172                     block, so it will leak light surfaces into dark mode. Regenerate with a \
173                     dark-aware generator or add a dark block."
174                );
175            }
176            Some(css)
177        }
178        Err(e) => {
179            log::warn!("RUSTIO_TOKENS_CSS={path:?} unreadable: {e}; serving baked CSS");
180            None
181        }
182    }
183}
184
185/// Embedded admin JS (sidebar drawer + dropdowns + bulk select +
186/// FK autocomplete). ≤200 LOC, no build step.
187const ADMIN_JS: &str = include_str!("../../assets/static/admin.js");
188
189/// Self-hosted fonts (SIL OFL-1.1, see assets/static/fonts/LICENSE.txt).
190/// Bundling them as bytes keeps the single-binary deploy story intact
191/// and avoids the FOUT/CDN round-trip every consuming app would
192/// otherwise inherit from a Google Fonts <link>.
193///
194/// Latin (identity): Inter Variable wght 100..900 (body + display).
195/// Mono: JetBrains Mono Variable (latin + latin-ext). Arabic: Noto
196/// Naskh Arabic Variable wght 400..700 (default reading face) +
197/// Tajawal 400/500/700 (selective geometric accent). International
198/// scripts: Noto Sans Thai + Devanagari (variable, auto-loaded via
199/// unicode-range) + Noto Sans JP/KR/SC (static Regular, lang-gated
200/// to avoid Han Unification shape collisions).
201const FONT_INTER: &[u8] = include_bytes!("../../assets/static/fonts/InterVariable.woff2");
202const FONT_TAJAWAL_REG: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Regular.woff2");
203const FONT_TAJAWAL_MED: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Medium.woff2");
204const FONT_TAJAWAL_BOLD: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Bold.woff2");
205const FONT_NOTO_NASKH_AR: &[u8] =
206    include_bytes!("../../assets/static/fonts/NotoNaskhArabic-Variable.woff2");
207const FONT_NOTO_THAI: &[u8] =
208    include_bytes!("../../assets/static/fonts/NotoSansThai-Variable.woff2");
209const FONT_NOTO_DEVA: &[u8] =
210    include_bytes!("../../assets/static/fonts/NotoSansDevanagari-Variable.woff2");
211const FONT_NOTO_JP: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansJP-Regular.woff2");
212const FONT_NOTO_KR: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansKR-Regular.woff2");
213const FONT_NOTO_SC: &[u8] = include_bytes!("../../assets/static/fonts/NotoSansSC-Regular.woff2");
214// Latin identity faces. Body + display: Inter (variable wght 100..900,
215// FONT_INTER above). Mono: JetBrains Mono (variable wght 400..700, latin +
216// latin-ext) — the framework mono stack is SFMono-system, but the shop
217// example's brand override selects JetBrains. @font-face in base/fonts.css.
218// (The prior Spectral / Hanken Grotesk faces were retired with the contract.)
219const FONT_JETBRAINS_LATIN: &[u8] =
220    include_bytes!("../../assets/static/fonts/JetBrainsMono-Variable-latin.woff2");
221const FONT_JETBRAINS_LATIN_EXT: &[u8] =
222    include_bytes!("../../assets/static/fonts/JetBrainsMono-Variable-latinext.woff2");
223
224/// One woff2 response: immutable 1-year cache (bytes never change per build).
225fn font_response(bytes: &'static [u8]) -> Response {
226    Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
227        .with_header("content-type", "font/woff2")
228        .with_header("cache-control", "public, max-age=31536000, immutable")
229}
230
231/// Register every served font route. Ctx-free, so it's unit-testable
232/// (a removed face's path 404s; see `tests`). The retired Geist /
233/// Spectral / Hanken faces are intentionally absent.
234fn register_font_routes(router: Router) -> Router {
235    const FONTS: &[(&str, &[u8])] = &[
236        ("/static/fonts/InterVariable.woff2", FONT_INTER),
237        (
238            "/static/fonts/JetBrainsMono-Variable-latin.woff2",
239            FONT_JETBRAINS_LATIN,
240        ),
241        (
242            "/static/fonts/JetBrainsMono-Variable-latinext.woff2",
243            FONT_JETBRAINS_LATIN_EXT,
244        ),
245        ("/static/fonts/Tajawal-Regular.woff2", FONT_TAJAWAL_REG),
246        ("/static/fonts/Tajawal-Medium.woff2", FONT_TAJAWAL_MED),
247        ("/static/fonts/Tajawal-Bold.woff2", FONT_TAJAWAL_BOLD),
248        (
249            "/static/fonts/NotoNaskhArabic-Variable.woff2",
250            FONT_NOTO_NASKH_AR,
251        ),
252        ("/static/fonts/NotoSansThai-Variable.woff2", FONT_NOTO_THAI),
253        (
254            "/static/fonts/NotoSansDevanagari-Variable.woff2",
255            FONT_NOTO_DEVA,
256        ),
257        ("/static/fonts/NotoSansJP-Regular.woff2", FONT_NOTO_JP),
258        ("/static/fonts/NotoSansKR-Regular.woff2", FONT_NOTO_KR),
259        ("/static/fonts/NotoSansSC-Regular.woff2", FONT_NOTO_SC),
260    ];
261    let mut router = router;
262    for &(path, bytes) in FONTS {
263        router = router.get(path, move |_req| async move { Ok(font_response(bytes)) });
264    }
265    router
266}
267
268use super::handlers::{self, AdminCtx};
269use super::render;
270use super::types::Admin;
271
272/// Either an identity + a permission check passed, or any non-Allow
273/// response the route closure should return as-is (a 303 redirect to
274/// /admin/login, a 403 forbidden body, etc.).
275enum Guard {
276    Allow(Identity),
277    Redirect(Response),
278}
279
280/// Paths a user with `must_change_password = TRUE` is allowed to
281/// reach without first completing the forced rotation.
282/// Locked-decision per `DESIGN_R2_ORGANISATIONAL.md` §12.
283///
284/// Exact-path match (no prefix matching). Sub-paths of
285/// `/admin/account/sessions` (e.g. `/admin/account/sessions/revoke`)
286/// are intentionally NOT whitelisted — a user being forced to
287/// rotate may view their active sessions but must finish the
288/// rotation before revoking siblings.
289const MUST_CHANGE_WHITELIST: &[&str] = &[
290    "/admin/must-change-password",
291    "/admin/logout",
292    "/admin/account/sessions",
293];
294
295/// Whether `path` is on the must-change-password whitelist.
296/// Pulled out as a free fn so the rule is unit-testable without a
297/// `Request`. See [`MUST_CHANGE_WHITELIST`] for the contract.
298fn is_must_change_whitelisted_path(path: &str) -> bool {
299    MUST_CHANGE_WHITELIST.contains(&path)
300}
301
302/// Exact-path read-only-mode allowlist for mutating verbs
303/// (POST / PUT / DELETE). When [`Admin::read_only`] is on, every
304/// other mutating request under `/admin/*` is rejected with 403;
305/// the entries here keep auth-flow round-trips working so an
306/// operator can still sign in and out of a frozen admin.
307///
308/// Sub-paths under `/admin/account/sessions/`, `/admin/account/mfa/`,
309/// and `/admin/reset-password/` need prefix matching (variable
310/// `:id` / `:token` segments); [`is_read_only_writable_path`]
311/// handles those separately.
312const READ_ONLY_EXACT_ALLOW: &[&str] = &[
313    "/admin/login",
314    "/admin/logout",
315    "/admin/reauth",
316    "/admin/forgot-password",
317    "/admin/mfa/verify",
318    "/admin/must-change-password",
319    "/admin/password_change",
320];
321
322const READ_ONLY_PREFIX_ALLOW: &[&str] = &[
323    // Token-bearing self-recovery URL — operators must be able to
324    // consume a reset link even when project mutations are frozen.
325    "/admin/reset-password/",
326    // Own-session management — sign-out-everywhere, revoke this
327    // device, etc. Identity self-service is not "data mutation."
328    "/admin/account/sessions/",
329    // Own MFA management — enrolment, regeneration, disable. Same
330    // identity-self-service reasoning.
331    "/admin/account/mfa/",
332];
333
334/// `/admin/<model>/saved_filters` and its
335/// `/admin/<model>/saved_filters/<id>/delete` sibling are
336/// per-operator UI state (bookmarks for the list page), not
337/// project-data mutations. Allowlist by suffix-match so they
338/// remain usable even in read-only mode.
339fn is_saved_filter_path(path: &str) -> bool {
340    if let Some(rest) = path.strip_prefix("/admin/") {
341        // Path shape: `<admin_name>/saved_filters` or
342        // `<admin_name>/saved_filters/<id>/delete`. Find the
343        // first `/saved_filters` segment after the model slug.
344        if let Some((_, after)) = rest.split_once('/') {
345            return after == "saved_filters" || after.starts_with("saved_filters/");
346        }
347    }
348    false
349}
350
351/// `true` when the HTTP method writes to state — POST, PUT,
352/// PATCH, DELETE. GET / HEAD / OPTIONS pass through the read-only
353/// guard untouched. Idempotency isn't the discriminator (PUT/PATCH
354/// are mutating despite being idempotent) — the question is "does
355/// this typically mutate server state?"
356pub(crate) fn is_mutating_method(method: &hyper::Method) -> bool {
357    matches!(
358        *method,
359        hyper::Method::POST | hyper::Method::PUT | hyper::Method::PATCH | hyper::Method::DELETE
360    )
361}
362
363/// Returns `true` when a mutating request to `path` should be
364/// allowed despite [`Admin::read_only`] being on. The narrow set
365/// of exceptions covers identity / auth flows so a read-only
366/// admin is still usable as a sign-in surface.
367///
368/// Pulled out as a free fn so the policy is unit-testable
369/// without a `Request`.
370pub(crate) fn is_read_only_writable_path(path: &str) -> bool {
371    if READ_ONLY_EXACT_ALLOW.contains(&path) {
372        return true;
373    }
374    if READ_ONLY_PREFIX_ALLOW
375        .iter()
376        .any(|prefix| path.starts_with(prefix))
377    {
378        return true;
379    }
380    is_saved_filter_path(path)
381}
382
383/// Extract the `:admin_name` URL segment from a request path of the
384/// shape `/admin/<admin_name>[/...]`. Returns `None` for paths that
385/// don't match (root `/admin/`, framework reserved `/admin/_*`, or
386/// non-admin paths). Used by the per-model read-only gate so the
387/// middleware can check `Admin::is_model_read_only` without parsing
388/// the router's `:admin_name` capture.
389pub(crate) fn extract_admin_name(path: &str) -> Option<&str> {
390    let rest = path.strip_prefix("/admin/")?;
391    let slug = rest.split('/').next()?;
392    if slug.is_empty() || slug.starts_with('_') {
393        return None;
394    }
395    Some(slug)
396}
397
398/// Paths reachable when `MfaPolicy::Required` is active and the
399/// user has not yet enrolled (R3 commit #18). Forward-only
400/// enforcement per `DESIGN_R3_MFA.md` D6: existing sessions
401/// continue to work, but every non-whitelisted request from a
402/// not-yet-enrolled user redirects to the enrolment form.
403/// Mirrors [`MUST_CHANGE_WHITELIST`]'s shape so the two
404/// interstitial flows compose identically when both gates fire.
405///
406/// Exact-path match. Sub-paths of `/admin/account/sessions`
407/// (e.g. `/admin/account/sessions/revoke`) are NOT whitelisted
408/// — a user being forced to enrol may view their active
409/// sessions but must finish enrolment before revoking siblings.
410const MFA_ENROLL_WHITELIST: &[&str] = &[
411    "/admin/account/mfa/enroll",
412    "/admin/logout",
413    "/admin/account/sessions",
414];
415
416fn is_mfa_enroll_whitelisted_path(path: &str) -> bool {
417    MFA_ENROLL_WHITELIST.contains(&path)
418}
419
420/// Paths reachable when the user has MFA enrolled but the
421/// current session has not yet been promoted to `mfa_verified`
422/// (the post-password, pre-MFA-verify window from R3 commit
423/// #16's `do_login`). The user can complete the second-factor
424/// verify, log out, or inspect their active sessions — nothing
425/// else.
426///
427/// Exact-path match. See [`MFA_ENROLL_WHITELIST`] for the
428/// rationale around the sessions page.
429const MFA_VERIFY_WHITELIST: &[&str] = &[
430    "/admin/mfa/verify",
431    "/admin/logout",
432    "/admin/account/sessions",
433];
434
435fn is_mfa_verify_whitelisted_path(path: &str) -> bool {
436    MFA_VERIFY_WHITELIST.contains(&path)
437}
438
439/// Whether the active `MfaPolicy` requires MFA for a given
440/// role. Pulled out as a free fn so the rule is unit-testable
441/// without an `Admin` context.
442///
443/// `MfaPolicy::Disabled` / `Optional` → never required.
444/// `MfaPolicy::Required` → required for every role.
445/// `MfaPolicy::RequiredForRoles(roles)` → required iff the
446///   user's role appears in the slice. An empty slice reads
447///   as "no role requires MFA" — equivalent to `Optional`.
448fn mfa_required_for_role(policy: crate::auth::MfaPolicy, role: Role) -> bool {
449    use crate::auth::MfaPolicy;
450    match policy {
451        MfaPolicy::Disabled | MfaPolicy::Optional => false,
452        MfaPolicy::Required => true,
453        MfaPolicy::RequiredForRoles(roles) => roles.contains(&role),
454    }
455}
456
457async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
458    let cookie = match req.header("cookie") {
459        Some(c) => c,
460        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
461    };
462    let token = match auth::session_token_from_cookie(cookie) {
463        Some(t) => t,
464        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
465    };
466    let ident = match auth::identity_from_session(&ctx.db, &token).await? {
467        Some(i) => i,
468        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
469    };
470    if !ident.is_active {
471        return Ok(Guard::Redirect(Response::redirect("/admin/login")));
472    }
473
474    // R2 forced-rotation gate (`DESIGN_R2_ORGANISATIONAL.md` §3.4 +
475    // §9.2). When the flag is set, every authenticated request EXCEPT
476    // the whitelist redirects to `/admin/must-change-password`. The
477    // check sits BEFORE any role gate so even Administrators /
478    // Developers with the flag set are funnelled through.
479    if ident.must_change_password && !is_must_change_whitelisted_path(req.path()) {
480        return Ok(Guard::Redirect(Response::redirect(
481            "/admin/must-change-password",
482        )));
483    }
484
485    // R3 MFA-required gate — forward-only per D6
486    // (`DESIGN_R3_MFA.md` §12.3). When the active MfaPolicy
487    // requires MFA for this user's role AND they have not
488    // enrolled, every non-whitelisted request redirects to the
489    // enrolment form. Existing sessions continue to work; the
490    // redirect kicks in at the NEXT request, not at the moment
491    // the policy flips. This matches R2's must-change-password
492    // shape — see MFA_ENROLL_WHITELIST for the reachable paths.
493    let policy = ctx.admin.active_mfa_policy();
494    if mfa_required_for_role(policy, ident.role)
495        && !ident.mfa_enabled
496        && !is_mfa_enroll_whitelisted_path(req.path())
497    {
498        return Ok(Guard::Redirect(Response::redirect(
499            "/admin/account/mfa/enroll",
500        )));
501    }
502
503    // R3 pending-MFA-verify gate (`DESIGN_R3_MFA.md` §4.2 +
504    // §12.3). When the user has MFA enrolled but the current
505    // session has not yet been promoted to mfa_verified (the
506    // post-password, pre-MFA-verify window from commit #16's
507    // do_login), restrict access to the MFA verify whitelist.
508    // The verify POST handler rotates the session via
509    // promote_session_to_mfa_verified once both factors land.
510    use crate::auth::SessionTrust;
511    if ident.mfa_enabled
512        && ident.trust_level != SessionTrust::MfaVerified
513        && !is_mfa_verify_whitelisted_path(req.path())
514    {
515        return Ok(Guard::Redirect(Response::redirect("/admin/mfa/verify")));
516    }
517
518    Ok(Guard::Allow(ident))
519}
520
521async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
522    match login_guard(ctx, req).await? {
523        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
524        Guard::Allow(ident) => {
525            if ident.role.includes(min) {
526                Ok(Guard::Allow(ident))
527            } else {
528                let body = render::render_forbidden_body(
529                    &ctx.admin,
530                    &ctx.templates,
531                    &ident,
532                    handlers::csrf_token(req),
533                    None,
534                    Some(min.label()),
535                )?;
536                Ok(Guard::Redirect(
537                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
538                ))
539            }
540        }
541    }
542}
543
544async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
545    match role_guard(ctx, req, Role::Staff).await? {
546        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
547        Guard::Allow(ident) => {
548            if ident.role.bypasses_group_checks() {
549                return Ok(Guard::Allow(ident));
550            }
551            if auth::check_permission(&ctx.db, &ident, perm).await? {
552                Ok(Guard::Allow(ident))
553            } else {
554                let body = render::render_forbidden_body(
555                    &ctx.admin,
556                    &ctx.templates,
557                    &ident,
558                    handlers::csrf_token(req),
559                    Some(perm.to_string()),
560                    None,
561                )?;
562                Ok(Guard::Redirect(
563                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
564                ))
565            }
566        }
567    }
568}
569
570/// Pure decision logic for `perm_guard`, factored out so it can be
571/// unit-tested without a `Db`.
572#[cfg(test)]
573fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
574    if !ident.is_active {
575        return false;
576    }
577    if ident.role.bypasses_group_checks() {
578        return true;
579    }
580    perm_held
581}
582
583fn parse_id(raw: Option<&str>) -> Result<i64> {
584    raw.and_then(|s| s.parse().ok())
585        .ok_or_else(|| Error::BadRequest("invalid id".into()))
586}
587
588fn model_name_from_req(req: &Request) -> Result<String> {
589    req.param("admin_name")
590        .map(|s| s.to_string())
591        .ok_or_else(|| Error::BadRequest("missing model".into()))
592}
593
594fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
595    let entry = ctx
596        .admin
597        .find(admin_name)
598        .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
599    let singular = entry.singular_name.to_ascii_lowercase();
600    Ok(format!("{admin_name}.{action}_{singular}"))
601}
602
603/// Pure verdict for the R1 strict-mailer boot guard
604/// (`DESIGN_RECOVERY.md` §12.1). Returns an operator-facing error
605/// string when the policy demands a real mailer but `Admin::new()`'s
606/// default `LogMailer` is still in place; returns `Ok(())`
607/// otherwise.
608///
609/// Detection is deterministic and structural: it reads
610/// [`Admin::has_custom_mailer`] (set whenever
611/// [`Admin::mailer`] has been called). No `Arc::ptr_eq` against a
612/// freshly-constructed `LogMailer`; no environment heuristics; no
613/// hostname checks; no "production mode" guessing — the operator
614/// declares intent by calling `Admin::mailer(...)` (and opts the
615/// policy in via `RecoveryPolicy::strict_mailer_required(true)`).
616///
617/// The framework treats an explicit `Admin::mailer(...)` call as
618/// satisfying the guard even when the supplied mailer is itself a
619/// `LogMailer` — this is the documented escape hatch for projects
620/// that want to silence the guard during a migration window
621/// without yet wiring a real transport.
622/// Best-effort identity resolution for the chrome of an admin
623/// error page. Mirrors what `auth::routes::login_guard` does on
624/// the success path, but is non-fatal: any failure returns `None`
625/// and the error page falls back to the unauthenticated chrome
626/// (just like the original behaviour).
627///
628/// `VISIBILITY_AUDIT.md` A2: without this lookup, every 4xx/5xx
629/// page rendered as if the operator were logged out, so the
630/// operator lost their sidebar + top-bar account links the
631/// moment they hit an erroring route — a navigational dead-end.
632async fn resolve_identity_for_error_page(db: &Db, cookie_header: &str) -> Option<Identity> {
633    let token = auth::session_token_from_cookie(cookie_header)?;
634    let identity = auth::identity_from_session(db, token.as_str())
635        .await
636        .ok()
637        .flatten()?;
638    if !identity.is_active {
639        return None;
640    }
641    Some(identity)
642}
643
644fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
645    if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
646        Err(
647            "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
648             was registered via Admin::mailer(...).\n\n\
649             The framework's default LogMailer writes recovery emails to log::info! instead \
650             of sending them, which is unsuitable for production. Recovery routes are NOT \
651             registered with this configuration.\n\n\
652             To resolve, choose one:\n\
653              (a) register a real mailer before calling register_admin_routes:\n\
654                  Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
655              (b) opt the policy out of strict mode (the framework default — dev / CI / \
656                  testing baseline):\n\
657                  RecoveryPolicy::strict_mailer_required(false)\n\n\
658             See DESIGN_RECOVERY.md §12.1 for the contract."
659                .to_string(),
660        )
661    } else {
662        Ok(())
663    }
664}
665
666// public:
667pub fn register_admin_routes(
668    router: Router,
669    admin: Admin,
670    db: Db,
671    templates: Arc<Templates>,
672) -> Router {
673    // R1 commit #9 — strict-mailer boot guard. Runs BEFORE any
674    // route registration so a misconfigured deployment fails
675    // loudly at startup rather than registering recovery routes
676    // against a production-unsafe default mailer
677    // (`DESIGN_RECOVERY.md` §12.1). The check is structural: see
678    // [`strict_mailer_guard_check`] for why we don't do
679    // pointer-equality tricks against the default LogMailer.
680    if let Err(msg) = strict_mailer_guard_check(&admin) {
681        panic!("{msg}");
682    }
683
684    let ctx = Arc::new(AdminCtx::new(
685        Arc::new(admin),
686        db.clone(),
687        templates.clone(),
688    ));
689
690    // Bespoke user/group pages share the same DB / templates / Admin
691    // arc but live in their own ctx type with the same shape.
692    let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
693        admin: ctx.admin.clone(),
694        db,
695        templates,
696    });
697
698    // Render `Err(_)` from /admin/* handlers as styled HTML instead of
699    // the framework default `text/plain`. Non-admin paths bubble
700    // through unchanged so JSON / curl consumers still get the text
701    // body. `Error::Forbidden` (handled by `role_guard` via
702    // `admin/forbidden.html`) and login-required redirects come
703    // through as `Ok` responses and bypass this branch.
704    //
705    // The middleware also resolves the operator's identity from the
706    // session cookie BEFORE handing off, so the error page renders
707    // with the same chrome (sidebar, top-bar actor, "Log out") the
708    // operator was using when they hit the failing route. Without
709    // this lookup the 404 page rendered as if the operator were
710    // unauthenticated — a navigational dead-end documented as
711    // `VISIBILITY_AUDIT.md` finding A2.
712    let err_admin = ctx.admin.clone();
713    let err_templates = ctx.templates.clone();
714    let err_db = ctx.db.clone();
715    let router = router.middleware(move |req, next| {
716        let admin = err_admin.clone();
717        let templates = err_templates.clone();
718        let db = err_db.clone();
719        Box::pin(async move {
720            let is_admin_path = req.path().starts_with("/admin");
721            // Capture the cookie header BEFORE moving `req` into
722            // `next.run` so the error branch can re-resolve the
723            // operator's identity. The auth middleware sits inside
724            // this one; the request is consumed by `next.run` before
725            // we get to the `Err` branch.
726            let cookie_header = if is_admin_path {
727                req.header("cookie").map(|s| s.to_string())
728            } else {
729                None
730            };
731            let result = next.run(req).await;
732            match result {
733                Ok(resp) => Ok(resp),
734                Err(err) if is_admin_path => {
735                    let identity = match cookie_header.as_deref() {
736                        Some(cookie) => resolve_identity_for_error_page(&db, cookie).await,
737                        None => None,
738                    };
739                    Ok(render::render_admin_error_response(
740                        &admin,
741                        &templates,
742                        identity.as_ref(),
743                        err.status(),
744                        err.client_message().to_string(),
745                    ))
746                }
747                Err(err) => Err(err),
748            }
749        })
750    });
751
752    // Read-only guard. When [`Admin::read_only`] is on, every
753    // mutating verb (POST / PUT / PATCH / DELETE) under `/admin/*`
754    // returns 403 — except a small allowlist for auth flows
755    // (login / logout / reauth / MFA verify / password recovery /
756    // own-session management). Mounted AFTER the error-renderer
757    // above so the 403 gets the styled HTML page treatment instead
758    // of plain text. Mounted BEFORE every route registration below
759    // so the chain runs early and the actual handler never sees a
760    // blocked mutation. Pass-through (`next.run(req)`) when the
761    // admin isn't read-only — zero overhead for non-frozen
762    // deployments.
763    let ro_flag = ctx.admin.is_read_only();
764    let ro_models = std::sync::Arc::new(ctx.admin.read_only_models.clone());
765    let router = router.middleware(move |req, next| {
766        let ro_models = ro_models.clone();
767        Box::pin(async move {
768            if req.path().starts_with("/admin")
769                && is_mutating_method(req.method())
770                && !is_read_only_writable_path(req.path())
771            {
772                // Whole-admin read-only takes precedence: every
773                // project-data mutation is rejected.
774                if ro_flag {
775                    return Err(Error::Forbidden(
776                        "This admin is currently in read-only mode. \
777                         Project-data mutations are disabled until the operator \
778                         turns read-only off."
779                            .into(),
780                    ));
781                }
782                // Per-model read-only: extract the `:admin_name`
783                // segment and check the frozen set. A frozen slug
784                // stays frozen even if the rest of the admin is
785                // writable.
786                if !ro_models.is_empty() {
787                    if let Some(slug) = extract_admin_name(req.path()) {
788                        if ro_models.contains(slug) {
789                            return Err(Error::Forbidden(format!(
790                                "Model `{slug}` is frozen (read-only). \
791                                 Mutations on this model are disabled."
792                            )));
793                        }
794                    }
795                }
796            }
797            next.run(req).await
798        })
799    });
800
801    // Embedded stylesheet + JS. The bytes are baked into the binary
802    // so single-binary deploy is preserved. CSS/JS use `no-cache`
803    // (revalidate every request) so theme + design tweaks roll out the
804    // moment the binary restarts; fonts (next block) keep their long
805    // immutable cache because their bytes never change per release.
806    // Compute the CSS payload once at router-build time (baked bundle +
807    // optional RUSTIO_TOKENS_CSS override). The `no-cache` header means a
808    // restart is what rolls a theme change out, so reading the override
809    // file here — not per request — matches the existing semantics and
810    // keeps the hot path free of disk IO. `Bytes` is Arc-backed, so the
811    // per-request clone is cheap.
812    let admin_css = admin_css_payload(load_token_override().as_deref());
813    let router = router.get("/static/admin.css", move |_req| {
814        let body = admin_css.clone();
815        async move {
816            Ok(Response::new(hyper::StatusCode::OK, body)
817                .with_header("content-type", "text/css; charset=utf-8")
818                .with_header("cache-control", "no-cache, must-revalidate"))
819        }
820    });
821    let router = router.get("/static/admin.js", |_req| async move {
822        Ok(Response::new(
823            hyper::StatusCode::OK,
824            bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
825        )
826        .with_header("content-type", "application/javascript; charset=utf-8")
827        .with_header("cache-control", "no-cache, must-revalidate"))
828    });
829
830    // Self-hosted fonts (baked, immutable 1-year cache). Registered via a
831    // ctx-free helper so the served set is unit-testable (see tests).
832    let router = register_font_routes(router);
833
834    // Public: liveness / readiness probe. No auth — load
835    // balancers and k8s probes don't carry session cookies.
836    // Registered ahead of `/admin/:admin_name` so the route
837    // can't be shadowed by a model literally named `healthz`.
838    let c = ctx.clone();
839    let router = router.get("/admin/healthz", move |_req| {
840        let c = c.clone();
841        async move { super::healthz::healthz(&c.db).await }
842    });
843
844    // Public: login/logout.
845    let c = ctx.clone();
846    let router = router.get("/admin/login", move |req| {
847        let c = c.clone();
848        async move { handlers::show_login(&c, req).await }
849    });
850
851    let c = ctx.clone();
852    let router = router.post("/admin/login", move |req| {
853        let c = c.clone();
854        async move { handlers::do_login(&c, req).await }
855    });
856
857    let c = ctx.clone();
858    let router = router.post("/admin/logout", move |req| {
859        let c = c.clone();
860        async move { handlers::do_logout(&c, req).await }
861    });
862
863    // === R1 recovery routes ====================================
864    //
865    // MUST be registered BEFORE the `/admin/:admin_name` model
866    // wildcards lower down — without that ordering, a request to
867    // `/admin/forgot-password` would match `:admin_name =
868    // "forgot-password"` and route into the model CRUD handler.
869    //
870    // Recovery state (the rate-limit buckets) is built once here
871    // and cloned into each route closure so the buckets persist
872    // for the process lifetime. No global / static / OnceLock —
873    // the Arc lives in the closures.
874    //
875    // Strict-mailer boot guard already ran at the top of this fn
876    // (would have panicked if misconfigured); reaching this block
877    // means we have the operator's blessing to wire recovery.
878
879    let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
880        &ctx.admin,
881    ));
882
883    let c = ctx.clone();
884    let router = router.get("/admin/forgot-password", move |req| {
885        let c = c.clone();
886        async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
887    });
888
889    let c = ctx.clone();
890    let rs = recovery_state.clone();
891    let router = router.post("/admin/forgot-password", move |req| {
892        let c = c.clone();
893        let rs = rs.clone();
894        async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
895    });
896
897    let c = ctx.clone();
898    let router = router.get("/admin/forgot-password/sent", move |req| {
899        let c = c.clone();
900        async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
901    });
902
903    let c = ctx.clone();
904    let router = router.get("/admin/reset-password/:token", move |req| {
905        let c = c.clone();
906        async move {
907            let token = req
908                .param("token")
909                .ok_or_else(|| Error::BadRequest("missing token".into()))?
910                .to_string();
911            super::recovery_handlers::show_reset_password(&c, &req, &token).await
912        }
913    });
914
915    let c = ctx.clone();
916    let rs = recovery_state.clone();
917    let router = router.post("/admin/reset-password/:token", move |req| {
918        let c = c.clone();
919        let rs = rs.clone();
920        async move {
921            let token = req
922                .param("token")
923                .ok_or_else(|| Error::BadRequest("missing token".into()))?
924                .to_string();
925            super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
926        }
927    });
928
929    // Dashboard — Staff floor. User-tier sees the forbidden page.
930    let c = ctx.clone();
931    let router = router.get("/admin", move |req| {
932        let c = c.clone();
933        async move {
934            match role_guard(&c, &req, Role::Staff).await? {
935                Guard::Redirect(r) => Ok(r),
936                Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
937            }
938        }
939    });
940
941    // Database browser — Developer-only schema explorer at
942    // `/admin/db`. Read-only `information_schema` / `pg_catalog`
943    // queries; no DDL, no row sampling. Registered before the
944    // generic `/admin/:admin_name` so a model coincidentally named
945    // `db` can't shadow it (also the static literal wins on tie).
946    let c = ctx.clone();
947    let router = router.get("/admin/db", move |req| {
948        let c = c.clone();
949        async move {
950            match role_guard(&c, &req, Role::Developer).await? {
951                Guard::Redirect(r) => Ok(r),
952                Guard::Allow(ident) => super::db_browser::show_db_browser(&c, ident, &req).await,
953            }
954        }
955    });
956
957    // Notifications — per-operator list page. Any signed-in
958    // operator sees their own notifications (filtered by user_id
959    // in the handler), so the gate is just Staff. The topbar
960    // bell links here from every authenticated page.
961    let c = ctx.clone();
962    let router = router.get("/admin/notifications", move |req| {
963        let c = c.clone();
964        async move {
965            match role_guard(&c, &req, Role::Staff).await? {
966                Guard::Redirect(r) => Ok(r),
967                Guard::Allow(ident) => handlers::show_notifications(&c, ident, &req).await,
968            }
969        }
970    });
971    let c = ctx.clone();
972    let router = router.post("/admin/notifications/mark_all_read", move |req| {
973        let c = c.clone();
974        async move {
975            match role_guard(&c, &req, Role::Staff).await? {
976                Guard::Redirect(r) => Ok(r),
977                Guard::Allow(ident) => {
978                    handlers::do_mark_all_notifications_read(&c, ident, req).await
979                }
980            }
981        }
982    });
983
984    // Feature flags — Administrator-only management page.
985    // Lists existing flags with toggle buttons + a "create"
986    // form. Reads in project code via
987    // `rustio_admin::feature_enabled(db, key)`.
988    let c = ctx.clone();
989    let router = router.get("/admin/feature_flags", move |req| {
990        let c = c.clone();
991        async move {
992            match role_guard(&c, &req, Role::Administrator).await? {
993                Guard::Redirect(r) => Ok(r),
994                Guard::Allow(ident) => handlers::show_feature_flags(&c, ident, &req).await,
995            }
996        }
997    });
998    let c = ctx.clone();
999    let router = router.post("/admin/feature_flags", move |req| {
1000        let c = c.clone();
1001        async move {
1002            match role_guard(&c, &req, Role::Administrator).await? {
1003                Guard::Redirect(r) => Ok(r),
1004                Guard::Allow(ident) => handlers::do_create_feature_flag(&c, ident, req).await,
1005            }
1006        }
1007    });
1008    let c = ctx.clone();
1009    let router = router.post("/admin/feature_flags/:key/toggle", move |req| {
1010        let c = c.clone();
1011        async move {
1012            let key = req
1013                .param("key")
1014                .ok_or_else(|| Error::BadRequest("missing flag key".into()))?
1015                .to_string();
1016            match role_guard(&c, &req, Role::Administrator).await? {
1017                Guard::Redirect(r) => Ok(r),
1018                Guard::Allow(ident) => handlers::do_toggle_feature_flag(&c, ident, &key, req).await,
1019            }
1020        }
1021    });
1022
1023    // Health dashboard — Administrator-only web counterpart to
1024    // `rustio-admin doctor`. Runs the same DB probes the CLI does
1025    // (Postgres reachable, auth tables present, ≥1 active admin,
1026    // RUSTIO_SECRET_KEY shape). Distinct from `/admin/healthz`
1027    // which is the public liveness probe.
1028    let c = ctx.clone();
1029    let router = router.get("/admin/health", move |req| {
1030        let c = c.clone();
1031        async move {
1032            match role_guard(&c, &req, Role::Administrator).await? {
1033                Guard::Redirect(r) => Ok(r),
1034                Guard::Allow(ident) => handlers::show_health(&c, ident, &req).await,
1035            }
1036        }
1037    });
1038
1039    // Global history log (admin-only; high-signal page).
1040    let c = ctx.clone();
1041    let router = router.get("/admin/history", move |req| {
1042        let c = c.clone();
1043        async move {
1044            match role_guard(&c, &req, Role::Administrator).await? {
1045                Guard::Redirect(r) => Ok(r),
1046                Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
1047            }
1048        }
1049    });
1050
1051    // Self-service active-sessions listing (R0). Any logged-in user
1052    // (User-tier and above) can see their own active sessions.
1053    let c = ctx.clone();
1054    let router = router.get("/admin/account/sessions", move |req| {
1055        let c = c.clone();
1056        async move {
1057            match role_guard(&c, &req, Role::User).await? {
1058                Guard::Redirect(r) => Ok(r),
1059                Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
1060            }
1061        }
1062    });
1063
1064    // R1 commit #10 — active-sessions revoke buttons. All three
1065    // POST routes go through `auth::invalidate_sessions` (Doctrine
1066    // 22) and write `AuditEvent::SessionsRevokedSelf` per revoked
1067    // id. The `/revoke-others` and `/revoke-all` literal segments
1068    // sit at depth-4 while `:id/revoke` sits at depth-5, so segment
1069    // count alone disambiguates them — no explicit ordering
1070    // constraint between the three.
1071    let c = ctx.clone();
1072    let router = router.post("/admin/account/sessions/revoke-others", move |req| {
1073        let c = c.clone();
1074        async move {
1075            match role_guard(&c, &req, Role::User).await? {
1076                Guard::Redirect(r) => Ok(r),
1077                Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
1078            }
1079        }
1080    });
1081
1082    let c = ctx.clone();
1083    let router = router.post("/admin/account/sessions/revoke-all", move |req| {
1084        let c = c.clone();
1085        async move {
1086            match role_guard(&c, &req, Role::User).await? {
1087                Guard::Redirect(r) => Ok(r),
1088                Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
1089            }
1090        }
1091    });
1092
1093    let c = ctx.clone();
1094    let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
1095        let c = c.clone();
1096        async move {
1097            match role_guard(&c, &req, Role::User).await? {
1098                Guard::Redirect(r) => Ok(r),
1099                Guard::Allow(ident) => {
1100                    let id = parse_id(req.param("id"))?;
1101                    handlers::do_revoke_session(&c, ident, req, id).await
1102                }
1103            }
1104        }
1105    });
1106
1107    // Self-service password change. Any logged-in user (User-tier and
1108    // above). User-tier can change their own password even though
1109    // they can't access the dashboard.
1110    let c = ctx.clone();
1111    let router = router.get("/admin/password_change", move |req| {
1112        let c = c.clone();
1113        async move {
1114            match role_guard(&c, &req, Role::User).await? {
1115                Guard::Redirect(r) => Ok(r),
1116                Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
1117            }
1118        }
1119    });
1120    let c = ctx.clone();
1121    let router = router.post("/admin/password_change", move |req| {
1122        let c = c.clone();
1123        async move {
1124            match role_guard(&c, &req, Role::User).await? {
1125                Guard::Redirect(r) => Ok(r),
1126                Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
1127            }
1128        }
1129    });
1130
1131    // === R2 re-auth wall (R2 commit #11) ====================================
1132    //
1133    // Standalone wall: any authenticated user can promote their own
1134    // session into the elevated band by re-entering their password.
1135    // The handler validates `return_to` strictly (only `/admin*`
1136    // paths; see `admin_recovery_handlers::validate_return_to`).
1137    // Any role from User-tier upward.
1138
1139    let c = ctx.clone();
1140    let router = router.get("/admin/reauth", move |req| {
1141        let c = c.clone();
1142        async move {
1143            match role_guard(&c, &req, Role::User).await? {
1144                Guard::Redirect(r) => Ok(r),
1145                Guard::Allow(ident) => {
1146                    super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
1147                }
1148            }
1149        }
1150    });
1151
1152    let c = ctx.clone();
1153    let router = router.post("/admin/reauth", move |req| {
1154        let c = c.clone();
1155        async move {
1156            match role_guard(&c, &req, Role::User).await? {
1157                Guard::Redirect(r) => Ok(r),
1158                Guard::Allow(ident) => {
1159                    super::admin_recovery_handlers::do_reauth(&c, ident, req).await
1160                }
1161            }
1162        }
1163    });
1164
1165    // === R2 forced password rotation (R2 commit #12) ========================
1166    //
1167    // The `must_change_password` interstitial is the only writeable
1168    // surface a user can reach while their flag is TRUE. The path is
1169    // on `MUST_CHANGE_WHITELIST`; the `login_guard` redirect therefore
1170    // skips it (otherwise the rotation would be unreachable). Role::User
1171    // matches: any authenticated user can be forced to rotate, even a
1172    // User-tier account that can't access the dashboard.
1173
1174    let c = ctx.clone();
1175    let router = router.get("/admin/must-change-password", move |req| {
1176        let c = c.clone();
1177        async move {
1178            match role_guard(&c, &req, Role::User).await? {
1179                Guard::Redirect(r) => Ok(r),
1180                Guard::Allow(ident) => {
1181                    super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
1182                }
1183            }
1184        }
1185    });
1186
1187    let c = ctx.clone();
1188    let router = router.post("/admin/must-change-password", move |req| {
1189        let c = c.clone();
1190        async move {
1191            match role_guard(&c, &req, Role::User).await? {
1192                Guard::Redirect(r) => Ok(r),
1193                Guard::Allow(ident) => {
1194                    super::admin_recovery_handlers::do_must_change_password(&c, ident, req).await
1195                }
1196            }
1197        }
1198    });
1199
1200    // === R3 MFA surface (R3 commits #12-#15) ================================
1201    //
1202    // Eight routes:
1203    //   /admin/mfa/verify                        — login second factor (#12)
1204    //   /admin/account/mfa/enroll                — provision + confirm (#13)
1205    //   /admin/account/mfa/regenerate-codes      — atomic batch swap   (#14)
1206    //   /admin/account/mfa/disable               — self-disable        (#15)
1207    //
1208    // All gated by `Role::User` — every authenticated user can manage
1209    // their own MFA. The /admin/mfa/verify path is on
1210    // `MFA_VERIFY_WHITELIST`; the enrol path is on
1211    // `MFA_ENROLL_WHITELIST` — so `login_guard` does NOT redirect
1212    // away from these routes even when the user is in the pending-
1213    // verify or required-enrol state. Otherwise the interstitial
1214    // pages would be unreachable.
1215
1216    // --- /admin/mfa/verify (R3 commit #12) ---
1217    let c = ctx.clone();
1218    let router = router.get("/admin/mfa/verify", move |req| {
1219        let c = c.clone();
1220        async move {
1221            match role_guard(&c, &req, Role::User).await? {
1222                Guard::Redirect(r) => Ok(r),
1223                Guard::Allow(ident) => super::mfa_handlers::show_verify(&c, ident, &req).await,
1224            }
1225        }
1226    });
1227
1228    let c = ctx.clone();
1229    let router = router.post("/admin/mfa/verify", move |req| {
1230        let c = c.clone();
1231        async move {
1232            match role_guard(&c, &req, Role::User).await? {
1233                Guard::Redirect(r) => Ok(r),
1234                Guard::Allow(ident) => super::mfa_handlers::do_verify(&c, ident, req).await,
1235            }
1236        }
1237    });
1238
1239    // --- /admin/account/mfa/enroll (R3 commit #13) ---
1240    let c = ctx.clone();
1241    let router = router.get("/admin/account/mfa/enroll", move |req| {
1242        let c = c.clone();
1243        async move {
1244            match role_guard(&c, &req, Role::User).await? {
1245                Guard::Redirect(r) => Ok(r),
1246                Guard::Allow(ident) => super::mfa_handlers::show_enroll(&c, ident, &req).await,
1247            }
1248        }
1249    });
1250
1251    let c = ctx.clone();
1252    let router = router.post("/admin/account/mfa/enroll", move |req| {
1253        let c = c.clone();
1254        async move {
1255            match role_guard(&c, &req, Role::User).await? {
1256                Guard::Redirect(r) => Ok(r),
1257                Guard::Allow(ident) => super::mfa_handlers::do_enroll(&c, ident, req).await,
1258            }
1259        }
1260    });
1261
1262    // --- /admin/account/mfa/regenerate-codes (R3 commit #14) ---
1263    let c = ctx.clone();
1264    let router = router.get("/admin/account/mfa/regenerate-codes", move |req| {
1265        let c = c.clone();
1266        async move {
1267            match role_guard(&c, &req, Role::User).await? {
1268                Guard::Redirect(r) => Ok(r),
1269                Guard::Allow(ident) => super::mfa_handlers::show_regenerate(&c, ident, &req).await,
1270            }
1271        }
1272    });
1273
1274    let c = ctx.clone();
1275    let router = router.post("/admin/account/mfa/regenerate-codes", move |req| {
1276        let c = c.clone();
1277        async move {
1278            match role_guard(&c, &req, Role::User).await? {
1279                Guard::Redirect(r) => Ok(r),
1280                Guard::Allow(ident) => super::mfa_handlers::do_regenerate(&c, ident, req).await,
1281            }
1282        }
1283    });
1284
1285    // --- /admin/account/mfa/disable (R3 commit #15) ---
1286    let c = ctx.clone();
1287    let router = router.get("/admin/account/mfa/disable", move |req| {
1288        let c = c.clone();
1289        async move {
1290            match role_guard(&c, &req, Role::User).await? {
1291                Guard::Redirect(r) => Ok(r),
1292                Guard::Allow(ident) => super::mfa_handlers::show_disable(&c, ident, &req).await,
1293            }
1294        }
1295    });
1296
1297    let c = ctx.clone();
1298    let router = router.post("/admin/account/mfa/disable", move |req| {
1299        let c = c.clone();
1300        async move {
1301            match role_guard(&c, &req, Role::User).await? {
1302                Guard::Redirect(r) => Ok(r),
1303                Guard::Allow(ident) => super::mfa_handlers::do_disable(&c, ident, req).await,
1304            }
1305        }
1306    });
1307
1308    // --- Built-in users admin (admin-only) ---
1309    let c = ctx.clone();
1310    let ac = auth_ctx.clone();
1311    let router = router.get("/admin/users", move |req| {
1312        let c = c.clone();
1313        let ac = ac.clone();
1314        async move {
1315            match role_guard(&c, &req, Role::Administrator).await? {
1316                Guard::Redirect(r) => Ok(r),
1317                Guard::Allow(ident) => {
1318                    super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
1319                }
1320            }
1321        }
1322    });
1323
1324    let c = ctx.clone();
1325    let ac = auth_ctx.clone();
1326    let router = router.get("/admin/users/new", move |req| {
1327        let c = c.clone();
1328        let ac = ac.clone();
1329        async move {
1330            match role_guard(&c, &req, Role::Administrator).await? {
1331                Guard::Redirect(r) => Ok(r),
1332                Guard::Allow(ident) => {
1333                    super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
1334                }
1335            }
1336        }
1337    });
1338
1339    let c = ctx.clone();
1340    let ac = auth_ctx.clone();
1341    let router = router.post("/admin/users/new", move |req| {
1342        let c = c.clone();
1343        let ac = ac.clone();
1344        async move {
1345            match role_guard(&c, &req, Role::Administrator).await? {
1346                Guard::Redirect(r) => Ok(r),
1347                Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
1348            }
1349        }
1350    });
1351
1352    let c = ctx.clone();
1353    let ac = auth_ctx.clone();
1354    let router = router.get("/admin/users/:id/edit", move |req| {
1355        let c = c.clone();
1356        let ac = ac.clone();
1357        async move {
1358            match role_guard(&c, &req, Role::Administrator).await? {
1359                Guard::Redirect(r) => Ok(r),
1360                Guard::Allow(ident) => {
1361                    let id = parse_id(req.param("id"))?;
1362                    super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
1363                }
1364            }
1365        }
1366    });
1367
1368    let c = ctx.clone();
1369    let ac = auth_ctx.clone();
1370    let router = router.post("/admin/users/:id/edit", move |req| {
1371        let c = c.clone();
1372        let ac = ac.clone();
1373        async move {
1374            match role_guard(&c, &req, Role::Administrator).await? {
1375                Guard::Redirect(r) => Ok(r),
1376                Guard::Allow(ident) => {
1377                    let id = parse_id(req.param("id"))?;
1378                    super::builtin::do_user_edit(&ac, ident, id, req).await
1379                }
1380            }
1381        }
1382    });
1383
1384    let c = ctx.clone();
1385    let ac = auth_ctx.clone();
1386    let router = router.get("/admin/users/:id/delete", move |req| {
1387        let c = c.clone();
1388        let ac = ac.clone();
1389        async move {
1390            match role_guard(&c, &req, Role::Administrator).await? {
1391                Guard::Redirect(r) => Ok(r),
1392                Guard::Allow(ident) => {
1393                    let id = parse_id(req.param("id"))?;
1394                    super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
1395                        .await
1396                }
1397            }
1398        }
1399    });
1400
1401    let c = ctx.clone();
1402    let ac = auth_ctx.clone();
1403    let router = router.post("/admin/users/:id/delete", move |req| {
1404        let c = c.clone();
1405        let ac = ac.clone();
1406        async move {
1407            match role_guard(&c, &req, Role::Administrator).await? {
1408                Guard::Redirect(r) => Ok(r),
1409                Guard::Allow(ident) => {
1410                    let id = parse_id(req.param("id"))?;
1411                    super::builtin::do_user_delete(&ac, ident, id, req).await
1412                }
1413            }
1414        }
1415    });
1416
1417    // === R2 admin-driven recovery routes ====================================
1418    //
1419    // Registered alongside the existing `/admin/users/:id/...` cluster
1420    // (per `DESIGN_R2_ORGANISATIONAL.md` §7.2 — user-related cluster
1421    // contiguous). All gated `Role::Administrator`; the cross-rank
1422    // safety check + the re-auth wall are enforced INSIDE the
1423    // handlers (commits #15 / #16) so a Supervisor probe doesn't even
1424    // reach the form.
1425    //
1426    // Insertion-order note: these are 4-segment routes, so the
1427    // 3-segment `/admin/users/:id` read-only view further down doesn't
1428    // conflict regardless of order. Placing them before the 3-segment
1429    // view keeps the user routes lexically clustered.
1430
1431    // GET /admin/users/:id/reset-password — admin reset form (R2 #15).
1432    let c = ctx.clone();
1433    let router = router.get("/admin/users/:id/reset-password", move |req| {
1434        let c = c.clone();
1435        async move {
1436            match role_guard(&c, &req, Role::Administrator).await? {
1437                Guard::Redirect(r) => Ok(r),
1438                Guard::Allow(ident) => {
1439                    let id = parse_id(req.param("id"))?;
1440                    super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
1441                        .await
1442                }
1443            }
1444        }
1445    });
1446
1447    // POST /admin/users/:id/reset-password — apply admin reset (R2 #15).
1448    let c = ctx.clone();
1449    let router = router.post("/admin/users/:id/reset-password", move |req| {
1450        let c = c.clone();
1451        async move {
1452            match role_guard(&c, &req, Role::Administrator).await? {
1453                Guard::Redirect(r) => Ok(r),
1454                Guard::Allow(ident) => {
1455                    let id = parse_id(req.param("id"))?;
1456                    super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
1457                        .await
1458                }
1459            }
1460        }
1461    });
1462
1463    // GET /admin/users/:id/lock — lock confirmation form (R2 #16).
1464    let c = ctx.clone();
1465    let router = router.get("/admin/users/:id/lock", move |req| {
1466        let c = c.clone();
1467        async move {
1468            match role_guard(&c, &req, Role::Administrator).await? {
1469                Guard::Redirect(r) => Ok(r),
1470                Guard::Allow(ident) => {
1471                    let id = parse_id(req.param("id"))?;
1472                    super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
1473                }
1474            }
1475        }
1476    });
1477
1478    // POST /admin/users/:id/lock — apply manual lock (R2 #16).
1479    let c = ctx.clone();
1480    let router = router.post("/admin/users/:id/lock", move |req| {
1481        let c = c.clone();
1482        async move {
1483            match role_guard(&c, &req, Role::Administrator).await? {
1484                Guard::Redirect(r) => Ok(r),
1485                Guard::Allow(ident) => {
1486                    let id = parse_id(req.param("id"))?;
1487                    super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
1488                }
1489            }
1490        }
1491    });
1492
1493    // GET /admin/users/:id/unlock — unlock confirmation form (R2 #16).
1494    let c = ctx.clone();
1495    let router = router.get("/admin/users/:id/unlock", move |req| {
1496        let c = c.clone();
1497        async move {
1498            match role_guard(&c, &req, Role::Administrator).await? {
1499                Guard::Redirect(r) => Ok(r),
1500                Guard::Allow(ident) => {
1501                    let id = parse_id(req.param("id"))?;
1502                    super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
1503                }
1504            }
1505        }
1506    });
1507
1508    // POST /admin/users/:id/unlock — clear lock (R2 #16).
1509    let c = ctx.clone();
1510    let router = router.post("/admin/users/:id/unlock", move |req| {
1511        let c = c.clone();
1512        async move {
1513            match role_guard(&c, &req, Role::Administrator).await? {
1514                Guard::Redirect(r) => Ok(r),
1515                Guard::Allow(ident) => {
1516                    let id = parse_id(req.param("id"))?;
1517                    super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
1518                }
1519            }
1520        }
1521    });
1522
1523    // GET /admin/users/:id/revoke-sessions — revoke confirmation form
1524    // (R2 #16).
1525    let c = ctx.clone();
1526    let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
1527        let c = c.clone();
1528        async move {
1529            match role_guard(&c, &req, Role::Administrator).await? {
1530                Guard::Redirect(r) => Ok(r),
1531                Guard::Allow(ident) => {
1532                    let id = parse_id(req.param("id"))?;
1533                    super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
1534                        .await
1535                }
1536            }
1537        }
1538    });
1539
1540    // POST /admin/users/:id/revoke-sessions — revoke all sessions (R2 #16).
1541    let c = ctx.clone();
1542    let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
1543        let c = c.clone();
1544        async move {
1545            match role_guard(&c, &req, Role::Administrator).await? {
1546                Guard::Redirect(r) => Ok(r),
1547                Guard::Allow(ident) => {
1548                    let id = parse_id(req.param("id"))?;
1549                    super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
1550                        .await
1551                }
1552            }
1553        }
1554    });
1555
1556    // POST /admin/users/:id/sessions/:session_id/revoke — revoke
1557    // ONE specific session (per-row affordance on the user_view
1558    // sessions tab). Narrower than `revoke-sessions` above: no
1559    // confirmation page, no reason field, no re-auth wall. Same
1560    // Administrator role gate; the handler enforces cross-rank +
1561    // session-ownership + same-actor-session refusal in-line.
1562    let c = ctx.clone();
1563    let router = router.post("/admin/users/:id/sessions/:session_id/revoke", move |req| {
1564        let c = c.clone();
1565        async move {
1566            match role_guard(&c, &req, Role::Administrator).await? {
1567                Guard::Redirect(r) => Ok(r),
1568                Guard::Allow(ident) => {
1569                    let user_id = parse_id(req.param("id"))?;
1570                    let session_id = parse_id(req.param("session_id"))?;
1571                    super::admin_recovery_handlers::do_admin_revoke_one_session(
1572                        &c, ident, user_id, session_id, req,
1573                    )
1574                    .await
1575                }
1576            }
1577        }
1578    });
1579
1580    // Read-only user profile view. MUST be registered AFTER
1581    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
1582    // above: the router matches in insertion order, and `:id` is a
1583    // wildcard that would happily swallow "new" or extra path
1584    // segments. Putting this last preserves the more-specific routes'
1585    // priority.
1586    let c = ctx.clone();
1587    let ac = auth_ctx.clone();
1588    let router = router.get("/admin/users/:id", move |req| {
1589        let c = c.clone();
1590        let ac = ac.clone();
1591        async move {
1592            match role_guard(&c, &req, Role::Administrator).await? {
1593                Guard::Redirect(r) => Ok(r),
1594                Guard::Allow(ident) => {
1595                    let id = parse_id(req.param("id"))?;
1596                    let q = req.query();
1597                    let tab = q.get("tab").map(|s| s.to_string());
1598                    let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
1599                    let viewing_session_id = match req
1600                        .header("cookie")
1601                        .and_then(crate::auth::session_token_from_cookie)
1602                    {
1603                        Some(token) => crate::auth::current_session_id(&ac.db, &token)
1604                            .await
1605                            .ok()
1606                            .flatten(),
1607                        None => None,
1608                    };
1609                    super::builtin::show_user_view(
1610                        &ac,
1611                        ident,
1612                        id,
1613                        handlers::csrf_token(&req),
1614                        tab,
1615                        page,
1616                        viewing_session_id,
1617                    )
1618                    .await
1619                }
1620            }
1621        }
1622    });
1623
1624    // --- Built-in groups admin (admin-only) ---
1625    let c = ctx.clone();
1626    let ac = auth_ctx.clone();
1627    let router = router.get("/admin/groups", move |req| {
1628        let c = c.clone();
1629        let ac = ac.clone();
1630        async move {
1631            match role_guard(&c, &req, Role::Administrator).await? {
1632                Guard::Redirect(r) => Ok(r),
1633                Guard::Allow(ident) => {
1634                    super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
1635                }
1636            }
1637        }
1638    });
1639
1640    let c = ctx.clone();
1641    let ac = auth_ctx.clone();
1642    let router = router.get("/admin/groups/new", move |req| {
1643        let c = c.clone();
1644        let ac = ac.clone();
1645        async move {
1646            match role_guard(&c, &req, Role::Administrator).await? {
1647                Guard::Redirect(r) => Ok(r),
1648                Guard::Allow(ident) => {
1649                    super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
1650                }
1651            }
1652        }
1653    });
1654
1655    let c = ctx.clone();
1656    let ac = auth_ctx.clone();
1657    let router = router.post("/admin/groups/new", move |req| {
1658        let c = c.clone();
1659        let ac = ac.clone();
1660        async move {
1661            match role_guard(&c, &req, Role::Administrator).await? {
1662                Guard::Redirect(r) => Ok(r),
1663                Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
1664            }
1665        }
1666    });
1667
1668    let c = ctx.clone();
1669    let ac = auth_ctx.clone();
1670    let router = router.get("/admin/groups/:id/edit", move |req| {
1671        let c = c.clone();
1672        let ac = ac.clone();
1673        async move {
1674            match role_guard(&c, &req, Role::Administrator).await? {
1675                Guard::Redirect(r) => Ok(r),
1676                Guard::Allow(ident) => {
1677                    let id = parse_id(req.param("id"))?;
1678                    super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
1679                        .await
1680                }
1681            }
1682        }
1683    });
1684
1685    let c = ctx.clone();
1686    let ac = auth_ctx.clone();
1687    let router = router.post("/admin/groups/:id/edit", move |req| {
1688        let c = c.clone();
1689        let ac = ac.clone();
1690        async move {
1691            match role_guard(&c, &req, Role::Administrator).await? {
1692                Guard::Redirect(r) => Ok(r),
1693                Guard::Allow(ident) => {
1694                    let id = parse_id(req.param("id"))?;
1695                    super::builtin::do_group_edit(&ac, ident, id, req).await
1696                }
1697            }
1698        }
1699    });
1700
1701    let c = ctx.clone();
1702    let ac = auth_ctx.clone();
1703    let router = router.get("/admin/groups/:id/delete", move |req| {
1704        let c = c.clone();
1705        let ac = ac.clone();
1706        async move {
1707            match role_guard(&c, &req, Role::Administrator).await? {
1708                Guard::Redirect(r) => Ok(r),
1709                Guard::Allow(ident) => {
1710                    let id = parse_id(req.param("id"))?;
1711                    super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
1712                        .await
1713                }
1714            }
1715        }
1716    });
1717
1718    let c = ctx.clone();
1719    let ac = auth_ctx.clone();
1720    let router = router.post("/admin/groups/:id/delete", move |req| {
1721        let c = c.clone();
1722        let ac = ac.clone();
1723        async move {
1724            match role_guard(&c, &req, Role::Administrator).await? {
1725                Guard::Redirect(r) => Ok(r),
1726                Guard::Allow(ident) => {
1727                    let id = parse_id(req.param("id"))?;
1728                    super::builtin::do_group_delete(&ac, ident, id, req).await
1729                }
1730            }
1731        }
1732    });
1733
1734    // Uploaded-file serve. `Admin::uploads_dir` is the storage
1735    // root; the route resolves `<rel>` under it with a canonical-
1736    // path guard so a hand-edited URL can't reach files outside.
1737    // Identity-gated at Staff (anyone with admin access can see
1738    // uploaded files in v1; per-row visibility checks are a
1739    // future iteration). Registered before any generic
1740    // `/admin/:admin_name/…` route so the literal `uploads`
1741    // segment can't be shadowed by a model named `uploads`.
1742    let c = ctx.clone();
1743    let router = router.get("/admin/uploads/:filename", move |req| {
1744        let c = c.clone();
1745        async move {
1746            match role_guard(&c, &req, Role::Staff).await? {
1747                Guard::Redirect(r) => Ok(r),
1748                Guard::Allow(ident) => {
1749                    let filename = req
1750                        .param("filename")
1751                        .map(str::to_string)
1752                        .unwrap_or_default();
1753                    handlers::serve_upload(&c, ident, &filename, req).await
1754                }
1755            }
1756        }
1757    });
1758
1759    // FK autocomplete lookup. Registered before any generic
1760    // `/admin/:admin_name/…` route so a literal `_lookup` segment
1761    // can't be shadowed by a model named `_lookup`. The endpoint
1762    // is gated on `view` permission for the target model — same
1763    // surface as the user reading the target's list page.
1764    let c = ctx.clone();
1765    let router = router.get("/admin/_lookup/:admin_name", move |req| {
1766        let c = c.clone();
1767        async move {
1768            let name = model_name_from_req(&req)?;
1769            let perm = perm_for(&c, &name, "view")?;
1770            match perm_guard(&c, &req, &perm).await? {
1771                Guard::Redirect(r) => Ok(r),
1772                Guard::Allow(ident) => handlers::lookup_model(&c, ident, &name, req).await,
1773            }
1774        }
1775    });
1776
1777    // Global cross-model search (`⌘K` palette). Registered before
1778    // any generic `/admin/:admin_name/…` route so a literal `_search`
1779    // segment can't be shadowed by a model named `_search`. Role
1780    // gate is `Staff` — anyone who can reach the admin can open the
1781    // palette; the handler itself filters results to models the
1782    // operator can `view`, so each operator sees only what their
1783    // sidebar already lets them reach.
1784    let c = ctx.clone();
1785    let router = router.get("/admin/_search", move |req| {
1786        let c = c.clone();
1787        async move {
1788            match role_guard(&c, &req, Role::Staff).await? {
1789                Guard::Redirect(r) => Ok(r),
1790                Guard::Allow(ident) => handlers::search_models(&c, ident, req).await,
1791            }
1792        }
1793    });
1794
1795    // Built-in framework docs — Staff-gated read-only pages
1796    // rendering the embedded markdown sources from `docs/*.md`.
1797    // Mounted before the generic `/admin/:admin_name` pattern so
1798    // a literal `docs` segment can't be shadowed by a model
1799    // named `docs`. The per-doc route uses `:slug` which is
1800    // matched against the static `EMBEDDED_DOCS` list — unknown
1801    // slugs 404 cleanly.
1802    let c = ctx.clone();
1803    let router = router.get("/admin/docs", move |req| {
1804        let c = c.clone();
1805        async move {
1806            match role_guard(&c, &req, Role::Staff).await? {
1807                Guard::Redirect(r) => Ok(r),
1808                Guard::Allow(ident) => handlers::show_docs_index(&c, ident, &req).await,
1809            }
1810        }
1811    });
1812    let c = ctx.clone();
1813    let router = router.get("/admin/docs/:slug", move |req| {
1814        let c = c.clone();
1815        async move {
1816            let slug = req
1817                .param("slug")
1818                .ok_or_else(|| Error::BadRequest("missing doc slug".into()))?
1819                .to_string();
1820            match role_guard(&c, &req, Role::Staff).await? {
1821                Guard::Redirect(r) => Ok(r),
1822                Guard::Allow(ident) => handlers::show_doc_page(&c, ident, &slug, &req).await,
1823            }
1824        }
1825    });
1826
1827    // Auto-generated OpenAPI 3.0 spec. Registered before the
1828    // generic `/admin/:admin_name/…` patterns so a literal `apis`
1829    // / `openapi.json` segment can't be shadowed by a model named
1830    // `apis`. Role gate: Staff. The doc itself only lists endpoint
1831    // shapes — clients still need per-model `view` to call the
1832    // endpoints it describes.
1833    let c = ctx.clone();
1834    let router = router.get("/admin/apis/openapi.json", move |req| {
1835        let c = c.clone();
1836        async move {
1837            match role_guard(&c, &req, Role::Staff).await? {
1838                Guard::Redirect(r) => Ok(r),
1839                Guard::Allow(_) => {
1840                    let spec = super::openapi::build_spec(&c.admin);
1841                    super::json_api::json_response(spec)
1842                }
1843            }
1844        }
1845    });
1846
1847    // Auto-generated TypeScript SDK skeleton. Sibling of the
1848    // OpenAPI spec endpoint above; same Staff gate. Emits one
1849    // `export interface` per registered project model with field
1850    // types projected from `FieldType`. Operators wrap their own
1851    // fetch around the documented JSON envelopes.
1852    let c = ctx.clone();
1853    let router = router.get("/admin/apis/sdk.ts", move |req| {
1854        let c = c.clone();
1855        async move {
1856            match role_guard(&c, &req, Role::Staff).await? {
1857                Guard::Redirect(r) => Ok(r),
1858                Guard::Allow(_) => {
1859                    let body = super::sdk_gen::build_typescript(&c.admin);
1860                    Ok(crate::http::Response::ok(body)
1861                        .with_header("content-type", "text/typescript; charset=utf-8")
1862                        .with_header(
1863                            "content-disposition",
1864                            "attachment; filename=\"rustio-sdk.ts\"",
1865                        ))
1866                }
1867            }
1868        }
1869    });
1870
1871    // Human-readable HTML index for the API surface. Sibling of
1872    // the openapi.json endpoint above; same Staff gate. Lists
1873    // every registered model's endpoint table + field shapes so
1874    // operators can read the surface without opening a JSON
1875    // viewer.
1876    let c = ctx.clone();
1877    let router = router.get("/admin/apis", move |req| {
1878        let c = c.clone();
1879        async move {
1880            match role_guard(&c, &req, Role::Staff).await? {
1881                Guard::Redirect(r) => Ok(r),
1882                Guard::Allow(ident) => handlers::show_apis_index(&c, ident, &req).await,
1883            }
1884        }
1885    });
1886
1887    // Interactive playground (read-only preview): pick a model,
1888    // build a list-page query, fetch the JSON envelope in-page.
1889    // Mounted before the generic /admin/:admin_name pattern so
1890    // `apis` / `playground` segments don't collide with a model
1891    // named `playground`. Same Staff gate as the API index;
1892    // operators still need per-model `view` to receive non-empty
1893    // results.
1894    let c = ctx.clone();
1895    let router = router.get("/admin/apis/playground", move |req| {
1896        let c = c.clone();
1897        async move {
1898            match role_guard(&c, &req, Role::Staff).await? {
1899                Guard::Redirect(r) => Ok(r),
1900                Guard::Allow(ident) => handlers::show_apis_playground(&c, ident, &req).await,
1901            }
1902        }
1903    });
1904
1905    // Per-model list — needs `view` permission.
1906    let c = ctx.clone();
1907    let router = router.get("/admin/:admin_name", move |req| {
1908        let c = c.clone();
1909        async move {
1910            let name = model_name_from_req(&req)?;
1911            let perm = perm_for(&c, &name, "view")?;
1912            match perm_guard(&c, &req, &perm).await? {
1913                Guard::Redirect(r) => Ok(r),
1914                Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
1915            }
1916        }
1917    });
1918
1919    // CSV export — must be registered before `/admin/:admin_name/:id/…`
1920    // patterns so the literal `export.csv` second segment isn't
1921    // shadowed by a numeric `:id` route. Same `view` permission gate
1922    // as the list page; `find_project_entry` blocks core entries.
1923    let c = ctx.clone();
1924    let router = router.get("/admin/:admin_name/export.csv", move |req| {
1925        let c = c.clone();
1926        async move {
1927            let name = model_name_from_req(&req)?;
1928            let perm = perm_for(&c, &name, "view")?;
1929            match perm_guard(&c, &req, &perm).await? {
1930                Guard::Redirect(r) => Ok(r),
1931                Guard::Allow(ident) => handlers::export_model_csv(&c, ident, &name, req).await,
1932            }
1933        }
1934    });
1935
1936    // CSV import — sibling of export.csv. Requires the model's
1937    // `change` permission (it inserts rows). Each CSV row goes
1938    // through `AdminOps::create` so framework validation runs
1939    // unchanged. Per-row failures surface on a result page;
1940    // partial imports are explicit, not silent.
1941    let c = ctx.clone();
1942    let router = router.post("/admin/:admin_name/import.csv", move |req| {
1943        let c = c.clone();
1944        async move {
1945            let name = model_name_from_req(&req)?;
1946            let perm = perm_for(&c, &name, "change")?;
1947            match perm_guard(&c, &req, &perm).await? {
1948                Guard::Redirect(r) => Ok(r),
1949                Guard::Allow(ident) => handlers::import_model_csv(&c, ident, &name, req).await,
1950            }
1951        }
1952    });
1953
1954    // Saved-filter create — POST /admin/:admin_name/saved_filters.
1955    // Same `view` gate as the list page: any operator who can
1956    // reach the list can bookmark its state. Registered before the
1957    // generic /:admin_name/:id/edit route so the literal
1958    // `saved_filters` segment isn't shadowed by a numeric `:id`.
1959    let c = ctx.clone();
1960    let router = router.post("/admin/:admin_name/saved_filters", move |req| {
1961        let c = c.clone();
1962        async move {
1963            let name = model_name_from_req(&req)?;
1964            let perm = perm_for(&c, &name, "view")?;
1965            match perm_guard(&c, &req, &perm).await? {
1966                Guard::Redirect(r) => Ok(r),
1967                Guard::Allow(ident) => handlers::do_save_filter(&c, ident, &name, req).await,
1968            }
1969        }
1970    });
1971
1972    // Saved-filter delete — POST /admin/:admin_name/saved_filters/:id/delete.
1973    // SQL scope-locks to identity.user_id so the route gate can
1974    // stay `view`; an operator can only delete their own bookmarks.
1975    let c = ctx.clone();
1976    let router = router.post("/admin/:admin_name/saved_filters/:id/delete", move |req| {
1977        let c = c.clone();
1978        async move {
1979            let name = model_name_from_req(&req)?;
1980            let id = parse_id(req.param("id"))?;
1981            let perm = perm_for(&c, &name, "view")?;
1982            match perm_guard(&c, &req, &perm).await? {
1983                Guard::Redirect(r) => Ok(r),
1984                Guard::Allow(ident) => handlers::do_delete_filter(&c, ident, &name, id, req).await,
1985            }
1986        }
1987    });
1988
1989    // Create.
1990    let c = ctx.clone();
1991    let router = router.get("/admin/:admin_name/new", move |req| {
1992        let c = c.clone();
1993        async move {
1994            let name = model_name_from_req(&req)?;
1995            let perm = perm_for(&c, &name, "add")?;
1996            match perm_guard(&c, &req, &perm).await? {
1997                Guard::Redirect(r) => Ok(r),
1998                Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
1999            }
2000        }
2001    });
2002    let c = ctx.clone();
2003    let router = router.post("/admin/:admin_name/new", move |req| {
2004        let c = c.clone();
2005        async move {
2006            let name = model_name_from_req(&req)?;
2007            let perm = perm_for(&c, &name, "add")?;
2008            match perm_guard(&c, &req, &perm).await? {
2009                Guard::Redirect(r) => Ok(r),
2010                Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
2011            }
2012        }
2013    });
2014
2015    // JSON detail. `GET /admin/:admin_name/:id` returns the row
2016    // as JSON when the client asked for it (Accept header or
2017    // `?format=json`); otherwise redirects to the HTML edit
2018    // form so a browser landing on the bare URL ends up where
2019    // it expects. View permission, same as the list endpoint
2020    // which JSON-list is already gated on.
2021    let c = ctx.clone();
2022    let router = router.get("/admin/:admin_name/:id", move |req| {
2023        let c = c.clone();
2024        async move {
2025            let name = model_name_from_req(&req)?;
2026            let perm = perm_for(&c, &name, "view")?;
2027            match perm_guard(&c, &req, &perm).await? {
2028                Guard::Redirect(r) => Ok(r),
2029                Guard::Allow(ident) => {
2030                    let id = parse_id(req.param("id"))?;
2031                    handlers::show_object_json(&c, ident, &name, id, &req).await
2032                }
2033            }
2034        }
2035    });
2036
2037    // Edit.
2038    let c = ctx.clone();
2039    let router = router.get("/admin/:admin_name/:id/edit", move |req| {
2040        let c = c.clone();
2041        async move {
2042            let name = model_name_from_req(&req)?;
2043            let perm = perm_for(&c, &name, "change")?;
2044            match perm_guard(&c, &req, &perm).await? {
2045                Guard::Redirect(r) => Ok(r),
2046                Guard::Allow(ident) => {
2047                    let id = parse_id(req.param("id"))?;
2048                    handlers::show_edit_form(&c, ident, &name, id, &req).await
2049                }
2050            }
2051        }
2052    });
2053    let c = ctx.clone();
2054    let router = router.post("/admin/:admin_name/:id/edit", move |req| {
2055        let c = c.clone();
2056        async move {
2057            let name = model_name_from_req(&req)?;
2058            let perm = perm_for(&c, &name, "change")?;
2059            match perm_guard(&c, &req, &perm).await? {
2060                Guard::Redirect(r) => Ok(r),
2061                Guard::Allow(ident) => {
2062                    let id = parse_id(req.param("id"))?;
2063                    handlers::do_update(&c, ident, &name, id, req).await
2064                }
2065            }
2066        }
2067    });
2068
2069    // Per-object history. Read-only; same `view` permission as the
2070    // changelist (if you can list, you can read the audit trail).
2071    let c = ctx.clone();
2072    let router = router.get("/admin/:admin_name/:id/history", move |req| {
2073        let c = c.clone();
2074        async move {
2075            let name = model_name_from_req(&req)?;
2076            let perm = perm_for(&c, &name, "view")?;
2077            match perm_guard(&c, &req, &perm).await? {
2078                Guard::Redirect(r) => Ok(r),
2079                Guard::Allow(ident) => {
2080                    let id = parse_id(req.param("id"))?;
2081                    handlers::show_object_history(&c, ident, &name, id, &req).await
2082                }
2083            }
2084        }
2085    });
2086
2087    // Delete.
2088    let c = ctx.clone();
2089    let router = router.get("/admin/:admin_name/:id/delete", move |req| {
2090        let c = c.clone();
2091        async move {
2092            let name = model_name_from_req(&req)?;
2093            let perm = perm_for(&c, &name, "delete")?;
2094            match perm_guard(&c, &req, &perm).await? {
2095                Guard::Redirect(r) => Ok(r),
2096                Guard::Allow(ident) => {
2097                    let id = parse_id(req.param("id"))?;
2098                    handlers::show_delete_confirm(&c, ident, &name, id, &req).await
2099                }
2100            }
2101        }
2102    });
2103    let c = ctx.clone();
2104    let router = router.post("/admin/:admin_name/:id/delete", move |req| {
2105        let c = c.clone();
2106        async move {
2107            let name = model_name_from_req(&req)?;
2108            let perm = perm_for(&c, &name, "delete")?;
2109            match perm_guard(&c, &req, &perm).await? {
2110                Guard::Redirect(r) => Ok(r),
2111                Guard::Allow(ident) => {
2112                    let id = parse_id(req.param("id"))?;
2113                    handlers::do_delete(&c, ident, &name, req, id).await
2114                }
2115            }
2116        }
2117    });
2118
2119    // Bulk delete — same permission gate as the per-row delete.
2120    // Two-step flow: first POST renders the confirm page, second POST
2121    // (with `_confirmed=1`) executes. See `handlers::handle_bulk_delete`
2122    // for the full contract.
2123    let c = ctx.clone();
2124    let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
2125        let c = c.clone();
2126        async move {
2127            let name = model_name_from_req(&req)?;
2128            let perm = perm_for(&c, &name, "delete")?;
2129            match perm_guard(&c, &req, &perm).await? {
2130                Guard::Redirect(r) => Ok(r),
2131                Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
2132            }
2133        }
2134    });
2135
2136    // Project-defined bulk actions. Permission gated on `change` —
2137    // bulk actions modify rows but don't delete them (delete has its
2138    // own route). Project-side guard against further write-vs-read
2139    // distinctions belongs inside `execute_bulk_action`.
2140    let c = ctx.clone();
2141    router.post("/admin/:admin_name/bulk/:action", move |req| {
2142        let c = c.clone();
2143        async move {
2144            let name = model_name_from_req(&req)?;
2145            let action = req
2146                .param("action")
2147                .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
2148                .to_string();
2149            let perm = perm_for(&c, &name, "change")?;
2150            match perm_guard(&c, &req, &perm).await? {
2151                Guard::Redirect(r) => Ok(r),
2152                Guard::Allow(ident) => {
2153                    handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
2154                }
2155            }
2156        }
2157    })
2158}
2159
2160#[cfg(test)]
2161mod tests {
2162    use super::*;
2163
2164    fn make_identity(role: Role, is_active: bool) -> Identity {
2165        Identity {
2166            user_id: 42,
2167            email: "test@example.com".into(),
2168            role,
2169            is_active,
2170            is_demo: false,
2171            demo_label: None,
2172            must_change_password: false,
2173            mfa_enabled: false,
2174            trust_level: crate::auth::SessionTrust::Authenticated,
2175        }
2176    }
2177
2178    // `admin_css_payload` is pure on its input, so these exercise the
2179    // RUSTIO_TOKENS_CSS layering without touching process-global env
2180    // (env mutation races other parallel tests).
2181
2182    #[test]
2183    fn admin_css_payload_none_is_the_baked_bundle_verbatim() {
2184        let css = admin_css_payload(None);
2185        assert_eq!(css.as_ref(), ADMIN_CSS.as_bytes());
2186        // Sanity: the baked bundle still carries a known token.
2187        assert!(ADMIN_CSS.contains("--rio-rust"));
2188    }
2189
2190    #[test]
2191    fn admin_css_payload_appends_override_after_baked_bundle() {
2192        let override_css = ":root{--rio-rust:#abcdef}";
2193        let css = admin_css_payload(Some(override_css));
2194        let text = std::str::from_utf8(css.as_ref()).expect("utf-8");
2195        // Override is present...
2196        assert!(text.ends_with(override_css));
2197        // ...and lands *after* the baked colours layer, so its `:root`
2198        // wins the cascade. The baked bundle defines the DS accent
2199        // `--rio-rust` near the top of colors.css; the override (its
2200        // last occurrence) must come later.
2201        let baked = text.find("--rio-rust").expect("baked token present");
2202        let overridden = text.rfind("--rio-rust:#abcdef").expect("override present");
2203        assert!(overridden > baked, "override must follow the baked bundle");
2204    }
2205
2206    // Light-only override hazard detector. Fixtures mirror the two real
2207    // shapes: the shop's current (pre-fix) generator output, which is
2208    // light-only, and rio-theme's new dual-block output.
2209
2210    /// Mirrors `examples/shop/generated/tokens.css` as it ships today:
2211    /// `:root` brand + surface tokens, no dark handling at all.
2212    const SHOP_LIGHT_ONLY: &str = "/* GENERATED by rustio-design */\n\
2213        :root {\n  --rio-brand-light: #0e6b5b;\n  --rio-accent: #0e6b5b;\n  \
2214        --rio-bg: #f7f5f2;\n  --rio-surface: #ffffff;\n}\n";
2215
2216    /// Mirrors rio-theme's new emission: `:root` light + an explicit
2217    /// `[data-theme="dark"]` block + a `prefers-color-scheme` auto block.
2218    const DARK_AWARE: &str = "/* Generated by rio-theme */\n\
2219        :root {\n  --rio-accent: #0e6b5b;\n  --rio-bg: #f7f5f2;\n}\n\
2220        :root[data-theme=\"dark\"] {\n  --rio-bg: #0f172a;\n}\n\
2221        @media (prefers-color-scheme: dark) {\n  :root {\n    --rio-bg: #0f172a;\n  }\n}\n";
2222
2223    #[test]
2224    fn dark_leak_detector_warns_on_shop_light_only_override() {
2225        assert!(
2226            override_is_dark_leak_hazard(SHOP_LIGHT_ONLY),
2227            "the shop's current light-only override must be flagged"
2228        );
2229    }
2230
2231    #[test]
2232    fn dark_leak_detector_clears_rio_theme_dual_block_output() {
2233        assert!(
2234            !override_is_dark_leak_hazard(DARK_AWARE),
2235            "rio-theme's dual-block output must NOT be flagged"
2236        );
2237    }
2238
2239    #[test]
2240    fn dark_leak_detector_clears_explicit_dark_block_alone() {
2241        let css = ":root{--rio-bg:#fff}\n:root[data-theme=\"dark\"]{--rio-bg:#0f172a}";
2242        assert!(!override_is_dark_leak_hazard(css));
2243    }
2244
2245    #[test]
2246    fn dark_leak_detector_clears_media_block_alone() {
2247        let css =
2248            ":root{--rio-bg:#fff}\n@media (prefers-color-scheme: dark){:root{--rio-bg:#0f172a}}";
2249        assert!(!override_is_dark_leak_hazard(css));
2250    }
2251
2252    #[test]
2253    fn dark_leak_detector_ignores_override_with_no_color_tokens() {
2254        // A spacing/radius-only override isn't a dark hazard.
2255        let css = ":root{--rio-radius-md:10px}";
2256        assert!(!override_is_dark_leak_hazard(css));
2257    }
2258
2259    // Font routes: the shipped faces are served; the retired ones (Geist,
2260    // Spectral, Hanken) 404. Exercises the real `register_font_routes`.
2261    #[tokio::test]
2262    async fn font_routes_serve_shipped_and_404_retired() {
2263        let router = register_font_routes(Router::new());
2264        let req = |p: &str| {
2265            Request::new(
2266                hyper::Method::GET,
2267                p.to_string(),
2268                String::new(),
2269                Default::default(),
2270                bytes::Bytes::new(),
2271            )
2272        };
2273        for p in [
2274            "/static/fonts/InterVariable.woff2",
2275            "/static/fonts/JetBrainsMono-Variable-latin.woff2",
2276            "/static/fonts/NotoNaskhArabic-Variable.woff2",
2277            "/static/fonts/Tajawal-Regular.woff2",
2278        ] {
2279            assert_eq!(
2280                router.dispatch(req(p)).await.status.as_u16(),
2281                200,
2282                "{p} is a shipped face and must be served"
2283            );
2284        }
2285        for p in [
2286            "/static/fonts/Geist-Variable.woff2",
2287            "/static/fonts/GeistMono-Variable.woff2",
2288            "/static/fonts/Spectral-400-latin.woff2",
2289            "/static/fonts/HankenGrotesk-Variable-latin.woff2",
2290        ] {
2291            assert_eq!(
2292                router.dispatch(req(p)).await.status.as_u16(),
2293                404,
2294                "{p} was retired and must 404"
2295            );
2296        }
2297    }
2298
2299    // role_guard's decision is `Role::includes(min)`. The 25-case
2300    // matrix lives in `auth::role::tests::includes_matrix_…`; the
2301    // cases below pin the most operator-relevant pairings.
2302
2303    #[test]
2304    fn role_guard_decision_admin_meets_staff_floor() {
2305        let id = make_identity(Role::Administrator, true);
2306        assert!(id.role.includes(Role::Staff));
2307    }
2308
2309    #[test]
2310    fn role_guard_decision_user_does_not_meet_staff() {
2311        let id = make_identity(Role::User, true);
2312        assert!(!id.role.includes(Role::Staff));
2313    }
2314
2315    #[test]
2316    fn role_guard_decision_administrator_does_not_meet_developer() {
2317        let id = make_identity(Role::Administrator, true);
2318        assert!(!id.role.includes(Role::Developer));
2319    }
2320
2321    #[test]
2322    fn role_guard_decision_developer_meets_everything() {
2323        let id = make_identity(Role::Developer, true);
2324        for &min in &[
2325            Role::User,
2326            Role::Staff,
2327            Role::Supervisor,
2328            Role::Administrator,
2329            Role::Developer,
2330        ] {
2331            assert!(id.role.includes(min), "Developer should meet {min:?}");
2332        }
2333    }
2334
2335    // ---- perm_guard_verdict matrix --------------------------------------
2336
2337    #[test]
2338    fn perm_guard_admin_short_circuits_without_perm() {
2339        let id = make_identity(Role::Administrator, true);
2340        assert!(perm_guard_verdict(&id, false));
2341    }
2342
2343    #[test]
2344    fn perm_guard_developer_short_circuits_without_perm() {
2345        let id = make_identity(Role::Developer, true);
2346        assert!(perm_guard_verdict(&id, false));
2347    }
2348
2349    #[test]
2350    fn perm_guard_staff_with_perm_passes() {
2351        let id = make_identity(Role::Staff, true);
2352        assert!(perm_guard_verdict(&id, true));
2353    }
2354
2355    #[test]
2356    fn perm_guard_staff_without_perm_denies() {
2357        let id = make_identity(Role::Staff, true);
2358        assert!(!perm_guard_verdict(&id, false));
2359    }
2360
2361    #[test]
2362    fn perm_guard_inactive_admin_denies_even_with_bypass() {
2363        // Defense-in-depth invariant.
2364        let id = make_identity(Role::Administrator, false);
2365        assert!(!perm_guard_verdict(&id, true));
2366    }
2367
2368    #[test]
2369    fn perm_guard_supervisor_without_perm_denies() {
2370        // Supervisor doesn't bypass; needs the per-model perm.
2371        let id = make_identity(Role::Supervisor, true);
2372        assert!(!perm_guard_verdict(&id, false));
2373    }
2374
2375    // ---- strict_mailer_guard_check ----------------------------------------
2376
2377    /// Default `Admin::new()` doesn't override the mailer AND
2378    /// doesn't enable strict mode — the guard passes.
2379    #[test]
2380    fn strict_mailer_guard_passes_for_default_admin() {
2381        let admin = super::super::types::Admin::new();
2382        assert!(strict_mailer_guard_check(&admin).is_ok());
2383    }
2384
2385    /// Strict-mailer mode + default LogMailer = boot guard fires.
2386    /// The error message is operator-actionable.
2387    #[test]
2388    fn strict_mailer_guard_fails_when_required_but_default_mailer() {
2389        use crate::auth::DefaultRecoveryPolicy;
2390        let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
2391            DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
2392        ));
2393        let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
2394        assert!(
2395            err.contains("strict_mailer_required"),
2396            "error message must name the policy method: {err}"
2397        );
2398        assert!(
2399            err.contains("Admin::mailer"),
2400            "error message must direct the operator to the fix: {err}"
2401        );
2402    }
2403
2404    /// Strict-mailer mode + project-supplied mailer = guard passes.
2405    /// Note: the explicit override flips the flag even when the
2406    /// supplied value happens to be another LogMailer — the
2407    /// operator's intent is what matters, not the concrete type.
2408    #[test]
2409    fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
2410        use crate::auth::DefaultRecoveryPolicy;
2411        use crate::email::LogMailer;
2412        let admin = super::super::types::Admin::new()
2413            .recovery_policy(std::sync::Arc::new(
2414                DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
2415            ))
2416            .mailer(std::sync::Arc::new(LogMailer));
2417        assert!(strict_mailer_guard_check(&admin).is_ok());
2418    }
2419
2420    /// Project NOT in strict mode + default LogMailer = passes
2421    /// (dev / CI / testing baseline).
2422    #[test]
2423    fn strict_mailer_guard_passes_when_strict_mode_disabled() {
2424        let admin = super::super::types::Admin::new();
2425        assert!(strict_mailer_guard_check(&admin).is_ok());
2426    }
2427
2428    // ---- must-change-password whitelist (R2 commit #13) --------------------
2429
2430    #[test]
2431    fn whitelist_accepts_the_three_locked_paths() {
2432        // Locked-decision per DESIGN_R2_ORGANISATIONAL.md §12.
2433        assert!(super::is_must_change_whitelisted_path(
2434            "/admin/must-change-password"
2435        ));
2436        assert!(super::is_must_change_whitelisted_path("/admin/logout"));
2437        assert!(super::is_must_change_whitelisted_path(
2438            "/admin/account/sessions"
2439        ));
2440    }
2441
2442    #[test]
2443    fn whitelist_rejects_subpaths_of_account_sessions() {
2444        // Sub-paths of /admin/account/sessions (revoke buttons) are
2445        // intentionally NOT whitelisted — a user being forced to
2446        // rotate may VIEW their sessions but must finish the
2447        // rotation before revoking siblings.
2448        assert!(!super::is_must_change_whitelisted_path(
2449            "/admin/account/sessions/revoke"
2450        ));
2451        assert!(!super::is_must_change_whitelisted_path(
2452            "/admin/account/sessions/revoke-others"
2453        ));
2454        assert!(!super::is_must_change_whitelisted_path(
2455            "/admin/account/sessions/"
2456        ));
2457    }
2458
2459    #[test]
2460    fn whitelist_rejects_other_admin_paths() {
2461        for path in [
2462            "/admin",
2463            "/admin/",
2464            "/admin/users",
2465            "/admin/users/42",
2466            "/admin/login",
2467            "/admin/password_change",
2468            "/admin/forgot-password",
2469            "/admin/reauth",
2470            "/admin/must-change-password/", // trailing slash → not exact
2471        ] {
2472            assert!(
2473                !super::is_must_change_whitelisted_path(path),
2474                "expected reject for {path:?}"
2475            );
2476        }
2477    }
2478
2479    #[test]
2480    fn whitelist_rejects_paths_outside_admin_surface() {
2481        for path in ["/", "/login", "/static/admin.css", "/api"] {
2482            assert!(
2483                !super::is_must_change_whitelisted_path(path),
2484                "expected reject for {path:?}"
2485            );
2486        }
2487    }
2488
2489    // ---- Read-only writable-path allowlist ----------------------
2490
2491    #[test]
2492    fn read_only_allows_auth_flow_exact_paths() {
2493        for path in [
2494            "/admin/login",
2495            "/admin/logout",
2496            "/admin/reauth",
2497            "/admin/forgot-password",
2498            "/admin/mfa/verify",
2499            "/admin/must-change-password",
2500            "/admin/password_change",
2501        ] {
2502            assert!(
2503                super::is_read_only_writable_path(path),
2504                "auth path {path:?} must be writable in read-only mode"
2505            );
2506        }
2507    }
2508
2509    #[test]
2510    fn read_only_allows_prefix_paths() {
2511        // Token-bearing recovery URL, own-session and own-MFA
2512        // management — all carry dynamic segments, allowlisted by
2513        // prefix not by exact match.
2514        for path in [
2515            "/admin/reset-password/abc123",
2516            "/admin/reset-password/abc123/whatever",
2517            "/admin/account/sessions/42/revoke",
2518            "/admin/account/sessions/revoke-all",
2519            "/admin/account/mfa/enroll",
2520            "/admin/account/mfa/disable",
2521        ] {
2522            assert!(
2523                super::is_read_only_writable_path(path),
2524                "prefix-allowlisted path {path:?} must be writable"
2525            );
2526        }
2527    }
2528
2529    #[test]
2530    fn read_only_blocks_project_data_mutations() {
2531        // Project CRUD, bulk actions, admin-driven user lifecycle
2532        // — none of these should slip through.
2533        for path in [
2534            "/admin/posts/new",
2535            "/admin/posts/42/edit",
2536            "/admin/posts/42/delete",
2537            "/admin/posts/bulk_delete",
2538            "/admin/posts/bulk/archive",
2539            "/admin/users/new",
2540            "/admin/users/42/edit",
2541            "/admin/users/42/reset-password",
2542            "/admin/users/42/lock",
2543            "/admin/users/42/sessions/99/revoke",
2544            "/admin/groups/new",
2545            "/admin/groups/42/delete",
2546        ] {
2547            assert!(
2548                !super::is_read_only_writable_path(path),
2549                "data-mutation path {path:?} must be blocked in read-only mode"
2550            );
2551        }
2552    }
2553
2554    #[test]
2555    fn read_only_blocks_random_paths_outside_admin_surface() {
2556        // Paths outside /admin/* don't reach this helper in
2557        // production (the middleware checks `starts_with("/admin")`
2558        // first), but the helper itself must still say "not
2559        // writable" so the policy is self-consistent.
2560        for path in ["/", "/login", "/static/admin.css", "/api/v1/posts"] {
2561            assert!(
2562                !super::is_read_only_writable_path(path),
2563                "non-admin path {path:?} must not be writable"
2564            );
2565        }
2566    }
2567
2568    #[test]
2569    fn extract_admin_name_parses_slug_segment() {
2570        assert_eq!(super::extract_admin_name("/admin/posts"), Some("posts"));
2571        assert_eq!(super::extract_admin_name("/admin/posts/"), Some("posts"));
2572        assert_eq!(
2573            super::extract_admin_name("/admin/posts/42/edit"),
2574            Some("posts")
2575        );
2576        assert_eq!(
2577            super::extract_admin_name("/admin/users/42/sessions/99/revoke"),
2578            Some("users")
2579        );
2580    }
2581
2582    #[test]
2583    fn extract_admin_name_rejects_root_reserved_and_non_admin() {
2584        // Root /admin and trailing slash → no model slug.
2585        assert_eq!(super::extract_admin_name("/admin/"), None);
2586        assert_eq!(super::extract_admin_name("/admin"), None);
2587        // Underscore-prefixed slugs are framework-reserved (`_search`,
2588        // `_lookup`, `healthz` etc.) — never project models.
2589        assert_eq!(super::extract_admin_name("/admin/_search"), None);
2590        assert_eq!(super::extract_admin_name("/admin/_lookup/posts"), None);
2591        // Paths outside /admin.
2592        assert_eq!(super::extract_admin_name("/login"), None);
2593        assert_eq!(super::extract_admin_name("/static/admin.css"), None);
2594    }
2595
2596    #[test]
2597    fn read_only_model_builder_and_accessor_round_trip() {
2598        let admin = super::super::types::Admin::new()
2599            .read_only_model("archive_posts")
2600            .read_only_model("legacy_invoices");
2601        assert!(admin.is_model_read_only("archive_posts"));
2602        assert!(admin.is_model_read_only("legacy_invoices"));
2603        assert!(!admin.is_model_read_only("posts"));
2604        // Whole-admin flag stays independent.
2605        assert!(!admin.is_read_only());
2606    }
2607
2608    #[test]
2609    fn is_mutating_method_recognises_write_verbs() {
2610        use hyper::Method;
2611        for m in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
2612            assert!(super::is_mutating_method(&m), "{m} must be mutating");
2613        }
2614        for m in [Method::GET, Method::HEAD, Method::OPTIONS] {
2615            assert!(!super::is_mutating_method(&m), "{m} must not be mutating");
2616        }
2617    }
2618}
2619
2620/// Drift guard for the CSS bundle. The `@import` manifest in
2621/// `assets/static/admin/admin.css` and the `concat!(include_str!(…))`
2622/// block in [`ADMIN_CSS`] are two hand-maintained lists that MUST stay
2623/// in lock-step (see the doctrine note on `ADMIN_CSS`). Adding,
2624/// removing, or reordering a fragment in one but not the other silently
2625/// changes the served bundle; this test turns that drift into a failure.
2626#[cfg(test)]
2627mod css_lockstep_tests {
2628    /// Fragments declared in `admin.css`, in `@import url("…")` order.
2629    fn manifest_imports() -> Vec<&'static str> {
2630        let manifest = include_str!("../../assets/static/admin/admin.css");
2631        manifest
2632            .match_indices("@import url(\"")
2633            .map(|(i, m)| {
2634                let rest = &manifest[i + m.len()..];
2635                &rest[..rest.find("\")").expect("unterminated @import url(...)")]
2636            })
2637            .collect()
2638    }
2639
2640    /// Fragments baked by `ADMIN_CSS`, in `include_str!` order. The
2641    /// manifest itself (`admin.css`) is the source list, never a baked
2642    /// fragment, so it is excluded. The search needle is assembled with
2643    /// `concat!` so this test's own source does not match itself when it
2644    /// reads `routes.rs` back as text.
2645    fn baked_fragments() -> Vec<&'static str> {
2646        let routes_src = include_str!("routes.rs");
2647        let needle = concat!("include_str!(\"", "../../assets/static/admin/");
2648        routes_src
2649            .match_indices(needle)
2650            .map(|(i, m)| {
2651                let rest = &routes_src[i + m.len()..];
2652                &rest[..rest.find("\")").expect("unterminated include_str!(...)")]
2653            })
2654            .filter(|f| *f != "admin.css")
2655            .collect()
2656    }
2657
2658    #[test]
2659    fn import_manifest_matches_concat_bundle() {
2660        assert_eq!(
2661            manifest_imports(),
2662            baked_fragments(),
2663            "admin.css @import manifest and ADMIN_CSS concat! bundle have drifted; \
2664             update BOTH lists in lock-step (see the note on ADMIN_CSS in routes.rs)"
2665        );
2666    }
2667}