crates/gunnar-ui-server/src/reset.rs
//! ★ **`reset-state`: getting back in when nobody can sign in.**
//!
//! Since the identity core (IDENTITY-HOST, 2026-10-27) this console holds no
//! passkey, no session and no session key: the passkeys and the sessions are
//! gunnar's, behind `nornir.identity.v1.IdentityService`. What a box handed
//! over, or misconfigured, can still be stuck on here is:
//!
//! * the **first-admin mark** — "the first admin's passkey was registered
//! through this console", which retires the break-glass
//! `GUNNAR_UI_ADMIN_TOKEN`. Forget it and the console offers the token
//! again; gunnar's fed first-login link decides whether it still opens
//! anything;
//! * the **leftovers of the console's own identity from before the core**
//! (`passkeys`, `session_key`, `session_epoch`, `session_floor:*`,
//! `web-session:*`, `spent_invite/*`): nothing reads them any more, and a
//! reset clears them.
//!
//! Until this verb existed the recovery was `rm /opt/gunnar-ui/state.redb`,
//! which on a running console removes the name and leaves the open file
//! behind, takes everybody's preferences and the README cache with it, and
//! says nothing about what was there.
//!
//! # Two scopes
//!
//! [`Scope::Passkeys`] forgets exactly the keys above and nothing else.
//! [`Scope::Everything`] empties the store.
//!
//! # It answers before it acts
//!
//! [`plan`] reads and changes nothing; [`apply`] does it. The CLI runs [`plan`]
//! unless it is given `--yes`. The plan names the keys it will drop AND the
//! keys it will keep, because the second list is what tells an operator whether
//! they picked the right scope.
//!
//! # Values are never read, printed or returned
//!
//! [`crate::state::StateStore::auth_keys`] hands over names only.
use crate::auth::ADMIN_REGISTERED_KEY;
use crate::state::StateStore;
/// The console's own passkey table from before the identity core.
const LEGACY_PASSKEYS_KEY: &str = "passkeys";
/// The keys of the console's own identity from before the identity core, and
/// the first-admin mark: what [`Scope::Passkeys`] forgets.
fn identity_key(key: &str) -> bool {
matches!(key, LEGACY_PASSKEYS_KEY | "session_key" | "session_epoch" | ADMIN_REGISTERED_KEY)
|| key.starts_with("session_floor:")
|| key.starts_with("web-session:")
|| key.starts_with("spent_invite/")
}
/// How much a reset throws away.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
/// The first-admin mark and the console's identity leftovers from before
/// the core. Everything else — the invites, the preferences, the cache —
/// is kept.
Passkeys,
/// Every table. The console comes back up as if it had never been
/// configured, and the break-glass `GUNNAR_UI_ADMIN_TOKEN` is the way back
/// in, exactly as it was on the first day.
Everything,
}
impl Scope {
/// The word the CLI flag spells, for messages.
pub fn flag(self) -> &'static str {
match self {
Scope::Passkeys => "--passkeys",
Scope::Everything => "--all",
}
}
}
/// **What a reset would do, read out of the store and changed nothing.**
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan {
pub scope: Scope,
/// How many passkeys the console's table from before the core still
/// lists. `None` when the stored value is there and does not parse.
pub passkeys: Option<usize>,
/// The auth keys this scope would drop, sorted.
pub dropping: Vec<String>,
/// The auth keys this scope would keep, sorted.
pub keeping: Vec<String>,
/// Rows in the preference and cache tables, which only
/// [`Scope::Everything`] touches.
pub prefs: usize,
pub cache: usize,
}
impl Plan {
/// True when there is nothing for this scope to do.
pub fn is_noop(&self) -> bool {
self.dropping.is_empty() && self.prefs == 0 && self.cache == 0
}
}
/// **What a reset did.**
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Done {
pub scope: Scope,
/// The auth keys that were dropped, sorted.
pub dropped: Vec<String>,
/// Kept for the CLI's shape: the sessions are the identity core's, so a
/// reset here bumps nothing. Always `None`.
pub epoch: Option<u64>,
/// Rows emptied out of the preference and cache tables.
pub prefs: usize,
pub cache: usize,
}
/// Read what a reset WOULD do. Changes nothing.
///
/// # Errors
///
/// Fails when the store cannot be read.
pub fn plan(state: &StateStore, scope: Scope) -> Result<Plan, String> {
let keys = state
.auth_keys()
.map_err(|e| format!("cannot read the state store: {e}"))?;
let (dropping, keeping): (Vec<String>, Vec<String>) =
keys.into_iter().partition(|key| drops(scope, key));
let stats = state
.cache_stats()
.map_err(|e| format!("cannot measure the cache: {e}"))?;
let prefs = state
.pref_count()
.map_err(|e| format!("cannot count the preferences: {e}"))?;
Ok(Plan {
scope,
passkeys: count_passkeys(state)?,
dropping,
keeping,
prefs: matches!(scope, Scope::Everything).then_some(prefs).unwrap_or(0),
cache: matches!(scope, Scope::Everything)
.then_some(stats.entries)
.unwrap_or(0),
})
}
/// Do it.
///
/// # Errors
///
/// Fails when the store cannot be read or written. A [`Scope::Everything`]
/// failure changes nothing — the clear is one transaction.
pub fn apply(state: &StateStore, scope: Scope) -> Result<Done, String> {
let plan = plan(state, scope)?;
match scope {
Scope::Passkeys => {
let mut dropped = Vec::new();
for key in &plan.dropping {
let had = state
.remove(key)
.map_err(|e| format!("cannot forget {key:?}: {e}"))?;
if had {
dropped.push(key.clone());
}
}
Ok(Done { scope, dropped, epoch: None, prefs: 0, cache: 0 })
}
Scope::Everything => {
let cleared = state
.clear()
.map_err(|e| format!("cannot empty the state store: {e}"))?;
Ok(Done { scope, dropped: plan.dropping, epoch: None, prefs: cleared.prefs, cache: cleared.cache })
}
}
}
/// Whether this scope drops this key.
fn drops(scope: Scope, key: &str) -> bool {
match scope {
Scope::Passkeys => identity_key(key),
Scope::Everything => true,
}
}
/// How many passkeys the console's table from before the core lists.
fn count_passkeys(state: &StateStore) -> Result<Option<usize>, String> {
let held = state
.get(LEGACY_PASSKEYS_KEY)
.map_err(|e| format!("cannot read the passkeys: {e}"))?;
let Some(bytes) = held else {
return Ok(Some(0));
};
Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|v| v.as_array().map(Vec::len)))
}
/// The one place that knows a key is a session floor from before the core,
/// for the CLI's summary.
pub fn is_session_floor(key: &str) -> bool {
key.starts_with("session_floor:")
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (StateStore, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("a temp dir");
let store = StateStore::open(dir.path().join("state.redb")).expect("a fresh store");
(store, dir)
}
/// A console upgraded onto the identity core: its leftovers from before,
/// the first-admin mark, the login offer's key, and a preference.
fn furnished() -> (StateStore, tempfile::TempDir) {
let (store, dir) = store();
store.put(LEGACY_PASSKEYS_KEY, br#"[{"id":"a"},{"id":"b"}]"#).expect("passkeys");
store.put("session_key", &[7u8; 32]).expect("session key");
store.put("session_epoch", &9u64.to_le_bytes()).expect("epoch");
store.put("session_floor:alice", &1_700_000_000i64.to_le_bytes()).expect("floor");
store.put(ADMIN_REGISTERED_KEY, &1i64.to_le_bytes()).expect("mark");
store.put("login_offer_key", &[3u8; 32]).expect("offer key");
store.pref_put("bob\u{1f}reading", b"prose").expect("pref");
(store, dir)
}
/// ★ **The narrow scope forgets the console's identity leftovers and the
/// first-admin mark, and NOTHING else.**
#[test]
fn resetting_the_passkeys_forgets_the_identity_leftovers_and_keeps_the_rest() {
let (store, _dir) = furnished();
let plan = plan(&store, Scope::Passkeys).expect("a plan");
assert_eq!(plan.passkeys, Some(2));
for gone in [LEGACY_PASSKEYS_KEY, "session_key", "session_epoch", "session_floor:alice", ADMIN_REGISTERED_KEY] {
assert!(plan.dropping.contains(&gone.to_owned()), "{gone}: {:?}", plan.dropping);
}
assert_eq!(plan.keeping, vec!["login_offer_key".to_owned()]);
assert_eq!((plan.prefs, plan.cache), (0, 0));
assert!(store.get(LEGACY_PASSKEYS_KEY).expect("read").is_some(), "planning changed nothing");
let done = apply(&store, Scope::Passkeys).expect("applied");
assert_eq!(done.dropped.len(), 5, "{:?}", done.dropped);
assert_eq!(done.epoch, None, "the sessions are the identity core's");
assert_eq!(store.get(ADMIN_REGISTERED_KEY).expect("read"), None, "the break-glass door is offered again");
assert!(store.get("login_offer_key").expect("read").is_some());
assert_eq!(store.pref_get("bob\u{1f}reading").expect("read"), Some(b"prose".to_vec()));
}
/// ★ **`--all` empties every table, in one transaction.**
#[test]
fn the_whole_store_goes_when_the_whole_store_is_asked_for() {
let (store, _dir) = furnished();
store.cache_put("readme:abc", b"<svg/>", 10, 1_000_000).expect("cache");
let plan = plan(&store, Scope::Everything).expect("a plan");
assert!(plan.keeping.is_empty(), "{:?}", plan.keeping);
assert_eq!(plan.prefs, 1);
assert_eq!(plan.cache, 1);
let done = apply(&store, Scope::Everything).expect("applied");
assert_eq!((done.prefs, done.cache, done.epoch), (1, 1, None));
assert!(store.auth_keys().expect("read").is_empty());
}
/// A store with nothing in it is not an error, and says it has nothing to do.
#[test]
fn an_empty_store_is_a_noop_and_not_a_failure() {
let (store, _dir) = store();
let plan = plan(&store, Scope::Passkeys).expect("a plan");
assert!(plan.is_noop());
assert_eq!(plan.passkeys, Some(0));
assert!(apply(&store, Scope::Passkeys).expect("applied").dropped.is_empty());
}
/// ★ **A passkey blob that does not parse is `None`, never `0`.**
#[test]
fn an_unparseable_passkey_entry_is_reported_as_unknown_and_not_as_none() {
let (store, _dir) = store();
store.put(LEGACY_PASSKEYS_KEY, b"not json at all").expect("write");
assert_eq!(plan(&store, Scope::Passkeys).expect("a plan").passkeys, None);
store.put(LEGACY_PASSKEYS_KEY, b"{}").expect("write");
assert_eq!(plan(&store, Scope::Passkeys).expect("a plan").passkeys, None);
}
#[test]
fn a_session_floor_is_recognisable_to_the_summary() {
assert!(is_session_floor("session_floor:alice"));
assert!(!is_session_floor(LEGACY_PASSKEYS_KEY));
}
}