crates/monetize-server/src/estate_order/new_estate.rs
//! **THE NEW-ESTATE SLOT** (owner 2026-10-29, lane OVW-CLEAN) — the Overview's one empty row:
//! the operator types a name and picks a product, and this box makes the estate and orders it.
//!
//! The server decides everything the slot does not say, and refuses by name:
//!
//! * **A declared site** (`[estates.<name>]`, fed or added in the application): nothing is made;
//! it is ordered as it is declared — its product must be the one picked, and it must be one of
//! the operator's own sites.
//! * **A new name**: the estate is copied from a TEMPLATE, a declared operator site of the same
//! product that lays from media of its own ([`template_of`]: a terminable one first, then the
//! one billing through the product's own route, then by label). The new site takes the
//! template's first admin, Stripe account, mode, terms and media; it is terminable; its domain
//! is its name in the template's zone ([`domain_for`]: `test4-gunnar-rs` → `test4.gunnar.rs`,
//! the rule a declared site's label already follows). It is added to the box's SITE BOOK
//! (`SiteBook.PutSite`: judged by the box's own loader, kept, audited, taken live).
//! * **Then the ONE site order** ([`super::site::start_site_back_to`], `EstateOrder.OrderSite`):
//! the same Stripe step, the same lay. There is no second buy flow.
//!
//! The slot never lays over an estate that exists (installed or being laid,
//! [`super::standing`]); a name whose estate ended is free again.
use super::standing;
use crate::config::EstateConfig;
use crate::site_book::{SitePut, SiteTerms};
use crate::state::AppState;
/// How long the slot's call looks whether the box took a site it added (PID 1's look, two
/// seconds, then the server's config watcher, two seconds) before a queued job owns the order
/// (OVW-5): the call is never held longer.
pub const TAKEN_AT_ONCE: std::time::Duration = std::time::Duration::from_secs(5);
/// How long the queued job waits for the box to take it before it says so by name (an ALERT
/// line PID 1 mails to the operator).
pub const TAKEN_WITHIN: std::time::Duration = std::time::Duration::from_secs(120);
/// How often the job looks.
const TAKEN_RELOOK: std::time::Duration = std::time::Duration::from_millis(250);
/// The jera job kind of a late order.
pub const LATE_JOB: &str = "monetize_new_estate_order";
/// **An order the slot's call left to a queued job**: the site is added, the box has not taken it.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LateOrder {
pub name: String,
pub email: String,
pub size: String,
pub return_url: String,
}
/// Pure: what the slot's call answers when the order is the job's.
pub fn late_words(name: &str, domain: &str, template: &str) -> String {
format!("{name} is made as {domain} (its admin, account, terms and media as {template}'s); the box takes it within moments and then it is ordered: its payment page is on the Sites page")
}
/// **Queue the order of `o`** on the slot's lane (OVW-5): answered at once; the job waits for
/// the box to take the site, then places the ONE site order. The same name while its job is
/// queued or running is that job.
pub fn queue_late_order(state: &std::sync::Arc<AppState>, o: &LateOrder) -> Result<(), String> {
let lane = lane(state)?;
let spec = jera::jobs::chain::ChainSpec::new(
LATE_JOB,
format!("new-estate/{}", o.name),
"new-estate",
0,
"monetize",
serde_json::to_value(o).map_err(|e| e.to_string())?,
)
.with_target(format!(
"order the new estate {} once the box takes it",
o.name
))
.with_priority(jera::jobs::chain::Priority::Normal);
lane.submit(spec).job_id().map(|_| ()).ok_or_else(|| {
format!(
"REFUSED [new-estate-queue] the slot's lane refused the order of {}",
o.name
)
})
}
fn lane(
state: &std::sync::Arc<AppState>,
) -> Result<std::sync::Arc<jera::jobs::chain::ChainQueue>, String> {
if let Some(q) = state.estates.new_estate_lane.get() {
return Ok(q.clone());
}
let rt = tokio::runtime::Handle::try_current()
.map_err(|_| "no runtime to run the New-estate lane on".to_string())?;
let sink = crate::jobs::lane_sink("estate order: the New-estate slot");
let queue = jera::jobs::chain::ChainQueue::new(sink);
let q = state
.estates
.new_estate_lane
.get_or_init(|| queue.clone())
.clone();
if std::sync::Arc::ptr_eq(&q, &queue) {
let weak = std::sync::Arc::downgrade(state);
// A32-14, stays a task: the New-estate lane's jera worker.
rt.spawn(queue.clone().run(move |spec| {
let Some(st) = weak.upgrade() else {
anyhow::bail!("the server stopped")
};
let o: LateOrder = serde_json::from_value(spec.payload.clone())?;
match order_when_taken(&st, &o, TAKEN_WITHIN) {
Ok(line) => {
eprintln!("monetize-server: {line}");
Ok(serde_json::json!({ "estate": o.name }))
}
Err(why) => {
eprintln!(
"monetize-server: ALERT estate-orders.new-estate {}: {why}",
o.name
);
anyhow::bail!("{why}")
}
}
}));
}
Ok(q)
}
/// **The job's work**: wait (at most `within`) for the box to take `o.name`, then place the ONE
/// site order. Answers the line it says; `Err` names why nothing was ordered.
pub fn order_when_taken(
state: &AppState,
o: &LateOrder,
within: std::time::Duration,
) -> Result<String, String> {
let t0 = std::time::Instant::now();
while !taken(state, &o.name) {
if t0.elapsed() >= within {
return Err(format!("REFUSED [new-estate-not-taken-yet] {} is in the site book, and the box has not taken it within {} s: start it again from the slot by its name (nothing was ordered or held)", o.name, within.as_secs()));
}
std::thread::sleep(TAKEN_RELOOK);
}
let sizes = super::site::sizes_of_on(state, Some(&o.name), &o.size)?;
let run = super::site::start_site_back_to(state, &o.name, &o.email, &sizes, "", &o.return_url)
.map_err(|e| e.to_string())?;
Ok(format!("new estate {}: taken by the box and ordered as {}: pay {} at its payment page (the Sites page)", o.name, run.reference, run.amount.in_words()))
}
/// **Why the slot refused** — each the operator reads whole.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NewRefusal {
/// The ask is malformed (a name, a product, an address).
Bad(String),
/// The name (or its domain) belongs to an estate that exists.
Taken(String),
/// This box cannot make it (no template, a customer's site, no site book).
NotHere(String),
}
impl std::fmt::Display for NewRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NewRefusal::Bad(w) | NewRefusal::Taken(w) | NewRefusal::NotHere(w) => f.write_str(w),
}
}
}
/// What the slot does with a name it accepted.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Prepared {
/// A declared site: ordered as it is.
Declared { domain: String },
/// A new site: this put first (copied from `template`), then the order.
Create { put: Box<SitePut>, template: String },
}
/// Pure: **why `name` cannot be an estate's name**, or `None` — a site label (lowercase letters,
/// digits, inner hyphens, at most 63), with letters or digits enough to be named at the cloud.
pub fn name_refusal(name: &str) -> Option<String> {
let ok = !name.is_empty()
&& name.len() <= 63
&& !name.starts_with('-')
&& !name.ends_with('-')
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !ok {
return Some(format!("REFUSED [new-estate-name] {name:?} is not an estate's name: lowercase letters, digits and inner hyphens, at most 63 (`test4-example-com`)"));
}
let company = super::site::company_of(name);
monetize_cloud::lay::company_name_refusal(&company).map(|why| format!("REFUSED [new-estate-name] {name:?} cannot be laid: as the cloud names an estate it is {company:?}, and {why}"))
}
/// Pure: **the zone a site's domain is in** — its last two labels (`test2.gunnar.rs` →
/// `gunnar.rs`); `None` for a name with fewer.
pub fn zone_of(domain: &str) -> Option<String> {
let labels: Vec<&str> = domain.trim().trim_end_matches('.').split('.').collect();
(labels.len() >= 2 && labels.iter().all(|l| !l.is_empty()))
.then(|| labels[labels.len() - 2..].join("."))
}
/// Pure: **a new estate's domain**, from its name in `zone` — the rule a declared site's label
/// follows (its domain with hyphens for dots): the zone's own label is the zone
/// (`gunnar-rs` → `gunnar.rs`), a name that ends in it is a host under it (`test4-gunnar-rs` →
/// `test4.gunnar.rs`, `my-test-gunnar-rs` → `my-test.gunnar.rs`), and any other name is a host
/// under the zone as it is (`test4` → `test4.gunnar.rs`).
pub fn domain_for(name: &str, zone: &str) -> String {
let own = zone.replace('.', "-");
if name == own {
return zone.to_string();
}
match name.strip_suffix(&format!("-{own}")) {
Some(host) if !host.is_empty() => format!("{host}.{zone}"),
_ => format!("{name}.{zone}"),
}
}
/// Pure: a declared site's orders lay from media of its own.
fn has_media(e: &EstateConfig) -> bool {
!e.orders.media.is_empty()
|| !e.orders.media_objects.is_empty()
|| !e.orders.templates.is_empty()
}
/// Pure: **the declared site a new estate of `product` is copied from**, or `None`: an operator
/// site of that product, with a domain and media of its own — a terminable one first (a site the
/// operator may end: what a new estate is), then the one billing through the product's own route
/// (`route`), then by label.
pub fn template_of(
declared: &[(String, EstateConfig)],
product: &str,
route: Option<&str>,
) -> Option<(String, EstateConfig)> {
let mut c: Vec<&(String, EstateConfig)> = declared
.iter()
.filter(|(_, e)| {
e.product == product
&& e.operator_site
&& !e.owner.trim().is_empty()
&& zone_of(&e.site).is_some()
&& has_media(e)
})
.collect();
c.sort_by_key(|(label, e)| {
(
!e.terminable,
route != Some(e.vendor.as_str()),
label.clone(),
)
});
c.first().map(|(l, e)| (l.clone(), e.clone()))
}
/// **The products a new estate can be made of here**: each that has a template.
pub fn products_offered(state: &AppState) -> Vec<String> {
let declared = state.registry.declared_estates();
let mut products: Vec<String> = declared
.iter()
.map(|(_, e)| e.product.clone())
.filter(|p| !p.is_empty())
.collect();
products.sort();
products.dedup();
products.retain(|p| template_of(&declared, p, state.registry.route_of(p).as_deref()).is_some());
products
}
/// Pure: **the site book's put for a new estate** copied from `template`.
pub fn put_for(
name: &str,
domain: &str,
template_label: &str,
template: &EstateConfig,
live: bool,
actor: &str,
) -> SitePut {
let o = &template.orders;
SitePut {
estate: name.to_string(),
create: true,
domain: domain.to_string(),
product: template.product.clone(),
first_admin: template.owner.trim().to_string(),
operator_site: true,
terminable: true,
vendor: template.vendor.clone(),
live,
terms: SiteTerms {
term_days: o.term_days.unwrap_or(0),
term_months: o.term_months.unwrap_or(0),
grace_hours: o.grace_hours.map(u32::from).unwrap_or(0),
margin_bp: o.margin_bp.unwrap_or(0),
},
media_from: template_label.to_string(),
actor: actor.trim().to_string(),
..Default::default()
}
}
/// **What the slot does with `name` of `product`**, decided before anything is kept or held:
/// a declared site is ordered as it is; a new name is put in the site book first. Refused by
/// name: a bad name or address, a product with no template, a declared site of another product
/// or a customer's, an estate that exists, a domain another site has.
pub fn prepare(
state: &AppState,
name: &str,
product: &str,
email: &str,
actor: &str,
) -> Result<Prepared, NewRefusal> {
let (name, product, email) = (name.trim(), product.trim(), email.trim());
if let Some(why) = name_refusal(name) {
return Err(NewRefusal::Bad(why));
}
if email.is_empty() || !email.contains('@') || email.chars().any(char::is_whitespace) {
return Err(NewRefusal::Bad(
"REFUSED [new-estate-email] an estate needs the address its \"ready\" mail goes to"
.into(),
));
}
let declared = state.registry.declared_estates();
if let Some((_, e)) = declared.iter().find(|(label, _)| label == name) {
if e.product != product {
return Err(NewRefusal::Bad(format!(
"REFUSED [new-estate-other-product] {name} is a declared {} site, not {product}",
e.product
)));
}
if !e.operator_site {
return Err(NewRefusal::NotHere(format!("REFUSED [new-estate-customer-site] {name} is a customer's site: only the operator's own sites are laid from the slot")));
}
return Ok(Prepared::Declared {
domain: e.site.trim().to_string(),
});
}
let offered = products_offered(state);
let Some((label, template)) = template_of(
&declared,
product,
state.registry.route_of(product).as_deref(),
) else {
return Err(NewRefusal::Bad(format!(
"REFUSED [new-estate-product] {product:?} has no declared site of its own here to copy (an operator site with a domain and media of its own); the slot makes {}",
if offered.is_empty() { "nothing on this box".to_string() } else { offered.join(", ") }
)));
};
let zone = zone_of(&template.site).unwrap_or_default();
let domain = domain_for(name, &zone);
if let Some((other, _)) = declared
.iter()
.find(|(_, e)| e.site.trim().eq_ignore_ascii_case(&domain))
{
return Err(NewRefusal::Taken(format!("REFUSED [new-estate-domain-taken] {domain} is already {other}'s domain: start {other} by its name, or pick another name")));
}
if let Some(line) = standing::overview(state)
.estates
.into_iter()
.find(|l| l.name == name)
{
return Err(NewRefusal::Taken(format!(
"REFUSED [new-estate-taken] {name} exists ({}): it is not laid over",
line.words
)));
}
if state.registry.linked().site_book.is_none() {
return Err(NewRefusal::NotHere(format!(
"REFUSED [new-estate-no-book] {}",
crate::site_book::NO_BOOK
)));
}
let live = state
.registry
.find_vendor(&template.vendor)
.and_then(|v| v.livemode())
== Some(true);
Ok(Prepared::Create {
put: Box::new(put_for(name, &domain, &label, &template, live, actor)),
template: label,
})
}
/// Whether the box has taken `name` as one of the operator's sites (its config watcher applied it).
pub fn taken(state: &AppState, name: &str) -> bool {
state.registry.operator_admin_of(name).is_some() && state.registry.site_of(name).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
fn site(
product: &str,
vendor: &str,
domain: &str,
terminable: bool,
media: bool,
) -> EstateConfig {
let mut e = EstateConfig {
product: product.into(),
vendor: vendor.into(),
owner: "ignalina".into(),
site: domain.into(),
operator_site: true,
terminable,
..Default::default()
};
if media {
e.orders
.media
.insert("dev".into(), "objsto/release-media".into());
}
e.orders.term_days = Some(1);
e.orders.grace_hours = Some(1);
e.orders.margin_bp = Some(2500);
e
}
/// ★ **The name is a site label the cloud can name** — refused by name, never trimmed into one.
#[test]
fn a_name_is_a_site_label_and_a_bad_one_is_refused_by_name() {
for ok in ["test4-gunnar-rs", "lab4", "a1", "my-test-gunnar-rs"] {
assert_eq!(name_refusal(ok), None, "{ok}");
}
// The cloud's own rule too: 16 letters and digits at most, `test<n>` its own.
for bad in [
"",
"Test4",
"-x",
"x-",
"a/b",
"a.b",
"a b",
&"x".repeat(64),
"test4",
"4lab",
"abcdefghijklmnopq-rs",
] {
let why = name_refusal(bad).unwrap_or_else(|| panic!("{bad:?} is taken"));
assert!(why.starts_with("REFUSED [new-estate-name]"), "{why}");
}
}
/// ★ **The domain derives from the name** in the template's zone — the rule a declared
/// site's label already follows (test2-gunnar-rs is test2.gunnar.rs).
#[test]
fn the_domain_is_the_name_in_the_templates_zone() {
assert_eq!(zone_of("test2.gunnar.rs").as_deref(), Some("gunnar.rs"));
assert_eq!(zone_of("holger.rs").as_deref(), Some("holger.rs"));
assert_eq!(zone_of("p1-home.gunnar.rs.").as_deref(), Some("gunnar.rs"));
assert_eq!(zone_of("localhost"), None);
assert_eq!(zone_of(""), None);
assert_eq!(
domain_for("test4-gunnar-rs", "gunnar.rs"),
"test4.gunnar.rs"
);
assert_eq!(
domain_for("test2-holger-rs", "holger.rs"),
"test2.holger.rs"
);
assert_eq!(
domain_for("my-test-gunnar-rs", "gunnar.rs"),
"my-test.gunnar.rs"
);
assert_eq!(domain_for("gunnar-rs", "gunnar.rs"), "gunnar.rs");
assert_eq!(domain_for("test4", "gunnar.rs"), "test4.gunnar.rs");
assert_eq!(
domain_for("test4-holger-rs", "gunnar.rs"),
"test4-holger-rs.gunnar.rs",
"a name in another zone is a host in this one, never that zone"
);
}
/// ★ **The template**: an operator site of the product with a domain and media of its own;
/// a terminable one first, then the product's own route, then by label. None: not offered.
#[test]
fn the_template_is_a_terminable_operator_site_of_the_product_with_media() {
let prod = vec![
(
"gunnar-rs".to_string(),
site("gunnar", "stripe/gunnar", "gunnar.rs", false, true),
),
(
"holger-rs".to_string(),
site("holger", "stripe/holger", "holger.rs", false, true),
),
(
"test2-gunnar-rs".to_string(),
site(
"gunnar",
"stripe/gunnar-test2",
"test2.gunnar.rs",
true,
true,
),
),
(
"test2-holger-rs".to_string(),
site(
"holger",
"stripe/gunnar-test2",
"test2.holger.rs",
true,
true,
),
),
];
assert_eq!(
template_of(&prod, "gunnar", Some("stripe/gunnar"))
.map(|t| t.0)
.as_deref(),
Some("test2-gunnar-rs"),
"the terminable test site, over the protected home"
);
assert_eq!(
template_of(&prod, "holger", Some("stripe/holger"))
.map(|t| t.0)
.as_deref(),
Some("test2-holger-rs")
);
assert_eq!(
template_of(&prod, "lake", None),
None,
"no site of the product"
);
// test2's box: two terminable Holger sites; the one on the product's own route wins.
let t2 = vec![
(
"holger-p1-check".to_string(),
site(
"holger",
"stripe/gunnar-test2",
"p1-check.holger.rs",
true,
true,
),
),
(
"holger-r34f".to_string(),
site(
"holger",
"stripe/holger-test2",
"r34f.holger.rs",
true,
true,
),
),
];
assert_eq!(
template_of(&t2, "holger", Some("stripe/holger-test2"))
.map(|t| t.0)
.as_deref(),
Some("holger-r34f")
);
// A site with no media of its own, a customer's site, and one with no domain are no template.
let mut customer = site("gunnar", "stripe/kunde", "kunde.se", true, true);
customer.operator_site = false;
let bare = vec![
(
"bare".to_string(),
site("gunnar", "stripe/x", "bare.gunnar.rs", true, false),
),
("kunde-se".to_string(), customer),
(
"nodomain".to_string(),
site("gunnar", "stripe/y", "", true, true),
),
];
assert_eq!(template_of(&bare, "gunnar", None), None);
}
/// ★ **The put copies the template** and makes the new site the operator's, terminable,
/// laid from the template's media.
#[test]
fn the_put_copies_the_templates_admin_account_terms_and_media() {
let t = site(
"gunnar",
"stripe/gunnar-test2",
"test2.gunnar.rs",
true,
true,
);
let p = put_for(
"test4-gunnar-rs",
"test4.gunnar.rs",
"test2-gunnar-rs",
&t,
true,
"console:op-1",
);
assert_eq!(
(
p.estate.as_str(),
p.domain.as_str(),
p.product.as_str(),
p.first_admin.as_str(),
p.vendor.as_str()
),
(
"test4-gunnar-rs",
"test4.gunnar.rs",
"gunnar",
"ignalina",
"stripe/gunnar-test2"
)
);
assert!(p.create && p.operator_site && p.terminable && p.live);
assert_eq!(
(
p.terms.term_days,
p.terms.term_months,
p.terms.grace_hours,
p.terms.margin_bp
),
(1, 0, 1, 2500)
);
assert_eq!(
(p.media_from.as_str(), p.actor.as_str()),
("test2-gunnar-rs", "console:op-1")
);
assert!(
p.vendor_secret_key.is_empty() && p.vendor_webhook_secret.is_empty(),
"no secret: the account is the template's, held"
);
}
}