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

nordisk/monetize

crates/monetize-admin/src/app_config.rs

//! **CONFIG IN THE APPLICATION** (owner 2026-10-14: "environments are set up by Rust code IN
//! THE APPLICATION … what stays xtask is the build, the signing and a first bootstrap of an
//! empty ONE box") — the box's own SITE BOOK: a site is added and edited through the
//! console, its gRPC (`SiteBook`) and the `monetize site add|edit|show` CLI at the door,
//! never by a feed from the operator's machine.
//!
//! ## The design
//!
//! * **The box's store is the source of truth after the bootstrap.** What the operator adds
//!   or edits here is kept on the box (`<data>/solo/app-sites.json`, the secrets apart in
//!   `<data>/solo/app-secrets.json`, both 0600) and never written back to the operator's
//!   `solo.toml`. The installer's feed and the door's feed (`cargo xtask feed`) seed an empty
//!   box; the app layer lies over them ([`overlay`]): **installer ⊕ door ⊕ app**.
//! * **What is editable**: a SITE (`[estates.<label>]`: domain, product, first admin,
//!   operator site, terminable, its order terms, the site whose media it lays from) and its
//!   STRIPE account (`vendor.stripe.<instance>`: a new one or one the box holds; its secret
//!   key and webhook secret typed in, test or live). A new account runs the signed Stripe
//!   guest the box ALREADY holds (its module and `.p7s` copied from a held account), so no
//!   plugin enters this way.
//! * **Judged as a feed is**: the feed in force with the change over it goes through the
//!   box's own loader ([`crate::solo::judge_in`]) — refused WHOLE, by name, else kept. PID 1
//!   takes the new feed in force on its next look (two seconds) and monetize-server's config
//!   watcher applies the site and the vendor live, no restart.
//! * **Audited**: every change is a line of `<data>/solo/app-config.audit.jsonl` — who (the
//!   console's signed-in operator, `door:installer` for the CLI), when, what, field by field.
//!   A secret is "set" or "replaced" with an 8-hex fingerprint, never its value.
//! * **Bootstrap and app edits coexist by ONE rule: the app wins, by name, and says so.** A
//!   door feed put after app edits never drops an app-made site or account: the app layer is
//!   its own file, and the door's answer names every site, account and secret the app keeps
//!   over it ([`shadowed`]). The door judges its feed WITH the app layer over it, so a feed
//!   that would break an app-made site is refused whole.
//! * **Bootstrap-only** (never editable here): the box's identity (its seed, estate name and
//!   installer CA), the door's and the installer's certificates, the plugin root pin
//!   (`plugin_root.*`), monetize's signing key and bearers, the products and their prices and
//!   signups (`plugin.<id>.*`, `[signups]`), the shop (`[estate_orders]`: cloud guest and its
//!   ONE token, the ONE DNS writer and its token, the release media), the relay and alerts,
//!   the backup, the web hosts and the break-glass token.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use monetize_server::site_book::{self as book, SiteBookRefusal, SitePut, SitePutDone, SiteRecord, SiteShown, SiteTerms};
use serde::{Deserialize, Serialize};

pub type Feed = BTreeMap<String, String>;

/// The sites (and accounts) added or edited in the application (0600, JSON).
pub const SITES_REL: &str = "solo/app-sites.json";
/// The secrets typed into the application, by NAME (0600, JSON).
pub const SECRETS_REL: &str = "solo/app-secrets.json";
/// Every change: who, when, what (JSON lines, 0600; never a secret's value).
pub const AUDIT_REL: &str = "solo/app-config.audit.jsonl";
/// Where the book judges a change (its own: PID 1 judges in `solo/judging`).
pub const JUDGE_REL: &str = "solo/judging-app";
/// The feed key of monetize's own config fragment (`intake::FEED_MONETIZE_TOML`).
const MONETIZE_TOML: &str = "monetize.toml";
/// ★ A32-15: a vendor account's feed keys are `vendor.<kind>.<account>.{toml,wasm,p7s}`; the kind
/// and its shape (its secrets' look, its mode marker) are the vendor's DATA
/// ([`monetize_pay::shapes`]), never a name written here.
fn prefix_of(kind: &monetize_pay::shapes::Shape) -> String {
    format!("vendor.{}.", kind.kind)
}

/// ★ A32-15: **the payment vendor kind this box holds accounts of** — the kind of the signed
/// vendor guests its feed holds (`vendor.<kind>.<account>.toml`, a kind with a shape); a box
/// that holds none takes the first kind the vendor data knows.
pub fn kind_of_feed(feed: &Feed) -> &'static monetize_pay::shapes::Shape {
    feed.keys()
        .filter_map(|k| k.strip_prefix("vendor.")?.split_once('.'))
        .filter(|(_, rest)| rest.ends_with(".toml"))
        .find_map(|(kind, _)| monetize_pay::shapes::of(kind))
        .unwrap_or(&monetize_pay::shapes::SHAPES[0])
}

