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

nordisk/holger-ui

src/session.rs

//! **Who is logged in — holger's answer, rendered** (thin layer, 2026-10-17).
//!
//! The people are holger's: their passkeys, invites, sessions and roles live in
//! holger's passkey door (`/-/auth/passkey/*`, `holger/server/lib/src/passkey.rs`).
//! The login page (`login.html`) runs the ceremony against that door; the
//! session holger mints is the console server's HttpOnly cookie (A34-38), which
//! every gRPC-web call and every door call carries and the server turns into
//! `authorization: Bearer`. This module only asks holger who that is and shows
//! it. It decides nothing: holger checks the session on every call.

#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]

use serde::Deserialize;

/// The login page (a static page in the bundle, served at `/login`).
pub const LOGIN_PAGE: &str = "/login";
/// holger's passkey door: does this holger do passkeys at all (404 when not).
pub const CONFIG_PATH: &str = "/-/auth/passkey/config";
/// holger's passkey door: who this bearer is.
pub const SESSION_PATH: &str = "/-/auth/passkey/session";
/// holger's passkey door: end every session of this person.
pub const LOGOUT_PATH: &str = "/-/auth/passkey/logout";
/// holger's sign-up door: what the ONE shop sells this person here (A11 fix 5). A metered
/// console's only (A28 M13), as the two below.
#[cfg(feature = "metered")]
pub const SIGNUP_DOOR: &str = "/-/signup";

/// Pure: **the console's way to buy** (A11 fix 5, Gunnar's Account page "Buy a plan" / "Grow
/// your account"), from holger's sign-up door's answer for this session: the link to the
/// sign-up page and its words, or `None` where the shop sells this person nothing here. The
/// page decides nothing: holger's answer does.
#[cfg(feature = "metered")]
pub fn buy_link(answer: &str) -> Option<(String, &'static str)> {
    let v: serde_json::Value = serde_json::from_str(answer).ok()?;
    if v.get("signed_in").and_then(|b| b.as_bool()) != Some(true) {
        return None;
    }
    // A12: the site's own sign-up page (`/signup?plan=…`, or the same page on the site's https
    // origin, as holger's door answers them); never `//host` or `/\host`, never another
    // scheme, and never a page of the ONE shop's (OD6, 2026-10-26: `/shop/private`, `/kop` and
    // the `/?product=` intake are deleted).
    let https = |k: &str| v.get(k).and_then(|u| u.as_str()).is_some_and(own_sign_up);
    if https("grow_account_url") {
        let words = if v.get("plan").and_then(|p| p.as_str()) == Some("free") { "Buy a plan" } else { "Grow your account" };
        return Some(("/signup?plan=account".to_owned(), words));
    }
    // ★ No "New instance" here (owner 2026-10-27, Gunnar is the template): a private instance
    // is ordered from Account → Buy a plan, whose page has the two tabs.
    None
}

/// Pure: **is `u` a site's own sign-up page** — `/signup?plan=…` (or `/signup`), on this site or
/// on an https origin: the path is `/signup` and nothing else.
#[cfg(feature = "metered")]
fn own_sign_up(u: &str) -> bool {
    let path = match u.strip_prefix("https://") {
        Some(rest) => match rest.find('/') {
            Some(i) if i > 0 => &rest[i..],
            _ => return false,
        },
        None => u,
    };
    let page = path.split(['?', '#']).next().unwrap_or("");
    page == "/signup" && !path.starts_with("//")
}

/// holger's sign-up door for this tab's session: the console's way to buy, if any.
#[cfg(all(feature = "metered", target_arch = "wasm32"))]
pub async fn fetch_buy_link() -> Option<(String, &'static str)> {
    match door(SIGNUP_DOOR, None).await.ok()? {
        (200, body) => buy_link(&body),
        _ => None,
    }
}

