//! **Members never buy** (owner, 2026-10-07: a customer is an ACCOUNT that
//! pays; MEMBERS — the users an account's admin enrols — use the account's
//! space, never reach the shop, and ask the account's admin for more).
//!
//! The members walkthrough of 2026-10-07, turned into rows. Three people:
//!
//! * `acme` — the account. Its admin logs in AS `acme`: on Gunnar the account
//! IS its admin's principal.
//! * `ben` — a member `acme` enrols from `/acme` → Add users, who signs in
//! through his invite with his own soft-authenticator passkey.
//! * `kim` — the matrix's second person: an individual with an account of her
//! own and no members.
//!
//! What the walkthrough measured, and what each row now holds: ben saw the
//! same You ▾ pages as an account admin — "More room", "Grow your account",
//! "Buy a private Gunnar" — and a Storage page about a namespace `ben` that
//! does not exist; he could open a real checkout for tenant `ben` and pay for
//! room that lands nowhere; acme's admin had no Settings tab and no way to give
//! ben Write; acme's audit log named the rig's box operator, "alice
//! (operator)", for the users acme bought.
//!
//! Pictures go to `$MEMBERS_OUT` (default `<work>/members`); secrets (invite
//! links, checkout ids) never reach a row's text.
use std::path::PathBuf;
use std::time::Duration;
use crate::cdp::Cdp;
use crate::http;
use crate::json::esc;
use crate::matrix::{go, want, Row};
use crate::rig::Rig;
const ACCOUNT: &str = "acme";
const EMPLOYEE: &str = "ben";
const REPO: &str = "handbook";
const MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";
const HEADINGS: &str = "[...document.querySelectorAll('main h1, main h2, main h3, main h4')].map(h => h.textContent.trim()).filter(Boolean).join(' | ')";
/// The line a member reads where the shop would be.
const LINE: &str = "Your space comes from acme. Ask acme for more room.";
/// Invite secrets and checkout ids never reach a row.
fn scrub(s: &str) -> String {
let mut out = s.to_owned();
for marker in ["invite=", "cs_test_", "cs_live_", "secret\":\""] {
let mut at = 0;
while let Some(i) = out[at..].find(marker) {
let start = at + i + marker.len();
let end = out[start..]
.find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
.map(|e| start + e)
.unwrap_or(out.len());
out.replace_range(start..end, "…");
at = start + "…".len();
}
}
out.replace('\n', " ⏎ ")
}
fn type_into(c: &mut Cdp, sess: &str, id: &str, value: &str) -> Result<(), String> {
let done = c.eval(
sess,
&format!(
"(() => {{ const i = document.getElementById({}); if (!i) return false;
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
set.call(i, {}); i.dispatchEvent(new Event('input', {{bubbles: true}})); return true; }})()",
esc(id),
esc(value)
),
)?;
want(done.as_bool() == Some(true), || format!("no #{id} on the page"))
}
/// One request made by the page's own session: status and the body's start.
fn probe(c: &mut Cdp, sess: &str, method: &str, path: &str, body: Option<&str>) -> Result<(u64, String), String> {
let init = match body {
None => format!("{{method: {}}}", esc(method)),
Some(b) => format!(
"{{method: {}, headers: {{'Content-Type': 'application/json'}}, body: {}}}",
esc(method),
esc(b)
),
};
let j = c.eval(
sess,
&format!(
"fetch({}, {init}).then(async r => ({{status: r.status, body: (await r.text()).slice(0, 900)}}))",
esc(path)
),
)?;
let status = j.path("status").and_then(|v| v.as_f64()).unwrap_or(0.0) as u64;
Ok((status, j.text("body")))
}
fn settle(c: &mut Cdp, sess: &str) {
let _ = c.wait_for(
sess,
"!!document.querySelector('main, body') && !document.querySelector('[aria-busy=\"true\"]')",
15_000,
);
std::thread::sleep(Duration::from_millis(1500));
}
fn visit(c: &mut Cdp, sess: &str, url: &str) -> Result<(), String> {
c.navigate(sess, url)?;
settle(c, sess);
Ok(())
}
/// The whole page, grown to its height (at most 2600 px), 1440 wide.
fn shot(c: &mut Cdp, sess: &str, dir: &std::path::Path, name: &str) -> Result<(), String> {
let _ = std::fs::create_dir_all(dir);
let h = c
.eval(sess, "Math.min(2600, Math.max(900, document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0))")?
.as_f64()
.unwrap_or(900.0) as u32;
c.viewport(sess, 1440, h)?;
std::thread::sleep(Duration::from_millis(500));
c.screenshot(sess, &dir.join(format!("{name}.png")))?;
c.viewport(sess, 1440, 900)?;
Ok(())
}
/// `/auth/status` as `sess` sees it.
fn status(c: &mut Cdp, sess: &str) -> Result<crate::json::Json, String> {
c.eval(sess, "fetch('/auth/status').then(r => r.json())")
}
/// A passkey session for `user`, from an invite link `url`.
fn sign_in_through(c: &mut Cdp, o: &str, url: &str, label: &str, user: &str) -> Result<String, String> {
let sess = c.isolated_page()?;
let auth = c.add_authenticator(&sess)?;
// A link's registration signs in at once where the press is still live (owner
// 2026-10-21); otherwise "Log in" is the way on.
let signed_in = crate::matrix::register_through_link(c, &sess, url, label).map_err(|e| format!("{user}'s registration: {e}"))?;
want(c.credentials(&sess, &auth)?.len() == 1, || format!("{user}'s authenticator holds no key"))?;
if !signed_in {
c.navigate(&sess, &format!("{o}/login"))?;
c.wait_for(&sess, "!!document.getElementById('login')", 30_000)?;
c.eval(&sess, "document.getElementById('login').click()")?;
c.wait_for(&sess, "fetch('/auth/status').then(r => r.json()).then(j => j.authenticated === true)", 30_000)?;
}
let st = status(c, &sess)?;
want(st.text("user") == user, || format!("logged in as {:?}, not {user}", st.text("user")))?;
Ok(sess)
}
/// A commit of `bytes` random bytes in `wt`, named `what`.
fn commit_noise(r: &Rig, wt: &std::path::Path, what: &str, bytes: usize) -> Result<(), String> {
let mut noise = vec![0u8; bytes];
{
use std::io::Read as _;
std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut noise)).map_err(|e| e.to_string())?;
}
std::fs::write(wt.join(format!("{what}.bin")), &noise).map_err(|e| e.to_string())?;
r.git(wt, &["add", &format!("{what}.bin")])?;
r.git(wt, &["commit", "-qm", what])?;
Ok(())
}
pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, boxadmin: &str, kim: &str) {
let out: PathBuf = std::env::var("MEMBERS_OUT").map(PathBuf::from).unwrap_or_else(|_| r.work.join("members"));
let o = r.origin();
let mut admin = String::new();
let mut ben = String::new();
let ben_key = r.work.join("ben-key");
let wt = r.work.join("members-wt");
// ── the account: its admin's invite, her passkey, her session ──
go(rows, "members-the-account-admin-signs-in-as-acme", || {
let j = c.eval(
boxadmin,
&format!(
"fetch('/auth/passkey/invites', {{method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{user:{}, role:'viewer'}})}}).then(r => r.json())",
esc(ACCOUNT)
),
)?;
let url = j.text("url");
want(url.contains("/login?invite="), || format!("no invite for acme: {}", scrub(&http::cut(&j.dump(), 200))))?;
admin = sign_in_through(c, &o, &url, "anna-laptop", ACCOUNT)?;
Ok("acme signed in".to_owned())
});
// ── a private repository of the account, with something in it ──
go(rows, "members-acme-makes-a-private-repository-and-it-is-pushed", || {
want(!admin.is_empty(), || "no acme session".to_owned())?;
let made = c.eval(
&admin,
&format!(
"fetch('/api/repos', {{method: 'POST', headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{namespace: {}, name: {}, hash_kind: 'sha256', visibility: 'private', description: 'acme handbook'}})}}).then(r => r.json())",
esc(ACCOUNT),
esc(REPO)
),
)?;
want(made.text("path") == format!("{ACCOUNT}/{REPO}"), || format!("acme could not make {REPO}: {}", http::cut(&made.dump(), 300)))?;
let _ = std::fs::remove_dir_all(&wt);
std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
r.git(&wt, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
std::fs::write(wt.join("README.md"), "# acme handbook\n\nHow acme works.\n").map_err(|e| e.to_string())?;
r.git(&wt, &["add", "README.md"])?;
r.git(&wt, &["commit", "-qm", "first"])?;
r.git(&wt, &["push", "-q", &r.remote(&format!("{ACCOUNT}/{REPO}")), "main"])?;
Ok(format!("{ACCOUNT}/{REPO} made by acme, private, pushed"))
});
// ── the member: Add users on /acme, his invite, his own passkey ──
go(rows, "members-acme-enrols-ben-from-add-users", || {
want(!admin.is_empty(), || "no acme session".to_owned())?;
visit(c, &admin, &format!("{o}/{ACCOUNT}"))?;
c.wait_for(&admin, "!!document.querySelector('#add-users #users-add')", 30_000)
.map_err(|_| format!("no Add users on /acme for acme: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
type_into(c, &admin, "users-count", "1")?;
type_into(c, &admin, "users-room", "10")?;
type_into(c, &admin, "users-name", EMPLOYEE)?;
c.eval(&admin, "(() => { document.getElementById('users-add').click(); return 1; })()")?;
c.wait_for(&admin, "!!document.getElementById('users-added') || !!document.querySelector('#add-users .fail, .fail-box, .failure')", 30_000)?;
let said = c.eval_str(&admin, "(document.getElementById('users-added') || {textContent: ''}).textContent")?;
want(said.contains(&format!("{EMPLOYEE} is added")), || format!("the page says {said:?}: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
Ok(said)
});
go(rows, "members-ben-signs-in-through-his-invite-with-his-own-passkey", || {
let invite = c.eval_str(&admin, "(document.getElementById('employee-invite') || {textContent: ''}).textContent")?;
want(invite.contains("invite="), || "no invite link for ben on acme's page".to_owned())?;
ben = sign_in_through(c, &o, &invite, "ben-laptop", EMPLOYEE)?;
Ok("ben signed in".to_owned())
});
// ── (b) Gunnar says whose space it is; the shop's doors are not his ──
go(rows, "members-the-employee-is-told-whose-space-it-is", || {
let st = status(c, &ben)?;
let said = scrub(&http::cut(&st.dump(), 400));
want(st.text("account") == ACCOUNT && st.text("account_role") == "employee", || format!("/auth/status does not say ben is acme's member: {said}"))?;
want(st.text("account_line") == LINE, || format!("no line naming whom to ask: {said}"))?;
want(st.text("buy_url").is_empty() && st.text("private_url").is_empty(), || format!("a member is given the shop's doors: {said}"))?;
Ok(said)
});
// ── (c) /account/storage: no shop — a member is sent on to Account,
// where the ACCOUNT's room is, read only (owner 2026-10-17) ──
go(rows, "members-the-employee-storage-is-the-accounts-room-and-no-shop", || {
visit(c, &ben, &format!("{o}/account/storage"))?;
c.wait_for(&ben, "location.pathname === '/account'", 15_000).map_err(|e| format!("a member's /account/storage did not go on to Account: {e}"))?;
let _ = c.wait_for(&ben, "!!document.querySelector('main .metrics, main .failure, main .fail-box')", 15_000);
let text = c.eval_str(&ben, MAIN_TEXT)?;
let heads = c.eval_str(&ben, HEADINGS)?;
let doors = c.eval_str(&ben, "String(document.querySelectorAll('#account-buy-grow').length)")?;
let line = c.eval_str(&ben, "(document.getElementById('member-line') || {textContent: ''}).textContent")?;
shot(c, &ben, &out, "employee-storage")?;
want(!heads.contains("More room") && doors == "0", || format!("the shop is drawn for a member: headings {heads:?}, {doors} door(s)"))?;
want(line == LINE, || format!("no member line, the page says {:?}", http::cut(&text, 300)))?;
want(!text.contains("not in the catalog") && heads.contains("acme's room"), || format!("not acme's room: headings {heads:?}: {}", http::cut(&text, 300)))?;
Ok(format!("{heads} · {line}"))
});
go(rows, "members-the-employee-account-page-is-the-accounts-space", || {
visit(c, &ben, &format!("{o}/account"))?;
let _ = c.wait_for(&ben, "!!document.getElementById('member-line')", 10_000);
let heads = c.eval_str(&ben, HEADINGS)?;
let line = c.eval_str(&ben, "(document.getElementById('member-line') || {textContent: ''}).textContent")?;
let audit = c.eval_str(&ben, "String(!!document.getElementById('account-audit-log'))")?;
shot(c, &ben, &out, "employee-account")?;
want(!heads.contains("Plan") && !heads.contains("Subscription"), || format!("a member is shown a plan of his own: {heads:?}"))?;
want(line == LINE, || format!("no member line on Account: {heads:?}"))?;
want(heads.contains("acme's room"), || format!("the account's room is not shown: {heads:?}"))?;
want(audit == "false", || "the account admin's audit log is offered to a member".to_owned())?;
Ok(heads)
});
// ── (a)+(c) pressing the shop anyway: refused, nothing to pay ──
go(rows, "members-the-employee-cannot-buy", || {
want(r.bench.is_some(), || "NOT RUN: no --bench-monetize (a community console sells nothing: the sign-up page, the Plan panel and the shop are a metered console's)".into())?;
let (s, b) = probe(c, &ben, "POST", "/api/account/subscription/buy-bundle", Some("{\"bundle\":\"10gb\"}"))?;
want(s == 403 && b.contains("Ask acme"), || format!("a member's order was not refused by name: {s} {}", scrub(&http::cut(&b, 300))))?;
want(!b.contains("checkout"), || format!("a checkout for a member: {}", scrub(&http::cut(&b, 300))))?;
let (ps, pb) = probe(
c,
&ben,
"POST",
"/auth/private/start",
Some("{\"company\":\"benco\",\"email\":\"ben@example.com\",\"front_plan\":\"\",\"main_plan\":\"\",\"main_data_gib\":0}"),
)?;
want(ps == 403 && pb.contains("Members don"), || format!("a member's private Gunnar was not refused: {ps} {}", scrub(&http::cut(&pb, 300))))?;
Ok(format!("buy-bundle {s} · private {ps}: {}", scrub(&http::cut(&b, 160))))
});
// ── (a) a member owns no namespace by his name ──
go(rows, "members-the-employee-is-offered-no-namespace-of-his-own", || {
let (s, b) = probe(c, &ben, "GET", "/api/repos/creatable", None)?;
want(s == 200 && !b.contains(&format!("\"name\":\"{EMPLOYEE}\"")), || format!("ben is offered a namespace `ben`: {s} {b}"))?;
Ok(b)
});
// ── round 5.3: no Clans for a member, and no import into `ben/…` ──
//
// The walkthrough's two leftovers: ben's You ▾ offered Clans (a "Make a
// clan" form gunnar refuses an enterprise account's people), and his
// Import read "Lands at ben/." — a namespace that does not exist. gunnar
// names where he may make a repository (`/api/repos/creatable`: nowhere,
// with Read on acme), so the console offers no Import and the import page
// says, in words, whom to ask; gunnar's word that he is a member hides Clans.
go(rows, "members-the-employee-has-no-clans-and-no-import-into-a-namespace-of-his-own", || {
visit(c, &ben, &format!("{o}/repos"))?;
let _ = c.wait_for(&ben, "document.querySelectorAll('header.topbar details.menu .menu-link').length > 0", 15_000);
let you = c.eval_str(&ben, "[...document.querySelectorAll('header.topbar details.menu:not(.admin-menu) .menu-link')].map(a => a.textContent.trim()).join(' | ')")?;
want(you.contains("Account") && !you.contains("Groups"), || format!("a member's You menu: {you}"))?;
// New repository and Import wait on the same answer; acme's admin's
// page draws both from it, so its absence here is the answer's.
let _ = c.wait_for(&ben, "!!document.getElementById('filter')", 10_000);
std::thread::sleep(Duration::from_millis(800));
let import = c.eval_str(&ben, "String([...document.querySelectorAll('a')].some(a => a.textContent.includes('Import repository')))")?;
want(import == "false", || "an Import button for a member gunnar names no namespace for".to_owned())?;
visit(c, &ben, &format!("{o}/mirrors/import"))?;
c.wait_for(&ben, "!!document.getElementById('import-nowhere')", 15_000)
.map_err(|_| format!("no words where ben may import nowhere: {}", http::cut(&c.eval_str(&ben, MAIN_TEXT).unwrap_or_default(), 300)))?;
let line = c.eval_str(&ben, "document.getElementById('import-nowhere').innerText")?;
let form = c.eval_str(&ben, "String(!(document.getElementById('import-forms') || {hidden: true}).hidden)")?;
shot(c, &ben, &out, "employee-import")?;
want(form == "false", || "an import form gunnar would refuse is drawn".to_owned())?;
want(line.contains("acme") && !line.contains("ben/"), || format!("the words do not name the account: {line:?}"))?;
// The admin keeps both, and her import lands in acme.
visit(c, &admin, &format!("{o}/mirrors/import"))?;
c.wait_for(&admin, "(document.getElementById('import-namespace') || {}).value === 'acme'", 15_000)
.map_err(|_| "acme's admin's import does not land in acme".to_owned())?;
let admin_you = c.eval_str(&admin, "[...document.querySelectorAll('header.topbar details.menu:not(.admin-menu) .menu-link')].map(a => a.textContent.trim()).join(' | ')")?;
want(admin_you.contains("Groups"), || format!("acme's admin lost Groups: {admin_you}"))?;
Ok(format!("ben: You {you}; import: {line}"))
});
// ── (f) the sign-up page says members don't buy, and names the admin ──
go(rows, "members-the-signup-page-tells-the-employee-he-buys-nothing", || {
want(r.bench.is_some(), || "NOT RUN: no --bench-monetize (a community console sells nothing: the sign-up page, the Plan panel and the shop are a metered console's)".into())?;
visit(c, &ben, &format!("{o}/signup?plan=10gb"))?;
let heading = c.eval_str(&ben, "document.getElementById('heading').textContent")?;
let msg = c.eval_str(&ben, "document.getElementById('msg').textContent")?;
let go_hidden = c.eval_str(&ben, "String(document.getElementById('go').hidden)")?;
shot(c, &ben, &out, "employee-signup")?;
want(heading == "You are a member of acme", || format!("heading {heading:?}"))?;
want(msg.contains("Members don’t buy") && msg.contains("Ask acme"), || format!("message {msg:?}"))?;
want(go_hidden == "true", || "a buy button is drawn for a member".to_owned())?;
Ok(msg)
});
// ── the account's admin and an individual keep the shop ──
go(rows, "members-the-admin-and-the-individual-keep-the-shop", || {
want(r.bench.is_some(), || "NOT RUN: no --bench-monetize (a community console sells nothing: the sign-up page, the Plan panel and the shop are a metered console's)".into())?;
let mut said = Vec::new();
for (who, sess, role) in [(ACCOUNT, admin.as_str(), "admin"), ("kim", kim, "individual")] {
let st = status(c, sess)?;
want(st.text("account_role") == role && !st.text("buy_url").is_empty(), || format!("{who}: {}", http::cut(&st.dump(), 300)))?;
said.push(format!("{who} {role} buy_url {}", st.text("buy_url")));
}
// The account's admin buys: Account's ONE button, and /account/storage
// goes on to the same sign-up page (owner 2026-10-17).
visit(c, &admin, &format!("{o}/account"))?;
c.wait_for(&admin, "!!document.getElementById('account-buy-grow')", 15_000).map_err(|e| format!("acme's admin has no button on Account: {e}"))?;
shot(c, &admin, &out, "admin-account")?;
visit(c, &admin, &format!("{o}/account/storage"))?;
c.wait_for(&admin, "location.pathname === '/signup' && ['Buy a plan', 'Grow your account'].includes(document.getElementById('heading')?.textContent)", 15_000)
.map_err(|e| format!("acme's admin's /account/storage did not go on to the sign-up page: {e}"))?;
Ok(said.join("; "))
});
// ── (e) the account's admin has the Settings tab of the account's repos ──
go(rows, "members-the-account-admin-sees-the-repo-settings-tab", || {
visit(c, &admin, &format!("{o}/repo/{ACCOUNT}/{REPO}"))?;
let _ = c.wait_for(&admin, "[...document.querySelectorAll('nav.repo-tabs a')].some(a => a.textContent === 'Settings')", 10_000);
let tabs = c.eval_str(&admin, "[...document.querySelectorAll('nav.repo-tabs a')].map(a => a.textContent).join(' | ')")?;
want(tabs.ends_with("Settings"), || format!("no Settings tab for acme's admin: {tabs}"))?;
visit(c, &ben, &format!("{o}/repo/{ACCOUNT}/{REPO}"))?;
let ben_tabs = c.eval_str(&ben, "[...document.querySelectorAll('nav.repo-tabs a')].map(a => a.textContent).join(' | ')")?;
want(!ben_tabs.contains("Settings"), || format!("a member is given Settings: {ben_tabs}"))?;
Ok(tabs)
});
// ── round 5.3.1: a member's publish, posted past the page, is refused ──
go(rows, "members-a-member-cannot-publish-and-the-repository-stays-private", || {
want(!ben.is_empty() && !admin.is_empty(), || "no ben or acme session".to_owned())?;
let (s, b) = probe(c, &ben, "PATCH", &format!("/api/repos/{ACCOUNT}/{REPO}"), Some("{\"visibility\":\"public\"}"))?;
want(s == 403, || format!("a member's publish was not refused: {s} {}", scrub(&http::cut(&b, 300))))?;
let (s2, after) = probe(c, &admin, "GET", &format!("/api/repos/{ACCOUNT}/{REPO}"), None)?;
want(s2 == 200 && after.contains("\"visibility\":\"private\""), || format!("{ACCOUNT}/{REPO} is not private after a member's publish: {s2} {}", http::cut(&after, 300)))?;
Ok(format!("403: {}", scrub(&http::cut(&b, 140))))
});
// ── (d) the admin gives ben Write; ben pushes; the admin takes it away ──
go(rows, "members-the-admin-grants-write-and-the-employee-can-push", || {
let _ = std::fs::remove_file(&ben_key);
let _ = std::fs::remove_file(ben_key.with_extension("pub"));
let made = std::process::Command::new("ssh-keygen")
.current_dir(&r.work)
.args(["-q", "-t", "ed25519", "-N", "", "-C", "ben", "-f"])
.arg(&ben_key)
.output()
.map_err(|e| format!("ssh-keygen: {e}"))?;
want(made.status.success(), || "ssh-keygen failed".to_owned())?;
let public = std::fs::read_to_string(ben_key.with_extension("pub")).map_err(|e| e.to_string())?;
let (s, b) = probe(c, &ben, "POST", "/api/account/keys", Some(&format!("{{\"openssh_public_key\":{}}}", esc(public.trim()))))?;
want(s == 200, || format!("ben's own key: {s} {}", http::cut(&b, 200)))?;
let remote = r.remote_as(EMPLOYEE, &format!("{ACCOUNT}/{REPO}"));
commit_noise(r, &wt, "ben-1", 1 << 20)?;
want(r.git_as(&ben_key, &wt, &["push", "-q", &remote, "main"]).is_err(), || "ben pushed with Read only".to_owned())?;
// The members panel, on the repository's Settings, as acme's admin.
visit(c, &admin, &format!("{o}/repo/{ACCOUNT}/{REPO}/settings"))?;
c.wait_for(&admin, "!!document.getElementById('member-ben-write')", 15_000)
.map_err(|_| format!("no Members panel with ben for acme's admin: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
let before = c.eval_str(&admin, "document.querySelector('#member-ben .member-holds').textContent")?;
want(before == "Read on all of acme", || format!("ben holds {before:?} before"))?;
c.eval(&admin, "(() => { document.getElementById('member-ben-write').click(); return 1; })()")?;
c.wait_for(&admin, "(document.querySelector('#member-ben .member-holds') || {}).textContent === 'Write on this repository'", 15_000)
.map_err(|_| format!("the grant did not land: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
shot(c, &admin, &out, "admin-members-panel")?;
r.git_as(&ben_key, &wt, &["push", "-q", &remote, "main"]).map_err(|e| format!("ben's push with Write: {}", http::cut(&e, 300)))?;
// Taken away: back to the account's Read, and the next push is refused.
c.eval(&admin, "(() => { document.getElementById('member-ben-take').click(); return 1; })()")?;
c.wait_for(&admin, "(document.querySelector('#member-ben .member-holds') || {}).textContent === 'Read on all of acme'", 15_000)
.map_err(|_| "taking Write away did not land".to_owned())?;
commit_noise(r, &wt, "ben-2", 1 << 10)?;
want(r.git_as(&ben_key, &wt, &["push", "-q", &remote, "main"]).is_err(), || "ben still pushes after Write was taken away".to_owned())?;
Ok("Read: refused · Write: pushed · taken away: refused".to_owned())
});
// ── (e) the audit log names the account's admin for the users it bought ──
go(rows, "members-the-audit-log-names-the-account-admin", || {
let (s, b) = probe(c, &admin, "GET", "/api/account/audit?kinds=member", None)?;
want(s == 200, || format!("acme's log: {s} {}", http::cut(&b, 200)))?;
visit(c, &admin, &format!("{o}/admin/audit"))?;
let rows_text = c.eval_str(&admin, "[...document.querySelectorAll('main tr')].map(t => t.innerText.replace(/\\t/g, ' · ')).join(' ⏎ ')")?;
shot(c, &admin, &out, "admin-audit-log")?;
let added = rows_text.split(" ⏎ ").find(|row| row.contains("member added")).unwrap_or_default().to_owned();
want(added.contains("acme (account admin)") && !added.contains("(operator)"), || format!("the member's row does not name acme's admin: {added:?}"))?;
Ok(added)
});
// ── round 5.3.1: the account's admin turns encryption on for an EXISTING repository ──
//
// README §5f: the account admin turns it on at the console. Settings → "Turn encryption
// on…", the confirmation, and gunnar refuses a plaintext push from then on; a member is
// offered no control; turned off again, so the rows after it push plaintext as before.
go(rows, "repo-settings-the-account-admin-turns-encryption-on", || {
want(!admin.is_empty() && !ben.is_empty(), || "no acme or ben session".to_owned())?;
visit(c, &ben, &format!("{o}/repo/{ACCOUNT}/{REPO}/settings"))?;
std::thread::sleep(Duration::from_millis(1500));
let offered = c.eval_str(&ben, "String(!!document.getElementById('enc-change'))")?;
want(offered == "false", || "a member is offered the encryption control".to_owned())?;
visit(c, &admin, &format!("{o}/repo/{ACCOUNT}/{REPO}/settings"))?;
c.wait_for(&admin, "!!document.getElementById('enc-change')", 15_000)
.map_err(|_| format!("no encryption control for acme's admin: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
c.eval(&admin, "(() => { document.getElementById('enc-change').click(); return 1; })()")?;
c.wait_for(&admin, "!!document.getElementById('enc-confirm')", 10_000).map_err(|_| "no confirmation before turning encryption on".to_owned())?;
let consequence = c.eval_str(&admin, "document.getElementById('enc-consequence').textContent")?;
want(consequence.contains("not encrypted") || consequence.contains("plaintext"), || format!("the confirmation does not say what changes: {consequence:?}"))?;
c.eval(&admin, "(() => { document.getElementById('enc-confirm').click(); return 1; })()")?;
c.wait_for(&admin, "!!document.getElementById('repo-encrypted')", 15_000)
.map_err(|_| format!("no encrypted badge after turning it on: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
shot(c, &admin, &out, "admin-encryption-on")?;
let (s, b) = probe(c, &admin, "GET", &format!("/api/repos/{ACCOUNT}/{REPO}"), None)?;
want(s == 200 && b.contains("\"encrypted\":true"), || format!("gunnar does not say acme/handbook is encrypted: {s} {}", http::cut(&b, 300)))?;
commit_noise(r, &wt, "plain-after-encryption", 1 << 10)?;
let pushed = r.git(&wt, &["push", "-q", &r.remote(&format!("{ACCOUNT}/{REPO}")), "main"]);
want(pushed.is_err(), || "gunnar took a plaintext push into an encrypted repository".to_owned())?;
let refusal = pushed.err().unwrap_or_default();
r.git(&wt, &["reset", "-q", "--hard", "HEAD~1"])?;
// Back off: the rows after this one push plaintext.
c.eval(&admin, "(() => { document.getElementById('enc-change').click(); return 1; })()")?;
c.wait_for(&admin, "!!document.getElementById('enc-confirm')", 10_000)?;
c.eval(&admin, "(() => { document.getElementById('enc-confirm').click(); return 1; })()")?;
c.wait_for(&admin, "!document.getElementById('repo-encrypted') && !!document.getElementById('enc-change')", 15_000)
.map_err(|_| "turning encryption off did not land".to_owned())?;
Ok(format!("on: badge, plaintext push refused ({}); off again", http::cut(&refusal, 120)))
});
// ── round 5.3.1 (owner 2026-10-08): an APPOINTED account admin ──
//
// The account's admin gives ben Admin over the account (gunnar's namespace grant): gunnar
// answers WhoAmI `admin-appointed`. He decides for the account's repositories (Settings,
// publish) and reads its audit log — and is still a member who never buys: no door on
// /auth/status, the members line, every purchase refused. Last in the list: it changes
// ben, and the rows above read him as a plain member.
go(rows, "members-an-appointed-admin-publishes-sees-the-audit-log-and-never-buys", || {
want(!ben.is_empty() && !admin.is_empty(), || "no ben or acme session".to_owned())?;
r.control(&["grant", EMPLOYEE, "admin", "--namespace", ACCOUNT]).map_err(|e| format!("acme's Admin for ben: {}", http::cut(&e, 300)))?;
// gunnar's answer about a role is believed for a minute (ROLE_TTL): ask until it says so.
let mut st = status(c, &ben)?;
for _ in 0..70 {
if st.text("account_role") == "admin-appointed" {
break;
}
std::thread::sleep(Duration::from_secs(1));
st = status(c, &ben)?;
}
let said = scrub(&http::cut(&st.dump(), 400));
want(st.text("account") == ACCOUNT && st.text("account_role") == "admin-appointed", || format!("gunnar does not name ben acme's appointed admin: {said}"))?;
want(st.text("account_line") == LINE, || format!("no members line for the appointed admin: {said}"))?;
for door in ["buy_url", "private_url", "signup_url"] {
want(st.text(door).is_empty(), || format!("{door} for an appointed admin: {said}"))?;
}
// Never buys: `may_buy` false where this console sells, and absent where it sells nothing.
want(st.path("may_buy").and_then(|v| v.as_bool()) != Some(true), || format!("may_buy for an appointed admin: {said}"))?;
// The account's repository decisions: Settings, and publishing.
visit(c, &ben, &format!("{o}/repo/{ACCOUNT}/{REPO}"))?;
let _ = c.wait_for(&ben, "[...document.querySelectorAll('nav.repo-tabs a')].some(a => a.textContent === 'Settings')", 15_000);
let tabs = c.eval_str(&ben, "[...document.querySelectorAll('nav.repo-tabs a')].map(a => a.textContent).join(' | ')")?;
want(tabs.contains("Settings"), || format!("no Settings tab for the appointed admin: {tabs}"))?;
let (s, b) = probe(c, &ben, "PATCH", &format!("/api/repos/{ACCOUNT}/{REPO}"), Some("{\"visibility\":\"public\"}"))?;
want(s == 200, || format!("the appointed admin could not publish: {s} {}", scrub(&http::cut(&b, 300))))?;
let (s, b) = probe(c, &ben, "PATCH", &format!("/api/repos/{ACCOUNT}/{REPO}"), Some("{\"visibility\":\"private\"}"))?;
want(s == 200, || format!("the appointed admin could not take it back: {s} {}", scrub(&http::cut(&b, 300))))?;
// The account's audit log: the door on Account, and gunnar's answer.
visit(c, &ben, &format!("{o}/account"))?;
c.wait_for(&ben, "!!document.getElementById('account-audit-log')", 15_000)
.map_err(|_| format!("no Audit log on Account for the appointed admin: {}", http::cut(&c.eval_str(&ben, MAIN_TEXT).unwrap_or_default(), 300)))?;
// The page asks for the account it shows (`?account=`, from /api/account/room's
// namespace); a bare /api/account/audit is the person's OWN account, which an
// appointed admin is not the admin of (r5.3 batch matrix 2026-10-08).
let (s, b) = probe(c, &ben, "GET", &format!("/api/account/audit?account={ACCOUNT}"), None)?;
want(s == 200, || format!("gunnar refused the appointed admin acme's audit log: {s} {}", scrub(&http::cut(&b, 300))))?;
// Never a buyer. A community console (no --bench-monetize) sells nothing at all:
// there the order door and the sign-up page are a metered console's, not here.
if r.bench.is_none() {
return Ok(format!("{tabs}; publish 200; audit 200; a community console sells nothing"));
}
let (s, b) = probe(c, &ben, "POST", "/api/account/subscription/buy-bundle", Some("{\"bundle\":\"10gb\"}"))?;
want(s == 403 && b.contains("Ask acme"), || format!("the appointed admin's order was not refused by name: {s} {}", scrub(&http::cut(&b, 300))))?;
visit(c, &ben, &format!("{o}/signup?plan=10gb"))?;
c.wait_for(&ben, "!!document.getElementById('heading') && document.getElementById('heading').textContent.includes('member of acme')", 15_000)
.map_err(|_| format!("the sign-up page sells to the appointed admin: {}", http::cut(&c.eval_str(&ben, MAIN_TEXT).unwrap_or_default(), 300)))?;
shot(c, &ben, &out, "appointed-admin-signup")?;
Ok(format!("{tabs}; publish 200; audit 200; buy-bundle 403; sign-up: a member of acme"))
});
}