//! **A machine asks; the admin approves with a touch of HER key; gunnar
//! decides** (2026-10-05) — the ops kit's console credential without the
//! owner's copied session cookie.
//!
//! The kit is a browser context of its own with its own virtual authenticator:
//! it opens the creation ceremony anonymously, asks gunnar for an ADMIN
//! credential under the machine name `kit-uitest`, and gets a code. bob — the
//! admin, in his own session — sees "Pending: … requests admin — code …" on
//! Administration → Identities and presses Approve: HIS virtual authenticator
//! answers the user-verified step-up (the soft key), the console relays it,
//! gunnar verifies it and makes the machine. The row is gone, and the kit logs
//! in with ITS key and reads as `kit-uitest`, admin. A refused request, and an
//! approval that names another code, enrol nothing. (A touch steps the admin's
//! identity-core session up for gunnar's window, 10 minutes: an approval inside it
//! needs no second touch, so "no touch" is not a state the rig's admin can be in.)
//!
//! Every assertion is the server's word (`/auth/machine/enrol/<id>`,
//! `/auth/status`) or the authenticator's, never the page's claim.
use crate::cdp::Cdp;
use crate::http;
use crate::json::{esc, Json};
use crate::matrix::{go, want, Row};
use crate::rig::Rig;
const MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";
/// The kit's request, as `estate console enrol --request` sends it — a real
/// creation ceremony by the kit context's own authenticator.
fn ask(c: &mut Cdp, kit: &str, machine: &str, role: &str) -> Result<Json, String> {
c.eval(
kit,
&format!(
"(async () => {{
const b2u = s => {{ s = s.replace(/-/g,'+').replace(/_/g,'/'); while (s.length % 4) s += '=';
return Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer; }};
const u2b = b => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
const st = await fetch('/auth/machine/enrol/start?machine=' + encodeURIComponent({m}));
if (!st.ok) return {{ status: st.status, text: await st.text() }};
const start = await st.json();
const pk = start.publicKey;
pk.challenge = b2u(pk.challenge); pk.user.id = b2u(pk.user.id); pk.excludeCredentials = [];
const cred = await navigator.credentials.create({{ publicKey: pk }});
const r = await fetch('/auth/machine/enrol', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ ceremony_id: start.ceremony_id, machine: {m}, label: {l}, role: {r},
response: {{ clientDataJSON: u2b(cred.response.clientDataJSON), attestationObject: u2b(cred.response.attestationObject) }} }}) }});
const text = await r.text();
let body = null; try {{ body = JSON.parse(text); }} catch (e) {{}}
return {{ status: r.status, text, body }};
}})()",
m = esc(machine),
l = esc(&format!("installer kit on {}", machine.trim_start_matches("kit-"))),
r = esc(role),
),
)
}
/// The request's state, as gunnar answers the kit's poll.
fn state_of(c: &mut Cdp, kit: &str, id: &str) -> Result<String, String> {
let j = c.eval(kit, &format!("fetch('/auth/machine/enrol/' + {}).then(r => r.json())", esc(id)))?;
Ok(j.text("state"))
}
pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, admin: &str) {
let mut kit = String::new();
let mut kit_auth = String::new();
let mut asked = (String::new(), String::new());
go(rows, "a-kit-asks-for-an-admin-credential-and-waits-with-a-code", || {
kit = c.isolated_page()?;
kit_auth = c.add_authenticator(&kit)?;
c.navigate(&kit, &format!("{}/login", r.origin()))?;
let j = ask(c, &kit, "kit-uitest", "admin")?;
want(j.path("status").and_then(|s| s.as_f64()) == Some(200.0), || format!("the kit's request: {}", http::cut(&j.dump(), 300)))?;
let (id, code) = (j.text("body.id"), j.text("body.code"));
want(j.text("body.state") == "pending", || format!("not pending: {}", http::cut(&j.dump(), 300)))?;
let b = code.as_bytes();
want(b.len() == 7 && b[3] == b'-' && b[..3].iter().all(u8::is_ascii_uppercase) && b[4..].iter().all(u8::is_ascii_digit), || format!("a code is ABC-123: {code:?}"))?;
let creds = c.credentials(&kit, &kit_auth)?;
want(creds.len() == 1, || format!("the kit's authenticator holds {} credentials", creds.len()))?;
// Pending is nobody: gunnar does not offer the kit's key at a login —
// not even at one named for the machine (the security-key path).
let offered = c.eval(&kit, "fetch('/auth/passkey/login/start?user=kit-uitest').then(r => r.json()).then(j => (j.publicKey.allowCredentials || []).map(c => c.id))")?;
let kit_id = creds[0].text("credentialId");
want(!offered.dump().contains(&kit_id.replace('+', "-").replace('/', "_").trim_end_matches('=').to_owned()), || format!("a PENDING credential is offered at login: {}", http::cut(&offered.dump(), 300)))?;
want(state_of(c, &kit, &id)? == "pending", || "the request is not pending".to_owned())?;
asked = (id, code.clone());
Ok(format!("kit-uitest asks admin — code {code}; gunnar offers its key to no login yet"))
});
let (id, code) = asked.clone();
let row = format!("document.querySelector('.machine-request[data-request={}]')", esc(&id));
go(rows, "the-admin-sees-pending-label-role-and-code-on-identities", || {
want(!id.is_empty(), || "no request (the row before failed)".to_owned())?;
c.navigate(admin, &format!("{}/access", r.origin()))?;
c.wait_for(admin, &format!("!!{row}"), 30_000)
.map_err(|e| format!("no pending row on Identities ({e}): {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
let line = c.eval_str(admin, &format!("{row}.querySelector('.machine-request-line').textContent"))?;
let want_line = format!("Pending: installer kit on uitest requests admin — code {code}");
want(line == want_line, || format!("the row reads {line:?}, not {want_line:?}"))?;
Ok(line)
});
go(rows, "approve-is-the-admins-own-touch-and-gunnar-makes-the-machine", || {
want(!id.is_empty(), || "no request (the rows before failed)".to_owned())?;
c.eval(admin, &format!("(() => {{ {row}.querySelector('button.approve').click(); return 1; }})()"))?;
c.wait_for(admin, &format!("!{row}"), 30_000).map_err(|e| {
format!("the row is still there ({e}): {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 400))
})?;
let state = state_of(c, &kit, &id)?;
want(state == "approved", || format!("gunnar says the request is {state:?}"))?;
let j = c.eval(&kit, &format!("fetch('/auth/machine/enrol/' + {}).then(r => r.json())", esc(&id)))?;
want(j.text("decided_by") == "bob", || format!("approved by {:?}, not bob: {}", j.text("decided_by"), http::cut(&j.dump(), 200)))?;
Ok("the row is gone; gunnar: approved by bob".into())
});
go(rows, "the-kit-logs-in-with-its-own-key-and-whoami-is-kit-uitest-admin", || {
c.navigate(&kit, &format!("{}/login", r.origin()))?;
// The ONE button, naming nobody (owner 2026-10-07, (g)): the kit's key
// was registered discoverable (`residentKey: required`), so the
// authenticator answers the anonymous ceremony with it.
c.eval(&kit, "document.getElementById('login').click()")?;
c.wait_for(&kit, "fetch('/auth/status').then(r => r.json()).then(j => j.authenticated === true || !!document.getElementById('msg')?.classList.contains('err'))", 30_000)?;
let st = c.eval(&kit, "fetch('/auth/status').then(r => r.json())")?;
want(st.path("authenticated").and_then(|v| v.as_bool()) == Some(true), || format!("the kit is not logged in: {}", http::cut(&st.dump(), 200)))?;
want(st.text("user") == "kit-uitest" && st.text("role") == "admin", || format!("the kit reads as {:?} {:?}", st.text("user"), st.text("role")))?;
// gunnar lists the kit's credential on its machine, never on bob.
let list = c.eval(admin, "fetch('/auth/passkey/credentials').then(r => r.json())")?;
let owners: Vec<String> = list.path("credentials").map(|a| a.arr().iter().map(|x| x.text("user")).collect()).unwrap_or_default();
want(owners.iter().any(|o| o == "kit-uitest"), || format!("no credential on kit-uitest in {}", http::cut(&list.dump(), 300)))?;
Ok(format!("{} as {}; the credential is the machine's", st.text("user"), st.text("role")))
});
go(rows, "a-refused-request-and-an-approval-with-the-wrong-code-enrol-nothing", || {
let other = c.isolated_page()?;
c.add_authenticator(&other)?;
c.navigate(&other, &format!("{}/login", r.origin()))?;
let j = ask(c, &other, "kit-refused", "operator")?;
let id2 = j.text("body.id");
want(!id2.is_empty(), || format!("no second request: {}", http::cut(&j.dump(), 300)))?;
want(!code.is_empty() && code != j.text("body.code"), || "no other code to name (the rows before failed)".to_owned())?;
// The code is the machine's proof that the admin approves THIS request: another
// request's code (the kit's, decided already) is refused by gunnar, and nothing moves.
let bare = c.eval(
admin,
&format!(
"fetch('/auth/machine/requests/' + {} + '/approve', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, body: JSON.stringify({{ code: {} }}) }}).then(r => r.status)",
esc(&id2),
esc(&code)
),
)?;
want(bare.as_f64().is_some_and(|s| s >= 400.0), || format!("an approval with the wrong code answered {}", bare.dump()))?;
want(state_of(c, &other, &id2)? == "pending", || "an approval with the wrong code moved the request".to_owned())?;
let row2 = format!("document.querySelector('.machine-request[data-request={}]')", esc(&id2));
c.navigate(admin, &format!("{}/access", r.origin()))?;
c.wait_for(admin, &format!("!!{row2}"), 30_000)?;
c.eval(admin, &format!("(() => {{ {row2}.querySelector('button.refuse').click(); return 1; }})()"))?;
c.wait_for(admin, &format!("!{row2}"), 30_000)?;
let state = state_of(c, &other, &id2)?;
want(state == "refused", || format!("gunnar says {state:?}"))?;
let users = r.control(&["user", "list"])?;
want(!users.contains("kit-refused"), || format!("a refused request made a principal: {}", http::cut(&users, 200)))?;
Ok(format!("the wrong code: {} and still pending; refused: no principal", bare.dump()))
});
// The admin lists the kit's credential and revokes it — and it leaves the
// box with one admin passkey again, as the rows after these expect.
go(rows, "the-admin-revokes-the-kits-credential-and-the-kit-is-out", || {
want(!kit.is_empty(), || "no kit (the rows before failed)".to_owned())?;
let list = c.eval(admin, "fetch('/auth/passkey/credentials').then(r => r.json())")?;
let id = list
.path("credentials")
.and_then(|a| a.arr().iter().find(|x| x.text("user") == "kit-uitest").map(|x| x.text("id")))
.ok_or_else(|| format!("gunnar lists no credential on kit-uitest: {}", http::cut(&list.dump(), 300)))?;
let st = c.eval(
admin,
&format!(
"fetch('/auth/passkey/credentials/revoke', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, body: JSON.stringify({{ id: {} }}) }}).then(async r => ({{ status: r.status, text: await r.text() }}))",
esc(&id)
),
)?;
want(st.path("status").and_then(|s| s.as_f64()) == Some(200.0), || format!("revoking the kit's credential: {}", http::cut(&st.dump(), 300)))?;
let after = c.eval(&kit, "fetch('/auth/status').then(r => r.json())")?;
want(after.path("authenticated").and_then(|v| v.as_bool()) != Some(true), || format!("the kit is still logged in after its credential was revoked: {}", http::cut(&after.dump(), 200)))?;
let list = c.eval(admin, "fetch('/auth/passkey/credentials').then(r => r.json())")?;
let left = list.path("credentials").map(|a| a.arr().iter().any(|x| x.text("user") == "kit-uitest")).unwrap_or(true);
want(!left, || format!("gunnar still lists the kit's credential: {}", http::cut(&list.dump(), 300)))?;
Ok("revoked by bob at gunnar; the kit's session ended".into())
});
}