Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/serve.rs

//! The router, and the order its layers run in.
//!
//! API routes are registered first and the built console is the fallback, so an
//! unmatched path serves `index.html` and a deep link survives a reload.
//!
//! Layer order, innermost outwards: routes and static files, then
//! `require_auth`, then the CSRF origin guard, then the policy, then the static
//! headers. The last two are outermost on purpose — a 401 and a static file
//! both need them.

use std::sync::Arc;

use axum::response::IntoResponse;
use axum::routing::{delete, get, post};
use axum::Router;
use tower_http::compression::Compression;
use tower_http::services::{ServeDir, ServeFile};

use crate::api;
use crate::app::AppState;
#[cfg(feature = "metered")]
use crate::auth::signup as routes_signup;
use crate::auth::{middleware, routes};
use crate::security;
use crate::sse;

/// Build the console's HTTP surface.
pub fn router(state: AppState) -> Router {
    router_around(state, None)
}

/// **The console with a product's own routes in front of it, behind the ONE throttle**
/// (GUN-GUEST): `front`'s routes answer first (gunnar-front's `/download/…`), the console is
/// their fallback, and every request of either passes the same limiter once. A front that
/// wrapped [`router`] in routes of its own would serve those unthrottled.
pub fn router_around(state: AppState, front: Option<Router>) -> Router {
    // **A SOLD Gunnar hears what its shop announced** (one shop 4,
    // 2026-10-07): a resize that stops its machines, asked of its own
    // monetize every 20 s, so a Gunnar that does not answer meanwhile is down
    // for maintenance ([`crate::monetize::sold_maintenance`]).
    #[cfg(feature = "metered")]
    if let (Some(m), Ok(rt)) = (state.monetize.as_ref().filter(|m| m.provider().is_some()), tokio::runtime::Handle::try_current()) {
        rt.spawn(crate::monetize::watch_notice(m.clone()));
    }
    let csp = Arc::new(security::build_csp(&inline_hashes(&state)));
    let csrf_origin = Arc::new(state.webauthn.as_ref().map(|w| w.origin().to_owned()));
    // **HSTS is sent only where it is true.** The passkey origin is this
    // console's own statement about how it is reached, and it is the same
    // `Option` the CSRF check is derived from, deliberately: a deployment with
    // no TLS to offer must not tell a browser to insist on it. See
    // `security::apply_hsts`.
    let hsts = state
        .webauthn
        .as_ref()
        .is_some_and(|w| w.origin().starts_with("https://"));
    // **The one throttle** (GUN-GUEST, owner 2026-10-30: nobody sidesteps it): every request
    // of every route, the static files and the fallback included, guest or signed in.
    let throttle = crate::throttle::Throttle::for_router();

    let mut app = Router::new()
        .route("/health", get(|| async { "ok" }))
        .route("/login", get(routes::login_page))
        // The login page's repository list. Beside `/login` and not under
        // `/api/` on purpose: it belongs to that page, has that page's shape,
        // and has that page's failure mode (an empty array, never an error).
        .route("/login/repos", get(routes::public_repos))
        .route("/auth/status", get(routes::status))
        .route("/auth/logout", post(routes::logout))
        // `gunnar login` (5.4 §3b): a CLI asks, a person types its code and
        // touches their passkey, gunnar enrols the CLI's own key.
        .route("/login/device", get(crate::auth::device::page))
        .route("/auth/device/start", post(crate::auth::device::start))
        .route("/auth/device/poll", post(crate::auth::device::poll))
        .route("/auth/device/lookup", get(crate::auth::device::lookup))
        .route("/auth/device/approve/start", get(crate::auth::device::approve_start))
        .route("/auth/device/approve", post(crate::auth::device::approve))
        .route("/auth/device/deny", post(crate::auth::device::deny))
        .route("/auth/passkey/login/start", get(routes::passkey_login_start))
        .route("/auth/passkey/login/finish", post(routes::passkey_login_finish))
        // A fresh touch in place of a new login (2026-10-19): the identity core's
        // step-up challenge (the person's own keys, UV required); the finish steps
        // the SAME session up at the core.
        .route(
            "/auth/passkey/step-up",
            get(routes::passkey_step_up_options).post(routes::passkey_step_up),
        )
        .route("/auth/passkey/register/start", get(routes::register_start))
        .route(
            "/auth/passkey/register/finish",
            post(routes::register_finish),
        )
        .route(
            "/auth/passkey/invites",
            get(routes::invite_list).post(routes::invite_create),
        )
        .route("/auth/passkey/invites/revoke", post(routes::invite_revoke))
        .route("/auth/passkey/credentials", get(routes::credentials))
        .route(
            "/auth/passkey/credentials/rename",
            post(routes::credential_rename),
        )
        .route(
            "/auth/passkey/credentials/revoke",
            post(routes::credential_revoke),
        )
        .route(
            "/auth/sessions/revoke-all",
            post(routes::sessions_revoke_all),
        )
        // A machine asks; a person approves; gunnar decides (2026-10-05).
        .route("/auth/machine/enrol/start", get(crate::auth::machine::enrol_start))
        .route("/auth/machine/enrol", post(crate::auth::machine::enrol))
        .route("/auth/machine/enrol/{id}", get(crate::auth::machine::enrol_status))
        .route("/auth/machine/requests", get(crate::auth::machine::requests))
        .route("/auth/machine/step-up", get(routes::passkey_step_up_options))
        .route(
            "/auth/machine/requests/{id}/approve",
            post(crate::auth::machine::approve),
        )
        .route(
            "/auth/machine/requests/{id}/refuse",
            post(crate::auth::machine::refuse),
        )
        // The live feed lives under /api/ for two reasons. It is inside
        // `require_auth` either way, but the `/api/` prefix is what keeps the
        // static-asset exemption away from it — and, found the hard way, a bare
        // `/events` collides with the console's own route of that name. The
        // server route wins, so navigating straight to the events page served
        // an endless SSE stream instead of the page, and only reloads showed it.
        .route("/api/events", get(sse::events))
        .route("/api/health", get(api::admin::health))
        // The forge's SSH host key, read live (Mirror.LogHead's `answer_key`),
        // for the Overview's "Server key SHA256:…" line. A viewer's read: the
        // host key is what every ssh client is shown anyway.
        .route("/api/server-key", get(api::server_key::server_key))
        .route("/api/version", get(api::admin::version))
        // **The throttle's counters** (GUN-GUEST): what it refused and blocked. An admin's read
        // (`ADMIN_READS`): the addresses it holds are visitors' business, not a viewer's.
        .route("/api/throttle", get(crate::throttle::counters))
        .route("/api/stats", get(api::admin::stats))
        .route("/api/storage", get(api::admin::storage))
        // **The machine itself**: the layout config the installer wrote, the
        // configured filesystems as measured now, and gunnar's placement
        // table. It reads `Admin.StorageInfo`'s `reality` field — the same
        // call `/api/storage` above makes, a different half of the answer, and
        // a different audience. An ADMIN read — the one GET on this surface
        // that is — because the rows carry the box's hardware identity: device
        // paths, the roots it writes to, and every key the installer recorded.
        // See `ADMIN_READS` in `auth::middleware`.
        .route("/api/machine", get(api::machine::machine))
        .route("/api/machine/allocation", get(api::usage::allocation))
        // **The estate's boxes** (owner, 2026-10-04): `Admin.Topology` as
        // gunnar answered it, with the same rows drawn by the suite's one estate
        // renderer (`svg`) for the Overview's Machine card. Under `/api/machine`,
        // so `ADMIN_READS` holds it at admin.
        .route("/api/machine/topology", get(api::topology::topology))
        // **Every disk**: the data set and its members, the system disk, every
        // mount and block device, the exploded read cache, the twins' volumes.
        // An ADMIN read (`ADMIN_READS`), for the reason `/api/machine` is one:
        // device names and mount points are the box's hardware identity. A
        // viewer or operator is refused before gunnar is dialled.
        .route("/api/disks", get(api::disks::disks))
        // **The front's certificates** (owner 2026-10-25): the front's report and its try
        // now, passed on (`front_certs`); `ADMIN_READS` and `ADMIN_SURFACES` hold both.
        .route(crate::front_certs::CERTS_PATH, get(crate::front_certs::certs))
        .route(crate::front_certs::TRY_PATH, post(crate::front_certs::try_cert))
        // **The data set**: total, used and free of the store root, its member
        // layout and whether it can grow — one `Admin.Capacity` asked for
        // nothing in particular. An OPERATOR read (`OPERATOR_READS`).
        .route("/api/dataset", get(api::usage::dataset))
        .route("/api/bootstrap", get(api::admin::bootstrap))
        // **The twin**: its heartbeat rows (an operator's read, `OPERATOR_READS`)
        // and the one-step restore from it (the eligibility is a read; the POST
        // and POST are an admin's, `/api/restore` is on `ADMIN_READS` and
        // `ADMIN_SURFACES`). See `api::twin`.
        .route("/api/twin", get(api::twin::panel))
        .route("/api/restore", get(api::twin::eligibility).post(api::twin::restore))
        // **The twin door** (5.4): the same review and recharge for an admin
        // whose main cannot log them in — public here, and every request must
        // carry an admin key the TWIN vouches for (`api::twin::twin_admin`).
        .route("/twin-door/restore", get(api::twin::door_review).post(api::twin::door_restore))
        .route(
            "/api/namespaces",
            get(api::repos::list_namespaces).post(api::repos::create_namespace),
        )
        .route(
            "/api/namespaces/{name}",
            delete(api::repos::delete_namespace),
        )
        // What one namespace holds. A GET, so `role_floor` puts it at viewer
        // even though `/api/namespaces` is an ADMIN surface for anything that
        // mutates — reading your own meters is not an act of administration,
        // and gunnar's own grant on the namespace is what decides whether the
        // call is answered at all (see `api::usage`).
        .route("/api/namespaces/{name}/usage", get(api::usage::read))
        // What one namespace MOVED, day by day. `Usage.Activity`, and it takes
        // the same grant `/usage` does — `Read` on the namespace — so it sits
        // beside it under the same viewer floor rather than on the admin
        // surface: a tenant's own traffic is a tenant's own business. Not on
        // the anonymous allow-list, and `anonymous.rs` proves it: this console
        // signs with a server-wide admin key, so a route reachable without a
        // session hands a stranger whichever namespace they can spell.
        .route("/api/namespaces/{name}/activity", get(api::usage::activity))
        // The admission plan for one namespace — `Usage.AdmitPlan`, the SAME
        // grant as the two above (`Read` on the namespace), so the same viewer
        // floor and, like them, NOT on the anonymous allow-list.
        .route("/api/namespaces/{name}/admit", get(api::usage::admit_plan))
        // **`/<ns>`** (owner, 2026-10-03): the namespace and the repositories
        // THIS reader may see in it, asked of gunnar as the reader — so a
        // visitor (the guest) sees a private namespace's public repositories
        // only, and one with none answers as a namespace that does not exist.
        // On the anonymous allow-list (`is_anonymous_read`).
        .route("/api/namespaces/{name}/page", get(api::repos::namespace_page))
        .route("/api/repos", get(api::repos::list).post(api::repos::create))
        // ── More room for one namespace ────────────────────────────────────
        //
        // Registered below, and ONLY when the console holds a monetize bearer,
        // so that the open build has no route and a metered build without the
        // credential has no route either. See `api::increase`.
        // BEFORE `/api/repos/{namespace}/{name}`, and it has to be: axum
        // matches a literal segment ahead of a capture, but only when the
        // literal route exists — `deleted` is one segment where that route
        // takes two, so there is no collision to lose. It is written here, next
        // to the list it is the other half of, for the reader rather than the
        // router.
        .route("/api/repos/deleted", get(api::repos::list_deleted))
        .route("/api/repos/creatable", get(api::repos::creatable))
        // The store directories with no catalog row and no tombstone. A
        // listing and nothing else: `ReclaimOrphan` destroys objects and stays
        // an operator's decision at the control plane.
        .route("/api/orphans", get(api::repos::list_orphans))
        .route(
            "/api/repos/{namespace}/{name}",
            get(api::repos::get)
                .patch(api::repos::update)
                .delete(api::repos::delete),
        )
        // May THIS session change the repository's visibility (the account
        // admin only)? A GET, so a viewer's to ask; the PATCH above re-asks
        // before it signs.
        .route(
            "/api/repos/{namespace}/{name}/visibility",
            get(api::visibility::read),
        )
        // May THIS session delete the repository, and for how many days can it
        // be restored (the Danger zone, owner spec 2026-10-28)? gunnar's answers,
        // asked as the person; the DELETE re-asks.
        .route(
            "/api/repos/{namespace}/{name}/deletable",
            get(api::repos::deletable),
        )
        // The account admin gives a member Read/Write on a repository and
        // takes it away (2026-10-07): signed as the person, gunnar decides.
        .route("/api/repos/{namespace}/{name}/members", get(api::members::list))
        .route(
            "/api/repos/{namespace}/{name}/members/{principal}",
            axum::routing::put(api::members::give).delete(api::members::take),
        )
        // The undo behind that delete. Its floor is the delete's (`role_floor`,
        // owner spec 2026-10-28): signed as the person, and gunnar decides
        // (`Repos.Undelete` needs Admin over the repository or its namespace).
        .route(
            "/api/repos/{namespace}/{name}/undelete",
            post(api::repos::undelete),
        )
        .route(
            "/api/repos/{namespace}/{name}/pulls",
            get(api::prs::list).post(api::prs::create),
        )
        .route(
            "/api/repos/{namespace}/{name}/pulls/{number}",
            get(api::prs::get),
        )
        .route(
            "/api/repos/{namespace}/{name}/pulls/{number}/merge",
            post(api::prs::merge),
        )
        // May this person merge it now (gunnar's CheckMerge; nothing moves).
        .route(
            "/api/repos/{namespace}/{name}/pulls/{number}/mergeable",
            get(api::prs::mergeable),
        )
        .route(
            "/api/repos/{namespace}/{name}/pulls/{number}/close",
            post(api::prs::close),
        )
        // The review half. `review` is a GET and therefore a viewer's to read;
        // `approve` is a POST under `/api/`, which `role_floor` puts at
        // operator — an approval is an identity claim, and a viewer must not
        // be able to make one.
        .route(
            "/api/repos/{namespace}/{name}/pulls/{number}/review",
            get(api::prs::review),
        )
        .route(
            "/api/repos/{namespace}/{name}/pulls/{number}/approve",
            post(api::prs::approve),
        )
        // Inline review comments: a GET to read them (a viewer's), a POST to
        // file one (an operator's, per `role_floor`). The comment anchors to
        // `(path, line, blob_oid)` so it follows the code, and the diff page
        // renders it on its line.
        .route(
            "/api/repos/{namespace}/{name}/pulls/{number}/comments",
            get(api::prs::comments).post(api::prs::comment),
        )
        // Issues: the other half of the forge. `list` and `get` are GETs and so
        // a viewer's to read; `open`, `comment` and `close` are POSTs under
        // `/api/`, which `role_floor` puts at operator — filing a signed issue
        // is an identity claim the console makes with its own key, and a viewer
        // must not be able to make one.
        .route(
            "/api/repos/{namespace}/{name}/issues",
            get(api::issues::list).post(api::issues::open),
        )
        .route(
            "/api/repos/{namespace}/{name}/issues/{number}",
            get(api::issues::get),
        )
        .route(
            "/api/repos/{namespace}/{name}/issues/{number}/comment",
            post(api::issues::comment),
        )
        .route(
            "/api/repos/{namespace}/{name}/issues/{number}/close",
            post(api::issues::close),
        )
        // What THIS person may do on the repository: gunnar's `Access.Check`,
        // asked as them (owner order 2026-10-18). A GET, a viewer's; not on
        // the anonymous allow-list.
        .route("/api/repos/{namespace}/{name}/rights", get(api::rights::read))
        .route("/api/repos/{namespace}/{name}/refs", get(api::refs::list))
        .route("/api/repos/{namespace}/{name}/head", get(api::refs::head))
        .route("/api/repos/{namespace}/{name}/log", get(api::refs::log))
        // The object route takes its oid in the query string, never in a path
        // segment: `is_public_asset` exempts anything ending in a known
        // extension, and a path-shaped route would hand repository contents to
        // anyone who asked for `…/lib.rs`.
        .route(
            "/api/repos/{namespace}/{name}/object",
            get(api::refs::object),
        )
        // Phase two. Same rule as above, for the same reason: the file path
        // rides in the query string so it can never end a route with a public
        // asset's extension.
        .route(
            "/api/repos/{namespace}/{name}/browse",
            get(api::browse::browse),
        )
        .route("/api/repos/{namespace}/{name}/diff", get(api::browse::diff))
        // One file's bytes, as the file — what a README's link to a PDF opens.
        // Same query-string rule; its content type is decided from the name
        // off a short list, see `api::browse::raw`.
        .route("/api/repos/{namespace}/{name}/raw", get(api::browse::raw))
        // **The README, typeset.** The `?ref=` rides in the query string like
        // every other revision on this surface; nothing here takes a path
        // segment, for the reason at the top of `api::browse`.
        //
        // It carries a compression layer of its OWN, and it is the only API
        // route that does. The answer is a typeset page whose every glyph is an
        // SVG path — measured at about 105 bytes per character of README, so a
        // 3 KB README is a 350 KB response — and gzip takes that to about a
        // quarter. `console_files` compresses the static bundle for the same
        // reason and by the same argument; this is scoped to one route rather
        // than to `/api/` because every other answer here is small JSON, and a
        // compressor on the SSE stream would buffer the live feed.
        .route(
            "/api/repos/{namespace}/{name}/readme",
            get(api::readme::read).layer(tower_http::compression::CompressionLayer::new()),
        )
        // **Any markdown file in the tree, typeset** — the same pipeline, with
        // the path in the caller's hands instead of `CANDIDATES`. Same query
        // string rule, and the same compression layer for the same reason: the
        // answer is a page of glyph outlines and gzip takes it to a quarter.
        //
        // On the anonymous allow-list beside `readme`: a public repository's
        // own documentation, reached through exactly the gunnar calls `browse`
        // already makes for the same visitor.
        .route(
            "/api/repos/{namespace}/{name}/render",
            get(api::render::render).layer(tower_http::compression::CompressionLayer::new()),
        )
        // **The same document as a PDF, and this one needs a session.** It is
        // the most expensive thing this console can be asked for — a full paged
        // typst compile — and it is a convenience for keeping a file rather
        // than a way to read a page. NOT on `is_anonymous_read`, and refused
        // again in the handler by the same `reading::Offer` that decides
        // whether the console draws the button. No compression layer: a PDF is
        // already compressed and gzipping it costs CPU to make it larger.
        .route("/api/repos/{namespace}/{name}/pdf", get(api::render::pdf))
        // Blame and search. Both take their file path / pattern in the query
        // string, never a path segment, for the same reason `object` and
        // `browse` do: a path-shaped route could end with a public asset's
        // extension and hand repository contents to anyone. Blame folds
        // `Refs.Blame` with a per-commit `Admin.VerifyChain`; search is
        // `Refs.Search` over one revision.
        .route(
            "/api/repos/{namespace}/{name}/blame",
            get(api::blame::blame),
        )
        .route(
            "/api/repos/{namespace}/{name}/search",
            get(api::search::search),
        )
        // The provenance chain — `gunnar verify`, served (`Admin.VerifyChain`).
        // GETs; nothing here mutates. `?rev=` and `?line=<path>:<n>` narrow
        // the repo walk; the artifact route takes no repository on purpose —
        // the build record names it, and a browser must not get to disagree.
        .route("/api/repos/{namespace}/{name}/chain", get(api::chain::walk))
        .route(
            "/api/chain/artifact/{sha256}",
            get(api::chain::walk_artifact),
        )
        // The pre-receive gate's configuration. gunnar serves all six Policy
        // RPCs; until these routes existed nothing here could reach them, so
        // branch protection worked on the server and was unreachable from the
        // console.
        //
        // Role floor, from `role_floor` and nothing bespoke: the GETs are
        // `Viewer`, the POST/DELETE/PUT are `Operator` — the default for a
        // mutation under `/api/` that is not an identity surface. Deliberate,
        // and argued in full at the top of `api::policy`: gunnar gates these
        // three at `GrantLevel::Admin` but checks that against the CONSOLE's
        // key, which holds a server-wide admin grant, so gunnar cannot tell one
        // browser session from another and this ladder is the only gate there
        // is. `Operator` is where every other repository mutation already sits,
        // including the strictly more destructive `DELETE /api/repos/{ns}/{n}`.
        //
        // The delete takes its pattern in the query string because a
        // `ref_pattern` contains slashes — `refs/heads/main` has two — and
        // axum's `{pattern}` matches one segment only.
        .route(
            "/api/repos/{namespace}/{name}/protection",
            get(api::policy::list)
                .post(api::policy::set)
                .delete(api::policy::delete),
        )
        .route(
            "/api/repos/{namespace}/{name}/ref-names",
            get(api::policy::ref_names).put(api::policy::set_ref_names),
        )
        .route(
            "/api/repos/{namespace}/{name}/gates",
            get(api::policy::gates),
        )
        // The gc button and its poller share one path: POST enqueues, GET reports.
        // Both are the repository's own maintenance, so they sit with the repo
        // routes rather than under an admin prefix — `Gc` is gated at Write and
        // `GcStatus` at Read, by the server, not by where the route lives.
        .route(
            "/api/repos/{namespace}/{name}/gc",
            get(api::repos::gc_status).post(api::repos::gc),
        )
        // **The per-USER surface: lock a user out, and empty their account.**
        //
        // Under `/api/accounts`, which `role_floor` names as an ADMIN surface —
        // that is what decides whether a human may press these, because the BFF
        // signs to gunnar as one principal holding server-wide admin and
        // gunnar's own check cannot tell two console users apart. A GET here is
        // still viewer, as every GET is: seeing who is locked out is not
        // locking anybody out.
        .route("/api/accounts/gates", get(api::accounts::list_gates))
        // The lockout: POST takes it, DELETE lifts it. A DELETE rather than a
        // second POST because lifting one is removing the row, and the account
        // is named in the path with nothing else to say about it.
        .route(
            "/api/accounts/{account}/gate",
            post(api::accounts::gate).delete(api::accounts::ungate),
        )
        // The purge and its poller share one path, exactly as the gc button
        // does: POST enqueues, GET reports. The GET also carries the LOCKOUT,
        // because `done` beside a gate still in force is a different situation
        // from `done` beside an open account, and only one of them needs an
        // operator.
        .route(
            "/api/accounts/{account}/purge",
            get(api::accounts::purge_status).post(api::accounts::purge),
        )
        // **The operator's side of the enterprise list** (2026-10-05): the
        // aggregates (totals, never a name), the looks the operator asked for
        // and the asking, the name filter's lists. gunnar decides each.
        .route("/api/accounts/aggregates", get(api::enterprise::aggregates))
        .route("/api/accounts/looks", get(api::enterprise::my_looks).post(api::enterprise::ask_look))
        .route("/api/accounts/looks/{id}/end", post(api::enterprise::end))
        .route(
            "/api/accounts/name-lists",
            get(api::enterprise::name_lists).put(api::enterprise::set_name_lists),
        )
        // ── Connecting a forge, and importing what it lists ────────────────
        //
        // None of these reaches gunnar. They talk to github.com, codeberg.org
        // or a GitLab through `gunnar-forge`, because "what repositories does
        // this account have" is a question the git protocol cannot express.
        //
        // `/api/forges` is a viewer's since 2026-10-02: a connection is the
        // person's OWN, keyed to their session, and is what lets an ordinary
        // user import into their own namespace. `api::Importer` is what every
        // route in the family takes — the two OAuth GETs below included — so a
        // request with no named session reaches none of them.
        .route("/api/forges", get(api::forges::list))
        .route("/api/forges/connect", post(api::forges::connect))
        .route("/api/forges/{forge}", delete(api::forges::forget))
        .route("/api/forges/{forge}/repos", get(api::forges::repos))
        // Singular `/api/forge/` for the OAuth pair, because these are not the
        // connection surface — they are one forge's dance, and the callback URL
        // is registered with that forge and cannot move afterwards.
        .route("/api/forge/{forge}/authorize", get(api::forges::authorize))
        .route("/api/forge/{forge}/callback", get(api::forges::callback))
        // ── The mirrors themselves ─────────────────────────────────────────
        //
        // The GETs are viewer's, as every GET is, and are refused outright for
        // an anonymous request: `api::Importer`/`api::SignedIn` have no arm for
        // one, and none of these paths is on either anonymous allow-list, so the
        // middleware refuses before gunnar is dialled at all. The mutations are
        // a viewer's by the floor and decided per namespace by
        // `api::mirror_gate` — an ordinary user imports into their OWN
        // namespace; an administrator keeps the box-wide power.
        .route("/api/mirrors", get(api::mirrors::list))
        .route("/api/mirrors/import", post(api::mirrors::import))
        // The second door: one repository from any git server, by its address
        // and an optional token that goes one way. Same gate as the picker.
        .route("/api/mirrors/import-url", post(api::mirrors::import_url))
        // The panel's poller and the row's two buttons. `status_one` shares the
        // repository's path with the DELETE, exactly as the gc button shares
        // one with its poller: GET reports, DELETE removes the registration.
        .route(
            "/api/mirrors/{namespace}/{name}",
            get(api::mirrors::status_one).delete(api::mirrors::remove),
        )
        .route(
            "/api/mirrors/{namespace}/{name}/sync",
            post(api::mirrors::sync),
        )
        .route(
            "/api/mirrors/{namespace}/{name}/credential",
            post(api::mirrors::credential),
        )
        .route(
            "/api/principals",
            get(api::access::list_principals).post(api::access::add_principal),
        )
        .route(
            "/api/principals/{id}",
            delete(api::access::remove_principal),
        )
        .route(
            "/api/keys",
            get(api::access::list_keys).post(api::access::add_key),
        )
        .route("/api/keys/{fingerprint}", delete(api::access::revoke_key))
        // **A person's OWN keys, and the one mutating route a VIEWER may
        // reach.** Singular `/api/account/`, one letter from the admin
        // `/api/accounts/` above, and `role_floor` is asserted to tell them
        // apart.
        //
        // What makes a viewer's floor safe is not the floor: it is that the
        // target is `Me` — the name in the session — and can be reached by no
        // path segment, query parameter or body field. Registering a key
        // against the name you already log in as escalates to nothing, and
        // until this route existed a tenant who had just signed up could not
        // do it at all. The DELETE takes its fingerprint in the query string
        // because a fingerprint is base64 and contains `/`. See
        // `api::account`.
        .route(
            "/api/account/keys",
            get(api::account::list)
                .post(api::account::add)
                .delete(api::account::revoke),
        )
        // **The person's own encryption key** (CRYPT-YUBI): its public half and
        // its unlock methods, relayed AS THE PERSON; the same `Me` rule.
        .route(
            "/api/account/encryption-key",
            get(api::encryption_key::get).post(api::encryption_key::create),
        )
        .route("/api/account/encryption-key/methods", axum::routing::post(api::encryption_key::add))
        .route("/api/account/encryption-key/methods/{id}", axum::routing::delete(api::encryption_key::remove))
        // **The console's own key on this person's principal**, and the way to
        // unstick it. NOT behind `metered`: what this fixes is a READ falling
        // back to the grantless guest, which happens on a community appliance
        // exactly as it does on one that sells. The money panels were only the
        // loudest symptom.
        //
        // Same prefix and the same argument as the keys above: the target is
        // `Me`, the principal comes off the passkey's pin, and the body carries
        // neither — so a viewer may post here because there is nothing to
        // escalate to. The POST carries somebody's own signature over their own
        // principal; gunnar verifies it, this console does not. See
        // `crate::tenant`.
        // **Åtgärder** — what this account has to do something about, and the
        // headline the console carries at the top of every page. Not behind
        // `metered`: "you have no key, so you cannot push" is true on a
        // community appliance too, and it is the item that matters most.
        .route("/api/account/actions", get(api::actions::list))
        // **The account admin's side of the enterprise list** (2026-10-05):
        // the audit log and its export, the operator's looks to approve (with
        // a fresh step-up) or end, the names held for review. Signed AS THE
        // PERSON; gunnar admits the account's admins alone.
        .route("/api/account/audit", get(api::enterprise::audit))
        .route("/api/account/audit/export", get(api::enterprise::export))
        .route("/api/account/operator-access", get(api::enterprise::looks))
        .route("/api/account/operator-access/{id}/approve", post(api::enterprise::approve))
        .route("/api/account/operator-access/{id}/refuse", post(api::enterprise::refuse))
        .route("/api/account/operator-access/{id}/end", post(api::enterprise::end))
        .route("/api/account/step-up", get(api::enterprise::step_up))
        .route("/api/account/names", get(api::enterprise::names))
        .route("/api/account/names/{id}", post(api::enterprise::decide_name))
        // **The account admin's own surface** (owner, 2026-10-03): the
        // namespace's two settings (public, events public) and the account's
        // employees. Under `/api/account/` so a customer's viewer session can
        // reach it; the handlers admit the ACCOUNT ADMIN alone, asked against
        // gunnar's grant table before anything is signed. See
        // `api::account_admin`.
        .route(
            "/api/account/namespaces/{name}/settings",
            get(api::account_admin::read_settings).patch(api::account_admin::settings),
        )
        .route(
            "/api/account/employees",
            get(api::account_admin::employees).post(api::account_admin::enroll),
        )
        // "My room" on You → Account → Storage: the session's OWN namespace
        // (named from the session, never the request) and its repositories.
        .route("/api/account/room", get(api::room::room))
        .route("/api/account/console-key", get(api::account::console_key))
        .route(
            "/api/account/console-key/attestation",
            post(api::account::attest_console_key),
        )
        // **How this person reads markdown.** Under the singular
        // `/api/account/` prefix on purpose: `role_floor` puts that surface at
        // `Viewer` because everything under it acts on the name in the SESSION,
        // and a reading preference is exactly that shape. A viewer who could
        // not choose how they read a file would be looking at a button that
        // answers 403. See `crate::reading`.
        .route(
            "/api/account/reading",
            get(crate::reading::read).put(crate::reading::set),
        )
        // **You → Clans** (2026-10-05): every verb signed AS THE PERSON, and
        // gunnar decides — who the clan's admin is, who may join, whether a
        // contribution fits and how low it may go. See `api::clans`.
        .route("/api/account/clans", get(api::clans::list).post(api::clans::create))
        .route("/api/account/clans/{clan}", get(api::clans::get))
        .route("/api/account/clans/{clan}/invite", post(api::clans::invite))
        .route("/api/account/clans/{clan}/accept", post(api::clans::accept))
        .route("/api/account/clans/{clan}/decline", post(api::clans::decline))
        .route("/api/account/clans/{clan}/contribution", post(api::clans::contribution))
        .route("/api/account/clans/{clan}/leave", post(api::clans::leave))
        .route("/api/account/clans/{clan}/remove", post(api::clans::remove))
        .route("/api/account/clans/{clan}/admin", post(api::clans::admin))
        .route("/api/account/clans/{clan}/dissolve", post(api::clans::dissolve))
        // Round 2: a repository moved out of a clan — the clan admin offers,
        // the offered account's admin takes or declines (gunnar decides who).
        .route("/api/account/clans/{clan}/offer", post(api::clans::offer))
        .route("/api/account/clans/{clan}/offers/{repo}", post(api::clans::answer_offer))
        .route(
            "/api/grants",
            get(api::access::list_grants).post(api::access::grant),
        )
        .route("/api/grants/revoke", post(api::access::revoke_grant));

    // **Company sign-in** (`sso`, off by default, A34-15): the relay's doors and
    // the account admin's Identity provider API. See `auth::sso::routes`.
    #[cfg(feature = "sso")]
    {
        app = crate::auth::sso::routes(app);
    }

    // **Gunnar's MCP connector** (`mcp`, off by default): `/mcp`, its OAuth
    // doors (let through `require_auth` by `is_mcp_door`) and the person's own
    // list of connections. See `crate::mcp`.
    #[cfg(feature = "mcp")]
    let mcp_switch = crate::mcp::Switch::starting(&state);
    #[cfg(feature = "mcp")]
    {
        app = crate::mcp::routes(app, &state, &mcp_switch);
    }

    // **The one surface that is added to the router rather than declared in
    // it.** Every other route above exists in every build; these three exist
    // only in a `metered` build that was given a monetize bearer.
    //
    // A route that answered 501 instead would be a promise the console cannot
    // keep, printed at a tenant who can do nothing about it, and it would put
    // the paths into the open build's route table where
    // `the_open_console_serves_no_money_route` would find them. Absence is the
    // honest state and it is also the testable one.
    //
    // They live under `/api/namespaces/{name}/`, which is deliberate and not
    // cosmetic: `role_floor` names `/api/namespaces` an ADMIN surface, so the
    // POST that spends money is admin-floored by the table every other route
    // is floored by, with nothing bespoke to forget. The GETs are viewer, as
    // every GET is — asking what a raise would come to spends nothing.
    // **The paid signup's two, and they are the only routes in this console
    // answered to a visitor with NO session at all** (`/login` and its
    // repository list aside, which say nothing about anybody). They are here
    // rather than in the table above for the same reason the three below are:
    // an appliance that sells nothing has no signup to complete, and a page
    // that polled a route that cannot exist would be a promise printed at
    // somebody who paid.
    //
    // `/welcome` is where the vendor returns a payer; `/auth/signup/status` is
    // what that page polls. Both are signup doors (`middleware::is_signup_door`,
    // open only while this console sells), not on `is_public`'s list, and the
    // status route carries its own rate limit — see `auth::signup`.
    #[cfg(feature = "metered")]
    if state.sells_here() {
        app = app
            .route("/welcome", get(routes_signup::welcome_page))
            // **The ONE sign-up page** (owner 2026-10-07); its doors are the shared
            // router's, below.
            .route("/signup", get(routes_signup::signup_page))
            // **A private gunnar** (PRIVATE-GUNNAR.md), bought on the ONE
            // sign-up page: `/private` is a redirect to it now, and the calls
            // it makes go straight through to monetize's `EstateOrder` — the
            // ladder, the order, its status. The page shows the answers.
            .route("/private", get(crate::auth::private::private_page))
            // **The ONE order page** (T16, A28 M11): where the vendor sends a buyer back. The
            // shared crate's script in this console's look; `/paid/estate/*` stays the shared
            // router's (a more specific route, merged below).
            .route("/paid/{*reference}", get(routes_signup::paid_page))
            .route("/cancel/{*reference}", get(routes_signup::paid_page));
        // ★ **The private-instance doors are the shared buy flow's router** (A28 H19):
        // `/auth/private/{ladder,start,change,status,renew,sign-in-again,restore-front,
        // cancel,terminate}` and the vendor's ways back, `/paid/estate/{company}` (the page
        // follows THAT order, A17) and `/paid/estate/{company}/{change}` (a grow: the same
        // watch, told the machines restart). TERMINATE (owner 2026-10-12) stays admin only,
        // a fresh login, the name typed back: `GunnarSite::stepped_up` and the account's own
        // `estate` ticket.
        // ★ **The sign-up doors are the shared router's too** (A28 H19): `/auth/signup/
        // {ladder,name,start,status,rename,sign-in-again}` — the account ladder, the name
        // settled before money moves (its own per-address budget), the checkout, the status
        // `/welcome` polls (rate-limited), a name taken mid-card settled by a rename instead
        // of a refund, and the sign-in link again.
        if let Some(doors) = crate::auth::buy_site::doors(&state, monetize_site_buy::Doors { signup: true, private: true, ..monetize_site_buy::Doors::NONE }) {
            app = app.merge(doors);
        }
    }

    // ★ **LOW-PRIV: a Gunnar that sells nothing says so at its sign-up page** (an open build,
    // a sold private Gunnar): a visitor who asks for `/signup` is told it is private and whom to
    // ask, never the console's "This page does not exist". Public by
    // [`middleware::is_private_signup`], on the same fact the sign-up page hangs on.
    if !state.sells_here() {
        app = app.route("/signup", get(private_signup));
    }

    #[cfg(feature = "metered")]
    if state.monetize.is_some() {
        // ★ **Where an order may START** (one shop, owner 2026-10-07): on a
        // SOLD (provided) Gunnar nothing is bought — its owner buys at the
        // provider — so the routes that begin an order are not registered
        // there, and the reads beside them (what is held, what monetize
        // answers about the window: nothing) stay. Absent, not refused: the
        // same shape as a console with no monetize at all.
        // **[Rebuild main]** (main rebuild, owner 2026-10-12): the ONE box's guardian, asked
        // for THIS estate's main — the look an admin's read, the press an admin's act,
        // stepped up; the twin door for a person whose main cannot log them in.
        app = app.route("/api/rebuild", get(api::rebuild::look).post(api::rebuild::press)).route("/twin-door/rebuild", axum::routing::post(api::rebuild::door_press));
        let buys = state.buys_here();
        app = app
            // ── A customer renewing their OWN subscription ─────────────────
            //
            // Under the singular `/api/account/` prefix, which `role_floor`
            // puts at `Viewer` — the tenant rung — and **that is the point**.
            // The two surfaces above are an ADMIN's: an operator placing an
            // order on a tenant's behalf, with the namespace in the path. These
            // three are the tenant's own, and there is no namespace in the
            // request at all: it comes from the session's pinned principal, so
            // a tenant cannot name another account here even to be refused.
            //
            // The POST sits below the `Operator` floor that every other `/api/`
            // mutation gets, and what makes that safe is the same thing that
            // makes `/api/account/keys` safe: the handler acts on the name in
            // the SESSION, which no path segment, query parameter or body field
            // can change, so there is nothing to escalate to. The money
            // decision is not this floor's to make — gunnar refuses to vouch
            // for a session on a namespace it does not hold, and monetize
            // refuses a renewal whose ticket does not verify. See
            // `api::subscription`.
            // The plan and the disk. Both are gunnar's answers about the
            // caller's own account, and both are registered here rather than
            // beside `/api/namespaces/{name}/...` so that no tenant-facing
            // route anywhere takes a namespace it could be given a different
            // one for.
            .route("/api/account/plan", get(api::subscription::plan))
            .route("/api/account/storage", get(api::subscription::storage));
        // ★ **The account's own doors are the shared buy flow's router** (A28 H19): the quote
        // (`/api/account/subscription`), the status poll, the customer's own "Cancel order",
        // the window (`…/bundles`, a READ ticket), the history (a READ ticket: a ticket is
        // single-use, so a read must not spend the one the customer came to renew with) and
        // Your purchases (`/api/account/purchases`, the account's `estate` ticket) — and,
        // only where this console's buyers buy (never on a sold Gunnar), the two that START
        // an order: Prolong (`…/renew`) and a window bundle (`…/buy-bundle`, an ORDER ticket
        // covering the caps it resolves to). The namespace is the session's pinned
        // principal (`GunnarSite::who`); gunnar vouches over the person's own key and
        // monetize refuses a ticket that does not verify. Their refusals read as every other
        // `/api/` route's (`buy_site::account_refusal`).
        // ★ **And the operator's orders for a tenant** (G-REDS 4a), the shared router's
        // `operator` group in the same mount: `/api/namespaces/{name}/increase` (what is for
        // sale; POST the typed order), `…/offer`, `…/status`, `…/bundles` (the window) and
        // `…/bundle` (POST, one from the window). An ADMIN's, with the namespace in the path:
        // `role_floor` puts the POSTs under the admin surface, and `GunnarSite::operator`
        // refuses every door that names a tenant to anyone below admin (the reads too: another
        // tenant's caps and the operator's reason are not a customer's to read). No ticket: an
        // operator places it on the tenant's behalf. The two that start an order only where
        // this console's buyers buy.
        let account = monetize_site_buy::Doors { account: true, orders: buys, operator: true, operator_orders: buys, ..monetize_site_buy::Doors::NONE };
        if let Some(doors) = crate::auth::buy_site::doors(&state, account) {
            app = app.merge(doors.layer(axum::middleware::from_fn(crate::auth::buy_site::account_refusal)));
        }
    }

    // **gunnar.rs's public face** (`public-site`, off by default): the
    // admin's runtime switch here, the site's layer outside `require_auth`
    // below. See `crate::public_site`.
    #[cfg(feature = "public-site")]
    let public = crate::public_site::site(&state);
    #[cfg(feature = "public-site")]
    {
        app = crate::public_site::routes(app, &public);
    }

    if let Some(dir) = &state.ui_dir {
        let index = dir.join("index.html");
        if index.is_file() {
            app = app.fallback_service(
                tower::ServiceBuilder::new()
                    .layer(axum::middleware::from_fn(asset_miss_is_not_a_page))
                    .service(console_files(dir, &index)),
            );
        } else {
            tracing::warn!(
                dir = %dir.display(),
                "no index.html there, so the console is not being served; run `trunk build`"
            );
        }
    }

    // INSIDE `require_auth`, so the `Caller` it decides is in the extensions
    // when the hidden-name check signs its second ask with the same `Reader`.
    #[cfg(feature = "metered")]
    let app = app.layer(axum::middleware::from_fn_with_state(state.clone(), crate::monetize::sold_maintenance));
    let app = app
        .layer(axum::middleware::from_fn_with_state(
            state.clone(),
            api::absent::hide,
        ))
        .layer(axum::middleware::from_fn_with_state(
            state.clone(),
            middleware::require_auth,
        ));
    // OUTSIDE `require_auth`, so the session gate reads the MCP switch (`is_mcp_door`).
    #[cfg(feature = "mcp")]
    let app = app.layer(axum::Extension(mcp_switch));
    // OUTSIDE `require_auth`, so a visitor is served a built page before the
    // login redirect; INSIDE the security headers, so the page gets them.
    #[cfg(feature = "public-site")]
    let app = crate::public_site::mount(app, public, &state);
    // GUN-GUEST (owner 2026-10-30): the old `/repo/{ns}/{name}…` answers 301 to the console's
    // one address scheme, for everybody, before anything asks who they are.
    let app = app.layer(axum::middleware::from_fn(crate::address::legacy_redirect));
    let console: Router = app
        .layer(axum::middleware::from_fn(move |request, next| {
            security::csrf_layer(csrf_origin.clone(), request, next)
        }))
        .layer(axum::middleware::from_fn(move |request, next| {
            security::csp_layer(csp.clone(), request, next)
        }))
        .with_state(state);
    let served = match front {
        Some(front) => front.fallback_service(console),
        None => console,
    };
    // OUTSIDE everything but the static headers: a 429 is made before any gate, handler or
    // gunnar call, and still carries `nosniff` and the rest.
    served
        .layer(axum::middleware::from_fn_with_state(throttle, crate::throttle::layer))
        .layer(axum::middleware::from_fn(move |request, next| {
            security::security_headers_layer(hsts, request, next)
        }))
}