/// One site as the application keeps it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AppSite {
    pub domain: String,
    pub product: String,
    pub first_admin: String,
    pub operator_site: bool,
    pub terminable: bool,
    pub vendor: String,
    pub terms: SiteTerms,
    pub media_from: String,
}

/// One vendor account the application holds the block of: its mode, and (a NEW account) the
/// site its customers return to.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AppVendor {
    pub live: bool,
    /// The success / cancel pages' host; empty: the template block's own.
    pub site: String,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Book {
    pub sites: BTreeMap<String, AppSite>,
    /// By instance (`gunnar-test2`).
    pub vendors: BTreeMap<String, AppVendor>,
}

/// Secret NAME → value.
pub type Secrets = BTreeMap<String, String>;

fn read_json<T: for<'de> Deserialize<'de> + Default>(root: &Path, rel: &str) -> T {
    std::fs::read_to_string(root.join(rel)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
}

pub fn read_book(root: &Path) -> Book {
    read_json(root, SITES_REL)
}

pub fn read_secrets(root: &Path) -> Secrets {
    read_json(root, SECRETS_REL)
}

fn write_json<T: Serialize>(root: &Path, rel: &str, v: &T) -> Result<(), String> {
    let text = serde_json::to_string_pretty(v).map_err(|e| e.to_string())?;
    monetize_front::write_secret(root, rel, &text).map_err(|e| format!("{rel}: {e}"))
}

/// Pure: an account's two secret NAMES (the kit's: `gunnar-test2` of its kind →
/// `MONETIZE_<KIND>_GUNNAR_TEST2_…`).
pub fn secret_names(kind: &monetize_pay::shapes::Shape, instance: &str) -> (String, String) {
    let up = |t: &str| -> String { t.chars().map(|c| if c == '-' { '_' } else { c.to_ascii_uppercase() }).collect() };
    let (k, env) = (up(kind.kind), up(instance));
    (format!("MONETIZE_{k}_{env}_SECRET_KEY"), format!("MONETIZE_{k}_{env}_WEBHOOK_SECRET"))
}

/// Pure: a label (a site's or an account's) — lowercase letters, digits and inner hyphens, at most 63.
pub fn label_ok(l: &str) -> bool {
    !l.is_empty() && l.len() <= 63 && !l.starts_with('-') && !l.ends_with('-') && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}

/// Pure: a domain the site is laid under (`test5.acme.example`): at least two labels.
pub fn domain_ok(d: &str) -> bool {
    d.len() <= 253 && d.contains('.') && d.split('.').all(label_ok)
}

fn instance_of<'a>(kind: &monetize_pay::shapes::Shape, vendor: &'a str) -> Option<&'a str> {
    vendor.strip_prefix(kind.kind)?.strip_prefix('/').filter(|i| label_ok(i))
}

/// The vendor accounts a feed holds of its kind ([`kind_of_feed`]), by instance.
pub fn held_vendors(feed: &Feed) -> Vec<String> {
    let prefix = prefix_of(kind_of_feed(feed));
    feed.keys().filter_map(|k| k.strip_prefix(prefix.as_str())?.strip_suffix(".toml")).map(str::to_owned).collect()
}

/// The `[estates]` a feed declares (its `monetize.toml` fragment).
pub fn estates_of(feed: &Feed) -> toml::Table {
    feed.get(MONETIZE_TOML).and_then(|t| toml::from_str::<toml::Table>(t).ok()).and_then(|t| t.get("estates").and_then(|e| e.as_table()).cloned()).unwrap_or_default()
}

/// The products a feed serves.
pub fn products_of(feed: &Feed) -> Vec<String> {
    feed.keys().filter_map(|k| k.strip_prefix("plugin.")?.strip_suffix(".toml")).map(str::to_owned).collect()
}

fn media_of(site: &toml::Table) -> Option<toml::Table> {
    let o = site.get("orders")?.as_table()?;
    let t: toml::Table = ["media", "media_objects", "templates"].iter().filter_map(|k| o.get(*k).map(|v| (k.to_string(), v.clone()))).collect();
    (!t.is_empty()).then_some(t)
}

/// The sites of a feed whose media a new site may lay from.
pub fn media_sites(feed: &Feed) -> Vec<String> {
    estates_of(feed).iter().filter(|(_, v)| v.as_table().and_then(media_of).is_some()).map(|(l, _)| l.clone()).collect()
}