/// What the console shows about the caller, from holger's two answers.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AuthStatus {
    pub authenticated: bool,
    pub user: String,
    pub role: Option<String>,
    /// holger serves passkeys (its config door answered 200).
    pub passkey_enabled: bool,
    /// A holger without passkeys knows this tab's pasted bearer (its WhoAmI says signed in).
    pub token_held: bool,
}

/// holger's `/-/auth/passkey/session` answer, the fields shown.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct SessionBody {
    subject: String,
    display: String,
    role: String,
}

/// The console's word for holger's role — rendering only (holger decides).
pub fn role_word(holger_role: &str) -> &'static str {
    match holger_role.trim().to_ascii_lowercase().as_str() {
        "admin" => "admin",
        "writer" => "operator",
        "denied" => "denied",
        _ => "viewer",
    }
}

/// Pure: holger's two answers → what is shown. `session` is the body of a 200
/// from the session door, `None` when there is no bearer or holger refused it.
pub fn status_of(passkey_enabled: bool, session: Option<&str>) -> AuthStatus {
    let me = session.and_then(|b| serde_json::from_str::<SessionBody>(b).ok());
    match me {
        Some(me) => AuthStatus {
            authenticated: true,
            user: if me.display.is_empty() { me.subject } else { me.display },
            role: Some(role_word(&me.role).to_owned()),
            passkey_enabled,
            token_held: false,
        },
        None => AuthStatus { passkey_enabled, ..AuthStatus::default() },
    }
}

/// Pure: holger's refusal body (`{"error": "…"}`, or plain text) as its sentence.
pub fn refusal_words(status: u16, body: &str) -> String {
    let said = serde_json::from_str::<serde_json::Value>(body)
        .ok()
        .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_owned))
        .unwrap_or_else(|| body.trim().to_owned());
    if said.is_empty() {
        format!("HTTP {status}")
    } else {
        said
    }
}

/// What the topbar and /account show.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Shown {
    /// holger has not answered yet.
    Unknown,
    /// holger knows this bearer: who, and holger's role in the console's words.
    Session { user: String, role: String },
    /// holger does passkeys and this tab holds no live session.
    LoggedOut { login: &'static str },
    /// holger does no passkeys: a pasted bearer is the way in.
    TokenOnly { has_token: bool },
}

pub fn shown(status: Option<&AuthStatus>, has_token: bool) -> Shown {
    let Some(s) = status else { return Shown::Unknown };
    if s.authenticated {
        return Shown::Session { user: s.user.clone(), role: s.role.clone().unwrap_or_default() };
    }
    if s.passkey_enabled {
        return Shown::LoggedOut { login: LOGIN_PAGE };
    }
    Shown::TokenOnly { has_token }
}

impl Shown {
    pub fn offers_login(&self) -> bool {
        matches!(self, Shown::LoggedOut { .. } | Shown::TokenOnly { has_token: false })
    }

    pub fn token_forms(&self) -> bool {
        matches!(self, Shown::TokenOnly { .. })
    }
}

/// One call to holger's passkey door, same origin, with this tab's session.
/// `(status, body)`; `Err` when holger was not reached.
#[cfg(target_arch = "wasm32")]
pub async fn door(path: &str, body: Option<&serde_json::Value>) -> Result<(u16, String), String> {
    let req = match body {
        Some(_) => gloo_net::http::Request::post(path),
        None => gloo_net::http::Request::get(path),
    };
    let sent = match body {
        Some(b) => req.json(b).map_err(|e| e.to_string())?.send().await,
        None => req.send().await,
    };
    let resp = sent.map_err(|e| format!("holger did not answer: {e}"))?;
    Ok((resp.status(), resp.text().await.unwrap_or_default()))
}

