crates/monetize-ui-server/src/site_order.rs
//! **THE SITE ORDER** (owner 2026-10-13: "the shop must deploy gunnar.rs itself") — the
//! operator's form: his own sites, what an order needs of each (already fed), and the order.
//! Thin: monetize says which sites, what is fed, whether one can be ordered and what an
//! order did; this side only carries it.
//!
//! ```text
//! GET /api/sites → {sites: [SiteJson], sizes: [..]}
//! POST /api/sites/order {estate, size, email, confirm} → {reference, checkout_url, price_words, words}
//! POST /api/sites/terminate {estate, confirm} → {words, done}
//! POST /api/sites/update {estate, release, confirm} → UpdateJson ("Update to release X")
//! GET /api/sites/update-status?estate=… → UpdateJson
//! POST /api/sites/live-update {estate, on} → UpdateJson
//! ```
//!
//! The POST is a mutation under `/api/`: the console's own gate floors it at admin with a
//! fresh passkey touch; monetize refuses anyone but the operator a second time.
use axum::extract::State;
use axum::Json;
use monetize_proto as pb;
use serde::{Deserialize, Serialize};
use crate::api::ApiError;
use crate::app::AppState;
use crate::grpc::Fail;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SiteJson {
pub estate: String,
pub domain: String,
pub vendor: String,
pub first_admin: String,
pub dns_words: String,
pub dns_writes: bool,
pub cloud_words: String,
pub state: String,
pub words: String,
pub checkout_url: String,
pub orderable: bool,
pub why_not: String,
/// monetize's word that a `Terminate` may end it; a protected site is false.
pub terminable: bool,
/// monetize's word that "Check again" may ask its boxes (F5).
pub recheckable: bool,
/// ★ A33-6: monetize's words for what it costs.
pub price_words: String,
/// ★ A32-29: **the settles this site offers** ([`site_settles`]): the console draws them
/// (for an admin) and decides none.
#[serde(default)]
pub actions: Vec<crate::accounts::ActionJson>,
/// ★ FIRST-LINK-1: a laid site's first-admin door as monetize's `Status` says it — `open`,
/// `spent`, `expired`, `handed_off`, `unknown` — and its one sentence (an address masked);
/// empty when not asked (not ready) or not answered. Shown beside "Send the first sign-in
/// link again".
#[serde(default)]
pub first_admin_door: String,
#[serde(default)]
pub first_admin_door_words: String,
}
/// Pure: **a site with its first-admin door**, from `EstateOrder.Status` (FIRST-LINK-1).
pub fn with_door(mut site: SiteJson, status: &pb::EstateOrderStatusResponse) -> SiteJson {
site.first_admin_door = status.first_admin_door.clone();
site.first_admin_door_words = status.first_admin_door_words.clone();
site
}
/// Pure: **whose door is asked**: a site that offers "Send the first sign-in link again".
pub fn asks_door(site: &SiteJson) -> bool {
site.actions.iter().any(|a| a.verb == "first-link")
}
/// Pure: **the settles a site offers** (F2, F5; moved from monetize-ui-web, A32-29): "Check
/// again" first where its machines stand, then by state: owed, set up again or refund; a
/// stopped install, set up again; installing, abandon.
pub fn site_settles(state: &str, recheckable: bool) -> Vec<crate::accounts::ActionJson> {
use crate::accounts::ActionJson;
let mut out = if recheckable { vec![ActionJson::of("recheck")] } else { Vec::new() };
match state {
"owed" => out.extend([ActionJson::of("retry"), ActionJson::of("refund")]),
"install_failed" => out.push(ActionJson::of("retry")),
"installing" => out.push(ActionJson::of("abandon")),
// FIRST-LINK-1: a laid site's first sign-in link again (EstateOrder.FirstLink, no address).
"ready" => out.push(ActionJson::of("first-link")),
_ => {}
}
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SitesJson {
pub sites: Vec<SiteJson>,
pub sizes: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OrderBody {
pub estate: String,
#[serde(default)]
pub size: String,
pub email: String,
/// The seed's PUBLIC key (optional): the kit seeds the site's repositories with its private half.
#[serde(default)]
pub seed_key: String,
/// The operator's "lay it": a real site is laid and EUR 0.50 is paid.
#[serde(default)]
pub confirm: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OrderedJson {
pub reference: String,
pub checkout_url: String,
pub price_words: String,
pub words: String,
}
/// Pure: one site for the browser, as monetize said it.
pub fn site_json(s: &pb::EstateSite) -> SiteJson {
SiteJson {
estate: s.estate.clone(),
domain: s.domain.clone(),
vendor: s.vendor.clone(),
first_admin: s.first_admin.clone(),
dns_words: s.dns_words.clone(),
dns_writes: s.dns_writes,
cloud_words: s.cloud_words.clone(),
state: s.state.clone(),
words: s.words.clone(),
checkout_url: s.checkout_url.clone(),
price_words: s.price_words.clone(),
orderable: s.orderable,
why_not: s.why_not.clone(),
terminable: s.terminable,
recheckable: s.recheckable,
actions: site_settles(&s.state, s.recheckable),
first_admin_door: String::new(),
first_admin_door_words: String::new(),
}
}
/// Pure: the order as monetize takes it — carried, never decided here. `return_url`: this
/// console's own Sites page ([`sites_page`]), where the payment page sends the operator back.
pub fn order_request(b: &OrderBody, return_url: &str) -> pb::EstateOrderSiteRequest {
pb::EstateOrderSiteRequest { estate: b.estate.trim().to_owned(), size: b.size.trim().to_owned(), email: b.email.trim().to_owned(), seed_key: b.seed_key.trim().to_owned(), return_url: return_url.to_owned(), test_order: false }
}
/// Pure: **this console's Sites page** — from its passkey origin (`https://monetize.ignalina.dk`),
/// else the request's own `Host` (https); empty when neither names one. The payment page sends
/// the operator back HERE, never to the site being laid, which has no front yet (live
/// 2026-10-14: back to `https://test2.gunnar.rs/paid/estate/test2gunnarrs`, nothing there).
pub fn sites_page(origin: Option<&str>, host: Option<&str>) -> String {
if let Some(o) = origin.map(|o| o.trim().trim_end_matches('/')).filter(|o| o.starts_with("https://")) {
return format!("{o}/sites");
}
match host.map(str::trim).filter(|h| !h.is_empty() && !h.chars().any(|c| c.is_whitespace() || c == '/')) {
Some(h) => format!("https://{h}/sites"),
None => String::new(),
}
}
/// The console's client with the OPERATOR bearer — what its money pages (refund, settle) use.
fn operator(state: &AppState) -> Result<&crate::grpc::Monetize, ApiError> {
state.operator.as_ref().ok_or_else(|| ApiError(Fail { code: "unavailable".into(), message: "this console holds no operator credential, so it cannot order a site; nothing was sent".into() }))
}
/// `GET /api/sites` — the operator's sites and the sizes on offer (`EstateOrder.ListSites`).
pub async fn list(State(state): State<AppState>) -> Result<Json<SitesJson>, ApiError> {
let t0 = std::time::Instant::now();
let m = operator(&state)?;
let r = m.estate_orders().list_sites(pb::EstateSitesRequest {}).await?.into_inner();
// FIRST-LINK-1: the door beside the button, as monetize says it; a status that does not
// answer leaves it empty (the button still stands). MHUNT-1 / RA5-25: monetize answers it
// by asking the site's own front, so every door is asked AT ONCE and within what is left of
// the page's bound (one flight per site, a late one goes on and is kept), and a door read
// less than DOOR_FRESH ago is not asked again: a page view starts no ceremony on the site.
let left = crate::bounded::PAGE_WITHIN.saturating_sub(t0.elapsed()).max(std::time::Duration::from_millis(100));
let doors = r.sites.iter().map(site_json).map(|s| {
let m = m.clone();
async move {
if !asks_door(&s) {
return s;
}
if let Some((st, _)) = m.site_status_seen.get(&s.estate).filter(|(_, age)| *age < DOOR_FRESH.as_secs()) {
return with_door(s, &st);
}
let mut ask = m.estate_orders();
let company = s.estate.clone();
let call = async move { ask.status(pb::EstateOrderStatusRequest { company, actor_ticket: Vec::new() }).await.map(|r| r.into_inner()) };
match crate::bounded::within_for(&m.site_status_seen, &s.estate, "the site's status", left, call).await {
Ok((st, _)) => with_door(s, &st),
Err(_) => s,
}
}
});
let sites = futures_util::future::join_all(doors).await;
Ok(Json(SitesJson { sites, sizes: r.sizes }))
}
/// How long a site's first-admin door, once read, is shown without asking the site again
/// (MHUNT-1 / RA5-25: each ask probes the site's live door with its invite).
pub const DOOR_FRESH: std::time::Duration = std::time::Duration::from_secs(60);
/// `POST /api/sites/order` — lay one of the operator's own sites (`EstateOrder.OrderSite`).
pub async fn order(State(state): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<OrderBody>) -> Result<Json<OrderedJson>, ApiError> {
if !b.confirm {
return Err(ApiError(Fail::invalid("ordering a site lays real machines and is a real purchase: confirm it")));
}
let host = headers.get(axum::http::header::HOST).and_then(|v| v.to_str().ok());
let back = sites_page(state.webauthn.as_ref().map(|w| w.origin()), host);
let r = operator(&state)?.estate_orders().order_site(order_request(&b, &back)).await?.into_inner();
Ok(Json(OrderedJson { reference: r.reference, checkout_url: r.checkout_url, price_words: r.price_words, words: r.words }))
}
// ── THE NEW-ESTATE SLOT (OVW-CLEAN, owner 2026-10-29) ───────────────────────
//
// ```text
// POST /api/estates/new {name, product, email, size?, confirm} → NewEstateJson
// ```
//
// The Overview's one empty row. Thin: monetize-server's `Estates.NewEstate` decides the rest (a
// declared site is ordered as it is; a new name is copied from a declared site of its product
// into the site book), refuses by name, and hands it to the one site order: the answer names
// where the operator pays, exactly as `POST /api/sites/order` does.
#[derive(Debug, Clone, Deserialize)]
pub struct NewEstateBody {
pub name: String,
pub product: String,
pub email: String,
#[serde(default)]
pub size: String,
/// The operator's "lay it": a real estate is laid and paid for.
#[serde(default)]
pub confirm: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewEstateJson {
pub estate: String,
pub domain: String,
pub created: bool,
pub reference: String,
pub checkout_url: String,
pub price_words: String,
pub words: String,
}
/// Pure: **this console's Overview**, where the payment page sends the operator back after a
/// new estate's payment ([`sites_page`]'s rule, the console's root).
pub fn overview_page(origin: Option<&str>, host: Option<&str>) -> String {
sites_page(origin, host).strip_suffix("sites").map(str::to_owned).unwrap_or_default()
}
/// Pure: the slot's ask as monetize takes it — carried, never decided here.
pub fn new_estate_request(b: &NewEstateBody, return_url: &str, actor: &str) -> pb::NewEstateRequest {
pb::NewEstateRequest { name: b.name.trim().to_owned(), product: b.product.trim().to_owned(), email: b.email.trim().to_owned(), size: b.size.trim().to_owned(), return_url: return_url.to_owned(), actor: actor.to_owned() }
}
/// `POST /api/estates/new` — the New-estate slot (`Estates.NewEstate`, the operator's).
pub async fn new_estate(State(state): State<AppState>, headers: axum::http::HeaderMap, caller: Option<axum::Extension<crate::auth::Caller>>, Json(b): Json<NewEstateBody>) -> Result<Json<NewEstateJson>, ApiError> {
if !b.confirm {
return Err(ApiError(Fail::invalid("a new estate lays real machines and is a real purchase: confirm it")));
}
let actor = caller.map(|axum::Extension(c)| format!("console:{}", c.name())).unwrap_or_else(|| "console".to_owned());
let host = headers.get(axum::http::header::HOST).and_then(|v| v.to_str().ok());
let back = overview_page(state.webauthn.as_ref().map(|w| w.origin()), host);
let r = operator(&state)?.estates().new_estate(new_estate_request(&b, &back, &actor)).await?.into_inner();
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "estate.new", user = %actor, estate = %r.estate, created = r.created, "the operator ordered an estate from the slot");
Ok(Json(NewEstateJson { estate: r.estate, domain: r.domain, created: r.created, reference: r.reference, checkout_url: r.checkout_url, price_words: r.price_words, words: r.words }))
}
/// What `POST /api/sites/terminate` is given: the site and its name typed back. No backup is
/// offered at terminate (owner 2026-10-22).
#[derive(Debug, Clone, Deserialize)]
pub struct TerminateBody {
pub estate: String,
#[serde(default)]
pub confirm: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminatedJson {
pub words: String,
pub done: bool,
}
/// Pure: the terminate as monetize takes it — the typed name is checked THERE.
pub fn terminate_request(b: &TerminateBody) -> pb::EstateOrderTerminateRequest {
pb::EstateOrderTerminateRequest { company: b.estate.trim().to_owned(), actor_ticket: Vec::new(), confirm: b.confirm.trim().to_owned() }
}
/// `POST /api/sites/terminate` — end one of the operator's own sites (`EstateOrder.Terminate`,
/// the operator's). monetize refuses a protected site and a name that was not typed back.
pub async fn terminate(State(state): State<AppState>, Json(b): Json<TerminateBody>) -> Result<Json<TerminatedJson>, ApiError> {
let r = operator(&state)?.estate_orders().terminate(terminate_request(&b)).await?.into_inner();
Ok(Json(TerminatedJson { words: r.words, done: r.done }))
}
// ── THE SITE BOOK (2026-10-14: config in the application) ──────────────────
//
// ```text
// GET /api/sites/book?estate=<label> → SiteShownJson (empty estate: a blank new site + the choices)
// POST /api/sites/put SitePutBody → {words, changed}
// ```
//
// Thin: monetize-server's `SiteBook` judges, keeps and audits. The secrets ride the POST to
// monetize and nowhere else: never logged here, never answered back (a show says "set" and a
// fingerprint at most).
/// A site's own order terms (0: the shop's).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TermsJson {
pub term_days: u32,
pub term_months: u32,
pub grace_hours: u32,
pub margin_bp: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SiteRecordJson {
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: TermsJson,
pub media_from: String,
pub source: String,
pub secret_key_set: bool,
pub secret_key_fingerprint: String,
pub webhook_secret_set: bool,
pub webhook_secret_fingerprint: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SiteShownJson {
pub site: SiteRecordJson,
pub products: Vec<String>,
pub vendors: Vec<String>,
pub media_sites: Vec<String>,
pub currency: String,
pub sizes: Vec<String>,
pub history: Vec<String>,
}
/// What the New-site and Edit forms post. The secrets are write-only (empty: keep).
#[derive(Clone, Default, Deserialize)]
#[serde(default)]
pub struct SitePutBody {
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,
// A32-15: the vendor-neutral names; a page from before reads as these.
#[serde(alias = "stripe_secret_key")]
pub vendor_secret_key: String,
#[serde(alias = "stripe_webhook_secret")]
pub vendor_webhook_secret: String,
pub live: bool,
pub terms: TermsJson,
pub media_from: String,
}
impl std::fmt::Debug for SitePutBody {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SitePutBody").field("estate", &self.estate).field("create", &self.create).field("domain", &self.domain).field("vendor", &self.vendor).field("secrets", &"(never shown)").finish()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SitePutJson {
pub words: String,
pub changed: Vec<String>,
}
/// Pure: the form's put as monetize takes it — `actor` is the signed-in operator.
pub fn put_request(b: &SitePutBody, actor: &str) -> pb::SitePutRequest {
pb::SitePutRequest {
estate: b.estate.trim().to_owned(),
create: b.create,
domain: b.domain.trim().to_owned(),
product: b.product.trim().to_owned(),
first_admin: b.first_admin.trim().to_owned(),
operator_site: b.operator_site,
terminable: b.terminable,
vendor: b.vendor.trim().to_owned(),
vendor_secret_key: b.vendor_secret_key.trim().to_owned(),
vendor_webhook_secret: b.vendor_webhook_secret.trim().to_owned(),
live: b.live,
terms: Some(pb::SiteTerms { term_days: b.terms.term_days, term_months: b.terms.term_months, grace_hours: b.terms.grace_hours, margin_bp: b.terms.margin_bp }),
media_from: b.media_from.trim().to_owned(),
actor: actor.to_owned(),
}
}
/// Pure: a shown site for the browser, as monetize said it (it carries no secret).
pub fn shown_json(r: &pb::SiteShowResponse) -> SiteShownJson {
let s = r.site.clone().unwrap_or_default();
let t = s.terms.unwrap_or_default();
SiteShownJson {
site: SiteRecordJson {
estate: s.estate,
domain: s.domain,
product: s.product,
first_admin: s.first_admin,
operator_site: s.operator_site,
terminable: s.terminable,
vendor: s.vendor,
live: s.live,
terms: TermsJson { term_days: t.term_days, term_months: t.term_months, grace_hours: t.grace_hours, margin_bp: t.margin_bp },
media_from: s.media_from,
source: s.source,
secret_key_set: s.secret_key_set,
secret_key_fingerprint: s.secret_key_fingerprint,
webhook_secret_set: s.webhook_secret_set,
webhook_secret_fingerprint: s.webhook_secret_fingerprint,
},
products: r.products.clone(),
vendors: r.vendors.clone(),
media_sites: r.media_sites.clone(),
currency: r.currency.clone(),
sizes: r.sizes.clone(),
history: r.history.clone(),
}
}
/// `GET /api/sites/book?estate=` — one site as the form edits it (`SiteBook.ShowSite`).
pub async fn book(State(state): State<AppState>, axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>) -> Result<Json<SiteShownJson>, ApiError> {
let estate = q.get("estate").map(|e| e.trim().to_owned()).unwrap_or_default();
let r = operator(&state)?.site_book().show_site(pb::SiteShowRequest { estate }).await?.into_inner();
Ok(Json(shown_json(&r)))
}
/// `POST /api/sites/put` — add or edit a site (`SiteBook.PutSite`); the audit's "who" is the
/// signed-in operator.
pub async fn put(State(state): State<AppState>, caller: Option<axum::Extension<crate::auth::Caller>>, Json(b): Json<SitePutBody>) -> Result<Json<SitePutJson>, ApiError> {
let actor = caller.map(|axum::Extension(c)| format!("console:{}", c.name())).unwrap_or_else(|| "console".to_owned());
let r = operator(&state)?.site_book().put_site(put_request(&b, &actor)).await?.into_inner();
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "site.put", user = %actor, estate = %b.estate, create = b.create, changes = r.changed.len(), "the operator put a site");
Ok(Json(SitePutJson { words: r.words, changed: r.changed }))
}
// ── the update of a site this box laid (owner 2026-10-14) ───────────────────
/// One box's step, as monetize said it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateStepJson {
pub role: String,
pub state: String,
pub how: String,
pub words: String,
}
/// An estate's update, as monetize said it: the release sold now (the button's "X"), what each
/// box runs, live on or off, and the open (else last) update's steps.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateJson {
pub host: String,
pub release: String,
pub release_of: std::collections::BTreeMap<String, String>,
pub live_on: bool,
pub state: String,
pub steps: Vec<UpdateStepJson>,
pub update_release: String,
pub words: String,
pub up_to_date: bool,
pub offered: bool,
pub why_not: String,
pub release_name: String,
pub boxes: Vec<UpdateBoxJson>,
}
/// One box's line, as monetize said it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateBoxJson {
pub role: String,
pub state: String,
pub words: String,
}
/// Pure: the update for the browser, carried.
pub fn update_json(r: pb::EstateUpdateResponse) -> UpdateJson {
UpdateJson {
host: r.host,
release: r.release,
release_of: r.release_of.into_iter().collect(),
live_on: r.live_on,
state: r.state,
steps: r.steps.into_iter().map(|s| UpdateStepJson { role: s.role, state: s.state, how: s.how, words: s.words }).collect(),
update_release: r.update_release,
words: r.words,
up_to_date: r.up_to_date,
offered: r.offered,
why_not: r.why_not,
release_name: r.release_name,
boxes: r.boxes.into_iter().map(|b| UpdateBoxJson { role: b.role, state: b.state, words: b.words }).collect(),
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateBody {
pub estate: String,
/// The release the button named (what the page showed); monetize refuses a stale one.
#[serde(default)]
pub release: String,
#[serde(default)]
pub confirm: bool,
}
/// `POST /api/sites/update` — "Update to release X" (`EstateOrder.UpdateEstate`, the operator's).
pub async fn update(State(state): State<AppState>, Json(b): Json<UpdateBody>) -> Result<Json<UpdateJson>, ApiError> {
if !b.confirm {
return Err(ApiError(Fail::invalid("updating a site restarts its boxes one by one: confirm it")));
}
let req = pb::EstateUpdateRequest { estate: b.estate.trim().to_owned(), release: b.release.trim().to_owned(), front_first: false };
let r = operator(&state)?.estate_orders().update_estate(req).await?.into_inner();
Ok(Json(update_json(r)))
}
#[derive(Debug, Clone, Deserialize)]
pub struct EstateBody {
pub estate: String,
}
/// `POST /api/sites/update-status` — where the site's update stands.
pub async fn update_status(State(state): State<AppState>, axum::extract::Query(b): axum::extract::Query<EstateBody>) -> Result<Json<UpdateJson>, ApiError> {
let r = operator(&state)?.estate_orders().update_status(pb::EstateUpdateStatusRequest { estate: b.estate.trim().to_owned() }).await?.into_inner();
Ok(Json(update_json(r)))
}
#[derive(Debug, Clone, Deserialize)]
pub struct LiveBody {
pub estate: String,
pub on: bool,
}
/// `POST /api/sites/live-update` — live updates on or OFF for the site.
pub async fn live_update(State(state): State<AppState>, Json(b): Json<LiveBody>) -> Result<Json<UpdateJson>, ApiError> {
let r = operator(&state)?.estate_orders().set_live_update(pb::EstateSetLiveUpdateRequest { estate: b.estate.trim().to_owned(), on: b.on }).await?.into_inner();
Ok(Json(update_json(r)))
}
// ── A PRODUCT'S OWN ORDERS (MONETIZE-FIX 2026-11-01) ─────────────────────────
//
// ```text
// GET /api/sites/kinds?estate=<label> → KindsJson (what the site's product declares)
// POST /api/sites/do DoBody → DoneJson (signed, queued; the box pulls it)
// GET /api/sites/do?estate=<label>&seq=<n> → DoAnswerJson (the box's ack)
// ```
//
// Thin: monetize-server judges against the product's facts, signs, queues and keeps the answer.
// A kind with a file is the CLI's (`monetize site do … --file`): the console sends no file.
/// One declared param.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct KindParamJson {
pub name: String,
pub words: String,
pub required: bool,
}
/// One declared order kind.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct KindJson {
pub kind: String,
pub role: String,
pub words: String,
pub params: Vec<KindParamJson>,
pub file: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct KindsJson {
pub kinds: Vec<KindJson>,
}
/// `GET /api/sites/kinds` — the order kinds the site's product declares.
pub async fn kinds(State(state): State<AppState>, axum::extract::Query(b): axum::extract::Query<EstateBody>) -> Result<Json<KindsJson>, ApiError> {
let r = operator(&state)?.estate_orders().product_order_kinds(pb::EstateProductOrderKindsRequest { estate: b.estate.trim().to_owned() }).await?.into_inner();
Ok(Json(KindsJson {
kinds: r
.kinds
.into_iter()
.map(|k| KindJson { kind: k.kind, role: k.role, words: k.words, file: k.file, params: k.params.into_iter().map(|p| KindParamJson { name: p.name, words: p.words, required: p.required }).collect() })
.collect(),
}))
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct DoBody {
pub estate: String,
pub kind: String,
pub params: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct DoneJson {
pub words: String,
pub seq: u64,
pub role: String,
}
/// `POST /api/sites/do` — one of the product's own orders queued for its box to pull.
pub async fn product_order(State(state): State<AppState>, Json(b): Json<DoBody>) -> Result<Json<DoneJson>, ApiError> {
let req = pb::EstateProductOrderRequest { estate: b.estate.trim().to_owned(), kind: b.kind.trim().to_owned(), params: b.params.into_iter().collect(), file: Vec::new() };
let r = operator(&state)?.estate_orders().product_order(req).await?.into_inner();
Ok(Json(DoneJson { words: r.words, seq: r.seq, role: r.role }))
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct DoAnswerQuery {
pub estate: String,
pub seq: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct DoAnswerJson {
pub kind: String,
pub answered: bool,
pub ok: bool,
pub detail: String,
}
/// `GET /api/sites/do` — the box's answer to one product order.
pub async fn product_order_answer(State(state): State<AppState>, axum::extract::Query(q): axum::extract::Query<DoAnswerQuery>) -> Result<Json<DoAnswerJson>, ApiError> {
let r = operator(&state)?.estate_orders().product_order_answer(pb::EstateProductOrderAnswerRequest { estate: q.estate.trim().to_owned(), seq: q.seq }).await?.into_inner();
Ok(Json(DoAnswerJson { kind: r.kind, answered: r.answered, ok: r.ok, detail: r.detail }))
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ A32-29: **a site's settles are the server's** (the console draws them for an admin):
/// owed: set up again, refund; a stopped install: set up again; installing: abandon; a
/// failed site whose machines stand: check again first.
#[test]
fn a_sites_settles_are_the_servers() {
let verbs = |s: &pb::EstateSite| site_json(s).actions.iter().map(|a| (a.verb.clone(), a.label.clone(), a.confirm.clone())).collect::<Vec<_>>();
let site = |state: &str, recheckable: bool| pb::EstateSite { estate: "acme".into(), state: state.into(), recheckable, ..Default::default() };
let v = |a: &str, b: &str, c: &str| (a.to_string(), b.to_string(), c.to_string());
assert_eq!(verbs(&site("owed", false)), vec![v("retry", "Set up again", "Confirm: set it up again"), v("refund", "Refund", "Confirm: refund the order")]);
assert_eq!(verbs(&site("install_failed", true)), vec![v("recheck", "Check again", "Confirm: check every box again"), v("retry", "Set up again", "Confirm: set it up again")]);
assert_eq!(verbs(&site("installing", false)), vec![v("abandon", "Abandon this install", "Confirm: abandon the install")]);
assert!(verbs(&site("queued", false)).is_empty(), "once set up again the buttons go");
// FIRST-LINK-1: a laid, ready site offers its first sign-in link again.
assert_eq!(verbs(&site("ready", false)), vec![v("first-link", "Send the first sign-in link again", "Confirm: mail it to the first admin's address")]);
}
/// ★ **The update offer is carried as monetize said it** (owner 2026-10-17): up to date, the
/// button or why not, the release's name, every box's line — the page decides nothing.
#[test]
fn the_update_offer_is_carried_as_monetize_said_it() {
let r = pb::EstateUpdateResponse {
release: "27478f3a499f".into(),
up_to_date: true,
offered: false,
why_not: "x".into(),
release_name: "r5.4 · 2026-10-17".into(),
boxes: vec![pb::EstateUpdateBox { role: "front".into(), state: "not-reported".into(), words: "not reported yet".into() }],
..Default::default()
};
let u = update_json(r);
assert!(u.up_to_date && !u.offered);
assert_eq!((u.why_not.as_str(), u.release_name.as_str()), ("x", "r5.4 · 2026-10-17"));
assert_eq!(u.boxes, vec![UpdateBoxJson { role: "front".into(), state: "not-reported".into(), words: "not reported yet".into() }]);
}
/// ★ The form's put is carried, trimmed, with the signed-in operator as its "who"; its
/// Debug never shows a secret; a shown site carries monetize's words and no secret.
#[test]
fn the_site_book_is_carried_and_never_shows_a_secret() {
let b = SitePutBody { estate: " test5-gunnar-rs ".into(), create: true, domain: " test5.gunnar.rs ".into(), vendor_secret_key: " sk_test_SECRET ".into(), vendor_webhook_secret: "whsec_HOOK".into(), terms: TermsJson { term_days: 1, ..Default::default() }, ..Default::default() };
let r = put_request(&b, "console:op-1");
assert_eq!((r.estate.as_str(), r.domain.as_str(), r.vendor_secret_key.as_str(), r.actor.as_str(), r.terms.unwrap().term_days), ("test5-gunnar-rs", "test5.gunnar.rs", "sk_test_SECRET", "console:op-1", 1));
let d = format!("{b:?}");
assert!(!d.contains("SECRET") && !d.contains("HOOK"), "{d}");
let shown = shown_json(&pb::SiteShowResponse { site: Some(pb::SiteRecord { estate: "test5-gunnar-rs".into(), secret_key_set: true, secret_key_fingerprint: "sha256:0123abcd".into(), ..Default::default() }), vendors: vec!["stripe/gunnar".into()], currency: "EUR".into(), ..Default::default() });
assert!(shown.site.secret_key_set && shown.site.secret_key_fingerprint == "sha256:0123abcd" && !shown.site.webhook_secret_set);
assert_eq!((shown.vendors.len(), shown.currency.as_str()), (1, "EUR"));
}
/// The console carries monetize's words and decides nothing: a site's fields, its
/// state and whether it can be ordered are monetize's; the order is the form's three
/// fields, trimmed.
#[test]
fn the_console_carries_the_site_and_the_order_as_monetize_said_them() {
let s = pb::EstateSite { terminable: false, estate: "gunnar-rs".into(), domain: "gunnar.rs".into(), vendor: "stripe/gunnar".into(), first_admin: "ignalina".into(), dns_words: "writes".into(), dns_writes: true, cloud_words: "upcloud · se-sto1".into(), state: "none".into(), words: "Not laid by this box.".into(), checkout_url: String::new(), orderable: true, why_not: String::new(), recheckable: true, why: String::new(), why_since: String::new(), price_words: "0.50 EUR".into() };
let j = site_json(&s);
assert!(j.recheckable, "F5: monetize's word that Check again may ask its boxes is carried");
assert_eq!((j.estate.as_str(), j.domain.as_str(), j.vendor.as_str(), j.first_admin.as_str(), j.orderable), ("gunnar-rs", "gunnar.rs", "stripe/gunnar", "ignalina", true));
let r = order_request(&OrderBody { estate: " gunnar-rs ".into(), size: " Mini".into(), email: " ops@example.invalid ".into(), seed_key: String::new(), confirm: true }, "https://monetize.ignalina.dk/sites");
assert_eq!((r.estate.as_str(), r.size.as_str(), r.email.as_str(), r.return_url.as_str()), ("gunnar-rs", "Mini", "ops@example.invalid", "https://monetize.ignalina.dk/sites"));
}
/// ★ The payment page sends the operator back to THIS console's Sites page — its passkey
/// origin first, else the request's host — never to the site being laid.
#[test]
fn a_site_order_returns_to_this_consoles_sites_page() {
assert_eq!(sites_page(Some("https://monetize.ignalina.dk/"), Some("10.0.0.1:8091")), "https://monetize.ignalina.dk/sites");
assert_eq!(sites_page(None, Some("monetize.ignalina.dk")), "https://monetize.ignalina.dk/sites");
assert_eq!(sites_page(Some("http://localhost:8091"), Some("monetize.ignalina.dk")), "https://monetize.ignalina.dk/sites", "a non-https origin is not a payer's page");
assert_eq!(sites_page(None, None), "");
assert_eq!(sites_page(None, Some("evil.example/x")), "");
}
/// ★ OVW-CLEAN: **the slot's ask is carried, never decided here**, and its payment page
/// sends the operator back to THIS console's Overview by the Sites page's rule.
#[test]
fn the_new_estate_slot_is_carried_and_returns_to_this_consoles_overview() {
assert_eq!(overview_page(Some("https://monetize.ignalina.dk/"), None), "https://monetize.ignalina.dk/");
assert_eq!(overview_page(None, Some("test2.monetize.ignalina.dk")), "https://test2.monetize.ignalina.dk/");
assert_eq!(overview_page(None, None), "");
assert_eq!(overview_page(None, Some("evil.example/x")), "");
let b = NewEstateBody { name: " test4-gunnar-rs ".into(), product: " gunnar".into(), email: " ops@example.invalid ".into(), size: String::new(), confirm: true };
let r = new_estate_request(&b, "https://monetize.ignalina.dk/", "console:op-1");
assert_eq!((r.name.as_str(), r.product.as_str(), r.email.as_str(), r.size.as_str(), r.return_url.as_str(), r.actor.as_str()), ("test4-gunnar-rs", "gunnar", "ops@example.invalid", "", "https://monetize.ignalina.dk/", "console:op-1"));
// Whatever the name is, it is monetize's to judge: carried as typed (trimmed).
let odd = new_estate_request(&NewEstateBody { name: "Not A Name!".into(), ..b }, "", "console");
assert_eq!(odd.name, "Not A Name!");
}
/// ★ **FIRST-LINK-1: the Sites page carries a ready site's first-admin door**, word for word
/// from `EstateOrder.Status`, and asks it only for a site that offers the link again.
#[test]
fn a_ready_site_carries_its_first_admin_door_from_the_status() {
let ready = site_json(&pb::EstateSite { estate: "test2-gunnar-rs".into(), state: "ready".into(), ..Default::default() });
assert!(asks_door(&ready));
assert!(!asks_door(&site_json(&pb::EstateSite { estate: "x".into(), state: "installing".into(), ..Default::default() })));
let st = pb::EstateOrderStatusResponse { first_admin_door: "handed_off".into(), first_admin_door_words: "handed off to the site: its main says the link was sent to r***@ignalina.dk".into(), ..Default::default() };
let s = with_door(ready, &st);
assert_eq!((s.first_admin_door.as_str(), s.first_admin_door_words.as_str()), ("handed_off", "handed off to the site: its main says the link was sent to r***@ignalina.dk"));
let j = serde_json::to_value(&s).unwrap();
assert_eq!(j["first_admin_door"], "handed_off", "on the wire: {j}");
}
}