/// Pure: an account's block from a template block (a held account's): its own secret NAMES,
/// its mode, and (when named) the site its customers return to — every way back
/// (`success_url`, `cancel_url`, and the template's `return_url`) on that site.
fn vendor_block(kind: &monetize_pay::shapes::Shape, template: &str, instance: &str, v: &AppVendor) -> Result<String, String> {
    use toml::Value;
    let mut t: toml::Table = toml::from_str(template).map_err(|e| format!("the held {} block is not TOML: {e}", kind.title))?;
    let (key, hook) = secret_names(kind, instance);
    t.insert("enabled".into(), Value::Boolean(true));
    t.insert("livemode".into(), Value::Boolean(v.live));
    t.insert("webhook_secret".into(), Value::String(hook.clone()));
    t.insert("hmac_secrets".into(), Value::Array(vec![Value::String(hook)]));
    if let Some(auth) = t.get_mut("http_auth").and_then(Value::as_array_mut) {
        for a in auth.iter_mut().filter_map(Value::as_table_mut) {
            a.insert("secret".into(), Value::String(key.clone()));
        }
    }
    if !v.site.is_empty() {
        t.insert("success_url".into(), Value::String(format!("https://{}/paid/{{reference}}", v.site)));
        t.insert("cancel_url".into(), Value::String(format!("https://{}/cancel/{{reference}}", v.site)));
        // R34-8: the server-side confirm's way back is the site's own too, never the template's.
        if t.contains_key("return_url") {
            t.insert("return_url".into(), Value::String(format!("https://{}/paid/{{reference}}", v.site)));
        }
    }
    toml::to_string(&t).map_err(|e| e.to_string())
}

/// Pure: **the writer of `product`'s zone**, as the box's feed names it for a declared site
/// (private-monetize-ops `Orders::writer_for`): the writer a fed site of `product` names; else
/// the zone writer `<writer>-<product>` the feed holds as a signed guest
/// (`dns_guest.<writer>-<product>.toml`, `[…dns.zones] <product>`), `<writer>` being what the
/// shop's own product's fed sites name; else, for the shop's own product, that writer. A
/// product with no zone writer of its own gets NONE (its orders are refused by name), never
/// another product's zone: a name written in a zone it is not under is `<name>.<zone>`.
pub fn writer_for(feed: &Feed, fed: &toml::Table, shop: &str, product: &str) -> Option<String> {
    let named = |p: &str| fed.values().filter(|s| s.get("product").and_then(toml::Value::as_str) == Some(p)).filter_map(|s| s.get("orders")?.get("dns")?.as_str().map(str::trim).filter(|w| !w.is_empty()).map(str::to_owned)).next();
    if let Some(w) = named(product) {
        return Some(w);
    }
    let base = if shop.is_empty() { None } else { named(shop) }?;
    let zone = format!("{base}-{product}");
    if feed.contains_key(&format!("dns_guest.{zone}.toml")) {
        return Some(zone);
    }
    (product == shop).then_some(base)
}

/// Pure: **the feed in force** — `base` (the installer's ⊕ the door's) with the application's
/// book and secrets over it. An app-made account runs the signed Stripe guest the box holds.
pub fn overlay(base: &Feed, b: &Book, secrets: &Secrets) -> Result<Feed, String> {
    use toml::Value;
    let mut f = base.clone();
    for (n, v) in secrets {
        f.insert(format!("secret.{n}"), v.clone());
    }
    let held = held_vendors(base);
    let kind = kind_of_feed(base);
    let pre = prefix_of(kind);
    for (inst, v) in &b.vendors {
        let template = if held.contains(inst) {
            inst.clone()
        } else {
            held.first().cloned().ok_or_else(|| format!("REFUSED [site-no-vendor-guest] this box holds no signed {} guest, so the account {}/{inst} cannot run: the bootstrap feeds one first", kind.title, kind.kind))?
        };
        for part in ["wasm", "p7s"] {
            if let Some(m) = base.get(&format!("{pre}{template}.{part}")) {
                f.entry(format!("{pre}{inst}.{part}")).or_insert_with(|| m.clone());
            }
        }
        let block = vendor_block(kind, &base[&format!("{pre}{template}.toml")], inst, v)?;
        f.insert(format!("{pre}{inst}.toml"), block);
    }
    if b.sites.is_empty() {
        return Ok(f);
    }
    let mut top: toml::Table = match base.get(MONETIZE_TOML) {
        Some(t) => toml::from_str(t).map_err(|e| format!("the fed monetize.toml is not TOML: {e}"))?,
        None => toml::Table::new(),
    };
    let fed = top.get("estates").and_then(Value::as_table).cloned().unwrap_or_default();
    let shop = top.get("estate_orders").and_then(|o| o.get("product")).and_then(Value::as_str).unwrap_or_default().to_owned();
    let mut estates = fed.clone();
    for (label, s) in &b.sites {
        let mut t = fed.get(label).and_then(Value::as_table).cloned().unwrap_or_default();
        t.insert("product".into(), Value::String(s.product.clone()));
        t.insert("vendor".into(), Value::String(s.vendor.clone()));
        t.insert("owner".into(), Value::String(s.first_admin.clone()));
        t.insert("site".into(), Value::String(s.domain.clone()));
        for (k, on) in [("operator_site", s.operator_site), ("terminable", s.terminable)] {
            if on {
                t.insert(k.into(), Value::Boolean(true));
            } else {
                t.remove(k);
            }
        }
        let mut o = t.get("orders").and_then(Value::as_table).cloned().unwrap_or_default();
        for (k, n) in [("term_days", s.terms.term_days), ("term_months", s.terms.term_months), ("grace_hours", s.terms.grace_hours), ("margin_bp", s.terms.margin_bp)] {
            if n == 0 {
                o.remove(k);
            } else {
                o.insert(k.into(), Value::Integer(i64::from(n)));
            }
        }
        // DNS-WRITER (2026-10-26): the writer of the site's OWN product's zone, as a declared
        // site is fed (`writer_for(product)`), never the first fed site's (r34e.holger.rs was
        // written into the gunnar.rs zone as r34e.holger.rs.gunnar.rs).
        if !o.contains_key("dns") {
            if let Some(w) = writer_for(base, &fed, &shop, &s.product) {
                o.insert("dns".into(), Value::String(w));
            }
        }
        if !s.media_from.is_empty() {
            let m = fed.get(&s.media_from).and_then(Value::as_table).and_then(media_of).ok_or_else(|| format!("REFUSED [site-media-from] {} lays from no media of its own: name a site that does", s.media_from))?;
            for k in ["media", "media_objects", "templates"] {
                o.remove(k);
            }
            o.extend(m);
        }
        if o.is_empty() {
            t.remove("orders");
        } else {
            t.insert("orders".into(), Value::Table(o));
        }
        estates.insert(label.clone(), Value::Table(t));
    }
    top.insert("estates".into(), Value::Table(estates));
    f.insert(MONETIZE_TOML.into(), toml::to_string(&top).map_err(|e| e.to_string())?);
    Ok(f)
}

