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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/passkeys_at_gunnar.rs

//! **Passkeys, invites and sessions are the identity core's, over gunnar's
//! people** (IDENTITY-HOST, 2026-10-27; thin layer L before it, owner
//! 2026-10-04: "make ui thin, you're moving code to server").
//!
//! Tests only. The console RELAYS: the ceremonies are
//! `nornir.identity.v1.IdentityService`'s (LoginStart/Finish,
//! RedeemStart/Finish, StepUpStart/Finish), served by gunnar over its own
//! principals and passkeys; the session is the core's opaque token, which the
//! cookie carries and every call the console makes for the person carries
//! (`nornir-identity-session`). Invites are the core's, minted AS the person
//! who invites; the passkey list and its revocation are gunnar's `Access`.
//!
//! # The fake is gunnar with the REAL core
//!
//! The fake gunnar here serves edda's real `IdentityGrpc` over a host whose
//! people and passkeys ARE the fake's principals and web credentials — what
//! `GunnarHost` is over gunnar's control plane — so every ceremony is checked
//! by the code gunnar runs. The fake records every call as `<Method> by
//! <who>`, `who` being the person whose core session the call carried
//! (`console` for the console's own key); the identity calls as
//! `Identity.<Method>`.

use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};

use base64::Engine as _;
use nornir_identity::grpc::pb as ipb;
use nornir_identity::{Credential, Event, IdentityHost, Invite, Mailer, NoMail, Revocations, Roles, Session, Site, StoreError, User, UserState, UserStore};
use p256::ecdsa::signature::Signer as _;
use sha2::{Digest as _, Sha256};
use tonic::{Request, Response, Status};
use tower::ServiceExt as _;

use crate::api::mirror_gate::Imports;
use crate::pb;

const CONSOLE_SEED: [u8; 32] = [71u8; 32];
const RP_ID: &str = "console.example";
const ORIGIN: &str = "https://console.example";
const B64: base64::engine::general_purpose::GeneralPurpose =
    base64::engine::general_purpose::URL_SAFE_NO_PAD;

fn console_signer() -> crate::signer::Signer {
    crate::signer::Signer::new(ssh_key::PrivateKey::from(
        ssh_key::private::Ed25519Keypair::from_seed(&CONSOLE_SEED),
    ))
    .expect("a fixed seed makes a signer")
}

fn fingerprint_of<T>(request: &Request<T>) -> String {
    request
        .metadata()
        .get(crate::signer::CREDENTIAL_METADATA_KEY)
        .and_then(|value| value.to_str().ok())
        .and_then(|line| line.split_whitespace().nth(1))
        .unwrap_or("(unsigned)")
        .to_owned()
}

fn openssh_fingerprint(line: &str) -> Result<String, Status> {
    ssh_key::PublicKey::from_openssh(line.trim())
        .map(|key| key.fingerprint(ssh_key::HashAlg::Sha256).to_string())
        .map_err(|e| Status::invalid_argument(format!("not an OpenSSH key: {e}")))
}

/// What real gunnar checks of a relayed registration since 2026-10-10 (stinky
/// gunnar row 13), as far as a fake needs: the browser's `attestationObject`
/// came along, and it carries the relayed key's x and y.
pub(crate) fn attestation_carries(attestation_object: &[u8], public_key: &[u8]) -> Result<(), Status> {
    let carries = |part: &[u8]| attestation_object.windows(part.len()).any(|w| w == part);
    if public_key.len() != 65 || !carries(&public_key[1..33]) || !carries(&public_key[33..]) {
        return Err(Status::permission_denied(
            "refused: no attestation object, or one that names another key than `public_key`",
        ));
    }
    Ok(())
}

fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
    m.lock().unwrap_or_else(|p| p.into_inner())
}

/// A passkey the core registered at the fake, as the fake heard it.
#[derive(Debug, Clone)]
pub(crate) struct Registered {
    pub(crate) principal_id: String,
    pub(crate) credential_id: Vec<u8>,
    pub(crate) public_key: Vec<u8>,
    pub(crate) attestation_object: Vec<u8>,
    pub(crate) rp_id: String,
    pub(crate) origin: String,
}

// ───────────────────────────────────────────────────────────────────────────
// The fake: gunnar's identity table, and the core over it
// ───────────────────────────────────────────────────────────────────────────
#[derive(Clone, Default)]
pub(crate) struct Fake {
    /// `<Method> by <who>`.
    heard: Arc<Mutex<Vec<String>>>,
    /// key fingerprint → principal.
    keys: Arc<Mutex<BTreeMap<String, String>>>,
    principals: Arc<Mutex<BTreeMap<String, pb::Principal>>>,
    /// principal → its server-wide grant level.
    server: Arc<Mutex<BTreeMap<String, i32>>>,
    /// The passkeys gunnar stores, with their SEC1 public keys.
    #[allow(clippy::type_complexity)]
    credentials: Arc<Mutex<Vec<(pb::WebCredential, Vec<u8>)>>>,
    /// The core's own records at this gunnar: sessions, invites, floors.
    sessions: Arc<Mutex<Vec<Session>>>,
    invites: Arc<Mutex<Vec<Invite>>>,
    revocations: Arc<Mutex<Revocations>>,
    /// The identity core this gunnar serves (over the tables above).
    core: Arc<OnceLock<Arc<nornir_identity::Identity<FakeHost>>>>,
    /// Challenges `Access.MachineChallenge` issued and nothing consumed yet.
    challenges: Arc<Mutex<Vec<Vec<u8>>>>,
    issued: Arc<AtomicU32>,
    /// Every `LoginStart` and `RedeemStart` the console asked (the client it named).
    starts: Arc<Mutex<Vec<String>>>,
    /// Every `LoginFinish` as the console relayed it.
    logins: Arc<Mutex<Vec<ipb::LoginFinishRequest>>>,
    /// Every passkey the core registered.
    pub(crate) registrations: Arc<Mutex<Vec<Registered>>>,
    /// The people an invite was made for (`EnrollEmployee{invite}` and
    /// `Access.EnrolmentLink`): the principal ids, in order.
    invite_requests: Arc<Mutex<Vec<String>>>,
    revoked_keys: Arc<Mutex<Vec<String>>>,
    /// Keys added (`Access.AddKey`): (principal, OpenSSH line, expiry).
    #[allow(clippy::type_complexity)]
    added_keys: Arc<Mutex<Vec<(String, String, Option<i64>)>>>,
    /// principal → (its encryption key's public half, its unlock methods).
    #[allow(clippy::type_complexity)]
    encryption: Arc<Mutex<BTreeMap<String, (String, Vec<pb::UnlockMethod>)>>>,
    revoked_credentials: Arc<Mutex<Vec<Vec<u8>>>>,
    /// (account admin, employee): who enrolled whom.
    employees: Arc<Mutex<Vec<(String, String)>>>,
    /// Answer every LoginFinish as a refused ceremony.
    refuse_logins: Arc<AtomicBool>,
    /// Answer UNAVAILABLE: gunnar not reachable yet (a front just rebooted,
    /// its WireGuard tunnel to main not back).
    unreachable: Arc<AtomicBool>,
    /// Answer LoginFinish with the core's STORE refusal, as gunnar does while
    /// its data set is grown: the transaction log is released for the remount.
    growing: Arc<AtomicBool>,
    /// Answer LoginFinish with exactly this status (code, message) — what
    /// reaches the console when the call itself fails (an http2 error).
    login_status: Arc<Mutex<Option<(tonic::Code, String)>>>,
    /// Machine enrolment requests gunnar holds (2026-10-05), with the key.
    #[allow(clippy::type_complexity)]
    enrolments: Arc<Mutex<Vec<(pb::MachineEnrolment, Vec<u8>)>>>,
    /// Every request as the console relayed it.
    enrol_requests: Arc<Mutex<Vec<pb::RequestMachineEnrolmentRequest>>>,
    /// Every approval as the console relayed it, by whom, and whether their
    /// session had stepped up.
    approvals: Arc<Mutex<Vec<(String, bool, pb::ApproveMachineEnrolmentRequest)>>>,
    /// How long RedeemFinish takes between hearing a passkey and storing it.
    register_delay: Arc<Mutex<Option<std::time::Duration>>>,
    /// The origin a real browser on loopback answers from (`first_touch`);
    /// the core takes the relay's word for the relying party.
    pub(crate) origin: Arc<Mutex<Option<String>>>,
    /// How long the next `LoginStart`s / `RedeemStart`s take, one entry each
    /// (a slow gunnar right after paying).
    pub(crate) challenge_delays: Arc<Mutex<std::collections::VecDeque<std::time::Duration>>>,
    /// RA5-2: the core's own step-up window has closed for every session (its clock, not the
    /// console's): `CreateInvite` is the core's `needs_step_up` until a person steps up again.
    stale_at_core: Arc<AtomicBool>,
    /// GHUNT-19: every `Forge.Append` gunnar took — by whom, whether their
    /// session had stepped up, and the event exactly as the console sent it.
    forge_events: Arc<Mutex<Vec<(String, bool, pb::SignedForgeEvent)>>>,
}

impl Fake {
    pub(crate) fn new() -> Fake {
        let fake = Fake::default();
        let host = FakeHost { fake: fake.clone() };
        let core = Arc::new(nornir_identity::Identity::new(host, nornir_identity::Config::default()));
        fake.core.set(core).unwrap_or_else(|_| unreachable!("one core"));
        fake.person("anna", None);
        fake.person("boss", Some(pb::GrantLevel::Admin));
        fake.person("acme", None);
        fake
    }

    fn core(&self) -> &Arc<nornir_identity::Identity<FakeHost>> {
        self.core.get().expect("the fake's core")
    }

    pub(crate) fn person(&self, id: &str, server: Option<pb::GrantLevel>) {
        lock(&self.principals).insert(
            id.to_owned(),
            pb::Principal {
                id: id.to_owned(),
                name: id.to_owned(),
                kind: pb::PrincipalKind::User as i32,
                created_at_unix_ms: 1_000,
            },
        );
        if let Some(level) = server {
            lock(&self.server).insert(id.to_owned(), level as i32);
        }
    }

    /// A passkey gunnar already stores on `principal`.
    fn holds_passkey(&self, principal: &str, yubikey: &Yubikey) {
        lock(&self.credentials).push((
            pb::WebCredential {
                credential_id: yubikey.id.clone(),
                principal_id: principal.to_owned(),
                label: format!("{principal}'s key"),
                created_at_unix_ms: 2_000,
                last_used_unix_ms: None,
                sign_count: 0,
            },
            yubikey.sec1(),
        ));
    }

    fn said(&self, call: String) {
        lock(&self.heard).push(call);
    }

    fn heard(&self) -> Vec<String> {
        lock(&self.heard).clone()
    }

    /// Who a call is: the person whose core session it carried, the console
    /// by its key, or a stranger.
    fn who<T>(&self, request: &Request<T>) -> String {
        if let Some(token) = request.metadata().get(crate::grpc::SESSION_METADATA_KEY).and_then(|v| v.to_str().ok()) {
            return self.core().session(token).map(|v| v.user_id).unwrap_or_else(|_| "stranger".to_owned());
        }
        let fingerprint = fingerprint_of(request);
        if fingerprint == console_signer().fingerprint() {
            return "console".to_owned();
        }
        lock(&self.keys).get(&fingerprint).cloned().unwrap_or_else(|| "stranger".to_owned())
    }

    /// Whether the call's core session stepped up (the core's word).
    fn stepped_up<T>(&self, request: &Request<T>) -> bool {
        request
            .metadata()
            .get(crate::grpc::SESSION_METADATA_KEY)
            .and_then(|v| v.to_str().ok())
            .and_then(|token| self.core().session(token).ok())
            .is_some_and(|v| v.stepped_up)
    }

    fn admin(&self, who: &str) -> bool {
        who == "console" || lock(&self.server).get(who).copied() == Some(pb::GrantLevel::Admin as i32)
    }

    /// Note the call; refuse a stranger.
    fn hear<T>(&self, request: &Request<T>, method: &str) -> Result<String, Status> {
        let who = self.who(request);
        self.said(format!("{method} by {who}"));
        if who == "stranger" {
            return Err(Status::unauthenticated("no such key"));
        }
        Ok(who)
    }

    /// Note the call; refuse anybody but a server admin (the relay's RPCs).
    fn hear_admin<T>(&self, request: &Request<T>, method: &str) -> Result<String, Status> {
        let who = self.hear(request, method)?;
        if !self.admin(&who) {
            return Err(Status::permission_denied(format!("{method} is a server admin's")));
        }
        Ok(who)
    }

    /// The challenge a clientDataJSON carries, consumed — or a refusal.
    fn consume(&self, client_data: &[u8], kind: &str) -> Result<(), Status> {
        let data: serde_json::Value = serde_json::from_slice(client_data)
            .map_err(|_| Status::invalid_argument("clientDataJSON is not JSON"))?;
        let origin = lock(&self.origin).clone().unwrap_or_else(|| ORIGIN.to_owned());
        if data["type"] != kind || data["origin"] != origin.as_str() {
            return Err(Status::unauthenticated("wrong ceremony"));
        }
        let challenge = B64
            .decode(data["challenge"].as_str().unwrap_or_default())
            .map_err(|_| Status::invalid_argument("challenge"))?;
        let mut issued = lock(&self.challenges);
        let at = issued
            .iter()
            .position(|c| *c == challenge)
            .ok_or_else(|| Status::unauthenticated("the challenge was not issued here"))?;
        issued.remove(at);
        Ok(())
    }

    fn store_credential(&self, principal: &str, id: &[u8], public: &[u8], label: &str) -> pb::WebCredential {
        let credential = pb::WebCredential {
            credential_id: id.to_vec(),
            principal_id: principal.to_owned(),
            label: label.to_owned(),
            created_at_unix_ms: 3_000,
            last_used_unix_ms: None,
            sign_count: 0,
        };
        lock(&self.credentials).push((credential.clone(), public.to_vec()));
        credential
    }

    /// The identity calls, faults injected the way a real gunnar fails them.
    fn fault(&self) -> Option<Status> {
        self.unreachable.load(Ordering::SeqCst).then(|| Status::unavailable("tcp connect error"))
    }

    async fn delay(&self) {
        let delay = lock(&self.challenge_delays).pop_front();
        if let Some(d) = delay {
            tokio::time::sleep(d).await;
        }
    }
}

// ── The core's host over the fake's tables (what `GunnarHost` is) ──────────

/// gunnar's group for a server-wide Admin grant, as `GunnarStore` reads it.
const SERVER_ADMIN: &str = "server-admin";
const SERVER_WRITE: &str = "server-write";

pub(crate) struct FakeHost {
    fake: Fake,
}

impl FakeHost {
    fn groups(&self, id: &str) -> Vec<String> {
        match lock(&self.fake.server).get(id).copied() {
            Some(l) if l == pb::GrantLevel::Admin as i32 => vec![SERVER_ADMIN.to_owned()],
            Some(l) if l == pb::GrantLevel::Write as i32 => vec![SERVER_WRITE.to_owned()],
            _ => Vec::new(),
        }
    }
}

