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

nordisk/holger-ui

crates/holger-public-site/src/setting.rs

//! **The administrator's switch, as holger keeps it** (owner decision 2026-10-26, item 5:
//! "a switch in admin, account admin only, stored as a generic server setting, audited").
//!
//! The switch's position is holger's generic setting [`NAME`] (`/-/settings/<name>` on holger's
//! HTTP door: an administrator only, every write audited there), the bytes `on` or `off`.
//! holger-ui-server reads it with the console's own bearer sent only as the proof
//! (`x-holger-proxy-authorization`), at start and every few seconds after, and writes it with
//! the administrator's own bearer on a flip; these are the pure halves of that (no HTTP here).
//! `HOLGER_UI_PUBLIC_SITE`, and the front's feed `console_public_site`, are the INITIAL
//! position: they hold until a position is stored, and the stored one wins from then on.

/// The setting's name at holger.
pub const NAME: &str = "console.public-site";
/// holger's door for it.
pub const DOOR: &str = "/-/settings/console.public-site";
/// The console's route for the switch (GET the position, PUT `{"on": bool}`).
pub const ROUTE: &str = "/api/public-site";

// The rules, once for both consoles (RA2-15): the stored bytes, what a store holds, the
// fail-closed position, the follower's steps.
pub use nordisk_console_switch::{effective, position, stored, stored_of, Follow, Said, Stored, READ_DEADLINE, RECHECK};

/// Pure: holger's `value_hex` as bytes; `None` for anything that is not hex.
fn unhex(hex: &str) -> Option<Vec<u8>> {
    if !hex.len().is_multiple_of(2) {
        return None;
    }
    (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()).collect()
}

/// Pure: **the stored position in holger's answer** to `GET /-/settings/<name>`
/// (`{"present": bool, "value_hex": "…"}`): `None` where nothing (usable) is stored.
pub fn position_in(answer: &[u8]) -> Option<bool> {
    match stored_in(200, answer)? {
        Stored::At(on) => Some(on),
        Stored::Unset | Stored::Damaged => None,
    }
}

/// Pure: **holger's answer to a read** (`status`, body) as what it stores; `None` where it said
/// nothing usable (a refusal, an error): nothing is learnt. A 503 `{"damaged": true}` is a
/// damaged store; a stored value that is neither `on` nor `off` is damaged too.
pub fn stored_in(status: u16, answer: &[u8]) -> Option<Stored> {
    let v: serde_json::Value = serde_json::from_slice(answer).ok()?;
    match status {
        503 if v.get("damaged").and_then(serde_json::Value::as_bool) == Some(true) => Some(Stored::Damaged),
        200 => {
            let present = v.get("present").and_then(serde_json::Value::as_bool)?;
            let value = v.get("value_hex").and_then(serde_json::Value::as_str).and_then(unhex);
            Some(match (present, value) {
                (false, _) => Stored::Unset,
                (true, Some(bytes)) => stored_of(true, &bytes),
                (true, None) => Stored::Damaged,
            })
        }
        _ => None,
    }
}

/// Pure: the position a flip asks for (`{"on": bool}`), `None` for any other body.
pub fn asked(body: &[u8]) -> Option<bool> {
    serde_json::from_slice::<serde_json::Value>(body).ok()?.get("on").and_then(serde_json::Value::as_bool)
}

/// Pure: the console's answer for a position.
pub fn answer(on: bool) -> String {
    serde_json::json!({ "on": on }).to_string()
}

/// Pure: the console's answer for a refusal.
pub fn refusal(words: &str) -> String {
    serde_json::json!({ "error": words }).to_string()
}

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

    #[test]
    fn holgers_answer_gives_the_stored_position_and_nothing_else() {
        assert_eq!(position_in(br#"{"present":true,"value_hex":"6f6e"}"#), Some(true));
        assert_eq!(position_in(br#"{"present":true,"value_hex":"6f6666"}"#), Some(false));
        assert_eq!(position_in(br#"{"present":false,"value_hex":""}"#), None, "nothing stored: the initial position holds");
        assert_eq!(position_in(br#"{"present":true,"value_hex":"79"}"#), None);
        assert_eq!(position_in(b"Forbidden"), None);
        assert_eq!((stored(true), stored(false)), (&b"on"[..], &b"off"[..]));
    }

    #[test]
    fn a_flip_asks_for_on_or_off_and_nothing_else() {
        assert_eq!(asked(br#"{"on":true}"#), Some(true));
        assert_eq!(asked(br#"{"on":false}"#), Some(false));
        assert_eq!(asked(br#"{"on":"yes"}"#), None);
        assert_eq!(asked(b""), None);
        assert_eq!(answer(true), r#"{"on":true}"#);
        assert!(refusal("no").contains("\"error\""));
    }

    /// ★ RA2-15: the switch rules are the shared crate's (nordisk-console-switch), not a copy.
    #[test]
    fn the_switch_rules_are_the_shared_crates() {
        let unset: nordisk_console_switch::Stored = Stored::Unset;
        assert!(effective(true, Some(unset)));
        assert_eq!(nordisk_console_switch::stored(true), stored(true));
    }

    #[test]
    fn holgers_answer_is_read_as_stored_unset_damaged_or_nothing_learnt() {
        assert_eq!(stored_in(200, br#"{"present":true,"value_hex":"6f6e"}"#), Some(Stored::At(true)));
        assert_eq!(stored_in(200, br#"{"present":false,"value_hex":""}"#), Some(Stored::Unset));
        assert_eq!(stored_in(200, br#"{"present":true,"value_hex":"79"}"#), Some(Stored::Damaged));
        assert_eq!(stored_in(503, br#"{"error":"x","damaged":true}"#), Some(Stored::Damaged));
        assert_eq!(stored_in(403, b"{}"), None, "a refused read learns nothing");
        assert_eq!(stored_in(200, b"nonsense"), None);
    }
}