crates/monetize-admin/src/door.rs
//! **The operator's door** — `:8475`, TLS 1.3 with a client certificate from
//! the box's installer CA REQUIRED, and the operator's certificate (CN
//! `installer`) the only one that may write. What the solo monetize takes
//! after its install:
//!
//! * `PUT /feed` — the WHOLE solo feed (a JSON object of feed keys to values:
//! `plugin.<id>.*`, `vendor.<kind>.<instance>.*`, `secret.<NAME>`, …, the
//! kit's `monetize-admin` declaration rendered), judged by monetize's own
//! loader ([`crate::solo::judge`]) and REFUSED whole, by name, else kept
//! (0600) at `<data>/solo/door-feed.json`, lying over the installer's feed;
//! PID 1 takes it on its next look (two seconds), no restart. Answered once the box
//! APPLIED it (`applied: …`; R34-5: PID 1 took it and monetize-server applied the config
//! written for it), a feed PID 1 refused as one (422), else after
//! [`crate::solo::APPLY_WAIT`] `accepted, applying: …`.
//! * `GET /feed/applied` — where the door's feed stands: applied, NOT taken, or applying.
//! * `GET /feed` — the key NAMES the door holds, never a value.
//! * `GET /offer` — what the box offers (its build, monetize's PUBLIC key),
//! the installer's offer exactly: read after the close (an upgrade in place
//! keeps the box closed).
//! * `POST /passkeys/reset` — break-glass RECOVERY (the operator lost the
//! passkey device): the console disables every passkey and revokes every
//! session on its next look, and the break-glass token enrols the first
//! passkey again.
//! * `POST /attach/token` — a one-shot enrolment token for ONE estate and
//! product (`{"estate", "product", "ttl_secs"}`, G7): the estate redeems it
//! at the edge's public `POST /attach/enrol` and is taken by the guardian.
//! * `POST /attach/revoke` — `{"estate"}`: that estate's bearer is revoked (ST-13); a new
//! one is a new enrolment.
//! * `GET /money/log[?tail=N]` — ALICA-3: the box's money lines, newest last, from their own
//! home on the data disk ([`crate::money_log`]), which no log noise pushes out.
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::put;
use axum::Router;
use nordisk_pairing::mtls::{self, Issued};
use crate::solo;
/// The door's port — the plugin door's (the mock and the firewall admit it).
pub const PORT: u16 = 8475;
/// The largest feed taken (the plugins' modules ride in it, base64).
pub const BODY_LIMIT: usize = 64 * 1024 * 1024;
#[derive(Clone)]
struct Conn {
root: PathBuf,
operator: Result<(), String>,
/// The product plugins the server links: fed with no module ([`solo::judge_linked`]).
linked: Arc<Vec<&'static str>>,
}
fn not_the_operator(cn: &str) -> (StatusCode, String) {
(StatusCode::FORBIDDEN, format!("REFUSED [not-the-operator] the client certificate is `{cn}`, not the operator's (`{}`)\n", mtls::INSTALLER_CN))
}
/// The body as a feed: a JSON object of strings.
pub fn parse_feed(body: &[u8]) -> Result<BTreeMap<String, String>, String> {
serde_json::from_slice(body).map_err(|e| format!("REFUSED [feed-json] the feed is a JSON object of feed keys to string values: {e}"))
}
async fn put_feed(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let feed = match parse_feed(&body) {
Ok(f) => f,
Err(e) => return (StatusCode::BAD_REQUEST, format!("{e}\n")),
};
let (root, linked) = (c.root.clone(), c.linked.clone());
// The application's sites and accounts lie over whatever the door is given (the app wins,
// by name): the feed is judged WITH them, so a feed that would break one is refused whole.
let judged = tokio::task::spawn_blocking(move || {
let (book, secrets) = (crate::app_config::read_book(&root), crate::app_config::read_secrets(&root));
let with_app = crate::app_config::overlay(&feed, &book, &secrets).map_err(|e| format!("{e} (the application's sites need it)"))?;
let kept = crate::app_config::shadowed(&feed, &book, &secrets);
solo::judge_linked(&root, &with_app, &linked).and_then(|ps| solo::write_door_feed(&root, &feed).map(|_| (ps, feed.len(), kept, solo::door_feed_digest(&root))).map_err(|e| format!("{}: {e}", solo::DOOR_FEED_REL)))
})
.await;
match judged {
Ok(Ok((products, keys, kept, door))) => {
tracing::info!(products = ?products, keys, kept = kept.len(), "door: the operator put the feed; the door answers once the box applied it");
let kept = if kept.is_empty() { String::new() } else { format!("the application keeps its own over this feed: {}\n", kept.join(", ")) };
let what = format!("{keys} key(s); monetize serves {} product(s): {}", products.len(), products.join(", "));
// R34-5: answered once the box APPLIED it (PID 1 took it, the server applied the
// config written for it) — never before; past the wait, "accepted, applying".
match solo::wait_applied(&c.root, &door, solo::APPLY_WAIT).await {
solo::Applied::Applied => (StatusCode::OK, format!("applied: {what}\n{kept}")),
solo::Applied::Refused(why) => (StatusCode::UNPROCESSABLE_ENTITY, format!("written, but the box did NOT take it (NOT taken: monetize serves what it had): {why}\n{kept}")),
solo::Applied::Applying(why) => (StatusCode::OK, format!("accepted, applying: {what} — not applied yet: {why}; `GET /feed/applied` says when it is\n{kept}")),
}
}
Ok(Err(e)) => (StatusCode::UNPROCESSABLE_ENTITY, format!("{e}\n")),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}\n")),
}
}
/// **`PUT /feed/next`** (M-FOLLOW 7): the NEXT release's feed, staged before `box update` —
/// the release this box's own pointer names, when it would take it live. The app that runs
/// never applies it; that next app takes it at its first start (no product gap), and it
/// becomes the door feed once that app runs confirmed. It is judged only by the next app's
/// loader, at that start: a feed it refuses leaves the box on the feed it had.
async fn put_feed_next(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let feed = match parse_feed(&body) {
Ok(f) => f,
Err(e) => return (StatusCode::BAD_REQUEST, format!("{e}\n")),
};
let root = c.root.clone();
let staged = tokio::task::spawn_blocking(move || {
let (r, n, p, from) = crate::self_update::look_from(&root)?;
let app = crate::self_update::next_feed_for(&r, &n, &p)?;
solo::stage_next_feed(&root, &app, &feed).map_err(|e| format!("{}: {e}", solo::NEXT_FEED_REL))?;
Ok::<_, String>((app, n.build, feed.len(), from))
})
.await;
match staged {
Ok(Ok((app, build, keys, from))) => {
tracing::warn!(app = %app, keys, "door: the operator staged the next release's feed (never applied by this app)");
(StatusCode::OK, format!("staged: {keys} key(s) for the next release (app {}, build {build}, from {from}); that app takes it at its first start, before it serves; this app keeps its own feed; a rollback leaves it untouched\n", &app[..app.len().min(12)]))
}
Ok(Err(e)) => (StatusCode::CONFLICT, format!("REFUSED {}\n", e.trim_start_matches("REFUSED "))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("REFUSED [feed-next] {e}\n")),
}
}
/// **`POST /restore`** (ST-12): an opened off-box copy, `{entry: hex}`, staged for the box's
/// next start ([`crate::restore`]) — operator only; a copy that does not verify, or a box that
/// already serves, is refused by name.
async fn post_restore(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let entries = match crate::restore::entries_of_json(&body) {
Ok(e) => e,
Err(e) => return (StatusCode::BAD_REQUEST, format!("{e}\n")),
};
let root = c.root.clone();
match tokio::task::spawn_blocking(move || crate::restore::stage(&root, &entries)).await {
Ok(Ok(said)) => {
tracing::warn!("door: the operator staged a RESTORE — {said}");
(StatusCode::OK, format!("{said}\n"))
}
Ok(Err(e)) => {
tracing::warn!("door: a restore was REFUSED — {e}");
(StatusCode::UNPROCESSABLE_ENTITY, format!("{e}\n"))
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}\n")),
}
}
/// **`GET /feed/applied`** (R34-5): where the door feed stands — applied, refused, or applying.
async fn get_feed_applied(State(c): State<Conn>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
if !c.root.join(solo::DOOR_FEED_REL).exists() {
return (StatusCode::NOT_FOUND, "no feed was put on the door\n".into());
}
match solo::applied(&c.root, &solo::door_feed_digest(&c.root)) {
solo::Applied::Applied => (StatusCode::OK, "applied: the door's feed is taken and monetize applied it\n".into()),
solo::Applied::Refused(why) => (StatusCode::UNPROCESSABLE_ENTITY, format!("NOT taken (monetize serves what it had): {why}\n")),
solo::Applied::Applying(why) => (StatusCode::ACCEPTED, format!("applying: {why}\n")),
}
}
async fn get_feed(State(c): State<Conn>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let keys: Vec<String> = solo::read_door_feed(&c.root).into_keys().collect();
if keys.is_empty() {
return (StatusCode::NOT_FOUND, "no feed was put on the door; monetize serves the installer's\n".into());
}
(StatusCode::OK, keys.join("\n") + "\n")
}
async fn get_offer(State(c): State<Conn>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
match serde_json::to_string(&crate::product::offers_of(&c.root)) {
Ok(j) => (StatusCode::OK, j),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}\n")),
}
}
/// The marker the door leaves for the console ([`solo::RESET_REL`]).
async fn post_passkeys_reset(State(c): State<Conn>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let marker = c.root.join(solo::RESET_REL);
match marker.parent().map(std::fs::create_dir_all).transpose().and_then(|_| std::fs::write(&marker, b"reset asked on the operator's door\n")) {
Ok(()) => {
tracing::warn!("door: the operator asked for a passkey reset; the console applies it on its next look");
(StatusCode::ACCEPTED, "asked: every passkey is disabled and every session revoked on the console's next look (two seconds); the break-glass token enrols the first passkey again\n".into())
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}\n")),
}
}
/// The operator mints an enrolment token (G7).
async fn post_attach_token(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: monetize_front::attach::AttachTokenAsk = match serde_json::from_slice(&body) {
Ok(a) => a,
Err(e) => return (StatusCode::BAD_REQUEST, format!("REFUSED [attach-ask] {{\"estate\", \"product\", \"ttl_secs\"}}: {e}\n")),
};
let central = monetize_front::attach::Central { root: c.root.clone(), plugin_door: String::new() };
let ttl = Duration::from_secs(if ask.ttl_secs == 0 { 24 * 3600 } else { ask.ttl_secs });
match tokio::task::spawn_blocking(move || monetize_front::attach::mint_token(¢ral, &ask.estate, &ask.product, ttl)).await {
Ok(Ok(token)) => {
tracing::info!("door: the operator minted an enrolment token");
(StatusCode::OK, serde_json::json!({ "token": token }).to_string())
}
Ok(Err(r)) => (StatusCode::BAD_REQUEST, format!("REFUSED [{}] {r}\n", r.clause())),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}\n")),
}
}
/// An estate enrols over the door (the mock's `--central-at`): the same answer
/// as the edge's public `POST /attach/enrol` — the one-shot token is the gate,
/// whichever installer-CA client calls.
/// The door it names is the box's configured one (ST-14), never the request's `Host`.
async fn post_attach_enrol(State(c): State<Conn>, body: Bytes) -> (StatusCode, axum::Json<serde_json::Value>) {
crate::edge::enrol_at(c.root.clone(), body).await
}
/// **`POST /attach/revoke`** (STINKY ST-13): `{"estate"}` — the operator revokes that
/// estate's bearer; the guardian refuses it from its next take. A new bearer is a new
/// enrolment (`attach-token`, then the estate enrols again).
async fn post_attach_revoke(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
#[derive(serde::Deserialize)]
struct Ask {
estate: String,
}
let ask: Ask = match serde_json::from_slice(&body) {
Ok(a) => a,
Err(e) => return (StatusCode::BAD_REQUEST, format!("REFUSED [attach-ask] {{\"estate\"}}: {e}\n")),
};
let central = monetize_front::attach::Central { root: c.root.clone(), plugin_door: String::new() };
match tokio::task::spawn_blocking(move || monetize_front::attach::revoke(¢ral, &ask.estate)).await {
Ok(Ok(said)) => {
tracing::warn!("door: the operator revoked an estate bearer — {said}");
(StatusCode::OK, format!("{said}\n"))
}
Ok(Err(e)) => (StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}\n")),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}\n")),
}
}
// ── the operator's SITES (owner 2026-10-13) ─────────────────────────────────
//
// The operator's `monetize` CLI reaches the box here — the one credential an operator holds
// off the box is this door's certificate; monetize's operator verbs are not served at the
// edge. Every route below is a pure FORWARDER to monetize-server's `EstateOrder` verbs with
// the box's own operator bearer (`console::operator_client`, what the console's operator pages
// use): nothing is decided here, and the console's form calls the very same verbs.
/// Pure: an estate label or a run's name — lowercase letters, digits and hyphens, at most 63.
fn site_label(estate: &str) -> Option<String> {
let e = estate.trim();
(!e.is_empty() && e.len() <= 63 && e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')).then(|| e.to_string())
}
/// Pure: monetize's refusal as the door answers it — its code and its own words.
fn site_refusal(s: &tonic::Status) -> (StatusCode, String) {
let http = match s.code() {
tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST,
tonic::Code::NotFound => StatusCode::NOT_FOUND,
tonic::Code::AlreadyExists | tonic::Code::FailedPrecondition => StatusCode::CONFLICT,
tonic::Code::PermissionDenied | tonic::Code::Unauthenticated => StatusCode::FORBIDDEN,
tonic::Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY,
};
(http, format!("{}\n", serde_json::json!({ "refused": format!("{:?}", s.code()), "words": s.message() })))
}
/// monetize-server, as this box's operator.
fn sites_client(root: &std::path::Path) -> Result<monetize_ui_server::grpc::Monetize, (StatusCode, String)> {
crate::console::operator_client(root, &monetize_front::intake::GRPC.to_string()).ok_or_else(|| (StatusCode::SERVICE_UNAVAILABLE, "REFUSED [site-no-operator] this box holds no operator bearer yet (monetize's files are not prepared): nothing was asked\n".to_string()))
}
fn ok_json(v: serde_json::Value) -> (StatusCode, String) {
(StatusCode::OK, format!("{v}\n"))
}
/// **`POST /site/order`** `{"estate", "size", "email", "seed_key"}` → `EstateOrder.OrderSite`.
/// The address is never logged.
async fn post_site_order(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
site_order_at_the_door(c, body, false).await
}
/// ★ **`POST /site/test-order`** `{"estate", "size", "email", "seed_key"}` → `EstateOrder.OrderSite`
/// with `test_order`: the order paid server-side with the site's vendor's TEST card, refused by
/// monetize-server, by name and before the vendor is asked anything, on a live key (owner:
/// "monetize must be automatic"). The door decides nothing; no return page is forwarded.
async fn post_site_test_order(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
site_order_at_the_door(c, body, true).await
}
/// Pure: **the door's ask as monetize's `OrderSite` request** — a test order names no return page.
pub fn site_order_request(estate: &str, ask: &serde_json::Value, test_order: bool) -> monetize_proto::EstateOrderSiteRequest {
let word = |k: &str| ask[k].as_str().unwrap_or_default().trim().to_string();
monetize_proto::EstateOrderSiteRequest { estate: estate.to_string(), size: word("size"), email: word("email"), seed_key: word("seed_key"), return_url: if test_order { String::new() } else { word("return_url") }, test_order }
}
async fn site_order_at_the_door(c: Conn, body: Bytes, test_order: bool) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = match serde_json::from_slice(&body) {
Ok(a) => a,
Err(e) => return (StatusCode::BAD_REQUEST, format!("REFUSED [site-ask] {{\"estate\", \"size\", \"email\"}}: {e}\n")),
};
let Some(estate) = ask["estate"].as_str().and_then(site_label) else {
return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] `estate` is the site's label (lowercase letters, digits, hyphens)\n".into());
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().order_site(site_order_request(&estate, &ask, test_order)).await {
Ok(r) => {
let r = r.into_inner();
let how = if test_order { ", paid with the vendor's TEST card" } else { "" };
tracing::warn!("door: the operator ordered the site {estate} laid by this box ({}){how}", r.reference);
ok_json(serde_json::json!({ "reference": r.reference, "company": r.company, "checkout_url": r.checkout_url, "price_words": r.price_words, "words": r.words }))
}
Err(s) => site_refusal(&s),
}
}
/// **`GET /money/log[?tail=N]`** (ALICA-3) — the box's money lines, newest last (the newest
/// [`crate::money_log::DEFAULT_TAIL`], at most [`crate::money_log::MAX_TAIL`]), plain text.
async fn get_money_log(State(c): State<Conn>, axum::extract::Query(q): axum::extract::Query<BTreeMap<String, String>>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let tail = q.get("tail").and_then(|t| t.parse().ok()).unwrap_or(crate::money_log::DEFAULT_TAIL);
let root = c.root.clone();
match tokio::task::spawn_blocking(move || crate::money_log::read(&root, tail)).await {
Ok(text) => (StatusCode::OK, text),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("REFUSED [money-log] {e}\n")),
}
}
/// **`GET /self/running`** — what this box runs, what the release bucket offers next, and what
/// `POST /self/update` would do (owner 2026-10-14, point 5).
async fn get_self_running(State(c): State<Conn>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let root = c.root.clone();
// OXG-3: `back` offers the way back only when the box can take it from its own other slot.
match tokio::task::spawn_blocking(move || crate::self_update::look_checked(&root).map(|l| { let back = crate::self_update::back_now(&l.0); (l, back) })).await {
Ok(Ok(((r, n, p, from), back))) => ok_json(serde_json::json!({ "next_key": from, "base": r.base, "app": r.app, "slot": r.slot, "live_update": r.live_update, "next_app": n.app, "next_base": n.base, "next_build": n.build, "plan": match &p { crate::self_update::Plan::RolledBack(w) | crate::self_update::Plan::Config(w) => w.clone(), other => format!("{other:?}") }, "back": crate::self_update::back_words(&r, &back) })),
Ok(Err(e)) => (StatusCode::CONFLICT, format!("REFUSED {}\n", e.trim_start_matches("REFUSED "))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("REFUSED [self-look] {e}\n")),
}
}
/// **`GET /self/certs`** — this box's certificates as edda estate-front reports them (owner
/// 2026-10-25): valid until, next attempt, last reason, try now, the alert. Read from the data
/// disk the box's web half writes; the door decides nothing.
async fn get_self_certs(State(c): State<Conn>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
ok_json(estate_front::web::status::report_json(&c.root, std::time::SystemTime::now()))
}
/// **`POST /self/certs/try`** `{"host"}` — the operator's "try now": refused (409) while a
/// failed validation's wait runs, which Let's Encrypt counts; else the box's web half looks
/// at once (DNS first, then ACME).
async fn post_self_certs_try(State(c): State<Conn>, body: String) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
let host = v["host"].as_str().unwrap_or("").trim().to_ascii_lowercase();
if host.is_empty() || !host.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '.') || host.starts_with('.') || host.contains("..") {
return (StatusCode::BAD_REQUEST, "REFUSED [tls-try-host] {\"host\": \"<a served name>\"}\n".to_string());
}
match estate_front::web::status::try_now(&c.root, &host, std::time::SystemTime::now()) {
Ok(words) => {
tracing::warn!("door: the operator asked {host}'s certificate to be tried now");
ok_json(serde_json::json!({ "words": words }))
}
Err(e) => (StatusCode::CONFLICT, format!("{e}\n")),
}
}
/// **`POST /self/update`** — the operator's word: the next signed release staged into the other
/// app slot and this box restarted into it (a trial: confirmed after serving, else rolled back).
async fn post_self_update(State(c): State<Conn>, body: String) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
// M-FOLLOW 3: `{"back": true}` is the way back, the other slot's release confirmed here.
let back = match crate::self_update::ask_of(&body) {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, format!("REFUSED {e}\n")),
};
let root = c.root.clone();
match tokio::task::spawn_blocking(move || if back { crate::self_update::update_back(&root) } else { crate::self_update::update(&root) }).await {
Ok(Ok(words)) => {
tracing::warn!("door: the operator updated this box: {words}");
ok_json(serde_json::json!({ "words": words }))
}
Ok(Err(e)) => (StatusCode::CONFLICT, format!("REFUSED {}\n", e.trim_start_matches("REFUSED "))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("REFUSED [self-update] {e}\n")),
}
}
/// A plugin update's answer at the door, as JSON: the job, where it stands, one row per plugin.
fn plugins_value(j: monetize_proto::PluginUpdateJob) -> serde_json::Value {
let rows: Vec<serde_json::Value> = j.rows.iter().map(|r| serde_json::json!({ "name": r.name, "role": r.role, "outcome": r.outcome, "detail": r.detail, "blake3": r.blake3 })).collect();
serde_json::json!({ "job": j.job, "state": j.state, "apply": j.apply, "rows": rows, "error": j.error })
}
/// **`POST /box/plugins`** `{"apply": bool}` → `Plugins.Update` (owner 2026-10-17): the release's
/// signed plugins taken from the release bucket as a QUEUED job; answers the job at once.
async fn post_box_plugins(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = if body.iter().all(u8::is_ascii_whitespace) { serde_json::json!({}) } else { serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null) };
let apply = match (ask.is_object(), &ask["apply"]) {
(true, serde_json::Value::Null) => false,
(true, serde_json::Value::Bool(b)) => *b,
_ => return (StatusCode::BAD_REQUEST, "REFUSED [plugins-ask] {\"apply\": true|false}: true takes the plugins, false only looks\n".into()),
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.plugins().update(monetize_proto::PluginUpdateRequest { apply }).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator queued a plugin {} ({})", if apply { "update" } else { "look" }, r.job);
ok_json(plugins_value(r))
}
Err(s) => site_refusal(&s),
}
}
/// **`GET /box/plugins/<job>`** (and `/box/plugins`: the newest) → `Plugins.Status`.
async fn get_box_plugins(State(c): State<Conn>, axum::extract::Path(job): axum::extract::Path<String>) -> (StatusCode, String) {
plugins_status(c, job).await
}
async fn get_box_plugins_newest(State(c): State<Conn>) -> (StatusCode, String) {
plugins_status(c, String::new()).await
}
async fn plugins_status(c: Conn, job: String) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.plugins().status(monetize_proto::PluginUpdateStatusRequest { job: job.trim().to_string() }).await {
Ok(r) => ok_json(plugins_value(r.into_inner())),
Err(s) => site_refusal(&s),
}
}
/// An update's answer at the door, as JSON.
fn update_value(r: monetize_proto::EstateUpdateResponse) -> serde_json::Value {
let steps: Vec<serde_json::Value> = r.steps.iter().map(|s| serde_json::json!({ "role": s.role, "state": s.state, "how": s.how, "words": s.words })).collect();
serde_json::json!({ "host": r.host, "release": r.release, "release_of": r.release_of, "live_on": r.live_on, "state": r.state, "steps": steps, "update_release": r.update_release, "update_live": r.update_live, "words": r.words })
}
/// **`POST /estate/update`** `{"estate", "release"}` → `EstateOrder.UpdateEstate` (owner
/// 2026-10-14: the ONE box updates what it laid). `estate`: a sold estate's company or a site's label.
async fn post_estate_update(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else {
return (StatusCode::BAD_REQUEST, "REFUSED [update-ask] `estate` is the estate's company or the site's label (lowercase letters, digits, hyphens)\n".into());
};
let release = ask["release"].as_str().unwrap_or_default().trim().to_string();
let front_first = ask["front_first"].as_bool().unwrap_or(false);
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().update_estate(monetize_proto::EstateUpdateRequest { estate: estate.clone(), release, front_first }).await {
Ok(r) => {
tracing::warn!("door: the operator opened an update of {estate}");
ok_json(update_value(r.into_inner()))
}
Err(s) => site_refusal(&s),
}
}
/// Pure: **the door's adopt ask as monetize's request** — `{"estate", "zone", "machines":
/// [{"role", "server", "hostname", "title", "plan", "public_ipv4", "utility_ipv4", "disks":
/// [{"title", "id", "gib", "tier", "address"}]}], "release_of": {role: release}, "again"}`.
pub fn adopt_request(ask: &serde_json::Value) -> Result<monetize_proto::EstateAdoptRequest, String> {
let estate = ask["estate"].as_str().and_then(site_label).ok_or("`estate` is the site's label (lowercase letters, digits, hyphens)")?;
let s = |v: &serde_json::Value, k: &str| v[k].as_str().unwrap_or_default().trim().to_string();
let machines = ask["machines"].as_array().ok_or("`machines` is front, main and twin, each with its `server` id")?;
let machines = machines
.iter()
.map(|m| monetize_proto::EstateAdoptMachine {
role: s(m, "role"),
server: s(m, "server"),
hostname: s(m, "hostname"),
title: s(m, "title"),
plan: s(m, "plan"),
public_ipv4: s(m, "public_ipv4"),
utility_ipv4: s(m, "utility_ipv4"),
disks: m["disks"].as_array().into_iter().flatten().map(|d| monetize_proto::EstateAdoptDisk { title: s(d, "title"), id: s(d, "id"), gib: d["gib"].as_u64().unwrap_or(0), tier: s(d, "tier"), address: s(d, "address") }).collect(),
})
.collect();
let release_of = ask["release_of"].as_object().map(|o| o.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.trim().to_string()))).collect()).unwrap_or_default();
Ok(monetize_proto::EstateAdoptRequest { estate, zone: s(ask, "zone"), machines, release_of, again: ask["again"].as_bool().unwrap_or(false) })
}
/// **`POST /estate/adopt`** → `EstateOrder.AdoptEstate` (owner 2026-10-17: a kit-installed site
/// is updated by this box once adopted). The answer carries the front's feed lines — a SECRET,
/// answered to the operator once and never logged here.
async fn post_estate_adopt(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let req = match adopt_request(&ask) {
Ok(r) => r,
Err(e) => return (StatusCode::BAD_REQUEST, format!("REFUSED [adopt-ask] {e}\n")),
};
let estate = req.estate.clone();
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().adopt_estate(req).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator adopted {estate} as {} (its client key answered once, never logged)", r.company);
ok_json(serde_json::json!({ "company": r.company, "reference": r.reference, "host": r.host, "client_key": r.client_key, "feed": r.feed, "words": r.words }))
}
Err(s) => site_refusal(&s),
}
}
/// **`GET /estate/update/<estate>`** → `EstateOrder.UpdateStatus`.
async fn get_estate_update(State(c): State<Conn>, axum::extract::Path(estate): axum::extract::Path<String>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let Some(estate) = site_label(&estate) else { return (StatusCode::BAD_REQUEST, "REFUSED [update-ask] which estate?\n".into()) };
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().update_status(monetize_proto::EstateUpdateStatusRequest { estate }).await {
Ok(r) => ok_json(update_value(r.into_inner())),
Err(s) => site_refusal(&s),
}
}
/// ★ A32-7: **`POST /estate/update/cancel`** `{"estate"}` → `EstateOrder.CancelUpdate`: the open
/// update closed as cancelled (the boxes done stay done); a new one may open.
async fn post_estate_update_cancel(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else {
return (StatusCode::BAD_REQUEST, "REFUSED [update-ask] {\"estate\": <label>}\n".into());
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().cancel_update(monetize_proto::EstateUpdateStatusRequest { estate: estate.clone() }).await {
Ok(r) => {
tracing::warn!("door: the operator cancelled the update of {estate}");
ok_json(update_value(r.into_inner()))
}
Err(s) => site_refusal(&s),
}
}
/// **`POST /estate/live-update`** `{"estate", "on"}` → `EstateOrder.SetLiveUpdate`.
async fn post_estate_live_update(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let (Some(estate), Some(on)) = (ask["estate"].as_str().and_then(site_label), ask["on"].as_bool()) else {
return (StatusCode::BAD_REQUEST, "REFUSED [update-ask] {\"estate\": <label>, \"on\": true|false}\n".into());
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().set_live_update(monetize_proto::EstateSetLiveUpdateRequest { estate: estate.clone(), on }).await {
Ok(r) => {
tracing::warn!("door: the operator switched live updates {} for {estate}", if on { "on" } else { "OFF" });
ok_json(update_value(r.into_inner()))
}
Err(s) => site_refusal(&s),
}
}
/// **`GET /site/list`** → `EstateOrder.ListSites`.
async fn get_site_list(State(c): State<Conn>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().list_sites(monetize_proto::EstateSitesRequest {}).await {
Ok(r) => {
let r = r.into_inner();
let sites: Vec<serde_json::Value> = r
.sites
.iter()
.map(|s| serde_json::json!({ "estate": s.estate, "domain": s.domain, "vendor": s.vendor, "first_admin": s.first_admin, "dns_words": s.dns_words, "dns_writes": s.dns_writes, "cloud_words": s.cloud_words, "state": s.state, "words": s.words, "checkout_url": s.checkout_url, "orderable": s.orderable, "why_not": s.why_not, "terminable": s.terminable, "why": s.why, "why_since": s.why_since }))
.collect();
ok_json(serde_json::json!({ "sites": sites, "sizes": r.sizes }))
}
Err(s) => site_refusal(&s),
}
}
/// **`GET /estates/overview`** → `Estates.Overview` (OVW-CLEAN): the estates that exist, the
/// ended or never installed, and the New-estate slot's choices — as the console's Overview draws
/// them, field for field ([`monetize_ui_server::api::overview_json`]). Read only.
async fn get_estates_overview(State(c): State<Conn>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estates().overview(monetize_proto::EstatesOverviewRequest {}).await {
Ok(r) => ok_json(monetize_ui_server::api::overview_json(&r.into_inner(), None)),
Err(s) => site_refusal(&s),
}
}
/// Pure: **the cloud account as this box lists it** (MAP-SEG 3), from each product's cloud block
/// ([`monetize_ui_server::api::fleet_json`]'s `cloud`) with its product: one entry per cloud
/// ACCOUNT (MAP-3: two products listing one linked cloud are one account, naming both), its
/// owned rows, orphans, missing rows and backups each ONCE, grouped by estate — the console's
/// Iron page's own merge ([`monetize_ui_server::iron::accounts`]). Read only, and nothing
/// secret is in it: ids, sizes, zones, labels, prices.
pub fn inventory_value(blocks: &[(String, serde_json::Value)]) -> serde_json::Value {
serde_json::json!({ "clouds": monetize_ui_server::iron::accounts(blocks) })
}
/// **`GET /cloud/inventory`** → `Usage.Fleet`'s cloud block for every product, merged
/// ([`inventory_value`]): the operator's READ-ONLY view of the account (MAP-SEG 3, so an orphan
/// list never needs a console session). Asks the provider nothing this box does not already ask.
async fn get_cloud_inventory(State(c): State<Conn>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
let products = match m.products().list(monetize_proto::ListProductsRequest {}).await {
Ok(r) => r.into_inner().products,
Err(s) => return site_refusal(&s),
};
let mut blocks = Vec::new();
for p in &products {
match m.usage().fleet(monetize_proto::FleetRequest { product: p.id.clone() }).await {
Ok(r) => blocks.push((p.id.clone(), monetize_ui_server::api::fleet_json(&r.into_inner()).cloud)),
Err(s) => return site_refusal(&s),
}
}
ok_json(inventory_value(&blocks))
}
/// **`GET /site/status/<estate>`** → `EstateOrder.Status`, the operator's whole read of his site run.
async fn get_site_status(State(c): State<Conn>, axum::extract::Path(estate): axum::extract::Path<String>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let Some(estate) = site_label(&estate) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] which site?\n".into()) };
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().status(monetize_proto::EstateOrderStatusRequest { company: estate, actor_ticket: Vec::new() }).await {
Ok(r) => {
let r = r.into_inner();
ok_json(serde_json::json!({ "company": r.company, "state": r.state, "phase": r.phase, "words": r.words, "url": r.url, "paid_until": r.paid_until, "why": r.why, "why_since": r.why_since, "first_admin_door": r.first_admin_door, "first_admin_door_words": r.first_admin_door_words, "first_link_answered": r.first_link_answered, "first_link_mailed": r.first_link_mailed, "refeed_answered": r.refeed_answered, "refeed_kept": r.refeed_kept, "refeed_words": r.refeed_words }))
}
Err(s) => site_refusal(&s),
}
}
/// Pure: `?tail=N` out of a query string — `None` when not asked; a whole number of at least 1.
fn tail_asked(query: Option<&str>) -> Result<Option<usize>, String> {
let Some(v) = query.unwrap_or_default().split('&').find_map(|kv| kv.strip_prefix("tail=")) else { return Ok(None) };
v.parse::<usize>().ok().filter(|n| *n > 0).map(Some).ok_or_else(|| format!("REFUSED [site-ask] ?tail={v}: how many of the box's last lines, a whole number of at least 1\n"))
}
/// Pure: the last `n` lines of `text` (all of it when `None`), newest last, each ending in a newline.
fn last_lines(text: &str, n: Option<usize>) -> String {
let lines: Vec<&str> = text.lines().collect();
let from = n.map_or(0, |n| lines.len().saturating_sub(n));
lines[from..].iter().map(|l| format!("{l}\n")).collect()
}
/// Pure: **a narrated site's boxes, summed up** — each box's lines held in the tail, its bytes,
/// when it was last written and its newest line; no log text beyond that line.
fn narrate_summary(r: monetize_proto::EstateNarrateLogResponse) -> serde_json::Value {
let vms: Vec<serde_json::Value> = r
.vms
.iter()
.map(|v| {
serde_json::json!({
"vm": v.vm,
"lines": v.text.lines().count(),
"bytes": v.bytes,
"updated": if v.updated_unix_ms > 0 { crate::app_config::when(v.updated_unix_ms as u64) } else { String::new() },
"last": v.text.lines().last().unwrap_or_default(),
})
})
.collect();
serde_json::json!({ "estate": r.estate, "narrated": r.narrated, "words": r.words, "vms": vms })
}
/// **`GET /site/narrate/<estate>`** → `EstateOrder.NarrateLog`: a narrated site's boxes, each
/// one's summary ([`narrate_summary`]). The operator's alone, as the console's admin-only Logs.
async fn get_site_narrate(State(c): State<Conn>, axum::extract::Path(estate): axum::extract::Path<String>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let Some(estate) = site_label(&estate) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] which site?\n".into()) };
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().narrate_log(monetize_proto::EstateNarrateLogRequest { estate, vm: String::new(), max_bytes: 0 }).await {
Ok(r) => ok_json(narrate_summary(r.into_inner())),
Err(s) => site_refusal(&s),
}
}
/// **`GET /site/narrate/<estate>/<vm>[?tail=N]`** → `EstateOrder.NarrateLog`: one box's boot
/// log as plain text, newest last (its last N lines when asked).
async fn get_site_narrate_vm(State(c): State<Conn>, axum::extract::Path((estate, vm)): axum::extract::Path<(String, String)>, axum::extract::RawQuery(query): axum::extract::RawQuery) -> axum::response::Response {
use axum::response::IntoResponse as _;
if let Err(cn) = &c.operator {
return not_the_operator(cn).into_response();
}
let (Some(estate), Some(vm)) = (site_label(&estate), site_label(&vm)) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] which site, which box?\n".to_string()).into_response() };
let tail = match tail_asked(query.as_deref()) {
Ok(t) => t,
Err(r) => return (StatusCode::BAD_REQUEST, r).into_response(),
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r.into_response(),
};
match m.estate_orders().narrate_log(monetize_proto::EstateNarrateLogRequest { estate, vm, max_bytes: 0 }).await {
Ok(r) => {
let text: String = r.into_inner().vms.into_iter().map(|v| v.text).collect();
(StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], last_lines(&text, tail)).into_response()
}
Err(s) => site_refusal(&s).into_response(),
}
}
/// Pure: `?since=N` out of a query string — 0 when not asked; a whole number.
fn since_asked(query: Option<&str>) -> Result<u64, String> {
let Some(v) = query.unwrap_or_default().split('&').find_map(|kv| kv.strip_prefix("since=")) else { return Ok(0) };
v.parse::<u64>().map_err(|_| format!("REFUSED [site-ask] ?since={v}: the first line to read, a whole number (0: from the start)\n"))
}
/// **`GET /site/installer-log/<estate>/<vm>[?since=N]`** → `EstateOrder.InstallLog`: the box's
/// install log as the ONE box's pairing read it over the installer's link (the VM's
/// `/install/log`, from its first boot line), as plain text from line N. The operator's alone.
async fn get_site_installer_log(State(c): State<Conn>, axum::extract::Path((estate, vm)): axum::extract::Path<(String, String)>, axum::extract::RawQuery(query): axum::extract::RawQuery) -> axum::response::Response {
use axum::response::IntoResponse as _;
if let Err(cn) = &c.operator {
return not_the_operator(cn).into_response();
}
let (Some(estate), Some(vm)) = (site_label(&estate), site_label(&vm)) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] which site, which box?\n".to_string()).into_response() };
let since = match since_asked(query.as_deref()) {
Ok(n) => n,
Err(r) => return (StatusCode::BAD_REQUEST, r).into_response(),
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r.into_response(),
};
match m.estate_orders().install_log(monetize_proto::EstateInstallLogRequest { estate, vm, since }).await {
Ok(r) => (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], r.into_inner().text).into_response(),
Err(s) => site_refusal(&s).into_response(),
}
}
/// The settle actions the door forwards: the Sites page's "Check again", "Set up again" and
/// "Abandon this install".
const SITE_SETTLE_ACTIONS: [&str; 3] = ["recheck", "retry", "abandon"];
/// **`POST /site/settle`** `{"estate", "action": "recheck"|"retry"|"abandon"}` → `EstateOrder.Settle`,
/// the verb the console's Sites page calls: `recheck` asks every box its pair status again and
/// adopts the site when every one is PAIRED (F5); `retry` sets it up again; `abandon` fails an
/// install still running (F2). The operator's alone.
async fn post_site_settle(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] {\"estate\", \"action\": \"recheck\"|\"retry\"|\"abandon\"}\n".into()) };
let action = ask["action"].as_str().unwrap_or_default().trim().to_string();
if !SITE_SETTLE_ACTIONS.contains(&action.as_str()) {
return (StatusCode::BAD_REQUEST, format!("REFUSED [site-ask] action {action:?}: a site is settled at the door by `recheck`, `retry` or `abandon`\n"));
}
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().settle(monetize_proto::EstateOrderSettleRequest { company: estate.clone(), action: action.clone() }).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator settled the site {estate}: {action} (now {})", r.phase);
ok_json(serde_json::json!({ "phase": r.phase, "words": r.words }))
}
Err(s) => site_refusal(&s),
}
}
/// **`POST /site/open-pin/{site}`** → `EstateOrder.Settle` `open-pin` (F6): the guardian's pin
/// window opened again, until each pins, for every box of the site whose key is not pinned
/// yet; a pinned key is never replaced. The operator's alone.
async fn post_site_open_pin(State(c): State<Conn>, axum::extract::Path(estate): axum::extract::Path<String>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let Some(estate) = site_label(&estate) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] which site?\n".into()) };
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().settle(monetize_proto::EstateOrderSettleRequest { company: estate.clone(), action: "open-pin".into() }).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator opened the pin windows of the site {estate}: {}", r.words);
ok_json(serde_json::json!({ "phase": r.phase, "words": r.words }))
}
Err(s) => site_refusal(&s),
}
}
/// **`POST /site/cancel`** `{"estate"}` → `EstateOrder.Cancel` (the site stops renewing).
async fn post_site_cancel(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] {\"estate\"}\n".into()) };
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().cancel(monetize_proto::EstateOrderCancelRequest { company: estate.clone(), actor_ticket: Vec::new() }).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator stopped the site {estate} renewing");
ok_json(serde_json::json!({ "words": r.words, "paid_until": r.paid_until, "delete_on": r.delete_on }))
}
Err(s) => site_refusal(&s),
}
}
/// **`POST /site/change-plan`** `{"estate", "main_plan"?, "front_plan"?, "data_gib"?, "order"?: {..},
/// "test"?}` → `EstateOrder.ChangePlan` as the operator (MONETIZE-FIX 2026-11-01): the plan change
/// of one of his own sites (a grow: main's data, the product's own order fields). Whether the
/// site is his, what it costs and what grows are monetize's to judge; the door names them only.
async fn post_site_change_plan(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
const ASK: &str = "REFUSED [site-ask] {\"estate\", \"main_plan\"?, \"front_plan\"?, \"data_gib\"?, \"order\"?: {..}, \"test\"?}\n";
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else { return (StatusCode::BAD_REQUEST, ASK.into()) };
if !(ask["order"].is_null() || ask["order"].is_object()) || !(ask["data_gib"].is_null() || ask["data_gib"].is_u64()) {
return (StatusCode::BAD_REQUEST, ASK.into());
}
let word = |k: &str| ask[k].as_str().unwrap_or_default().trim().to_string();
let order = match ask["order"].as_object() {
Some(o) if !o.is_empty() => ask["order"].to_string(),
_ => String::new(),
};
let sizes = monetize_proto::EstateSizes { front_plan: word("front_plan"), main_plan: word("main_plan"), main_data_gib: ask["data_gib"].as_u64().unwrap_or(0), order };
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
let req = monetize_proto::EstateOrderChangePlanRequest { company: estate.clone(), sizes: Some(sizes), actor_ticket: Vec::new(), test_order: ask["test"].as_bool().unwrap_or(false) };
match m.estate_orders().change_plan(req).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator changed the plan of the site {estate}: {} ({})", r.reference, r.status);
ok_json(serde_json::json!({ "words": r.words, "reference": r.reference, "status": r.status, "checkout_url": r.checkout_url, "price_words": r.price_words, "twin_plan": r.twin_plan }))
}
Err(s) => site_refusal(&s),
}
}
/// **`POST /site/terminate`** `{"estate", "confirm"}` → `EstateOrder.Terminate`: the name typed
/// back, a protected site refused — all monetize's. A customer's SOLD estate too (owner
/// 2026-10-22): the buyer's own path, no backup taken, nothing refunded (A32-27: the wire
/// carries no backup word).
async fn post_site_terminate(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] {\"estate\", \"confirm\"}\n".into()) };
let word = |k: &str| ask[k].as_str().unwrap_or_default().trim().to_string();
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().terminate(monetize_proto::EstateOrderTerminateRequest { company: estate.clone(), actor_ticket: Vec::new(), confirm: word("confirm") }).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator asked the estate {estate} terminated (done={})", r.done);
ok_json(serde_json::json!({ "words": r.words, "done": r.done }))
}
Err(s) => site_refusal(&s),
}
}
/// **`POST /site/first-link`** `{"estate", "to"}` → `EstateOrder.FirstLink` (owner 2026-10-22):
/// a laid site's first sign-in link mailed again, to a new address or (no `to`, FIRST-LINK-1)
/// to the first admin's recorded one. The address, the spent
/// door and the site's state are monetize's to judge; the door only asks that one is given.
/// The address is never logged here.
async fn post_site_first_link(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] {\"estate\", \"to\"}\n".into()) };
// FIRST-LINK-1: no `to` is the first admin's recorded address (monetize's to judge).
let to = ask["to"].as_str().unwrap_or_default().trim().to_string();
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().first_link(monetize_proto::EstateOrderFirstLinkRequest { estate: estate.clone(), to }).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator re-sent the first sign-in link of the site {estate}");
ok_json(serde_json::json!({ "words": r.words, "asked": r.asked }))
}
Err(s) => site_refusal(&s),
}
}
/// **`POST /site/refeed`** `{"estate", "only"?: [group]}` → `EstateOrder.Refeed` (MAIL-RELAY +
/// NIGHT-PUBREPO, 2026-10-28): the keys this box holds for a laid estate now (the mains' mail
/// relay, the site's public repositories), fed to its boxes again as a signed order. What is fed
/// and whether the site is laid are monetize's to judge; the door names the site and the groups
/// only. No value passes through here.
async fn post_site_refeed(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] {\"estate\", \"only\"?: [group]}\n".into()) };
let only: Vec<String> = ask["only"].as_array().into_iter().flatten().filter_map(|g| g.as_str().map(str::to_owned)).collect();
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().refeed(monetize_proto::EstateOrderRefeedRequest { estate: estate.clone(), only }).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator fed the site {estate}'s boxes their keys again (order {})", r.asked);
ok_json(serde_json::json!({ "words": r.words, "asked": r.asked }))
}
Err(s) => site_refusal(&s),
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Pure: standard base64 with padding.
fn b64_encode(b: &[u8]) -> String {
let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
for c in b.chunks(3) {
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
out.push(B64[(n >> 18) as usize & 63] as char);
out.push(B64[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
}
out
}
/// Pure: standard base64 (padding optional); anything else is refused.
fn b64_decode(s: &str) -> Result<Vec<u8>, ()> {
let s = s.trim_end_matches('=').as_bytes();
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let (mut acc, mut bits) = (0u32, 0u32);
for &c in s {
let v = B64.iter().position(|&x| x == c).ok_or(())? as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
if s.len() % 4 == 1 {
return Err(());
}
Ok(out)
}
/// **`POST /site/console`** `{"estate", "method", "path", "body_b64"?, "content_type"?, "repo"?}` →
/// `EstateOrder.Console` (OX-WGDS3, 2026-10-28): one call on a laid site's console through its
/// public front, with the custody's break-glass bearer. Which calls are made, and on which
/// site, are monetize's to judge (it audits each); the door names the call only and logs neither
/// the body nor the query. The answer: `{"status", "body"}` (text) and `"body_b64"` when the
/// body is not UTF-8.
async fn post_site_console(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
const ASK: &str = "REFUSED [site-ask] {\"estate\", \"method\", \"path\", \"body_b64\"?, \"content_type\"?, \"repo\"?}\n";
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else { return (StatusCode::BAD_REQUEST, ASK.into()) };
let (Some(method), Some(path)) = (ask["method"].as_str().map(str::trim).filter(|m| !m.is_empty()), ask["path"].as_str().filter(|p| p.starts_with('/'))) else { return (StatusCode::BAD_REQUEST, ASK.into()) };
let sent = match ask["body_b64"].as_str() {
None => Vec::new(),
Some(b) => match b64_decode(b.trim()) {
Ok(v) => v,
Err(_) => return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] body_b64 is not base64\n".into()),
},
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
let req = monetize_proto::EstateOrderConsoleRequest {
estate: estate.clone(),
method: method.to_ascii_uppercase(),
path_and_query: path.to_string(),
body: sent,
content_type: ask["content_type"].as_str().unwrap_or_default().to_string(),
repo: ask["repo"].as_bool().unwrap_or(false),
};
let named = format!("{} {}{}", req.method, if req.repo { "repo:" } else { "" }, path.split('?').next().unwrap_or_default());
match m.estate_orders().console(req).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator called the site {estate}'s console: {named} → {}", r.status);
let mut v = serde_json::json!({ "status": r.status, "body": String::from_utf8_lossy(&r.body) });
if std::str::from_utf8(&r.body).is_err() {
v["body_b64"] = serde_json::json!(b64_encode(&r.body));
}
ok_json(v)
}
Err(s) => site_refusal(&s),
}
}
/// **`POST /site/do`** `{"estate", "kind", "params"?: {name: value}, "file_b64"?}` →
/// `EstateOrder.ProductOrder` (MONETIZE-FIX 2026-11-01, replacing `/site/console`): a kind the
/// site's product declares, signed and queued for the box that runs it, which PULLS it (and its
/// file). Which kinds exist and what they take are monetize's to judge (from the product's
/// facts); the door names them and logs no value. Answers `{"words", "seq", "role"}`.
async fn post_site_do(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
const ASK: &str = "REFUSED [site-ask] {\"estate\", \"kind\", \"params\"?: {..}, \"file_b64\"?}\n";
let ask: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
let Some(estate) = ask["estate"].as_str().and_then(site_label) else { return (StatusCode::BAD_REQUEST, ASK.into()) };
let Some(kind) = ask["kind"].as_str().map(str::trim).filter(|k| !k.is_empty() && k.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')) else { return (StatusCode::BAD_REQUEST, ASK.into()) };
if !(ask["params"].is_null() || ask["params"].as_object().is_some_and(|o| o.values().all(|v| v.is_string()))) {
return (StatusCode::BAD_REQUEST, ASK.into());
}
let params = ask["params"].as_object().map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string())).collect()).unwrap_or_default();
let file = match ask["file_b64"].as_str() {
None => Vec::new(),
Some(b) => match b64_decode(b.trim()) {
Ok(v) => v,
Err(_) => return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] file_b64 is not base64\n".into()),
},
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().product_order(monetize_proto::EstateProductOrderRequest { estate: estate.clone(), kind: kind.to_string(), params, file }).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator queued {kind} on the site {estate} for its {} (order {})", r.role, r.seq);
ok_json(serde_json::json!({ "words": r.words, "seq": r.seq, "role": r.role }))
}
Err(s) => site_refusal(&s),
}
}
/// **`GET /site/do/<estate>/<seq>`** → `EstateOrder.ProductOrderAnswer`: the box's answer to one
/// product order (`{"kind", "answered", "ok", "detail"}`).
async fn get_site_do(State(c): State<Conn>, axum::extract::Path((estate, seq)): axum::extract::Path<(String, u64)>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let Some(estate) = site_label(&estate) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] /site/do/<site>/<seq>\n".into()) };
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().product_order_answer(monetize_proto::EstateProductOrderAnswerRequest { estate, seq }).await {
Ok(r) => {
let r = r.into_inner();
ok_json(serde_json::json!({ "kind": r.kind, "answered": r.answered, "ok": r.ok, "detail": r.detail }))
}
Err(s) => site_refusal(&s),
}
}
/// **`GET /site/kinds/<estate>`** → `EstateOrder.ProductOrderKinds`: the order kinds the site's
/// product declares, each with its role, words, params and file (the Sites page's buttons).
async fn get_site_kinds(State(c): State<Conn>, axum::extract::Path(estate): axum::extract::Path<String>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let Some(estate) = site_label(&estate) else { return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] /site/kinds/<site>\n".into()) };
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.estate_orders().product_order_kinds(monetize_proto::EstateProductOrderKindsRequest { estate }).await {
Ok(r) => {
let kinds: Vec<serde_json::Value> = r
.into_inner()
.kinds
.into_iter()
.map(|k| serde_json::json!({ "kind": k.kind, "role": k.role, "words": k.words, "file": k.file, "params": k.params.into_iter().map(|p| serde_json::json!({ "name": p.name, "words": p.words, "required": p.required })).collect::<Vec<_>>() }))
.collect();
ok_json(serde_json::json!({ "kinds": kinds }))
}
Err(s) => site_refusal(&s),
}
}
/// **`POST /site/put`** `{estate, create, domain, product, first_admin, operator_site, terminable,
/// vendor, vendor_secret_key, vendor_webhook_secret, live, terms, media_from}` →
/// `SiteBook.PutSite` (2026-10-14: a site added or edited in the application). The secrets
/// pass through and are never logged; the audit's "who" is the door's operator certificate.
async fn post_site_put(State(c): State<Conn>, body: Bytes) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let ask: serde_json::Value = match serde_json::from_slice(&body) {
Ok(a) => a,
Err(e) => return (StatusCode::BAD_REQUEST, format!("REFUSED [site-ask] a site is a JSON object: {e}\n")),
};
let Some(estate) = ask["estate"].as_str().and_then(site_label) else {
return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] `estate` is the site's label (lowercase letters, digits, hyphens)\n".into());
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.site_book().put_site(site_put_of(&ask, &estate)).await {
Ok(r) => {
let r = r.into_inner();
tracing::warn!("door: the operator put the site {estate} ({} change(s))", r.changed.len());
ok_json(serde_json::json!({ "words": r.words, "changed": r.changed }))
}
Err(s) => site_refusal(&s),
}
}
/// Pure: the door's ask as `SiteBook.PutSite` takes it — the operator's certificate is the "who".
fn site_put_of(ask: &serde_json::Value, estate: &str) -> monetize_proto::SitePutRequest {
let word = |k: &str| ask[k].as_str().unwrap_or_default().trim().to_string();
let n = |k: &str| ask["terms"][k].as_u64().and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
monetize_proto::SitePutRequest {
estate: estate.to_string(),
create: ask["create"].as_bool().unwrap_or(false),
domain: word("domain"),
product: word("product"),
first_admin: word("first_admin"),
operator_site: ask["operator_site"].as_bool().unwrap_or(false),
terminable: ask["terminable"].as_bool().unwrap_or(false),
vendor: word("vendor"),
// A32-15: an older CLI's field names (`stripe_*`) are still read.
vendor_secret_key: Some(word("vendor_secret_key")).filter(|w| !w.is_empty()).unwrap_or_else(|| word(&["str", "ipe_secret_key"].concat())),
vendor_webhook_secret: Some(word("vendor_webhook_secret")).filter(|w| !w.is_empty()).unwrap_or_else(|| word(&["str", "ipe_webhook_secret"].concat())),
live: ask["live"].as_bool().unwrap_or(false),
terms: Some(monetize_proto::SiteTerms { term_days: n("term_days"), term_months: n("term_months"), grace_hours: n("grace_hours"), margin_bp: n("margin_bp") }),
media_from: word("media_from"),
actor: format!("door:{}", mtls::INSTALLER_CN),
}
}
/// **`GET /site/show/<estate>`** (and `/site/show`: a blank new site) → `SiteBook.ShowSite`:
/// the site as the form edits it, the box's choices, its audit history — never a secret.
async fn get_site_show(State(c): State<Conn>, estate: Option<axum::extract::Path<String>>) -> (StatusCode, String) {
if let Err(cn) = &c.operator {
return not_the_operator(cn);
}
let estate = match estate {
Some(axum::extract::Path(e)) => match site_label(&e) {
Some(e) => e,
None => return (StatusCode::BAD_REQUEST, "REFUSED [site-ask] which site?\n".into()),
},
None => String::new(),
};
let m = match sites_client(&c.root) {
Ok(m) => m,
Err(r) => return r,
};
match m.site_book().show_site(monetize_proto::SiteShowRequest { estate }).await {
Ok(r) => ok_json(site_shown_json(&r.into_inner())),
Err(s) => site_refusal(&s),
}
}
/// Pure: a shown site as the door answers it (the record carries no secret value).
pub fn site_shown_json(r: &monetize_proto::SiteShowResponse) -> serde_json::Value {
let s = r.site.clone().unwrap_or_default();
let t = s.terms.unwrap_or_default();
serde_json::json!({
"site": {
"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": { "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, "vendors": r.vendors, "media_sites": r.media_sites, "currency": r.currency, "sizes": r.sizes, "history": r.history,
})
}
/// **The door's routes** for one connection: `operator` is `Ok` when its
/// client certificate is the operator's, else the CN it showed.
#[cfg(test)]
fn app(root: PathBuf, operator: Result<(), String>) -> Router {
app_with(root, operator, Arc::new(crate::linked().product_names()))
}
/// [`app`] for a server that links the product plugins `linked`.
fn app_with(root: PathBuf, operator: Result<(), String>, linked: Arc<Vec<&'static str>>) -> Router {
Router::new().route("/feed", put(put_feed).get(get_feed)).route("/feed/applied", axum::routing::get(get_feed_applied)).route("/feed/next", put(put_feed_next)).route("/offer", axum::routing::get(get_offer)).route("/passkeys/reset", axum::routing::post(post_passkeys_reset)).route("/attach/token", axum::routing::post(post_attach_token)).route("/attach/revoke", axum::routing::post(post_attach_revoke)).route("/site/order", axum::routing::post(post_site_order)).route("/site/test-order", axum::routing::post(post_site_test_order)).route("/site/list", axum::routing::get(get_site_list)).route("/estates/overview", axum::routing::get(get_estates_overview)).route("/cloud/inventory", axum::routing::get(get_cloud_inventory)).route("/site/status/{estate}", axum::routing::get(get_site_status)).route("/site/narrate/{estate}", axum::routing::get(get_site_narrate)).route("/site/narrate/{estate}/{vm}", axum::routing::get(get_site_narrate_vm)).route("/site/installer-log/{estate}/{vm}", axum::routing::get(get_site_installer_log)).route("/site/cancel", axum::routing::post(post_site_cancel)).route("/site/change-plan", axum::routing::post(post_site_change_plan)).route("/site/settle", axum::routing::post(post_site_settle)).route("/site/open-pin/{estate}", axum::routing::post(post_site_open_pin)).route("/site/terminate", axum::routing::post(post_site_terminate)).route("/site/first-link", axum::routing::post(post_site_first_link)).route("/site/refeed", axum::routing::post(post_site_refeed)).route("/site/console", axum::routing::post(post_site_console)).route("/site/do", axum::routing::post(post_site_do)).route("/site/do/{estate}/{seq}", axum::routing::get(get_site_do)).route("/site/kinds/{estate}", axum::routing::get(get_site_kinds)).route("/site/put", axum::routing::post(post_site_put)).route("/site/show", axum::routing::get(get_site_show)).route("/site/show/{estate}", axum::routing::get(get_site_show)).route("/estate/update", axum::routing::post(post_estate_update)).route("/estate/update/{estate}", axum::routing::get(get_estate_update)).route("/estate/update/cancel", axum::routing::post(post_estate_update_cancel)).route("/estate/live-update", axum::routing::post(post_estate_live_update)).route("/estate/adopt", axum::routing::post(post_estate_adopt)).route("/self/running", axum::routing::get(get_self_running)).route("/money/log", axum::routing::get(get_money_log)).route("/self/update", axum::routing::post(post_self_update)).route("/self/certs", axum::routing::get(get_self_certs)).route("/self/certs/try", axum::routing::post(post_self_certs_try)).route("/box/plugins", axum::routing::post(post_box_plugins).get(get_box_plugins_newest)).route("/box/plugins/{job}", axum::routing::get(get_box_plugins)).route("/restore", axum::routing::post(post_restore)).route(crate::edge::ENROL_PATH, axum::routing::post(post_attach_enrol)).layer(axum::extract::DefaultBodyLimit::max(BODY_LIMIT)).with_state(Conn { root, operator, linked })
}
/// **Serve the door** on `listener` for the life of the process, answered with
/// `identity()` (read per connection: the box's own certificate once the
/// installer signed one); `root` is the data root.
pub async fn serve(listener: tokio::net::TcpListener, ca_pem: String, identity: Arc<dyn Fn() -> Issued + Send + Sync>, root: PathBuf) {
serve_linked(listener, ca_pem, identity, root, crate::linked().product_names()).await
}
/// [`serve`] judging feeds against `linked`, the product plugins the server links.
pub async fn serve_linked(listener: tokio::net::TcpListener, ca_pem: String, identity: Arc<dyn Fn() -> Issued + Send + Sync>, root: PathBuf, linked: Vec<&'static str>) {
let linked = Arc::new(linked);
loop {
let (tcp, peer) = match listener.accept().await {
Ok(x) => x,
Err(e) => {
tracing::warn!(error = %e, "door: accept");
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
};
let cfg = match mtls::server_config(&ca_pem, &identity()) {
Ok(c) => c,
Err(e) => {
tracing::error!(error = %e, "door: no TLS configuration");
continue;
}
};
let (root, linked) = (root.clone(), linked.clone());
// A32-14, stays a task: one task per operator-door connection.
tokio::spawn(async move {
let tls = match tokio::time::timeout(Duration::from_secs(10), tokio_rustls::TlsAcceptor::from(cfg).accept(tcp)).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
tracing::info!(%peer, error = %e, "door: handshake refused (no client certificate from the installer CA)");
return;
}
Err(_) => return,
};
let cn = mtls::peer_cn(tls.get_ref().1).unwrap_or_default();
let operator = if cn == mtls::INSTALLER_CN { Ok(()) } else { Err(cn) };
let svc = hyper_util::service::TowerToHyperService::new(app_with(root, operator, linked));
let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()).serve_connection(hyper_util::rt::TokioIo::new(tls), svc).await;
});
}
}
/// **Start the door** on `0.0.0.0:`[`PORT`].
pub async fn start(ca_pem: String, identity: Arc<dyn Fn() -> Issued + Send + Sync>, root: PathBuf) {
let addr = SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, PORT));
match tokio::net::TcpListener::bind(addr).await {
Ok(l) => {
tracing::info!(%addr, "door: mTLS, the operator's certificate only — PUT/GET /feed, POST /passkeys/reset, POST /attach/token, POST /attach/enrol");
// A32-14, stays a task: the operator door's accept loop, for the box's life.
tokio::spawn(serve(l, ca_pem, identity, root));
}
Err(e) => tracing::error!(error = %e, "door: cannot listen on {addr} — no feed can be put after the install"),
}
}
#[cfg(test)]
mod tests {
async fn post(app: axum::Router, path: &str, body: &str) -> (u16, String) {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move { axum::serve(l, app).await });
let mut tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
let req = format!("POST {path} HTTP/1.1\r\nHost: monetize-test.ignalina.dk:8475\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
tcp.write_all(req.as_bytes()).await.unwrap();
let mut out = Vec::new();
let _ = tcp.read_to_end(&mut out).await;
let t = String::from_utf8_lossy(&out).to_string();
(t.split_whitespace().nth(1).and_then(|s| s.parse().ok()).unwrap_or(0), t)
}
/// ★ **`GET /self/certs`, `POST /self/certs/try`** (owner 2026-10-25): the operator's CLI
/// reads the box's certificates as edda estate-front reports them, and "try now" is
/// refused while a failed validation's wait runs and taken after any other failure.
#[tokio::test]
async fn the_door_says_the_certificates_and_takes_try_now_by_the_rule() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-certs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let now = std::time::SystemTime::now();
estate_front::web::acme::failed_as(&root, "v.example", now, estate_front::web::acme::FailKind::Validation);
estate_front::web::acme::failed_as(&root, "a.example", now, estate_front::web::acme::FailKind::Acme);
let (st, text) = get(super::app(root.clone(), Err("estate".into())), "/self/certs").await;
assert_eq!(st, 403, "the operator's: {text}");
let (st, text) = get(super::app(root.clone(), Ok(())), "/self/certs").await;
assert_eq!(st, 200, "{text}");
assert!(text.contains("\"host\":\"a.example\"") && text.contains("\"host\":\"v.example\"") && text.contains("next attempt:"), "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/self/certs/try", r#"{"host":"v.example"}"#).await;
assert_eq!(st, 409, "{text}");
assert!(text.contains("tls-try-now-validation"), "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/self/certs/try", r#"{"host":"a.example"}"#).await;
assert_eq!(st, 200, "{text}");
assert!(estate_front::web::acme::wait(&root, "a.example").unwrap().at <= std::time::SystemTime::now(), "the wait is skipped");
let (st, text) = post(super::app(root.clone(), Ok(())), "/self/certs/try", r#"{"host":"../x"}"#).await;
assert_eq!(st, 400, "{text}");
let _ = std::fs::remove_dir_all(&root);
}
/// i54a run6 O2 (`[attach-refused] HTTP 404`): the mock's estate enrols over the
/// door (the kit's `--central-at`), so the door answers `POST /attach/enrol` too —
/// the token is the gate, as at the edge, whatever installer-CA client calls.
#[tokio::test]
async fn the_door_answers_an_enrolment() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
// ST-14: without a configured name the door names no host — the Host header never does.
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/attach/enrol", "{").await;
assert_eq!(st, 503, "{text}");
assert!(text.contains("attach-no-host"), "{text}");
crate::solo::UiSettings { rp_id: "monetize.ignalina.dk".into(), admin_token: None }.write(&root).unwrap();
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/attach/enrol", "{").await;
assert_eq!(st, 400, "{text}");
assert!(text.contains("attach-not-a-request"), "{text}");
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **The operator's sites at the door are pure forwarders** (owner 2026-10-13): only the
/// operator; a malformed ask is refused before monetize is asked; a box with no operator
/// bearer says so; and monetize's own refusal is answered in its words.
#[tokio::test]
async fn the_operators_site_routes_forward_and_decide_nothing() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-site-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ask = r#"{"estate":"gunnar-rs","size":"mini","email":"ops@example.invalid","on":false,"machines":[]}"#;
for path in ["/site/order", "/site/cancel", "/site/terminate", "/site/put", "/estate/update", "/estate/live-update", "/estate/adopt"] {
let (st, text) = post(super::app(root.clone(), Err("estate".into())), path, ask).await;
assert_eq!(st, 403, "{path} is the operator's: {text}");
let (st, text) = post(super::app(root.clone(), Ok(())), path, r#"{"estate":"../x"}"#).await;
assert_eq!(st, 400, "{path}: {text}");
let (st, text) = post(super::app(root.clone(), Ok(())), path, ask).await;
assert_eq!(st, 503, "{path}: no operator bearer on this box yet: {text}");
assert!(text.contains("site-no-operator") && !text.contains("example.invalid"), "{text}");
}
assert!(super::site_label("gunnar-rs").is_some() && super::site_label("Gunnar").is_none());
let (http, body) = super::site_refusal(&tonic::Status::failed_precondition("gunnar-rs stands: its main called home"));
assert_eq!(http, 409);
assert!(body.contains("gunnar-rs stands") && body.contains("FailedPrecondition"), "{body}");
assert_eq!(super::site_refusal(&tonic::Status::permission_denied("no")).0, 403);
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **`POST /site/test-order`** (owner: "monetize must be automatic"): the door's TEST
/// order verb is the operator's alone and a pure forwarder like `/site/order` — a malformed
/// ask is refused before monetize is asked, a box with no operator bearer says so and never
/// echoes the address. Whether the key is a test key is monetize-server's answer.
#[tokio::test]
async fn the_test_order_route_is_the_operators_and_forwards() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-test-order-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ask = r#"{"estate":"test2-gunnar-rs","size":"mini","email":"ops@example.invalid"}"#;
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/site/test-order", ask).await;
assert_eq!(st, 403, "the operator's: {text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/test-order", r#"{"estate":"../x"}"#).await;
assert_eq!(st, 400, "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/test-order", ask).await;
assert_eq!(st, 503, "no operator bearer on this box yet: {text}");
assert!(text.contains("site-no-operator") && !text.contains("example.invalid"), "{text}");
let _ = std::fs::remove_dir_all(&root);
// The ask, forwarded: the test verb sets `test_order` and never a return page; the
// ordinary verb is as it was.
let a: serde_json::Value = serde_json::json!({ "estate": "test2-gunnar-rs", "size": " mini ", "email": "ops@example.invalid", "seed_key": "", "return_url": "https://elsewhere.example/sites" });
let t = super::site_order_request("test2-gunnar-rs", &a, true);
assert_eq!((t.estate.as_str(), t.size.as_str(), t.email.as_str(), t.return_url.as_str(), t.test_order), ("test2-gunnar-rs", "mini", "ops@example.invalid", "", true));
let o = super::site_order_request("test2-gunnar-rs", &a, false);
assert_eq!((o.return_url.as_str(), o.test_order), ("https://elsewhere.example/sites", false));
}
/// ★ **`POST /site/first-link`** (owner 2026-10-22): the operator's alone; a malformed site
/// or an ask with no address is refused before monetize is asked; a box with no operator
/// bearer says so, and never echoes the address.
#[tokio::test]
async fn the_first_link_route_is_the_operators_and_forwards() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-first-link-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ask = r#"{"estate":"holger-rs","to":"owner@example.invalid"}"#;
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/site/first-link", ask).await;
assert_eq!(st, 403, "the operator's: {text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/first-link", r#"{"estate":"../x","to":"owner@example.invalid"}"#).await;
assert_eq!(st, 400, "{text}");
// FIRST-LINK-1: no address is the recorded one; it reaches monetize like any other ask.
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/first-link", r#"{"estate":"holger-rs"}"#).await;
assert_eq!(st, 503, "no address is monetize's to judge: {text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/first-link", ask).await;
assert_eq!(st, 503, "no operator bearer on this box yet: {text}");
assert!(text.contains("site-no-operator") && !text.contains("example.invalid"), "{text}");
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **`POST /site/refeed`** (MAIL-RELAY 2026-10-28): the operator's alone; a malformed
/// site is refused before monetize is asked; a well-formed one reaches monetize (here: no
/// operator bearer on this box yet, said by name).
#[tokio::test]
async fn the_refeed_route_is_the_operators_and_forwards() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-refeed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/site/refeed", r#"{"estate":"holger-rs"}"#).await;
assert_eq!(st, 403, "the operator's: {text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/refeed", r#"{"estate":"../x"}"#).await;
assert_eq!(st, 400, "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/refeed", r#"{}"#).await;
assert_eq!(st, 400, "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/refeed", r#"{"estate":"holger-rs","only":["public_repositories"]}"#).await;
assert_eq!(st, 503, "no operator bearer on this box yet: {text}");
assert!(text.contains("site-no-operator"), "{text}");
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **`POST /site/change-plan`** (MONETIZE-FIX 2026-11-01): the operator's alone; a malformed
/// ask is refused before monetize is asked; a well-formed one reaches monetize (here: no
/// operator bearer on this box yet, said by name).
#[tokio::test]
async fn the_change_plan_route_is_the_operators_and_forwards() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-change-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ask = r#"{"estate":"test2-gunnar-rs","data_gib":60,"test":true}"#;
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/site/change-plan", ask).await;
assert_eq!(st, 403, "the operator's: {text}");
for bad in [r#"{"estate":"../x","data_gib":60}"#, r#"{"data_gib":60}"#, r#"{"estate":"a","data_gib":"x"}"#, r#"{"estate":"a","order":[1]}"#] {
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/change-plan", bad).await;
assert_eq!(st, 400, "{bad}: {text}");
}
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/change-plan", r#"{"estate":"lake","order":{"be":3}}"#).await;
assert_eq!(st, 503, "no operator bearer on this box yet: {text}");
assert!(text.contains("site-no-operator"), "{text}");
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **`POST /site/do`** (MONETIZE-FIX 2026-11-01): the operator's alone; a malformed ask (no
/// site, no plain kind, params that are not words, a file that is not base64) is refused
/// before monetize is asked; a well-formed one reaches monetize (here: no operator bearer on
/// this box yet, said by name). The answer and kinds reads are the operator's too.
#[tokio::test]
async fn the_product_order_routes_are_the_operators_and_forward() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-do-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ask = r#"{"estate":"test3-holger-rs","kind":"downstream_enrol","params":{"name":"lab-1"}}"#;
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/site/do", ask).await;
assert_eq!(st, 403, "the operator's: {text}");
for bad in [r#"{"estate":"../x","kind":"a"}"#, r#"{"estate":"a"}"#, r#"{"estate":"a","kind":"Bad Kind"}"#, r#"{"estate":"a","kind":"k","params":{"n":1}}"#, r#"{"estate":"a","kind":"k","file_b64":"@@"}"#] {
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/do", bad).await;
assert_eq!(st, 400, "{bad}: {text}");
}
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/do", r#"{"estate":"test3-holger-rs","kind":"bundle_put","file_b64":"aGk="}"#).await;
assert_eq!(st, 503, "no operator bearer on this box yet: {text}");
assert!(text.contains("site-no-operator"), "{text}");
for path in ["/site/do/test3-holger-rs/7", "/site/kinds/test3-holger-rs"] {
let (st, text) = get(super::app(root.clone(), Err("estate".into())), path).await;
assert_eq!(st, 403, "{path}: {text}");
let (st, text) = get(super::app(root.clone(), Ok(())), path).await;
assert_eq!(st, 503, "{path}: {text}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_console_bodys_base64_goes_both_ways() {
for b in [&b""[..], b"f", b"fo", b"foo", &[0u8, 1, 2, 255, 254, 253, 7][..]] {
assert_eq!(super::b64_decode(&super::b64_encode(b)).unwrap(), b.to_vec());
}
assert_eq!(super::b64_encode(b"hi!"), "aGkh");
assert!(super::b64_decode("@@").is_err());
}
/// ★ **`POST /site/console`** (OX-WGDS3 2026-10-28): the operator's alone; a malformed ask
/// (no site, no method, a path that is not absolute, a body that is not base64) is refused
/// before monetize is asked; a well-formed one reaches monetize (here: no operator bearer
/// on this box yet, said by name).
#[tokio::test]
async fn the_console_route_is_the_operators_and_forwards() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-console-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let ask = r#"{"estate":"test3-holger-rs","method":"POST","path":"/-/sync/downstreams/enrol?name=lab-1&key=ab&via=front"}"#;
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/site/console", ask).await;
assert_eq!(st, 403, "the operator's: {text}");
for bad in [r#"{"estate":"../x","method":"GET","path":"/-/sync/key"}"#, r#"{"estate":"a","path":"/-/sync/key"}"#, r#"{"estate":"a","method":"GET","path":"x"}"#, r#"{"estate":"a","method":"PUT","path":"/bundles/b","body_b64":"@@"}"#] {
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/console", bad).await;
assert_eq!(st, 400, "{bad}: {text}");
}
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/console", r#"{"estate":"test3-holger-rs","method":"PUT","path":"/bundles/b-1","body_b64":"aGk=","repo":true}"#).await;
assert_eq!(st, 503, "no operator bearer on this box yet: {text}");
assert!(text.contains("site-no-operator"), "{text}");
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **`POST /estate/adopt`'s ask** (owner 2026-10-17) is monetize's request field for field.
#[test]
fn the_adopt_ask_is_monetizes_request() {
let ask = serde_json::json!({ "estate": "gunnar-rs", "zone": "se-sto1", "again": true, "release_of": { "main": "02abc" },
"machines": [{ "role": "front", "server": " f-1 ", "public_ipv4": "198.51.100.1", "disks": [{ "id": "d0", "gib": 25, "address": "virtio:0" }] }, { "role": "main", "server": "m-1" }, { "role": "twin", "server": "t-1" }] });
let r = super::adopt_request(&ask).unwrap();
assert_eq!((r.estate.as_str(), r.zone.as_str(), r.again, r.release_of.get("main").map(String::as_str)), ("gunnar-rs", "se-sto1", true, Some("02abc")));
assert_eq!(r.machines.iter().map(|m| (m.role.as_str(), m.server.as_str())).collect::<Vec<_>>(), vec![("front", "f-1"), ("main", "m-1"), ("twin", "t-1")]);
assert_eq!((r.machines[0].disks[0].gib, r.machines[0].public_ipv4.as_str()), (25, "198.51.100.1"));
assert!(super::adopt_request(&serde_json::json!({ "estate": "gunnar-rs" })).unwrap_err().contains("machines"));
assert!(super::adopt_request(&serde_json::json!({ "estate": "Gunnar.rs", "machines": [] })).is_err());
}
/// ★ **The box's own update at the door** (owner 2026-10-14, point 5): the operator's alone;
/// on a machine that is no korp box (no ESP records) it is refused by name and nothing moves.
#[tokio::test]
async fn the_boxs_own_update_is_the_operators_and_refused_by_name_off_a_korp_box() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-self-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/self/update", "").await;
assert_eq!(st, 403, "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/self/update", "").await;
assert_eq!(st, 409, "{text}");
assert!(text.contains("REFUSED") && text.contains("live"), "{text}");
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **The box's plugins at the door** (owner 2026-10-17): `POST /box/plugins` and
/// `GET /box/plugins[/<job>]` are the operator's alone, a malformed ask is refused before
/// monetize is asked, a box with no operator bearer says so, and the job is answered as
/// monetize said it — every row's name, role, outcome and words.
#[tokio::test]
async fn the_boxs_plugins_at_the_door_are_the_operators_and_forward_the_job() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-plugins-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/box/plugins", r#"{"apply":true}"#).await;
assert_eq!(st, 403, "{text}");
for path in ["/box/plugins", "/box/plugins/plugins-1"] {
let (st, text) = get(super::app(root.clone(), Err("estate".into())), path).await;
assert_eq!(st, 403, "{path}: {text}");
let (st, text) = get(super::app(root.clone(), Ok(())), path).await;
assert_eq!(st, 503, "{path}: {text}");
assert!(text.contains("site-no-operator"), "{text}");
}
let (st, text) = post(super::app(root.clone(), Ok(())), "/box/plugins", r#"{"apply":"yes"}"#).await;
assert_eq!(st, 400, "{text}");
assert!(text.contains("plugins-ask"), "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/box/plugins", r#"{"apply":true}"#).await;
assert_eq!(st, 503, "{text}");
assert!(text.contains("site-no-operator"), "{text}");
let v = super::plugins_value(monetize_proto::PluginUpdateJob {
job: "plugins-3".into(),
state: "done".into(),
apply: true,
rows: vec![monetize_proto::PluginRow { name: "stripe.wasm".into(), role: "vendor".into(), outcome: "replaced".into(), detail: "[vendors.stripe.gunnar]".into(), blake3: "eb45".into() }],
error: String::new(),
});
assert_eq!(v, serde_json::json!({ "job": "plugins-3", "state": "done", "apply": true, "error": "", "rows": [{ "name": "stripe.wasm", "role": "vendor", "outcome": "replaced", "detail": "[vendors.stripe.gunnar]", "blake3": "eb45" }] }));
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **STINKY ST-13: the operator revokes an estate's bearer at the door** — only the
/// operator; an estate that never enrolled is refused by name; a revoked record keeps no
/// digest.
#[tokio::test]
async fn the_operator_revokes_an_estates_bearer_and_nobody_else_does() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-revoke-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/attach/revoke", r#"{"estate":"acme"}"#).await;
assert_eq!(st, 403, "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/attach/revoke", r#"{"estate":"acme"}"#).await;
assert_eq!(st, 422, "{text}");
assert!(text.contains("attach-not-enrolled"), "{text}");
let rec = serde_json::json!({ "estate": "acme", "product": "gunnar", "box_name": "main", "endpoint": "", "estate_ca_pem": "", "owner_key": "", "enrolled_unix_ms": 1, "bearer_sha256": "ab".repeat(32), "bearer_until_unix_ms": u64::MAX });
monetize_front::write_secret(&root, &format!("{}/acme.json", monetize_front::attach::ESTATES_REL), &rec.to_string()).unwrap();
let (st, text) = post(super::app(root.clone(), Ok(())), "/attach/revoke", r#"{"estate":"acme"}"#).await;
assert_eq!(st, 200, "{text}");
let central = monetize_front::attach::Central { root: root.clone(), plugin_door: String::new() };
let kept = monetize_front::attach::enrolled(¢ral, "acme").unwrap().unwrap();
assert!(kept.bearer_sha256.is_empty() && kept.bearer_revoked_unix_ms > 0, "{kept:?}");
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **`GET /offer`: what the box offers, read by the operator after its
/// install is closed** (pmo `upgrade`, 2026-10-11: a box upgraded IN PLACE
/// comes back CLOSED, so the installer never reads its offer again) — its
/// build and monetize's PUBLIC key, the installer's offer exactly; only the
/// operator reads it.
#[tokio::test]
async fn the_operator_reads_the_boxs_offer_its_build_and_monetizes_public_key() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-offer-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
monetize_front::intake::prepare(&root).unwrap();
let (st, text) = get(super::app(root.clone(), Ok(())), "/offer").await;
assert_eq!(st, 200, "{text}");
let body = text.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or_default();
let o: std::collections::BTreeMap<String, String> = serde_json::from_str(body).unwrap();
assert_eq!(o, crate::product::offers_of(&root), "the installer's offer exactly");
assert_eq!(o.get(crate::OFFER_VERSION).map(String::as_str), Some(crate::GIT_HASH));
assert_eq!(o.get(monetize_front::intake::OFFER_MONETIZE_PUBKEY).map(String::len), Some(64));
let (st, text) = get(super::app(root.clone(), Err("estate".into())), "/offer").await;
assert!(st == 403 && text.contains("[not-the-operator]"), "{st} {text}");
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **ST-12: `POST /restore` is the operator's** — an opened off-box copy put on a fresh
/// box's door is staged for its next start; anybody else is refused, and a body that is
/// not `{entry: hex}` is refused by name.
#[tokio::test]
async fn the_operator_stages_a_restore_on_the_door_and_nobody_else_does() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-restore-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
monetize_front::intake::prepare(&root).unwrap();
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/restore", "{}").await;
assert!(st == 403 && text.contains("[not-the-operator]"), "{st} {text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/restore", "{").await;
assert!(st == 400 && text.contains("[restore-json]"), "{st} {text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/restore", "{\"OFFSITE\": \"00\"}").await;
assert!(st == 422 && text.contains("[offsite-verify]"), "a copy that is no copy: {st} {text}");
assert!(!root.join(crate::restore::STAGED_REL).exists());
let _ = std::fs::remove_dir_all(&root);
}
async fn get(app: axum::Router, path: &str) -> (u16, String) {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move { axum::serve(l, app).await });
let mut tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
let req = format!("GET {path} HTTP/1.1\r\nHost: monetize-test.ignalina.dk:8475\r\nConnection: close\r\n\r\n");
tcp.write_all(req.as_bytes()).await.unwrap();
let mut out = Vec::new();
let _ = tcp.read_to_end(&mut out).await;
let t = String::from_utf8_lossy(&out).to_string();
(t.split_whitespace().nth(1).and_then(|s| s.parse().ok()).unwrap_or(0), t)
}
/// ★ **The money lines at the door** (ALICA-3): `GET /money/log[?tail=N]` answers the box's
/// money lines, newest last, to the operator alone.
#[tokio::test]
async fn the_money_lines_are_the_operators_at_the_door() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-money-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for i in 0..3 {
crate::money_log::keep(&root, &format!("monetize-server: signup stripe:cs_{i} CREATED u{i}")).unwrap();
}
let (st, text) = get(super::app(root.clone(), Err("estate".into())), "/money/log").await;
assert!(st == 403 && text.contains("[not-the-operator]"), "{st} {text}");
let (st, text) = get(super::app(root.clone(), Ok(())), "/money/log").await;
assert!(st == 200 && text.contains("signup stripe:cs_0 CREATED u0") && text.trim_end().ends_with("signup stripe:cs_2 CREATED u2"), "{st} {text}");
let (st, text) = get(super::app(root.clone(), Ok(())), "/money/log?tail=1").await;
assert!(st == 200 && !text.contains("cs_1 ") && text.contains("cs_2 "), "{st} {text}");
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **A narrated site's boot log at the door** (2026-10-20: an operator with no console
/// passkey session reads it): `GET /site/narrate/<site>` (each box's summary) and
/// `GET /site/narrate/<site>/<vm>[?tail=N]` (one box's log, plain text, newest last) are the
/// operator's alone, refuse a malformed ask before monetize is asked, and say so on a box
/// with no operator bearer.
#[tokio::test]
async fn a_narrated_sites_log_at_the_door_is_the_operators() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-narrate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for path in ["/site/narrate/test3-holger-rs", "/site/narrate/test3-holger-rs/main", "/site/narrate/test3-holger-rs/main?tail=50"] {
let (st, text) = get(super::app(root.clone(), Err("estate".into())), path).await;
assert_eq!(st, 403, "{path} is the operator's: {text}");
let (st, text) = get(super::app(root.clone(), Ok(())), path).await;
assert_eq!(st, 503, "{path}: {text}");
assert!(text.contains("site-no-operator"), "{text}");
}
for bad in ["/site/narrate/Test3", "/site/narrate/test3-holger-rs/Main", "/site/narrate/test3-holger-rs/main?tail=x", "/site/narrate/test3-holger-rs/main?tail=0"] {
let (st, text) = get(super::app(root.clone(), Ok(())), bad).await;
assert_eq!(st, 400, "{bad}: {text}");
}
assert_eq!(super::tail_asked(None), Ok(None));
assert_eq!(super::tail_asked(Some("tail=5")), Ok(Some(5)));
assert_eq!(super::tail_asked(Some("x=1&tail=7")), Ok(Some(7)));
assert!(super::tail_asked(Some("tail=-1")).is_err());
assert_eq!(super::last_lines("a\nb\nc\n", Some(2)), "b\nc\n");
assert_eq!(super::last_lines("a\nb\nc", Some(2)), "b\nc\n", "newest last, whole lines");
assert_eq!(super::last_lines("a\nb\n", Some(9)), "a\nb\n");
assert_eq!(super::last_lines("a\nb\n", None), "a\nb\n");
let v = super::narrate_summary(monetize_proto::EstateNarrateLogResponse {
estate: "test3-holger-rs".into(),
narrated: true,
words: "heard here".into(),
vms: vec![
monetize_proto::EstateNarrateVm { vm: "main".into(), text: "one\ntwo\n".into(), bytes: 8, updated_unix_ms: 0 },
monetize_proto::EstateNarrateVm { vm: "front".into(), text: String::new(), bytes: 0, updated_unix_ms: 0 },
],
});
assert_eq!(v["estate"], "test3-holger-rs");
assert_eq!((v["narrated"].as_bool(), v["words"].as_str()), (Some(true), Some("heard here")));
assert_eq!(v["vms"][0], serde_json::json!({ "vm": "main", "lines": 2, "bytes": 8, "updated": "", "last": "two" }));
assert_eq!(v["vms"][1]["lines"], 0);
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **A box's install log at the door** (2026-10-20: the narrate store had trimmed a
/// front's first boot lines): `GET /site/installer-log/<site>/<vm>[?since=N]` is the
/// operator's alone, refuses a malformed ask before monetize is asked, and says so on a box
/// with no operator bearer.
#[tokio::test]
async fn a_boxs_install_log_at_the_door_is_the_operators() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-install-log-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for path in ["/site/installer-log/test3-holger-rs/front", "/site/installer-log/test3-holger-rs/front?since=40"] {
let (st, text) = get(super::app(root.clone(), Err("estate".into())), path).await;
assert_eq!(st, 403, "{path} is the operator's: {text}");
let (st, text) = get(super::app(root.clone(), Ok(())), path).await;
assert_eq!(st, 503, "{path}: {text}");
assert!(text.contains("site-no-operator"), "{text}");
}
for bad in ["/site/installer-log/Test3/front", "/site/installer-log/test3-holger-rs/Front", "/site/installer-log/test3-holger-rs/front?since=x"] {
let (st, text) = get(super::app(root.clone(), Ok(())), bad).await;
assert_eq!(st, 400, "{bad}: {text}");
}
assert_eq!(super::since_asked(None), Ok(0));
assert_eq!(super::since_asked(Some("since=12")), Ok(12));
assert_eq!(super::since_asked(Some("since=0")), Ok(0));
assert!(super::since_asked(Some("since=-1")).is_err());
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **"Check again" and "Set up again" at the door** (F5, 2026-10-20: only the console's
/// Sites page had them): `POST /site/settle` `{"estate", "action": "recheck"|"retry"}` is the
/// operator's alone, refuses any other action or a malformed site before monetize is
/// asked, and says so on a box with no operator bearer.
#[tokio::test]
async fn settling_a_site_at_the_door_is_the_operators_and_recheck_retry_or_abandon_only() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-settle-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for action in ["recheck", "retry", "abandon"] {
let ask = format!(r#"{{"estate":"test3-holger-rs","action":"{action}"}}"#);
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/site/settle", &ask).await;
assert_eq!(st, 403, "{action}: {text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/settle", &ask).await;
assert_eq!(st, 503, "{action}: {text}");
assert!(text.contains("site-no-operator"), "{text}");
}
for bad in [r#"{"estate":"test3-holger-rs","action":"refund"}"#, r#"{"estate":"test3-holger-rs"}"#, r#"{"estate":"../x","action":"retry"}"#] {
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/settle", bad).await;
assert_eq!(st, 400, "{bad}: {text}");
assert!(text.contains("site-ask"), "{text}");
}
let _ = std::fs::remove_dir_all(&root);
}
/// ★ **`POST /site/open-pin/{site}`** (F6, 2026-10-20): the guardian's pin windows opened
/// again for the site's boxes not pinned yet — the operator's alone, a malformed site
/// refused before monetize is asked, and said so on a box with no operator bearer.
#[tokio::test]
async fn opening_a_sites_pin_windows_at_the_door_is_the_operators() {
let root = std::env::temp_dir().join(format!("monetize-admin-door-open-pin-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (st, text) = post(super::app(root.clone(), Err("estate".into())), "/site/open-pin/test3-holger-rs", "").await;
assert_eq!(st, 403, "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/open-pin/test3-holger-rs", "").await;
assert_eq!(st, 503, "{text}");
assert!(text.contains("site-no-operator"), "{text}");
let (st, text) = post(super::app(root.clone(), Ok(())), "/site/open-pin/Bad_Site", "").await;
assert_eq!(st, 400, "{text}");
assert!(text.contains("site-ask"), "{text}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_feed_is_an_object_of_strings() {
assert_eq!(super::parse_feed(br#"{"a":"b"}"#).unwrap().get("a").map(String::as_str), Some("b"));
for bad in [&b"[1, 2]"[..], br#"{"a": 1}"#, b"not json"] {
assert!(super::parse_feed(bad).unwrap_err().contains("[feed-json]"));
}
}
/// ★ **MAP-SEG 3: the door's read of the account** — each product's block merged into one per
/// cloud ACCOUNT (MAP-3: "gunnar · upcloud" and "holger · upcloud" were the same account),
/// naming every product on it; an orphan and a backup (on every product's block) said ONCE;
/// a block that could not be listed marking its account not listed.
#[test]
fn the_inventory_is_one_list_per_provider_with_each_row_once() {
let row = |id: &str, estate: &str| serde_json::json!({ "id": id, "kind": "disk", "gib": 20, "estate": estate });
let bk = serde_json::json!({ "id": "storage/bk", "gib": 10, "estate": "holger-rs", "words": "backup of holger-web-ui-holgerrs, 2026-10-27" });
let gunnar = serde_json::json!({ "provider": "upcloud", "account": "upcloud", "available": true, "error": "", "foreign_count": 11, "owned": [row("storage/a", "test2-gunnar-rs")], "orphans": [row("storage/o", "holgerr34e")], "missing": [], "backups": [bk.clone()] });
let holger = serde_json::json!({ "provider": "upcloud", "account": "upcloud", "available": true, "error": "", "foreign_count": 11, "owned": [row("storage/b", "holger-rs")], "orphans": [row("storage/o", "holgerr34e")], "missing": [], "backups": [bk] });
let none = serde_json::json!({ "provider": "", "available": false, "error": "the server sent no cloud block", "owned": [], "orphans": [], "missing": [] });
let v = crate::door::inventory_value(&[("gunnar".into(), gunnar.clone()), ("holger".into(), holger), ("lake".into(), none)]);
let clouds = v["clouds"].as_array().unwrap();
assert_eq!(clouds.len(), 1, "{v}");
assert_eq!(clouds[0]["products"], serde_json::json!(["gunnar", "holger"]), "{v}");
assert_eq!(clouds[0]["owned"].as_array().unwrap().len(), 2);
assert_eq!(clouds[0]["orphans"].as_array().unwrap().len(), 1, "an orphan is said once: {v}");
assert_eq!(clouds[0]["backups"].as_array().unwrap().len(), 1, "a backup is said once: {v}");
assert_eq!(clouds[0]["foreign_count"], 11);
let down = serde_json::json!({ "provider": "upcloud", "account": "upcloud", "available": false, "error": "401", "owned": [], "orphans": [], "missing": [] });
let v = crate::door::inventory_value(&[("gunnar".into(), gunnar), ("holger".into(), down)]);
assert_eq!((v["clouds"][0]["available"].as_bool(), v["clouds"][0]["error"].as_str()), (Some(false), Some("401")));
}
}