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"));
}
}