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