crates/gunnar-ui-web/src/console_key.rs
//! **What the console's own credential on a person's account is worth**, on the
//! host side of the wasm boundary. Same argument as [`crate::room`].
//!
//! The console reads gunnar as the person sitting in front of it: it enrols a
//! key of its own on their principal and signs every read with it (the server
//! half is `gunnar_ui_server::tenant`). gunnar accepts a SECOND key on a
//! principal only when the principal vouches for it with an sshsig made by a
//! key they already hold — so anybody who registered an SSH key BEFORE their
//! first console read is refused, their reads fall back to the grantless guest,
//! and the console shows them the public subset of a box they own. Until
//! 2026-09-18 there was no sentence on any page saying so.
//!
//! The rule that matters is the one below, and it is about silence: an enrolled
//! credential says NOTHING. A permanent "all good" panel is a panel a reader
//! stops seeing by the second visit, and this one has to be noticed the one
//! time it appears. So the ordinary case renders nothing at all, and every
//! other answer is judged here rather than inside a `view!` where it could not
//! be tested.
use serde::Deserialize;
/// `GET /api/account/console-key`, as the server sends it.
///
/// `#[serde(default)]` on the whole struct and not on one field: `refusal` is
/// already `skip_serializing_if` on the server, and a console that refuses to
/// parse an older or newer appliance's answer would hide exactly the panel
/// whose job is to explain a mismatch. Missing fields become empty, and empty
/// is a state [`shown`] already has an answer for.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct ConsoleKey {
/// `enrolled`, `awaiting_attestation` or `absent`.
pub state: String,
/// The principal this session acts for, when there is one.
pub principal_id: String,
/// `SHA256:…` of the console's key — the enrolled one, or the one waiting.
pub fingerprint: String,
/// The OpenSSH line to attest. Empty unless something is waiting.
pub openssh_public_key: String,
/// The command to run on the appliance, composed by the server from the two
/// values above. Empty unless something is waiting.
pub command: String,
/// **gunnar's own sentence**, verbatim, when it refused. Never reworded on
/// the way to the screen: the appliance knows why and this console does
/// not, and two explanations of one refusal leave a reader with no way to
/// tell which is true.
pub refusal: String,
}
/// What the account page draws, and mostly it draws nothing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Shown {
/// Nothing at all. The credential works, or there is nothing to say about
/// it that a person could act on.
Nothing,
/// gunnar's refusal, with no way out on offer — there is no key waiting to
/// be vouched for, so a form would ask for something nobody can produce.
Refused { refusal: String },
/// A key is waiting for the account holder's word: gunnar's reason, the
/// command that signs it, and the fingerprint that command is about.
Waiting {
refusal: String,
command: String,
fingerprint: String,
},
}
/// ★ **The one decision the panel makes.**
///
/// Read in this order, and the order is the point:
///
/// * `enrolled` wins over everything, including a `refusal` left in the answer
/// by an earlier attempt. The credential works; there is nothing to do.
/// * `awaiting_attestation` is the case this whole surface exists for, and the
/// only one that draws a form.
/// * anything else — `absent`, or a state word from an appliance newer than
/// this bundle — shows gunnar's sentence if there is one and stays quiet if
/// there is not. Quiet rather than a guess: an unknown word is not a reason
/// to invent a ceremony, and a refusal with nothing waiting is not a form.
pub fn shown(key: &ConsoleKey) -> Shown {
let refusal = key.refusal.trim().to_owned();
match key.state.as_str() {
"enrolled" => Shown::Nothing,
"awaiting_attestation" => Shown::Waiting {
refusal,
command: key.command.trim().to_owned(),
fingerprint: key.fingerprint.trim().to_owned(),
},
_ if refusal.is_empty() => Shown::Nothing,
_ => Shown::Refused { refusal },
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The ordinary case, and it is silent. Asserted first because it is what
/// every visit to `/account` hits: a panel here would be noise on every
/// page load for everybody whose console is working.
#[test]
fn an_enrolled_credential_says_nothing() {
assert_eq!(
shown(&ConsoleKey {
state: "enrolled".to_owned(),
principal_id: "p-anna".to_owned(),
fingerprint: "SHA256:theconsoleskey".to_owned(),
..ConsoleKey::default()
}),
Shown::Nothing
);
// Even with a sentence left over from the attempt that got it there:
// the credential works now, and yesterday's refusal is not news.
assert_eq!(
shown(&ConsoleKey {
state: "enrolled".to_owned(),
refusal: "principal p-anna already holds 1 key(s)".to_owned(),
..ConsoleKey::default()
}),
Shown::Nothing
);
}
/// ★ **The stuck account**: gunnar's words, the command, and the
/// fingerprint it is about, all three, because a person who has to run
/// something on the appliance cannot go and read the console's log to find
/// out which key is meant.
#[test]
fn a_waiting_key_carries_gunnars_reason_and_the_command() {
let waiting = shown(&ConsoleKey {
state: "awaiting_attestation".to_owned(),
principal_id: "p-anna".to_owned(),
fingerprint: "SHA256:thewaitingkey".to_owned(),
openssh_public_key: "ssh-ed25519 AAAAwaiting x".to_owned(),
command: "gunnar control user attest-key p-anna --key 'ssh-ed25519 AAAAwaiting x'\n"
.to_owned(),
refusal: " principal p-anna already holds 1 key(s), so adding another needs their \
own attestation "
.to_owned(),
});
let Shown::Waiting {
refusal,
command,
fingerprint,
} = waiting
else {
panic!("a key waiting to be vouched for drew something else: {waiting:?}");
};
assert!(
refusal.starts_with("principal p-anna already holds"),
"gunnar's sentence was reworded or padded: {refusal:?}"
);
assert!(
command.starts_with("gunnar control user attest-key") && command.ends_with('\''),
"the command is not one pasteable line: {command:?}"
);
assert_eq!(fingerprint, "SHA256:thewaitingkey");
}
/// **A refusal with nothing waiting is a sentence and not a form.** The
/// mint itself failed, or the store did; asking for an attestation of a key
/// that was never made is asking for something nobody can produce.
#[test]
fn a_refusal_with_nothing_waiting_offers_no_ceremony() {
assert_eq!(
shown(&ConsoleKey {
state: "absent".to_owned(),
refusal: "gunnar has no principal bound to this session".to_owned(),
..ConsoleKey::default()
}),
Shown::Refused {
refusal: "gunnar has no principal bound to this session".to_owned()
}
);
// And an absence gunnar gave no reason for stays quiet. There is
// nothing a reader could do with "absent" on its own.
assert_eq!(
shown(&ConsoleKey {
state: "absent".to_owned(),
..ConsoleKey::default()
}),
Shown::Nothing
);
}
/// **A state word this bundle has never heard of.** The console and the
/// appliance are upgraded separately, so a newer server WILL eventually
/// send one. It must not become a form — the browser would be inventing a
/// ceremony for a state it cannot know the shape of — and it must not
/// swallow the server's reason either.
#[test]
fn a_newer_appliances_state_word_is_not_guessed_at() {
assert_eq!(
shown(&ConsoleKey {
state: "revoked_upstream".to_owned(),
refusal: "the key was revoked on the appliance".to_owned(),
command: "gunnar control user attest-key p-anna --key 'x'".to_owned(),
..ConsoleKey::default()
}),
Shown::Refused {
refusal: "the key was revoked on the appliance".to_owned()
},
"an unknown state drew the attestation form"
);
}
/// **The answer's shape is the server's**, and this pins it against
/// `gunnar_ui_server::api::account::ConsoleKey`. The two structs are
/// mirrored rather than shared — this crate compiles to wasm and does not
/// depend on the BFF — so a field renamed on one side has to fail on the
/// other, and a silently-empty panel is not a failure anybody would notice.
#[test]
fn the_answer_shape_is_the_servers() {
let sent = r#"{
"state": "awaiting_attestation",
"principal_id": "p-anna",
"fingerprint": "SHA256:thewaitingkey",
"openssh_public_key": "ssh-ed25519 AAAAwaiting gunnar-ui console session for anna",
"command": "gunnar control user attest-key p-anna --key 'ssh-ed25519 AAAAwaiting x'",
"refusal": "principal p-anna already holds 1 key(s)"
}"#;
let parsed: ConsoleKey = serde_json::from_str(sent).expect("the server's own shape");
assert_eq!(parsed.state, "awaiting_attestation");
assert_eq!(parsed.principal_id, "p-anna");
assert!(parsed.command.contains("attest-key"));
// `refusal` is `skip_serializing_if = "String::is_empty"` on the
// server, so the enrolled answer arrives WITHOUT it. That must parse.
let enrolled = r#"{"state":"enrolled","principal_id":"p-anna",
"fingerprint":"SHA256:x","openssh_public_key":"","command":""}"#;
let parsed: ConsoleKey = serde_json::from_str(enrolled).expect("no refusal field");
assert_eq!(shown(&parsed), Shown::Nothing);
}
}