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");
}
}