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

nordisk/monetize

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