/// Pure: **what the application keeps over a feed put on the door** — its sites, accounts and
/// secrets that the feed names too (the app wins; the door's answer says so, by name).
pub fn shadowed(door: &Feed, b: &Book, secrets: &Secrets) -> Vec<String> {
    let estates = estates_of(door);
    let mut out: Vec<String> = b.sites.keys().filter(|l| estates.contains_key(*l)).map(|l| format!("site {l}")).collect();
    let held = held_vendors(door);
    let kind = kind_of_feed(door).kind;
    out.extend(b.vendors.keys().filter(|i| held.contains(i)).map(|i| format!("account {kind}/{i}")));
    out.extend(secrets.keys().filter(|n| door.contains_key(&format!("secret.{n}"))).map(|n| format!("secret {n}")));
    out
}

/// **The installer's feed and the door's under it**, as PID 1 holds them: the installer's
/// fed reply on the data disk (`pairing/joined.toml`, SEALED at rest since nordisk-pairing
/// 0.5.17 `fed_at_rest`; a plain one from before still reads) with the door's over it, key by key.
pub fn base_feed(root: &Path) -> Feed {
    let installer = nordisk_pairing::fed_at_rest::read(root, nordisk_pairing::boxside::JOINED_REL).and_then(|t| nordisk_pairing::join::JoinReply::parse(&t).ok()).map(|r| r.feed).unwrap_or_default();
    let mut f = installer;
    f.extend(crate::solo::read_door_feed(root));
    f
}

/// One audit line.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Audit {
    pub at_unix_ms: u64,
    pub actor: String,
    pub verb: String,
    pub estate: String,
    pub changed: Vec<String>,
}

impl Audit {
    /// As the history shows it.
    pub fn line(&self) -> String {
        format!("{} — {} — {} {}: {}", when(self.at_unix_ms), self.actor, self.verb, self.estate, self.changed.join("; "))
    }
}

/// Pure: a UTC time, to the second (`2026-10-14 12:00:00 UTC`).
pub fn when(ms: u64) -> String {
    let s = ms / 1000;
    let (days, rem) = (s / 86_400, s % 86_400);
    // Civil from days (Howard Hinnant's algorithm).
    let z = days as i64 + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = yoe + era * 400 + i64::from(m <= 2);
    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC", rem / 3600, rem % 3600 / 60, rem % 60)
}

fn append_audit(root: &Path, a: &Audit) -> Result<(), String> {
    use std::io::Write as _;
    use std::os::unix::fs::OpenOptionsExt as _;
    let p = root.join(AUDIT_REL);
    if let Some(d) = p.parent() {
        std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
    }
    let mut f = std::fs::OpenOptions::new().create(true).append(true).mode(0o600).open(&p).map_err(|e| format!("{AUDIT_REL}: {e}"))?;
    writeln!(f, "{}", serde_json::to_string(a).map_err(|e| e.to_string())?).map_err(|e| format!("{AUDIT_REL}: {e}"))
}

/// The audit's lines for `estate`, newest first.
pub fn history(root: &Path, estate: &str) -> Vec<String> {
    let text = std::fs::read_to_string(root.join(AUDIT_REL)).unwrap_or_default();
    let mut v: Vec<String> = text.lines().filter_map(|l| serde_json::from_str::<Audit>(l).ok()).filter(|a| a.estate == estate).map(|a| a.line()).collect();
    v.reverse();
    v
}

