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