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

nordisk/monetize

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