impl UserStore for FakeHost {
    fn user(&self, id: &str) -> Result<Option<User>, StoreError> {
        let principal = lock(&self.fake.principals).get(id).cloned();
        Ok(principal.map(|p| User {
            groups: self.groups(&p.id),
            display: p.name,
            email: None,
            state: UserState::Active,
            created: 0,
            id: p.id,
        }))
    }
    fn user_by_email(&self, _: &str) -> Result<Option<User>, StoreError> {
        Ok(None)
    }
    fn users(&self) -> Result<Vec<User>, StoreError> {
        let ids: Vec<String> = lock(&self.fake.principals).keys().cloned().collect();
        Ok(ids.iter().filter_map(|id| self.user(id).ok().flatten()).collect())
    }
    fn put_user(&self, user: &User) -> Result<(), StoreError> {
        let exists = lock(&self.fake.principals).contains_key(&user.id);
        if !exists {
            lock(&self.fake.principals).insert(
                user.id.clone(),
                pb::Principal { id: user.id.clone(), name: user.display.clone(), kind: pb::PrincipalKind::User as i32, created_at_unix_ms: 1_000 },
            );
        }
        if user.groups.iter().any(|g| g == SERVER_ADMIN) {
            lock(&self.fake.server).insert(user.id.clone(), pb::GrantLevel::Admin as i32);
        } else if user.groups.iter().any(|g| g == SERVER_WRITE) {
            lock(&self.fake.server).entry(user.id.clone()).or_insert(pb::GrantLevel::Write as i32);
        }
        Ok(())
    }
    fn credential(&self, id: &[u8]) -> Result<Option<Credential>, StoreError> {
        Ok(lock(&self.fake.credentials).iter().find(|(c, _)| c.credential_id == id).map(|(c, key)| Credential {
            id: c.credential_id.clone(),
            user: c.principal_id.clone(),
            public_key: key.clone(),
            sign_count: c.sign_count,
            created: 0,
        }))
    }
    fn credentials_of(&self, user: &str) -> Result<Vec<Credential>, StoreError> {
        Ok(lock(&self.fake.credentials)
            .iter()
            .filter(|(c, _)| c.principal_id == user)
            .map(|(c, key)| Credential { id: c.credential_id.clone(), user: c.principal_id.clone(), public_key: key.clone(), sign_count: c.sign_count, created: 0 })
            .collect())
    }
    fn put_credential(&self, credential: &Credential) -> Result<(), StoreError> {
        let mut held = lock(&self.fake.credentials);
        match held.iter_mut().find(|(c, _)| c.credential_id == credential.id) {
            Some((c, _)) => c.sign_count = credential.sign_count,
            None => {
                drop(held);
                self.fake.store_credential(&credential.user, &credential.id, &credential.public_key, "passkey");
            }
        }
        Ok(())
    }
    fn session(&self, id: &str) -> Result<Option<Session>, StoreError> {
        Ok(lock(&self.fake.sessions).iter().find(|s| s.id == id).cloned())
    }
    fn put_session(&self, session: &Session) -> Result<(), StoreError> {
        let mut all = lock(&self.fake.sessions);
        all.retain(|s| s.id != session.id);
        all.push(session.clone());
        Ok(())
    }
    fn delete_session(&self, id: &str) -> Result<(), StoreError> {
        lock(&self.fake.sessions).retain(|s| s.id != id);
        Ok(())
    }
    fn invites(&self) -> Result<Vec<Invite>, StoreError> {
        Ok(lock(&self.fake.invites).clone())
    }
    fn update_invites(&self, change: &mut dyn FnMut(&mut Vec<Invite>)) -> Result<(), StoreError> {
        change(&mut lock(&self.fake.invites));
        Ok(())
    }
    fn revocations(&self) -> Result<Revocations, StoreError> {
        Ok(lock(&self.fake.revocations).clone())
    }
    fn update_revocations(&self, change: &mut dyn FnMut(&mut Revocations)) -> Result<(), StoreError> {
        change(&mut lock(&self.fake.revocations));
        Ok(())
    }
}

impl IdentityHost for FakeHost {
    fn site(&self) -> Site {
        Site::Private
    }
    fn store(&self) -> &dyn UserStore {
        self
    }
    fn roles(&self, user: &User) -> Roles {
        Roles { admin: user.groups.iter().any(|g| g == SERVER_ADMIN), names: user.groups.clone() }
    }
    fn audit(&self, _: Event) {}
    fn mailer(&self) -> &dyn Mailer {
        &NoMail
    }
    /// An account admin enrols the account's own people (gunnar's rule).
    fn may_enrol(&self, by: &User, person: &User) -> bool {
        lock(&self.fake.employees).iter().any(|(admin, employee)| *admin == by.id && *employee == person.id)
    }
}

// ── The IdentityService gunnar serves: the real adapter, heard and faulted ──
// (`fixtures/`: the adapter's own method names, which no product code may
// define, are a test fixture's to implement.)
mod fixtures;
use fixtures::Heard;

#[tonic::async_trait]
impl pb::access_server::Access for Fake {
    async fn who_am_i(
        &self,
        request: Request<pb::WhoAmIRequest>,
    ) -> Result<Response<pb::WhoAmIResponse>, Status> {
        if self.unreachable.load(Ordering::SeqCst) {
            return Err(Status::unavailable("tcp connect error"));
        }
        let who = self.hear(&request, "Access.WhoAmI")?;
        let principal = lock(&self.principals).get(&who).cloned();
        let level = lock(&self.server).get(&who).copied().unwrap_or(0);
        Ok(Response::new(pb::WhoAmIResponse {
            principal,
            server_level: level,
            ..Default::default()
        }))
    }

    async fn list_principals(
        &self,
        request: Request<pb::ListPrincipalsRequest>,
    ) -> Result<Response<pb::ListPrincipalsResponse>, Status> {
        self.hear(&request, "Access.ListPrincipals")?;
        let name = request.into_inner().name;
        Ok(Response::new(pb::ListPrincipalsResponse {
            principals: lock(&self.principals).values().filter(|p| name.is_empty() || p.name == name).cloned().collect(),
            ..Default::default()
        }))
    }

    async fn add_principal(
        &self,
        request: Request<pb::AddPrincipalRequest>,
    ) -> Result<Response<pb::AddPrincipalResponse>, Status> {
        self.hear_admin(&request, "Access.AddPrincipal")?;
        let name = request.into_inner().name;
        if lock(&self.principals).contains_key(&name) {
            return Err(Status::already_exists(format!("{name} exists")));
        }
        self.person(&name, None);
        let principal = lock(&self.principals).get(&name).cloned();
        Ok(Response::new(pb::AddPrincipalResponse { principal }))
    }

    async fn remove_principal(
        &self,
        request: Request<pb::RemovePrincipalRequest>,
    ) -> Result<Response<pb::RemovePrincipalResponse>, Status> {
        self.hear_admin(&request, "Access.RemovePrincipal")?;
        lock(&self.principals).remove(&request.into_inner().principal_id);
        Ok(Response::new(pb::RemovePrincipalResponse::default()))
    }

    async fn grant(
        &self,
        request: Request<pb::GrantRequest>,
    ) -> Result<Response<pb::GrantResponse>, Status> {
        self.hear_admin(&request, "Access.Grant")?;
        let grant = request.into_inner();
        if grant.namespace.is_empty() && grant.repo.is_empty() {
            lock(&self.server).insert(grant.principal_id, grant.level);
        }
        Ok(Response::new(pb::GrantResponse::default()))
    }

    async fn list_grants(
        &self,
        request: Request<pb::ListGrantsRequest>,
    ) -> Result<Response<pb::ListGrantsResponse>, Status> {
        self.hear_admin(&request, "Access.ListGrants")?;
        Ok(Response::new(pb::ListGrantsResponse {
            grants: lock(&self.server)
                .iter()
                .map(|(principal, level)| pb::Grant { principal_id: principal.clone(), level: *level, ..Default::default() })
                .collect(),
        }))
    }

    async fn add_key(
        &self,
        request: Request<pb::AddKeyRequest>,
    ) -> Result<Response<pb::AddKeyResponse>, Status> {
        let who = self.hear(&request, "Access.AddKey")?;
        let add = request.into_inner();
        if add.principal_id != who && !self.admin(&who) {
            return Err(Status::permission_denied("not your principal"));
        }
        let fingerprint = openssh_fingerprint(&add.openssh_public_key)?;
        lock(&self.keys).insert(fingerprint.clone(), add.principal_id.clone());
        lock(&self.added_keys).push((add.principal_id.clone(), add.openssh_public_key.clone(), add.expires_at_unix_ms));
        Ok(Response::new(pb::AddKeyResponse {
            key: Some(pb::PublicKey {
                fingerprint,
                principal_id: add.principal_id,
                comment: add.comment,
                expires_at_unix_ms: add.expires_at_unix_ms,
                ..Default::default()
            }),
        }))
    }

    async fn list_keys(
        &self,
        request: Request<pb::ListKeysRequest>,
    ) -> Result<Response<pb::ListKeysResponse>, Status> {
        let who = self.hear(&request, "Access.ListKeys")?;
        let principal = request.into_inner().principal_id;
        if principal != who && !self.admin(&who) {
            return Err(Status::permission_denied("not your keys"));
        }
        Ok(Response::new(pb::ListKeysResponse {
            keys: lock(&self.keys)
                .iter()
                .filter(|(_, p)| **p == principal)
                .map(|(fingerprint, p)| pb::PublicKey { fingerprint: fingerprint.clone(), principal_id: p.clone(), ..Default::default() })
                .collect(),
        }))
    }

    async fn get_encryption_key(
        &self,
        request: Request<pb::GetEncryptionKeyRequest>,
    ) -> Result<Response<pb::GetEncryptionKeyResponse>, Status> {
        let who = self.hear(&request, "Access.GetEncryptionKey")?;
        let held = lock(&self.encryption).get(&who).cloned();
        Ok(Response::new(match held {
            Some((public_key, methods)) => pb::GetEncryptionKeyResponse { exists: true, public_key, methods },
            None => pb::GetEncryptionKeyResponse::default(),
        }))
    }

    async fn create_encryption_key(
        &self,
        request: Request<pb::CreateEncryptionKeyRequest>,
    ) -> Result<Response<pb::CreateEncryptionKeyResponse>, Status> {
        let who = self.hear(&request, "Access.CreateEncryptionKey")?;
        let r = request.into_inner();
        let mut all = lock(&self.encryption);
        if all.contains_key(&who) {
            return Err(Status::already_exists("you have an encryption key already; add a method to it instead"));
        }
        let mut first = r.first.ok_or_else(|| Status::invalid_argument("no first method"))?;
        first.id = format!("{:016x}", first.wrap.len() as u64 * 7919 + 1);
        all.insert(who, (r.public_key.clone(), vec![first.clone()]));
        Ok(Response::new(pb::CreateEncryptionKeyResponse { public_key: r.public_key, methods: vec![first] }))
    }

    async fn add_unlock_method(
        &self,
        request: Request<pb::AddUnlockMethodRequest>,
    ) -> Result<Response<pb::AddUnlockMethodResponse>, Status> {
        let who = self.hear(&request, "Access.AddUnlockMethod")?;
        let mut m = request.into_inner().method.ok_or_else(|| Status::invalid_argument("no method"))?;
        let mut all = lock(&self.encryption);
        let (_, methods) = all.get_mut(&who).ok_or_else(|| Status::failed_precondition("you have no encryption key yet"))?;
        m.id = format!("{:016x}", m.wrap.len() as u64 * 7919 + methods.len() as u64 + 2);
        methods.push(m);
        Ok(Response::new(pb::AddUnlockMethodResponse { methods: methods.clone() }))
    }

    async fn remove_unlock_method(
        &self,
        request: Request<pb::RemoveUnlockMethodRequest>,
    ) -> Result<Response<pb::RemoveUnlockMethodResponse>, Status> {
        let who = self.hear(&request, "Access.RemoveUnlockMethod")?;
        let id = request.into_inner().id;
        let mut all = lock(&self.encryption);
        let (_, methods) = all.get_mut(&who).ok_or_else(|| Status::not_found("no such method"))?;
        if !methods.iter().any(|m| m.id == id) {
            return Err(Status::not_found("no such method"));
        }
        if methods.len() == 1 {
            return Err(Status::failed_precondition("that is your last unlock method: add another first, or nothing opens your encryption key again"));
        }
        methods.retain(|m| m.id != id);
        Ok(Response::new(pb::RemoveUnlockMethodResponse { methods: methods.clone() }))
    }

    async fn revoke_key(
        &self,
        request: Request<pb::RevokeKeyRequest>,
    ) -> Result<Response<pb::RevokeKeyResponse>, Status> {
        let who = self.hear(&request, "Access.RevokeKey")?;
        let fingerprint = request.into_inner().fingerprint;
        let owner = lock(&self.keys).get(&fingerprint).cloned();
        if owner.as_deref() != Some(who.as_str()) && !self.admin(&who) {
            return Err(Status::permission_denied("not your key"));
        }
        lock(&self.keys).remove(&fingerprint);
        lock(&self.revoked_keys).push(fingerprint);
        Ok(Response::new(pb::RevokeKeyResponse::default()))
    }

    /// The console's first-login link for an existing person (a server admin's
    /// door): minted by the core, first only when asked.
    async fn enrolment_link(
        &self,
        request: Request<pb::EnrolmentLinkRequest>,
    ) -> Result<Response<pb::EnrolmentLinkResponse>, Status> {
        self.hear_admin(&request, "Access.EnrolmentLink")?;
        let ask = request.into_inner();
        if ask.first_only && lock(&self.credentials).iter().any(|(c, _)| c.principal_id == ask.principal_id) {
            return Err(Status::failed_precondition(format!("{} already holds a passkey", ask.principal_id)));
        }
        let link = self.core().enrolment(&ask.principal_id, "console", Some(900)).map_err(nornir_identity::grpc::status)?;
        lock(&self.invite_requests).push(ask.principal_id);
        Ok(Response::new(pb::EnrolmentLinkResponse { secret: link.secret, invite_id: link.invite_id, expires_at_unix_ms: link.expires_at * 1000 }))
    }

    async fn machine_challenge(
        &self,
        request: Request<pb::MachineChallengeRequest>,
    ) -> Result<Response<pb::MachineChallengeResponse>, Status> {
        if self.unreachable.load(Ordering::SeqCst) {
            return Err(Status::unavailable("tcp connect error"));
        }
        self.hear_admin(&request, "Access.MachineChallenge")?;
        let n = self.issued.fetch_add(1, Ordering::SeqCst) as u8;
        let challenge = vec![0xC0 ^ n; 32];
        lock(&self.challenges).push(challenge.clone());
        Ok(Response::new(pb::MachineChallengeResponse { challenge, expires_at_unix_ms: crate::auth::now_secs() * 1000 + 300_000 }))
    }

    async fn list_credentials(
        &self,
        request: Request<pb::ListCredentialsRequest>,
    ) -> Result<Response<pb::ListCredentialsResponse>, Status> {
        if self.unreachable.load(Ordering::SeqCst) {
            return Err(Status::unavailable("tcp connect error"));
        }
        let who = self.hear(&request, "Access.ListCredentials")?;
        let principal = request.into_inner().principal_id;
        if principal != who && !self.admin(&who) {
            return Err(Status::permission_denied("not your passkeys"));
        }
        Ok(Response::new(pb::ListCredentialsResponse {
            credentials: lock(&self.credentials)
                .iter()
                .filter(|(c, _)| principal.is_empty() || c.principal_id == principal)
                .map(|(c, _)| c.clone())
                .collect(),
        }))
    }

