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/services/site_book.rs

//! **`SiteBook`** — the operator adds and edits a site in the application
//! ([`crate::site_book`]). Thin: the linked book judges, keeps and audits; this checks the
//! operator, maps the wire, and adds the shop's read-only words (currency, sizes).

use std::sync::Arc;

use monetize_proto as pb;
use monetize_proto::site_book_server::SiteBook;
use tonic::{Request, Response, Status};

use crate::services::blocking;
use crate::site_book::{self as book, SiteBookRefusal, SitePut, SiteTerms};
use crate::state::AppState;

pub struct SiteBookService(pub Arc<AppState>);

/// The operator's alone: the operator marker, never a site's own bearer.
fn operator_only<T>(req: &Request<T>) -> bool {
    req.extensions().get::<crate::auth::Operator>().is_some()
        && req
            .extensions()
            .get::<crate::auth::CallerEstate>()
            .is_none()
}

fn refused(r: SiteBookRefusal) -> Status {
    match r {
        SiteBookRefusal::Bad(w) => Status::invalid_argument(w),
        SiteBookRefusal::Taken(w) => Status::already_exists(w),
        SiteBookRefusal::NotFound(w) => Status::not_found(w),
        SiteBookRefusal::Refused(w) => Status::failed_precondition(w),
        SiteBookRefusal::Broken(w) => Status::internal(w),
    }
}

fn the_book(s: &AppState) -> Result<book::SiteBookLink, Status> {
    s.registry
        .linked()
        .site_book
        .clone()
        .ok_or_else(|| Status::unimplemented(book::NO_BOOK))
}

fn terms_in(t: Option<pb::SiteTerms>) -> SiteTerms {
    let t = t.unwrap_or_default();
    SiteTerms {
        term_days: t.term_days,
        term_months: t.term_months,
        grace_hours: t.grace_hours,
        margin_bp: t.margin_bp,
    }
}

fn terms_out(t: &SiteTerms) -> pb::SiteTerms {
    pb::SiteTerms {
        term_days: t.term_days,
        term_months: t.term_months,
        grace_hours: t.grace_hours,
        margin_bp: t.margin_bp,
    }
}

/// Pure: the wire's put as the book takes it.
pub fn put_of(r: pb::SitePutRequest) -> SitePut {
    SitePut {
        estate: r.estate.trim().to_owned(),
        create: r.create,
        domain: r.domain.trim().to_ascii_lowercase(),
        product: r.product.trim().to_owned(),
        first_admin: r.first_admin.trim().to_owned(),
        operator_site: r.operator_site,
        terminable: r.terminable,
        vendor: r.vendor.trim().to_owned(),
        vendor_secret_key: r.vendor_secret_key.trim().to_owned(),
        vendor_webhook_secret: r.vendor_webhook_secret.trim().to_owned(),
        live: r.live,
        terms: terms_in(r.terms),
        media_from: r.media_from.trim().to_owned(),
        actor: r.actor.trim().to_owned(),
    }
}

/// Pure: a record on the wire (it carries no secret: the record has none).
pub fn record_out(s: &book::SiteRecord) -> pb::SiteRecord {
    pb::SiteRecord {
        estate: s.estate.clone(),
        domain: s.domain.clone(),
        product: s.product.clone(),
        first_admin: s.first_admin.clone(),
        operator_site: s.operator_site,
        terminable: s.terminable,
        vendor: s.vendor.clone(),
        live: s.live,
        terms: Some(terms_out(&s.terms)),
        media_from: s.media_from.clone(),
        source: s.source.clone(),
        secret_key_set: s.secret_key_set,
        secret_key_fingerprint: s.secret_key_fingerprint.clone(),
        webhook_secret_set: s.webhook_secret_set,
        webhook_secret_fingerprint: s.webhook_secret_fingerprint.clone(),
    }
}

#[tonic::async_trait]
impl SiteBook for SiteBookService {
    async fn put_site(
        &self,
        req: Request<pb::SitePutRequest>,
    ) -> Result<Response<pb::SitePutResponse>, Status> {
        if !operator_only(&req) {
            return Err(Status::permission_denied("a site is added and edited by the operator: the operator bearer — never a site's own, never the service bearer"));
        }
        let put = put_of(req.into_inner());
        let done = blocking(&self.0, move |s| {
            let b = the_book(s)?;
            b.0.put(&put).map_err(refused)
        })
        .await?;
        Ok(Response::new(pb::SitePutResponse {
            words: done.words,
            changed: done.changed,
        }))
    }