#[cfg(target_arch = "wasm32")]
pub async fn fetch_status() -> Option<AuthStatus> {
    let (config, _) = door(CONFIG_PATH, None).await.ok()?;
    if config != 200 {
        // No passkeys: a pasted bearer is the way in, and holger's WhoAmI says whether it holds.
        let held = crate::grpc::who_am_i().await.map(|w| w.authenticated).unwrap_or(false);
        return Some(AuthStatus { token_held: held, ..status_of(false, None) });
    }
    let session = match door(SESSION_PATH, Some(&serde_json::json!({}))).await.ok()? {
        (200, body) => Some(body),
        _ => None,
    };
    Some(status_of(true, session.as_deref()))
}

/// holger ends the person's sessions; the console server clears its cookie. `Err`: holger did
/// not (its words, RA5-11) — the session lives on and the cookie is kept.
#[cfg(target_arch = "wasm32")]
pub async fn logout() -> Result<(), String> {
    match door(LOGOUT_PATH, Some(&serde_json::json!({}))).await? {
        (status, _) if (200..300).contains(&status) => Ok(()),
        (status, body) => Err(refusal_words(status, &body)),
    }
}

/// The console server's door for a pasted bearer (`holger-ui-server` `session::TOKEN_DOOR`).
pub const TOKEN_DOOR: &str = "/-/console/token";