    /// A revoked passkey ends its person's core sessions (as gunnar does).
    async fn revoke_credential(
        &self,
        request: Request<pb::RevokeCredentialRequest>,
    ) -> Result<Response<pb::RevokeCredentialResponse>, Status> {
        let who = self.hear(&request, "Access.RevokeCredential")?;
        let id = request.into_inner().credential_id;
        let mut held = lock(&self.credentials);
        let at = held
            .iter()
            .position(|(c, _)| c.credential_id == id)
            .ok_or_else(|| Status::not_found("no such web credential"))?;
        if held[at].0.principal_id != who && !self.admin(&who) {
            return Err(Status::permission_denied("not your passkey"));
        }
        let (gone, _) = held.remove(at);
        drop(held);
        self.core().end_sessions(&gone.principal_id, &who).map_err(nornir_identity::grpc::status)?;
        lock(&self.revoked_credentials).push(id);
        Ok(Response::new(pb::RevokeCredentialResponse::default()))
    }

    /// Relabelled as gunnar does: one's own passkey, or anybody's for a server admin.
    async fn rename_credential(
        &self,
        request: Request<pb::RenameCredentialRequest>,
    ) -> Result<Response<pb::RenameCredentialResponse>, Status> {
        let who = self.hear(&request, "Access.RenameCredential")?;
        let ask = request.into_inner();
        let label = ask.label.trim();
        if label.is_empty() || label.chars().count() > 128 {
            return Err(Status::invalid_argument("`label`: not empty, at most 128 characters"));
        }
        let mut held = lock(&self.credentials);
        let (c, _) = held
            .iter_mut()
            .find(|(c, _)| c.credential_id == ask.credential_id)
            .ok_or_else(|| Status::not_found("no such web credential"))?;
        if c.principal_id != who && !self.admin(&who) {
            return Err(Status::permission_denied("not your passkey"));
        }
        c.label = label.to_owned();
        Ok(Response::new(pb::RenameCredentialResponse { credential: Some(c.clone()) }))
    }

    // ── a machine asks, a person approves (2026-10-05) ──────────────────────

    async fn request_machine_enrolment(
        &self,
        request: Request<pb::RequestMachineEnrolmentRequest>,
    ) -> Result<Response<pb::RequestMachineEnrolmentResponse>, Status> {
        self.hear_admin(&request, "Access.RequestMachineEnrolment")?;
        let asked = request.into_inner();
        lock(&self.enrol_requests).push(asked.clone());
        self.consume(&asked.client_data_json, "webauthn.create")?;
        attestation_carries(&asked.attestation_object, &asked.public_key)?;
        let mut held = lock(&self.enrolments);
        let row = pb::MachineEnrolment {
            request_id: format!("req-{}", held.len()),
            machine: asked.machine,
            label: asked.label,
            server_level: asked.server_level,
            code: format!("ABC-12{}", held.len()),
            key_fingerprint: "SHA256:kitkey".to_owned(),
            requested_at_unix_ms: 1_000,
            expires_at_unix_ms: 9_000_000_000_000,
            state: pb::MachineEnrolmentState::Pending as i32,
            credential_id: asked.credential_id,
            ..Default::default()
        };
        held.push((row.clone(), asked.public_key));
        Ok(Response::new(pb::RequestMachineEnrolmentResponse { request: Some(row) }))
    }

    async fn machine_enrolment_status(
        &self,
        request: Request<pb::MachineEnrolmentStatusRequest>,
    ) -> Result<Response<pb::MachineEnrolmentStatusResponse>, Status> {
        self.hear_admin(&request, "Access.MachineEnrolmentStatus")?;
        let id = request.into_inner().request_id;
        let row = lock(&self.enrolments)
            .iter()
            .find(|(r, _)| r.request_id == id)
            .map(|(r, _)| r.clone())
            .ok_or_else(|| Status::not_found("no such request"))?;
        Ok(Response::new(pb::MachineEnrolmentStatusResponse { request: Some(row) }))
    }

    async fn list_machine_enrolments(
        &self,
        request: Request<pb::ListMachineEnrolmentsRequest>,
    ) -> Result<Response<pb::ListMachineEnrolmentsResponse>, Status> {
        self.hear_admin(&request, "Access.ListMachineEnrolments")?;
        Ok(Response::new(pb::ListMachineEnrolmentsResponse {
            requests: lock(&self.enrolments)
                .iter()
                .filter(|(r, _)| r.state == pb::MachineEnrolmentState::Pending as i32)
                .map(|(r, _)| r.clone())
                .collect(),
        }))
    }

    /// gunnar's rule: the approver's own core session, stepped up.
    async fn approve_machine_enrolment(
        &self,
        request: Request<pb::ApproveMachineEnrolmentRequest>,
    ) -> Result<Response<pb::ApproveMachineEnrolmentResponse>, Status> {
        let who = self.hear_admin(&request, "Access.ApproveMachineEnrolment")?;
        let stepped_up = self.stepped_up(&request);
        let approve = request.into_inner();
        lock(&self.approvals).push((who.clone(), stepped_up, approve.clone()));
        if !stepped_up {
            return Err(Status::permission_denied("[stepup] Access.ApproveMachineEnrolment needs a fresh touch of your own passkey"));
        }
        let mut held = lock(&self.enrolments);
        let (row, public) = held
            .iter_mut()
            .find(|(r, _)| r.request_id == approve.request_id && r.code == approve.code)
            .ok_or_else(|| Status::failed_precondition("no such pending request with that code"))?;
        row.state = pb::MachineEnrolmentState::Approved as i32;
        row.decided_by = who;
        let (row, public) = (row.clone(), public.clone());
        drop(held);
        lock(&self.principals).insert(
            row.machine.clone(),
            pb::Principal { id: row.machine.clone(), name: row.machine.clone(), kind: pb::PrincipalKind::Machine as i32, created_at_unix_ms: 1_000 },
        );
        lock(&self.server).insert(row.machine.clone(), row.server_level);
        let credential = self.store_credential(&row.machine, &row.credential_id, &public, &row.label);
        let principal = lock(&self.principals).get(&row.machine).cloned();
        Ok(Response::new(pb::ApproveMachineEnrolmentResponse { request: Some(row), principal, credential: Some(credential) }))
    }

    async fn refuse_machine_enrolment(
        &self,
        request: Request<pb::RefuseMachineEnrolmentRequest>,
    ) -> Result<Response<pb::RefuseMachineEnrolmentResponse>, Status> {
        let who = self.hear_admin(&request, "Access.RefuseMachineEnrolment")?;
        let id = request.into_inner().request_id;
        let mut held = lock(&self.enrolments);
        let (row, _) = held.iter_mut().find(|(r, _)| r.request_id == id).ok_or_else(|| Status::not_found("no such request"))?;
        row.state = pb::MachineEnrolmentState::Refused as i32;
        row.decided_by = who;
        Ok(Response::new(pb::RefuseMachineEnrolmentResponse { request: Some(row.clone()) }))
    }
}

/// **gunnar's `Forge.Append` rule for a person's session** (GHUNT-19): an
/// UNSIGNED event from the session's own person is attested when the session
/// stepped up and refused `[stepup]` when it did not; a session's event signed
/// with the console's key is somebody else's word in her name, refused as gunnar
/// refuses it.
#[tonic::async_trait]
impl pb::forge_server::Forge for Fake {
    async fn append(&self, request: Request<pb::AppendEventRequest>) -> Result<Response<pb::AppendEventResponse>, Status> {
        let who = self.hear(&request, "Forge.Append")?;
        let session = request.metadata().get(crate::grpc::SESSION_METADATA_KEY).is_some();
        let stepped_up = self.stepped_up(&request);
        let signed = request.into_inner().event.unwrap_or_default();
        let actor = signed.event.as_ref().map(|e| e.actor.clone()).unwrap_or_default();
        if actor != who {
            return Err(Status::permission_denied(format!("this event is attributed to {actor:?} and the caller is {who:?}")));
        }
        if session && !signed.key_fingerprint.is_empty() {
            return Err(Status::invalid_argument(format!(
                "this event is attributed to {actor:?} and is signed by {}, which is not {actor:?}'s key",
                signed.key_fingerprint
            )));
        }
        if session && !stepped_up {
            return Err(Status::permission_denied("[stepup] Forge.Append needs a fresh touch of your own passkey (user-verified, within the last 10 minutes)"));
        }
        let mut events = lock(&self.forge_events);
        events.push((who, stepped_up, signed));
        Ok(Response::new(pb::AppendEventResponse { index: events.len() as u64 - 1 }))
    }
    async fn get_review(&self, _: Request<pb::GetReviewRequest>) -> Result<Response<pb::GetReviewResponse>, Status> {
        Ok(Response::new(pb::GetReviewResponse::default()))
    }
    async fn list_reviews(&self, _: Request<pb::ListReviewsRequest>) -> Result<Response<pb::ListReviewsResponse>, Status> {
        Ok(Response::new(pb::ListReviewsResponse::default()))
    }
    async fn get_issue(&self, _: Request<pb::GetIssueRequest>) -> Result<Response<pb::GetIssueResponse>, Status> {
        Err(Status::not_found("no issue"))
    }
    async fn list_issues(&self, _: Request<pb::ListIssuesRequest>) -> Result<Response<pb::ListIssuesResponse>, Status> {
        Ok(Response::new(pb::ListIssuesResponse::default()))
    }
}

#[tonic::async_trait]
impl pb::accounts_server::Accounts for Fake {
    async fn enroll_employee(
        &self,
        request: Request<pb::EnrollEmployeeRequest>,
    ) -> Result<Response<pb::EnrollEmployeeResponse>, Status> {
        let who = self.hear(&request, "Accounts.EnrollEmployee")?;
        let enroll = request.into_inner();
        if enroll.account != who {
            return Err(Status::permission_denied("not your account"));
        }
        self.person(&enroll.id, None);
        lock(&self.employees).push((who, enroll.id.clone()));
        let principal = lock(&self.principals).get(&enroll.id).cloned();
        // The new named user's first-login link, made in the same call (Add
        // users, 2026-10-04): the core's.
        let (invite_id, invite_secret, expires) = if enroll.invite {
            let link = self.core().enrolment(&enroll.id, "acme", None).map_err(nornir_identity::grpc::status)?;
            lock(&self.invite_requests).push(enroll.id.clone());
            (link.invite_id, link.secret, link.expires_at * 1000)
        } else {
            (String::new(), String::new(), 0)
        };
        Ok(Response::new(pb::EnrollEmployeeResponse {
            principal,
            invite_id,
            invite_secret,
            invite_expires_at_unix_ms: expires,
            ..Default::default()
        }))
    }
}

// ───────────────────────────────────────────────────────────────────────────
// The console, and a person's authenticator
// ───────────────────────────────────────────────────────────────────────────

pub(crate) struct Rig {
    pub(crate) router: axum::Router,
    pub(crate) webauthn: Arc<crate::auth::Webauthn>,
    store: crate::state::StateStore,
    fake: Fake,
}

async fn console(fake: Fake) -> Rig {
    console_selling(fake, None).await
}

/// The console, and — for a `metered` build's paid signup — monetize's
/// `Signup` service on the same loopback port.
#[cfg_attr(not(feature = "metered"), allow(unused_variables))]
async fn console_selling(
    fake: Fake,
    #[cfg(feature = "metered")] monetize: Option<crate::auth::signup::tests::FakeMonetize>,
    #[cfg(not(feature = "metered"))] monetize: Option<()>,
) -> Rig {
    console_selling_at(fake, monetize, RP_ID, ORIGIN).await
}

/// [`console_selling`] for the relying party `rp_id` at `origin` (a real browser on loopback).
#[cfg_attr(not(feature = "metered"), allow(unused_variables))]
pub(crate) async fn console_selling_at(
    fake: Fake,
    #[cfg(feature = "metered")] monetize: Option<crate::auth::signup::tests::FakeMonetize>,
    #[cfg(not(feature = "metered"))] monetize: Option<()>,
    rp_id: &str,
    origin: &str,
) -> Rig {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("a loopback port");
    let addr = listener.local_addr().expect("its address");
    let serving = fake.clone();
    let identity = Heard { fake: fake.clone(), inner: nornir_identity::grpc::IdentityGrpc::new(Arc::clone(fake.core())) };
    #[cfg(feature = "metered")]
    let selling = monetize.is_some();
    tokio::spawn(async move {
        let router = tonic::transport::Server::builder()
            .add_service(pb::access_server::AccessServer::new(serving.clone()))
            .add_service(pb::accounts_server::AccountsServer::new(serving.clone()))
            .add_service(pb::forge_server::ForgeServer::new(serving))
            .add_service(ipb::identity_service_server::IdentityServiceServer::new(identity));
        #[cfg(feature = "metered")]
        let router = router.add_optional_service(
            monetize.map(crate::mpb::signup_server::SignupServer::new),
        );
        router
            .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
            .await
    });
    let dir = tempfile::tempdir().expect("a temp dir").keep();
    let store = crate::state::StateStore::open(dir.join("state.redb")).expect("a store");
    let webauthn = Arc::new(
        crate::auth::Webauthn::new(
            crate::auth::RelyingParty {
                rp_id: rp_id.to_owned(),
                origin: origin.to_owned(),
                rp_name: "gunnar".to_owned(),
            },
            store.clone(),
        )
        .expect("a subsystem"),
    );
    let endpoint = format!("http://{addr}");
    let state: crate::app::AppState = Arc::new(crate::app::App {
        webauthn: Some(webauthn.clone()),
        admin_token: Some("break-glass".to_owned()),
        #[cfg(feature = "metered")]
        monetize: selling.then(|| {
            crate::monetize::Monetize::connect_lazy(
                &format!("http://{addr}"),
                crate::auth::signup::tests::BEARER,
                "gunnar",
            )
            .expect("a lazy channel")
        }),
        events: crate::sse::EventHub::detached(),
        gunnar: crate::grpc::Gunnar::connect_lazy(&endpoint, Arc::new(console_signer()))
            .expect("a handle"),
        twin: None,
        guest: None,
        reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(
            endpoint.clone(),
            store.clone(),
        )),
        tenants: Arc::new(crate::tenant::TenantKeys::new(endpoint, store.clone())),
        forges: Default::default(),
        imports: Arc::new(Imports::default()),
        forge_apps: Arc::default(),
        ui_dir: None,
        caches: crate::api::Caches::default(),
        objects: Default::default(),
        readmes: Default::default(),
    });
    let router = crate::serve::router(state);
    Rig {
        router,
        webauthn,
        store,
        fake,
    }
}

/// A P-256 authenticator and the credential id it reports.
pub(crate) struct Yubikey {
    key: p256::ecdsa::SigningKey,
    id: Vec<u8>,
    counter: u32,
    /// Whether a `get` reports a verified user (PIN or biometric); a bare
    /// touch when false.
    uv: bool,
}

impl Yubikey {
    fn new(seed: u8, id: &str) -> Yubikey {
        Yubikey {
            key: p256::ecdsa::SigningKey::from_bytes(&[seed; 32].into()).expect("a scalar"),
            id: id.as_bytes().to_vec(),
            counter: 0,
            uv: true,
        }
    }

    /// The public key as gunnar stores it: SEC1 uncompressed, 65 bytes.
    fn sec1(&self) -> Vec<u8> {
        self.key.verifying_key().to_sec1_point(false).as_bytes().to_vec()
    }

    fn xy(&self) -> (Vec<u8>, Vec<u8>) {
        let point = self.key.verifying_key().to_sec1_point(false);
        (point.x().expect("x").to_vec(), point.y().expect("y").to_vec())
    }

    fn client_json(kind: &str, challenge: &str) -> Vec<u8> {
        serde_json::json!({ "type": kind, "challenge": challenge, "origin": ORIGIN })
            .to_string()
            .into_bytes()
    }