/// **The console's static files, compressed on the way out.**
///
/// A separate function so the guard below measures the service `router` really
/// installs rather than a second one built to look like it.
///
/// # Why this layer exists
///
/// Measured 2026-08-31 on the release bundle: the wasm is **1 380 889 bytes**
/// on disk, **520 239** gzipped and **383 428** brotlied. `tower-http` was
/// pulled in with the `fs` feature alone, so every browser opening the console
/// downloaded the whole 1.38 MB — the compression is a **72% cut in what a
/// person waits for, and it costs the bundle nothing**. CI has been arguing
/// about twenty-kilobyte movements in the on-disk number while a megabyte of
/// it was avoidable; see `.github/wasm-baseline` for what that number is now
/// held to.
///
/// `CompressionLayer`'s default predicate already declines `text/event-stream`
/// and bodies under 32 bytes, but this is scoped to the static files anyway:
/// the live feed is an API route and never reaches here, so no amount of later
/// editing to the predicate can start buffering it.
fn console_files(
    dir: &std::path::Path,
    index: &std::path::Path,
) -> Compression<ServeDir<ServeFile>> {
    Compression::new(
        ServeDir::new(dir)
            .append_index_html_on_directories(true)
            .fallback(ServeFile::new(index)),
    )
}

/// **An asset that is missing is a 404, never the page.**
///
/// The console is an SPA, so an unmatched path serves `index.html` and a deep
/// link survives a reload. That rule must not reach the hashed assets: every
/// deploy renames them (`gunnar-ui-web-<hash>_bg.wasm`), and a browser holding
/// the previous `index.html` — an open tab, or a cached one — asks for the
/// name it was given. Served the page instead, the loader received
/// `<!DOCTYPE html>` with `200 text/html` and tried to instantiate it as
/// WebAssembly. What a person saw was the "no WebAssembly" fallback on a
/// browser that has it, with nothing in the log and 200s all the way down
/// (MEASURED 2026-09-16, after a console deploy).
///
/// So a request that NAMES a static file and comes back as HTML is a miss, and
/// says so. The extension list is the one trunk emits; a path with no
/// extension is an app route and still gets the page.
async fn asset_miss_is_not_a_page(
    req: axum::extract::Request,
    next: axum::middleware::Next,
) -> axum::response::Response {
    const ASSET: &[&str] = &[
        ".wasm", ".js", ".css", ".svg", ".png", ".webp", ".ico", ".pdf", ".json", ".map", ".txt",
        ".woff", ".woff2",
    ];
    let path = req.uri().path().to_ascii_lowercase();
    // Trunk emits every asset flat at the root, so only a single segment names one: a
    // repository's own file (`/{ns}/{name}/blob/app.js`, GUN-GUEST) is a console page.
    let names_a_file = !path.trim_start_matches('/').contains('/') && ASSET.iter().any(|e| path.ends_with(e));
    let res = next.run(req).await;
    if !names_a_file {
        return res;
    }
    let served_html = res
        .headers()
        .get(axum::http::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|v| v.starts_with("text/html"));
    if served_html {
        return (axum::http::StatusCode::NOT_FOUND, "no such asset\n").into_response();
    }
    res
}

