crates/monetize-ui-server/src/serve.rs
//! The router, and the order its layers run in: routes and static files, then
//! `require_auth`, then the CSRF origin guard, then the static headers and CSP
//! (outermost — a 401 and a static file both need them). API routes first, the
//! built console as the fallback, so a deep link survives a reload.
use std::sync::Arc;
use axum::extract::Request;
use axum::middleware::{from_fn, from_fn_with_state, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use base64::Engine as _;
use http::{HeaderName, HeaderValue, Method, StatusCode};
use sha2::{Digest, Sha256};
use tower_http::compression::Compression;
use tower_http::services::{ServeDir, ServeFile};
use crate::app::AppState;
use crate::{accounts, api, auth, guardian, sse};
pub fn router(state: AppState) -> Router {
let csp = Arc::new(Csp::new(state.ui_dir.clone()));
let origin = Arc::new(state.operators.as_ref().map(|w| w.origin().to_owned()));
let mut app = Router::new()
.route("/health", get(|| async { "ok" }))
.route("/auth/status", get(auth::status))
.route("/auth/logout", post(auth::logout))
.route("/auth/passkey/login/start", get(auth::passkey_sign_in_open))
.route(
"/auth/passkey/login/finish",
post(auth::passkey_sign_in_done),
)
.route(
"/auth/passkey/register/start",
get(auth::passkey_register_open),
)
.route(
"/auth/passkey/register/finish",
post(auth::passkey_register_done),
)
.route("/auth/passkey/credentials", get(auth::credentials))
.route("/auth/sessions/revoke-all", post(auth::sessions_revoke_all))
// Operator invites (owner 2026-11-03): one-time, per person; no sign-up anywhere.
.route(
"/auth/invites",
get(auth::invites_open).post(auth::invite_make),
)
.route("/auth/invites/withdraw", post(auth::invite_withdraw))
.route("/auth/invite/start", post(auth::invite_open))
.route("/api/events", get(sse::events))
.route("/api/health", get(api::health))
// Every estate the ONE monetize's FLEET knows, as it last called home
// (ONE monetize: no estate is dialled); the SPA's estate picker.
.route("/api/estates", get(api::estates))
// **OVERVIEW**: the fleet's estates by name and product, in one call
// to the ONE monetize; each row then reads its own `summary`.
.route("/api/overview", get(api::overview))
// **ACCOUNTS** (ONE monetize, 5.4): every (product, tenant) account of
// the monetize this request is for, and one account's rows.
.route("/api/accounts", get(accounts::list))
.route("/api/accounts/{product}/{*tenant}", get(accounts::rows))
// UW-13: a private instance's setup, as narrated (the order's phases, the installer's lines).
.route("/api/instances/{company}/log", get(accounts::instance_log))
// The estate guardian (5.4): cases, the four buttons, the auto-fix opt-in.
.route("/api/guardian", get(guardian::list))
.route("/api/guardian/{instance}", get(guardian::estate))
.route("/api/guardian/{instance}/press", post(guardian::press))
.route(
"/api/guardian/{instance}/auto-fix",
post(guardian::auto_fix),
)
// THE TEST BUTTON (2026-10-11): the products' declared test actions and
// their runs. The press is a POST, so it is an admin's, with a fresh touch.
.route("/api/test-purchase", get(crate::test_purchase::list))
.route(
"/api/test-purchase/start",
post(crate::test_purchase::start),
)
// THE SITE ORDER (2026-10-13): the operator's own sites, and the order that lays one.
.route("/api/sites", get(crate::site_order::list))
.route("/api/sites/order", post(crate::site_order::order))
// THE NEW-ESTATE SLOT (OVW-CLEAN): the Overview's one empty row, the one site order behind it.
.route("/api/estates/new", post(crate::site_order::new_estate))
.route("/api/sites/terminate", post(crate::site_order::terminate))
// THE SITE BOOK (2026-10-14): a site added and edited in the application.
.route("/api/sites/book", get(crate::site_order::book))
.route("/api/sites/put", post(crate::site_order::put))
.route("/api/sites/update", post(crate::site_order::update))
.route(
"/api/sites/update-status",
get(crate::site_order::update_status),
)
.route(
"/api/sites/live-update",
post(crate::site_order::live_update),
)
// A PRODUCT'S OWN ORDERS (MONETIZE-FIX): the kinds its facts declare, one queued, its answer.
.route("/api/sites/kinds", get(crate::site_order::kinds))
.route(
"/api/sites/do",
post(crate::site_order::product_order).get(crate::site_order::product_order_answer),
)
// DEV-NARRATE (lane N2): a narrated test site's boot logs — ADMIN ONLY, reads included.
.route("/api/narrate/{estate}", get(crate::narrate::logs))
.route("/api/narrate/{estate}/{vm}", get(crate::narrate::vm_text))
.route("/api/plugins/update", post(crate::plugins::update))
.route("/api/plugins/status", get(crate::plugins::status))
// CODE RED (2026-10-18): what an automatic path left down, on every page.
.route("/api/code-red", get(crate::code_red::list))
.route("/api/code-red/{key}/ack", post(crate::code_red::ack))
.route("/api/self", get(crate::self_box::look))
.route("/api/self/update", post(crate::self_box::update))
// This box's certificates (owner 2026-10-25): the box's report, and an admin's try now.
.route("/api/self/certs", get(crate::self_box::certs))
.route("/api/self/certs/try", post(crate::self_box::try_cert))
// **THE ESTATE IS THE ADDRESS**: a name the fleet knows (else 404),
// answered from the ONE monetize; the estate itself is never asked.
.route("/api/estates/{estate}/map", get(api::estate_map))
.route("/api/iron", get(api::iron))
.route("/api/estates/{estate}/summary", get(api::estate_summary))
.route("/api/products", get(api::products))
.route("/api/products/{id}/tenants", get(api::tenants))
.route("/api/tenants/{product}/{tenant}", get(api::tenant))
// **The flow series for one tenant.** Its own route and not a field on
// the tenant read above, because it is a call to the PRODUCT and that
// read is answered entirely from this server's own store: folding it in
// would make every tenant page hang on the product's box, which is the
// thing the reachability dot exists to avoid.
.route(
"/api/tenants/{product}/{tenant}/activity",
get(api::activity),
)
// What an order would come to, before any money moves. A read: it
// reserves nothing, so a viewer may ask.
.route("/api/quote", get(api::quote))
// **The shop window, read.** What this deployment would sell this
// tenant right now, with monetize's own verdict on each row. A read
// that reserves nothing and costs 0 GiB and 0 SEK, so a viewer may ask
// — and so the live bench can ask without spending money.
.route("/api/bundles", get(api::bundles))
.route("/api/ledger", get(api::ledger))
// The TAX page: the periods a year is filed in, and one filled form
// (with the accountant's CSV of the same lines riding along).
.route("/api/tax/periods", get(api::tax_periods))
.route("/api/tax/report", get(api::tax_report))
// The paid signups, refusals included. A read, so a viewer may see them:
// "somebody paid and got no account" is exactly what an operator on duty
// needs to notice, and it carries no e-mail to leak.
.route("/api/signups", get(api::signups))
// The vendor deliveries monetize REFUSED. A read, on the same floor as
// the signups beside it and for the same reason: "the customer was
// charged and monetize holds nothing" is what an operator on duty has
// to be able to see, and it names no customer and no amount — monetize
// refused the delivery before it had either.
.route("/api/callback-refusals", get(api::callback_refusals))
// What the VENDOR says it took and monetize did not fulfil — the
// payments behind the refused deliveries above. A read, on the same
// floor and for the same reason: "the customer was charged and
// monetize holds nothing" is what an operator on duty has to see.
// No e-mail is on the row; the amount and the vendor's session id are,
// because those are what the operator acts on at the vendor.
.route("/api/paid-at-vendor", get(api::paid_at_vendor))
// ★ UW-3: THE OPERATOR'S MONEY FIXES (the ONE box has no shell). The owed-back
// queue is a read; refund, hand-settle and an order's settle are POSTs (admin, a
// fresh touch), confirmed, and sent with the console's OPERATOR bearer.
.route("/api/owed-back", get(crate::money::owed_back))
.route("/api/signups/refund", post(crate::money::refund))
.route("/api/signups/settle", post(crate::money::settle))
.route(
"/api/estate-orders/{company}/settle",
post(crate::money::order_settle),
)
// **THE ESTATE, DRAWN** — one board, one chip per machine,
// `image/svg+xml` straight off monetize's renderer. A GET, so
// it needs no fresh touch like every other read here; it is
// under `/api/`, so `require_auth` refuses a caller with no session,
// and its three segments keep it clear of `is_public_asset`, which
// hands out a single-segment `*.svg` with no cookie at all.
//
// The OLD product-addressed map (owner 2026-10-02: behind it stood
// whichever estate a cookie chose). The console no longer asks it; it
// stays for private-gunnar-ops' admin-box chain (A5), and every answer
// names the estate it drew in `x-monetize-estate`.
.route("/api/estate/{product}", get(api::estate))
// POST, so it wants a fresh touch (`needs_step_up`); it takes no body because
// the destination is monetize's own configured backup_dir.
.route("/api/backup", post(api::backup))
// The console's only door to the transaction monetize exists for: an
// ORDER {product, tenant, caps}. POST, so it wants a fresh
// touch (`needs_step_up`) — it spends money.
.route("/api/purchase", post(api::start_purchase))
// Its own path, not a verb on the reference: this one moves money BACK,
// and it should not be reachable by fumbling a URL.
.route("/api/purchase/abandon", post(api::abandon_purchase))
// The reference is `<product>/<tenant>/<date>` and holds slashes, so it
// is the rest of the path, not one segment.
.route("/api/purchase/{*reference}", get(api::purchase_status))
.route(
"/api/entitlement/{product}/{tenant}",
post(api::set_entitlement),
);
if let Some(dir) = &state.ui_dir {
// Compression is scoped to the static files: the SSE route never reaches it.
let files = Compression::new(
ServeDir::new(dir)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(dir.join("index.html"))),
);
app = app.fallback_service(files);
}
app.layer(from_fn_with_state(state.clone(), auth::require_auth))
.layer(from_fn(move |req, next| {
csrf_layer(origin.clone(), req, next)
}))
.layer(from_fn(move |req, next| {
headers_layer(csp.clone(), req, next)
}))
.with_state(state)
}
/// Refuse a cookie-authenticated state change whose `Origin` is missing or
/// foreign. A bearer-authenticated call carries no cookie and is not the CSRF
/// shape; an open console has no origin to compare against.
pub fn csrf_blocked(method: &Method, headers: &http::HeaderMap, allowed: Option<&str>) -> bool {
if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) {
return false;
}
let Some(allowed) = allowed else { return false };
if auth::session_cookie(headers).is_none() {
return false;
}
headers
.get(http::header::ORIGIN)
.and_then(|v| v.to_str().ok())
!= Some(allowed)
}
async fn csrf_layer(allowed: Arc<Option<String>>, request: Request, next: Next) -> Response {
if csrf_blocked(request.method(), request.headers(), allowed.as_deref()) {
tracing::warn!(method = %request.method(), path = %request.uri().path(), "blocked a cookie-authenticated request with a missing or foreign Origin");
return (StatusCode::FORBIDDEN, "cross-site request blocked").into_response();
}
next.run(request).await
}
/// **No browser keeps an answer of the console's API or its sign-in** (owner 2026-10-17): a cached
/// anonymous `/auth/status` read "logged out" after a good login.
pub fn no_store(path: &str) -> bool {
path.starts_with("/auth/") || path.starts_with("/api/")
}
async fn headers_layer(csp: Arc<Csp>, request: Request, next: Next) -> Response {
let store_none = no_store(request.uri().path());
let mut response = next.run(request).await;
let h = response.headers_mut();
// A route that says its own caching (a revalidated estate: private, no-cache + ETag) keeps it.
if store_none && !h.contains_key(http::header::CACHE_CONTROL) {
h.insert(
http::header::CACHE_CONTROL,
HeaderValue::from_static("no-store"),
);
}
// permissions-policy deliberately does NOT restrict publickey-credentials-*: that is the login.
for (name, value) in [
("x-content-type-options", "nosniff"),
("x-frame-options", "DENY"),
("referrer-policy", "no-referrer"),
("cross-origin-opener-policy", "same-origin"),
("cross-origin-resource-policy", "same-origin"),
(
"permissions-policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=()",
),
] {
h.insert(
HeaderName::from_static(name),
HeaderValue::from_static(value),
);
}
if let Ok(v) = HeaderValue::from_str(&csp.header()) {
h.insert(HeaderName::from_static("content-security-policy"), v);
}
response
}
/// `style-src` keeps `'unsafe-inline'` permanently: it also governs the
/// `style=` attributes Leptos writes, which no nonce or hash can cover. The
/// strict half that matters is `script-src`, which allows only the bundle and
/// the hashed inline bootstrap trunk emits.
pub fn build_csp(script_hashes: &[String]) -> String {
let hashes: String = script_hashes.iter().map(|h| format!(" '{h}'")).collect();
format!(
"default-src 'none'; script-src 'self' 'wasm-unsafe-eval'{hashes}; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'"
)
}
/// `sha256-…` of every inline `<script>` in `html` (one without a `src`).
pub fn inline_script_hashes(html: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = html;
while let Some(start) = rest.find("<script") {
let after = &rest[start..];
let Some(tag_end) = after.find('>') else {
break;
};
let tag = &after[..tag_end];
let body_start = tag_end + 1;
let Some(body_len) = after[body_start..].find("</script>") else {
break;
};
if !tag.contains(" src=") {
let body = &after[body_start..body_start + body_len];
out.push(format!(
"sha256-{}",
base64::engine::general_purpose::STANDARD.encode(Sha256::digest(body.as_bytes()))
));
}
rest = &after[body_start + body_len..];
}
out
}
/// A file's `(mtime, len)`.
type Stamp = (std::time::SystemTime, u64);
/// **The CSP, kept in step with the `index.html` actually on disk.**
///
/// `script-src` allows the console's inline bootstrap by SHA-256 HASH, and
/// Trunk's bootstrap embeds the bundle's content-hashed filename — so shipping a
/// new bundle changes the script, changes its hash, and invalidates the header.
///
/// It used to be computed ONCE at router construction. MEASURED on the live
/// console 2026-09-04: a new bundle was copied to the ui dir at 19:18 and the
/// service was not restarted until 20:36, so for over an hour the header
/// advertised the OLD hash while the page served the NEW script. The browser
/// blocked the console's own bootstrap, nothing mounted, and the dark theme left
/// a BLACK PAGE — which is what an operator got on logging in. Nothing logged,
/// nothing 500'd, and every probe of the routes came back 200: the failure lived
/// entirely in a header the server was sure about and the page had moved past.
///
/// So it re-reads when the file changes, keyed on `(mtime, len)`, and a bundle
/// swap needs no restart. The stat is one syscall against a request that is
/// about to do a gRPC round trip.
pub struct Csp {
dir: Option<std::path::PathBuf>,
/// The stamp the cached header was built from, and the header.
cached: std::sync::Mutex<(Option<Stamp>, Arc<String>)>,
}
impl Csp {
pub fn new(dir: Option<std::path::PathBuf>) -> Csp {
let csp = Csp {
dir,
cached: std::sync::Mutex::new((None, Arc::new(build_csp(&[])))),
};
// Build it once eagerly so the first request pays nothing unusual and a
// missing index.html is logged at start rather than on a user's first click.
csp.header();
csp
}
/// A stamp that changes whenever the file does. Not a hash of the content:
/// the point is to decide CHEAPLY whether to re-read, and a wrong answer
/// here is recoverable on the next request while a hash would cost the read
/// we are trying to avoid.
fn stamp(path: &std::path::Path) -> Option<(std::time::SystemTime, u64)> {
let m = std::fs::metadata(path).ok()?;
Some((m.modified().ok()?, m.len()))
}
pub fn header(&self) -> Arc<String> {
let Some(dir) = &self.dir else {
return self
.cached
.lock()
.unwrap_or_else(|e| e.into_inner())
.1
.clone();
};
let path = dir.join("index.html");
let now = Self::stamp(&path);
let mut cached = self.cached.lock().unwrap_or_else(|e| e.into_inner());
if cached.0 == now && cached.0.is_some() {
return cached.1.clone();
}
let hashes = match std::fs::read_to_string(&path) {
Ok(html) => inline_script_hashes(&html),
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "cannot read the console's index.html; serving a CSP that allows no inline script");
Vec::new()
}
};
tracing::info!(
hashes = hashes.len(),
"content-security-policy rebuilt from index.html"
);
*cached = (now, Arc::new(build_csp(&hashes)));
cached.1.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_api_and_the_sign_in_are_never_stored_by_a_browser() {
for p in [
"/auth/status",
"/auth/passkey/login/finish",
"/api/sites/update-status",
"/api/overview",
] {
assert!(no_store(p), "{p}");
}
for p in [
"/monetize-ui-web-10abb735fbe212a0.js",
"/console-363e4def32fb7385.css",
] {
assert!(!no_store(p), "{p}: a hash-named asset may be kept");
}
}
/// **The header must follow the file, without a restart.**
///
/// RED before the fix: the hashes were read once at router construction, so
/// shipping a new bundle left `script-src` advertising the old inline
/// script's hash. The browser then blocked the console's own bootstrap and
/// rendered a BLACK PAGE — measured on the live console 2026-09-04, and the
/// reason an operator saw nothing on logging in. Every route still answered
/// 200; the failure was entirely in a header.
#[test]
fn the_csp_follows_a_new_bundle_with_no_restart() {
let dir = tempfile::tempdir().unwrap();
let index = dir.path().join("index.html");
// The shape trunk emits: an inline module that names the bundle.
let v1 = r#"<html><head><script type="module">import init from '/app-AAAA.js'; await init();</script></head><body></body></html>"#;
std::fs::write(&index, v1).unwrap();
let csp = Csp::new(Some(dir.path().to_path_buf()));
let first = csp.header().to_string();
let want1 = inline_script_hashes(v1);
assert_eq!(want1.len(), 1);
assert!(
first.contains(&format!("'{}'", want1[0])),
"the first bundle's script must be allowed:\n{first}"
);
// Cached: a second read with no change is the same header.
assert_eq!(csp.header().to_string(), first);
// SHIP A NEW BUNDLE. The filename is content-hashed, so the inline
// script's bytes change — exactly what happened on the box.
let v2 = r#"<html><head><script type="module">import init from '/app-BBBB.js'; await init();</script></head><body></body></html>"#;
// The stamp is (mtime, len) and both files are the same length, so the
// mtime must actually move or this test would pass for the wrong reason.
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(&index, v2).unwrap();
let want2 = inline_script_hashes(v2);
assert_ne!(
want1[0], want2[0],
"the two bundles must produce different hashes or nothing is being tested"
);
let second = csp.header().to_string();
assert!(
second.contains(&format!("'{}'", want2[0])),
"the NEW script must be allowed without a restart:\n{second}"
);
assert!(
!second.contains(&format!("'{}'", want1[0])),
"and the stale hash must be gone:\n{second}"
);
}
/// No ui dir (the API-only shape) must still produce a usable header rather
/// than panicking or allowing everything.
#[test]
fn with_no_console_directory_the_policy_still_allows_no_inline_script() {
let csp = Csp::new(None);
let h = csp.header().to_string();
assert!(h.contains("script-src 'self' 'wasm-unsafe-eval';"), "{h}");
assert!(h.contains("default-src 'none'"), "{h}");
// `style-src 'unsafe-inline'` is deliberate and permanent — it governs
// the `style=` attributes Leptos writes, which no hash can cover. The
// strict half is SCRIPT-src, so scope the assertion to that directive
// instead of the whole header.
let script_src = h
.split(';')
.find(|d| d.trim().starts_with("script-src"))
.unwrap();
assert!(!script_src.contains("unsafe-inline"), "{script_src}");
}
#[test]
fn inline_scripts_are_hashed_and_external_ones_are_not() {
let html =
r#"<html><script src="/a.js"></script><script type="module">init();</script></html>"#;
let hashes = inline_script_hashes(html);
assert_eq!(hashes.len(), 1);
assert_eq!(
hashes[0],
format!(
"sha256-{}",
base64::engine::general_purpose::STANDARD.encode(Sha256::digest(b"init();"))
)
);
assert!(build_csp(&hashes).contains(&format!("'{}'", hashes[0])));
assert!(
!build_csp(&[]).contains("unsafe-inline'; connect")
|| build_csp(&[]).contains("script-src 'self' 'wasm-unsafe-eval';")
);
}
#[test]
fn csrf_blocks_only_cookie_authenticated_mutations_from_elsewhere() {
let mut h = http::HeaderMap::new();
assert!(
!csrf_blocked(&Method::POST, &h, Some("https://c.example")),
"no cookie: not the CSRF shape"
);
h.insert(
http::header::COOKIE,
HeaderValue::from_static("__Host-monetize_ui_session=abc"),
);
assert!(
csrf_blocked(&Method::POST, &h, Some("https://c.example")),
"cookie without Origin"
);
h.insert(
http::header::ORIGIN,
HeaderValue::from_static("https://evil.example"),
);
assert!(csrf_blocked(&Method::PUT, &h, Some("https://c.example")));
h.insert(
http::header::ORIGIN,
HeaderValue::from_static("https://c.example"),
);
assert!(!csrf_blocked(&Method::PUT, &h, Some("https://c.example")));
assert!(
!csrf_blocked(&Method::GET, &h, Some("https://x"))
&& !csrf_blocked(&Method::POST, &h, None)
);
}
}
/// R5a: every console page carries this header. A panic that once held the
/// cache must not make every later page panic; the cache holds a whole header.
#[cfg(test)]
mod poisoned_csp_tests {
use super::*;
#[test]
fn a_poisoned_csp_cache_still_answers() {
for dir in [
None,
Some(
std::env::temp_dir().join(format!("monetize-csp-poisoned-{}", std::process::id())),
),
] {
let csp = Csp::new(dir);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _held = csp.cached.lock();
panic!("a panic while the header is held");
}));
assert!(csp.cached.is_poisoned());
assert!(!csp.header().is_empty());
}
}
}