    /// `navigator.credentials.create`: `none` attestation with the COSE key.
    fn create(&mut self, challenge: &str) -> (Vec<u8>, String) {
        self.counter += 1;
        let mut auth_data = Sha256::digest(RP_ID.as_bytes()).to_vec();
        auth_data.push(0x01 | 0x04 | 0x40); // UP | UV | AT
        auth_data.extend_from_slice(&0u32.to_be_bytes());
        auth_data.extend_from_slice(&[0u8; 16]); // aaguid
        auth_data.extend_from_slice(&(self.id.len() as u16).to_be_bytes());
        auth_data.extend_from_slice(&self.id);
        let (x, y) = self.xy();
        let cose = ciborium::value::Value::Map(vec![
            (1.into(), 2.into()),
            (3.into(), (-7).into()),
            ((-1).into(), 1.into()),
            ((-2).into(), ciborium::value::Value::Bytes(x)),
            ((-3).into(), ciborium::value::Value::Bytes(y)),
        ]);
        ciborium::into_writer(&cose, &mut auth_data).expect("cbor");
        let object = ciborium::value::Value::Map(vec![
            ("fmt".into(), "none".into()),
            ("attStmt".into(), ciborium::value::Value::Map(vec![])),
            ("authData".into(), ciborium::value::Value::Bytes(auth_data)),
        ]);
        let mut attestation = Vec::new();
        ciborium::into_writer(&object, &mut attestation).expect("cbor");
        (Self::client_json("webauthn.create", challenge), B64.encode(attestation))
    }

    /// `navigator.credentials.get`: (clientDataJSON, authenticatorData, DER signature).
    fn get(&mut self, challenge: &str) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
        self.counter += 1;
        let client_data = Self::client_json("webauthn.get", challenge);
        let mut auth_data = Sha256::digest(RP_ID.as_bytes()).to_vec();
        auth_data.push(if self.uv { 0x01 | 0x04 } else { 0x01 });
        auth_data.extend_from_slice(&self.counter.to_be_bytes());
        let mut message = auth_data.clone();
        message.extend_from_slice(&Sha256::digest(&client_data));
        let signature: p256::ecdsa::Signature = self.key.sign(&message);
        (client_data, auth_data, signature.to_der().as_bytes().to_vec())
    }

    /// An assertion as the login page posts it, for `options`.
    fn answer(&mut self, options: &serde_json::Value) -> serde_json::Value {
        let challenge = options["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
        let (client_data, auth_data, signature) = self.get(&challenge);
        serde_json::json!({
            "ceremony_id": options["ceremony_id"],
            "id": B64.encode(&self.id),
            "response": {
                "clientDataJSON": B64.encode(&client_data),
                "authenticatorData": B64.encode(&auth_data),
                "signature": B64.encode(&signature),
            }
        })
    }
}

pub(crate) struct Answer {
    status: http::StatusCode,
    /// `Retry-After`, when the answer names one.
    retry_after: Option<String>,
    cookie: Option<String>,
    body: serde_json::Value,
    text: String,
}

impl Rig {
    async fn send(
        &self,
        method: &str,
        uri: &str,
        bearer: Option<&str>,
        cookie: Option<&str>,
        body: Option<serde_json::Value>,
    ) -> Answer {
        let mut request = http::Request::builder()
            .method(method)
            .uri(uri)
            .header(http::header::ORIGIN, ORIGIN)
            .header(http::header::ACCEPT, "application/json");
        if let Some(token) = bearer {
            request = request.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
        }
        if let Some(value) = cookie {
            request = request.header(http::header::COOKIE, format!("{}={value}", crate::auth::SESSION_COOKIE));
        }
        let request = match body {
            Some(json) => request
                .header(http::header::CONTENT_TYPE, "application/json")
                .body(axum::body::Body::from(json.to_string())),
            None => request.body(axum::body::Body::empty()),
        }
        .expect("a request");
        let response = self.router.clone().oneshot(request).await.expect("the console answers");
        let status = response.status();
        let retry_after = response.headers().get(http::header::RETRY_AFTER).and_then(|v| v.to_str().ok()).map(str::to_owned);
        let cookie = response
            .headers()
            .get_all(http::header::SET_COOKIE)
            .iter()
            .filter_map(|v| v.to_str().ok())
            .filter(|v| !v.contains("Max-Age=0"))
            .find_map(|v| {
                v.strip_prefix(&format!("{}=", crate::auth::SESSION_COOKIE))
                    .map(|rest| rest.split(';').next().unwrap_or_default().to_owned())
            });
        let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.expect("a body");
        let text = String::from_utf8_lossy(&bytes).into_owned();
        Answer { status, retry_after, cookie, body: serde_json::from_str(&text).unwrap_or(serde_json::Value::Null), text }
    }

    /// The login page's `login()`: start, the authenticator, finish.
    async fn try_login(&self, yubikey: &mut Yubikey) -> Answer {
        let options = self.send("GET", "/auth/passkey/login/start", None, None, None).await;
        if options.status != http::StatusCode::OK {
            return options;
        }
        let body = yubikey.answer(&options.body);
        self.send("POST", "/auth/passkey/login/finish", None, None, Some(body)).await
    }

    async fn login(&self, yubikey: &mut Yubikey) -> String {
        let answer = self.try_login(yubikey).await;
        assert_eq!(answer.status, http::StatusCode::OK, "the passkey did not log in: {}", answer.text);
        answer.cookie.expect("a login that worked hands out a session")
    }

    /// The login page's `register()` with an invite link's secret.
    async fn register_with_invite(&self, secret: &str, user: &str, yubikey: &mut Yubikey) -> Answer {
        let options = self
            .send("GET", &format!("/auth/passkey/register/start?invite={secret}&user={user}"), None, None, None)
            .await;
        assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
        let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
        let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
        let (client_data, attestation) = yubikey.create(&challenge);
        self.send(
            "POST",
            "/auth/passkey/register/finish",
            None,
            None,
            Some(serde_json::json!({
                "ceremony_id": ceremony,
                "invite": secret,
                "user": user,
                "label": "kim's key",
                "response": {
                    "clientDataJSON": B64.encode(&client_data),
                    "attestationObject": attestation,
                },
            })),
        )
        .await
    }

    /// Nothing about passkeys, sessions or invites lives in the console's own store.
    fn console_keeps_no_logins(&self) {
        let keys = self.store.auth_keys().expect("the store reads");
        for kept in &keys {
            assert!(
                !(kept == "passkeys" || kept == "session_key" || kept == "session_epoch" || kept.starts_with("session_floor:") || kept.starts_with("web-session:") || kept.starts_with("tenant-identity:") || kept == "invites" || kept == "signups"),
                "the console keeps {kept} of its own: {keys:?}"
            );
        }
    }
}

/// The invite secret a minted link carries.
fn secret_of(url: &str) -> String {
    url.split("invite=").nth(1).and_then(|r| r.split('&').next()).expect("a secret").to_owned()
}

// ───────────────────────────────────────────────────────────────────────────
// The rows
// ───────────────────────────────────────────────────────────────────────────

/// ★ **A login is the core's verdict, and the session acts as the person.**
/// The ceremony is the core's, the assertion goes to `LoginFinish` byte for
/// byte at the console's relying party, the cookie carries the core's session,
/// and what the person then reads is asked with that session — never a key
/// the console minted, never one it enrolled with its own Admin.
#[tokio::test]
async fn a_login_is_verified_by_the_core_and_the_session_acts_as_the_person() {
    let fake = Fake::new();
    let mut yubikey = Yubikey::new(0x41, "annas-yubikey");
    fake.holds_passkey("anna", &yubikey);
    let rig = console(fake).await;

    let options = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
    assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
    assert!(rig.fake.heard().contains(&"Identity.LoginStart".to_owned()), "{:?}", rig.fake.heard());
    let body = yubikey.answer(&options.body);
    let answer = rig.send("POST", "/auth/passkey/login/finish", None, None, Some(body.clone())).await;
    assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
    assert_eq!(answer.body["user"], "anna", "{}", answer.text);
    let cookie = answer.cookie.expect("a session");
    assert!(cookie.starts_with(nornir_identity::TOKEN_PREFIX), "the cookie carries the core's token");

    let login = lock(&rig.fake.logins)[0].clone();
    assert_eq!(login.credential_id, yubikey.id);
    assert_eq!(B64.encode(&login.client_data_json), body["response"]["clientDataJSON"].as_str().unwrap(), "the browser's bytes, unchanged");
    assert_eq!((login.rp_id.as_str(), login.origin.as_str()), (RP_ID, ORIGIN));

    let keys = rig.send("GET", "/api/account/keys", None, Some(&cookie), None).await;
    assert_eq!(keys.status, http::StatusCode::OK, "{}", keys.text);
    assert!(
        rig.fake.heard().contains(&"Access.ListKeys by anna".to_owned()),
        "the person's own keys are asked AS anna, with her session: {:?}",
        rig.fake.heard()
    );
    assert!(!rig.fake.heard().iter().any(|h| h.starts_with("Access.AddKey")), "the console enrols no key of its own on anybody: {:?}", rig.fake.heard());
    assert!(rig.store.get("tenant-identity:anna").expect("reads").is_none(), "no custody key");
    rig.console_keeps_no_logins();
}

/// The credential ids a `login/start` answer offers, as the browser reads them.
fn offered(answer: &Answer) -> Vec<String> {
    answer.body["publicKey"]["allowCredentials"]
        .as_array()
        .unwrap_or_else(|| panic!("no allowCredentials: {}", answer.text))
        .iter()
        .map(|c| c["id"].as_str().unwrap_or_default().to_owned())
        .collect()
}

/// ★ **An anonymous `login/start` offers NOBODY's passkey** (the load-test
/// helper, 2026-10-06): the discoverable-credential ceremony, an empty
/// `allowCredentials`, and gunnar is not even asked for a list.
#[tokio::test]
async fn an_anonymous_login_start_offers_no_ones_passkey_and_lists_nothing() {
    let fake = Fake::new();
    let mut anna = Yubikey::new(0x41, "annas-yubikey");
    for (i, who) in ["anna", "bob", "cleo"].into_iter().enumerate() {
        fake.person(who, None);
        fake.holds_passkey(who, &Yubikey::new(0x50 + i as u8, &format!("{who}s-key")));
    }
    fake.holds_passkey("anna", &anna);
    let rig = console(fake).await;

    let options = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
    assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
    assert!(offered(&options).is_empty(), "an anonymous login/start offers credential ids: {}", options.text);
    assert!(!rig.fake.heard().iter().any(|h| h.starts_with("Access.ListCredentials")), "{:?}", rig.fake.heard());
    let answer = rig.try_login(&mut anna).await;
    assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
    assert_eq!(answer.body["user"], "anna", "{}", answer.text);
}

/// ★ **A34-7: every ceremony names the browser it is for**, so the core holds
/// one browser's open ceremonies apart from everyone else's.
#[tokio::test]
async fn every_ceremony_this_console_asks_names_the_browser() {
    let fake = Fake::new();
    let mut anna = Yubikey::new(0x41, "annas-yubikey");
    fake.holds_passkey("anna", &anna);
    let rig = console(fake).await;
    let answer = rig.try_login(&mut anna).await;
    assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
    let clients = lock(&rig.fake.starts).clone();
    assert!(!clients.is_empty(), "a login asked the core for a ceremony");
    assert!(clients.iter().all(|c| !c.trim().is_empty()), "a ceremony named no browser: {clients:?}");
}

/// ★ **The login offer does not grow with the people on the estate.**
#[tokio::test]
async fn the_login_offer_is_the_same_size_for_one_person_and_for_sixty_one() {
    let fake = Fake::new();
    fake.holds_passkey("anna", &Yubikey::new(0x41, "annas-yubikey"));
    let rig = console(fake).await;
    let size = |a: &Answer| (offered(a).len(), a.body["publicKey"]["allowCredentials"].to_string().len());
    let anonymous_one = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
    let named_one = rig.send("GET", "/auth/passkey/login/start?user=anna", None, None, None).await;
    for i in 0..60u8 {
        rig.fake.holds_passkey(&format!("person{i}"), &Yubikey::new(i.wrapping_add(0x60), &format!("key-of-person-{i:02}")));
    }
    let anonymous_many = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
    let named_many = rig.send("GET", "/auth/passkey/login/start?user=anna", None, None, None).await;
    for a in [&anonymous_one, &named_one, &anonymous_many, &named_many] {
        assert_eq!(a.status, http::StatusCode::OK, "{}", a.text);
    }
    assert_eq!(size(&anonymous_one), size(&anonymous_many));
    assert_eq!(size(&named_one), size(&named_many));
    assert_eq!(offered(&named_many).len(), crate::auth::LOGIN_OFFER, "{}", named_many.text);
}

/// ★ **A named login offers that account's passkeys, and an unknown name the
/// same shape** — the estate kit's path.
#[tokio::test]
async fn a_named_login_offers_that_accounts_passkeys_and_an_unknown_name_the_same_shape() {
    let fake = Fake::new();
    let mut anna = Yubikey::new(0x41, "annas-yubikey");
    fake.holds_passkey("anna", &anna);
    fake.person("bob", None);
    fake.holds_passkey("bob", &Yubikey::new(0x42, "bobs-yubikey"));
    let rig = console(fake).await;
    let (annas, bobs) = (B64.encode(b"annas-yubikey"), B64.encode(b"bobs-yubikey"));

    let named = rig.send("GET", "/auth/passkey/login/start?user=anna", None, None, None).await;
    assert_eq!(named.status, http::StatusCode::OK, "{}", named.text);
    let ids = offered(&named);
    assert!(ids.contains(&annas), "{}", named.text);
    assert!(!ids.contains(&bobs), "{}", named.text);
    assert_eq!(ids.len(), crate::auth::LOGIN_OFFER, "{}", named.text);
    assert!(rig.fake.heard().contains(&"Access.ListCredentials by console".to_owned()), "{:?}", rig.fake.heard());

    let unknown = rig.send("GET", "/auth/passkey/login/start?user=nosuch", None, None, None).await;
    assert_eq!(unknown.status, http::StatusCode::OK, "{}", unknown.text);
    let decoys = offered(&unknown);
    assert_eq!(decoys.len(), crate::auth::LOGIN_OFFER, "{}", unknown.text);
    assert!(!decoys.contains(&annas) && !decoys.contains(&bobs), "{}", unknown.text);
    let again = rig.send("GET", "/auth/passkey/login/start?user=nosuch", None, None, None).await;
    assert_eq!(offered(&again), decoys, "an unknown name's offer is as stable as a person's");
    let bytes: Vec<Vec<u8>> = ids.iter().map(|id| B64.decode(id).expect("base64url")).collect();
    let mut sorted = bytes.clone();
    sorted.sort();
    assert_eq!(bytes, sorted, "the offer is in one order whatever is real in it");

    let body = anna.answer(&named.body);
    let answer = rig.send("POST", "/auth/passkey/login/finish", None, None, Some(body)).await;
    assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
    assert_eq!(answer.body["user"], "anna", "{}", answer.text);
}

