crates/monetize-cli/src/lib.rs
//! **`monetize`** — the operator's command line for the ONE monetize box (owner 2026-10-13:
//! monetize had a server and a console and no CLI).
//!
//! ```text
//! monetize [--door <host:8475>] [--material <dir>] site list
//! monetize … site order <site> [--size <step>] --admin <address> [--seed-key <file>]
//! monetize … site test-order <site> [--size <step>] --admin <address> [--seed-key <file>]
//! monetize … site status <site>
//! monetize … site narrate <site> [<vm>] [--tail <n>]
//! monetize … site installer-log <site> <vm> [--since <n>]
//! monetize … site recheck <site>
//! monetize … site retry <site>
//! monetize … site abandon <site>
//! monetize … site open-pin <site>
//! monetize … site cancel <site>
//! monetize … site terminate <site> --confirm <name typed back>
//! monetize … site first-link <site> [--to <address>]
//! monetize … site refeed <site> [--only mail_relay|public_repositories|public_site]…
//! monetize … site console <site> <METHOD> <path> [--body-file <f>] [--content-type <t>] [--repo]
//! monetize … site show [<site>]
//! monetize … site add <site> --domain <apex> --first-admin <owner> [site fields]
//! monetize … site edit <site> [site fields]
//! monetize … estate update <estate> [--release <id>] [--front-first]
//! monetize … estate update-status <estate>
//! monetize … estate live-update <estate> on|off
//! monetize … estate update-cancel <estate> [--apply]
//! monetize … estate adopt <site> --front <server> --main <server> --twin <server> --feed-out <file>
//! [--machines <file.json>] [--zone <zone>] [--release-of <role>=<id>]… [--again]
//! monetize … box running
//! monetize … box update
//! monetize … box certs [--try <host>]
//! monetize … box plugins [--apply]
//! monetize front certs --at https://<site>/<its certificate door> (--token-file <f> | --cookie-file <f>)
//! [--ca <pem>] [--try <host>]
//! ```
//!
//! **`front certs`** is the one command that does not ask the ONE box: it asks a product's
//! FRONT its certificates, at the status door that front's console page reads (Holger's
//! `/front/certs`, Gunnar's `/api/front/certs`), with an admin's credential read from a file
//! (Holger's bearer token; Gunnar's break-glass token, or the console's session cookie) and
//! never printed. The front's lines are printed as it says them. The door is the path of the
//! address the operator names (RA1-3): the core holds no product's door.
//!
//! **The site book** (2026-10-14: config in the application, not in a kit): `add` and `edit`
//! put a site in the box's own store through the door (`SiteBook.PutSite`), judged by the
//! box's loader and applied live; `show` reads it back — the two Stripe secrets as "set" and
//! a fingerprint, never a value. A secret is read from a FILE (`--stripe-key-file`,
//! `--webhook-secret-file`), never from the command line, and never printed. `edit` is the
//! one command that asks twice: it reads the site, then puts it back with the flags given.
//!
//! **No logic here.** Each command is ONE request at the box's door (`:8475`, mutual TLS:
//! the operator's certificate under the box's installer CA — the one credential an operator
//! holds off the box; `--door` / `MONETIZE_DOOR`, `--material` / `MONETIZE_MATERIAL`), and
//! the door forwards it to monetize-server's `EstateOrder` verb (`ListSites`, `OrderSite`,
//! `Status`, `Cancel`, `Terminate`) with the box's own operator bearer — the very verbs the
//! console's Sites form calls. What a site is, whether it can be ordered, what an order
//! costs, whether a site is protected: all monetize-server's answers, printed as they come.
//!
//! [`request_of`] and [`said`] are pure and tested; [`door`] is the one piece of I/O.
use std::path::{Path, PathBuf};
/// The environment's defaults for `--door` and `--material`.
pub const DOOR_ENV: &str = "MONETIZE_DOOR";
pub const MATERIAL_ENV: &str = "MONETIZE_MATERIAL";
/// One request at the door.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ask {
pub method: &'static str,
pub path: String,
/// JSON, or empty for a read.
pub body: String,
/// The command, for [`said`].
pub what: What,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum What {
List,
Order,
Status,
Cancel,
Terminate,
Show,
/// `add`: one put.
Put,
/// `edit`: a show, then a put built from it ([`edit_body`]).
Edit,
/// `estate update`, `estate update-status`, `estate live-update`: the update's state.
Update,
/// `box running`: what the box runs and what the release bucket offers next.
BoxRunning,
/// `estate update-cancel <estate>` without `--apply` (A32-7): where the update stands, and
/// that nothing was cancelled.
UpdateCancelPlan,
/// `box update --apply`: the box updates itself from the next signed release.
BoxUpdate,
/// `box update` without `--apply` (A32-6, the kit rule): the PLAN — what the box runs and
/// what an update would do; nothing is staged, nothing restarts.
BoxPlan,
/// `box certs`: the box's certificates, as edda estate-front reports them.
BoxCerts,
/// `estate adopt`: a kit-installed site made the box's to update; the front's feed lines
/// are WRITTEN to `--feed-out` (0600), never printed ([`adopt_feed`]).
Adopt,
/// `box plugins [--apply]`: the release's signed plugins, a queued job followed to its end.
Plugins,
/// `estates`: the Overview as the box classifies it, READ ONLY (OVW-CLEAN): the estates that
/// exist, the ended or never installed, and the New-estate slot's choices.
Estates,
/// `box inventory`: the cloud account as this box lists it, READ ONLY (MAP-SEG 3): every
/// labelled row with its estate, its order and its label, the orphans and the missing.
Inventory,
/// `site narrate <site>`: a narrated site's boot logs, each box's summary.
Narrate,
/// `site narrate <site> <vm>`: one box's boot log, printed as the box sent it.
NarrateText,
/// `site installer-log <site> <vm>`: the box's install log as the ONE box's pairing read it.
InstallerLog,
/// `site recheck|retry|abandon <site>`: the Sites page's "Check again" / "Set up again" /
/// "Abandon this install"; `site
/// open-pin <site>`: the guardian's pin windows opened again.
Settle,
/// `site first-link <site> [--to <address>]`: the site's first sign-in link mailed again.
FirstLink,
/// `site refeed <site> [--only <group>]…`: the keys this box holds for a laid estate now (the
/// mains' mail relay, its public repositories) fed to its boxes again.
Refeed,
/// `site console <site> <METHOD> <path> [--body-file <f>] [--content-type <t>] [--repo]`: one
/// call on a laid site's console through its public front, with the custody's bearer.
Console,
/// `site test-order <site>`: the order paid server-side with the vendor's TEST card.
TestOrder,
/// `site change-plan <site> …`: the operator's plan change of his own site (a grow).
ChangePlan,
/// `site do <site> <kind> [<param>=<value>]… [--file <param>=<path>]`: a product's own order,
/// queued for its box to pull; the box's answer is waited for and said.
Do,
/// `site do-answer <site> <seq>`: the box's answer to one product order.
DoAnswer,
/// `site kinds <site>`: the order kinds the site's product declares.
Kinds,
}
/// Where the box is and who asks.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Target {
pub door: String,
pub material: PathBuf,
}
pub fn help() -> &'static str {
"monetize — the operator's command line for the ONE monetize box\n\n\
monetize [--door <host:8475>] [--material <dir>] site list\n\
monetize … site order <site> [--size <step>] --admin <address> [--seed-key <file>] [--return <url>]\n\
monetize … site test-order <site> [--size <step>] --admin <address> [--seed-key <file>]\n\
\x20 the order paid on the box with the site's vendor's TEST card (Stripe's pm_card_visa),\n\
\x20 nobody at a page; refused by name on a live key, before the vendor is asked anything\n\
monetize … site status <site>\n\
monetize … site narrate <site> [<vm>] [--tail <n>] a narrated site's boot logs (DEV-NARRATE): each box's\n\
\x20 summary, or one box's log, newest last (--tail: its last n lines)\n\
monetize … site installer-log <site> <vm> [--since <n>] the box's install log as the ONE box read it over\n\
\x20 the installer's link, from its first boot line (--since: from line n)\n\
monetize … site recheck <site> Check again: every box's pair status; all PAIRED adopts the site\n\
monetize … site retry <site> Set up again: a failed or owed site is laid and installed again\n\
monetize … site abandon <site> Abandon this install: an install still running is failed now\n\
monetize … site open-pin <site> the guardian's pin window opened again, until each pins, for the\n\
\x20 site's boxes whose key is not pinned yet (a pinned key is never replaced)\n\
monetize … site cancel <site>\n\
monetize … site change-plan <site> [--main-plan <plan>] [--front-plan <plan>] [--data-gib <n>] [--set <field>=<n>]… [--test]\n\
\x20 the operator's plan change of his own site: new plans, main's data grown to n GB a disk\n\
\x20 (never smaller), the product's own order fields (--set); a dearer change is held at the\n\
\x20 vendor's page (--test: with the vendor's TEST card, nobody at a page); a data step reaches\n\
\x20 the boxes as a signed go-ahead they pull\n\
monetize … site terminate <site> --confirm <the site's name, typed back>\n\
\x20 (a customer's sold estate too: no backup is taken; nothing is refunded)\n\
monetize … site first-link <site> [--to <address>] the site's first sign-in link mailed again: to a new\n\
\x20 address, or with no --to to the first admin's recorded one (a handed-off site's\n\
\x20 main is asked to mint it, and its answer is waited for and said: mailed, or\n\
\x20 refused with its reason; refused once its first admin has signed in)\n\
monetize … site refeed <site> [--only mail_relay|public_repositories|public_site]… what this box holds for a\n\
\x20 laid site NOW fed to its main and twin again as one signed order: the mail relay of\n\
\x20 the site's product (secret.sold_smtp_url.<product> / sold_smtp_from.<product>), its\n\
\x20 public_repositories; its main's answer is waited for and said: kept, or refused with\n\
\x20 its reason\n\
monetize … site console <site> <METHOD> <path> [--body-file <f>] [--content-type <t>] [--repo]\n\
\x20 one call (GET, POST or PUT) on a laid site's console at its public front (--repo: its\n\
\x20 repository host repo.<host>), with the bearer the ONE box holds for it; only a call the\n\
\x20 product or the box declares, audited; says HTTP <status>, then the front's answer\n\
monetize … site kinds <site> the order kinds the site's product declares (its facts): each\n\
\x20 kind, the role that runs it, its params (* required) and its file\n\
monetize … site do <site> <kind> [<param>=<value>]… [--file <param>=<path>] [--no-wait]\n\
\x20 one of the product's own orders, signed and queued for the box that runs it, which\n\
\x20 PULLS it (and its file) on its next look; its answer is waited for and said\n\
monetize … site do-answer <site> <seq> the box's answer to one product order\n\
monetize … site show [<site>] (no site: a blank one and the box's choices)\n\
monetize … site add <site> --domain <apex> --first-admin <owner> [site fields]\n\
monetize … site edit <site> [site fields] (what is not given stays as the box has it)\n\n\
site fields:\n\
\x20 --domain <apex> --product <id> --first-admin <owner> --operator-site yes|no --terminable yes|no\n\
\x20 --vendor stripe/<account> (add: default a new account of its own, stripe/<site>)\n\
\x20 --stripe-key-file <file> --webhook-secret-file <file> (read from files, never printed)\n\
\x20 --mode test|live --term-days <n> --term-months <n> --grace-hours <n> --margin-bp <n> --media-from <site>\n\n\
monetize … estate update <estate> [--release <id>] [--front-first] update twin, main, front to the release sold now\n\
\x20 --front-first: the shop keep-boots the front first (a front with no update taker yet)\n\
monetize … estate update-status <estate>\n\
monetize … estate live-update <estate> on|off off: keep media only (no live app slot)\n\
monetize … estate update-cancel <estate> [--apply] the plan; --apply: cancel the open update (the boxes done stay done)\n\
monetize … estate adopt <site> --front <server> --main <server> --twin <server> --feed-out <file>\n\
\x20 [--machines <file.json>] [--zone <zone>] [--release-of <role>=<id>]… [--again]\n\
\x20 a kit-installed site becomes this box's to update; the front's feed lines are\n\
\x20 written to --feed-out (0600, a new file), never printed\n\
monetize … box running what this box runs, what the bucket offers next\n\
monetize … box update [--apply] the plan; --apply: this box takes the next signed release (A/B, rolls back)\n\
monetize … box update --back [--apply] the way back: the other slot's release, confirmed on this box\n\
monetize … box certs [--try <host>] this box's certificates: valid until, next attempt, last\n\
\x20 reason; --try: try now (refused while a failed validation's wait runs)\n\
monetize … box plugins [--apply] the release's signed plugins: what would change (--apply: take them, live)\n\
monetize … estates the Overview as the box classifies it, read only: the estates\n\
\x20 that exist, the ended or never installed, the New-estate slot's choices\n\
monetize … box inventory the cloud account as this box lists it, read only: each\n\
\x20 labelled row with its estate and order, the orphans and the missing (no secret in it)\n\
monetize front certs --at https://<site>/<door> (--token-file <f> | --cookie-file <f>) [--ca <pem>] [--try <host>]\n\
\x20 a product front's certificates, asked at the status door named in --at (the one its\n\
\x20 console page reads) with an admin's credential read from the file (never printed);\n\
\x20 --try: try now (<door>/try); --ca: a front under its own CA\n\n\
--door the box's door (default: $MONETIZE_DOOR)\n\
--material the directory holding the operator's certificate for it (default: $MONETIZE_MATERIAL)\n\
--admin where the site's first admin's sign-in link is mailed\n\
--seed-key a file holding the seed's PUBLIC key (one OpenSSH line): the site's declared\n\
\x20 repositories are seeded with its private half, which never leaves your machine\n\
--return where the payment page sends you back (default: https://<the door's host>/sites,\n\
\x20 the box's own Sites page — never the site being laid, which has no front yet)\n\n\
Every answer is the box's own; this tool decides nothing."
}
fn refuse(id: &str, msg: impl std::fmt::Display) -> String {
format!("REFUSED [{id}] {msg}")
}
/// Pure: the leading `--door` / `--material` taken out (else the environment's), and the rest.
pub fn target_of(args: &[String], env: &dyn Fn(&str) -> Option<String>) -> Result<(Target, Vec<String>), String> {
let mut t = Target { door: env(DOOR_ENV).unwrap_or_default(), material: env(MATERIAL_ENV).map(PathBuf::from).unwrap_or_default() };
let mut rest = Vec::new();
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--door" => t.door = it.next().cloned().ok_or_else(|| refuse("flag", "--door needs <host:port>"))?,
"--material" => t.material = PathBuf::from(it.next().ok_or_else(|| refuse("flag", "--material needs a directory"))?),
_ => rest.push(a.clone()),
}
}
Ok((t, rest))
}
/// Pure: **the one request a command is** — or the refusal naming what the command lacks.
/// `read_file` reads `--seed-key`'s file (its public line).
pub fn request_of(words: &[String], read_file: &dyn Fn(&Path) -> Result<String, String>) -> Result<Ask, String> {
let w: Vec<&str> = words.iter().map(String::as_str).collect();
let flag = |name: &str| w.iter().position(|a| *a == name).and_then(|i| w.get(i + 1)).map(|v| v.to_string());
let site = |verb: &str| w.get(2).filter(|s| !s.starts_with("--")).map(|s| s.to_string()).ok_or_else(|| refuse("site", format!("`site {verb}` names the site: its label, as the box declares it (`monetize site list`)")));
match (w.first().copied(), w.get(1).copied()) {
(Some("site"), Some("list")) => Ok(Ask { method: "GET", path: "/site/list".into(), body: String::new(), what: What::List }),
(Some("site"), Some("status")) => Ok(Ask { method: "GET", path: format!("/site/status/{}", site("status")?), body: String::new(), what: What::Status }),
(Some("site"), Some("narrate")) => narrate_request(&w),
(Some("site"), Some("installer-log")) => installer_log_request(&w),
(Some("site"), Some("open-pin")) => Ok(Ask { method: "POST", path: format!("/site/open-pin/{}", site("open-pin")?), body: String::new(), what: What::Settle }),
(Some("site"), Some(action @ ("recheck" | "retry" | "abandon"))) => Ok(Ask { method: "POST", path: "/site/settle".into(), body: serde_json::json!({ "estate": site(action)?, "action": action }).to_string(), what: What::Settle }),
(Some("site"), Some("order")) => {
let estate = site("order")?;
let email = flag("--admin").ok_or_else(|| refuse("site-admin", "--admin <address>: where the first admin's sign-in link is mailed"))?;
let body = serde_json::json!({ "estate": estate, "size": flag("--size").unwrap_or_default(), "email": email, "seed_key": seed_key_of(flag("--seed-key"), read_file)?, "return_url": flag("--return").unwrap_or_default() });
Ok(Ask { method: "POST", path: "/site/order".into(), body: body.to_string(), what: What::Order })
}
(Some("site"), Some("test-order")) => {
let estate = site("test-order")?;
let email = flag("--admin").ok_or_else(|| refuse("site-admin", "--admin <address>: where the first admin's sign-in link is mailed"))?;
if w.contains(&"--return") {
return Err(refuse("site-test-order-return", "a test order is paid with the vendor's TEST card, at no page: it names no return page"));
}
let body = serde_json::json!({ "estate": estate, "size": flag("--size").unwrap_or_default(), "email": email, "seed_key": seed_key_of(flag("--seed-key"), read_file)? });
Ok(Ask { method: "POST", path: "/site/test-order".into(), body: body.to_string(), what: What::TestOrder })
}
(Some("site"), Some("change-plan")) => {
let estate = site("change-plan")?;
let gib = match flag("--data-gib") {
None => 0,
Some(n) => n.parse::<u64>().map_err(|_| refuse("site-change-plan", format!("--data-gib {n:?} is not a number of GB")))?,
};
let mut order = serde_json::Map::new();
for (i, _) in w.iter().enumerate().filter(|(_, a)| **a == "--set") {
let kv = w.get(i + 1).copied().unwrap_or_default();
let (k, v) = kv.split_once('=').filter(|(k, _)| !k.trim().is_empty()).ok_or_else(|| refuse("site-change-plan", format!("--set {kv:?} is <field>=<value>")))?;
let v = v.trim();
order.insert(k.trim().to_string(), v.parse::<u64>().map(serde_json::Value::from).or_else(|_| v.parse::<bool>().map(serde_json::Value::from)).unwrap_or_else(|_| serde_json::Value::from(v)));
}
let body = serde_json::json!({ "estate": estate, "main_plan": flag("--main-plan").unwrap_or_default(), "front_plan": flag("--front-plan").unwrap_or_default(), "data_gib": gib, "order": order, "test": w.contains(&"--test") });
if gib == 0 && order.is_empty() && body["main_plan"] == "" && body["front_plan"] == "" {
return Err(refuse("site-change-plan", "name what changes: --main-plan, --front-plan, --data-gib or --set"));
}
Ok(Ask { method: "POST", path: "/site/change-plan".into(), body: body.to_string(), what: What::ChangePlan })
}
(Some("site"), Some("cancel")) => Ok(Ask { method: "POST", path: "/site/cancel".into(), body: serde_json::json!({ "estate": site("cancel")? }).to_string(), what: What::Cancel }),
(Some("site"), Some("terminate")) => {
let estate = site("terminate")?;
let confirm = flag("--confirm").ok_or_else(|| refuse("site-confirm", format!("--confirm <name>: type the site's name back ({estate}) to terminate it")))?;
if w.contains(&"--backup") {
return Err(refuse("terminate-no-backup", "backups are not offered at terminate"));
}
let body = serde_json::json!({ "estate": estate, "confirm": confirm });
Ok(Ask { method: "POST", path: "/site/terminate".into(), body: body.to_string(), what: What::Terminate })
}
(Some("site"), Some("first-link")) => {
let estate = site("first-link")?;
// FIRST-LINK-1: no --to is the first admin's recorded address.
let to = flag("--to").unwrap_or_default();
let body = serde_json::json!({ "estate": estate, "to": to });
Ok(Ask { method: "POST", path: "/site/first-link".into(), body: body.to_string(), what: What::FirstLink })
}
(Some("site"), Some("console")) => {
let estate = site("console")?;
let usage = || refuse("site-console", "`site console <site> <METHOD> <path> [--body-file <f>] [--content-type <t>] [--repo]`");
let method = w.get(3).filter(|m| !m.starts_with("--")).map(|m| m.to_ascii_uppercase()).ok_or_else(usage)?;
let path = w.get(4).filter(|p| p.starts_with('/')).map(|p| p.to_string()).ok_or_else(usage)?;
let mut body = serde_json::json!({ "estate": estate, "method": method, "path": path, "repo": w.contains(&"--repo") });
if let Some(t) = flag("--content-type") {
body["content_type"] = serde_json::json!(t);
}
// The file is read where the request is sent (its bytes, base64: `body_b64`).
if let Some(f) = flag("--body-file") {
body[CONSOLE_BODY_FILE] = serde_json::json!(f);
}
Ok(Ask { method: "POST", path: "/site/console".into(), body: body.to_string(), what: What::Console })
}
(Some("site"), Some("kinds")) => Ok(Ask { method: "GET", path: format!("/site/kinds/{}", site("kinds")?), body: String::new(), what: What::Kinds }),
(Some("site"), Some("do-answer")) => {
let estate = site("do-answer")?;
let seq = w.get(3).and_then(|n| n.parse::<u64>().ok()).ok_or_else(|| refuse("site-do", "`site do-answer <site> <seq>`: the order's seq `site do` said"))?;
Ok(Ask { method: "GET", path: format!("/site/do/{estate}/{seq}"), body: String::new(), what: What::DoAnswer })
}
(Some("site"), Some("do")) => {
let estate = site("do")?;
let usage = || refuse("site-do", "`site do <site> <kind> [<param>=<value>]… [--file <param>=<path>] [--no-wait]` (`site kinds <site>` lists them)");
let kind = w.get(3).filter(|k| !k.starts_with("--") && !k.contains('=')).map(|k| k.to_string()).ok_or_else(usage)?;
let mut params = serde_json::Map::new();
let mut file = None;
let mut i = 4;
while let Some(a) = w.get(i) {
match *a {
"--file" => {
let (p, path) = w.get(i + 1).and_then(|f| f.split_once('=')).ok_or_else(usage)?;
file = Some((p.to_string(), path.to_string()));
i += 2;
}
"--no-wait" => i += 1,
kv => {
let (k, v) = kv.split_once('=').filter(|(k, _)| !k.is_empty()).ok_or_else(usage)?;
params.insert(k.to_string(), serde_json::Value::from(v));
i += 1;
}
}
}
let mut body = serde_json::json!({ "estate": estate, "kind": kind, "params": params, "wait": !w.contains(&"--no-wait") });
// The file is read where the request is sent (its bytes, base64: `file_b64`).
if let Some((p, path)) = file {
body[DO_FILE] = serde_json::json!(path);
body["file_param"] = serde_json::json!(p);
}
Ok(Ask { method: "POST", path: "/site/do".into(), body: body.to_string(), what: What::Do })
}
(Some("site"), Some("refeed")) => {
let estate = site("refeed")?;
let only: Vec<&str> = w.iter().enumerate().filter(|(_, a)| **a == "--only").filter_map(|(i, _)| w.get(i + 1).copied()).collect();
let body = serde_json::json!({ "estate": estate, "only": only });
Ok(Ask { method: "POST", path: "/site/refeed".into(), body: body.to_string(), what: What::Refeed })
}
(Some("site"), Some("show")) => {
let path = match w.get(2).filter(|s| !s.starts_with("--")) {
Some(s) => format!("/site/show/{s}"),
None => "/site/show".to_string(),
};
Ok(Ask { method: "GET", path, body: String::new(), what: What::Show })
}
(Some("site"), Some("add")) => {
let estate = site("add")?;
let blank = serde_json::json!({ "site": { "estate": estate, "operator_site": true } });
let mut body = put_body(&blank, words, read_file)?;
body["create"] = serde_json::Value::Bool(true);
if body["domain"].as_str().unwrap_or("").is_empty() {
return Err(refuse("site-domain", "--domain <apex>: the domain the site is laid under"));
}
if body["first_admin"].as_str().unwrap_or("").is_empty() {
return Err(refuse("site-first-admin", "--first-admin <owner>: the site's owner's account"));
}
Ok(Ask { method: "POST", path: "/site/put".into(), body: body.to_string(), what: What::Put })
}
(Some("site"), Some("edit")) => {
let estate = site("edit")?;
Ok(Ask { method: "GET", path: format!("/site/show/{estate}"), body: String::new(), what: What::Edit })
}
(Some("estate"), Some(verb @ ("update" | "update-status" | "live-update" | "update-cancel"))) => {
let estate = w.get(2).filter(|s| !s.starts_with("--")).map(|s| s.to_string()).ok_or_else(|| refuse("estate", format!("`estate {verb}` names the estate: a sold estate's company or a site's label")))?;
match verb {
"update" => Ok(Ask { method: "POST", path: "/estate/update".into(), body: serde_json::json!({ "estate": estate, "release": flag("--release").unwrap_or_default(), "front_first": w.contains(&"--front-first") }).to_string(), what: What::Update }),
"update-status" => Ok(Ask { method: "GET", path: format!("/estate/update/{estate}"), body: String::new(), what: What::Update }),
// ★ A32-7: the plan unless --apply (the kit rule): where the update stands.
"update-cancel" if !w.contains(&"--apply") => Ok(Ask { method: "GET", path: format!("/estate/update/{estate}"), body: String::new(), what: What::UpdateCancelPlan }),
"update-cancel" => Ok(Ask { method: "POST", path: "/estate/update/cancel".into(), body: serde_json::json!({ "estate": estate }).to_string(), what: What::Update }),
_ => {
let on = match w.get(3).copied() {
Some("on") => true,
Some("off") => false,
_ => return Err(refuse("estate-live", "`estate live-update <estate> on|off`")),
};
Ok(Ask { method: "POST", path: "/estate/live-update".into(), body: serde_json::json!({ "estate": estate, "on": on }).to_string(), what: What::Update })
}
}
}
(Some("estate"), Some("adopt")) => adopt_request(&w, read_file),
(Some("box"), Some("running")) => Ok(Ask { method: "GET", path: "/self/running".into(), body: String::new(), what: What::BoxRunning }),
// ★ A32-6 (the kit rule): no `--apply`, no move — the plan is what the box runs and what
// an update (or the way back) would do; only `--apply` stages and restarts the box.
(Some("box"), Some("update")) if !w.contains(&"--apply") => Ok(Ask { method: "GET", path: "/self/running".into(), body: String::new(), what: What::BoxPlan }),
(Some("box"), Some("update")) if w.contains(&"--back") => Ok(Ask { method: "POST", path: "/self/update".into(), body: serde_json::json!({ "back": true }).to_string(), what: What::BoxUpdate }),
(Some("box"), Some("update")) => Ok(Ask { method: "POST", path: "/self/update".into(), body: String::new(), what: What::BoxUpdate }),
(Some("box"), Some("certs")) => match flag("--try") {
Some(host) => Ok(Ask { method: "POST", path: "/self/certs/try".into(), body: serde_json::json!({ "host": host }).to_string(), what: What::BoxUpdate }),
None => Ok(Ask { method: "GET", path: "/self/certs".into(), body: String::new(), what: What::BoxCerts }),
},
(Some("box"), Some("inventory")) => Ok(Ask { method: "GET", path: "/cloud/inventory".into(), body: String::new(), what: What::Inventory }),
(Some("estates"), None) => Ok(Ask { method: "GET", path: "/estates/overview".into(), body: String::new(), what: What::Estates }),
(Some("box"), Some("plugins")) => Ok(Ask { method: "POST", path: "/box/plugins".into(), body: serde_json::json!({ "apply": w.contains(&"--apply") }).to_string(), what: What::Plugins }),
_ => Err(refuse("usage", help())),
}
}
/// Pure: **`estates`' words** (OVW-CLEAN): one line per estate that exists (`name product
/// standing domain — words`), then the quiet line and its names, then the slot's choices.
pub fn estates_words(v: &serde_json::Value) -> String {
let g = |e: &serde_json::Value, k: &str| e[k].as_str().unwrap_or_default().to_string();
let line = |e: &serde_json::Value| format!("{} {} {} {} — {}\n", g(e, "name"), g(e, "product"), g(e, "standing"), if g(e, "domain").is_empty() { "-".to_string() } else { g(e, "domain") }, g(e, "words"));
let rows: Vec<&serde_json::Value> = v["estates"].as_array().into_iter().flatten().collect();
let ended: Vec<&serde_json::Value> = v["ended"].as_array().into_iter().flatten().collect();
let mut out = if rows.is_empty() { "no estate is installed or being laid\n".to_string() } else { rows.iter().map(|e| line(e)).collect() };
if !ended.is_empty() {
out.push_str(&format!("{} ended or never-installed:\n", ended.len()));
out.extend(ended.iter().map(|e| format!(" {}", line(e))));
}
let list = |k: &str| v["slot"][k].as_array().into_iter().flatten().filter_map(|p| p.as_str()).collect::<Vec<_>>().join(", ");
out.push_str(&format!("new estate: products {}; declared, started by name: {}\n", or_none(&list("products")), or_none(&list("declared"))));
out
}
fn or_none(s: &str) -> String {
if s.is_empty() { "none".to_string() } else { s.to_string() }
}
/// Pure: **`box inventory`'s words** (MAP-SEG 3): one line per row — `list id kind size zone
/// attached estate reference label monthly` — orphans first (the money no order owns), then
/// what is owned, then what is missing; a cloud that could not be listed says so.
pub fn inventory_words(v: &serde_json::Value) -> String {
let mut out = String::new();
for c in v["clouds"].as_array().into_iter().flatten() {
let g = |r: &serde_json::Value, k: &str| r[k].as_str().unwrap_or_default().to_string();
// MAP-3: an account two products list is ONE entry; it names them.
let products: Vec<&str> = c["products"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).collect();
let named = if products.is_empty() { String::new() } else { format!(" ({})", products.join(", ")) };
out.push_str(&format!("cloud {}{named}: {}\n", g(c, "provider"), if c["available"].as_bool() == Some(true) { "listed".to_string() } else { format!("NOT listed: {}", g(c, "error")) }));
let dash = |x: String| if x.is_empty() { "-".to_string() } else { x };
for (list, rows) in [("orphan", "orphans"), ("owned", "owned")] {
for r in c[rows].as_array().into_iter().flatten() {
let size = if g(r, "kind") == "server" { "server".to_string() } else { format!("{}GiB", r["gib"].as_u64().unwrap_or(0)) };
let monthly = if r["priced"].as_bool() == Some(true) { format!("{}/mo", r["monthly_minor"].as_u64().unwrap_or(0)) } else { "unpriced".to_string() };
let title = if g(r, "title").is_empty() { String::new() } else { format!(" {:?}", g(r, "title")) };
out.push_str(&format!("{list} {} {} {size} {} {} estate={} ref={} res={} {monthly}{title}\n", g(r, "id"), g(r, "kind"), dash(g(r, "zone")), dash(g(r, "attached_to")), dash(g(r, "estate")), dash(g(r, "reference")), dash(g(r, "resource"))));
}
}
for b in c["backups"].as_array().into_iter().flatten() {
out.push_str(&format!("backup {} {}GiB {} estate={} ref={} {}\n", g(b, "id"), b["gib"].as_u64().unwrap_or(0), dash(g(b, "zone")), dash(g(b, "estate")), dash(g(b, "reference")), g(b, "words")));
}
for m in c["missing"].as_array().into_iter().flatten() {
out.push_str(&format!("missing {} estate={} ref={} {}\n", g(m, "id"), g(m, "estate"), g(m, "reference"), g(m, "resource")));
}
out.push_str(&format!("{} orphan(s), {} owned, {} backup(s), {} missing, {} not ours (no monetize label)\n", c["orphans"].as_array().map_or(0, Vec::len), c["owned"].as_array().map_or(0, Vec::len), c["backups"].as_array().map_or(0, Vec::len), c["missing"].as_array().map_or(0, Vec::len), c["foreign_count"].as_u64().unwrap_or(0)));
}
if out.is_empty() {
out.push_str("this box lists no cloud\n");
}
out
}
/// Pure: **`estate adopt`'s one request** — the site, its three machines (the server ids from
/// `--front`/`--main`/`--twin`, over what `--machines` (a JSON array the kit wrote: role,
/// server, hostname, title, plan, public_ipv4, utility_ipv4, disks) records of them), the
/// zone, the releases known, `--again`. `--feed-out` is required: the answer's secret goes
/// to a file, never to the terminal.
pub fn adopt_request(w: &[&str], read_file: &dyn Fn(&Path) -> Result<String, String>) -> Result<Ask, String> {
let flag = |name: &str| w.iter().position(|a| *a == name).and_then(|i| w.get(i + 1)).map(|v| v.to_string());
let site = w.get(2).filter(|s| !s.starts_with("--")).map(|s| s.to_string()).ok_or_else(|| refuse("estate", "`estate adopt` names the site: its label, as the box declares it (`monetize site list`)"))?;
if flag("--feed-out").is_none_or(|f| f.trim().is_empty()) {
return Err(refuse("adopt-feed-out", "--feed-out <file>: where the front's feed lines (its shop door and its client key, a SECRET) are written — never to the terminal"));
}
let mut machines: Vec<serde_json::Value> = match flag("--machines") {
Some(f) => {
let v: serde_json::Value = serde_json::from_str(&read_file(Path::new(&f))?).map_err(|e| refuse("adopt-machines", format!("{f}: not JSON: {e}")))?;
v.as_array().cloned().ok_or_else(|| refuse("adopt-machines", format!("{f}: a JSON array of machines (role, server, …)")))?
}
None => Vec::new(),
};
for role in ["front", "main", "twin"] {
let named = flag(&format!("--{role}"));
match machines.iter_mut().find(|m| m["role"].as_str() == Some(role)) {
Some(m) => {
if let Some(id) = named {
m["server"] = serde_json::Value::String(id);
}
}
None => match named {
Some(id) => machines.push(serde_json::json!({ "role": role, "server": id })),
None => return Err(refuse("adopt-machine", format!("--{role} <server id>: the {role}'s server (or a --machines file naming it)"))),
},
}
}
let mut release_of = serde_json::Map::new();
for (i, a) in w.iter().enumerate() {
if *a == "--release-of" {
let (role, id) = w.get(i + 1).and_then(|v| v.split_once('=')).ok_or_else(|| refuse("adopt-release", "--release-of <role>=<release id>"))?;
release_of.insert(role.trim().to_string(), serde_json::Value::String(id.trim().to_string()));
}
}
let body = serde_json::json!({ "estate": site, "zone": flag("--zone").unwrap_or_default(), "machines": machines, "release_of": release_of, "again": w.contains(&"--again") });
Ok(Ask { method: "POST", path: "/estate/adopt".into(), body: body.to_string(), what: What::Adopt })
}
/// Pure: `--seed-key <file>`'s one OpenSSH PUBLIC key line (empty: none); a private key is refused.
fn seed_key_of(file: Option<String>, read_file: &dyn Fn(&Path) -> Result<String, String>) -> Result<String, String> {
let Some(f) = file else { return Ok(String::new()) };
let line = read_file(Path::new(&f))?.trim().to_string();
if !line.starts_with("ssh-") || line.contains('\n') {
return Err(refuse("site-seed-key", format!("{f} is not one OpenSSH PUBLIC key line (make it with `ssh-keygen -y -f <the seed key>`); a private key is never sent")));
}
Ok(line)
}
/// Pure: **the front's feed lines out of the box's adopt answer** — `shop_door.toml` and
/// `secret.shop_client_key`, as the JSON object `--feed-out` holds.
pub fn adopt_feed(body: &str) -> Result<String, String> {
let v: serde_json::Value = serde_json::from_str(body.trim()).map_err(|e| format!("the box's answer is not JSON: {e}"))?;
let feed = v["feed"].as_object().filter(|f| f.contains_key("shop_door.toml") && f.keys().any(|k| k.starts_with("secret."))).ok_or("the box's answer carries no feed lines (shop_door.toml, secret.shop_client_key)")?;
serde_json::to_string_pretty(feed).map(|t| t + "\n").map_err(|e| e.to_string())
}
/// **Write a secret to a NEW file, 0600** — refused when the file is there already.
pub fn write_secret_file(path: &Path, text: &str) -> Result<(), String> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path).map_err(|e| refuse("adopt-feed-out", format!("{}: {e} (a new file: an earlier one is never overwritten)", path.display())))?;
f.write_all(text.as_bytes()).and_then(|_| f.sync_all()).map_err(|e| format!("{}: {e}", path.display()))
}
/// Pure: **the next look at a plugin job** — `GET /box/plugins/<job>` while the box says it is
/// `queued` or `running`; `None` once it is `done` or `failed` (or the answer is no job).
pub fn plugins_next(body: &str) -> Option<Ask> {
let v: serde_json::Value = serde_json::from_str(body.trim()).ok()?;
let job = v["job"].as_str().filter(|j| !j.is_empty())?;
matches!(v["state"].as_str(), Some("queued" | "running")).then(|| Ask { method: "GET", path: format!("/box/plugins/{job}"), body: String::new(), what: What::Plugins })
}
/// How often `site first-link` looks at a handed-off site's main's answer again, and for how
/// long at most (HOL-MAIL): the main pulls its order on its next round.
pub const FIRST_LINK_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
pub const FIRST_LINK_FOR: std::time::Duration = std::time::Duration::from_secs(300);
/// Pure: **the order `site first-link` queued for a handed-off site's main** (`asked`, the
/// signed `sign_in_again`'s seq) — 0 when no main was asked.
pub fn first_link_asked(body: &str) -> u64 {
serde_json::from_str::<serde_json::Value>(body.trim()).ok().and_then(|v| v["asked"].as_u64()).unwrap_or(0)
}
/// Pure: **the main's answer to the order `asked`, in a site's status** — `None` while the
/// newest order it answered is older; `Ok` minted and mailed, `Err` refused by name.
pub fn first_link_answer(estate: &str, asked: u64, status_body: &str) -> Option<Result<String, String>> {
let v: serde_json::Value = serde_json::from_str(status_body.trim()).ok()?;
let answered = v["first_link_answered"].as_u64().unwrap_or(0);
if answered == 0 || answered < asked {
return None;
}
let words = v["first_admin_door_words"].as_str().unwrap_or_default();
Some(if v["first_link_mailed"].as_bool().unwrap_or(false) {
Ok(format!("{estate}'s main minted a new sign-in link and mailed it (order {answered}): {words}\n"))
} else {
Err(format!("REFUSED by {estate}'s main (order {answered}): {words}"))
})
}
/// One request at the box's door, as [`follow_first_link`] asks it (the real [`door`], or a test's).
pub type AskDoor<'a> = dyn FnMut(&Ask) -> Result<(u16, String), String> + 'a;
/// **A handed-off site's first link, as its MAIN answered it** (HOL-MAIL, holger-rs 2026-10-27):
/// the door only queued the signed order `asked`; the site's status is looked at through
/// `ask_door` every `every` until its main answered that order ([`first_link_answer`]), for at
/// most `for_` (looked at once at least). Not answered in time: an error that says nothing is
/// known to be mailed — never a promise.
pub fn follow_first_link(estate: &str, asked: u64, ask_door: &mut AskDoor<'_>, every: std::time::Duration, for_: std::time::Duration) -> Result<String, String> {
let look = Ask { method: "GET", path: format!("/site/status/{estate}"), body: String::new(), what: What::Status };
let started = std::time::Instant::now();
loop {
let (status, body) = ask_door(&look)?;
if status != 200 {
return said(What::Status, status, &body);
}
if let Some(answer) = first_link_answer(estate, asked, &body) {
return answer;
}
if started.elapsed() >= for_ {
return Err(format!(
"{estate}'s main has not answered the sign-in order {asked} within {} s: nothing is known to be mailed; `site status {estate}` says its answer once it pulls the order",
for_.as_secs()
));
}
std::thread::sleep(every);
}
}
/// Pure: **the main's answer to the `refeed` order `asked`, in a site's status** — `None`
/// while the newest order it answered is older; `Ok` kept, `Err` refused by name.
pub fn refeed_answer(estate: &str, asked: u64, status_body: &str) -> Option<Result<String, String>> {
let v: serde_json::Value = serde_json::from_str(status_body.trim()).ok()?;
let answered = v["refeed_answered"].as_u64().unwrap_or(0);
if answered == 0 || answered < asked {
return None;
}
let words = v["refeed_words"].as_str().unwrap_or_default();
Some(if v["refeed_kept"].as_bool().unwrap_or(false) {
Ok(format!("{words}\n"))
} else {
Err(format!("REFUSED by {estate}'s main (order {answered}): {words}"))
})
}
/// **A laid site's refeed, as its MAIN answered it** (MAIL-RELAY 2026-10-28): the door only
/// queued the signed order `asked`; the site's status is looked at through `ask_door` every
/// `every` until its main answered that order ([`refeed_answer`]), for at most `for_` (looked
/// at once at least). Not answered in time: an error that says nothing is known to be kept.
pub fn follow_refeed(estate: &str, asked: u64, ask_door: &mut AskDoor<'_>, every: std::time::Duration, for_: std::time::Duration) -> Result<String, String> {
let look = Ask { method: "GET", path: format!("/site/status/{estate}"), body: String::new(), what: What::Status };
let started = std::time::Instant::now();
loop {
let (status, body) = ask_door(&look)?;
if status != 200 {
return said(What::Status, status, &body);
}
if let Some(answer) = refeed_answer(estate, asked, &body) {
return answer;
}
if started.elapsed() >= for_ {
return Err(format!(
"{estate}'s main has not answered the refeed order {asked} within {} s: nothing is known to be kept; `site status {estate}` says its answer once it pulls the order",
for_.as_secs()
));
}
std::thread::sleep(every);
}
}
/// How often `box plugins` looks at its job again, and for how long at most.
pub const PLUGINS_EVERY: std::time::Duration = std::time::Duration::from_secs(2);
pub const PLUGINS_FOR: std::time::Duration = std::time::Duration::from_secs(900);
/// Pure: **a put, from a shown site with the flags over it** — what is not given stays as
/// shown. The secrets are read from their files; nothing else carries them.
pub fn put_body(shown: &serde_json::Value, words: &[String], read_file: &dyn Fn(&Path) -> Result<String, String>) -> Result<serde_json::Value, String> {
let w: Vec<&str> = words.iter().map(String::as_str).collect();
let flag = |name: &str| w.iter().position(|a| *a == name).and_then(|i| w.get(i + 1)).map(|v| v.to_string());
let s = &shown["site"];
let word = |k: &str, f: &str| flag(f).unwrap_or_else(|| s[k].as_str().unwrap_or_default().to_string());
let yes = |k: &str, f: &str| -> Result<bool, String> {
match flag(f).as_deref() {
None => Ok(s[k].as_bool().unwrap_or(false)),
Some("yes") => Ok(true),
Some("no") => Ok(false),
Some(o) => Err(refuse("site-flag", format!("{f} yes|no, not {o:?}"))),
}
};
let n = |k: &str, f: &str| -> Result<u64, String> {
match flag(f) {
None => Ok(s["terms"][k].as_u64().unwrap_or(0)),
Some(v) => v.trim().parse().map_err(|_| refuse("site-flag", format!("{f} <a number>, not {v:?}"))),
}
};
let live = match flag("--mode").as_deref() {
None => s["live"].as_bool().unwrap_or(false),
Some("live") => true,
Some("test") => false,
Some(o) => return Err(refuse("site-flag", format!("--mode test|live, not {o:?}"))),
};
let secret = |f: &str| -> Result<String, String> {
match flag(f) {
None => Ok(String::new()),
Some(file) => {
let v = read_file(Path::new(&file))?.trim().to_string();
if v.is_empty() || v.contains('\n') {
return Err(refuse("site-secret-file", format!("{file} holds one secret on one line")));
}
Ok(v)
}
}
};
Ok(serde_json::json!({
"estate": s["estate"].as_str().unwrap_or_default(),
"create": false,
"domain": word("domain", "--domain"),
"product": word("product", "--product"),
"first_admin": word("first_admin", "--first-admin"),
"operator_site": yes("operator_site", "--operator-site")?,
"terminable": yes("terminable", "--terminable")?,
"vendor": word("vendor", "--vendor"),
"vendor_secret_key": secret("--stripe-key-file")?,
"vendor_webhook_secret": secret("--webhook-secret-file")?,
"live": live,
"terms": { "term_days": n("term_days", "--term-days")?, "term_months": n("term_months", "--term-months")?, "grace_hours": n("grace_hours", "--grace-hours")?, "margin_bp": n("margin_bp", "--margin-bp")? },
"media_from": word("media_from", "--media-from"),
}))
}
/// Pure: `edit`'s put, from the box's show of the site and the flags given.
pub fn edit_body(shown_body: &str, words: &[String], read_file: &dyn Fn(&Path) -> Result<String, String>) -> Result<Ask, String> {
let shown: serde_json::Value = serde_json::from_str(shown_body.trim()).map_err(|e| format!("the box's show is not JSON: {e}"))?;
let body = put_body(&shown, words, read_file)?;
Ok(Ask { method: "POST", path: "/site/put".into(), body: body.to_string(), what: What::Put })
}
/// Pure: `site installer-log <site> <vm> [--since <n>]` — one read at the door.
fn installer_log_request(w: &[&str]) -> Result<Ask, String> {
let mut free = Vec::new();
let mut since = None;
let mut it = w.iter().skip(2);
while let Some(a) = it.next() {
if *a == "--since" {
since = Some(it.next().and_then(|n| n.parse::<u64>().ok()).ok_or_else(|| refuse("site-installer-log", "--since <n>: the first line to read, a whole number (0: from the start)"))?);
} else {
free.push(*a);
}
}
let site = free.first().filter(|s| !s.starts_with("--")).ok_or_else(|| refuse("site", "`site installer-log` names the site: its label, as the box declares it (`monetize site list`)"))?;
let vm = free.get(1).ok_or_else(|| refuse("site-installer-log", format!("name the box: `monetize site installer-log {site} <vm>` (front, main, twin)")))?;
Ok(Ask { method: "GET", path: format!("/site/installer-log/{site}/{vm}{}", since.map(|n| format!("?since={n}")).unwrap_or_default()), body: String::new(), what: What::InstallerLog })
}
/// Pure: `site narrate <site> [<vm>] [--tail <n>]` — one read at the door.
fn narrate_request(w: &[&str]) -> Result<Ask, String> {
let mut free = Vec::new();
let mut tail = None;
let mut it = w.iter().skip(2);
while let Some(a) = it.next() {
if *a == "--tail" {
let n = it.next().and_then(|n| n.parse::<u32>().ok()).filter(|n| *n > 0).ok_or_else(|| refuse("site-narrate-tail", "--tail <n>: how many of the box's last lines, a whole number of at least 1"))?;
tail = Some(n);
} else {
free.push(*a);
}
}
let site = free.first().filter(|s| !s.starts_with("--")).ok_or_else(|| refuse("site", "`site narrate` names the site: its label, as the box declares it (`monetize site list`)"))?;
match (free.get(1), tail) {
(None, Some(_)) => Err(refuse("site-narrate-tail", format!("--tail reads one box's log: name it (`monetize site narrate {site} <vm> --tail <n>`)"))),
(None, None) => Ok(Ask { method: "GET", path: format!("/site/narrate/{site}"), body: String::new(), what: What::Narrate }),
(Some(vm), tail) => Ok(Ask { method: "GET", path: format!("/site/narrate/{site}/{vm}{}", tail.map(|n| format!("?tail={n}")).unwrap_or_default()), body: String::new(), what: What::NarrateText }),
}
}
/// Pure: **the run's own `why` as one line** (`<head><why> (since <when>)`), empty when none.
fn why_line(head: &str, why: &str, since: &str) -> String {
match (why.trim(), since.trim()) {
("", _) => String::new(),
(w, "") => format!("{head}{w}\n"),
(w, t) => format!("{head}{w} (since {t})\n"),
}
}
/// Pure: **the box's answer, as the operator reads it** — its own words; `Err` for a refusal.
pub fn said(what: What, status: u16, body: &str) -> Result<String, String> {
let v: serde_json::Value = serde_json::from_str(body.trim()).unwrap_or(serde_json::Value::Null);
let s = |k: &str| v[k].as_str().unwrap_or_default().to_string();
if status != 200 {
let words = if v["words"].is_string() { format!("{} ({})", s("words"), s("refused")) } else { body.trim().chars().take(600).collect() };
return Err(format!("REFUSED by the box (HTTP {status}): {words}"));
}
Ok(match what {
What::List => {
let mut out = String::new();
for site in v["sites"].as_array().into_iter().flatten() {
let g = |k: &str| site[k].as_str().unwrap_or_default();
out.push_str(&format!("{}\n domain {d}, www.{d}, git.{d} — already fed\n Stripe {} — already fed\n DNS {}\n cloud {} — already fed\n first admin {}\n state {} — {}\n", g("estate"), g("vendor"), g("dns_words"), g("cloud_words"), g("first_admin"), g("state"), g("words"), d = g("domain")));
if !g("checkout_url").is_empty() {
out.push_str(&format!(" pay here {}\n", g("checkout_url")));
}
out.push_str(&why_line(" why ", g("why"), g("why_since")));
out.push_str(&match (site["orderable"].as_bool().unwrap_or(false), g("why_not")) {
(true, _) => " can be ordered now\n".to_string(),
(false, why) => format!(" cannot be ordered: {why}\n"),
});
out.push_str(if site["terminable"].as_bool().unwrap_or(false) { " terminable\n" } else { " protected from terminate\n" });
}
let sizes: Vec<&str> = v["sizes"].as_array().into_iter().flatten().filter_map(|z| z.as_str()).collect();
out.push_str(&format!("sizes: {}\n", sizes.join(", ")));
out
}
What::Order => format!("{}\nreference: {}\npay {} here:\n{}\n", s("words"), s("reference"), s("price_words"), s("checkout_url")),
What::Status => format!(
"{} — {} ({})\n{}\n{}{}{}{}",
s("company"),
s("state"),
s("phase"),
s("words"),
why_line("why: ", &s("why"), &s("why_since")),
if s("paid_until").is_empty() { String::new() } else { format!("paid until {}\n", s("paid_until")) },
if s("first_admin_door").is_empty() { String::new() } else { format!("first admin: {} — {}\n", s("first_admin_door"), s("first_admin_door_words")) },
if s("refeed_words").is_empty() { String::new() } else { format!("refeed: {}\n", s("refeed_words")) }
),
What::Cancel => format!("{}\n", s("words")),
What::Do => format!("{}\n", s("words")),
What::DoAnswer => match (v["answered"].as_bool().unwrap_or(false), v["ok"].as_bool().unwrap_or(false)) {
(false, _) => format!("{}: not pulled yet\n", s("kind")),
(true, true) => format!("{}\n", s("detail")),
(true, false) => return Err(format!("REFUSED by the site's box ({}): {}", s("kind"), s("detail"))),
},
What::Kinds => {
let mut out = String::new();
for k in v["kinds"].as_array().into_iter().flatten() {
let g = |n: &str| k[n].as_str().unwrap_or_default().to_string();
let params: Vec<String> = k["params"].as_array().into_iter().flatten().map(|p| format!("{}{}", p["name"].as_str().unwrap_or_default(), if p["required"].as_bool().unwrap_or(false) { "*" } else { "" })).collect();
out.push_str(&format!("{} ({}) {}\n params: {}{}\n", g("kind"), if g("role").is_empty() { "primary".to_string() } else { g("role") }, g("words"), if params.is_empty() { "none".to_string() } else { params.join(", ") }, if g("file").is_empty() { String::new() } else { format!("; file: {}", g("file")) }));
}
if out.is_empty() { "the site's product declares no order kinds\n".into() } else { out }
}
What::ChangePlan => format!(
"{}\nreference: {}\nstatus: {}\n{}",
s("words"),
s("reference"),
s("status"),
if s("checkout_url").is_empty() { String::new() } else { format!("pay {} here:\n{}\n", s("price_words"), s("checkout_url")) }
),
What::FirstLink | What::Refeed => format!("{}\n", s("words")),
What::Console => format!("HTTP {}\n{}{}", v["status"].as_u64().unwrap_or(0), s("body"), if s("body").ends_with('\n') || s("body").is_empty() { "" } else { "\n" }),
What::TestOrder => format!("{}\nreference: {}\n{} held with the test card: nobody pays at a page\n", s("words"), s("reference"), s("price_words")),
What::NarrateText | What::InstallerLog => body.to_string(),
What::Settle => format!("{}\nphase: {}\n", s("words"), s("phase")),
What::Narrate => {
let mut out = format!("{} — {}\n{}\n", s("estate"), if v["narrated"].as_bool().unwrap_or(false) { "narrated" } else { "NOT narrated" }, s("words"));
for vm in v["vms"].as_array().into_iter().flatten() {
let g = |k: &str| vm[k].as_str().unwrap_or_default();
match vm["lines"].as_u64().unwrap_or(0) {
0 => out.push_str(&format!(" {:<5} nothing heard yet\n", g("vm"))),
n => out.push_str(&format!(" {:<5} {n} lines, {} bytes kept, updated {}\n last: {}\n", g("vm"), vm["bytes"].as_u64().unwrap_or(0), g("updated"), g("last"))),
}
}
out.push_str(&format!("one box's log: monetize site narrate {} <vm> [--tail <n>]\n", s("estate")));
out
}
What::Put | What::Edit => {
let changed: Vec<&str> = v["changed"].as_array().into_iter().flatten().filter_map(|c| c.as_str()).collect();
format!("{}\n{}", s("words"), changed.iter().map(|c| format!(" {c}\n")).collect::<String>())
}
What::Show => {
let site = &v["site"];
let g = |k: &str| site[k].as_str().unwrap_or_default().to_string();
let b = |k: &str| if site[k].as_bool().unwrap_or(false) { "yes" } else { "no" };
let secret = |set: &str, fp: &str| if site[set].as_bool().unwrap_or(false) { format!("set ({})", g(fp)) } else { "not set".to_string() };
let t = |k: &str| match site["terms"][k].as_u64().unwrap_or(0) { 0 => "the shop's".to_string(), n => n.to_string() };
let list = |k: &str| v[k].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ");
let mut out = format!(
"{} ({})\n domain {}\n product {}\n first admin {}\n operator site {}\n terminable {}\n Stripe {} ({})\n secret key {}\n webhook secret {}\n terms days {} · months {} · grace hours {} · margin bp {}\n media from {}\n",
g("estate"),
if g("source").is_empty() { "new".to_string() } else { g("source") },
g("domain"),
g("product"),
g("first_admin"),
b("operator_site"),
b("terminable"),
g("vendor"),
if site["live"].as_bool().unwrap_or(false) { "live" } else { "test" },
secret("secret_key_set", "secret_key_fingerprint"),
secret("webhook_secret_set", "webhook_secret_fingerprint"),
t("term_days"),
t("term_months"),
t("grace_hours"),
t("margin_bp"),
if g("media_from").is_empty() { "as it is".to_string() } else { g("media_from") },
);
out.push_str(&format!("choices: products {} · Stripe accounts {} · media {}\nthe shop's: {} · sizes {}\n", list("products"), list("vendors"), list("media_sites"), v["currency"].as_str().unwrap_or_default(), list("sizes")));
for h in v["history"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) {
out.push_str(&format!(" {h}\n"));
}
out
}
What::UpdateCancelPlan => format!("{}PLAN only: nothing was cancelled; say `estate update-cancel <estate> --apply` to cancel the open update (the boxes done stay done)\n", said(What::Update, status, body)?),
What::BoxPlan => format!("{}PLAN only: nothing was staged and the box did not restart; say `box update --apply` (or `box update --back --apply`) to do it\n", said(What::BoxRunning, status, body)?),
What::BoxRunning => format!(
"runs app {} (slot {}), BASE {}\nlive {}\nnext app {} (build {}), BASE {}{}\nupdate {}\nback {}\n",
s("app"),
s("slot"),
s("base"),
if v["live_update"].as_bool().unwrap_or(false) { "on" } else { "OFF" },
s("next_app"),
s("next_build"),
s("next_base"),
if s("next_key").is_empty() { String::new() } else { format!(" (from {})", s("next_key")) },
s("plan"),
if s("back").is_empty() { "(this box does not say)".to_string() } else { s("back") }
),
What::BoxUpdate => format!("{}\n", s("words")),
// The box's words, one block per certificate (edda estate-front's lines); nothing decided here.
What::BoxCerts => {
let certs = v["certificates"].as_array().cloned().unwrap_or_default();
if certs.is_empty() {
return Ok("no certificate is known to this box yet\n".to_string());
}
let mut out = String::new();
for c in &certs {
let hosts: Vec<&str> = c["hosts"].as_array().into_iter().flatten().filter_map(|h| h.as_str()).collect();
out.push_str(&format!("{} ({})\n", c["host"].as_str().unwrap_or_default(), hosts.join(", ")));
for l in c["lines"].as_array().into_iter().flatten().filter_map(|l| l.as_str()) {
out.push_str(&format!(" {l}\n"));
}
}
out
}
// The feed is NEVER said: [`run`] writes it to `--feed-out`.
What::Adopt => format!("{}\nrun: {} ({}) at {}\nclient key (public): {}\nfeed lines: {}\n", s("words"), s("company"), s("reference"), s("host"), s("client_key"), v["feed"].as_object().map(|f| f.keys().cloned().collect::<Vec<_>>().join(", ")).unwrap_or_default()),
What::Inventory => inventory_words(&v),
What::Estates => estates_words(&v),
What::Plugins => {
if s("state") == "failed" {
return Err(format!("REFUSED the plugin job {} failed: {}", s("job"), s("error")));
}
let mut out = String::new();
for r in v["rows"].as_array().into_iter().flatten() {
let g = |k: &str| r[k].as_str().unwrap_or_default();
out.push_str(&format!("{} ({}): {}{}\n", g("name"), g("role"), g("outcome"), if g("detail").is_empty() { String::new() } else { format!(" — {}", g("detail")) }));
}
if !matches!(s("state").as_str(), "done") {
out.push_str(&format!("the job {} is {} — `monetize box plugins` again to see it\n", s("job"), s("state")));
} else if !v["apply"].as_bool().unwrap_or(false) {
out.push_str("nothing was written: add --apply to take them (live, no restart)\n");
}
out
}
What::Update => {
let mut out = format!("{}\nrelease sold now: {}\nlive updates: {}\n", s("host"), s("release"), if v["live_on"].as_bool().unwrap_or(false) { "on" } else { "OFF (keep media only)" });
for role in ["twin", "main", "front"] {
out.push_str(&format!(" {role:<5} runs {}\n", v["release_of"][role].as_str().unwrap_or("(unknown)")));
}
for st in v["steps"].as_array().into_iter().flatten() {
let g = |k: &str| st[k].as_str().unwrap_or_default();
out.push_str(&format!(" step {:<5} {}{}{}\n", g("role"), g("state"), if g("how").is_empty() { String::new() } else { format!(" ({})", g("how")) }, if g("words").is_empty() { String::new() } else { format!(" — {}", g("words")) }));
}
out.push_str(&format!("{}\n", s("words")));
out
}
What::Terminate => {
format!("{}\n", s("words"))
}
})
}
/// **One request at the box's door**: mutual TLS with the operator's certificate under the
/// box's installer CA (`<material>/` as the installer keeps it). `(status, body)`.
pub fn door(t: &Target, ask: &Ask) -> Result<(u16, String), String> {
use nordisk_pairing::installer::{CA_CERT, INSTALLER_CERT, INSTALLER_KEY};
use std::net::ToSocketAddrs as _;
if t.door.trim().is_empty() {
return Err(refuse("door", format!("which box? --door <host:8475> (or ${DOOR_ENV})")));
}
if t.material.as_os_str().is_empty() {
return Err(refuse("material", format!("--material <dir> (or ${MATERIAL_ENV}): the directory holding the operator's certificate for the box's door")));
}
let read = |f: &str| std::fs::read_to_string(t.material.join(f)).map_err(|e| refuse("material", format!("{}: {e}", t.material.join(f).display())));
let me = nordisk_pairing::mtls::Issued { cert_pem: read(INSTALLER_CERT)?, key_pem: read(INSTALLER_KEY)? };
let cfg = nordisk_pairing::mtls::client_config(&read(CA_CERT)?, &me).map_err(|e| refuse("material", e))?;
let dial = Dial {
addr: t.door.clone(),
sock: first_address(&t.door, t.door.to_socket_addrs())?,
name: nordisk_pairing::mtls::vm_name(),
cfg,
host: nordisk_pairing::mtls::VM_SERVER_NAME.to_string(),
headers: Vec::new(),
wait: std::time::Duration::from_secs(120),
};
https(&dial, ask)
}
/// **Where one HTTPS request goes** (the one client, [`https`]): the address, the TLS name
/// and roots it is checked against, the `Host:` and the extra headers, and how long the
/// answer may take.
struct Dial {
/// `host:port`, as the error words say it.
addr: String,
sock: std::net::SocketAddr,
name: rustls::pki_types::ServerName<'static>,
cfg: std::sync::Arc<rustls::ClientConfig>,
host: String,
headers: Vec<(String, String)>,
wait: std::time::Duration,
}
/// The first address a name resolves to, or the refusal naming it.
fn first_address(addr: &str, found: std::io::Result<impl Iterator<Item = std::net::SocketAddr>>) -> Result<std::net::SocketAddr, String> {
found.map_err(|e| format!("{addr}: {e}"))?.next().ok_or_else(|| format!("{addr}: no address"))
}
/// Pure: **one request's bytes**: HTTP/1.1, one JSON request, `Connection: close`, the extra
/// headers after `Host:`.
pub fn request_text(host: &str, headers: &[(String, String)], ask: &Ask) -> String {
let extra: String = headers.iter().map(|(k, v)| format!("{k}: {v}\r\n")).collect();
format!("{} {} HTTP/1.1\r\nHost: {host}\r\n{extra}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", ask.method, ask.path, ask.body.len(), ask.body)
}
/// **The one HTTPS client** (RA1-4): the box's door and a product's front both. One TCP
/// dial (10 s), one TLS stream, the request, the answer read to its close (or the dial's
/// wait), then [`http_answer`]. It goes nowhere but the address it is given.
fn https(d: &Dial, ask: &Ask) -> Result<(u16, String), String> {
use std::io::{Read as _, Write as _};
let addr = &d.addr;
let mut tcp = std::net::TcpStream::connect_timeout(&d.sock, std::time::Duration::from_secs(10)).map_err(|e| format!("{addr}: {e}"))?;
tcp.set_read_timeout(Some(d.wait)).map_err(|e| e.to_string())?;
let conn = rustls::ClientConnection::new(d.cfg.clone(), d.name.clone()).map_err(|e| format!("{addr}: TLS: {e}"))?;
let mut tls = rustls::StreamOwned::new(conn, &mut tcp);
tls.write_all(request_text(&d.host, &d.headers, ask).as_bytes()).map_err(|e| format!("{addr}: {e}"))?;
let mut buf = Vec::new();
// A peer that closes without TLS's close_notify still sent its whole answer.
if let Err(e) = tls.read_to_end(&mut buf) {
if buf.is_empty() {
return Err(format!("{addr}: {e}"));
}
}
http_answer(&buf).map_err(|e| format!("{addr}: {e}"))
}
/// **One request at a product's front** (`front certs`): where, under which roots, with which
/// credential header, and the request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrontAsk {
pub host: String,
pub port: u16,
/// A PEM file of the roots to trust instead of the web's (a front under its own CA).
pub ca: Option<PathBuf>,
/// The admin's credential: (`Authorization`, `Bearer …`) or (`Cookie`, …).
pub header: (String, String),
pub ask: Ask,
}
/// Pure: **`front certs --at https://<site>/<door> …`** as one request, or the refusal naming
/// what is missing. The door is the path the operator names: each product's front says its
/// own (RA1-3), and the core holds none. `read_file` reads the credential's file.
pub fn front_request_of(words: &[String], read_file: &dyn Fn(&Path) -> Result<String, String>) -> Result<FrontAsk, String> {
let w: Vec<&str> = words.iter().map(String::as_str).collect();
let flag = |name: &str| w.iter().position(|a| *a == name).and_then(|i| w.get(i + 1)).map(|v| v.to_string());
let at = flag("--at").unwrap_or_default();
let Some(rest) = at.strip_prefix("https://") else {
return Err(refuse("front-at", "--at https://<the site>/<its certificate door> (the front's own name; https only)"));
};
if let Some(word) = w.get(2).filter(|a| !a.starts_with("--")) {
return Err(refuse("front-door", format!("`front certs {word}`: name the front's certificate door in the address instead, --at https://<site>/<door> (the ONE box's own: `monetize box certs`)")));
}
let (authority, path) = rest.split_once('/').map(|(a, p)| (a, format!("/{p}"))).unwrap_or((rest, String::new()));
let door = path.trim_end_matches('/');
if door.is_empty() || door.contains(['?', '#']) || door.chars().any(char::is_whitespace) {
return Err(refuse("front-door", format!("--at {at}: the front's certificate door as a path, https://<host>[:<port>]/<door> (the one its console page reads)")));
}
if authority.is_empty() {
return Err(refuse("front-at", format!("--at {at}: https://<host>[:<port>]/<door>")));
}
let (host, port) = match authority.rsplit_once(':').filter(|(h, _)| !h.contains(':') || h.ends_with(']')) {
Some((h, p)) => (h.trim_matches(['[', ']']).to_string(), p.parse::<u16>().map_err(|_| refuse("front-at", format!("--at {at}: the port is not a number")))?),
None => (authority.trim_matches(['[', ']']).to_string(), 443),
};
let secret = |name: &str| -> Result<Option<String>, String> {
let Some(f) = flag(name) else { return Ok(None) };
let v = read_file(Path::new(&f))?.trim().to_string();
if v.is_empty() {
return Err(refuse("front-credential", format!("{name} {f}: the file is empty")));
}
Ok(Some(v))
};
let header = match (secret("--token-file")?, secret("--cookie-file")?) {
(Some(t), None) => ("Authorization".to_string(), format!("Bearer {t}")),
(None, Some(c)) => ("Cookie".to_string(), c),
(Some(_), Some(_)) => return Err(refuse("front-credential", "--token-file or --cookie-file, not both")),
(None, None) => return Err(refuse("front-credential", "the certificates are an admin's: --token-file <file> (an admin's bearer token) or --cookie-file <file> (a console session cookie, `<name>=<value>`)")),
};
let ask = match flag("--try") {
Some(h) => Ask { method: "POST", path: format!("{door}/try"), body: serde_json::json!({ "host": h }).to_string(), what: What::BoxUpdate },
None => Ask { method: "GET", path: door.to_string(), body: String::new(), what: What::BoxCerts },
};
Ok(FrontAsk { host, port, ca: flag("--ca").map(PathBuf::from), header, ask })
}
/// Pure: an HTTP/1.1 answer's status and body (a chunked body put back together).
pub fn http_answer(raw: &[u8]) -> Result<(u16, String), String> {
let text = String::from_utf8_lossy(raw);
let (head, body) = text.split_once("\r\n\r\n").ok_or("no HTTP answer")?;
let status = head.split_whitespace().nth(1).and_then(|s| s.parse().ok()).ok_or("no HTTP answer")?;
let chunked = head.lines().any(|l| l.split_once(':').is_some_and(|(k, v)| k.trim().eq_ignore_ascii_case("transfer-encoding") && v.to_ascii_lowercase().contains("chunked")));
if !chunked {
return Ok((status, body.to_string()));
}
let (mut out, mut rest) = (String::new(), body);
while let Some((size, after)) = rest.split_once("\r\n") {
let n = usize::from_str_radix(size.split(';').next().unwrap_or("").trim(), 16).map_err(|_| "a chunked answer that does not parse")?;
if n == 0 || after.len() < n {
break;
}
out.push_str(&after[..n]);
rest = after[n..].trim_start_matches("\r\n");
}
Ok((status, out))
}
/// **One request at a product's front**: HTTPS under the web's roots (or `--ca`), the admin's
/// credential in its header. `(status, body)`.
pub fn front(fa: &FrontAsk) -> Result<(u16, String), String> {
use rustls::pki_types::pem::PemObject as _;
use std::net::ToSocketAddrs as _;
let mut roots = rustls::RootCertStore::empty();
match &fa.ca {
Some(p) => {
for c in rustls::pki_types::CertificateDer::pem_file_iter(p).map_err(|e| refuse("front-ca", format!("{}: {e}", p.display())))? {
roots.add(c.map_err(|e| refuse("front-ca", format!("{}: {e}", p.display())))?).map_err(|e| refuse("front-ca", format!("{}: {e}", p.display())))?;
}
}
None => roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()),
}
let cfg = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.with_root_certificates(roots)
.with_no_client_auth();
let addr = format!("{}:{}", fa.host, fa.port);
let dial = Dial {
sock: first_address(&addr, (fa.host.as_str(), fa.port).to_socket_addrs())?,
name: rustls::pki_types::ServerName::try_from(fa.host.clone()).map_err(|e| format!("{}: {e}", fa.host))?,
cfg: std::sync::Arc::new(cfg),
host: if fa.port == 443 { fa.host.clone() } else { addr.clone() },
headers: vec![fa.header.clone(), ("Accept".to_string(), "application/json".to_string())],
wait: std::time::Duration::from_secs(60),
addr,
};
https(&dial, &fa.ask)
}
/// The whole command: parse, ask the door once, say the answer.
pub fn run(args: &[String]) -> Result<String, String> {
if args.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") {
return Ok(format!("{}\n", help()));
}
if args.first().map(String::as_str) == Some("front") && args.get(1).map(String::as_str) == Some("certs") {
let fa = front_request_of(args, &|p| std::fs::read_to_string(p).map_err(|e| refuse("front-credential", format!("{}: {e}", p.display()))))?;
let (status, body) = front(&fa)?;
if status != 200 {
return Err(format!("REFUSED by the front (HTTP {status}): {}", body.trim().chars().take(600).collect::<String>()));
}
return said(fa.ask.what, status, &body).map(|out| if fa.ask.what == What::BoxCerts { out.replace("no certificate is known to this box yet", "no certificate is known to this front yet") } else { out });
}
let (target, words) = target_of(args, &|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty()))?;
let ask = request_of(&words, &|p| std::fs::read_to_string(p).map_err(|e| refuse("site-file", format!("{}: {e}", p.display()))))?;
let ask = with_return(ask, &target.door);
let ask = if ask.what == What::Console { console_body(ask, &|p| std::fs::read(p).map_err(|e| refuse("site-file", format!("{}: {e}", p.display()))))? } else { ask };
let wait = ask.what == What::Do && serde_json::from_str::<serde_json::Value>(&ask.body).ok().is_some_and(|v| v["wait"].as_bool().unwrap_or(true));
let ask = if ask.what == What::Do { do_file(ask, &|p| std::fs::read(p).map_err(|e| refuse("site-file", format!("{}: {e}", p.display()))))? } else { ask };
let feed_out = words.iter().position(|a| a == "--feed-out").and_then(|i| words.get(i + 1)).map(PathBuf::from);
if ask.what == What::Adopt {
if let Some(p) = feed_out.as_ref().filter(|p| p.exists()) {
return Err(refuse("adopt-feed-out", format!("{} is there already: a new file (an earlier one is never overwritten)", p.display())));
}
}
let (status, body) = door(&target, &ask)?;
if ask.what == What::Adopt && status == 200 {
let p = feed_out.ok_or_else(|| refuse("adopt-feed-out", "--feed-out <file>"))?;
// Written BEFORE anything is said: the answer is the only copy of the client key.
write_secret_file(&p, &adopt_feed(&body)?)?;
return said(What::Adopt, status, &body).map(|out| format!("{out}the front's feed lines are in {} (0600): feed them to the front, then delete the file\n", p.display()));
}
if ask.what == What::Plugins && status == 200 {
let (mut body, started) = (body, std::time::Instant::now());
while let Some(next) = plugins_next(&body) {
if started.elapsed() > PLUGINS_FOR {
break;
}
std::thread::sleep(PLUGINS_EVERY);
let (st, b) = door(&target, &next)?;
if st != 200 {
return said(What::Plugins, st, &b);
}
body = b;
}
return said(What::Plugins, status, &body);
}
// HOL-MAIL: a handed-off site's main was asked; its answer is the answer, never a promise.
if ask.what == What::FirstLink && status == 200 {
let asked = first_link_asked(&body);
if asked > 0 {
let estate = serde_json::from_str::<serde_json::Value>(&ask.body).ok().and_then(|v| v["estate"].as_str().map(str::to_owned)).unwrap_or_default();
eprint!("{}", said(What::FirstLink, status, &body)?);
return follow_first_link(&estate, asked, &mut |a: &Ask| door(&target, a), FIRST_LINK_EVERY, FIRST_LINK_FOR);
}
}
// MONETIZE-FIX: a product order was queued; the box's answer is the answer.
if ask.what == What::Do && status == 200 && wait {
let v: serde_json::Value = serde_json::from_str(body.trim()).unwrap_or(serde_json::Value::Null);
if let Some(seq) = v["seq"].as_u64().filter(|s| *s > 0) {
let estate = serde_json::from_str::<serde_json::Value>(&ask.body).ok().and_then(|v| v["estate"].as_str().map(str::to_owned)).unwrap_or_default();
eprint!("{}", said(What::Do, status, &body)?);
return follow_do(&estate, seq, &mut |a: &Ask| door(&target, a), FIRST_LINK_EVERY, FIRST_LINK_FOR);
}
}
// MAIL-RELAY: the main was fed its keys again; its answer is the answer.
if ask.what == What::Refeed && status == 200 {
let asked = first_link_asked(&body);
if asked > 0 {
let estate = serde_json::from_str::<serde_json::Value>(&ask.body).ok().and_then(|v| v["estate"].as_str().map(str::to_owned)).unwrap_or_default();
eprint!("{}", said(What::Refeed, status, &body)?);
return follow_refeed(&estate, asked, &mut |a: &Ask| door(&target, a), FIRST_LINK_EVERY, FIRST_LINK_FOR);
}
}
if ask.what == What::Edit && status == 200 {
let put = edit_body(&body, &words, &|p| std::fs::read_to_string(p).map_err(|e| refuse("site-secret-file", format!("{}: {e}", p.display()))))?;
let (status, body) = door(&target, &put)?;
return said(What::Put, status, &body);
}
said(ask.what, status, &body)
}
/// The request's key naming the file a `site do` order carries (replaced by its bytes,
/// `file_b64`, by [`do_file`]).
pub const DO_FILE: &str = "file";
/// **A `site do` request with its file's bytes in it** (`file_b64`), read by `read`; the
/// request's own keys (`wait`, the file param's name) are taken out.
pub fn do_file(mut ask: Ask, read: &dyn Fn(&Path) -> Result<Vec<u8>, String>) -> Result<Ask, String> {
let mut v: serde_json::Value = serde_json::from_str(&ask.body).map_err(|e| e.to_string())?;
if let Some(f) = v.get(DO_FILE).and_then(|f| f.as_str()).map(PathBuf::from) {
let bytes = read(&f)?;
v["file_b64"] = serde_json::json!(base64_of(&bytes));
}
if let Some(o) = v.as_object_mut() {
o.remove(DO_FILE);
o.remove("file_param");
o.remove("wait");
}
ask.body = v.to_string();
Ok(ask)
}
/// **A product order, as its box answered it**: the door only queued the signed order `seq`;
/// its answer is looked at through `ask_door` every `every` until the box answered, for at most
/// `for_` (looked at once at least). Not answered in time: an error naming how to look later.
pub fn follow_do(estate: &str, seq: u64, ask_door: &mut AskDoor<'_>, every: std::time::Duration, for_: std::time::Duration) -> Result<String, String> {
let look = Ask { method: "GET", path: format!("/site/do/{estate}/{seq}"), body: String::new(), what: What::DoAnswer };
let started = std::time::Instant::now();
loop {
let (status, body) = ask_door(&look)?;
let v: serde_json::Value = serde_json::from_str(body.trim()).unwrap_or(serde_json::Value::Null);
if status != 200 || v["answered"].as_bool().unwrap_or(false) {
return said(What::DoAnswer, status, &body);
}
if started.elapsed() >= for_ {
return Err(format!("{estate}'s box has not answered order {seq} within {} s: `site do-answer {estate} {seq}` says its answer once it pulls the order", for_.as_secs()));
}
std::thread::sleep(every);
}
}
/// The request's key naming the file a `site console` call sends (replaced by its bytes,
/// `body_b64`, by [`console_body`]).
pub const CONSOLE_BODY_FILE: &str = "body_file";
/// **A `site console` request with its file's bytes in it** (`body_b64`), read by `read`.
pub fn console_body(mut ask: Ask, read: &dyn Fn(&Path) -> Result<Vec<u8>, String>) -> Result<Ask, String> {
let mut v: serde_json::Value = serde_json::from_str(&ask.body).map_err(|e| e.to_string())?;
if let Some(f) = v.get(CONSOLE_BODY_FILE).and_then(|f| f.as_str()).map(PathBuf::from) {
let bytes = read(&f)?;
v["body_b64"] = serde_json::json!(base64_of(&bytes));
if let Some(o) = v.as_object_mut() {
o.remove(CONSOLE_BODY_FILE);
}
ask.body = v.to_string();
}
Ok(ask)
}
/// Pure: standard base64 with padding.
pub fn base64_of(b: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
for c in b.chunks(3) {
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
out.push(A[(n >> 18) as usize & 63] as char);
out.push(A[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' });
}
out
}
/// Pure: **the box's own Sites page, from its door** (`monetize.ignalina.dk:8475` →
/// `https://monetize.ignalina.dk/sites`); empty for a door named by an address or a bare name
/// (no page the payer's browser could open is guessed).
pub fn sites_page_of(door: &str) -> String {
let host = door.trim().rsplit_once(':').map(|(h, _)| h).unwrap_or(door.trim()).trim_matches(['[', ']']);
if host.is_empty() || !host.contains('.') || host.parse::<std::net::IpAddr>().is_ok() {
return String::new();
}
format!("https://{host}/sites")
}
/// Pure: an ORDER that names no `--return` comes back to the box's Sites page
/// ([`sites_page_of`]); any other request is unchanged.
pub fn with_return(mut ask: Ask, door: &str) -> Ask {
if ask.what != What::Order {
return ask;
}
let Ok(mut body) = serde_json::from_str::<serde_json::Value>(&ask.body) else { return ask };
if body["return_url"].as_str().unwrap_or("").trim().is_empty() {
body["return_url"] = serde_json::Value::String(sites_page_of(door));
ask.body = body.to_string();
}
ask
}
#[cfg(test)]
mod tests {
/// ★ **`front certs`** (A7; RA1-3): the front's certificate door is the address the
/// operator names (`--at https://<site>/<door>`), so the core holds no product's door;
/// try now is that door's `/try`; the admin's credential from its file; refused by name
/// when the door, the address or the credential is missing or wrong.
#[test]
fn front_certs_asks_the_door_the_operator_names_with_the_admins_credential() {
let w = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let files = |p: &std::path::Path| match p.to_str() {
Some("/t") => Ok(" tok-1 \n".to_string()),
Some("/c") => Ok("console_session=s1".to_string()),
Some("/empty") => Ok("\n".to_string()),
_ => Err("no such file".to_string()),
};
let h = front_request_of(&w(&["front", "certs", "--at", "https://a.example/front/certs", "--token-file", "/t"]), &files).unwrap();
assert_eq!((h.host.as_str(), h.port, h.ask.method, h.ask.path.as_str(), h.ask.what), ("a.example", 443, "GET", "/front/certs", What::BoxCerts));
assert_eq!(h.header, ("Authorization".to_string(), "Bearer tok-1".to_string()));
let g = front_request_of(&w(&["front", "certs", "--at", "https://b.example:8443/api/front/certs/", "--cookie-file", "/c", "--try", "www.b.example", "--ca", "/ca.pem"]), &files).unwrap();
assert_eq!((g.host.as_str(), g.port, g.ask.method, g.ask.path.as_str()), ("b.example", 8443, "POST", "/api/front/certs/try"));
assert_eq!(g.ask.body, r#"{"host":"www.b.example"}"#);
assert_eq!((g.header.0.as_str(), g.ca.as_deref()), ("Cookie", Some(std::path::Path::new("/ca.pem"))));
let v6 = front_request_of(&w(&["front", "certs", "--at", "https://[::1]:9443/x/certs", "--token-file", "/t"]), &files).unwrap();
assert_eq!((v6.host.as_str(), v6.port, v6.ask.path.as_str()), ("::1", 9443, "/x/certs"));
for (a, why) in [
(w(&["front", "certs"]), "[front-at]"),
(w(&["front", "certs", "--token-file", "/t"]), "[front-at]"),
(w(&["front", "certs", "--at", "http://x.example/front/certs", "--token-file", "/t"]), "[front-at]"),
// The door is the site's own path: an address with none names no door.
(w(&["front", "certs", "--at", "https://x.example", "--token-file", "/t"]), "[front-door]"),
(w(&["front", "certs", "--at", "https://x.example/", "--token-file", "/t"]), "[front-door]"),
(w(&["front", "certs", "--at", "https://x.example/c?x=1", "--token-file", "/t"]), "[front-door]"),
// The old form named a product; the core knows no product's door.
(w(&["front", "certs", "someproduct", "--at", "https://x.example/front/certs", "--token-file", "/t"]), "[front-door]"),
(w(&["front", "certs", "--at", "https://x.example/front/certs"]), "[front-credential]"),
(w(&["front", "certs", "--at", "https://x.example/front/certs", "--token-file", "/empty"]), "[front-credential]"),
(w(&["front", "certs", "--at", "https://x.example/front/certs", "--token-file", "/t", "--cookie-file", "/c"]), "[front-credential]"),
] {
let e = front_request_of(&a, &files).unwrap_err();
assert!(e.contains(why), "{a:?}: {e}");
}
}
/// ★ **One HTTPS client** (RA1-4): the box's door and a product's front go through the
/// same request, the same TLS stream and the same answer parser; the runtime code dials
/// one connection in one place.
#[test]
fn the_door_and_the_front_share_one_client() {
let src = include_str!("lib.rs");
let runtime = &src[..src.find("#[cfg(test)]").unwrap()];
for needle in ["ClientConnection::new(", "TcpStream::connect_timeout(", "read_to_end(", "HTTP/1.1\\r\\n"] {
assert_eq!(runtime.matches(needle).count(), 1, "{needle} is in more than one client");
}
assert_eq!(runtime.matches("http_answer(").count(), 2, "the one parser is defined once and called by the one client");
}
/// The one request's bytes: method and path, `Host:`, the extra headers, the JSON body
/// and its length, `Connection: close`.
#[test]
fn one_request_is_http11_with_its_headers_and_a_close() {
let ask = Ask { method: "POST", path: "/d/try".into(), body: r#"{"host":"h"}"#.into(), what: What::BoxUpdate };
let t = request_text("a.example:8443", &[("Authorization".into(), "Bearer t".into())], &ask);
assert_eq!(t, "POST /d/try HTTP/1.1\r\nHost: a.example:8443\r\nAuthorization: Bearer t\r\nContent-Type: application/json\r\nContent-Length: 12\r\nConnection: close\r\n\r\n{\"host\":\"h\"}");
let get = Ask { method: "GET", path: "/self/certs".into(), body: String::new(), what: What::BoxCerts };
assert!(request_text("vm", &[], &get).starts_with("GET /self/certs HTTP/1.1\r\nHost: vm\r\nContent-Type"));
}
/// An HTTP/1.1 answer, plain or chunked.
#[test]
fn an_http_answer_is_read_plain_or_chunked() {
assert_eq!(http_answer(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\n{}").unwrap(), (200, "{}".to_string()));
assert_eq!(http_answer(b"HTTP/1.1 401 X\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nREFU\r\n3\r\nSED\r\n0\r\n\r\n").unwrap(), (401, "REFUSED".to_string()));
assert!(http_answer(b"garbage").is_err());
}
/// ★ **`box certs [--try <host>]`** (owner 2026-10-25): the box's certificates as it says
/// them, and an admin's "try now" — the box decides; the CLI only shows.
#[test]
fn box_certs_reads_the_certificates_and_try_names_the_host() {
let no_file = |_: &Path| -> Result<String, String> { Err("no file".into()) };
assert_eq!(request_of(&w(&["box", "certs"]), &no_file).unwrap(), Ask { method: "GET", path: "/self/certs".into(), body: String::new(), what: What::BoxCerts });
let t = request_of(&w(&["box", "certs", "--try", "test2.example"]), &no_file).unwrap();
assert_eq!((t.method, t.path.as_str(), t.what), ("POST", "/self/certs/try", What::BoxUpdate));
assert_eq!(serde_json::from_str::<serde_json::Value>(&t.body).unwrap()["host"], "test2.example");
let out = said(What::BoxCerts, 200, r#"{"certificates":[{"host":"test2.example","hosts":["test2.example","www.test2.example"],"lines":["valid until: no valid certificate","last reason: dns: x"]}]}"#).unwrap();
assert_eq!(out, "test2.example (test2.example, www.test2.example)\n valid until: no valid certificate\n last reason: dns: x\n");
assert!(said(What::BoxCerts, 200, r#"{"certificates":[]}"#).unwrap().contains("no certificate"));
}
use super::*;
fn w(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
}
fn no_file(_: &Path) -> Result<String, String> {
Err("no file".into())
}
/// ★ **`box plugins [--apply]`** (owner 2026-10-17): one POST that queues the job, then the
/// job looked at until it is done or failed; one line per plugin in the box's words; a look
/// without `--apply` says nothing was written; a failed job is a refusal by name.
#[test]
fn box_plugins_queues_the_job_follows_it_and_says_each_plugin() {
assert_eq!(request_of(&w(&["box", "plugins"]), &no_file).unwrap(), Ask { method: "POST", path: "/box/plugins".into(), body: r#"{"apply":false}"#.into(), what: What::Plugins });
assert_eq!(request_of(&w(&["box", "plugins", "--apply"]), &no_file).unwrap().body, r#"{"apply":true}"#);
let queued = r#"{"job":"plugins-4","state":"queued","apply":true,"rows":[],"error":""}"#;
assert_eq!(plugins_next(queued), Some(Ask { method: "GET", path: "/box/plugins/plugins-4".into(), body: String::new(), what: What::Plugins }));
assert!(plugins_next(&queued.replace("queued", "running")).is_some());
let done = r#"{"job":"plugins-4","state":"done","apply":true,"error":"","rows":[{"name":"stripe.wasm","role":"vendor","outcome":"replaced","detail":"[vendors.stripe.gunnar]","blake3":"eb45"},{"name":"upcloud.wasm","role":"cloud","outcome":"refused","detail":"[plugin-unsigned] did not verify","blake3":"3d6c"}]}"#;
assert_eq!(plugins_next(done), None);
let out = said(What::Plugins, 200, done).unwrap();
assert!(out.contains("stripe.wasm (vendor): replaced — [vendors.stripe.gunnar]\n") && out.contains("upcloud.wasm (cloud): refused — [plugin-unsigned] did not verify\n"), "{out}");
assert!(!out.contains("--apply"), "{out}");
let look = said(What::Plugins, 200, &done.replace(r#""apply":true"#, r#""apply":false"#).replace("replaced", "would-replace")).unwrap();
assert!(look.contains("stripe.wasm (vendor): would-replace") && look.contains("nothing was written") && look.contains("--apply"), "{look}");
let failed = r#"{"job":"plugins-5","state":"failed","apply":true,"rows":[],"error":"[plugins-list-unsigned] the list did not verify"}"#;
assert_eq!(plugins_next(failed), None);
assert!(said(What::Plugins, 200, failed).unwrap_err().contains("[plugins-list-unsigned]"));
}
/// ★ **`site first-link <site> --to <address>`** (owner 2026-10-22): ONE request at the
/// door, the address in the body; no `--to` is refused by name; the help names it.
#[test]
fn first_link_is_one_request_with_the_new_address() {
let a = request_of(&w(&["site", "first-link", "holger-rs", "--to", "owner@example.invalid"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/site/first-link", What::FirstLink));
assert_eq!(serde_json::from_str::<serde_json::Value>(&a.body).unwrap(), serde_json::json!({ "estate": "holger-rs", "to": "owner@example.invalid" }));
// FIRST-LINK-1: no --to is the first admin's recorded address (a handed-off site's main
// mints the link).
let a = request_of(&w(&["site", "first-link", "holger-rs"]), &no_file).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&a.body).unwrap(), serde_json::json!({ "estate": "holger-rs", "to": "" }));
assert!(help().contains("site first-link <site> [--to <address>]"), "{}", help());
assert!(request_of(&w(&["site", "first-link"]), &no_file).is_err(), "the site is named");
assert_eq!(said(What::FirstLink, 200, r#"{"words":"holger-rs's first sign-in link is mailed to o***@example.invalid again"}"#).unwrap(), "holger-rs's first sign-in link is mailed to o***@example.invalid again\n");
}
/// ★ **A handed-off site's `site first-link` says its MAIN's answer, never a promise**
/// (HOL-MAIL, holger-rs 2026-10-27: the CLI said "a new sign-in link is on its way" while the
/// site's main refused, fed no relay). The door names the order it queued (`asked`); the CLI
/// looks at the site's status until the main answered THAT order: refused by name with the
/// main's reason (an error), minted and mailed, or not answered in time (an error that says
/// nothing is known to be mailed). An older order's answer is not this one's.
#[test]
fn a_handed_off_sites_first_link_says_the_mains_answer_never_a_promise() {
let status = |answered: u64, mailed: bool, words: &str| serde_json::json!({ "company": "holgerrs", "first_admin_door": "handed_off", "first_admin_door_words": words, "first_link_answered": answered, "first_link_mailed": mailed }).to_string();
assert_eq!(first_link_asked(r#"{"words":"holger-rs is handed off: nothing is mailed until its main answers","asked":7}"#), 7);
assert_eq!(first_link_asked(r#"{"words":"holger-rs's first sign-in link is mailed to o***@example.invalid again"}"#), 0, "no main asked: the words are the answer");
let refused = "holger-rs is handed off; its main refused the last ask: this box cannot mail (mail is off: this box was fed no relay (secret.smtp_url), so it dials nothing), so it mints no sign-in link for ignalina it could not mail";
let mut looks = vec![status(0, false, "holger-rs is handed off: a new first sign-in link was asked; its main has not pulled the order yet"), status(7, false, refused)].into_iter();
let mut seen = Vec::new();
let e = follow_first_link("holger-rs", 7, &mut |a: &Ask| {
seen.push((a.method, a.path.clone()));
Ok((200, looks.next().expect("two looks")))
}, std::time::Duration::ZERO, std::time::Duration::from_secs(60))
.unwrap_err();
assert_eq!(seen, vec![("GET", "/site/status/holger-rs".to_string()); 2], "the site's status, until the main answered");
assert!(e.starts_with("REFUSED by holger-rs's main") && e.contains("fed no relay (secret.smtp_url)") && !e.contains("on its way"), "{e}");
let mailed = "holger-rs is handed off; its main answered the last ask: a new sign-in link for ignalina (24 hours) was mailed to f***@mock.invalid";
let ok = follow_first_link("holger-rs", 8, &mut |_: &Ask| Ok((200, status(8, true, mailed))), std::time::Duration::ZERO, std::time::Duration::from_secs(60)).unwrap();
assert!(ok.contains("holger-rs's main minted a new sign-in link and mailed it") && ok.contains("f***@mock.invalid"), "{ok}");
// Only an older order is answered, and the time is up: nothing is known to be mailed.
let e = follow_first_link("holger-rs", 9, &mut |_: &Ask| Ok((200, status(8, true, mailed))), std::time::Duration::ZERO, std::time::Duration::ZERO).unwrap_err();
assert!(e.contains("holger-rs's main has not answered") && e.contains("nothing is known to be mailed") && e.contains("site status holger-rs"), "{e}");
// A refused look is said as the box said it.
let e = follow_first_link("holger-rs", 9, &mut |_: &Ask| Ok((503, r#"{"words":"monetize is not up","refused":"site-unavailable"}"#.into())), std::time::Duration::ZERO, std::time::Duration::from_secs(60)).unwrap_err();
assert!(e.contains("REFUSED by the box (HTTP 503)"), "{e}");
}
/// ★ **`site console <site> <METHOD> <path>`** (OX-WGDS3 2026-10-28): ONE request at the
/// door naming the site, the method, the path and query, the host (`--repo`) and the content
/// type; a body file's bytes ride as base64; the answer says the front's status, then its body.
#[test]
fn site_console_is_one_request_and_says_the_fronts_answer() {
let a = request_of(&w(&["site", "console", "test3-holger-rs", "post", "/-/sync/downstreams/enrol?name=lab-1&key=ab&via=front"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/site/console", What::Console));
assert_eq!(serde_json::from_str::<serde_json::Value>(&a.body).unwrap(), serde_json::json!({ "estate": "test3-holger-rs", "method": "POST", "path": "/-/sync/downstreams/enrol?name=lab-1&key=ab&via=front", "repo": false }));
let a = request_of(&w(&["site", "console", "test3-holger-rs", "PUT", "/bundles/bundle-1.znippy", "--repo", "--body-file", "/tmp/b1", "--content-type", "application/octet-stream"]), &no_file).unwrap();
let a = console_body(a, &|p| {
assert_eq!(p, Path::new("/tmp/b1"));
Ok(vec![0u8, 1, 2, 255, b'h', b'i', b'!'])
})
.unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&a.body).unwrap(), serde_json::json!({ "estate": "test3-holger-rs", "method": "PUT", "path": "/bundles/bundle-1.znippy", "repo": true, "content_type": "application/octet-stream", "body_b64": "AAEC/2hpIQ==" }));
for bad in [&["site", "console", "x"][..], &["site", "console", "x", "GET"], &["site", "console", "x", "GET", "nopath"]] {
assert!(request_of(&w(bad), &no_file).is_err(), "{bad:?}");
}
assert!(help().contains("site console <site> <METHOD> <path>"), "{}", help());
assert_eq!(said(What::Console, 200, r#"{"status":403,"body":"REFUSED [downstream-door] no"}"#).unwrap(), "HTTP 403\nREFUSED [downstream-door] no\n");
assert_eq!(said(What::Console, 200, r#"{"status":200,"body":"{\"name\":\"cloud\"}\n"}"#).unwrap(), "HTTP 200\n{\"name\":\"cloud\"}\n");
let refused = said(What::Console, 409, r#"{"words":"[site-console-not-allowed] DELETE /x is not a console call","refused":"FailedPrecondition"}"#).unwrap_err();
assert!(refused.contains("[site-console-not-allowed]"), "{refused}");
assert_eq!(base64_of(b""), "");
assert_eq!(base64_of(b"f"), "Zg==");
assert_eq!(base64_of(b"fo"), "Zm8=");
assert_eq!(base64_of(b"foo"), "Zm9v");
}
/// ★ **`site do <site> <kind> …`** (MONETIZE-FIX 2026-11-01): ONE request naming the kind and
/// its params; the file is read where the request is sent; the box's answer is followed until
/// it pulled the order, and a refusal is said as one.
#[test]
fn site_do_queues_one_request_and_says_the_boxs_answer() {
let a = request_of(&w(&["site", "do", "test3-holger-rs", "downstream_enrol", "name=lab-1", "via=front", "--file", "bundle=/tmp/b"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/site/do", What::Do));
let a = do_file(a, &|p| { assert_eq!(p, Path::new("/tmp/b")); Ok(b"hi".to_vec()) }).unwrap();
let v: serde_json::Value = serde_json::from_str(&a.body).unwrap();
assert_eq!(v, serde_json::json!({ "estate": "test3-holger-rs", "kind": "downstream_enrol", "params": { "name": "lab-1", "via": "front" }, "file_b64": "aGk=" }));
assert!(request_of(&w(&["site", "do", "x"]), &no_file).is_err(), "the kind is named");
assert!(request_of(&w(&["site", "do", "x", "k", "novalue"]), &no_file).is_err());
assert_eq!(request_of(&w(&["site", "kinds", "x"]), &no_file).unwrap().path, "/site/kinds/x");
assert_eq!(request_of(&w(&["site", "do-answer", "x", "7"]), &no_file).unwrap().path, "/site/do/x/7");
assert!(help().contains("site do <site> <kind>"), "{}", help());
let mut looks = vec![r#"{"kind":"downstream_enrol","answered":false}"#, r#"{"kind":"downstream_enrol","answered":true,"ok":true,"detail":"{\"name\":\"lab-1\"}"}"#].into_iter();
let out = follow_do("x", 7, &mut |_: &Ask| Ok((200, looks.next().unwrap().to_string())), std::time::Duration::ZERO, std::time::Duration::from_secs(60)).unwrap();
assert!(out.contains("lab-1"), "{out}");
let e = follow_do("x", 7, &mut |_: &Ask| Ok((200, r#"{"kind":"k","answered":true,"ok":false,"detail":"no such downstream"}"#.into())), std::time::Duration::ZERO, std::time::Duration::ZERO).unwrap_err();
assert!(e.contains("no such downstream"), "{e}");
let e = follow_do("x", 7, &mut |_: &Ask| Ok((200, r#"{"kind":"k","answered":false}"#.into())), std::time::Duration::ZERO, std::time::Duration::ZERO).unwrap_err();
assert!(e.contains("do-answer x 7"), "{e}");
}
/// ★ **`site change-plan <site>`** (MONETIZE-FIX 2026-11-01): ONE request at the door naming
/// the site and what changes; nothing named is refused before the door is asked.
#[test]
fn site_change_plan_is_one_request_naming_what_changes() {
let a = request_of(&w(&["site", "change-plan", "test2-gunnar-rs", "--data-gib", "60", "--test"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/site/change-plan", What::ChangePlan));
let v: serde_json::Value = serde_json::from_str(&a.body).unwrap();
assert_eq!((v["estate"].as_str(), v["data_gib"].as_u64(), v["test"].as_bool()), (Some("test2-gunnar-rs"), Some(60), Some(true)));
let a = request_of(&w(&["site", "change-plan", "lake", "--set", "be=3", "--set", "graph=true"]), &no_file).unwrap();
let v: serde_json::Value = serde_json::from_str(&a.body).unwrap();
assert_eq!(v["order"], serde_json::json!({ "be": 3, "graph": true }));
assert!(request_of(&w(&["site", "change-plan", "lake"]), &no_file).unwrap_err().contains("name what changes"));
assert!(request_of(&w(&["site", "change-plan", "lake", "--data-gib", "x"]), &no_file).is_err());
assert!(request_of(&w(&["site", "change-plan"]), &no_file).is_err(), "the site is named");
assert!(help().contains("site change-plan <site>"), "{}", help());
let held = r#"{"words":"main's data disks grow to 60 GB","reference":"estate/x/change-1","status":"waiting_for_payment","checkout_url":"https://pay.invalid/1","price_words":"1.00 EUR"}"#;
let out = said(What::ChangePlan, 200, held).unwrap();
assert!(out.contains("change-1") && out.contains("https://pay.invalid/1"), "{out}");
}
/// ★ **`site refeed <site>`** (MAIL-RELAY + NIGHT-PUBREPO 2026-10-28): ONE request at the
/// door naming the site and the groups asked (none: all), no value; the help names it; the
/// door's answer is followed until the site's MAIN answered the order it queued: kept,
/// refused by name with its reason, or not answered in time (nothing is known to be kept).
#[test]
fn site_refeed_is_one_request_and_says_the_mains_answer() {
let a = request_of(&w(&["site", "refeed", "holger-rs"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/site/refeed", What::Refeed));
assert_eq!(serde_json::from_str::<serde_json::Value>(&a.body).unwrap(), serde_json::json!({ "estate": "holger-rs", "only": [] }));
let a = request_of(&w(&["site", "refeed", "holger-rs", "--only", "mail_relay", "--only", "public_repositories"]), &no_file).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&a.body).unwrap(), serde_json::json!({ "estate": "holger-rs", "only": ["mail_relay", "public_repositories"] }));
assert!(help().contains("site refeed <site>"), "{}", help());
assert!(request_of(&w(&["site", "refeed"]), &no_file).is_err(), "the site is named");
let fed = r#"{"words":"the mail relay relay.mock.invalid:465 (implicit TLS) as es***@mock.invalid fed again to holger-rs's main and twin: signed orders main 7, twin 8 (refeed)","asked":7}"#;
assert_eq!(first_link_asked(fed), 7);
assert!(said(What::Refeed, 200, fed).unwrap().contains("main 7"));
let refused = said(What::Refeed, 412, r#"{"words":"[site-refeed-none] this ONE box holds nothing to feed holger-rs again","refused":"site-refused"}"#).unwrap_err();
assert!(refused.contains("[site-refeed-none]"), "{refused}");
let status = |answered: u64, kept: bool, words: &str| serde_json::json!({ "company": "holgerrs", "refeed_answered": answered, "refeed_kept": kept, "refeed_words": words }).to_string();
let mut looks = vec![status(0, false, "holger-rs's main was fed its keys again (order 7); it has not pulled the order yet"), status(7, false, "holger-rs's main refused its keys (order 7): order 7: Holger does not know the order kind \"refeed\"")].into_iter();
let e = follow_refeed("holger-rs", 7, &mut |a: &Ask| {
assert_eq!((a.method, a.path.as_str()), ("GET", "/site/status/holger-rs"));
Ok((200, looks.next().expect("two looks")))
}, std::time::Duration::ZERO, std::time::Duration::from_secs(60))
.unwrap_err();
assert!(e.starts_with("REFUSED by holger-rs's main") && e.contains("does not know"), "{e}");
let kept = "holger-rs's main kept its keys (order 8): kept public_repositories, secret.smtp_from, secret.smtp_url";
let ok = follow_refeed("holger-rs", 8, &mut |_: &Ask| Ok((200, status(8, true, kept))), std::time::Duration::ZERO, std::time::Duration::from_secs(60)).unwrap();
assert!(ok.contains("holger-rs's main kept its keys") && ok.contains("public_repositories"), "{ok}");
let e = follow_refeed("holger-rs", 9, &mut |_: &Ask| Ok((200, status(8, true, kept))), std::time::Duration::ZERO, std::time::Duration::ZERO).unwrap_err();
assert!(e.contains("holger-rs's main has not answered") && e.contains("nothing is known to be kept") && e.contains("site status holger-rs"), "{e}");
}
/// ★ **`site test-order <site>`** (owner: "monetize must be automatic"): ONE request at the
/// door's own test verb — the order's words, never a return page (nobody pays at one); no
/// `--admin` is refused by name; the answer says it was paid with the test card.
#[test]
fn site_test_order_is_one_request_at_the_doors_test_verb() {
let a = request_of(&w(&["site", "test-order", "test2-gunnar-rs", "--size", "mini", "--admin", "ops@example.invalid"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/site/test-order", What::TestOrder));
assert_eq!(serde_json::from_str::<serde_json::Value>(&a.body).unwrap(), serde_json::json!({ "estate": "test2-gunnar-rs", "size": "mini", "email": "ops@example.invalid", "seed_key": "" }));
assert!(request_of(&w(&["site", "test-order", "x"]), &no_file).unwrap_err().contains("[site-admin]"));
assert!(request_of(&w(&["site", "test-order", "--admin", "a@b.invalid"]), &no_file).unwrap_err().contains("[site]"));
assert!(request_of(&w(&["site", "test-order", "x", "--admin", "a@b.invalid", "--return", "https://x.example/"]), &no_file).unwrap_err().contains("[site-test-order-return]"), "no page, so no way back");
assert!(help().contains("site test-order <site> [--size <step>] --admin <address>"), "{}", help());
let out = said(What::TestOrder, 200, r#"{"reference":"estate/test2gunnarrs","company":"test2gunnarrs","checkout_url":"","price_words":"0.50 EUR","words":"test2-gunnar-rs is ordered and paid with the vendor's TEST card (pm_card_visa): this box lays it"}"#).unwrap();
assert_eq!(out, "test2-gunnar-rs is ordered and paid with the vendor's TEST card (pm_card_visa): this box lays it\nreference: estate/test2gunnarrs\n0.50 EUR held with the test card: nobody pays at a page\n");
let live = said(What::TestOrder, 409, r#"{"refused":"FailedPrecondition","words":"REFUSED [test-order-live-key] test2-gunnar-rs's vendor holds a LIVE key"}"#).unwrap_err();
assert!(live.contains("[test-order-live-key]"), "{live}");
}
/// ★ Each command is ONE request at the box's door, and names nothing the box already
/// knows of a declared site: no domain, no Stripe key, no DNS token, no cloud.
#[test]
fn each_command_is_one_request_at_the_door() {
let a = request_of(&w(&["site", "list"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.body.as_str()), ("GET", "/site/list", ""));
let a = request_of(&w(&["site", "order", "test3-gunnar-rs", "--size", "mini", "--admin", "ops@example.invalid"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str()), ("POST", "/site/order"));
let body: serde_json::Value = serde_json::from_str(&a.body).unwrap();
assert_eq!(body, serde_json::json!({ "estate": "test3-gunnar-rs", "size": "mini", "email": "ops@example.invalid", "seed_key": "", "return_url": "" }));
assert_eq!(request_of(&w(&["site", "status", "gunnar-rs"]), &no_file).unwrap().path, "/site/status/gunnar-rs");
assert_eq!(request_of(&w(&["site", "cancel", "gunnar-rs"]), &no_file).unwrap().body, r#"{"estate":"gunnar-rs"}"#);
let t = request_of(&w(&["site", "terminate", "test3-gunnar-rs", "--confirm", "test3-gunnar-rs"]), &no_file).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&t.body).unwrap(), serde_json::json!({ "estate": "test3-gunnar-rs", "confirm": "test3-gunnar-rs" }));
// No backup at terminate (owner 2026-10-22): the flag is refused by name, never ignored.
let b = request_of(&w(&["site", "terminate", "test3-gunnar-rs", "--confirm", "test3-gunnar-rs", "--backup", "take"]), &no_file).unwrap_err();
assert!(b.contains("[terminate-no-backup]"), "{b}");
assert!(!help().contains("--backup"), "the help offers no backup");
assert!(request_of(&w(&["site", "order", "x"]), &no_file).unwrap_err().contains("[site-admin]"));
assert!(request_of(&w(&["site", "terminate", "x"]), &no_file).unwrap_err().contains("[site-confirm]"), "the name is typed back");
assert!(request_of(&w(&["site", "order", "--size", "mini"]), &no_file).unwrap_err().contains("[site]"));
assert!(request_of(&w(&["shop"]), &no_file).unwrap_err().contains("[usage]"));
}
/// ★ **`monetize site narrate <site> [vm] [--tail N]`** (2026-10-20): ONE read at the door —
/// each box's summary, or one box's boot log printed as the box sent it.
#[test]
fn site_narrate_is_one_read_at_the_door() {
let a = request_of(&w(&["site", "narrate", "test3-holger-rs"]), &no_file).unwrap();
assert_eq!(a, Ask { method: "GET", path: "/site/narrate/test3-holger-rs".into(), body: String::new(), what: What::Narrate });
let a = request_of(&w(&["site", "narrate", "test3-holger-rs", "main"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("GET", "/site/narrate/test3-holger-rs/main", What::NarrateText));
let a = request_of(&w(&["site", "narrate", "test3-holger-rs", "main", "--tail", "50"]), &no_file).unwrap();
assert_eq!(a.path, "/site/narrate/test3-holger-rs/main?tail=50");
let a = request_of(&w(&["site", "narrate", "test3-holger-rs", "--tail", "50", "main"]), &no_file).unwrap();
assert_eq!(a.path, "/site/narrate/test3-holger-rs/main?tail=50", "the flag anywhere");
assert!(request_of(&w(&["site", "narrate", "test3-holger-rs", "main", "--tail", "x"]), &no_file).unwrap_err().contains("[site-narrate-tail]"));
assert!(request_of(&w(&["site", "narrate", "test3-holger-rs", "--tail", "5"]), &no_file).unwrap_err().contains("[site-narrate-tail]"), "--tail reads one box");
assert!(request_of(&w(&["site", "narrate"]), &no_file).unwrap_err().contains("[site]"));
let out = said(What::Narrate, 200, r#"{"estate":"test3-holger-rs","narrated":true,"words":"heard here","vms":[{"vm":"main","lines":2,"bytes":8,"updated":"2026-10-20 16:10:00 UTC","last":"two"},{"vm":"front","lines":0,"bytes":0,"updated":"","last":""}]}"#).unwrap();
assert!(out.starts_with("test3-holger-rs — narrated\nheard here\n"), "{out}");
assert!(out.contains(" main ") && out.contains("2 lines") && out.contains("2026-10-20 16:10:00 UTC") && out.contains("two"), "{out}");
assert!(out.contains(" front") && out.contains("nothing heard yet"), "{out}");
assert!(out.contains("monetize site narrate test3-holger-rs <vm>"), "{out}");
assert_eq!(said(What::NarrateText, 200, "a\nb\n").unwrap(), "a\nb\n", "the log as the box sent it");
assert!(said(What::NarrateText, 404, r#"{"refused":"NotFound","words":"test3 has no machine \"x\""}"#).unwrap_err().contains("no machine"));
}
/// ★ **`monetize site installer-log <site> <vm> [--since N]`** (2026-10-20): ONE read at the
/// door — the box's install log as the ONE box read it over the installer's link.
#[test]
fn site_installer_log_is_one_read_at_the_door() {
let a = request_of(&w(&["site", "installer-log", "test3-holger-rs", "front"]), &no_file).unwrap();
assert_eq!(a, Ask { method: "GET", path: "/site/installer-log/test3-holger-rs/front".into(), body: String::new(), what: What::InstallerLog });
let a = request_of(&w(&["site", "installer-log", "test3-holger-rs", "--since", "40", "front"]), &no_file).unwrap();
assert_eq!(a.path, "/site/installer-log/test3-holger-rs/front?since=40");
assert!(request_of(&w(&["site", "installer-log", "test3-holger-rs"]), &no_file).unwrap_err().contains("[site-installer-log]"), "the box is named");
assert!(request_of(&w(&["site", "installer-log", "test3-holger-rs", "front", "--since", "x"]), &no_file).unwrap_err().contains("[site-installer-log]"));
assert!(request_of(&w(&["site", "installer-log"]), &no_file).unwrap_err().contains("[site]"));
assert_eq!(said(What::InstallerLog, 200, "korp-start: network up\n").unwrap(), "korp-start: network up\n");
assert!(said(What::InstallerLog, 404, r#"{"refused":"NotFound","words":"no install log was kept"}"#).unwrap_err().contains("no install log"));
}
/// ★ **`monetize site recheck|retry|abandon <site>`** (F5, 2026-10-20; abandon 2026-10-21): ONE request at the door each —
/// the Sites page's "Check again" and "Set up again", the box's answer printed as it came.
#[test]
fn site_recheck_retry_and_abandon_are_one_request_at_the_door() {
for action in ["recheck", "retry", "abandon"] {
let a = request_of(&w(&["site", action, "test3-holger-rs"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/site/settle", What::Settle));
assert_eq!(serde_json::from_str::<serde_json::Value>(&a.body).unwrap(), serde_json::json!({ "estate": "test3-holger-rs", "action": action }));
assert!(request_of(&w(&["site", action]), &no_file).unwrap_err().contains("[site]"));
}
assert_eq!(said(What::Settle, 200, r#"{"phase":"ready","words":"adopted: every box PAIRED on re-check"}"#).unwrap(), "adopted: every box PAIRED on re-check\nphase: ready\n");
assert!(said(What::Settle, 409, r#"{"refused":"FailedPrecondition","words":"not recheckable"}"#).unwrap_err().contains("not recheckable"));
}
/// ★ **`monetize site open-pin <site>`** (F6, 2026-10-20): ONE request at the door — the
/// guardian's pin windows opened again for the site's boxes not pinned yet.
#[test]
fn site_open_pin_is_one_request_at_the_door() {
let a = request_of(&w(&["site", "open-pin", "test3-holger-rs"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what, a.body.as_str()), ("POST", "/site/open-pin/test3-holger-rs", What::Settle, ""));
assert!(request_of(&w(&["site", "open-pin"]), &no_file).unwrap_err().contains("[site]"));
assert!(help().contains("site open-pin <site>"), "the help names it");
assert_eq!(said(What::Settle, 200, r#"{"phase":"paired","words":"pin windows opened"}"#).unwrap(), "pin windows opened\nphase: paired\n");
}
/// ★ **`monetize estate update <estate>`** (owner 2026-10-14): one request at the door each —
/// open the update, read it, switch live updates; the box's answer printed as it came.
#[test]
fn estate_update_is_one_request_at_the_door_and_its_answer_is_the_boxs() {
let a = request_of(&w(&["estate", "update", "test2-gunnar-rs", "--release", "02abc"]), &no_file).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/estate/update", What::Update));
assert_eq!(serde_json::from_str::<serde_json::Value>(&a.body).unwrap(), serde_json::json!({ "estate": "test2-gunnar-rs", "release": "02abc", "front_first": false }));
let f = request_of(&w(&["estate", "update", "test2-gunnar-rs", "--front-first"]), &no_file).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&f.body).unwrap()["front_first"], serde_json::json!(true), "the front first, said");
assert_eq!(request_of(&w(&["estate", "update-status", "acme"]), &no_file).unwrap().path, "/estate/update/acme");
let l = request_of(&w(&["estate", "live-update", "acme", "off"]), &no_file).unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&l.body).unwrap(), serde_json::json!({ "estate": "acme", "on": false }));
assert!(request_of(&w(&["estate", "live-update", "acme"]), &no_file).unwrap_err().contains("[estate-live]"));
assert!(request_of(&w(&["estate", "update"]), &no_file).unwrap_err().contains("[estate]"));
// ★ A32-7: the cancel verb is a plan unless --apply.
assert_eq!(request_of(&w(&["estate", "update-cancel", "acme"]), &no_file).unwrap(), Ask { method: "GET", path: "/estate/update/acme".into(), body: String::new(), what: What::UpdateCancelPlan });
let c = request_of(&w(&["estate", "update-cancel", "acme", "--apply"]), &no_file).unwrap();
assert_eq!((c.method, c.path.as_str(), c.what), ("POST", "/estate/update/cancel", What::Update));
assert_eq!(serde_json::from_str::<serde_json::Value>(&c.body).unwrap(), serde_json::json!({ "estate": "acme" }));
let out = said(What::Update, 200, r#"{"host":"test2.gunnar.rs","release":"02abc","release_of":{"twin":"02abc","main":"01"},"live_on":true,"state":"running","steps":[{"role":"twin","state":"done","how":"live","words":""},{"role":"main","state":"waiting","how":"","words":""}],"words":"update to 02abc"}"#).unwrap();
assert!(out.contains("twin runs 02abc") && out.contains("step twin done (live)") && out.contains("front runs (unknown)"), "{out}");
assert!(said(What::Update, 409, "no").is_err());
// ★ A32-6 (the kit rule): `box update` without --apply is a PLAN — it asks what the box
// runs and what an update would do, and moves nothing; --apply restarts the box.
assert_eq!(request_of(&w(&["box", "update"]), &no_file).unwrap(), Ask { method: "GET", path: "/self/running".into(), body: String::new(), what: What::BoxPlan });
assert_eq!(request_of(&w(&["box", "update", "--back"]), &no_file).unwrap(), Ask { method: "GET", path: "/self/running".into(), body: String::new(), what: What::BoxPlan });
assert_eq!(request_of(&w(&["box", "update", "--apply"]), &no_file).unwrap(), Ask { method: "POST", path: "/self/update".into(), body: String::new(), what: What::BoxUpdate });
// M-FOLLOW 3: the way back, the other slot's release that confirmed on the box.
assert_eq!(request_of(&w(&["box", "update", "--back", "--apply"]), &no_file).unwrap(), Ask { method: "POST", path: "/self/update".into(), body: r#"{"back":true}"#.into(), what: What::BoxUpdate });
let plan = said(What::BoxPlan, 200, r#"{"app":"aa","slot":"b","base":"bb","live_update":true,"next_app":"cc","next_build":"6260c99","next_base":"bb","plan":"Live","back":"app dd in the other slot"}"#).unwrap();
assert!(plan.contains("update Live") && plan.contains("PLAN only") && plan.contains("--apply"), "the plan says nothing moved and how to act: {plan}");
assert_eq!(request_of(&w(&["box", "running"]), &no_file).unwrap().path, "/self/running");
assert!(said(What::BoxRunning, 200, r#"{"app":"aa","slot":"b","base":"bb","live_update":true,"next_app":"cc","next_build":"6260c99","next_base":"bb","plan":"Live"}"#).unwrap().contains("update Live"));
}
/// ★ **`estate adopt`** (owner 2026-10-17): ONE request at the door with the three servers
/// (over what the kit's `--machines` file records), the secret answer written to a NEW 0600
/// file and never said on the terminal.
#[test]
fn estate_adopt_is_one_request_and_its_secret_goes_to_a_file_never_the_terminal() {
let files = |p: &Path| match p.to_str() {
Some("m.json") => Ok(r#"[{"role":"front","server":"old-f","public_ipv4":"198.51.100.9","utility_ipv4":"10.0.0.9","plan":"1xCPU-2GB","disks":[{"id":"d-f","gib":25,"address":"virtio:0"}]}]"#.to_string()),
_ => Err("no file".to_string()),
};
let a = request_of(&w(&["estate", "adopt", "gunnar-rs", "--machines", "m.json", "--front", "f-1", "--main", "m-1", "--twin", "t-1", "--release-of", "main=02abc", "--feed-out", "feed.json"]), &files).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/estate/adopt", What::Adopt));
let b: serde_json::Value = serde_json::from_str(&a.body).unwrap();
assert_eq!(b["estate"], "gunnar-rs");
let ms = b["machines"].as_array().unwrap();
assert_eq!(ms.iter().map(|m| (m["role"].as_str().unwrap(), m["server"].as_str().unwrap())).collect::<Vec<_>>(), vec![("front", "f-1"), ("main", "m-1"), ("twin", "t-1")], "the flags name the servers over the file");
assert_eq!((ms[0]["public_ipv4"].as_str(), ms[0]["disks"][0]["gib"].as_u64()), (Some("198.51.100.9"), Some(25)), "the kit's record rides along");
assert_eq!((b["release_of"]["main"].as_str(), b["again"].as_bool()), (Some("02abc"), Some(false)));
assert!(request_of(&w(&["estate", "adopt", "gunnar-rs", "--front", "f", "--main", "m", "--twin", "t"]), &no_file).unwrap_err().contains("[adopt-feed-out]"), "the secret needs a file");
assert!(request_of(&w(&["estate", "adopt", "gunnar-rs", "--front", "f", "--main", "m", "--feed-out", "x"]), &no_file).unwrap_err().contains("--twin"));
let answer = r#"{"company":"gunnarrs","reference":"estate/gunnarrs","host":"gunnar.rs","client_key":"ab","feed":{"shop_door.toml":"door = \"https://monetize.ignalina.dk\"\n","secret.shop_client_key":"SEEDHEX"},"words":"gunnar-rs is adopted"}"#;
let out = said(What::Adopt, 200, answer).unwrap();
assert!(out.contains("gunnar-rs is adopted") && out.contains("secret.shop_client_key, shop_door.toml") && !out.contains("SEEDHEX"), "{out}");
let feed: serde_json::Value = serde_json::from_str(&adopt_feed(answer).unwrap()).unwrap();
assert_eq!(feed["secret.shop_client_key"], "SEEDHEX");
let dir = std::env::temp_dir().join(format!("monetize-cli-adopt-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("feed.json");
write_secret_file(&f, "x").unwrap();
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(std::fs::metadata(&f).unwrap().permissions().mode() & 0o777, 0o600);
assert!(write_secret_file(&f, "y").unwrap_err().contains("[adopt-feed-out]"), "never overwritten");
let _ = std::fs::remove_dir_all(&dir);
}
/// ★ A seed key is its PUBLIC line, read from a file; a private key is never sent.
#[test]
fn the_seed_key_is_a_public_line_and_never_a_private_key() {
let public = |_: &Path| Ok("ssh-ed25519 AAAAC3Nz installer-seed\n".to_string());
let a = request_of(&w(&["site", "order", "s", "--admin", "a@b", "--seed-key", "k.pub"]), &public).unwrap();
assert!(a.body.contains("ssh-ed25519 AAAAC3Nz installer-seed"));
let private = |_: &Path| Ok("-----BEGIN OPENSSH PRIVATE KEY-----\nxx\n".to_string());
assert!(request_of(&w(&["site", "order", "s", "--admin", "a@b", "--seed-key", "k"]), &private).unwrap_err().contains("[site-seed-key]"));
}
/// ★ **The run's own `why` is said, with since when** (test3-holger-rs 2026-10-22: it sat in
/// "queued (held)", `queue_admits` wrote its reason into the run's `why`, and neither `site
/// status` nor `site list` printed it). Not said when the box sets none.
#[test]
fn site_status_and_site_list_say_the_runs_why_and_since_when() {
let st = said(What::Status, 200, r#"{"company":"test3-holger-rs","state":"queued","phase":"held","words":"Your Holger is paid and being prepared.","url":"","paid_until":"","why":"queued: the account's room holds 2 estates and 2 are laid","why_since":"2026-10-22 18:12Z"}"#).unwrap();
assert!(st.contains("why: queued: the account's room holds 2 estates and 2 are laid (since 2026-10-22 18:12Z)"), "{st}");
let quiet = said(What::Status, 200, r#"{"company":"c","state":"ready","phase":"captured","words":"ready","url":"","paid_until":"","why":"","why_since":""}"#).unwrap();
assert!(!quiet.contains("why:") && !quiet.contains("first admin"), "{quiet}");
// FIRST-LINK-1: the first-admin door, as monetize said it.
let door = said(What::Status, 200, r#"{"company":"holgerrs","state":"ready","phase":"captured","words":"ready","url":"","paid_until":"","why":"","why_since":"","first_admin_door":"handed_off","first_admin_door_words":"holger-rs is handed off; its main answered the last ask: mailed to f***@mock.invalid"}"#).unwrap();
assert!(door.contains("first admin: handed_off — holger-rs is handed off; its main answered the last ask: mailed to f***@mock.invalid\n"), "{door}");
let list = said(What::List, 200, r#"{"sites":[{"estate":"test3-holger-rs","domain":"test3.holger.rs","vendor":"v","first_admin":"a","dns_words":"d","dns_writes":true,"cloud_words":"c","state":"queued","words":"Your Holger is paid and being prepared.","checkout_url":"","orderable":false,"why_not":"it is already ordered","terminable":true,"why":"queued: the account's room is full","why_since":"2026-10-22 18:12Z"}],"sizes":[]}"#).unwrap();
assert!(list.contains(" why queued: the account's room is full (since 2026-10-22 18:12Z)"), "{list}");
}
/// ★ The box's answers are said as they come: where to pay, a refusal in its own words.
#[test]
fn the_boxs_answers_are_said_as_they_come() {
let ordered = said(What::Order, 200, r#"{"reference":"estate/test3gunnarrs","company":"test3gunnarrs","checkout_url":"https://pay.example/x","price_words":"0.50 EUR","words":"ordered"}"#).unwrap();
assert!(ordered.contains("https://pay.example/x") && ordered.contains("0.50 EUR") && ordered.contains("estate/test3gunnarrs"), "{ordered}");
let refused = said(What::Order, 409, r#"{"refused":"FailedPrecondition","words":"gunnar-rs stands: its main called home"}"#).unwrap_err();
assert!(refused.contains("gunnar-rs stands") && refused.contains("409"), "{refused}");
let list = said(What::List, 200, r#"{"sites":[{"estate":"test3-gunnar-rs","domain":"test3.gunnar.rs","vendor":"stripe/gunnar-test2","first_admin":"ignalina","dns_words":"this box's one DNS token writes its zone","dns_writes":true,"cloud_words":"upcloud · se-sto1","state":"none","words":"Not laid by this box.","checkout_url":"","orderable":true,"why_not":"","terminable":true}],"sizes":["Mini","Small"]}"#).unwrap();
assert!(list.contains("test3.gunnar.rs, www.test3.gunnar.rs, git.test3.gunnar.rs — already fed") && list.contains("can be ordered now") && list.contains("terminable") && list.contains("sizes: Mini, Small"), "{list}");
let ended = said(What::Terminate, 200, r#"{"words":"x.example is terminated","done":true}"#).unwrap();
assert!(!ended.contains("backup"), "{ended}");
}
/// ★ **The site book at the door** (2026-10-14): `add` is ONE put with the site's fields and
/// its secrets read from FILES; `edit` reads the site, then puts it back with only the flags
/// given changed; `show` prints a secret as set and a fingerprint, never a value.
#[test]
fn a_site_is_added_and_edited_at_the_door_and_its_secrets_come_from_files() {
let files = |p: &Path| match p.to_str() {
Some("key") => Ok("sk_test_SECRETKEY\n".to_string()),
Some("hook") => Ok("whsec_SECRETHOOK".to_string()),
_ => Err("no file".to_string()),
};
let a = request_of(&w(&["site", "add", "test5-gunnar-rs", "--domain", "test5.gunnar.rs", "--first-admin", "ignalina", "--stripe-key-file", "key", "--webhook-secret-file", "hook", "--term-days", "1", "--terminable", "yes"]), &files).unwrap();
assert_eq!((a.method, a.path.as_str(), a.what), ("POST", "/site/put", What::Put));
let b: serde_json::Value = serde_json::from_str(&a.body).unwrap();
assert_eq!((b["estate"].as_str(), b["create"].as_bool(), b["domain"].as_str(), b["vendor_secret_key"].as_str(), b["vendor_webhook_secret"].as_str()), (Some("test5-gunnar-rs"), Some(true), Some("test5.gunnar.rs"), Some("sk_test_SECRETKEY"), Some("whsec_SECRETHOOK")));
assert_eq!((b["terms"]["term_days"].as_u64(), b["terminable"].as_bool(), b["operator_site"].as_bool(), b["live"].as_bool()), (Some(1), Some(true), Some(true), Some(false)));
assert!(request_of(&w(&["site", "add", "x", "--first-admin", "o"]), &files).unwrap_err().contains("[site-domain]"));
assert!(request_of(&w(&["site", "add", "x", "--domain", "x.rs", "--first-admin", "o", "--mode", "maybe"]), &files).unwrap_err().contains("[site-flag]"));
let e = request_of(&w(&["site", "edit", "test3-gunnar-rs", "--domain", "t3.gunnar.rs"]), &files).unwrap();
assert_eq!((e.method, e.path.as_str(), e.what), ("GET", "/site/show/test3-gunnar-rs", What::Edit));
let shown = r#"{"site":{"estate":"test3-gunnar-rs","domain":"test3.gunnar.rs","product":"gunnar","first_admin":"ignalina","operator_site":true,"terminable":true,"vendor":"stripe/gunnar-test2","live":false,"terms":{"term_days":1,"term_months":0,"grace_hours":1,"margin_bp":2500},"media_from":"","source":"fed","secret_key_set":true,"secret_key_fingerprint":"sha256:0123abcd","webhook_secret_set":true,"webhook_secret_fingerprint":"sha256:89abcdef"},"products":["gunnar"],"vendors":["stripe/gunnar-test2"],"media_sites":["test2-gunnar-rs"],"currency":"EUR","sizes":["Mini"],"history":["2026-10-14 12:00:00 UTC — console:op-1 — added test3-gunnar-rs: domain: test3.gunnar.rs"]}"#;
let put = edit_body(shown, &w(&["site", "edit", "test3-gunnar-rs", "--domain", "t3.gunnar.rs"]), &files).unwrap();
let p: serde_json::Value = serde_json::from_str(&put.body).unwrap();
assert_eq!((p["domain"].as_str(), p["vendor"].as_str(), p["create"].as_bool(), p["terms"]["margin_bp"].as_u64(), p["vendor_secret_key"].as_str()), (Some("t3.gunnar.rs"), Some("stripe/gunnar-test2"), Some(false), Some(2500), Some("")), "only the flag moved; the held secrets are kept");
let said_show = said(What::Show, 200, shown).unwrap();
assert!(said_show.contains("secret key set (sha256:0123abcd)") && said_show.contains("console:op-1") && said_show.contains("stripe/gunnar-test2 (test)"), "{said_show}");
assert_eq!(request_of(&w(&["site", "show"]), &files).unwrap().path, "/site/show");
let done = said(What::Put, 200, r#"{"words":"test5-gunnar-rs is added","changed":["domain: test5.gunnar.rs","Stripe secret key: set (sha256:0123abcd)"]}"#).unwrap();
assert!(done.contains("test5-gunnar-rs is added") && done.contains(" Stripe secret key: set"), "{done}");
}
#[test]
fn the_door_and_the_material_come_from_flags_or_the_environment() {
let env = |k: &str| (k == DOOR_ENV).then(|| "box.example:8475".to_string());
let (t, rest) = target_of(&w(&["site", "list", "--material", "/m"]), &env).unwrap();
assert_eq!((t.door.as_str(), t.material.to_str().unwrap(), rest), ("box.example:8475", "/m", w(&["site", "list"])));
let (t, _) = target_of(&w(&["--door", "other:8475", "site", "list"]), &env).unwrap();
assert_eq!(t.door, "other:8475");
assert!(door(&Target::default(), &Ask { method: "GET", path: "/site/list".into(), body: String::new(), what: What::List }).unwrap_err().contains("[door]"));
}
/// ★ A site order comes back to the box's own Sites page (live 2026-10-14 returned to the
/// site being laid, which had no front): from the door's host unless `--return` names one.
#[test]
fn an_order_returns_to_the_boxs_sites_page() {
assert_eq!(sites_page_of("monetize.ignalina.dk:8475"), "https://monetize.ignalina.dk/sites");
assert_eq!(sites_page_of("10.0.0.5:8475"), "");
assert_eq!(sites_page_of("[::1]:8475"), "");
assert_eq!(sites_page_of(""), "");
let order = request_of(&w(&["site", "order", "test2-gunnar-rs", "--admin", "ops@example.invalid"]), &no_file).unwrap();
let back: serde_json::Value = serde_json::from_str(&with_return(order.clone(), "monetize.ignalina.dk:8475").body).unwrap();
assert_eq!(back["return_url"], "https://monetize.ignalina.dk/sites");
let named = request_of(&w(&["site", "order", "x", "--admin", "a@b.invalid", "--return", "https://elsewhere.example/sites"]), &no_file).unwrap();
let kept: serde_json::Value = serde_json::from_str(&with_return(named, "monetize.ignalina.dk:8475").body).unwrap();
assert_eq!(kept["return_url"], "https://elsewhere.example/sites");
let list = request_of(&w(&["site", "list"]), &no_file).unwrap();
assert_eq!(with_return(list.clone(), "monetize.ignalina.dk:8475"), list);
}
/// ★ **OVW-CLEAN: `estates`**, READ ONLY: one GET at the door; the words are the console's
/// Overview — a line per estate that exists, the quiet line with its names, the slot.
#[test]
fn estates_is_one_read_and_says_the_overview_as_the_box_classifies_it() {
let no_file = |_: &Path| Err::<String, String>("no file".into());
assert_eq!(request_of(&w(&["estates"]), &no_file).unwrap(), Ask { method: "GET", path: "/estates/overview".into(), body: String::new(), what: What::Estates });
let answer = r#"{"estates":[{"name":"gunnar-rs","product":"gunnar","standing":"installed","words":"installed: its machines have reported in","in_fleet":true,"domain":"gunnar.rs"}],
"ended":[{"name":"acme2","product":"holger","standing":"ended","words":"terminated on 2026-10-27","in_fleet":true,"domain":""}],
"slot":{"products":["gunnar","holger"],"declared":["test2-gunnar-rs"]},"stale_secs":null}"#;
let out = said(What::Estates, 200, answer).unwrap();
assert_eq!(
out.lines().collect::<Vec<_>>(),
["gunnar-rs gunnar installed gunnar.rs — installed: its machines have reported in", "1 ended or never-installed:", " acme2 holger ended - — terminated on 2026-10-27", "new estate: products gunnar, holger; declared, started by name: test2-gunnar-rs"],
"{out}"
);
let empty = said(What::Estates, 200, r#"{"estates":[],"ended":[],"slot":{"products":[],"declared":[]}}"#).unwrap();
assert_eq!(empty, "no estate is installed or being laid\nnew estate: products none; declared, started by name: none\n");
assert!(help().contains("monetize … estates"));
}
/// ★ **MAP-SEG 3: `box inventory`**, READ ONLY: one GET at the door, and the words say per
/// row its list, estate, order and label — the orphans first — so the owner's orphan list
/// never needs a console session again. No secret is in the answer to print.
#[test]
fn box_inventory_is_one_read_and_names_each_rows_estate_order_and_label() {
let no_file = |_: &Path| Err::<String, String>("no file".into());
assert_eq!(request_of(&w(&["box", "inventory"]), &no_file).unwrap(), Ask { method: "GET", path: "/cloud/inventory".into(), body: String::new(), what: What::Inventory });
let answer = r#"{"clouds":[{"provider":"upcloud","available":true,"error":"","foreign_count":11,
"owned":[{"id":"server/00a","kind":"server","gib":0,"zone":"se-sto1","attached_to":"","estate":"test2-gunnar-rs","reference":"estate/test2gunnarrs","resource":"lay:front:server","priced":true,"monthly_minor":2200}],
"orphans":[{"id":"storage/01b","kind":"disk","gib":40,"zone":"se-sto1","attached_to":"","estate":"holgerr34e","reference":"estate/holgerr34e","resource":"lay:main:disk1","priced":true,"monthly_minor":400}],
"missing":[]}]}"#;
let out = said(What::Inventory, 200, answer).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "cloud upcloud: listed");
assert_eq!(lines[1], "orphan storage/01b disk 40GiB se-sto1 - estate=holgerr34e ref=estate/holgerr34e res=lay:main:disk1 400/mo", "{out}");
assert_eq!(lines[2], "owned server/00a server server se-sto1 - estate=test2-gunnar-rs ref=estate/test2gunnarrs res=lay:front:server 2200/mo", "{out}");
assert_eq!(lines[3], "1 orphan(s), 1 owned, 0 backup(s), 0 missing, 11 not ours (no monetize label)");
// ★ MAP-3: an account two products list is named once with both; a backup is its own
// line under its estate, never an orphan, and counted apart.
let answer = r#"{"clouds":[{"provider":"upcloud","account":"upcloud","products":["gunnar","holger"],"available":true,"error":"","foreign_count":3,
"owned":[],"orphans":[{"id":"storage/bk-gone","kind":"backup","gib":10,"zone":"se-sto1","attached_to":"","estate":"","reference":"estate/old","resource":"","title":"Simple backup of old-front system","priced":false}],
"backups":[{"id":"storage/bk1","gib":10,"zone":"se-sto1","estate":"holger-rs","reference":"estate/holgerrs","words":"backup of holger-web-ui-holgerrs, 2026-10-27"}],
"missing":[]}]}"#;
let out = said(What::Inventory, 200, answer).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "cloud upcloud (gunnar, holger): listed", "{out}");
assert_eq!(lines[1], "orphan storage/bk-gone backup 10GiB se-sto1 - estate=- ref=estate/old res=- unpriced \"Simple backup of old-front system\"", "{out}");
assert_eq!(lines[2], "backup storage/bk1 10GiB se-sto1 estate=holger-rs ref=estate/holgerrs backup of holger-web-ui-holgerrs, 2026-10-27", "{out}");
assert_eq!(lines[3], "1 orphan(s), 0 owned, 1 backup(s), 0 missing, 3 not ours (no monetize label)", "{out}");
assert!(said(What::Inventory, 200, r#"{"clouds":[{"provider":"upcloud","available":false,"error":"401"}]}"#).unwrap().contains("NOT listed: 401"));
assert!(help().contains("box inventory"));
}
}