/// Pure: **one site as the form edits it**, from the feed in force (no secret value).
pub fn record_of(f: &Feed, b: &Book, label: &str) -> Option<SiteRecord> {
    let estates = estates_of(f);
    let t = estates.get(label)?.as_table()?;
    let s = |k: &str| t.get(k).and_then(|v| v.as_str()).unwrap_or_default().to_string();
    let on = |k: &str| t.get(k).and_then(|v| v.as_bool()).unwrap_or(false);
    let n = |k: &str| t.get("orders").and_then(|o| o.get(k)).and_then(|v| v.as_integer()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
    let vendor = s("vendor");
    let kind = kind_of_feed(f);
    let inst = instance_of(kind, &vendor).unwrap_or_default();
    let live = f.get(&format!("{}{inst}.toml", prefix_of(kind))).and_then(|t| toml::from_str::<toml::Table>(t).ok()).and_then(|t| t.get("livemode").and_then(|v| v.as_bool())).unwrap_or(false);
    let (key, hook) = secret_names(kind, inst);
    let secret = |n: &str| f.get(&format!("secret.{n}")).map(|v| v.trim()).filter(|v| !v.is_empty());
    Some(SiteRecord {
        estate: label.to_string(),
        domain: s("site"),
        product: s("product"),
        first_admin: s("owner"),
        operator_site: on("operator_site"),
        terminable: on("terminable"),
        vendor,
        live,
        terms: SiteTerms { term_days: n("term_days"), term_months: n("term_months"), grace_hours: n("grace_hours"), margin_bp: n("margin_bp") },
        media_from: b.sites.get(label).map(|a| a.media_from.clone()).unwrap_or_default(),
        source: if b.sites.contains_key(label) { "app" } else { "fed" }.to_string(),
        secret_key_set: secret(&key).is_some(),
        secret_key_fingerprint: secret(&key).map(book::fingerprint).unwrap_or_default(),
        webhook_secret_set: secret(&hook).is_some(),
        webhook_secret_fingerprint: secret(&hook).map(book::fingerprint).unwrap_or_default(),
    })
}

/// Pure: the changes from `before` (none: an add) to `after`, field by field — a secret as
/// "set"/"replaced" and its fingerprint, never its value.
pub fn changes(before: Option<&SiteRecord>, after: &SiteRecord, key_given: bool, hook_given: bool) -> Vec<String> {
    let blank = SiteRecord::default();
    let b = before.unwrap_or(&blank);
    let mut out = Vec::new();
    let mut field = |name: &str, x: String, y: String| {
        if x != y {
            out.push(if x.is_empty() { format!("{name}: {y}") } else { format!("{name}: {x} → {y}") });
        }
    };
    field("domain", b.domain.clone(), after.domain.clone());
    field("product", b.product.clone(), after.product.clone());
    field("first admin", b.first_admin.clone(), after.first_admin.clone());
    field("vendor", b.vendor.clone(), after.vendor.clone());
    let yes = |v: bool| if v { "yes" } else { "no" }.to_string();
    // An add names what it set; an edit what it moved.
    let was = |v: bool| if before.is_some() { yes(v) } else { String::new() };
    if before.is_some() || after.operator_site {
        field("operator site", was(b.operator_site), yes(after.operator_site));
    }
    if before.is_some() || after.terminable {
        field("terminable", was(b.terminable), yes(after.terminable));
    }
    let mode = |l: bool| if l { "live" } else { "test" }.to_string();
    if before.is_some_and(|b| b.vendor == after.vendor) {
        field("mode", mode(b.live), mode(after.live));
    } else {
        field("mode", String::new(), mode(after.live));
    }
    let term = |n: u32| if n == 0 { "the shop's".to_string() } else { n.to_string() };
    for (name, x, y) in [("term days", b.terms.term_days, after.terms.term_days), ("term months", b.terms.term_months, after.terms.term_months), ("grace hours", b.terms.grace_hours, after.terms.grace_hours), ("margin bp", b.terms.margin_bp, after.terms.margin_bp)] {
        if x != y {
            out.push(if before.is_some() { format!("{name}: {} → {}", term(x), term(y)) } else { format!("{name}: {}", term(y)) });
        }
    }
    if b.media_from != after.media_from {
        out.push(format!("media from: {}", if after.media_from.is_empty() { "as it was".to_string() } else { after.media_from.clone() }));
    }
    for (name, given, was, fp) in [("Secret key", key_given, b.secret_key_set, &after.secret_key_fingerprint), ("Webhook secret", hook_given, b.webhook_secret_set, &after.webhook_secret_fingerprint)] {
        if given {
            out.push(format!("{name}: {} ({fp})", if was && before.is_some() { "replaced" } else { "set" }));
        }
    }
    out
}

fn now_ms() -> u64 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}