/// A login the core refuses hands out nothing.
#[tokio::test]
async fn a_login_the_core_refuses_hands_out_no_session() {
    let fake = Fake::new();
    let mut yubikey = Yubikey::new(0x41, "annas-yubikey");
    fake.holds_passkey("anna", &yubikey);
    fake.refuse_logins.store(true, Ordering::SeqCst);
    let rig = console(fake).await;
    let answer = rig.try_login(&mut yubikey).await;
    assert_eq!(answer.status, http::StatusCode::UNAUTHORIZED, "{}", answer.text);
    assert!(answer.cookie.is_none(), "no session for a refused login");
    assert!(rig.fake.heard().contains(&"Identity.LoginFinish".to_owned()));
}

/// ★ **A login while gunnar's data set is grown is 503, never 401**
/// (grow-login-503): the core's STORE refusal is gunnar not finishing the
/// call, never a verdict on the passkey; the same passkey logs in once the set
/// is back.
#[tokio::test]
async fn a_login_while_the_data_set_is_grown_is_unavailable_and_succeeds_after() {
    let fake = Fake::new();
    let mut yubikey = Yubikey::new(0x46, "annas-yubikey");
    fake.holds_passkey("anna", &yubikey);
    fake.growing.store(true, Ordering::SeqCst);
    let rig = console(fake).await;
    let answer = rig.try_login(&mut yubikey).await;
    assert_eq!(answer.status, http::StatusCode::SERVICE_UNAVAILABLE, "{}", answer.text);
    assert_eq!(answer.text, crate::auth::routes::LOGIN_UNAVAILABLE_WORDS, "the console's words, never gunnar's");
    assert!(answer.cookie.is_none());
    assert!(answer.retry_after.is_some(), "a 503 says when to ask again");
    rig.fake.growing.store(false, Ordering::SeqCst);
    let answer = rig.try_login(&mut yubikey).await;
    assert_eq!(answer.status, http::StatusCode::OK, "once the set is back: {}", answer.text);
}

/// ★ **Only "these credentials are wrong" is a 401** (grow-login-503, widened
/// after cov-B's UpCloud mockup): every answer that is NOT a refusal of the
/// passkey — a transport failure, gunnar unreachable, anything it could not
/// finish — is a 503 with a Retry-After.
#[tokio::test]
async fn only_a_credential_refusal_is_401_every_other_failure_on_the_login_path_is_503() {
    use tonic::Code;
    let fake = Fake::new();
    let mut yubikey = Yubikey::new(0x47, "annas-yubikey");
    fake.holds_passkey("anna", &yubikey);
    let rig = console(fake).await;
    for (code, message) in [
        (Code::Unknown, "h2 protocol error: http2 error"),
        (Code::Internal, "h2 protocol error: http2 error"),
        (Code::Unavailable, "tcp connect error: Connection refused (os error 111)"),
        (Code::DeadlineExceeded, "Timeout expired"),
        (Code::Cancelled, "operation was canceled"),
        (Code::Aborted, "the login raced another write; ask again"),
        (Code::ResourceExhausted, "too many calls"),
    ] {
        *lock(&rig.fake.login_status) = Some((code, message.to_owned()));
        let answer = rig.try_login(&mut yubikey).await;
        assert_eq!(answer.status, http::StatusCode::SERVICE_UNAVAILABLE, "{code:?} {message}: {}", answer.text);
        assert!(answer.retry_after.is_some(), "{code:?}");
        assert!(answer.cookie.is_none(), "{code:?}");
    }
    for (code, message) in [(Code::Unauthenticated, "the signature does not verify"), (Code::PermissionDenied, "the counter went back"), (Code::InvalidArgument, "the assertion is malformed")] {
        *lock(&rig.fake.login_status) = Some((code, message.to_owned()));
        let answer = rig.try_login(&mut yubikey).await;
        assert_eq!(answer.status, http::StatusCode::UNAUTHORIZED, "{code:?}: {}", answer.text);
    }
    *lock(&rig.fake.login_status) = None;
    let answer = rig.try_login(&mut yubikey).await;
    assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
}

/// ★ **gunnar not reachable is not "no passkey"** (round 3): a 503, and the
/// same login works once gunnar answers.
#[tokio::test]
async fn a_login_while_gunnar_is_unreachable_is_unavailable_not_no_passkey() {
    let fake = Fake::new();
    let mut yubikey = Yubikey::new(0x42, "annas-yubikey");
    fake.holds_passkey("anna", &yubikey);
    fake.unreachable.store(true, Ordering::SeqCst);
    let rig = console(fake).await;
    let answer = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
    assert_eq!(answer.status, http::StatusCode::SERVICE_UNAVAILABLE, "{}", answer.text);
    rig.fake.unreachable.store(false, Ordering::SeqCst);
    let answer = rig.try_login(&mut yubikey).await;
    assert_eq!(answer.status, http::StatusCode::OK, "once gunnar answers: {}", answer.text);
}

/// ★ **A role gunnar cannot tell yet is unavailable, not a viewer**, and not
/// remembered.
#[tokio::test]
async fn a_role_gunnar_cannot_tell_yet_is_unavailable_and_not_remembered() {
    let fake = Fake::new();
    let mut yubikey = Yubikey::new(0x44, "boss-yubikey");
    fake.holds_passkey("boss", &yubikey);
    let rig = console(fake).await;
    let cookie = rig.login(&mut yubikey).await;
    rig.webauthn.forget_role("boss");
    // The core still says who the session is; gunnar's Access does not answer.
    rig.fake.unreachable.store(true, Ordering::SeqCst);
    let answer = rig.send("GET", "/api/dataset", None, Some(&cookie), None).await;
    assert!(
        answer.status == http::StatusCode::SERVICE_UNAVAILABLE || answer.status == http::StatusCode::UNAUTHORIZED,
        "never a viewer's 403: {} {}",
        answer.status,
        answer.text
    );
    assert!(!answer.text.contains("your session is viewer"), "{}", answer.text);
    rig.fake.unreachable.store(false, Ordering::SeqCst);
    let answer = rig.send("GET", "/api/dataset", None, Some(&cookie), None).await;
    assert_ne!(answer.status, http::StatusCode::FORBIDDEN, "{}", answer.text);
    assert_ne!(answer.status, http::StatusCode::SERVICE_UNAVAILABLE, "{}", answer.text);
}

/// **Logging out ends the person's sessions at the core**, everywhere, and
/// the cookie stops working.
#[tokio::test]
async fn logout_ends_the_persons_sessions_at_the_core() {
    let fake = Fake::new();
    let mut yubikey = Yubikey::new(0x41, "annas-yubikey");
    fake.holds_passkey("anna", &yubikey);
    let rig = console(fake).await;
    let cookie = rig.login(&mut yubikey).await;
    let other = rig.login(&mut yubikey).await;
    let out = rig.send("POST", "/auth/logout", None, Some(&cookie), None).await;
    assert_eq!(out.status, http::StatusCode::OK, "{}", out.text);
    assert!(rig.fake.heard().contains(&"Identity.Logout by anna everywhere=true".to_owned()), "{:?}", rig.fake.heard());
    for gone in [&cookie, &other] {
        let after = rig.send("GET", "/api/account/keys", None, Some(gone), None).await;
        assert_eq!(after.status, http::StatusCode::UNAUTHORIZED, "{}", after.text);
    }
}

/// ★ **An invite is the core's, minted AS the administrator who invites, and
/// redeemed through the core.** The person is made AS the administrator
/// (gunnar decides), the operator role is their server grant, the link
/// carries the core's secret, and the registration is the core's.
#[tokio::test]
async fn an_invite_is_minted_by_the_administrator_and_redeemed_through_the_core() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let rig = console(fake).await;
    let boss = rig.login(&mut boss_key).await;

    let minted = rig
        .send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "kim", "role": "operator" })))
        .await;
    assert_eq!(minted.status, http::StatusCode::OK, "{}", minted.text);
    let heard = rig.fake.heard();
    assert!(heard.contains(&"Access.AddPrincipal by boss".to_owned()), "{heard:?}");
    assert!(heard.contains(&"Access.Grant by boss".to_owned()), "{heard:?}");
    assert!(heard.contains(&"Identity.CreateInvite by boss".to_owned()), "{heard:?}");
    assert_eq!(lock(&rig.fake.invite_requests).clone(), vec!["kim".to_owned()]);
    assert_eq!(lock(&rig.fake.server).get("kim").copied(), Some(pb::GrantLevel::Write as i32), "an operator is server Write");
    let url = minted.body["url"].as_str().expect("a link").to_owned();
    let secret = secret_of(&url);

    let listed = rig.send("GET", "/auth/passkey/invites", None, Some(&boss), None).await;
    assert_eq!(listed.status, http::StatusCode::OK, "{}", listed.text);
    assert!(rig.fake.heard().contains(&"Identity.ListInvites by boss".to_owned()));
    assert_eq!(listed.body["invites"][0]["id"], minted.body["id"], "{}", listed.text);
    assert_eq!(listed.body["invites"][0]["user"], "kim");
    assert_eq!(listed.body["invites"][0]["role"], "operator");

    let mut kim_key = Yubikey::new(0x43, "kims-yubikey");
    let registered = rig.register_with_invite(&secret, "kim", &mut kim_key).await;
    assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
    let register = lock(&rig.fake.registrations)[0].clone();
    assert_eq!(register.principal_id, "kim");
    assert_eq!(register.credential_id, kim_key.id);
    assert_eq!(register.public_key, kim_key.sec1(), "SEC1 uncompressed, 0x04 || X || Y");
    assert!(!register.attestation_object.is_empty());
    assert_eq!((register.rp_id.as_str(), register.origin.as_str()), (RP_ID, ORIGIN));

    let kim = rig.login(&mut kim_key).await;
    let status = rig.send("GET", "/auth/status", None, Some(&kim), None).await;
    assert_eq!(status.body["user"], "kim", "{}", status.text);
    rig.console_keeps_no_logins();
}

/// ★ **GHUNT-16 (lane OX-GUI16, 2026-10-28): a passkey keeps the name its person gave it.**
/// Since IDENTITY-HOST the core stores the passkey (`RedeemFinish`, which carries no label) and
/// gunnar files it as "passkey", so the name typed on the login page was lost on the way
/// (gunnar-ui uitest `the-session-lists-the-credential-it-logged-in-with`: "passkey" for the
/// "virtual" one). The label is gunnar's, not the core's: the console names the new passkey at
/// gunnar (`Access.RenameCredential`) AS the person, with the session the core just minted.
#[tokio::test]
async fn a_registered_passkey_keeps_the_name_its_person_gave_it() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let rig = console(fake).await;
    let boss = rig.login(&mut boss_key).await;
    let minted = rig
        .send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "kim", "role": "operator" })))
        .await;
    assert_eq!(minted.status, http::StatusCode::OK, "{}", minted.text);
    let secret = secret_of(minted.body["url"].as_str().expect("a link"));

    let mut kim_key = Yubikey::new(0x43, "kims-yubikey");
    let registered = rig.register_with_invite(&secret, "kim", &mut kim_key).await;
    assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
    let kim = registered.cookie.clone().expect("the registration hands out the person's session");
    assert!(rig.fake.heard().contains(&"Access.RenameCredential by kim".to_owned()), "named AS kim: {:?}", rig.fake.heard());

    let listed = rig.send("GET", "/auth/passkey/credentials", None, Some(&kim), None).await;
    assert_eq!(listed.status, http::StatusCode::OK, "{}", listed.text);
    let own = listed.body["credentials"]
        .as_array()
        .expect("credentials")
        .iter()
        .find(|c| c["id"] == B64.encode(&kim_key.id))
        .unwrap_or_else(|| panic!("kim's passkey is not listed: {}", listed.text))
        .clone();
    assert_eq!(own["label"], "kim's key", "{}", listed.text);
}

/// A registration that names no passkey keeps gunnar's own word, and asks gunnar nothing more.
#[tokio::test]
async fn a_registered_passkey_with_no_name_is_not_renamed() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let rig = console(fake).await;
    let boss = rig.login(&mut boss_key).await;
    let minted = rig
        .send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "kim", "role": "operator" })))
        .await;
    let secret = secret_of(minted.body["url"].as_str().expect("a link"));
    let mut kim_key = Yubikey::new(0x43, "kims-yubikey");
    let options = rig.send("GET", &format!("/auth/passkey/register/start?invite={secret}&user=kim"), None, None, None).await;
    let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
    let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
    let (client_data, attestation) = kim_key.create(&challenge);
    let registered = rig
        .send(
            "POST",
            "/auth/passkey/register/finish",
            None,
            None,
            Some(serde_json::json!({
                "ceremony_id": ceremony,
                "invite": secret,
                "user": "kim",
                "response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
            })),
        )
        .await;
    assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
    assert!(!rig.fake.heard().iter().any(|h| h.starts_with("Access.RenameCredential")), "{:?}", rig.fake.heard());
    let held = lock(&rig.fake.credentials).iter().find(|(c, _)| c.credential_id == kim_key.id).map(|(c, _)| c.label.clone());
    assert_eq!(held.as_deref(), Some("passkey"));
}

/// ★ **GHUNT-11 (lane OX-GUNHUNT, 2026-10-28): an invite never makes an admin.** Before
/// IDENTITY-HOST the role travelled as the invite's server level and gunnar refused ADMIN
/// (`invite_would_be_admin`). Since 7a0c3a8 the console grants the asked role itself
/// (`Access.Grant`, as the inviter) BEFORE the core mints the link, so a link for
/// `role: admin` made its holder a server admin, and the break-glass token minted such links
/// too (gunnar-ui uitest `an-invite-cannot-grant-admin`: 200 where it was 400). Refused by
/// name, before anything is made or granted.
#[tokio::test]
async fn an_invite_for_the_admin_role_is_refused_and_grants_nothing() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let rig = console(fake).await;
    let boss = rig.login(&mut boss_key).await;

    let refused = rig
        .send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "zed", "role": "admin" })))
        .await;
    assert_eq!(refused.status, http::StatusCode::BAD_REQUEST, "{}", refused.text);
    assert!(refused.text.contains("never makes an admin"), "{}", refused.text);
    let heard = rig.fake.heard();
    for asked in ["Access.Grant", "Access.AddPrincipal", "Identity.CreateInvite", "Access.EnrolmentLink"] {
        assert!(!heard.iter().any(|h| h.starts_with(asked)), "{asked} was asked for an admin invite: {heard:?}");
    }
    assert_eq!(lock(&rig.fake.server).get("zed").copied(), None, "no server grant for zed");
    rig.console_keeps_no_logins();
}

/// ★ **A login asks for a verified user** (owner 2026-10-11, usernameless
/// passkeys): the options require it.
#[tokio::test]
async fn a_login_asks_for_a_verified_user() {
    let rig = console(Fake::new()).await;
    let options = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
    assert_eq!(options.body["publicKey"]["userVerification"], "required", "{}", options.text);
}