/// The paths the console routes on its own, in the browser.
///
/// Kept here rather than only in the console crate because the collision this
/// guards against is between the two halves, and only this side can see both.
pub const CONSOLE_ROUTES: &[&str] = &[
    "/", "/repos", "/repo", "/access", "/storage", "/usage", "/events", "/account", "/mirrors",
    // Console routes that were missing from this list, named since `/<ns>`
    // (a namespace's page, any OTHER single segment) is decided against it:
    // `auth::middleware::is_namespace_page`.
    "/overview", "/admin", "/machine",
    // The gunnar CLI's page (E4, 2026-10-11): what the front serves under `/download/`.
    "/cli",
];

/// **What a Gunnar that sells nothing answers at `/signup`** (LOW-PRIV, the same as
/// Holger's `PRIVATE_SIGNUP`): there is no sign-up page here, and who lets a person in.
pub const PRIVATE_SIGNUP: &str = "This Gunnar is private. Ask its admin for an invite.";

/// [`PRIVATE_SIGNUP`], a 404 (there is no sign-up page), never cached.
async fn private_signup() -> axum::response::Response {
    (
        http::StatusCode::NOT_FOUND,
        [(http::header::CACHE_CONTROL, "no-store"), (http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
        PRIVATE_SIGNUP,
    )
        .into_response()
}

/// The path prefixes this server answers itself.
///
/// Anything under one of these never reaches the console: the router matches
/// before the static fallback does.
pub const SERVER_PREFIXES: &[&str] =
    &["/api/", "/auth/", "/health", "/login", "/welcome", "/signup", "/private"];

/// Hash every inline script this deployment serves.
///
/// The login page always, and the built `index.html` when there is one: trunk
/// emits an inline module bootstrap, and without its hash the policy would have
/// to allow inline script and the strictness would be theatre.
fn inline_hashes(state: &AppState) -> Vec<String> {
    let mut hashes = security::inline_script_hashes(routes::login_html());
    hashes.extend(security::inline_script_hashes(crate::auth::device::device_html()));
    // The welcome page is the second pre-login document, and it is hashed on
    // exactly the condition its route is registered on. A hash for a page this
    // build does not serve would be a policy naming a script nobody can load —
    // harmless, and still a lie about what this console does.
    #[cfg(feature = "metered")]
    if state.sells_here() {
        hashes.extend(security::inline_script_hashes(routes_signup::welcome_html()));
        hashes.extend(security::inline_script_hashes(routes_signup::signup_html()));
        hashes.extend(security::inline_script_hashes(routes_signup::paid_html()));
    }
    if let Some(dir) = &state.ui_dir {
        match std::fs::read_to_string(dir.join("index.html")) {
            Ok(html) => hashes.extend(security::inline_script_hashes(&html)),
            Err(e) => tracing::warn!(error = %e, "cannot read the console's index.html"),
        }
    }
    hashes.sort();
    hashes.dedup();
    hashes
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn no_console_route_is_shadowed_by_a_server_route() {
        // Found the hard way: the feed was served at `/events` while the console
        // routed a page of the same name. The server matches first, so opening
        // the events page directly streamed server-sent events at the browser
        // instead of rendering, and it only showed up on a reload rather than
        // on in-app navigation.
        for route in CONSOLE_ROUTES {
            for prefix in SERVER_PREFIXES {
                let shadowed = route == prefix
                    || route.starts_with(prefix)
                    || prefix.trim_end_matches('/') == *route;
                assert!(
                    !shadowed,
                    "the console routes {route}, but this server answers {prefix} itself, \
                     so the page can never render"
                );
            }
        }
    }

    /// **A stale tab asking for a deleted asset is told so, not handed the page.**
    ///
    /// Every deploy renames the hashed assets, and a browser holding the
    /// previous `index.html` asks for the name it was given. The SPA fallback
    /// answered that with `index.html`, `200 text/html` — so the wasm loader
    /// received `<!DOCTYPE html>`, failed to instantiate it, and the person saw
    /// the "no WebAssembly" message on a browser that has it. Nothing was in
    /// the log; every status was 200. MEASURED 2026-09-16 on the live console.
    ///
    /// Red-first: delete the `asset_miss_is_not_a_page` layer and the wasm case
    /// comes back 200 `text/html`, which is the bug.
    #[tokio::test]
    async fn a_missing_asset_is_a_404_and_an_app_route_is_still_the_page() {
        use tower::ServiceExt as _;

        let dir = std::env::temp_dir()
            .join(format!("gunnar-ui-asset-404-{}-{}", std::process::id(), line!()));
        std::fs::create_dir_all(&dir).expect("a temp dir");
        let index = dir.join("index.html");
        std::fs::write(&index, "<!doctype html><html></html>").expect("write index.html");

        let service = || {
            tower::ServiceBuilder::new()
                .layer(axum::middleware::from_fn(asset_miss_is_not_a_page))
                .service(console_files(&dir, &index))
        };

        // A hashed asset that this release does not carry — the stale tab.
        for gone in ["/gunnar-ui-web-deadbeef_bg.wasm", "/gunnar-ui-web-deadbeef.js", "/console-deadbeef.css"] {
            let res = service()
                .oneshot(
                    http::Request::builder()
                        .uri(gone)
                        .body(axum::body::Body::empty())
                        .expect("a request"),
                )
                .await
                .expect("the service answers");
            assert_eq!(
                res.status(),
                http::StatusCode::NOT_FOUND,
                "{gone} was answered with the page; a wasm loader cannot tell that from the bundle"
            );
        }

        // A deep link still renders: an app route has no file extension.
        for route in ["/storage", "/repos", "/"] {
            let res = service()
                .oneshot(
                    http::Request::builder()
                        .uri(route)
                        .body(axum::body::Body::empty())
                        .expect("a request"),
                )
                .await
                .expect("the service answers");
            assert_eq!(
                res.status(),
                http::StatusCode::OK,
                "{route} stopped serving the console, so a reload of a deep link is a 404"
            );
        }
    }

    /// **The console is served compressed, measured in bytes off the wire.**
    ///
    /// Asserted on the applied output — the body the service actually hands
    /// back — and not on `content-encoding` alone, because a header is a claim
    /// and the bytes are the thing a person waits for. The file written here is
    /// a megabyte of highly compressible text standing in for the wasm bundle,
    /// which really is 1 380 889 bytes and really does gzip to 520 239.
    ///
    /// Red-first, proven by deleting `Compression::new` from `console_files`:
    /// the response then carries no `content-encoding` and the body comes back
    /// at its full length, and both halves of this fail.
    #[tokio::test]
    async fn the_console_is_served_compressed() {
        use tower::ServiceExt as _;

        let dir = std::env::temp_dir().join(format!(
            "gunnar-ui-compression-{}-{}",
            std::process::id(),
            line!()
        ));
        std::fs::create_dir_all(&dir).expect("a temp dir");
        let index = dir.join("index.html");
        std::fs::write(&index, "<!doctype html>").expect("write index.html");
        let raw = "wasm bundles are mostly repeated opcodes. ".repeat(25_000);
        std::fs::write(dir.join("app_bg.wasm"), &raw).expect("write the stand-in bundle");

        let response = console_files(&dir, &index)
            .oneshot(
                http::Request::builder()
                    .uri("/app_bg.wasm")
                    .header(http::header::ACCEPT_ENCODING, "gzip")
                    .body(axum::body::Body::empty())
                    .expect("a request"),
            )
            .await
            .expect("the static service answers");

        assert_eq!(response.status(), http::StatusCode::OK);
        let encoding = response
            .headers()
            .get(http::header::CONTENT_ENCODING)
            .map(|v| v.to_str().unwrap_or_default().to_owned());
        assert_eq!(
            encoding.as_deref(),
            Some("gzip"),
            "the console's static files went out uncompressed, so every browser \
             downloads the whole wasm bundle"
        );

        let body = axum::body::to_bytes(axum::body::Body::new(response.into_body()), usize::MAX)
            .await
            .expect("read the body");
        // The header could be right while the bytes are not, so the bytes are
        // what is asserted. Half is a floor with a lot of slack in it: the real
        // bundle compresses to 38% of itself and this fixture to far less.
        assert!(
            body.len() * 2 < raw.len(),
            "the response claims gzip but carries {} bytes of a {}-byte file, so nothing \
             was actually compressed",
            body.len(),
            raw.len()
        );

        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn the_feed_is_reachable_only_under_the_api_prefix() {
        // Which is also what keeps `is_public_asset` away from it.
        assert!(SERVER_PREFIXES.contains(&"/api/"));
        assert!(!CONSOLE_ROUTES.contains(&"/api/events"));
    }
}