crates/monetize-server/src/site_book.rs
//! **The operator's SITE BOOK** (owner 2026-10-14: "environments are set up by Rust code in
//! the application, not by a kit") — the seam a site is added and edited through: its
//! domain, first admin, terms, its own Stripe account and that account's two secrets.
//!
//! monetize-server serves the verbs (`SiteBook.PutSite`, `.ShowSite`; the operator's alone)
//! and decides nothing about WHERE a site is kept: the build that hosts the box links the
//! book ([`Linked::site_book`](crate::registry::Linked)) — the ONE box's `monetize-admin`
//! keeps it in its own store beside the fed declaration, judges every change with the box's
//! own loader (refused whole, by name), applies it live through the config watcher, and
//! audits it. A monetize that links none answers that its sites are its config file's.
//!
//! **Secrets are write-only.** A put carries them; nothing here or in a book ever answers
//! one back: a show says `set` and a fingerprint at most.
use std::fmt;
use std::sync::Arc;
/// A site's own order terms; 0 = the shop's.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct SiteTerms {
pub term_days: u32,
pub term_months: u32,
pub grace_hours: u32,
pub margin_bp: u32,
}
/// **One site, as the form edits it** (never a secret's value).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SiteRecord {
pub estate: String,
pub domain: String,
pub product: String,
pub first_admin: String,
pub operator_site: bool,
pub terminable: bool,
pub vendor: String,
pub live: bool,
pub terms: SiteTerms,
pub media_from: String,
/// `fed` | `app` | empty (a blank new site).
pub source: String,
pub secret_key_set: bool,
pub secret_key_fingerprint: String,
pub webhook_secret_set: bool,
pub webhook_secret_fingerprint: String,
}
/// **A site as the book shows it**, with the form's choices and the site's audit history.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SiteShown {
pub site: SiteRecord,
pub products: Vec<String>,
pub vendors: Vec<String>,
pub media_sites: Vec<String>,
pub history: Vec<String>,
}
/// **A put** — an add (`create`) or an edit. The two secrets are write-only: empty keeps
/// the held one. `Debug` never prints them.
#[derive(Clone, Default, PartialEq, Eq)]
pub struct SitePut {
pub estate: String,
pub create: bool,
pub domain: String,
pub product: String,
pub first_admin: String,
pub operator_site: bool,
pub terminable: bool,
pub vendor: String,
pub vendor_secret_key: String,
pub vendor_webhook_secret: String,
pub live: bool,
pub terms: SiteTerms,
pub media_from: String,
pub actor: String,
}
impl fmt::Debug for SitePut {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let said = |s: &str| {
if s.is_empty() {
"(keep)"
} else {
"(given, never shown)"
}
};
f.debug_struct("SitePut")
.field("estate", &self.estate)
.field("create", &self.create)
.field("domain", &self.domain)
.field("product", &self.product)
.field("first_admin", &self.first_admin)
.field("operator_site", &self.operator_site)
.field("terminable", &self.terminable)
.field("vendor", &self.vendor)
.field("vendor_secret_key", &said(&self.vendor_secret_key))
.field("vendor_webhook_secret", &said(&self.vendor_webhook_secret))
.field("live", &self.live)
.field("terms", &self.terms)
.field("media_from", &self.media_from)
.field("actor", &self.actor)
.finish()
}
}
/// What a put did: one sentence, and the changes field by field as the audit keeps them.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SitePutDone {
pub words: String,
pub changed: Vec<String>,
}
/// **Why a book refused** — each the operator reads whole (these are his own sites).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SiteBookRefusal {
/// The ask is malformed (a label, a domain, a key of the wrong shape).
Bad(String),
/// An add whose label is taken.
Taken(String),
/// An edit or a show of a site the box does not have.
NotFound(String),
/// The box's own loader refused the feed with the change over it: nothing was kept.
Refused(String),
/// The book could not be read or written.
Broken(String),
}
impl fmt::Display for SiteBookRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SiteBookRefusal::Bad(w)
| SiteBookRefusal::Taken(w)
| SiteBookRefusal::NotFound(w)
| SiteBookRefusal::Refused(w)
| SiteBookRefusal::Broken(w) => f.write_str(w),
}
}
}
/// **Where a box keeps its sites** — linked by the build that hosts the box.
pub trait SiteBook: Send + Sync {
/// One site (empty `estate`: a blank new one) with the choices and its history.
fn show(&self, estate: &str) -> Result<SiteShown, SiteBookRefusal>;
/// Add or edit: judged, kept and audited, or refused whole.
fn put(&self, put: &SitePut) -> Result<SitePutDone, SiteBookRefusal>;
}
/// The book a build links ([`crate::registry::Linked::site_book`]).
#[derive(Clone)]
pub struct SiteBookLink(pub Arc<dyn SiteBook>);
impl fmt::Debug for SiteBookLink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SiteBookLink")
}
}
/// What a monetize that links no book answers.
pub const NO_BOOK: &str = "this monetize keeps no site book: its sites are its config file's ([estates.<label>]), not the application's";
/// Pure: **a secret's fingerprint** as a show may say it — `sha256:` and its first 8 hex.
/// Never the value, never enough of it to matter.
pub fn fingerprint(secret: &str) -> String {
use sha2::Digest as _;
let d = sha2::Sha256::digest(secret.trim().as_bytes());
format!(
"sha256:{}",
d.iter()
.take(4)
.map(|b| format!("{b:02x}"))
.collect::<String>()
)
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ A put's Debug never prints a secret, and a fingerprint is 8 hex of its digest.
#[test]
fn a_put_never_prints_its_secrets_and_a_fingerprint_is_short() {
let p = SitePut {
estate: "test5-gunnar-rs".into(),
vendor_secret_key: "sk_test_51SECRETVALUE".into(),
vendor_webhook_secret: "whsec_SECRETHOOK".into(),
..Default::default()
};
let d = format!("{p:?}");
assert!(
!d.contains("SECRETVALUE")
&& !d.contains("SECRETHOOK")
&& d.contains("given, never shown"),
"{d}"
);
assert!(format!("{:?}", SitePut::default()).contains("(keep)"));
let f = fingerprint("sk_test_51SECRETVALUE");
assert!(
f.starts_with("sha256:") && f.len() == 15 && !f.contains("SECRET"),
"{f}"
);
assert_eq!(
f,
fingerprint(" sk_test_51SECRETVALUE\n"),
"trimmed as kept"
);
}
}