/// ★ **A passkey an admin registers in person is their OWN** (owner
/// 2026-10-11): neither a `user` nor a `role` in the body is read; the core
/// mints a link onto the stepped-up caller and redeems it.
#[tokio::test]
async fn a_passkey_an_admin_registers_in_person_is_their_own() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let rig = console(fake).await;
    let boss = rig.login(&mut boss_key).await;

    let options = rig.send("GET", "/auth/passkey/register/start", None, Some(&boss), None).await;
    assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
    assert_eq!(options.body["publicKey"]["user"]["name"], "boss", "the key shows whose it is: {}", options.text);
    let excluded: Vec<&str> = options.body["publicKey"]["excludeCredentials"].as_array().expect("a list").iter().filter_map(|c| c["id"].as_str()).collect();
    assert_eq!(excluded, vec![B64.encode(&boss_key.id)], "the passkey boss holds is excluded: {}", options.text);
    let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
    let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
    let mut spare = Yubikey::new(0x44, "boss-spare");
    let (client_data, attestation) = spare.create(&challenge);
    let answer = rig
        .send(
            "POST",
            "/auth/passkey/register/finish",
            None,
            Some(&boss),
            Some(serde_json::json!({
                "ceremony_id": ceremony,
                "user": "kim",
                "role": "operator",
                "label": "spare",
                "response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
            })),
        )
        .await;
    assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
    let heard = rig.fake.heard();
    assert!(!heard.iter().any(|h| h.starts_with("Access.AddPrincipal")), "a name in the body made an account: {heard:?}");
    assert!(!heard.iter().any(|h| h.starts_with("Access.Grant")), "a role in the body was granted: {heard:?}");
    assert_eq!(lock(&rig.fake.invite_requests).clone(), vec!["boss".to_owned()], "the key is the caller's own");
    let spare_in = rig.login(&mut spare).await;
    let status = rig.send("GET", "/auth/status", None, Some(&spare_in), None).await;
    assert_eq!(status.body["user"], "boss", "{}", status.text);
}

/// Withdrawing an invite, and listing and revoking passkeys, are asked AS the
/// administrator; a revoked passkey ends its person's sessions (gunnar's).
#[tokio::test]
async fn invites_and_passkeys_are_withdrawn_as_the_administrator() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let mut annas = Yubikey::new(0x41, "annas-yubikey");
    fake.holds_passkey("anna", &annas);
    let rig = console(fake).await;
    let boss = rig.login(&mut boss_key).await;
    let anna = rig.login(&mut annas).await;
    let minted = rig
        .send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "anna", "role": "viewer" })))
        .await;
    assert_eq!(minted.status, http::StatusCode::OK, "{}", minted.text);
    assert!(!rig.fake.heard().contains(&"Access.AddPrincipal by boss".to_owned()), "anna exists: no principal is made");

    let withdrawn = rig
        .send("POST", "/auth/passkey/invites/revoke", None, Some(&boss), Some(serde_json::json!({ "id": minted.body["id"] })))
        .await;
    assert_eq!(withdrawn.status, http::StatusCode::OK, "{}", withdrawn.text);
    assert!(rig.fake.heard().contains(&"Identity.RevokeInvite by boss".to_owned()));
    let listed = rig.send("GET", "/auth/passkey/invites", None, Some(&boss), None).await;
    assert_eq!(listed.body["invites"], serde_json::json!([]), "{}", listed.text);

    let listed = rig.send("GET", "/auth/passkey/credentials", None, Some(&boss), None).await;
    assert_eq!(listed.status, http::StatusCode::OK, "{}", listed.text);
    assert!(rig.fake.heard().contains(&"Access.ListCredentials by boss".to_owned()));
    let annas_id = B64.encode(&annas.id);
    assert!(listed.text.contains(&annas_id), "{}", listed.text);

    let revoked = rig
        .send("POST", "/auth/passkey/credentials/revoke", None, Some(&boss), Some(serde_json::json!({ "id": annas_id })))
        .await;
    assert_eq!(revoked.status, http::StatusCode::OK, "{}", revoked.text);
    assert!(rig.fake.heard().contains(&"Access.RevokeCredential by boss".to_owned()));
    assert_eq!(*lock(&rig.fake.revoked_credentials), vec![annas.id.clone()]);
    let after = rig.send("GET", "/api/account/keys", None, Some(&anna), None).await;
    assert_eq!(after.status, http::StatusCode::UNAUTHORIZED, "anna's session outlived her passkey: {}", after.text);
}

/// ★ **An account admin's employee invite is gunnar's**, minted in the SAME
/// call (`EnrollEmployee{invite}`) — the core's link onto the employee.
#[tokio::test]
async fn an_employee_invite_is_created_by_the_account_admin_at_gunnar() {
    let fake = Fake::new();
    let mut acme_key = Yubikey::new(0x44, "acme-yubikey");
    fake.holds_passkey("acme", &acme_key);
    let rig = console(fake).await;
    let acme = rig.login(&mut acme_key).await;
    let enrolled = rig
        .send("POST", "/api/account/employees", None, Some(&acme), Some(serde_json::json!({ "account": "acme", "name": "olle" })))
        .await;
    assert_eq!(enrolled.status, http::StatusCode::OK, "{}", enrolled.text);
    let heard = rig.fake.heard();
    assert!(heard.contains(&"Accounts.EnrollEmployee by acme".to_owned()), "{heard:?}");
    assert!(!heard.iter().any(|h| h.starts_with("Identity.CreateInvite")), "a second call for the invite: {heard:?}");
    assert_eq!(lock(&rig.fake.invite_requests).clone(), vec!["olle".to_owned()]);
    let url = enrolled.body["url"].as_str().unwrap_or_default().to_owned();
    let mut olle = Yubikey::new(0x48, "olles-yubikey");
    let registered = rig.register_with_invite(&secret_of(&url), "olle", &mut olle).await;
    assert_eq!(registered.status, http::StatusCode::OK, "the core's link registers olle: {}", registered.text);
    rig.console_keeps_no_logins();
}

/// ★ **A paid signup's passkey is registered through the core on the account
/// monetize says the checkout paid for**: gunnar mints the account's first
/// login link (`Access.EnrolmentLink`, first only), the core redeems it, and a
/// second registration through the same link is refused (the account holds a
/// passkey now).
#[cfg(feature = "metered")]
#[tokio::test]
async fn a_paid_signups_passkey_is_registered_on_the_account_monetize_names() {
    use crate::auth::signup::tests::{FakeMonetize, SESSION};

    let fake = Fake::new();
    fake.person("bob", None);
    let rig = console_selling(fake, Some(FakeMonetize::created("bob"))).await;
    let mut bobs = Yubikey::new(0x45, "bobs-yubikey");
    let options = rig.send("GET", &format!("/auth/passkey/register/start?signup={SESSION}&user=bob"), None, None, None).await;
    assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
    assert!(rig.fake.heard().contains(&"Access.EnrolmentLink by console".to_owned()), "{:?}", rig.fake.heard());
    let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
    let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
    let (client_data, attestation) = bobs.create(&challenge);
    let registered = rig
        .send(
            "POST",
            "/auth/passkey/register/finish",
            None,
            None,
            Some(serde_json::json!({
                "ceremony_id": ceremony,
                "signup": SESSION,
                "user": "bob",
                "label": "bob's key",
                "response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
            })),
        )
        .await;
    assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
    assert_eq!(lock(&rig.fake.registrations)[0].principal_id, "bob", "the account monetize named");
    rig.console_keeps_no_logins();

    let issued = rig.fake.issued.load(Ordering::SeqCst);
    let again = rig.send("GET", &format!("/auth/passkey/register/start?signup={SESSION}&user=bob"), None, None, None).await;
    assert_eq!(again.status, http::StatusCode::CONFLICT, "{}", again.text);
    assert_eq!(rig.fake.issued.load(Ordering::SeqCst), issued, "no ceremony for a door that is shut");
    assert_eq!(lock(&rig.fake.registrations).len(), 1);
}

/// A registration's start is a door for anonymous visitors only with a token
/// that could open it; anything else is refused before the core is asked.
#[tokio::test]
async fn an_invite_that_cannot_be_one_opens_no_ceremony() {
    let rig = console(Fake::new()).await;
    let too_long = "a".repeat(300);
    for invite in ["", "%20", "%3Cscript%3E", "not%20a%20secret", too_long.as_str()] {
        let options = rig.send("GET", &format!("/auth/passkey/register/start?invite={invite}&user=bob"), None, None, None).await;
        assert_eq!(options.status, http::StatusCode::BAD_REQUEST, "invite {invite:?}: {}", options.text);
    }
    assert_eq!(rig.fake.issued.load(Ordering::SeqCst), 0, "no ceremony was asked of the core");
}

/// ★ **An invite registers ONE passkey** (gunnar.rs 2026-10-19): after the
/// first registration the invite is refused at the START — before the browser
/// makes anything — and at a finish, in plain words (410); a fresh invite for
/// the same person EXCLUDES the passkey they hold; and a login with a passkey
/// gunnar does not hold is said machine-readably.
#[tokio::test]
async fn a_spent_invite_is_refused_before_any_passkey_is_made() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let rig = console(fake).await;
    let boss = rig.login(&mut boss_key).await;
    let mint = || async {
        let minted = rig
            .send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "henrik", "role": "operator" })))
            .await;
        assert_eq!(minted.status, http::StatusCode::OK, "{}", minted.text);
        secret_of(minted.body["url"].as_str().expect("a link"))
    };
    let secret = mint().await;
    let mut henrik_key = Yubikey::new(0x44, "henriks-yubikey");

    // A ceremony opened before the first registration finished (a second tab).
    let early = rig.send("GET", &format!("/auth/passkey/register/start?invite={secret}&user=henrik"), None, None, None).await;
    assert_eq!(early.status, http::StatusCode::OK, "{}", early.text);
    let first = rig.register_with_invite(&secret, "henrik", &mut henrik_key).await;
    assert_eq!(first.status, http::StatusCode::OK, "{}", first.text);

    let again = rig.send("GET", &format!("/auth/passkey/register/start?invite={secret}&user=henrik"), None, None, None).await;
    assert_eq!(again.status, http::StatusCode::GONE, "{}", again.text);
    assert_eq!(again.text, crate::auth::INVITE_SPENT_WORDS);

    // The early ceremony's finish is refused too: the core spent the invite.
    let mut second_key = Yubikey::new(0x45, "henriks-second");
    let challenge = early.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
    let (client_data, attestation) = second_key.create(&challenge);
    let late = rig
        .send(
            "POST",
            "/auth/passkey/register/finish",
            None,
            None,
            Some(serde_json::json!({
                "ceremony_id": early.body["ceremony_id"],
                "invite": secret,
                "response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
            })),
        )
        .await;
    assert_eq!(late.status, http::StatusCode::GONE, "{}", late.text);
    assert_eq!(lock(&rig.fake.registrations).len(), 1, "one passkey for one invite");

    // An invite nobody made: refused at the start.
    let unknown = rig.send("GET", "/auth/passkey/register/start?invite=unknown-invite-secret-0", None, None, None).await;
    assert_eq!(unknown.status, http::StatusCode::GONE, "{}", unknown.text);

    // A fresh invite for henrik excludes the passkey he holds.
    let fresh = mint().await;
    let options = rig.send("GET", &format!("/auth/passkey/register/start?invite={fresh}&user=somebody"), None, None, None).await;
    assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
    let excluded: Vec<&str> = options.body["publicKey"]["excludeCredentials"].as_array().expect("a list").iter().filter_map(|c| c["id"].as_str()).collect();
    assert_eq!(excluded, vec![B64.encode(&henrik_key.id)], "{}", options.text);
    assert_eq!(options.body["publicKey"]["user"]["name"], "henrik", "the key shows gunnar's person: {}", options.text);

    // A passkey gunnar does not hold: said machine-readably.
    let answer = rig.try_login(&mut second_key).await;
    assert_eq!(answer.status, http::StatusCode::UNAUTHORIZED, "{}", answer.text);
    assert_eq!(answer.body["code"], "unknowncredential", "{}", answer.text);
    assert!(answer.cookie.is_none());
}

/// A paid signup's start is asked of monetize first: a session it does not
/// know, or one whose account holds a passkey already, opens no ceremony.
#[cfg(feature = "metered")]
#[tokio::test]
async fn a_signup_that_cannot_register_opens_no_ceremony() {
    use crate::auth::signup::tests::{FakeMonetize, SESSION};

    let rig = console_selling(Fake::new(), Some(FakeMonetize::unknown())).await;
    for signup in ["", "garbage", SESSION] {
        let options = rig.send("GET", &format!("/auth/passkey/register/start?signup={signup}&user=bob"), None, None, None).await;
        assert!(options.status.is_client_error(), "signup {signup:?}: {} {}", options.status, options.text);
    }
    assert_eq!(rig.fake.issued.load(Ordering::SeqCst), 0);

    let fake = Fake::new();
    fake.person("bob", None);
    fake.holds_passkey("bob", &Yubikey::new(0x45, "bobs-yubikey"));
    let rig = console_selling(fake, Some(FakeMonetize::created("bob"))).await;
    let options = rig.send("GET", &format!("/auth/passkey/register/start?signup={SESSION}&user=bob"), None, None, None).await;
    assert_eq!(options.status, http::StatusCode::CONFLICT, "{}", options.text);
    assert_eq!(rig.fake.issued.load(Ordering::SeqCst), 0);
}

/// ★ **One paid signup registers one passkey, however many ask at once**
/// (stinky 2026-10-10 #3): the second finish is a 409, and the core hears one.
#[cfg(feature = "metered")]
#[tokio::test]
async fn two_finishes_of_one_paid_signup_at_once_register_one_passkey() {
    use crate::auth::signup::tests::{FakeMonetize, SESSION};

    let fake = Fake::new();
    fake.person("bob", None);
    *lock(&fake.register_delay) = Some(std::time::Duration::from_millis(300));
    let rig = console_selling(fake, Some(FakeMonetize::created("bob"))).await;
    let mut finishes = Vec::new();
    for (seed, label) in [(0x45, "one"), (0x46, "two")] {
        let mut key = Yubikey::new(seed, label);
        let options = rig.send("GET", &format!("/auth/passkey/register/start?signup={SESSION}&user=bob"), None, None, None).await;
        assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
        let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
        let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
        let (client_data, attestation) = key.create(&challenge);
        finishes.push(serde_json::json!({
            "ceremony_id": ceremony,
            "signup": SESSION,
            "user": "bob",
            "label": label,
            "response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
        }));
    }
    let (a, b) = tokio::join!(
        rig.send("POST", "/auth/passkey/register/finish", None, None, Some(finishes[0].clone())),
        rig.send("POST", "/auth/passkey/register/finish", None, None, Some(finishes[1].clone())),
    );
    let mut statuses = [a.status, b.status];
    statuses.sort();
    assert_eq!(statuses, [http::StatusCode::OK, http::StatusCode::CONFLICT], "{} / {}", a.text, b.text);
    assert_eq!(lock(&rig.fake.registrations).len(), 1, "the core registered one passkey");
}

/// ★ **A paid signup's registration spends the poll's budget** (stinky
/// 2026-10-10 #3).
#[cfg(feature = "metered")]
#[tokio::test]
async fn a_paid_signups_registration_spends_the_polls_budget() {
    use crate::auth::signup::tests::FakeMonetize;

    let monetize = FakeMonetize::created("bob");
    let asked = monetize.calls.clone();
    let rig = console_selling(Fake::new(), Some(monetize)).await;
    rig.webauthn.freeze_signup_clock();
    let mut refused = 0;
    for n in 0..40 {
        let session = format!("cs_test_{n:024}");
        let finish = rig
            .send("POST", "/auth/passkey/register/finish", None, None, Some(serde_json::json!({ "signup": session, "ceremony_id": "nothing", "response": {} })))
            .await;
        if finish.status == http::StatusCode::TOO_MANY_REQUESTS {
            refused += 1;
        }
    }
    assert_eq!(refused, 10, "{refused} of 40 new sessions from one address were refused");
    let statuses = asked.lock().expect("the call log").iter().filter(|c| c.starts_with("Signup.Status")).count();
    assert!(statuses <= 35, "monetize was asked {statuses} times");
}

