crates/gunnar-ui-web/src/refusal.rs
//! **What of a refusal is fit to put on screen**, on the host side of the wasm
//! boundary so it can be tested natively — the same argument as [`crate::room`].
//! [`crate::api`] is wasm-only (it speaks `gloo-net`), and a rule that decides
//! whether raw markup reaches an operator is too important to leave untestable.
/// **The console's code for "gunnar is restarting"** (GUN-SESSION-RESTART): the server
/// holds a session it could not ask gunnar about yet, so it is not called lost.
pub const RESTARTING_CODE: &str = "restarting";
/// **What a person reads while gunnar restarts**, or `None` for any other refusal.
pub fn restarting_summary(status: u16, code: &str) -> Option<&'static str> {
(status == 503 && code == RESTARTING_CODE).then_some("Gunnar is starting. Your session is kept; try again in a moment.")
}
/// Longest plain-text refusal shown verbatim. A real one is a sentence.
const MAX_DETAIL_CHARS: usize = 400;
/// **What of a non-JSON error body is fit to put on screen.**
///
/// The console's own refusals are JSON or one line of text. Anything else was
/// written by something IN FRONT of the console: behind Cloudflare, an origin
/// 502 has its body swapped for Cloudflare's full HTML error page. Measured
/// 2026-09-14 — an operator logged in and got that page's markup printed
/// verbatim, twice, where gunnar's one-line reason should have been. Markup is
/// never the server's words, so it is replaced by a sentence saying whose it
/// was; plain text is kept but bounded, so no body can flood the page.
pub fn is_proxy_page(body: &str) -> bool {
let head: String = body.trim_start().chars().take(512).collect::<String>().to_ascii_lowercase();
head.starts_with('<') || head.contains("<html") || head.contains("<!doctype")
}
/// Plain text, bounded. A proxy page reads as empty here — the caller turns it
/// into "down for maintenance" ([`is_proxy_page`]) rather than describing whose
/// page it was, which is a detail for the journal and not for the visitor.
pub fn readable_body(body: &str) -> String {
if is_proxy_page(body) {
return String::new();
}
let trimmed = body.trim_start();
let mut text: String = trimmed.chars().take(MAX_DETAIL_CHARS).collect();
if trimmed.chars().count() > MAX_DETAIL_CHARS {
text.push('…');
}
text
}
/// **When Gunnar could not be reached at all** — the request never got an
/// answer (offline, a dropped connection). Never the browser's own words
/// ("TypeError: Failed to fetch"), which say nothing to a person.
pub const UNREACHABLE: &str = "Gunnar did not answer. Check your connection and try again.";
/// **When an answer came but could not be read** — never the parser's words.
pub const UNREADABLE: &str = "Gunnar's answer could not be read. Reload the page and try again.";
/// **The server's word for "a fresh touch of your key is needed"**
/// (gunnar-ui-server `auth::STEP_UP_CODE`, 2026-10-19): the person may do it,
/// their session's user-verified touch is only too old. Not a role refusal.
pub const STEP_UP_CODE: &str = "stepup";
/// Where the console asks for that touch: `GET` the options, `POST` the
/// assertion; the server re-mints the session for the same person.
pub const STEP_UP_PATH: &str = "/auth/passkey/step-up";
/// What a refusal of a fresh touch reads above the passkey's own words.
pub const STEP_UP_SUMMARY: &str = "This needs a fresh touch of your key.";
/// **Is this refusal answered with a touch in place?** Only the server's
/// machine-readable step-up refusal: a 403 with `"code":"stepup"`. Every
/// other 403 is a real refusal and is shown as one.
pub fn wants_step_up(status: u16, code: &str) -> bool {
status == 403 && code == STEP_UP_CODE
}
/// The step-up's own calls are never answered with another step-up: a
/// refusal there is the end of it.
pub fn may_step_up_for(path: &str) -> bool {
!path.starts_with(STEP_UP_PATH) && !path.starts_with("/auth/passkey/login")
}
/// The body of `POST` [`STEP_UP_PATH`]: the ceremony and the touch, as the
/// login page sends an assertion. Pure.
pub fn step_up_body(ceremony_id: &str, touch: &crate::machine_enrol::Touch) -> serde_json::Value {
use crate::machine_enrol::b64url;
serde_json::json!({
"ceremony_id": ceremony_id,
"id": b64url(&touch.raw_id),
"response": {
"clientDataJSON": b64url(&touch.client_data_json),
"authenticatorData": b64url(&touch.authenticator_data),
"signature": b64url(&touch.signature),
},
})
}
/// **A browser's passkey error, in plain words** (owner order 2026-10-18:
/// never show raw browser error text). `name` is the `DOMException`'s name;
/// anything unknown gets a plain sentence too, never the browser's message.
pub fn passkey_error_words(name: &str) -> &'static str {
match name {
"NotAllowedError" => "The passkey request was cancelled or timed out. Try again.",
"AbortError" => "The passkey request was cancelled. Try again.",
"InvalidStateError" => "This key already holds a passkey for this site.",
"SecurityError" => "Passkeys do not work at this address. Open the page at its own https address.",
"NotSupportedError" | "ConstraintError" => "This browser or key cannot make a passkey here. Try another browser or key.",
"TypeError" | "NetworkError" => UNREACHABLE,
_ => "Something went wrong with the passkey. Try again.",
}
}
#[cfg(test)]
mod tests {
use super::{is_proxy_page, may_step_up_for, passkey_error_words, readable_body, step_up_body, wants_step_up, STEP_UP_SUMMARY};
/// ★ Only the server's machine-readable step-up refusal is answered with a
/// touch; a real 403 (a role, gunnar's own permission denied) is not.
#[test]
fn only_a_stepup_refusal_asks_for_a_touch() {
assert!(wants_step_up(403, "stepup"));
for (status, code) in [(403, ""), (403, "permissiondenied"), (401, "stepup"), (400, "stepup"), (403, "notyours")] {
assert!(!wants_step_up(status, code), "{status} {code}");
}
assert!(may_step_up_for("/auth/passkey/invites"));
assert!(may_step_up_for("/api/rebuild"));
assert!(!may_step_up_for("/auth/passkey/step-up"), "the step-up never steps up again");
assert!(!may_step_up_for("/auth/passkey/login/finish"));
assert!(!STEP_UP_SUMMARY.to_ascii_lowercase().contains("role"));
}
#[test]
fn the_step_up_body_is_a_login_assertion() {
let t = crate::machine_enrol::Touch { raw_id: vec![1, 2], client_data_json: b"{}".to_vec(), authenticator_data: vec![9], signature: vec![7, 7] };
let body = step_up_body("c-9", &t);
assert_eq!(body["ceremony_id"], "c-9");
assert_eq!(body["id"], "AQI");
assert_eq!(body["response"]["clientDataJSON"], "e30");
assert_eq!(body["response"]["authenticatorData"], "CQ");
assert_eq!(body["response"]["signature"], "Bwc");
assert!(body.get("code").is_none() && body.get("role").is_none());
}
/// ★ No browser error text reaches the page: every name maps to a sentence
/// a person can act on, and an unknown one too.
#[test]
fn a_browser_error_is_said_in_plain_words() {
for name in ["NotAllowedError", "AbortError", "InvalidStateError", "SecurityError", "NotSupportedError", "TypeError", "SomethingNew"] {
let said = passkey_error_words(name);
assert!(!said.contains("Error") && said.ends_with('.'), "{name}: {said}");
}
}
#[test]
fn a_cloudflare_error_page_is_never_printed() {
let page = "<!DOCTYPE html> <!--[if lt IE 7]> <html class=\"no-js ie6 oldie\" lang=\"en-US\"> \
<head> <title>gunnar.rs | 502: Bad gateway</title>";
assert!(is_proxy_page(page));
let shown = readable_body(page);
assert!(shown.is_empty(), "markup or a description of it reached the page: {shown}");
}
#[test]
fn html_after_whitespace_or_in_other_case_is_caught_too() {
assert!(is_proxy_page("\n\n <HTML><body>x</body></HTML>"));
}
#[test]
fn a_plain_one_line_refusal_is_kept_verbatim() {
assert_eq!(readable_body("that name is taken"), "that name is taken");
}
#[test]
fn a_long_plain_body_is_bounded() {
let long = "x".repeat(5000);
let shown = readable_body(&long);
assert!(shown.chars().count() <= 401, "{} chars", shown.chars().count());
assert!(shown.ends_with('…'));
}
}