    async fn show_site(
        &self,
        req: Request<pb::SiteShowRequest>,
    ) -> Result<Response<pb::SiteShowResponse>, Status> {
        if !operator_only(&req) {
            return Err(Status::permission_denied(
                "the site book is the operator's to read: the operator bearer",
            ));
        }
        let estate = req.into_inner().estate.trim().to_owned();
        let (shown, currency, sizes) = blocking(&self.0, move |s| {
            let b = the_book(s)?;
            let mut shown = b.0.show(&estate).map_err(refused)?;
            served_domain(s, &estate, &mut shown);
            let currency = s
                .estates
                .cfg
                .as_ref()
                .map(|c| c.currency.clone())
                .unwrap_or_default();
            Ok((
                shown,
                currency,
                crate::estate_order::site::sizes_on_offer(s),
            ))
        })
        .await?;
        Ok(Response::new(pb::SiteShowResponse {
            site: Some(record_out(&shown.site)),
            products: shown.products,
            vendors: shown.vendors,
            media_sites: shown.media_sites,
            currency,
            sizes,
            history: shown.history,
        }))
    }
}

/// **R34-6: one source of truth for a site's domain** — what the box SERVES (its config's
/// `[estates.<label>] site`, the one `site list` says and an order lays). The book's own word,
/// when the box does not serve it (not taken yet, or a feed it refused), is said first in the
/// history, never as the domain.
fn served_domain(s: &AppState, estate: &str, shown: &mut book::SiteShown) {
    if estate.is_empty() {
        return;
    }
    let declared = s
        .registry
        .declared_estates()
        .into_iter()
        .any(|(l, _)| l == estate);
    let served = s.registry.site_of(estate).unwrap_or_default();
    let book = shown.site.domain.clone();
    if declared && served == book {
        return;
    }
    let line = if declared {
        format!("not served: this box serves {estate} under {:?}; the book's {book:?} is not taken (yet)", served)
    } else {
        format!("not served yet: this box does not serve {estate}; the book's {book:?} is taken on the box's next look")
    };
    shown.history.insert(0, line);
    if declared {
        shown.site.domain = served;
    }
}

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

    #[derive(Default)]
    struct Kept(Mutex<Vec<SitePut>>);
    struct Book(Arc<Kept>);
    impl book::SiteBook for Book {
        fn show(&self, estate: &str) -> Result<book::SiteShown, SiteBookRefusal> {
            if estate == "nowhere" {
                return Err(SiteBookRefusal::NotFound(
                    "nowhere is not a site of this box".into(),
                ));
            }
            if estate == "gunnar-p1-home" || estate == "gunnar-r34b-check" {
                let domain = if estate == "gunnar-p1-home" {
                    "p1-home.gunnar.rs"
                } else {
                    "r34b-check.gunnar.rs"
                };
                return Ok(book::SiteShown {
                    site: book::SiteRecord {
                        estate: estate.into(),
                        domain: domain.into(),
                        ..Default::default()
                    },
                    ..Default::default()
                });
            }
            Ok(book::SiteShown {
                site: book::SiteRecord {
                    estate: estate.into(),
                    secret_key_set: true,
                    secret_key_fingerprint: book::fingerprint("sk_test_x"),
                    ..Default::default()
                },
                vendors: vec!["stripe/gunnar".into()],
                ..Default::default()
            })
        }
        fn put(&self, put: &SitePut) -> Result<book::SitePutDone, SiteBookRefusal> {
            self.0 .0.lock().unwrap().push(put.clone());
            if put.domain.is_empty() {
                return Err(SiteBookRefusal::Bad("a site names its domain".into()));
            }
            Ok(book::SitePutDone {
                words: format!("{} is added", put.estate),
                changed: vec!["domain: test5.gunnar.rs".into()],
            })
        }
    }

    fn state_with(book: Option<Book>) -> Arc<AppState> {
        let linked = crate::registry::Linked {
            site_book: book.map(|b| book::SiteBookLink(Arc::new(b))),
            ..Default::default()
        };
        Arc::new(AppState::in_memory(
            crate::registry::Registry::new().with_linked(linked),
        ))
    }

    fn as_operator<T>(t: T) -> Request<T> {
        let mut r = Request::new(t);
        r.extensions_mut().insert(crate::auth::Operator);
        r
    }

    /// ★ **R34-6: `site show` names the domain the box SERVES, as `site list` does** (rehearsal
    /// 2026-10-25: list said test2.gunnar.rs, show p1-home.gunnar.rs for one site). The book's
    /// own word, when the box does not serve it (yet), is said beside it — never as the domain.
    #[tokio::test]
    async fn site_show_names_the_domain_the_box_serves() {
        let linked = crate::registry::Linked {
            site_book: Some(book::SiteBookLink(Arc::new(Book(
                Arc::new(Kept::default()),
            )))),
            ..Default::default()
        };
        let site = crate::config::EstateConfig {
            product: "gunnar".into(),
            vendor: "stripe/gunnar-test2".into(),
            site: "test2.gunnar.rs".into(),
            operator_site: true,
            owner: "ignalina".into(),
            ..Default::default()
        };
        let state = Arc::new(AppState::in_memory(
            crate::registry::Registry::new()
                .with_linked(linked)
                .estate("gunnar-p1-home", site),
        ));
        let svc = SiteBookService(state);
        let shown = svc
            .show_site(as_operator(pb::SiteShowRequest {
                estate: "gunnar-p1-home".into(),
            }))
            .await
            .unwrap()
            .into_inner();
        assert_eq!(
            shown.site.as_ref().unwrap().domain,
            "test2.gunnar.rs",
            "the served domain, as the list says it"
        );
        assert!(
            shown
                .history
                .first()
                .is_some_and(|h| h.contains("p1-home.gunnar.rs") && h.contains("not served")),
            "the book's word is said beside it: {:?}",
            shown.history
        );
        let fresh = svc
            .show_site(as_operator(pb::SiteShowRequest {
                estate: "gunnar-r34b-check".into(),
            }))
            .await
            .unwrap()
            .into_inner();
        assert_eq!(fresh.site.as_ref().unwrap().domain, "r34b-check.gunnar.rs");
        assert!(
            fresh
                .history
                .first()
                .is_some_and(|h| h.contains("not served")),
            "a site the box does not serve yet says so: {:?}",
            fresh.history
        );
    }

    /// ★ **The site book is the operator's alone**; a build that links none says so; the
    /// book's refusal is answered in its own words; the wire's put is trimmed and its
    /// domain lowercased — and a show carries a fingerprint, never a secret.
    #[tokio::test]
    async fn the_site_book_is_the_operators_and_carries_the_books_words() {
        let kept = Arc::new(Kept::default());
        let svc = SiteBookService(state_with(Some(Book(kept.clone()))));
        let put = pb::SitePutRequest {
            estate: " test5-gunnar-rs ".into(),
            create: true,
            domain: " Test5.Gunnar.RS ".into(),
            vendor_secret_key: " sk_test_abc ".into(),
            actor: "op-1".into(),
            ..Default::default()
        };
        let e = svc.put_site(Request::new(put.clone())).await.unwrap_err();
        assert_eq!(
            e.code(),
            tonic::Code::PermissionDenied,
            "the service bearer is refused"
        );
        let mut site = as_operator(put.clone());
        site.extensions_mut()
            .insert(crate::auth::CallerEstate("gunnar-rs".into()));
        assert_eq!(
            svc.put_site(site).await.unwrap_err().code(),
            tonic::Code::PermissionDenied,
            "a site's own bearer is refused"
        );
        let done = svc
            .put_site(as_operator(put.clone()))
            .await
            .unwrap()
            .into_inner();
        assert_eq!(done.words, "test5-gunnar-rs is added");
        let took = kept.0.lock().unwrap().last().cloned().unwrap();
        assert_eq!(
            (
                took.estate.as_str(),
                took.domain.as_str(),
                took.vendor_secret_key.as_str(),
                took.actor.as_str()
            ),
            ("test5-gunnar-rs", "test5.gunnar.rs", "sk_test_abc", "op-1")
        );
        let e = svc
            .put_site(as_operator(pb::SitePutRequest {
                estate: "x".into(),
                ..Default::default()
            }))
            .await
            .unwrap_err();
        assert_eq!(
            (e.code(), e.message()),
            (tonic::Code::InvalidArgument, "a site names its domain")
        );
        let shown = svc
            .show_site(as_operator(pb::SiteShowRequest {
                estate: "test5-gunnar-rs".into(),
            }))
            .await
            .unwrap()
            .into_inner();
        let r = shown.site.unwrap();
        assert!(r.secret_key_set && r.secret_key_fingerprint.starts_with("sha256:"));
        assert_eq!(shown.vendors, vec!["stripe/gunnar".to_string()]);
        assert_eq!(
            svc.show_site(as_operator(pb::SiteShowRequest {
                estate: "nowhere".into()
            }))
            .await
            .unwrap_err()
            .code(),
            tonic::Code::NotFound
        );
        let none = SiteBookService(state_with(None));
        let e = none
            .show_site(as_operator(pb::SiteShowRequest::default()))
            .await
            .unwrap_err();
        assert_eq!(
            (e.code(), e.message()),
            (tonic::Code::Unimplemented, book::NO_BOOK)
        );
    }
}