/// **The ONE box's site book** — over its data root.
pub struct AppBook {
    pub root: PathBuf,
    /// The feed the book builds on (the installer's ⊕ the door's): read from the data disk on
    /// the box; a test hands one in.
    pub base: Box<dyn Fn() -> Feed + Send + Sync>,
    /// Puts one at a time: a change is judged against the book it replaces.
    one: std::sync::Mutex<()>,
}

impl AppBook {
    /// The box's own: the base read from its data disk.
    pub fn on(root: PathBuf) -> AppBook {
        let r = root.clone();
        AppBook::with_base(root, Box::new(move || base_feed(&r)))
    }

    pub fn with_base(root: PathBuf, base: Box<dyn Fn() -> Feed + Send + Sync>) -> AppBook {
        AppBook { root, base, one: std::sync::Mutex::new(()) }
    }

    fn in_force(&self) -> Result<(Feed, Feed, Book, Secrets), SiteBookRefusal> {
        let base = (self.base)();
        let (b, s) = (read_book(&self.root), read_secrets(&self.root));
        let f = overlay(&base, &b, &s).map_err(SiteBookRefusal::Broken)?;
        Ok((base, f, b, s))
    }
}

fn bad(w: impl Into<String>) -> SiteBookRefusal {
    SiteBookRefusal::Bad(w.into())
}

impl book::SiteBook for AppBook {
    fn show(&self, estate: &str) -> Result<SiteShown, SiteBookRefusal> {
        let (_, f, b, _) = self.in_force()?;
        let products = products_of(&f);
        let site = if estate.is_empty() {
            SiteRecord { product: products.first().cloned().unwrap_or_default(), operator_site: true, ..Default::default() }
        } else {
            record_of(&f, &b, estate).ok_or_else(|| SiteBookRefusal::NotFound(format!("{estate} is not a site of this box (`monetize site list`)")))?
        };
        Ok(SiteShown { site, products, vendors: { let kind = kind_of_feed(&f).kind; held_vendors(&f).into_iter().map(|i| format!("{kind}/{i}")).collect() }, media_sites: media_sites(&f), history: if estate.is_empty() { Vec::new() } else { history(&self.root, estate) } })
    }