/// **A pasted bearer into the console server's HttpOnly cookie** (empty: forget it). `Err`: the
/// server's words.
#[cfg(target_arch = "wasm32")]
pub async fn hold_token(token: &str) -> Result<(), String> {
    let req = gloo_net::http::Request::post(TOKEN_DOOR).json(&serde_json::json!({ "token": token })).map_err(|e| e.to_string())?;
    let res = req.send().await.map_err(|e| format!("the console did not answer: {e}"))?;
    if res.ok() {
        Ok(())
    } else {
        Err(refusal_words(res.status(), &res.text().await.unwrap_or_default()))
    }
}

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

    /// ★ A11 fix 5: a signed-in person's way to buy, as holger's door answers it.
    #[cfg(feature = "metered")]
    #[test]
    fn the_console_offers_a_way_to_buy_where_holger_says_the_shop_sells() {
        let free = r#"{"signed_in":true,"account":"x14","plan":"free","grow_account_url":"https://holger.rs/signup?plan=account","private_url":"https://holger.rs/signup?plan=private"}"#;
        assert_eq!(buy_link(free), Some(("/signup?plan=account".to_owned(), "Buy a plan")));
        let held = free.replace("\"plan\":\"free\"", "\"plan\":\"plan\"");
        assert_eq!(buy_link(&held), Some(("/signup?plan=account".to_owned(), "Grow your account")));
        let no_room = r#"{"signed_in":true,"account":"nina","plan":"","grow_account_url":"","private_url":"https://holger.rs/signup?plan=private"}"#;
        // ★ No "New instance" in the top bar (owner 2026-10-27; Gunnar is the template): a private
        // instance is ordered from Account → Buy a plan, whose page has the two tabs.
        assert_eq!(buy_link(no_room), None, "the top bar offers no New instance");
        assert_eq!(buy_link(r#"{"signed_in":false,"account_url":"https://monetize.ignalina.dk/shop/signup","private_url":"https://x"}"#), None, "a visitor signs up on the front page");
        assert_eq!(buy_link(r#"{"signed_in":true,"grow_account_url":"","private_url":""}"#), None, "nothing sold, nothing shown");
        assert_eq!(buy_link(r#"{"signed_in":true,"grow_account_url":"javascript:x","private_url":"http://x"}"#), None, "only an https shop link counts");
        assert_eq!(buy_link("not json"), None);
        // A12: holger's door names the site's own pages.
        assert_eq!(buy_link(r#"{"signed_in":true,"plan":"free","grow_account_url":"/signup?plan=account","private_url":"/signup?plan=private"}"#), Some(("/signup?plan=account".to_owned(), "Buy a plan")));
        assert_eq!(buy_link(r#"{"signed_in":true,"grow_account_url":"","private_url":"/signup?plan=private"}"#), None, "the top bar offers no New instance");
        assert_eq!(buy_link(r#"{"signed_in":true,"grow_account_url":"//evil.example/signup","private_url":"/\\evil"}"#), None, "never another host");
    }

    /// ★ OD6 (2026-10-26): the ONE box's own order pages are deleted, so an answer that still
    /// names one (`/shop/private`, `/kop`, the `/?product=` intake) offers nothing; only a
    /// site's own sign-up page counts.
    #[cfg(feature = "metered")]
    #[test]
    fn a_link_to_the_one_shops_deleted_pages_offers_nothing() {
        for stale in [
            r#"{"signed_in":true,"plan":"free","grow_account_url":"https://monetize.ignalina.dk/?product=holger&repository=x14&ticket=ab","private_url":""}"#,
            r#"{"signed_in":true,"grow_account_url":"","private_url":"https://monetize.ignalina.dk/shop/private?product=holger&site=holger.rs&account=x14&ticket=cd"}"#,
            r#"{"signed_in":true,"grow_account_url":"https://monetize.ignalina.dk/kop?product=holger&tenant=x14","private_url":""}"#,
        ] {
            assert_eq!(buy_link(stale), None, "{stale}");
        }
    }

    fn offers_logout(s: &Shown) -> bool {
        matches!(s, Shown::Session { .. } | Shown::TokenOnly { has_token: true })
    }

    const ALICE: &str = r#"{"subject":"passkey:1","display":"alice","method":"passkey","role":"admin","uv":true}"#;

    #[test]
    fn a_passkey_session_never_sees_a_login_offer() {
        let s = shown(Some(&status_of(true, Some(ALICE))), true);
        assert_eq!(s, Shown::Session { user: "alice".into(), role: "admin".into() });
        assert!(!s.offers_login(), "no login menu for somebody who is logged in");
        assert!(!s.token_forms(), "and no second and third way in");
        assert!(offers_logout(&s));
    }

    #[test]
    fn a_passkey_holger_has_one_road_in() {
        let s = shown(Some(&status_of(true, None)), false);
        assert_eq!(s, Shown::LoggedOut { login: "/login" });
        assert!(s.offers_login() && !s.token_forms());
        assert_eq!(shown(Some(&status_of(true, None)), true), Shown::LoggedOut { login: "/login" }, "a refused bearer is logged out");
    }

    #[test]
    fn nothing_is_offered_before_holger_answers() {
        let s = shown(None, true);
        assert_eq!(s, Shown::Unknown);
        assert!(!s.offers_login() && !offers_logout(&s) && !s.token_forms());
    }

    #[test]
    fn a_token_only_holger_keeps_the_token_forms() {
        let out = shown(Some(&status_of(false, None)), false);
        assert!(out.offers_login() && out.token_forms());
        let inn = shown(Some(&status_of(false, None)), true);
        assert!(!inn.offers_login() && offers_logout(&inn) && inn.token_forms());
    }

    #[test]
    fn holgers_roles_are_shown_in_the_consoles_words_and_a_subject_stands_in_for_a_name() {
        assert_eq!(role_word("Writer"), "operator");
        assert_eq!(role_word("reader"), "viewer");
        assert_eq!(role_word("denied"), "denied");
        let s = status_of(false, Some(r#"{"subject":"console","display":"","role":"writer"}"#));
        assert_eq!((s.user.as_str(), s.role.as_deref()), ("console", Some("operator")));
        assert!(!status_of(true, Some("<html>")).authenticated);
    }

    #[test]
    fn a_refusal_is_holgers_own_sentence() {
        assert_eq!(refusal_words(403, r#"{"error":"log in again with your key"}"#), "log in again with your key");
        assert_eq!(refusal_words(404, "passkey login is not configured on this server"), "passkey login is not configured on this server");
        assert_eq!(refusal_words(502, ""), "HTTP 502");
    }
}