// ───────────────────────────────────────────────────────────────────────────
// A machine asks; a person approves; gunnar decides (2026-10-05)
// ───────────────────────────────────────────────────────────────────────────

impl Rig {
    /// The kit's request: the creation ceremony, anonymously, as the kit sends it.
    async fn kit_asks(&self, kit: &mut Yubikey, machine: &str, role: &str) -> Answer {
        let options = self.send("GET", &format!("/auth/machine/enrol/start?machine={machine}"), None, None, None).await;
        assert_eq!(options.status, http::StatusCode::OK, "the kit opens its ceremony anonymously: {}", options.text);
        let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
        let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
        let (client_data, attestation) = kit.create(&challenge);
        self.send(
            "POST",
            "/auth/machine/enrol",
            None,
            None,
            Some(serde_json::json!({
                "ceremony_id": ceremony,
                "machine": machine,
                "label": format!("installer kit on {}", machine.trim_start_matches("kit-")),
                "role": role,
                "response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
            })),
        )
        .await
    }

    /// The Approve button: the core's step-up options, the approver's key,
    /// the approval — as the Identities page sends them.
    async fn approve_with_step_up(&self, cookie: &str, approver: &mut Yubikey, id: &str, code: &str) -> Answer {
        let options = self.send("GET", "/auth/machine/step-up", None, Some(cookie), None).await;
        assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
        assert_eq!(options.body["publicKey"]["userVerification"], "required", "{}", options.text);
        let mut body = approver.answer(&options.body);
        body["code"] = code.into();
        self.send("POST", &format!("/auth/machine/requests/{id}/approve"), None, Some(cookie), Some(body)).await
    }
}

/// ★ **A machine asks anonymously and the console only relays**: gunnar's
/// challenge (`Access.MachineChallenge`, the console's key), the request with
/// the console's key, gunnar's row, the kit's anonymous poll.
#[tokio::test]
async fn a_machine_asks_anonymously_and_the_console_only_relays_to_gunnar() {
    let rig = console(Fake::new()).await;
    let mut kit = Yubikey::new(0x51, "kit-dwarf-key");
    let asked = rig.kit_asks(&mut kit, "kit-dwarf", "admin").await;
    assert_eq!(asked.status, http::StatusCode::OK, "{}", asked.text);
    assert_eq!((asked.body["state"].as_str(), asked.body["code"].as_str(), asked.body["machine"].as_str()), (Some("pending"), Some("ABC-120"), Some("kit-dwarf")), "{}", asked.text);
    let heard = rig.fake.heard();
    assert!(heard.contains(&"Access.MachineChallenge by console".to_owned()), "{heard:?}");
    assert!(heard.contains(&"Access.RequestMachineEnrolment by console".to_owned()), "{heard:?}");
    let relayed = lock(&rig.fake.enrol_requests)[0].clone();
    assert_eq!(relayed.credential_id, kit.id);
    assert_eq!(relayed.public_key, kit.sec1());
    assert!(!relayed.attestation_object.is_empty());
    assert_eq!(relayed.server_level, pb::GrantLevel::Admin as i32);
    assert_eq!((relayed.rp_id.as_str(), relayed.origin.as_str()), (RP_ID, ORIGIN));
    assert_eq!(relayed.label, "installer kit on dwarf");
    let id = asked.body["id"].as_str().expect("an id");
    let polled = rig.send("GET", &format!("/auth/machine/enrol/{id}"), None, None, None).await;
    assert_eq!(polled.status, http::StatusCode::OK, "{}", polled.text);
    assert_eq!(polled.body["state"], "pending");
    let mut ops = Yubikey::new(0x52, "kit-ops-key");
    let asked = rig.kit_asks(&mut ops, "kit-ops", "operator").await;
    assert_eq!(asked.status, http::StatusCode::OK, "{}", asked.text);
    assert_eq!(lock(&rig.fake.enrol_requests)[1].server_level, pb::GrantLevel::Write as i32);
    rig.console_keeps_no_logins();
}

/// ★ **The admin lists, approves from her own stepped-up session, and
/// refuses — each AS HERSELF, each decided by gunnar**; the kit then logs in
/// through the core as the machine gunnar made.
#[tokio::test]
async fn the_admin_approves_with_her_own_step_up_and_the_kit_logs_in_as_its_machine() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let mut anna_key = Yubikey::new(0x41, "annas-yubikey");
    fake.holds_passkey("anna", &anna_key);
    let rig = console(fake).await;
    let mut kit = Yubikey::new(0x51, "kit-dwarf-key");
    let asked = rig.kit_asks(&mut kit, "kit-dwarf", "admin").await;
    let (id, code) = (asked.body["id"].as_str().expect("id").to_owned(), asked.body["code"].as_str().expect("code").to_owned());
    boss_key.uv = false;
    let boss = rig.login(&mut boss_key).await;
    boss_key.uv = true;

    let listed = rig.send("GET", "/auth/machine/requests", None, Some(&boss), None).await;
    assert_eq!(listed.status, http::StatusCode::OK, "{}", listed.text);
    assert_eq!(listed.body["requests"][0]["code"], code.as_str(), "{}", listed.text);
    assert!(rig.fake.heard().contains(&"Access.ListMachineEnrolments by boss".to_owned()), "{:?}", rig.fake.heard());

    let options = rig.send("GET", "/auth/machine/step-up", None, Some(&boss), None).await;
    let allowed: Vec<String> = options.body["publicKey"]["allowCredentials"].as_array().expect("allowed").iter().map(|c| c["id"].as_str().unwrap_or_default().to_owned()).collect();
    assert_eq!(allowed, vec![B64.encode(&boss_key.id)], "only boss's own passkey steps boss up: {}", options.text);
    assert!(rig.fake.heard().contains(&"Identity.StepUpStart".to_owned()));

    let approved = rig.approve_with_step_up(&boss, &mut boss_key, &id, &code).await;
    assert_eq!(approved.status, http::StatusCode::OK, "{}", approved.text);
    assert_eq!(approved.body["machine"], "kit-dwarf", "{}", approved.text);
    assert!(rig.fake.heard().contains(&"Identity.StepUpFinish".to_owned()));
    let (by, stepped_up, relayed) = lock(&rig.fake.approvals).last().cloned().expect("an approval");
    assert_eq!(by, "boss", "the approval is AS boss, never the console");
    assert!(stepped_up, "boss's own session stepped up for it");
    assert_eq!(relayed.code, code);

    let answer = rig.try_login(&mut kit).await;
    assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
    assert_eq!((answer.body["user"].as_str(), answer.body["role"].as_str()), (Some("kit-dwarf"), Some("admin")), "{}", answer.text);

    let mut other = Yubikey::new(0x53, "kit-other-key");
    let second = rig.kit_asks(&mut other, "kit-other", "admin").await;
    let refused = rig.send("POST", &format!("/auth/machine/requests/{}/refuse", second.body["id"].as_str().expect("id")), None, Some(&boss), None).await;
    assert_eq!(refused.status, http::StatusCode::OK, "{}", refused.text);
    assert_eq!(refused.body["request"]["state"], "refused");

    let anna = rig.login(&mut anna_key).await;
    let listed = rig.send("GET", "/auth/machine/requests", None, Some(&anna), None).await;
    assert_eq!(listed.status, http::StatusCode::FORBIDDEN, "{}", listed.text);
    assert!(rig.fake.heard().contains(&"Access.ListMachineEnrolments by anna".to_owned()), "the refusal is gunnar's: {:?}", rig.fake.heard());
    rig.console_keeps_no_logins();
}

/// An approval from a session that did not step up is gunnar's `[stepup]`,
/// answered as the console's in-place step-up refusal.
#[tokio::test]
async fn an_approval_without_a_step_up_is_the_in_place_step_up_refusal() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let rig = console(fake).await;
    let mut kit = Yubikey::new(0x51, "kit-dwarf-key");
    let asked = rig.kit_asks(&mut kit, "kit-dwarf", "admin").await;
    boss_key.uv = false;
    let boss = rig.login(&mut boss_key).await;
    let refused = rig
        .send("POST", &format!("/auth/machine/requests/{}/approve", asked.body["id"].as_str().unwrap()), None, Some(&boss), Some(serde_json::json!({ "code": asked.body["code"] })))
        .await;
    assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
    assert_eq!(refused.body["code"], crate::auth::STEP_UP_CODE, "{}", refused.text);
}

impl Rig {
    /// The web console's answer to `"code":"stepup"`: the core's options, the
    /// person's touch, the finish — with the session it already has.
    async fn step_up(&self, cookie: &str, yubikey: &mut Yubikey) -> Answer {
        let options = self.send("GET", "/auth/passkey/step-up", None, Some(cookie), None).await;
        assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
        assert_eq!(options.body["publicKey"]["userVerification"], "required");
        let body = yubikey.answer(&options.body);
        self.send("POST", "/auth/passkey/step-up", None, Some(cookie), Some(body)).await
    }
}

/// ★ **A stale touch is answered in place** (owner 2026-10-19): `"code":
/// "stepup"`, a touch of the person's OWN key at `/auth/passkey/step-up`
/// steps the SAME session up at the core, and the refused call then goes
/// through. Another person's key steps nobody up; nobody signed in is told to
/// log in.
#[tokio::test]
async fn a_stale_touch_is_stepped_up_in_place_and_the_call_goes_through() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let mut anna_key = Yubikey::new(0x41, "annas-yubikey");
    fake.holds_passkey("anna", &anna_key);
    let rig = console(fake).await;
    boss_key.uv = false;
    let stale = rig.login(&mut boss_key).await;
    let invite = || Some(serde_json::json!({ "user": "kim", "role": "operator" }));

    let refused = rig.send("POST", "/auth/passkey/invites", None, Some(&stale), invite()).await;
    assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
    assert_eq!(refused.body["code"], crate::auth::STEP_UP_CODE, "machine-readable: {}", refused.text);
    assert!(!refused.text.to_ascii_lowercase().contains("role"), "{}", refused.text);

    let wrong = rig.step_up(&stale, &mut anna_key).await;
    assert!(!wrong.status.is_success(), "another person's key steps boss up: {}", wrong.text);

    let nobody = rig.send("GET", "/auth/passkey/step-up", None, None, None).await;
    assert_eq!(nobody.status, http::StatusCode::UNAUTHORIZED, "{}", nobody.text);

    boss_key.uv = true;
    let up = rig.step_up(&stale, &mut boss_key).await;
    assert_eq!(up.status, http::StatusCode::OK, "{}", up.text);
    assert_eq!(up.body["user"], "boss", "{}", up.text);
    let minted = rig.send("POST", "/auth/passkey/invites", None, Some(&stale), invite()).await;
    assert_eq!(minted.status, http::StatusCode::OK, "the same session, stepped up: {}", minted.text);
    rig.console_keeps_no_logins();
}

/// ★ **RA5-2 (nornir-identity 0.2.2): every invite needs a fresh step-up at the core.** The
/// core refuses `CreateInvite` with `needs_step_up` when the session's touch is no longer
/// fresh by ITS clock, even where the console's own check let the call through. The console
/// answers it as the in-place step-up (`"code":"stepup"`, which the web answers with a touch
/// and the same call once more), and a refused invite leaves nothing behind: no account made
/// for it, and no server grant for the person it named. After a touch of the inviter's own
/// key the same call mints the invite and grants the role.
#[tokio::test]
async fn an_invite_the_core_wants_a_fresh_step_up_for_asks_for_one_grants_nothing_then_works() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let rig = console(fake).await;
    let boss = rig.login(&mut boss_key).await;
    rig.fake.stale_at_core.store(true, Ordering::SeqCst);

    let for_anna = || Some(serde_json::json!({ "user": "anna", "role": "operator" }));
    let refused = rig.send("POST", "/auth/passkey/invites", None, Some(&boss), for_anna()).await;
    assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
    assert_eq!(refused.body["code"], crate::auth::STEP_UP_CODE, "the in-place step-up: {}", refused.text);
    assert!(rig.fake.heard().contains(&"Identity.CreateInvite by boss".to_owned()), "the core was asked: {:?}", rig.fake.heard());
    assert_eq!(lock(&rig.fake.server).get("anna").copied(), None, "a refused invite grants anna nothing");

    let for_kim = || Some(serde_json::json!({ "user": "kim", "role": "viewer" }));
    let refused = rig.send("POST", "/auth/passkey/invites", None, Some(&boss), for_kim()).await;
    assert_eq!(refused.body["code"], crate::auth::STEP_UP_CODE, "{}", refused.text);
    assert!(!lock(&rig.fake.principals).contains_key("kim"), "a refused invite leaves no account behind");

    let up = rig.step_up(&boss, &mut boss_key).await;
    assert_eq!(up.status, http::StatusCode::OK, "{}", up.text);
    let minted = rig.send("POST", "/auth/passkey/invites", None, Some(&boss), for_anna()).await;
    assert_eq!(minted.status, http::StatusCode::OK, "after the touch: {}", minted.text);
    assert_eq!(lock(&rig.fake.invite_requests).clone(), vec!["anna".to_owned()]);
    assert_eq!(lock(&rig.fake.server).get("anna").copied(), Some(pb::GrantLevel::Write as i32), "the operator role, now the invite exists");
    rig.console_keeps_no_logins();
}

/// ★ **The break-glass bearer approves nothing**: it is the OWNER's first
/// login link. The console turns it away, relays nothing, and the bearer stays
/// the owner's.
#[tokio::test]
async fn the_break_glass_bearer_approves_nothing_it_is_the_owners_first_invite() {
    let rig = console(Fake::new()).await;
    let mut kit = Yubikey::new(0x51, "kit-fresh-key");
    let asked = rig.kit_asks(&mut kit, "kit-fresh", "admin").await;
    let (id, code) = (asked.body["id"].as_str().expect("id").to_owned(), asked.body["code"].as_str().expect("code").to_owned());
    let refused = rig
        .send("POST", &format!("/auth/machine/requests/{id}/approve"), Some("break-glass"), None, Some(serde_json::json!({ "code": code })))
        .await;
    assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
    assert!(refused.text.contains("first invite"), "{}", refused.text);
    assert!(!rig.fake.heard().iter().any(|h| h.starts_with("Access.ApproveMachineEnrolment")), "{:?}", rig.fake.heard());
    let status = rig.send("GET", "/auth/status", None, None, None).await;
    assert_eq!(status.body["bootstrap_spent"], false, "{}", status.text);
}

/// ★ **A session bound to no principal lists nobody's passkeys** (stinky
/// 2026-10-10 #10).
#[tokio::test]
async fn a_session_bound_to_no_principal_lists_nobodys_passkeys() {
    let fake = Fake::new();
    let annas = Yubikey::new(0x46, "annas-yubikey");
    fake.holds_passkey("anna", &annas);
    let rig = console(fake).await;
    let olle = rig.webauthn.make_session("olle", crate::role::Role::Operator, true);
    let listed = rig.send("GET", "/auth/passkey/credentials", None, Some(&olle), None).await;
    assert!(!listed.status.is_success(), "{}", listed.text);
    assert!(!listed.text.contains("anna"), "{}", listed.text);
    assert!(!rig.fake.heard().iter().any(|c| c.starts_with("Access.ListCredentials")), "{:?}", rig.fake.heard());
}

// ───────────────────────────────────────────────────────────────────────────
// `gunnar login`: the CLI's browser device flow (5.4 §3b)
// ───────────────────────────────────────────────────────────────────────────