    fn put(&self, p: &SitePut) -> Result<SitePutDone, SiteBookRefusal> {
        let _one = self.one.lock().unwrap_or_else(|e| e.into_inner());
        let label = p.estate.trim();
        if !label_ok(label) {
            return Err(bad(format!("REFUSED [site-label] {label:?} is not a site's label: lowercase letters, digits and hyphens (`test5-acme-example`)")));
        }
        let (base, before_feed, mut b, mut secrets) = self.in_force()?;
        let before = record_of(&before_feed, &b, label);
        match (p.create, &before) {
            (true, Some(_)) => return Err(SiteBookRefusal::Taken(format!("REFUSED [site-taken] {label} is already a site of this box: edit it"))),
            (false, None) => return Err(SiteBookRefusal::NotFound(format!("REFUSED [site-unknown] {label} is not a site of this box: add it"))),
            _ => {}
        }
        let domain = p.domain.trim().to_ascii_lowercase();
        if !domain_ok(&domain) {
            return Err(bad(format!("REFUSED [site-domain] {domain:?} is not a domain the site can be laid under (`test5.acme.example`)")));
        }
        let products = products_of(&before_feed);
        let product = if p.product.trim().is_empty() { products.first().cloned().unwrap_or_default() } else { p.product.trim().to_string() };
        if !products.contains(&product) {
            return Err(bad(format!("REFUSED [site-product] {product:?} is not served by this box ({})", products.join(", "))));
        }
        let first_admin = p.first_admin.trim().to_string();
        if first_admin.is_empty() || first_admin.chars().any(|c| c.is_whitespace() || c == '/') {
            return Err(bad("REFUSED [site-first-admin] the site's first admin is its owner's account name (`ignalina`)"));
        }
        // ★ A32-15: the account's kind is the box's vendor kind; its secrets' look is that kind's data.
        let kind = kind_of_feed(&before_feed);
        let vendor = if p.vendor.trim().is_empty() { format!("{}/{label}", kind.kind) } else { p.vendor.trim().to_string() };
        let Some(inst) = instance_of(kind, &vendor).map(str::to_owned) else {
            return Err(bad(format!("REFUSED [site-vendor] {vendor:?} is not a {} account: `{}/<account>` (lowercase letters, digits, hyphens)", kind.title, kind.kind)));
        };
        let (key, hook) = (p.vendor_secret_key.trim(), p.vendor_webhook_secret.trim());
        if !kind.key_ok(key) {
            return Err(bad(format!("REFUSED [site-vendor-key] the {} secret key is {}; nothing was kept", kind.title, kind.key_words)));
        }
        if !kind.hook_ok(hook) {
            return Err(bad(format!("REFUSED [site-vendor-webhook] the webhook secret is {}; nothing was kept", kind.hook_words)));
        }
        let held = held_vendors(&before_feed).contains(&inst);
        if !held && (key.is_empty() || hook.is_empty()) {
            return Err(bad(format!("REFUSED [site-vendor-new] {}/{inst} is a NEW account: give its secret key and its webhook secret", kind.kind)));
        }
        let (key_name, hook_name) = secret_names(kind, &inst);
        let held_key = before_feed.get(&format!("secret.{key_name}")).cloned().unwrap_or_default();
        let effective = if key.is_empty() { held_key.as_str() } else { key };
        if !effective.is_empty() && kind.is_live(effective) != p.live {
            return Err(bad(format!(
                "REFUSED [site-vendor-mode] the account's key is a {} key and the site says {}: a live site takes a live key, a test site a test key; nothing was kept",
                if kind.is_live(effective) { "LIVE" } else { "TEST" },
                if p.live { "live" } else { "test" }
            )));
        }
        let media_from = p.media_from.trim().to_string();
        if !media_from.is_empty() && !media_sites(&before_feed).contains(&media_from) {
            return Err(bad(format!("REFUSED [site-media-from] {media_from} lays from no media of its own ({})", media_sites(&before_feed).join(", "))));
        }
        let t = &p.terms;
        if t.term_days > 366 || t.term_months > 36 || t.grace_hours > 24 * 31 || t.margin_bp > 100_000 || (t.term_days > 0 && t.term_months > 0) {
            return Err(bad("REFUSED [site-terms] a term is days (at most 366) OR months (at most 36), a grace at most 31 days, a margin at most 1000 %"));
        }
        let held_live = before_feed.get(&format!("{}{inst}.toml", prefix_of(kind))).and_then(|t| toml::from_str::<toml::Table>(t).ok()).and_then(|t| t.get("livemode").and_then(|v| v.as_bool()));
        if !held || !key.is_empty() || !hook.is_empty() || held_live != Some(p.live) {
            let site = b.vendors.get(&inst).map(|v| v.site.clone()).filter(|s| !s.is_empty()).unwrap_or_else(|| if held { String::new() } else { domain.clone() });
            b.vendors.insert(inst.clone(), AppVendor { live: p.live, site });
        }
        if !key.is_empty() {
            secrets.insert(key_name, key.to_string());
        }
        if !hook.is_empty() {
            secrets.insert(hook_name, hook.to_string());
        }
        b.sites.insert(label.to_string(), AppSite { domain, product, first_admin, operator_site: p.operator_site, terminable: p.terminable, vendor, terms: t.clone(), media_from });
        let after_feed = overlay(&base, &b, &secrets).map_err(SiteBookRefusal::Refused)?;
        crate::solo::judge_in(&self.root, JUDGE_REL, &after_feed, &crate::linked().product_names()).map_err(|e| SiteBookRefusal::Refused(format!("REFUSED [site-judged] the box's loader refused the feed with this change, and nothing was kept: {e}")))?;
        let after = record_of(&after_feed, &b, label).ok_or_else(|| SiteBookRefusal::Broken(format!("{label} is not in the feed it was put in")))?;
        let changed = changes(before.as_ref(), &after, !key.is_empty(), !hook.is_empty());
        if changed.is_empty() {
            return Ok(SitePutDone { words: format!("{label}: nothing changed"), changed });
        }
        write_json(&self.root, SECRETS_REL, &secrets).map_err(SiteBookRefusal::Broken)?;
        write_json(&self.root, SITES_REL, &b).map_err(SiteBookRefusal::Broken)?;
        let verb = if p.create { "added" } else { "edited" };
        let actor = if p.actor.trim().is_empty() { "the operator".to_string() } else { p.actor.trim().to_string() };
        append_audit(&self.root, &Audit { at_unix_ms: now_ms(), actor: actor.clone(), verb: verb.into(), estate: label.into(), changed: changed.clone() }).map_err(SiteBookRefusal::Broken)?;
        tracing::warn!(estate = label, actor = %actor, changes = changed.len(), "site book: the operator {verb} a site; the box takes it on its next look");
        let words = format!("{label} is {verb}: the box takes it on its next look (two seconds), and it is ordered on the Sites page");
        Ok(SitePutDone { words, changed })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ **The installer's feed is SEALED at rest, and the base feed opens it** (lane G5,
    /// Skidbladnir nordisk-pairing 0.5.17 `fed_at_rest`, Gunnar 0a038157): the front keeps its
    /// `joined.toml` sealed; the base feed under the door's reads it as it read a plain one.
    #[test]
    fn the_base_feed_opens_the_sealed_installer_feed() {
        let d = tempfile::tempdir().unwrap();
        let reply = nordisk_pairing::join::JoinReply {
            estate: "one.test".into(),
            me: nordisk_pairing::join::Assigned { name: "vm1".into(), overlay: nordisk_pairing::OVERLAY_FRONT },
            cert_pem: None,
            role: Some(nordisk_pairing::Role::Front),
            primary_overlay: None,
            primary_utility: None,
            peers: Vec::new(),
            feed: std::collections::BTreeMap::from([("monetize_pubkey".to_string(), "eb16f371b856".to_string())]),
        };
        nordisk_pairing::fed_at_rest::write(d.path(), nordisk_pairing::boxside::JOINED_REL, &reply.render().unwrap()).unwrap();
        assert_eq!(base_feed(d.path()).get("monetize_pubkey").map(String::as_str), Some("eb16f371b856"));
    }

    /// ★ **R34-8: an app-made account's every way back is its own site's** (rehearsal 2026-10-25:
    /// the block rewrote success_url and cancel_url to the new site and kept the template's
    /// `return_url` — another site's). A template with no return_url gives none.
    #[test]
    fn an_app_made_accounts_return_url_is_its_own_sites() {
        let template = "enabled = true\nlivemode = false\nsuccess_url = \"https://test2.holger.rs/paid/{reference}\"\ncancel_url = \"https://test2.holger.rs/cancel/{reference}\"\nreturn_url = \"https://test2.holger.rs/paid/{reference}\"\n";
        let block: toml::Table = toml::from_str(&vendor_block(monetize_pay::shapes::of("stripe").unwrap(), template, "r34-check", &AppVendor { live: false, site: "r34-check.gunnar.rs".into() }).unwrap()).unwrap();
        for k in ["success_url", "return_url"] {
            assert_eq!(block[k].as_str(), Some("https://r34-check.gunnar.rs/paid/{reference}"), "{k}: {block:?}");
        }
        assert_eq!(block["cancel_url"].as_str(), Some("https://r34-check.gunnar.rs/cancel/{reference}"));
        let bare: toml::Table = toml::from_str(&vendor_block(monetize_pay::shapes::of("stripe").unwrap(), "enabled = true\n", "r34-check", &AppVendor { live: false, site: "r34-check.gunnar.rs".into() }).unwrap()).unwrap();
        assert!(!bare.contains_key("return_url"), "no return_url is made up: {bare:?}");
    }

    /// ★ **The writer of a product's zone** (DNS-WRITER): a fed site's of the product; else the
    /// zone writer `<writer>-<product>` the feed holds; else the shop's own for its own product;
    /// else none — never another product's zone.
    #[test]
    fn writer_for_names_the_products_own_zone_writer() {
        let fed: toml::Table = toml::from_str("[test2-gunnar-rs]\nproduct = \"gunnar\"\norders = { dns = \"cloudflare\" }\n[test2-holger-rs]\nproduct = \"holger\"\norders = { dns = \"cloudflare-holger\" }\n[bare]\nproduct = \"lake\"\n").unwrap();
        let none = Feed::new();
        assert_eq!(writer_for(&none, &fed, "gunnar", "holger").as_deref(), Some("cloudflare-holger"), "a fed Holger site's");
        assert_eq!(writer_for(&none, &fed, "gunnar", "gunnar").as_deref(), Some("cloudflare"));
        let gunnar_only: toml::Table = fed.iter().filter(|(l, _)| l.as_str() == "test2-gunnar-rs").map(|(l, v)| (l.clone(), v.clone())).collect();
        assert_eq!(writer_for(&none, &gunnar_only, "gunnar", "holger"), None, "no zone writer for Holger: none");
        let zone = Feed::from([("dns_guest.cloudflare-holger.toml".to_string(), "zone_id = \"z\"\n".to_string())]);
        assert_eq!(writer_for(&zone, &gunnar_only, "gunnar", "holger").as_deref(), Some("cloudflare-holger"), "the zone writer the feed holds");
        assert_eq!(writer_for(&zone, &gunnar_only, "", "holger"), None, "a box that sells nothing names no writer");
        assert_eq!(writer_for(&zone, &fed, "gunnar", "lake"), None, "a product whose fed site names none, and no zone: none");
    }

    #[test]
    fn labels_domains_and_secret_names() {
        assert!(label_ok("test5-gunnar-rs") && !label_ok("Test5") && !label_ok("-x") && !label_ok("a/b") && !label_ok(""));
        assert!(domain_ok("test5.gunnar.rs") && !domain_ok("gunnar") && !domain_ok("a..b") && !domain_ok("x.Y.rs"));
        assert_eq!(secret_names(monetize_pay::shapes::of("stripe").unwrap(), "gunnar-test2"), ("MONETIZE_STRIPE_GUNNAR_TEST2_SECRET_KEY".into(), "MONETIZE_STRIPE_GUNNAR_TEST2_WEBHOOK_SECRET".into()));
        assert_eq!(when(0), "1970-01-01 00:00:00 UTC");
        assert_eq!(when(1_760_443_200_000), "2025-10-14 12:00:00 UTC");
    }
}