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

nordisk/monetize

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