/// A key the CLI minted for itself: (private OpenSSH, public line).
fn cli_key() -> (String, String) {
    let private = crate::reviewer::mint_ed25519().expect("a key");
    let public = crate::signer::Signer::from_openssh(&private).expect("a signer").public_key_openssh();
    (private, public)
}

impl Rig {
    async fn device_start(&self, public: &str, ttl_secs: Option<u64>) -> (String, String) {
        let mut body = serde_json::json!({ "public_key": public, "label": "anna's laptop" });
        if let Some(ttl) = ttl_secs {
            body["ttl_secs"] = ttl.into();
        }
        let started = self.send("POST", "/auth/device/start", None, None, Some(body)).await;
        assert_eq!(started.status, http::StatusCode::OK, "{}", started.text);
        assert_eq!(started.body["verification_uri"], format!("{ORIGIN}/login/device"), "{}", started.text);
        let device = started.body["device_code"].as_str().expect("a device code").to_owned();
        let user = started.body["user_code"].as_str().expect("a user code").to_owned();
        assert!(device.len() >= 32, "{device}");
        assert_ne!(device, user);
        (device, user)
    }

    async fn device_poll(&self, device: &str) -> Answer {
        self.send("POST", "/auth/device/poll", None, None, Some(serde_json::json!({ "device_code": device }))).await
    }

    /// The `/login/device` page's `approve()`.
    async fn device_approve(&self, typed: &str, yubikey: &mut Yubikey) -> Answer {
        let options = self.send("GET", &format!("/auth/device/approve/start?code={typed}"), None, None, None).await;
        if options.status != http::StatusCode::OK {
            return options;
        }
        assert_eq!(options.body["publicKey"]["userVerification"], "required", "{}", options.text);
        assert_eq!(options.body["publicKey"]["allowCredentials"], serde_json::json!([]), "{}", options.text);
        let mut body = yubikey.answer(&options.body);
        body["code"] = typed.into();
        self.send("POST", "/auth/device/approve", None, None, Some(body)).await
    }
}

/// ★ **`gunnar login` is a passkey touch that adds the CLI's OWN key.** The
/// core verifies the person's touch; AS the person (their fresh session) the
/// console adds the CLI's public key to their principal with an expiry; that
/// session is ended; the CLI's poll is answered ONCE; the browser is not
/// logged in by it.
#[tokio::test]
async fn gunnar_login_is_a_passkey_touch_that_adds_the_clis_own_key() {
    let fake = Fake::new();
    let mut annas = Yubikey::new(0x61, "annas-yubikey");
    fake.holds_passkey("anna", &annas);
    let rig = console(fake).await;
    let (_private, public) = cli_key();
    let (device, user_code) = rig.device_start(&public, None).await;
    let pending = rig.device_poll(&device).await;
    assert_eq!(pending.body["state"], "pending", "{}", pending.text);
    let looked = rig.send("GET", &format!("/auth/device/lookup?code={}", user_code.to_lowercase().replace('-', "")), None, None, None).await;
    assert_eq!(looked.status, http::StatusCode::OK, "{}", looked.text);
    let fingerprint = openssh_fingerprint(&public).expect("a fingerprint");
    assert_eq!(looked.body["fingerprint"], fingerprint, "{}", looked.text);

    let approved = rig.device_approve(&user_code, &mut annas).await;
    assert_eq!(approved.status, http::StatusCode::OK, "{}", approved.text);
    assert_eq!(approved.body["user"], "anna", "{}", approved.text);
    assert!(approved.cookie.is_none(), "approving a CLI login does not log the browser in");
    assert!(rig.fake.heard().contains(&"Access.AddKey by anna".to_owned()), "AS anna: {:?}", rig.fake.heard());
    assert!(rig.fake.heard().contains(&"Identity.Logout by anna everywhere=false".to_owned()), "the approval's session is ended: {:?}", rig.fake.heard());
    let (principal, line, expires) = lock(&rig.fake.added_keys).last().cloned().expect("a key added");
    assert_eq!((principal.as_str(), line.as_str()), ("anna", public.as_str()), "the CLI's key, not one the console minted");
    let ttl_ms = expires.expect("an expiry") - crate::auth::now_secs() * 1000;
    assert!(ttl_ms > 3_600_000 && ttl_ms <= 24 * 3_600_000 + 5_000, "{ttl_ms} ms");

    let collected = rig.device_poll(&device).await;
    assert_eq!(collected.body["state"], "approved", "{}", collected.text);
    assert_eq!(collected.body["principal"], "anna", "{}", collected.text);
    assert_eq!(collected.body["fingerprint"], fingerprint, "{}", collected.text);
    let again = rig.device_poll(&device).await;
    assert_ne!(again.body["state"], "approved", "an approval is collected once: {}", again.text);
    let reused = rig.device_approve(&user_code, &mut annas).await;
    assert!(!reused.status.is_success(), "a code is approved once: {}", reused.text);
    assert_eq!(lock(&rig.fake.added_keys).len(), 1, "the key was added once");
}

/// **The CLI cannot ask for a key that outlives a day, or for no key at all.**
#[tokio::test]
async fn a_device_login_asks_for_a_usable_key_and_at_most_a_day() {
    let fake = Fake::new();
    let mut annas = Yubikey::new(0x62, "annas-yubikey");
    fake.holds_passkey("anna", &annas);
    let rig = console(fake).await;
    for junk in ["", "ssh-ed25519 AAAA", "not a key", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC7 rsa"] {
        let refused = rig.send("POST", "/auth/device/start", None, None, Some(serde_json::json!({ "public_key": junk }))).await;
        assert_eq!(refused.status, http::StatusCode::BAD_REQUEST, "{junk:?}: {}", refused.text);
    }
    let (_private, public) = cli_key();
    let (device, user_code) = rig.device_start(&public, Some(30 * 86_400)).await;
    let approved = rig.device_approve(&user_code, &mut annas).await;
    assert_eq!(approved.status, http::StatusCode::OK, "{}", approved.text);
    let (_, _, expires) = lock(&rig.fake.added_keys).last().cloned().expect("a key");
    let ttl_ms = expires.expect("an expiry") - crate::auth::now_secs() * 1000;
    assert!(ttl_ms <= 24 * 3_600_000 + 5_000, "thirty days asked, a day at most: {ttl_ms} ms");
    assert_eq!(rig.device_poll(&device).await.body["state"], "approved");
}

/// **A denied or mistyped code adds nothing.**
#[tokio::test]
async fn a_denied_or_mistyped_device_code_adds_nothing() {
    let fake = Fake::new();
    let mut annas = Yubikey::new(0x63, "annas-yubikey");
    fake.holds_passkey("anna", &annas);
    let rig = console(fake).await;
    let (_private, public) = cli_key();
    let (device, user_code) = rig.device_start(&public, None).await;
    let wrong = rig.device_approve("BCDF-GHJK", &mut annas).await;
    assert_eq!(wrong.status, http::StatusCode::NOT_FOUND, "{}", wrong.text);
    let denied = rig.send("POST", "/auth/device/deny", None, None, Some(serde_json::json!({ "code": user_code }))).await;
    assert_eq!(denied.status, http::StatusCode::OK, "{}", denied.text);
    assert_eq!(rig.device_poll(&device).await.body["state"], "denied");
    let late = rig.device_approve(&user_code, &mut annas).await;
    assert!(!late.status.is_success(), "{}", late.text);
    assert!(lock(&rig.fake.added_keys).is_empty(), "gunnar added nothing");
    let unknown = rig.device_poll("00".repeat(32).as_str()).await;
    assert_eq!(unknown.body["state"], "expired", "{}", unknown.text);
    let mut limited = false;
    for n in 0..80 {
        let guess = rig.send("GET", &format!("/auth/device/lookup?code=ZZZZ{n:04}"), None, None, None).await;
        if guess.status == http::StatusCode::TOO_MANY_REQUESTS {
            limited = true;
            break;
        }
        assert_eq!(guess.status, http::StatusCode::NOT_FOUND, "{}", guess.text);
    }
    assert!(limited, "guessing codes from one address runs out of budget");
}

/// **The device flow's doors are public, and the page carries its own script
/// under the console's CSP.**
#[tokio::test]
async fn the_device_flow_is_public_and_its_page_is_served() {
    for path in [
        "/login/device",
        "/auth/device/start",
        "/auth/device/poll",
        "/auth/device/lookup",
        "/auth/device/approve/start",
        "/auth/device/approve",
        "/auth/device/deny",
    ] {
        assert!(crate::auth::middleware::is_public(path), "{path} must be reachable without a session");
    }
    let rig = console(Fake::new()).await;
    let page = rig.send("GET", "/login/device", None, None, None).await;
    assert_eq!(page.status, http::StatusCode::OK, "{}", page.text);
    assert!(page.text.contains("/auth/device/approve"));
    let html = crate::auth::device::device_html();
    assert_eq!(page.text, html);
    assert!(!crate::security::inline_script_hashes(html).is_empty());
}

/// ★ The first touch after paying, in a real browser (A29-20).
#[cfg(feature = "metered")]
pub(crate) mod first_touch;

/// ★ **The person's encryption key is relayed AS THE PERSON** (CRYPT-YUBI):
/// gunnar hears every call carried by anna's session, the first method
/// makes the key, more methods join it, the last is refused by gunnar's own
/// words, and a body that names somebody else is refused before anything is
/// signed. The console keeps nothing.
#[tokio::test]
async fn the_encryption_key_and_its_methods_are_relayed_as_the_person() {
    let fake = Fake::new();
    let mut anna = Yubikey::new(0x41, "annas-yubikey");
    fake.holds_passkey("anna", &anna);
    let rig = console(fake).await;
    let cookie = rig.login(&mut anna).await;
    let wrap = "-----BEGIN AGE ENCRYPTED FILE-----\nYWdl\n-----END AGE ENCRYPTED FILE-----\n";
    let sk = serde_json::json!({ "kind": "security-key", "label": "YubiKey", "method_ref": "AQID", "rp_id": "console.example", "wrap": wrap });

    let none = rig.send("GET", "/api/account/encryption-key", None, Some(&cookie), None).await;
    assert_eq!(none.status, http::StatusCode::OK, "{}", none.text);
    assert_eq!(none.body["exists"], false, "{}", none.text);

    let made = rig
        .send("POST", "/api/account/encryption-key", None, Some(&cookie), Some(serde_json::json!({ "public_key": "age1example", "first": sk })))
        .await;
    assert_eq!(made.status, http::StatusCode::OK, "{}", made.text);
    assert_eq!(made.body["methods"].as_array().map(Vec::len), Some(1), "{}", made.text);
    assert!(rig.fake.heard().contains(&"Access.CreateEncryptionKey by anna".to_owned()), "{:?}", rig.fake.heard());
    let again = rig
        .send("POST", "/api/account/encryption-key", None, Some(&cookie), Some(serde_json::json!({ "public_key": "age1other", "first": sk })))
        .await;
    assert_eq!(again.status, http::StatusCode::CONFLICT, "gunnar's refusal of a second key: {}", again.text);

    let code = serde_json::json!({ "kind": "recovery-code", "label": "printed", "wrap": wrap.replace("YWdl", "c2NyeXB0") });
    let two = rig.send("POST", "/api/account/encryption-key/methods", None, Some(&cookie), Some(code)).await;
    assert_eq!(two.status, http::StatusCode::OK, "{}", two.text);
    let ids: Vec<String> = two.body["methods"].as_array().unwrap().iter().map(|m| m["id"].as_str().unwrap().to_owned()).collect();
    assert_eq!(ids.len(), 2, "{}", two.text);
    assert!(rig.fake.heard().contains(&"Access.AddUnlockMethod by anna".to_owned()));

    let one = rig.send("DELETE", &format!("/api/account/encryption-key/methods/{}", ids[0]), None, Some(&cookie), None).await;
    assert_eq!(one.status, http::StatusCode::OK, "{}", one.text);
    assert_eq!(one.body["methods"].as_array().map(Vec::len), Some(1));
    let last = rig.send("DELETE", &format!("/api/account/encryption-key/methods/{}", ids[1]), None, Some(&cookie), None).await;
    assert!(last.status.is_client_error(), "{}", last.text);
    assert!(last.text.contains("last unlock method"), "gunnar's own words: {}", last.text);

    let heard_before = rig.fake.heard().len();
    let named = serde_json::json!({ "kind": "recovery-code", "wrap": wrap, "user": "boss" });
    let refused = rig.send("POST", "/api/account/encryption-key/methods", None, Some(&cookie), Some(named)).await;
    assert_eq!(refused.status, http::StatusCode::BAD_REQUEST, "{}", refused.text);
    assert!(
        !rig.fake.heard()[heard_before..].iter().any(|h| h.starts_with("Access.AddUnlockMethod")),
        "a body naming somebody else reached gunnar: {:?}",
        rig.fake.heard()
    );
    let anonymous = rig.send("GET", "/api/account/encryption-key", None, None, None).await;
    assert!(anonymous.status.is_client_error(), "{}", anonymous.text);
}

/// ★ **GHUNT-19: a person approves in the browser; gunnar attests it for her
/// stepped-up session.** The console sends the approval UNSIGNED, as HER, from
/// her own core session — never with its own key in her name. With only a plain
/// touch gunnar answers `[stepup]`, which the console says as `"code":"stepup"`
/// (the web then asks her key for a fresh touch in place); after the touch the
/// same approval is taken, hers, from a stepped-up session.
///
/// The fake serves no `PullRequests`, so the read-back after the approval is not
/// this test's: what gunnar HEARD is (the uitest drives the whole page).
#[tokio::test]
async fn a_persons_web_approval_is_unsigned_from_her_session_and_a_plain_touch_is_the_step_up() {
    let fake = Fake::new();
    let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
    fake.holds_passkey("boss", &boss_key);
    let rig = console(fake).await;
    boss_key.uv = false;
    let boss = rig.login(&mut boss_key).await;
    boss_key.uv = true;
    let approve = serde_json::json!({ "commit": "c0ffee" });

    let refused = rig.send("POST", "/api/repos/acme/app/pulls/1/approve", None, Some(&boss), Some(approve.clone())).await;
    assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
    assert_eq!(refused.body["code"], "stepup", "a plain touch is answered with the step-up: {}", refused.text);
    assert!(lock(&rig.fake.forge_events).is_empty(), "refused and taken anyway");

    let options = rig.send("GET", "/auth/passkey/step-up", None, Some(&boss), None).await;
    assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
    let touch = boss_key.answer(&options.body);
    let stepped = rig.send("POST", "/auth/passkey/step-up", None, Some(&boss), Some(touch)).await;
    assert_eq!(stepped.status, http::StatusCode::OK, "{}", stepped.text);

    let _ = rig.send("POST", "/api/repos/acme/app/pulls/1/approve", None, Some(&boss), Some(approve)).await;
    let taken = lock(&rig.fake.forge_events).clone();
    assert_eq!(taken.len(), 1, "the stepped-up approval reaches gunnar once: {:?}", rig.fake.heard());
    let (who, stepped_up, signed) = &taken[0];
    assert_eq!(who, "boss", "sent AS her, from her own session");
    assert!(*stepped_up, "from her stepped-up session");
    assert!(
        signed.key_fingerprint.is_empty() && signed.signature.is_empty(),
        "the console must not sign her approval with its own key: {}",
        signed.key_fingerprint
    );
    let event = signed.event.as_ref().expect("an event");
    assert_eq!((event.actor.as_str(), event.pull), ("boss", 1));
    assert!(matches!(&event.kind, Some(pb::forge_event::Kind::Approved(a)) if a.commit == "c0ffee"));
}