crates/monetize-server/src/registry.rs
//! **The registry**: which products, vendors and clouds are linked into THIS
//! server, chosen by the config file. Everything else looks providers up here by
//! name — a `Purchase.Start` naming a vendor that is not here is `NotFound`, never
//! a silently substituted one.
//!
//! # The registration seam — the registry knows no vendor's name
//!
//! The open server must run for people sharing their servers for free, with the
//! paid vendors and clouds in a private repository. So
//! there is no `match name { "<vendor>" => … }` here. A server is constructed
//! with a [`Linked`] set — a list of `monetize_pay::factory::VendorFactory` and
//! `monetize_cloud::factory::CloudFactory` values — and `from_config_with`
//! looks every enabled `[vendors.<name>]` / `[clouds.<name>]` up in that list.
//! [`Linked::open`] is what this crate's own binary links: the impls behind its
//! cargo features (`invoice`, `sandbox`, and `server` always). The vetra build
//! is a different binary in the private repo that starts from `Linked::open()`
//! and registers the paid factories on top through each impl crate's
//! `register`; it never edits this file.
//!
//! What every cloud gets, whoever linked it: the spend ceiling, or the
//! `CannotSpend` guard when its factory says it cannot spend. That wrap is
//! not a factory's choice — see [`build_cloud`].
use std::path::Path;
use std::sync::Arc;
use monetize_cloud::factory::{CloudBuild, CloudFactory};
use monetize_cloud::cloud::{Boot, Booted, Catalog, Change, Changed, CloudManifest, Give, Listed, Listing, Priced, RoomLeft};
use monetize_cloud::{Cloud, CloudError, Price, Reclaimed, Zone};
use monetize_pay::factory::{Settings, SignupBuild, SignupFactory, VendorBuild, VendorFactory};
use monetize_pay::{Payment, Vendor};
use monetize_product::pulled::Pulled;
use monetize_product::{ProductBuild, ProductFactory, ProductFacts, Served};
use crate::config::Config;
use crate::hot::{HotCloud, HotVendor, Slots};
/// **The vendors and clouds THIS binary can build**, by name. What
/// `from_config_with` looks configured names up in, and what `--install`
/// reads the secret environment names from.
#[derive(Clone, Debug, Default)]
pub struct Linked {
/// **The product plugins THIS binary can build**, by `[products.<id>]`.
/// The open build registers NONE: every plugin — gunnar, holger, a
/// deployment's own — reaches the server by registration, exactly as a
/// paid vendor does, so this file names no product.
pub products: Vec<ProductFactory>,
pub vendors: Vec<VendorFactory>,
pub clouds: Vec<CloudFactory>,
/// **What turns a PAID SIGNUP into a customer** — at most one, because a
/// deployment creates its customers in exactly one way. `None` is the open
/// build's answer and means [`crate::signup::LoudSink`]: the seam runs, the
/// refusals are real, and nothing is created.
///
/// The vetra build registers the real one exactly as it registers a paid
/// vendor — `monetize_pay_impl::register(&mut l.vendors)` has a sibling
/// that fills this slot — so this file still names no private crate.
pub signup: Option<SignupFactory>,
/// **What turns the ledger into a tax return** — at most one, because a
/// deployment files in one jurisdiction. `None` is the open build's answer:
/// a `[tax]` that declares one ([`monetize::tax::Declared`]), else
/// [`monetize::PerCountry`] — each buyer country's totals, from the
/// country, rate and tax every payment row carries (the vendor's).
pub tax: Option<monetize::TaxFactory>,
/// **The DNS writers THIS binary can build**, by `[estate_orders] dns`.
/// The open build registers none (`file:<path>` is the mock's recording
/// file, built in); an account's writer (Cloudflare) is registered by the
/// private build, the way a paid cloud is.
pub dns: Vec<monetize_cloud::dns::DnsFactory>,
/// **The installers THIS binary can build**, by `[estate_orders] pairing`
/// (no operator machine in a sale, 2026-10-08): the shop pairs a sold
/// estate's three machines itself. The open build registers none — the
/// pairing links Skidbladnir's nordisk-pairing, which this workspace never
/// does — and the builds that link `monetize-shop-pairing` (the solo monetize
/// box, gunnar's front) register `shop-pairing`.
pub installers: Vec<InstallerFactory>,
/// **The estate guardians THIS binary links**, by product id (5.4): a product plugin
/// answers its own through `Product::guardian`; a product served as a WASM guest cannot,
/// so the build that links its native crate registers its guardian here.
pub guardians: Vec<GuardianLink>,
/// **Where this box keeps the sites the operator adds and edits in the application**
/// (`SiteBook`, 2026-10-14). The open build links none (its sites are its config file's);
/// the ONE box's `monetize-admin` links its own store.
pub site_book: Option<crate::site_book::SiteBookLink>,
}
/// **A product's estate guardian a build links** ([`Linked::guardian`]).
#[derive(Clone, Copy)]
pub struct GuardianLink {
pub product: &'static str,
pub guardian: &'static dyn monetize_product::guardian::EstateGuardian,
}
impl std::fmt::Debug for GuardianLink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "GuardianLink({})", self.product)
}
}
/// **An installer a build links**: a name and how it is built for one shop.
#[derive(Clone, Copy)]
pub struct InstallerFactory {
pub name: &'static str,
pub build: fn(&InstallerBuild<'_>) -> Result<std::sync::Arc<dyn crate::estate_order::Installer>, String>,
}
impl std::fmt::Debug for InstallerFactory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "InstallerFactory({})", self.name)
}
}
/// What a linked installer is built from: the shop's order config and its data root.
pub struct InstallerBuild<'a> {
pub orders: &'a crate::estate_order::EstateOrderConfig,
pub data_dir: &'a std::path::Path,
}
impl Linked {
/// **The open set** — every impl this crate's features compile in. The
/// `server` cloud is not behind a feature of this crate: it is in
/// `monetize-cloud`'s default, and a server with no cloud that can serve
/// its own box would be an odd open-source deployment.
pub fn open() -> Linked {
let mut l = Linked::default();
#[cfg(feature = "invoice")]
l.vendor(monetize_pay::invoice::FACTORY);
#[cfg(feature = "sandbox")]
l.vendor(monetize_pay::sandbox::FACTORY);
l.cloud(monetize_cloud::server::FACTORY);
#[cfg(feature = "sandbox")]
l.cloud(monetize_cloud::sandbox::FACTORY);
l
}
/// Register a product plugin, replacing one of the same name
/// (`monetize_product::factory::register`).
/// **The guest factories a config asks for**, registered after the linked
/// set: every `[products.<id>]` with a `guest = "<path>.wasm"` key gets the
/// guest host's factory under that id. Registration replaces, so a `guest =`
/// key takes an id over from a linked plugin of the same name — a deployment
/// moves one product at a time and the binary needs no change. The open
/// server, which links no product, meters a guest exactly this way.
pub fn with_guests_from(mut self, cfg: &Config) -> Linked {
for (id, p) in &cfg.products {
if p.guest.is_some() {
self.product(monetize_product_wasm::factory(monetize_product_wasm::leak_id(id)));
}
}
// **A cloud guest the same way**: `[clouds.<name>] guest = …` takes the
// name over from a linked cloud of that name, and the open server,
// which links no paid cloud, buys through a signed module.
for (name, c) in &cfg.clouds {
if c.settings.get("guest").is_some() {
self.cloud(monetize_cloud_wasm::factory(crate::hot::intern_name(name)));
}
}
self
}
/// Register a product plugin, replacing one of the same name; the estate guardian its
/// factory brings is linked beside it ([`Linked::guardian`]).
pub fn product(&mut self, f: ProductFactory) -> &mut Self {
if let Some(g) = f.guardian {
self.guardian(f.name, g);
}
monetize_product::factory::register(&mut self.products, f);
self
}
/// Register a vendor, replacing one of the same name (`factory::register`).
pub fn vendor(&mut self, f: VendorFactory) -> &mut Self {
monetize_pay::factory::register(&mut self.vendors, f);
self
}
/// Register a cloud, replacing one of the same name.
pub fn cloud(&mut self, f: CloudFactory) -> &mut Self {
monetize_cloud::factory::register(&mut self.clouds, f);
self
}
/// Register a DNS writer, replacing one of the same name.
pub fn dns_writer(&mut self, f: monetize_cloud::dns::DnsFactory) -> &mut Self {
monetize_cloud::dns::register(&mut self.dns, f);
self
}
/// Register an installer; a second one under the same name replaces the first.
pub fn installer(&mut self, f: InstallerFactory) -> &mut Self {
self.installers.retain(|g| g.name != f.name);
self.installers.push(f);
self
}
/// Register a product's estate guardian; a second one for the same product replaces the first.
pub fn guardian(&mut self, product: &'static str, guardian: &'static dyn monetize_product::guardian::EstateGuardian) -> &mut Self {
self.guardians.retain(|g| g.product != product);
self.guardians.push(GuardianLink { product, guardian });
self
}
/// The installer named `name`, if this build links it.
pub fn find_installer(&self, name: &str) -> Option<&InstallerFactory> {
self.installers.iter().find(|f| f.name == name)
}
/// Every installer this build links, by name.
pub fn installer_names(&self) -> Vec<&'static str> {
self.installers.iter().map(|f| f.name).collect()
}
/// The DNS writer named `name`, if this build links it.
pub fn find_dns(&self, name: &str) -> Option<&monetize_cloud::dns::DnsFactory> {
self.dns.iter().find(|f| f.name == name)
}
/// Every DNS writer this build links, by name.
pub fn dns_names(&self) -> Vec<&'static str> {
self.dns.iter().map(|f| f.name).collect()
}
/// **Register the signup sink, replacing whatever was there.** The slot is
/// singular and so is this: registering twice is a build that linked two
/// ways of creating a customer, and the last one wins — the same rule
/// `factory::register` applies to a name, stated where "both run" cannot be
/// read into it.
pub fn signup(&mut self, f: SignupFactory) -> &mut Self {
self.signup = Some(f);
self
}
/// **Register the jurisdiction's tax report, replacing whatever was there.**
/// One slot, last writer wins — the rule [`Linked::signup`] states, for the
/// same reason: two returns filed for one period is not a richer deployment,
/// it is a wrong one.
pub fn tax(&mut self, f: monetize::TaxFactory) -> &mut Self {
self.tax = Some(f);
self
}
/// The report this build will use, built from `[tax]`. No factory and no
/// declared jurisdiction is [`monetize::PerCountry`]: the totals per buyer
/// country, each payment's country, rate and tax the vendor's.
pub fn build_tax(&self, data_dir: &Path, settings: &serde_json::Value) -> Result<Arc<dyn monetize::TaxReport>, String> {
match &self.tax {
Some(f) => (f.build)(&monetize::tax::TaxBuild { name: f.name, data_dir, settings: Settings(settings) }),
// No implementation linked: the jurisdiction `[tax]` DECLARES
// (`jurisdiction`, `rate_percent`) fills monetize's own folds;
// declaring none (the ONE box, owner 2026-10-11: Stripe decides per
// country, each payment is a row) totals per buyer country.
None => match monetize::tax::Declared::from_settings(settings)? {
Some(d) => Ok(Arc::new(d)),
None => Ok(Arc::new(monetize::PerCountry::default())),
},
}
}
/// The sink this build will use, built from `[signups.<product>]`. `None` from the
/// factory's own refusal is an error naming the block; no factory at all is
/// [`crate::signup::LoudSink`], the open build's working default.
pub fn build_signup_sink(&self, data_dir: &Path, settings: &serde_json::Value) -> Result<crate::signup::Sink, String> {
match &self.signup {
Some(f) => (f.build)(&SignupBuild { name: f.name, data_dir, settings: Settings(settings) }),
None => Ok(Arc::new(crate::signup::NoSink)),
}
}
/// Every environment name the SIGNUP SINK reads, for `--install`'s 0600 env
/// file. Empty when no sink is linked.
pub fn signup_config_envs(&self) -> Vec<&'static str> {
self.signup.iter().flat_map(|f| f.config_envs.iter().copied()).collect()
}
pub fn find_product(&self, name: &str) -> Option<&ProductFactory> {
monetize_product::factory::find(&self.products, name)
}
pub fn product_names(&self) -> Vec<&'static str> {
self.products.iter().map(|f| f.name).collect()
}
pub fn find_vendor(&self, name: &str) -> Option<&VendorFactory> {
self.vendors.iter().find(|f| f.name == name)
}
pub fn find_cloud(&self, name: &str) -> Option<&CloudFactory> {
self.clouds.iter().find(|f| f.name == name)
}
pub fn vendor_names(&self) -> Vec<&'static str> {
self.vendors.iter().map(|f| f.name).collect()
}
pub fn cloud_names(&self) -> Vec<&'static str> {
self.clouds.iter().map(|f| f.name).collect()
}
/// Every environment name a vendor LINKED INTO THIS BINARY reads,
/// assembled from each factory's own `config_envs`. `--install` forwards
/// these from the operator's environment into the 0600 `EnvironmentFile=`,
/// so a vendor that is linked cannot be forgotten (its own list is the
/// source) and one that is NOT linked cannot put a name into the unit —
/// the same rule `build_vendor` enforces for `enabled`.
///
/// A name with the `{INSTANCE}` placeholder is per vendor INSTANCE and is
/// not here: [`Linked::vendor_instance_envs`] expands it for the instances
/// a config holds.
pub fn vendor_config_envs(&self) -> Vec<&'static str> {
self.vendors.iter().flat_map(|f| f.config_envs.iter().copied()).filter(|n| !n.contains(monetize_pay::instance::INSTANCE_PLACEHOLDER)).collect()
}
/// The per-instance names (`MONETIZE_STRIPE_{INSTANCE}_SECRET_KEY`), once
/// for every ENABLED `[vendors.<kind>.<instance>]` in `cfg` whose kind is
/// linked: `MONETIZE_STRIPE_GUNNAR_SECRET_KEY`, `MONETIZE_STRIPE_HOLGER_…`.
pub fn vendor_instance_envs(&self, cfg: &Config) -> Vec<String> {
let mut out = Vec::new();
for (id, _) in cfg.vendors.iter().filter(|(_, v)| v.enabled) {
let Ok((kind, instance)) = monetize_pay::instance::split(id) else { continue };
let Some(f) = self.find_vendor(kind) else { continue };
for t in f.config_envs.iter().filter(|n| n.contains(monetize_pay::instance::INSTANCE_PLACEHOLDER)) {
let name = monetize_pay::instance::expand_env(t, instance);
if !out.contains(&name) {
out.push(name);
}
}
}
out
}
/// Every environment name a CLOUD linked into this binary reads. Same rule
/// and same reason: the credential lives in the operator's environment, not
/// in the 0644 toml, so the install has to carry it into the 0600 env file
/// or the service starts with no way to reach the provider.
pub fn cloud_config_envs(&self) -> Vec<&'static str> {
self.clouds.iter().flat_map(|f| f.config_envs.iter().copied()).collect()
}
}
/// **How many products may be ADDED while the server runs** — guests whose
/// `[products.<id>]` block appeared after the start. Preallocated, append-only
/// and never emptied, which is what lets [`Registry::products`] hand out plain
/// `monetize_product::Served<'_>` borrows while a writer adds one: a slot, once filled, is
/// never written again. A replacement does not take a slot (it swaps inside
/// its [`HotGuest`]); only a new id does. A deployment that meets the bound
/// gets a named refusal and restarts, which re-counts from zero.
pub const LIVE_PRODUCT_SLOTS: usize = 64;
/// **One product as the host holds it** (A32-13): the product's estate verbs, its facts READ
/// ONCE (asked again only while they are unread, and when its guest is replaced live), and the
/// guardian that answers for it. Every reader takes it as a [`Served`] handle.
pub struct HotProduct {
product: Arc<Pulled>,
/// **The block's id, where it serves a plugin of another id** (G15: `[products.gunnar-test2]
/// plugin = "gunnar"`): what the ledger, the prices, the vendor route and the signing key key
/// on. `None`: the plugin's own id.
served_as: Option<&'static str>,
facts: std::sync::RwLock<&'static ProductFacts>,
/// The guardian this build links for the product ([`Linked::guardian`]), which answers first.
linked_guardian: Option<&'static dyn monetize_product::guardian::EstateGuardian>,
/// The guest that serves it, whose own guardian answers when its facts declare one.
guest: Option<Arc<monetize_product_wasm::HotGuest>>,
}
impl HotProduct {
/// `product`, its facts read now.
pub fn new(product: Arc<Pulled>) -> HotProduct {
Self::served_as(product, None)
}
/// `product`, its facts read now, served under `id` when one is named ([`HotProduct::served_as`]).
pub fn served_as(product: Arc<Pulled>, id: Option<&'static str>) -> HotProduct {
let facts = std::sync::RwLock::new(Self::read(&product, id));
HotProduct { product, served_as: id, facts, linked_guardian: None, guest: None }
}
/// The product's facts, under the block's id when it serves another plugin's.
fn read(product: &Pulled, id: Option<&'static str>) -> &'static ProductFacts {
let f = product.facts();
match id {
Some(id) => ProductFacts { id, ..f }.leak(),
None => f.leak(),
}
}
fn guarded_by(mut self, linked: Option<&'static dyn monetize_product::guardian::EstateGuardian>, guest: Option<Arc<monetize_product_wasm::HotGuest>>) -> HotProduct {
self.linked_guardian = linked;
self.guest = guest;
self
}
/// The facts held; unread ones are asked again (the guest could not answer at its load).
pub fn facts(&self) -> &'static ProductFacts {
let held = *self.facts.read().unwrap_or_else(|e| e.into_inner());
if held.unread.is_none() {
return held;
}
let now = Self::read(&self.product, self.served_as);
if now.unread.is_some() {
return held;
}
*self.facts.write().unwrap_or_else(|e| e.into_inner()) = now;
now
}
/// Read the facts again (its guest was replaced live).
fn reread(&self) {
*self.facts.write().unwrap_or_else(|e| e.into_inner()) = Self::read(&self.product, self.served_as);
}
/// The product's estate verbs, shared.
pub fn handle(&self) -> &Arc<Pulled> {
&self.product
}
/// **The guardian that answers for this product**: the one the build links, else the serving
/// guest's own when its facts declare one.
pub fn guardian(&self) -> Option<&dyn monetize_product::guardian::EstateGuardian> {
if let Some(g) = self.linked_guardian {
return Some(g);
}
if !self.facts().guardian {
return None;
}
self.guest.as_deref().and_then(|g| g.guardian())
}
/// This product as every reader takes it.
pub fn served(&self) -> Served<'_> {
Served::new(self.facts(), self.product.as_ref()).with_guardian(self.guardian())
}
}
/// The append-only slots [`LIVE_PRODUCT_SLOTS`] describes.
struct LiveSlots(Box<[std::sync::OnceLock<HotProduct>]>);
impl Default for LiveSlots {
fn default() -> Self {
LiveSlots((0..LIVE_PRODUCT_SLOTS).map(|_| std::sync::OnceLock::new()).collect())
}
}
impl LiveSlots {
fn iter(&self) -> impl Iterator<Item = &HotProduct> {
self.0.iter().map_while(|s| s.get())
}
/// Fill the first empty slot. Callers hold [`Registry::guests`]'s lock, so
/// there is one writer and the first empty slot is really empty.
fn push(&self, p: HotProduct) -> Result<(), HotProduct> {
match self.0.iter().find(|s| s.get().is_none()) {
Some(slot) => slot.set(p),
None => Err(p),
}
}
}
/// **What a live guest reload did with one `[products.<id>]` block** — one
/// line of the operator's log. A refusal names its clause and keeps whatever
/// was serving.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GuestChange {
/// A new product id, served from now on.
Added { id: String, digest: String, signer: Option<String> },
/// The serving guest was replaced; calls in flight finished on the old one.
Replaced(monetize_product_wasm::Replaced),
/// Refused by name: the serving guest (if any) is untouched.
Refused { id: String, clause: &'static str, why: String },
}
impl std::fmt::Display for GuestChange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GuestChange::Added { id, digest, signer } => {
write!(f, "guest ADDED [products.{id}] blake3 {digest}{}", signer.as_deref().map(|s| format!(", signed by {s}")).unwrap_or_default())
}
GuestChange::Replaced(r) => write!(
f,
"guest REPLACED [products.{}] blake3 {} → {}{}; calls in flight finish on the old one",
r.id,
r.from_digest,
r.to_digest,
r.signer.as_deref().map(|s| format!(", signed by {s}")).unwrap_or_default()
),
GuestChange::Refused { id, clause, why } => write!(f, "guest REFUSED [{clause}] [products.{id}]: {why} — whatever served before still serves"),
}
}
}
#[derive(Default)]
pub struct Registry {
/// Shared, not boxed: the `server` cloud holds the product it measures
/// through, so a product handle has two owners in a deployment on its own iron.
products: Vec<HotProduct>,
/// Products ADDED while the server runs ([`Registry::apply_guests`]).
live: LiveSlots,
/// **Every product a guest serves**, by id — at start and added live — so a
/// changed block can be swapped into the one it names. The lock also
/// serialises [`Registry::apply_guests`]: one writer at a time.
guests: std::sync::Mutex<std::collections::BTreeMap<String, Arc<monetize_product_wasm::HotGuest>>>,
/// **What the start refused** and skipped, by name: a guest that did not
/// pass its gates, or a product this deployment may not serve unpriced.
/// One bad block never stops the server (it used to: the front's
/// refused plugin stopped its whole monetize at the next boot).
start_refused: Vec<GuestChange>,
/// The vendor guests the start refused and skipped, by name — the same
/// rule: one bad vendor block never stops the server.
vendor_start_refused: Vec<ProviderChange>,
/// Every vendor and every cloud in a slot that can be swapped, retired or
/// added while the server runs ([`crate::hot`]).
vendors: Slots<HotVendor>,
clouds: Slots<HotCloud>,
default_cloud: std::sync::RwLock<Option<String>>,
/// The factories this server was built with, kept so a block changed
/// live is rebuilt through the same seam as at start.
linked: Linked,
/// Serialises [`Registry::apply_providers`]: one writer at a time.
providers: std::sync::Mutex<()>,
/// **Every vendor a guest serves**, by name: a changed guest block is
/// replaced INSIDE it (`HotVendor::replace` — the host's memory for the
/// vendor shared with the build it replaces), not rebuilt from nothing.
vendor_guests: std::sync::Mutex<std::collections::BTreeMap<String, Arc<monetize_product_wasm::HotVendor>>>,
/// **The `server` cloud's grow slot** (g-dk-market, round 5.3): whether a
/// grant past its walls is queued, filled once the state exists
/// ([`crate::grow::arm`]). Shared with every `server` this
/// registry builds, at start and live.
pub grow_reach: monetize_cloud::server::GrowReach,
/// **product → vendor instance** (`[products.<id>] vendor`): the one
/// account a product's money goes to. A product with no route takes no
/// money — there is no "the only vendor" fallback (5.4, G3).
routes: std::sync::RwLock<std::collections::BTreeMap<String, String>>,
/// **estate → its site's route** (`[estates.<estate>]`): see [`Registry::route_for`].
estates: std::sync::RwLock<std::collections::BTreeMap<String, crate::config::EstateConfig>>,
/// **product → its own estate** (`[products.<id>] instance`).
homes: std::sync::RwLock<std::collections::BTreeMap<String, String>>,
/// **The tickets spent at each site**, by `<estate>\u{0}<nonce>` (ST-3): a site's ticket
/// checked under its own key ([`crate::site_ticket::may_act`]) is honoured once. In
/// memory, the lifetime the product's own ring has.
site_nonces: monetize_embed::SeenNonces,
/// **Each site's ticket key as its ESTATE delivered it** (ST-3, 2026-10-11: the owner's
/// install notice carries the elected primary's `ticket_pubkey`; [`crate::fleet::take_joined`]
/// hands it here). Kept apart from `[estates]`, which a config reload replaces: an
/// operator's `[estates.<e>] appliance_key` is a PIN over it ([`Registry::appliance_key_of`]).
delivered_keys: std::sync::RwLock<std::collections::BTreeMap<String, String>>,
/// The (estate, key) refusals already said, so a refused key is said once, not every tick.
refused_keys: std::sync::RwLock<std::collections::BTreeSet<String>>,
/// **A LINKED product's own ticket key** (A32-13: a ticket is the host's, never the
/// product's): `(key, pinned)`, the block's `appliance_key` (pinned: the operator's word
/// wins) or the key the product's HOME estate delivered ([`Registry::deliver_product_key`]).
/// A guest-served product's key is its block's ([`monetize_product_wasm::GuestProduct::appliance_key`]).
product_keys: std::sync::RwLock<std::collections::BTreeMap<String, (String, bool)>>,
/// The tickets spent for a LINKED product, by `<product>\u{0}<nonce>` (a guest-served
/// product's are spent in its host memory).
product_nonces: monetize_embed::SeenNonces,
/// How many presented tickets the host checked, per product ([`Registry::tickets_checked`]).
tickets_checked: std::sync::Mutex<std::collections::BTreeMap<String, u64>>,
/// **The guardian every pulled product reads and queues on** (5.4, G1): filled when the
/// server opens its fleet store; shared with [`crate::fleet::FleetState`].
pull_slot: monetize_guardian::pulled::GuardianSlot,
}
/// The guardian this build links for `[products.<id>]` (by its id, else by the plugin it names).
fn linked_guardian(linked: &Linked, id: &str, p: &crate::config::ProductConfig) -> Option<&'static dyn monetize_product::guardian::EstateGuardian> {
let plugin = p.plugin.as_deref().unwrap_or(id);
linked.guardians.iter().find(|g| g.product == id).or_else(|| linked.guardians.iter().find(|g| g.product == plugin)).map(|g| g.guardian)
}
/// `[products.<id>] instance` wraps the plugin: monetize never dials that estate.
fn pulled(id: &str, p: &crate::config::ProductConfig, facts: monetize_product::pulled::FactsSource, slot: &monetize_guardian::pulled::GuardianSlot) -> Arc<Pulled> {
match p.instance.as_deref() {
Some(instance) => {
// The box that serves the tenants is the product's primary (its lifecycle's first
// data role: `main` for Gunnar and Holger, the Lake's own), asked at each read
// because a guest's lifecycle is taken while the server runs.
let kind = p.plugin.clone().unwrap_or_else(|| id.to_string());
let primary: monetize_guardian::pulled::Primary = Arc::new(move || crate::estate_order::lifecycle::find(&kind).map(|l| l.primary_role()).unwrap_or_else(|| monetize_product::guardian::role::MAIN.to_string()));
Arc::new(monetize_guardian::pulled::pulled(facts, instance, slot.clone(), monetize_guardian::pulled::system_clock(), primary))
}
None => Arc::new(Pulled::new(facts, Arc::new(monetize_product::pulled::Unplaced(monetize_product_wasm::leak_id(id))))),
}
}
/// The serving guest's facts, asked each time (a guest replaced live answers anew).
fn guest_facts(hot: &Arc<monetize_product_wasm::HotGuest>) -> monetize_product::pulled::FactsSource {
let hot = Arc::clone(hot);
Arc::new(move || hot.facts())
}
/// **What a registry built in code takes as a product**: a served product, or a test double
/// served from its own main.
pub trait IntoServed {
fn into_served(self) -> Arc<Pulled>;
}
impl IntoServed for Pulled {
fn into_served(self) -> Arc<Pulled> {
Arc::new(self)
}
}
impl IntoServed for Arc<Pulled> {
fn into_served(self) -> Arc<Pulled> {
self
}
}
#[cfg(any(test, feature = "testing"))]
impl IntoServed for monetize::testing::MockProduct {
fn into_served(self) -> Arc<Pulled> {
Arc::new(self.pulled().clone())
}
}
#[cfg(any(test, feature = "testing"))]
impl IntoServed for Arc<monetize::testing::MockProduct> {
fn into_served(self) -> Arc<Pulled> {
Arc::new(self.pulled().clone())
}
}
/// **Why an order cannot be routed to a vendor instance** ([`Registry::route_for_coded`],
/// RA3-3): the name a caller decides by; the words are for people.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteRefusal {
/// `[estates.<e>]` sells another product (`[estate-other-product]`).
OtherProduct,
/// The product is sold per site and the order names no estate (`[estate-unnamed]`).
Unnamed,
/// The estate is not declared (`[estate-undeclared]`).
Undeclared,
/// The product names no vendor route of its own.
Unrouted,
}
impl Registry {
pub fn new() -> Self {
Self::default()
}
/// The slot the pulled products' guardian is set in (shared with the fleet state).
pub fn pull_slot(&self) -> monetize_guardian::pulled::GuardianSlot {
self.pull_slot.clone()
}
pub fn product(mut self, p: impl IntoServed) -> Self {
self.products.push(HotProduct::new(p.into_served()));
self
}
/// `p` served under the block id `id` (G15: a plugin of another id).
pub fn product_as(mut self, p: impl IntoServed, id: &'static str) -> Self {
self.products.push(HotProduct::served_as(p.into_served(), Some(id)));
self
}
/// The served product `p`, shared with the caller.
pub fn served_product(mut self, p: Arc<Pulled>) -> Self {
self.products.push(HotProduct::new(p));
self
}
/// The same as [`Self::product`]: `p` is a handle the caller keeps.
pub fn shared_product(self, p: impl IntoServed) -> Self {
self.product(p)
}
/// **`p`'s facts served as production serves every product** (A32-13 step 2): under
/// `Pulled`, its reads answered from the report of estate `instance`'s main and its changes
/// queued as signed orders the main pulls. In a test,
/// [`crate::testing::estate::FakeEstate`] is that main.
pub fn pulled_product(mut self, p: impl IntoServed, instance: &str) -> Self {
let facts = p.into_served();
let served = Arc::new(monetize_guardian::pulled::pulled(Arc::new(move || facts.facts()), instance, self.pull_slot.clone(), monetize_guardian::pulled::system_clock(), monetize_guardian::pulled::main_primary()));
self.products.push(HotProduct::new(served));
self
}
pub fn vendor(self, v: impl Payment + 'static) -> Self {
self.boxed_vendor(Box::new(v))
}
pub fn cloud(self, c: impl Cloud + 'static) -> Self {
self.boxed_cloud(Box::new(c))
}
/// A registry built in CODE with more than [`crate::hot::PROVIDER_SLOTS`]
/// vendors is a programming error and panics; a CONFIG that names too many
/// is refused by [`Registry::from_config`] instead.
pub fn boxed_vendor(self, v: Box<dyn Payment>) -> Self {
self.sourced_vendor(v, String::new()).unwrap_or_else(|e| panic!("{e}"))
}
/// As [`Registry::boxed_vendor`], for a cloud.
pub fn boxed_cloud(self, c: Box<dyn Cloud>) -> Self {
self.sourced_cloud(c, String::new()).unwrap_or_else(|e| panic!("{e}"))
}
/// A vendor built from a config block whose fingerprint is `source` — what
/// a live reload compares against. A provider handed in by code has an
/// empty source and is never retired by a config change. Past the slots:
/// an error naming them, never a panic (stinky monetize-server #23).
fn sourced_vendor(self, v: Box<dyn Payment>, source: String) -> anyhow::Result<Self> {
if self.vendors.push(Arc::new(HotVendor::serving(v, source))).is_err() {
anyhow::bail!("more than {} vendors in one registry; this server holds at most {0} [vendors.…] blocks", crate::hot::PROVIDER_SLOTS);
}
Ok(self)
}
/// A cloud built in CODE inside its spend cap: the slot holds the cap's shared ledger,
/// so the console, the lay gate and the reconcile read it as they read a configured one.
pub fn cloud_capped<C: Cloud + 'static>(self, c: monetize_cloud::cap::SpendCap<C>) -> Self {
let cap = c.cap();
self.sourced_cloud_in(Box::new(c), Some(cap), String::new()).unwrap_or_else(|e| panic!("{e}"))
}
fn sourced_cloud(self, c: Box<dyn Cloud>, source: String) -> anyhow::Result<Self> {
self.sourced_cloud_in(c, None, source)
}
fn sourced_cloud_in(self, c: Box<dyn Cloud>, cap: Option<Arc<monetize_cloud::cap::Cap>>, source: String) -> anyhow::Result<Self> {
if self.clouds.push(Arc::new(HotCloud::serving(c, cap, source))).is_err() {
anyhow::bail!("more than {} clouds in one registry; this server holds at most {0} [clouds.…] blocks", crate::hot::PROVIDER_SLOTS);
}
Ok(self)
}
/// Link `v` and route `product`'s money through it — a code-built
/// registry's `[vendors.…]` block plus `[products.<id>] vendor` in one.
pub fn sells(self, product: &str, v: impl Payment + 'static) -> Self {
let name = v.manifest().vendor;
self.vendor(v).route(product, &name)
}
/// Route `product`'s money through the vendor instance `vendor`.
pub fn route(mut self, product: &str, vendor: &str) -> Self {
self.routes.get_mut().unwrap_or_else(|e| e.into_inner()).insert(product.to_owned(), vendor.to_owned());
self
}
/// What this build links (the DNS writers a site's zone may name, built again live by
/// [`crate::estate_order::EstateOrders::follow_site_dns`]).
pub fn linked(&self) -> &Linked {
&self.linked
}
/// Hold `linked` as what this build links (a test's book, installers, guardians).
pub fn with_linked(mut self, linked: Linked) -> Self {
self.linked = linked;
self
}
/// Take the config's `[products.<id>] vendor` routes, replacing the ones held.
pub fn set_routes(&self, cfg: &Config) {
*self.routes.write().unwrap_or_else(|e| e.into_inner()) = cfg.vendor_routes();
*self.estates.write().unwrap_or_else(|e| e.into_inner()) = cfg.estates.clone();
*self.homes.write().unwrap_or_else(|e| e.into_inner()) = cfg.products.iter().filter_map(|(id, p)| p.instance.clone().map(|i| (id.clone(), i))).collect();
}
/// The vendor instance `product` bills through, if it names one.
pub fn route_of(&self, product: &str) -> Option<String> {
self.routes.read().unwrap_or_else(|e| e.into_inner()).get(product).cloned()
}
/// **The vendor instance an order of `product` from `estate` bills through** (ONE
/// monetize, several sites of one product). A product with `[estates]` declared routes
/// ONLY per estate: no estate, an estate not declared, or one declared for another
/// product is refused by name — never another site's account. A product with none
/// declared routes by its own `[products.<id>] vendor`, as before.
/// **Each site's own vendor instance** (`[estates.<label>] vendor`), by label.
pub fn estate_vendors(&self) -> std::collections::BTreeMap<String, String> {
self.estates.read().unwrap_or_else(|e| e.into_inner()).iter().map(|(id, e)| (id.clone(), e.vendor.clone())).collect()
}
pub fn route_for(&self, product: &str, estate: Option<&str>) -> Result<String, String> {
self.route_for_coded(product, estate).map_err(|(_, why)| why)
}
/// [`Self::route_for`], its refusal named by a [`RouteRefusal`] beside its words (RA3-3:
/// a caller decides by the name, never by matching the words).
pub fn route_for_coded(&self, product: &str, estate: Option<&str>) -> Result<String, (RouteRefusal, String)> {
let estates = self.estates.read().unwrap_or_else(|e| e.into_inner());
if let Some(e) = estate.and_then(|id| estates.get(id)).filter(|e| e.product != product) {
return Err((RouteRefusal::OtherProduct, format!("REFUSED [estate-other-product] [estates.{}] sells {:?}, not {product}", estate.unwrap_or_default(), e.product)));
}
if !estates.values().any(|e| e.product == product) {
drop(estates);
return self.route_of(product).ok_or_else(|| (RouteRefusal::Unrouted, self.route_said(product)));
}
let Some(estate) = estate else {
return Err((
RouteRefusal::Unnamed,
format!(
"REFUSED [estate-unnamed] {product} is sold for several sites here ([estates]: {}) and this order says by nothing which estate it is for — it is never billed through another site's account",
estates.iter().filter(|(_, e)| e.product == product).map(|(id, _)| id.as_str()).collect::<Vec<_>>().join(", ")
),
));
};
match estates.get(estate) {
Some(e) if e.product == product => Ok(e.vendor.clone()),
Some(e) => Err((RouteRefusal::OtherProduct, format!("REFUSED [estate-other-product] [estates.{estate}] sells {:?}, not {product}", e.product))),
None => Err((RouteRefusal::Undeclared, format!("REFUSED [estate-undeclared] {estate} is not a site this monetize sells {product} for (no [estates.{estate}]): nothing is billed for it"))),
}
}
/// **The estate a tenant of `product` belongs to**: `@<estate>/<ns>`'s estate, else the
/// product's own (`[products.<id>] instance`); `None` when the product names none.
pub fn estate_of_tenant(&self, product: &str, tenant: &str) -> Option<String> {
match monetize_product::TenantId(tenant.to_owned()).estate() {
Some((estate, _)) => Some(estate.to_owned()),
None => self.home_of(product),
}
}
/// **The estate whose own account `vendor` is, for `product`** (`[estates.<estate>]
/// vendor`): one account per site, so the instance a signup's money went through names
/// the site it began on. `None`: no declared site of `product` bills through it.
pub fn estate_of_vendor(&self, product: &str, vendor: &str) -> Option<String> {
let estates = self.estates.read().unwrap_or_else(|e| e.into_inner());
estates.iter().find(|(_, e)| e.product == product && e.vendor == vendor).map(|(id, _)| id.clone())
}
/// **The calling site's own instance of the vendor KIND `kind`** (`[estates.<estate>]
/// vendor` = `stripe/gunnar-test2` for `stripe`): a console names the kind, the rows the
/// instance's webhook keeps name the instance. `None`: no such estate, or its instance is
/// of another kind — another site's instance is never answered.
pub fn instance_of_kind(&self, estate: &str, kind: &str) -> Option<String> {
let estates = self.estates.read().unwrap_or_else(|e| e.into_inner());
let e = estates.get(estate)?;
let (k, _) = e.vendor.split_once('/')?;
(k == kind).then(|| e.vendor.clone())
}
/// **`estate`'s own main's ticket key**: the operator's PIN (`[estates.<estate>]
/// appliance_key`) when set, else the key the estate itself delivered
/// ([`Registry::deliver_appliance_key`]) — only for an estate declared here.
pub fn appliance_key_of(&self, estate: &str) -> Option<String> {
let estates = self.estates.read().unwrap_or_else(|e| e.into_inner());
let declared = estates.get(estate)?;
let pin = declared.appliance_key.trim();
if !pin.is_empty() {
return Some(pin.to_owned());
}
self.delivered_keys.read().unwrap_or_else(|e| e.into_inner()).get(estate).cloned()
}
/// **A site's ticket key, as its estate delivered it** (ST-3): `product`'s estate
/// `estate` names `key` (64 hex) through its enrolled owner's notice. `Ok(Some(line))`:
/// taken (new or changed); `Ok(None)`: nothing new, or a refusal already said; `Err`: refused
/// by name, once — an estate not declared here (or declared for another product), a key that
/// is no ed25519 key, or one the operator's pin contradicts (the pin wins).
pub fn deliver_appliance_key(&self, product: &str, estate: &str, key: &str) -> Result<Option<String>, String> {
let key = key.trim().to_ascii_lowercase();
let refuse = |why: String| -> Result<Option<String>, String> {
if self.refused_keys.write().unwrap_or_else(|e| e.into_inner()).insert(format!("{estate}\u{0}{key}\u{0}{why}")) {
Err(why)
} else {
Ok(None)
}
};
if crate::site_ticket::key_of(&key).is_none() {
return refuse(format!("REFUSED [site-key-shape] {estate} delivered a ticket key that is not an ed25519 public key (64 hex): nothing taken"));
}
let pin = {
let estates = self.estates.read().unwrap_or_else(|e| e.into_inner());
match estates.get(estate) {
None => return refuse(format!("REFUSED [site-key-undeclared] {estate} delivered its ticket key, and no [estates.{estate}] is declared here: a site this monetize does not sell for checks no ticket")),
Some(e) if e.product != product => return refuse(format!("REFUSED [site-key-other-product] {estate} delivered a {product} ticket key; [estates.{estate}] sells {:?}", e.product)),
Some(e) => e.appliance_key.trim().to_ascii_lowercase(),
}
};
if !pin.is_empty() && pin != key {
return refuse(format!("REFUSED [site-key-pinned] {estate} delivered a ticket key other than the operator's pin ([estates.{estate}] appliance_key): the pin is kept, the delivered key is not taken"));
}
let mut held = self.delivered_keys.write().unwrap_or_else(|e| e.into_inner());
if held.get(estate) == Some(&key) {
return Ok(None);
}
held.insert(estate.to_owned(), key.clone());
Ok(Some(format!("{estate}'s appliance key is the one its estate delivered ({}…){}", &key[..12], if pin.is_empty() { "" } else { ", as pinned" })))
}
/// `estate`'s own order terms (`[estates.<estate>.orders]`), when declared.
pub fn orders_of(&self, estate: &str) -> Option<crate::config::SiteOrders> {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).map(|e| e.orders.clone())
}
/// The product `estate` sells (`[estates.<estate>] product`), when declared.
pub fn product_of_estate(&self, estate: &str) -> Option<String> {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).map(|e| e.product.clone()).filter(|p| !p.is_empty())
}
/// Whether `product` is sold for declared sites here (`[estates]` naming it).
pub fn sold_per_site(&self, product: &str) -> bool {
self.estates.read().unwrap_or_else(|e| e.into_inner()).values().any(|e| e.product == product)
}
/// **The sites `product` is sold as** (`[estates.<estate>]` naming it): `(estate, site)`,
/// in estate order; the site is the estate's host (empty when it names none).
pub fn sites_of(&self, product: &str) -> Vec<(String, String)> {
self.estates.read().unwrap_or_else(|e| e.into_inner()).iter().filter(|(_, e)| e.product == product).map(|(id, e)| (id.clone(), e.site.trim().to_owned())).collect()
}
/// **Spend `nonce` at `estate`**: `true` the first time, `false` ever after.
pub fn spend_site_nonce(&self, estate: &str, nonce: &str) -> bool {
self.site_nonces.admit(&format!("{estate}\u{0}{nonce}"))
}
/// **`product` checks its buyers' tickets under `hex`**, as its block's `appliance_key`
/// does: a code-built registry's (a test's) `[products.<id>] appliance_key`. Pinned.
pub fn ticket_key(self, product: &str, hex: &str) -> Self {
self.product_keys.write().unwrap_or_else(|e| e.into_inner()).insert(product.to_owned(), (hex.trim().to_ascii_lowercase(), true));
self
}
/// **The key `product`'s own tickets are checked under** (no site key decides them): a
/// guest's block key; a linked product's block key, else the key its home estate delivered.
pub fn product_ticket_key(&self, product: &str) -> Option<String> {
if let Some(guest) = self.guest(product) {
return guest.appliance_key().map(str::to_owned);
}
self.product_keys.read().unwrap_or_else(|e| e.into_inner()).get(product).map(|(k, _)| k.clone())
}
/// **The key `product`'s HOME estate delivered** (its owner's install notice, ST-3), taken
/// for the product's own tickets, so no operator copies it into `[products.<id>]
/// appliance_key`. `Ok(line)`: taken. Refused by name: a guest-served product (its block's
/// key decides, as the guest's own host did), a key that is no ed25519 key, or a product
/// whose block pins one (the operator's word wins).
pub fn deliver_product_key(&self, product: &str, hex: &str) -> Result<String, String> {
if self.guest(product).is_some() {
return Err(format!("{product} takes no delivered appliance key"));
}
let key = hex.trim().to_ascii_lowercase();
if crate::site_ticket::key_of(&key).is_none() {
return Err(format!("an appliance key is 64 hex characters of an ed25519 public key, not {} characters", key.len()));
}
let mut keys = self.product_keys.write().unwrap_or_else(|e| e.into_inner());
if keys.get(product).is_some_and(|(_, pinned)| *pinned) {
return Err(format!("[products.{product}] appliance_key pins its ticket key: the delivered key is not taken"));
}
keys.insert(product.to_owned(), (key, false));
Ok(format!("{product} checks its buyers' tickets under the key its main delivered"))
}
/// **Spend `nonce` for the linked `product`**: `true` the first time, `false` ever after.
pub fn spend_product_nonce(&self, product: &str, nonce: &str) -> bool {
self.product_nonces.admit(&format!("{product}\u{0}{nonce}"))
}
/// A presented ticket of `product` reached the host's check.
pub(crate) fn ticket_checked(&self, product: &str) {
*self.tickets_checked.lock().unwrap_or_else(|e| e.into_inner()).entry(product.to_owned()).or_default() += 1;
}
/// **How many presented tickets of `product` the host checked**: a call that carried none
/// asked nothing.
pub fn tickets_checked(&self, product: &str) -> u64 {
self.tickets_checked.lock().unwrap_or_else(|e| e.into_inner()).get(product).copied().unwrap_or(0)
}
/// The feed no longer declares `id` (a test's feed change).
#[cfg(test)]
pub(crate) fn forget_estate(&self, id: &str) {
self.estates.write().unwrap_or_else(|p| p.into_inner()).remove(id);
}
/// The feed declares `id` again (a test's feed change).
#[cfg(test)]
pub(crate) fn declare_estate(&self, id: &str, e: crate::config::EstateConfig) {
self.estates.write().unwrap_or_else(|p| p.into_inner()).insert(id.to_owned(), e);
}
/// The declared estates, replacing the ones held (from `[estates]` and each product's
/// `instance`, its own estate).
pub fn estate(self, id: &str, e: crate::config::EstateConfig) -> Self {
self.estates.write().unwrap_or_else(|p| p.into_inner()).insert(id.to_owned(), e);
self
}
/// `product`'s own estate, if it names one.
pub fn home_of(&self, product: &str) -> Option<String> {
self.homes.read().unwrap_or_else(|e| e.into_inner()).get(product).cloned()
}
/// **The admin of `estate` when it is one of the operator's OWN sites**
/// (`[estates.<estate>] operator_site = true`): its declared owner; `None` for any other.
pub fn operator_admin_of(&self, estate: &str) -> Option<String> {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).filter(|e| e.operator_site).map(|e| e.owner.trim().to_owned()).filter(|o| !o.is_empty())
}
/// **Is `estate` one of the operator's OWN sites** (`[estates.<estate>] operator_site = true`)?
/// `false` for an undeclared (sold) estate or a customer's declared one (HENRIK-1).
pub fn operator_site(&self, estate: &str) -> bool {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).is_some_and(|e| e.operator_site)
}
/// **Whether `estate`'s own estate is narrated** (`[estates.<estate>] narrate = true`, lane N2).
pub fn narrates(&self, estate: &str) -> bool {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).is_some_and(|e| e.narrate && e.operator_site)
}
/// **Does `estate`'s OWN estate ship an off-site copy** (lane O1)? `[estates.<label>] offsite`
/// on an operator's own site.
pub fn offsite(&self, estate: &str) -> bool {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).is_some_and(|e| e.offsite && e.operator_site)
}
/// **Is `estate`'s public face on** (TASKS-12)? `[estates.<label>] public_site` on an
/// operator's own site (gunnar.rs, holger.rs).
pub fn public_site(&self, estate: &str) -> bool {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).is_some_and(|e| e.public_site && e.operator_site)
}
/// **Is `estate`'s CLI hole open to the internet** (RA1-14)? `[estates.<label>]
/// cli_hole_public` on an operator's own site (gunnar.rs).
pub fn cli_hole_public(&self, estate: &str) -> bool {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).is_some_and(|e| e.cli_hole_public && e.operator_site)
}
/// **The public repositories of `estate`'s own estate** (NIGHT-PUBREPO): `[estates.<label>]
/// public_repositories` on an operator's own site (holger.rs: `crates`, `npm`); none for
/// any other estate.
pub fn public_repositories(&self, estate: &str) -> Vec<String> {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).filter(|e| e.operator_site).map(|e| e.public_repositories.clone()).unwrap_or_default()
}
/// **The host a site's customers come from and return to** (`[estates.<label>] site`,
/// `test3.holger.rs`); `None` for an undeclared site or one that names none (A16).
pub fn site_of_estate(&self, estate: &str) -> Option<String> {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate.trim()).map(|e| e.site.trim().to_owned()).filter(|s| !s.is_empty())
}
/// **Every declared site** (`[estates]`): `(label, its config)`, in label order.
pub fn declared_estates(&self) -> Vec<(String, crate::config::EstateConfig)> {
self.estates.read().unwrap_or_else(|e| e.into_inner()).iter().map(|(k, v)| (k.clone(), v.clone())).collect()
}
/// **Where `estate`'s customers pay and renew** (NP-3): its `account_url`, else
/// `https://<site>/`; `None` when it names neither (the box's `[nonpay] account_url`).
pub fn account_url_of(&self, estate: &str) -> Option<String> {
let estates = self.estates.read().unwrap_or_else(|e| e.into_inner());
let e = estates.get(estate)?;
let own = e.account_url.trim();
if !own.is_empty() {
return Some(own.to_owned());
}
let site = e.site.trim();
(!site.is_empty()).then(|| format!("https://{site}/"))
}
/// `estate`'s declared site (`[estates.<estate>] site`), when it names one.
pub fn site_of(&self, estate: &str) -> Option<String> {
self.estates.read().unwrap_or_else(|e| e.into_inner()).get(estate).map(|e| e.site.trim().to_owned()).filter(|s| !s.is_empty())
}
/// [`Registry::home`] on a registry already shared (tests).
pub fn declare_home(&self, product: &str, estate: &str) {
self.homes.write().unwrap_or_else(|p| p.into_inner()).insert(product.to_owned(), estate.to_owned());
}
/// `product`'s own estate (`[products.<id>] instance`).
pub fn home(self, product: &str, estate: &str) -> Self {
self.homes.write().unwrap_or_else(|p| p.into_inner()).insert(product.to_owned(), estate.to_owned());
self
}
/// `product`'s route in the config's words, for a refusal:
/// `[products.gunnar] vendor = "stripe/gunnar"`, or that it names none.
pub fn route_said(&self, product: &str) -> String {
match self.route_of(product) {
Some(v) => format!("[products.{product}] vendor = {v:?}"),
None => format!("[products.{product}] names no `vendor`"),
}
}
pub fn with_default_cloud(mut self, name: Option<String>) -> Self {
*self.default_cloud.get_mut().unwrap_or_else(|e| e.into_inner()) = name;
self
}
/// Every product: those built at start, then those added live, in the
/// order they arrived.
pub fn products(&self) -> impl Iterator<Item = Served<'_>> {
self.slots().map(HotProduct::served)
}
/// Every product's slot: those built at start, then those added live.
pub fn slots(&self) -> impl Iterator<Item = &HotProduct> {
self.products.iter().chain(self.live.iter())
}
/// The slot of the product `id`.
pub fn slot(&self, id: &str) -> Option<&HotProduct> {
self.slots().find(|p| p.facts().id == id)
}
/// **The guardian that answers for `product`** ([`HotProduct::guardian`]).
pub fn guardian_of(&self, product: &str) -> Option<&dyn monetize_product::guardian::EstateGuardian> {
self.slot(product).and_then(HotProduct::guardian)
}
/// Every vendor serving now — a vendor retired live is not listed.
pub fn vendors(&self) -> impl Iterator<Item = Vendor<'_>> {
self.vendors.iter().filter(|v| v.is_serving()).map(|v| v.vendor())
}
/// Every cloud serving now — a cloud retired live is not listed.
pub fn clouds(&self) -> impl Iterator<Item = &HotCloud> {
self.clouds.iter().filter(|c| c.is_serving()).map(|c| c.as_ref())
}
pub fn find_product(&self, id: &str) -> Option<Served<'_>> {
self.slot(id).map(HotProduct::served)
}
/// The shared handle, for a cloud that measures through the product.
pub fn product_handle(&self, id: &str) -> Option<Arc<Pulled>> {
self.slot(id).map(|p| Arc::clone(&p.product))
}
/// By name — and `none` is always found: it is the vendor of every attempt
/// that moved no money (`monetize::NO_VENDOR`), so a free attempt's row can
/// be resumed, reconciled or abandoned by the same code as a paid one. It
/// declines every reserve, so naming it on a priced purchase is a refusal
/// with the reason on the row, never a sale.
pub fn find_vendor(&self, name: &str) -> Option<Vendor<'_>> {
if name == monetize::NO_VENDOR {
return Some(monetize::no_vendor());
}
self.vendors().find(|v| v.name == name)
}
/// The vendor a purchase of `product` at `price` goes through: none at
/// price 0 (the free path calls nobody), else the instance `product` is
/// routed to (`[products.<id>] vendor`). A `requested` vendor must BE that
/// instance — one product's money never reaches another's account. `None`
/// when the product has no route, the route serves nothing now, or the
/// request names another; the transaction then refuses by name
/// (`monetize::Refusal::NoVendor`) with nothing written. `market` is the
/// buyer's: an instance that does not serve it takes nothing.
pub fn vendor_for(&self, price: &monetize_pay::Money, market: monetize_pay::Market, product: &str, requested: &str) -> Option<Vendor<'_>> {
self.vendor_for_estate(price, market, product, None, requested)
}
/// [`Registry::vendor_for`] for an order of `tenant`: routed by the tenant's estate
/// ([`Registry::estate_of_tenant`]).
pub fn vendor_for_tenant(&self, price: &monetize_pay::Money, market: monetize_pay::Market, product: &str, tenant: &str, requested: &str) -> Option<Vendor<'_>> {
let estate = self.estate_of_tenant(product, tenant);
self.vendor_for_estate(price, market, product, estate.as_deref(), requested)
}
/// [`Registry::vendor_for`] for an order from `estate` ([`Registry::route_for`]).
pub fn vendor_for_estate(&self, price: &monetize_pay::Money, market: monetize_pay::Market, product: &str, estate: Option<&str>, requested: &str) -> Option<Vendor<'_>> {
if price.minor == 0 {
return Some(monetize::no_vendor());
}
let route = self.route_for(product, estate).ok()?;
// A console names the vendor KIND (`stripe`); the route names the INSTANCE
// (`stripe/gunnar-test2`, one account per site). The kind matches its instance;
// any other name is never substituted.
let kind = route.split_once('/').map_or(route.as_str(), |(k, _)| k);
if !requested.is_empty() && requested != route && requested != kind {
return None;
}
self.find_vendor(&route).filter(|v| v.markets.contains(&market))
}
/// Every linked cloud whose ACCOUNT changed since the last ask (CODE RED audit 5): its
/// name, and `Some(words)` exhausted or `None` working again.
pub fn account_changes(&self) -> Vec<(String, Option<String>)> {
self.clouds.iter().filter_map(|c| c.account_change().map(|a| (c.name().to_string(), a))).collect()
}
pub fn find_cloud(&self, name: &str) -> Option<&HotCloud> {
self.clouds().find(|c| c.name() == name)
}
/// The cloud the transaction provisions on: the configured default, else the
/// only one linked, else [`NoCloud`] — which refuses to ensure anything, so a
/// plan with resources fails honestly instead of being "provisioned" nowhere.
pub fn cloud_for_transactions(&self) -> &dyn Cloud {
match self.transaction_slot() {
Some(c) => c,
None => &NoCloud,
}
}
/// The slot [`Registry::cloud_for_transactions`] resolves to — its cap and name with it —
/// or `None` where that is [`NoCloud`].
pub fn transaction_slot(&self) -> Option<&HotCloud> {
let default = self.default_cloud.read().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(c) = default.as_deref().and_then(|name| self.find_cloud(name)) {
return Some(c);
}
let mut serving = self.clouds();
match (serving.next(), serving.next()) {
(Some(one), None) => Some(one),
_ => None,
}
}
/// The provider [`Registry::cloud_for_transactions`] resolves to, or `None`
/// when that is [`NoCloud`]. What the console marks as the cloud a sale
/// spends at — the resolved answer, not `default_cloud`'s string, because a
/// name that matches nothing linked is not where the money goes.
pub fn default_cloud_name(&self) -> Option<&'static str> {
// The same two rules, by name: comparing trait-object addresses would
// call a zero-sized linked cloud and `&NoCloud` the same object.
let default = self.default_cloud.read().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(c) = default.as_deref().and_then(|name| self.find_cloud(name)) {
return Some(c.name());
}
let mut serving = self.clouds();
match (serving.next(), serving.next()) {
(Some(one), None) => Some(one.name()),
_ => None,
}
}
/// The vendor guests the start refused and skipped, by name.
pub fn vendor_start_refusals(&self) -> &[ProviderChange] {
&self.vendor_start_refused
}
/// What the start refused and skipped, by name (see [`Registry::from_config_with`]).
pub fn start_refusals(&self) -> &[GuestChange] {
&self.start_refused
}
pub fn product_ids(&self) -> Vec<&'static str> {
self.products().map(|p| p.id()).collect()
}
pub fn vendor_names(&self) -> Vec<&'static str> {
self.vendors.iter().filter(|v| v.is_serving()).map(|v| v.name()).collect()
}
pub fn cloud_names(&self) -> Vec<&'static str> {
self.clouds().map(|c| c.name()).collect()
}
/// [`Registry::from_config_with`] over the open set this binary links.
pub fn from_config(cfg: &Config) -> anyhow::Result<Self> {
Self::from_config_with(cfg, &Linked::open())
}
/// Build from the config file: every `[products.<id>]` becomes a plugin, every
/// `enabled = true` vendor / cloud is constructed by its factory in `linked` —
/// and a vendor that is enabled but not linked into this binary is an error
/// at start, not a 404 at purchase.
pub fn from_config_with(cfg: &Config, linked: &Linked) -> anyhow::Result<Self> {
let base = linked.clone();
let linked = &linked.clone().with_guests_from(cfg);
let mut reg = Registry::new().with_default_cloud(cfg.default_cloud.clone());
reg.linked = base;
reg.set_routes(cfg);
for (id, p) in &cfg.products {
if let Some(why) = cfg.unpriced(id) {
reg.start_refused.push(GuestChange::Refused { id: id.clone(), clause: "product-no-prices", why });
continue;
}
// A guest is held where it can be replaced live; a linked plugin as it always was.
if let Some(spec) = guest_spec(p) {
// A guest that does not pass its gates is SKIPPED by name, never
// fatal: the rest of the deployment serves, and the watcher
// takes it in when a good module or block arrives.
let hot = match monetize_product_wasm::HotGuest::build(&product_build(id, p, Some(&spec))) {
Ok(h) => Arc::new(h),
Err(e) => {
reg.start_refused.push(GuestChange::Refused { id: id.clone(), clause: "guest-refused-at-start", why: e });
continue;
}
};
reg.guests.get_mut().unwrap_or_else(|e| e.into_inner()).insert(id.clone(), Arc::clone(&hot));
let served = pulled(id, p, guest_facts(&hot), ®.pull_slot);
reg.products.push(HotProduct::new(served).guarded_by(linked_guardian(linked, id, p), Some(hot)));
} else {
let (built, served_as) = build_product(id, p, linked)?;
let served = pulled(id, p, built, ®.pull_slot);
reg.products.push(HotProduct::served_as(served, served_as).guarded_by(linked_guardian(linked, id, p), None));
if let Some(key) = linked_ticket_key(id, p)? {
reg = reg.ticket_key(id, &key);
}
}
}
for (name, v) in cfg.vendors.iter().filter(|(_, v)| v.enabled) {
let source = vendor_source(name, v);
// A vendor guest is held where it can be replaced in place; a
// linked vendor is built by its factory as it always was.
if let Some(spec) = vendor_guest_spec(cfg, v) {
let settings = settings_json(&v.settings)?;
let b = monetize_product_wasm::VendorGuestBuild { name, spec: &spec, settings: &settings };
let hot = match monetize_product_wasm::HotVendor::build(&b) {
Ok(h) => Arc::new(h),
Err(e) => {
reg.vendor_start_refused.push(ProviderChange::Refused { list: "vendors", name: name.clone(), clause: "vendor-guest-refused-at-start", why: e });
continue;
}
};
reg.vendor_guests.get_mut().unwrap_or_else(|e| e.into_inner()).insert(name.clone(), Arc::clone(&hot));
if reg.vendors.push(Arc::new(HotVendor::serving_guest(hot, source))).is_err() {
anyhow::bail!("more than {} vendors in one registry", crate::hot::PROVIDER_SLOTS);
}
continue;
}
reg = reg.sourced_vendor(build_vendor(name, cfg, v, linked)?, source)?;
}
for (name, c) in cfg.clouds.iter().filter(|(_, c)| c.enabled) {
let source = cloud_source(name, c);
// A cloud GUEST that does not load (a dead token at its start-path
// proof, a module not vouched for) is SKIPPED by name like a vendor
// guest — the rest of the deployment serves and the watcher takes it
// in when a good block arrives. A LINKED cloud keeps its fatal start,
// and a guest block with no `cap` is refused as ever (below the gate).
let is_guest = c.settings.get("guest").is_some();
// A cloud that serves a PRODUCT not linked yet (`[clouds.server] product =
// "gunnar"`, whose plugin self-registers after monetize serves) is
// skipped by name at start: the watcher builds it once the product is
// in. Its absence is said, never the end of the server.
if let Some(product) = c.settings.get("product").and_then(|p| p.as_str()) {
if !reg.products.iter().any(|p| p.facts().id == product) {
reg.vendor_start_refused.push(ProviderChange::Refused {
list: "clouds",
name: name.clone(),
clause: "cloud-product-not-linked-at-start",
why: format!("product = {product:?}, but no [products.{product}] is linked yet — it is taken in when the product registers"),
});
continue;
}
}
let handles: Vec<Arc<Pulled>> = reg.products.iter().map(|p| Arc::clone(&p.product)).collect();
let (cloud, cap) = match build_cloud(name, cfg, c, linked, &handles, ®.grow_reach) {
Ok(cl) => cl,
Err(e) if is_guest && c.cap.is_some() => {
reg.vendor_start_refused.push(ProviderChange::Refused { list: "clouds", name: name.clone(), clause: "cloud-guest-refused-at-start", why: format!("{e:#}") });
continue;
}
Err(e) => return Err(e),
};
reg = reg.sourced_cloud_in(cloud, cap, source)?;
}
Ok(reg)
}
}
impl Registry {
/// **Apply the config's guest blocks to the RUNNING registry** — no
/// restart. For every `[products.<id>]` with a `guest =` key:
///
/// * **unchanged** (same module bytes, same block): nothing, and no line;
/// * **known id, changed block**: [`HotGuest::replace`] — every gate a start
/// passes, compiled before the swap; calls in flight finish on the old one;
/// * **new id**: built as at start, then `admit_new(id)` (the caller's
/// chance to install what else the product needs — its price list — and
/// refuse), then published in a [`LIVE_PRODUCT_SLOTS`] slot.
///
/// Everything a guest may reach (grants, `http_hosts`, `guest_grpc_hosts`)
/// is read from `cfg`, the file the operator wrote; a module can only be
/// refused against it. Every refusal keeps what was serving and is
/// returned by name. An id a LINKED plugin serves is not taken over live,
/// and a block that disappeared is not retired live: both are restarts,
/// said so.
///
/// [`HotGuest::replace`]: monetize_product_wasm::HotGuest::replace
pub fn apply_guests(&self, cfg: &Config, admit_new: &mut dyn FnMut(&str) -> Result<(), String>) -> Vec<GuestChange> {
let mut guests = self.guests.lock().unwrap_or_else(|e| e.into_inner());
self.set_routes(cfg);
let mut out = Vec::new();
let refused = |id: &str, clause: &'static str, why: String| GuestChange::Refused { id: id.to_string(), clause, why };
for (id, p) in &cfg.products {
let Some(spec) = guest_spec(p) else { continue };
let b = product_build(id, p, Some(&spec));
if let Some(hot) = guests.get(id) {
match hot.is_current_for(&b) {
Ok(true) => {}
Ok(false) => out.push(match hot.replace(&b) {
Ok(r) => {
// Its facts are read again: the replacement's meters and facts.
if let Some(slot) = self.slot(id) {
slot.reread();
}
GuestChange::Replaced(r)
}
Err(e) => refused(id, "guest-refused", e),
}),
Err(e) => out.push(refused(id, "guest-unreadable", e)),
}
continue;
}
if self.products().any(|x| x.id() == id) {
out.push(refused(
id,
"guest-over-linked",
"is served by a plugin linked into this binary; moving a product from a linked plugin to a guest takes a restart".into(),
));
continue;
}
if let Some(why) = cfg.unpriced(id) {
out.push(GuestChange::Refused { id: id.clone(), clause: "product-no-prices", why });
continue;
}
if self.live.iter().count() >= LIVE_PRODUCT_SLOTS {
out.push(refused(id, "guest-slots-full", format!("{LIVE_PRODUCT_SLOTS} products were already added since the start; restart to add more")));
continue;
}
let hot = match monetize_product_wasm::HotGuest::build(&b) {
Ok(h) => Arc::new(h),
Err(e) => {
out.push(refused(id, "guest-refused", e));
continue;
}
};
if let Err(e) = admit_new(id) {
out.push(refused(id, "guest-terms-refused", e));
continue;
}
let (digest, signer) = {
let g = hot.current();
(g.digest_hex().to_string(), g.signer().map(str::to_string))
};
let slot = HotProduct::new(pulled(id, p, guest_facts(&hot), &self.pull_slot)).guarded_by(linked_guardian(&self.linked, id, p), Some(Arc::clone(&hot)));
if self.live.push(slot).is_err() {
out.push(refused(id, "guest-slots-full", format!("{LIVE_PRODUCT_SLOTS} products were already added since the start; restart to add more")));
continue;
}
guests.insert(id.clone(), hot);
out.push(GuestChange::Added { id: id.clone(), digest, signer });
}
for id in guests.keys() {
if !cfg.products.get(id).is_some_and(|p| p.guest.is_some()) {
out.push(refused(id, "guest-removal-needs-restart", "its guest block is gone from the config; it keeps serving until the next restart".into()));
}
}
out
}
/// The guest serving `[products.<id>]` now, when a guest serves it.
/// The ids of every product a guest serves now.
pub fn guest_ids(&self) -> Vec<String> {
self.guests.lock().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect()
}
pub fn guest(&self, id: &str) -> Option<Arc<monetize_product_wasm::GuestProduct>> {
self.guests.lock().unwrap_or_else(|e| e.into_inner()).get(id).map(|h| h.current())
}
}
/// **What a live reload did with one `[vendors.<name>]` or `[clouds.<name>]`
/// block** — one line of the operator's journal. A refusal names its clause
/// and keeps whatever was serving.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProviderChange {
/// A name that was not serving is served from now on.
Added { list: &'static str, name: String, detail: String },
/// The serving provider was replaced; calls in flight finished on the old one.
Replaced { list: &'static str, name: String, detail: String },
/// `enabled = false`, or the block is gone: nothing new goes through it.
Retired { list: &'static str, name: String },
/// Refused by name: what served (if anything) still serves.
Refused { list: &'static str, name: String, clause: &'static str, why: String },
/// `default_cloud` moved.
DefaultCloud { from: Option<String>, to: Option<String> },
}
fn singular(list: &str) -> &'static str {
if list == "vendors" {
"vendor"
} else {
"cloud"
}
}
impl std::fmt::Display for ProviderChange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProviderChange::Added { list, name, detail } => write!(f, "{} ADDED {} {detail}", singular(list), block_name(list, name)),
ProviderChange::Replaced { list, name, detail } => {
write!(f, "{} REPLACED {} {detail}; calls in flight finish on the old one", singular(list), block_name(list, name))
}
ProviderChange::Retired { list, name } => {
write!(f, "{} RETIRED {}: disabled or removed from the config; nothing new goes through it", singular(list), block_name(list, name))
}
ProviderChange::Refused { list, name, clause, why } => {
write!(f, "{} REFUSED [{clause}] {}: {why} — whatever served before still serves", singular(list), block_name(list, name))
}
ProviderChange::DefaultCloud { from, to } => write!(f, "default_cloud {:?} → {:?}", from.as_deref().unwrap_or("(none)"), to.as_deref().unwrap_or("(none)")),
}
}
}
/// `[vendors.stripe.gunnar]` for the vendor instance `stripe/gunnar`; a cloud's
/// `[clouds.upcloud]` as written.
fn block_name(list: &str, name: &str) -> String {
format!("[{list}.{}]", name.replace('/', "."))
}
/// **A block's identity for a live reload**: every key of the block and the
/// bytes of every file a `guest =` key names (the module, its `.p7s`, its
/// roots). Two equal sources build the same provider; hashed, so a secret in a
/// block is never kept as text.
fn block_source(list: &str, name: &str, block: &serde_json::Value, guest: Option<&Path>, roots: &[std::path::PathBuf]) -> String {
let mut text = format!("{list}|{name}|{block}");
let file = |p: &Path| std::fs::read(p).map(|b| monetize_product_wasm::digest_hex(&b)).unwrap_or_else(|e| format!("unreadable:{e}"));
if let Some(module) = guest {
let mut sig = module.as_os_str().to_owned();
sig.push(".p7s");
text.push_str(&format!("|{}|{}", file(module), file(Path::new(&sig))));
}
for r in roots {
text.push_str(&format!("|{}", file(r)));
}
monetize_product_wasm::digest_hex(text.as_bytes())
}
fn vendor_source(name: &str, v: &crate::config::VendorConfig) -> String {
block_source("vendors", name, &serde_json::to_value(v).unwrap_or_default(), v.guest.as_deref(), &v.guest_trust_roots)
}
/// A cloud's guest keys live in the block's own table (the cloud guest host
/// reads them there), not in typed fields.
fn cloud_guest_files(c: &crate::config::CloudConfig) -> (Option<std::path::PathBuf>, Vec<std::path::PathBuf>) {
let module = c.settings.get("guest").and_then(|v| v.as_str()).map(std::path::PathBuf::from);
let roots = c.settings.get("guest_trust_roots").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|r| r.as_str().map(std::path::PathBuf::from)).collect()).unwrap_or_default();
(module, roots)
}
fn cloud_source(name: &str, c: &crate::config::CloudConfig) -> String {
let (module, roots) = cloud_guest_files(c);
block_source("clouds", name, &serde_json::to_value(c).unwrap_or_default(), module.as_deref(), &roots)
}
/// What the journal says a provider was built from: the guest module's digest
/// when the block names one, else the linked factory.
fn built_from(guest: Option<&Path>) -> String {
match guest {
Some(m) => match std::fs::read(m) {
Ok(b) => format!("guest {} blake3 {}", m.display(), monetize_product_wasm::digest_hex(&b)),
Err(_) => format!("guest {}", m.display()),
},
None => "linked".to_string(),
}
}
impl Registry {
/// **Apply the config's `[vendors.*]` and `[clouds.*]` blocks to the
/// RUNNING registry** — no restart. Per block:
///
/// * **unchanged** (same keys, same guest files): nothing, and no line;
/// * **changed**: rebuilt through the same factory as at start, off the
/// serving path; any refusal keeps what serves;
/// * **new name**: built and added;
/// * **`enabled = false`, or the block is gone**: retired — no new call
/// reaches it, and it is no longer listed. A provider handed in by code
/// (no block) is never retired by a config.
///
/// **A cloud is published only after its spend ceiling is reconciled.**
/// The replacement is built unwrapped; then the slot is quiesced (every
/// `ensure` in flight finishes, new ones wait), the provider is wrapped in
/// its `SpendCap` — whose total is read from its file only now — and the
/// cap is corrected from `spent(provider, currency)`, the LEDGER's total.
/// A cap that cannot be reconciled refuses the swap. Only then is it
/// published. The guest never sees any of it.
pub fn apply_providers(&self, cfg: &Config, spent: &dyn Fn(&str, monetize_pay::Currency) -> Result<u64, String>) -> Vec<ProviderChange> {
let _one = self.providers.lock().unwrap_or_else(|e| e.into_inner());
let mut out = Vec::new();
let linked = self.linked.clone().with_guests_from(cfg);
self.set_routes(cfg);
// ── vendors ──
for (name, v) in &cfg.vendors {
let source = vendor_source(name, v);
let slot = self.vendors.iter().find(|h| h.name() == name.as_str()).cloned();
let refused = |clause: &'static str, why: String| ProviderChange::Refused { list: "vendors", name: name.clone(), clause, why };
if !v.enabled {
if let Some(h) = slot.filter(|h| h.is_serving()) {
h.retire(source);
out.push(ProviderChange::Retired { list: "vendors", name: name.clone() });
}
continue;
}
if slot.as_ref().is_some_and(|h| h.is_serving() && h.source() == source) {
continue;
}
// **A vendor guest is replaced INSIDE its HotVendor** (the money
// lane's `replace`: every gate a start passes, the host's memory
// for the vendor shared with the build it replaces); the slot then
// points at it. A new guest block builds a new one.
if let Some(spec) = vendor_guest_spec(cfg, v) {
let settings = match settings_json(&v.settings) {
Ok(s) => s,
Err(e) => {
out.push(refused("vendor-settings", e.to_string()));
continue;
}
};
let b = monetize_product_wasm::VendorGuestBuild { name, spec: &spec, settings: &settings };
let mut guests = self.vendor_guests.lock().unwrap_or_else(|e| e.into_inner());
let (hot, detail) = match guests.get(name).cloned() {
Some(hot) => match hot.replace(&b) {
Ok(r) => (hot, format!("guest blake3 {} → {}{}", r.from_digest, r.to_digest, r.signer.map(|s| format!(", signed by {s}")).unwrap_or_default())),
Err(e) => {
out.push(refused("vendor-guest-refused", e));
continue;
}
},
None => match monetize_product_wasm::HotVendor::build(&b) {
Ok(h) => {
let h = Arc::new(h);
let d = {
let g = h.current();
format!("guest blake3 {}{}", g.digest_hex(), g.signer().map(|s| format!(", signed by {s}")).unwrap_or_default())
};
(h, d)
}
Err(e) => {
out.push(refused("vendor-guest-refused", e));
continue;
}
},
};
guests.insert(name.clone(), Arc::clone(&hot));
match slot {
Some(h) => {
let was = h.is_serving();
let markets = hot.markets();
h.publish(hot, markets, source);
out.push(if was { ProviderChange::Replaced { list: "vendors", name: name.clone(), detail } } else { ProviderChange::Added { list: "vendors", name: name.clone(), detail } });
}
None => match self.vendors.push(Arc::new(HotVendor::serving_guest(hot, source))) {
Ok(()) => out.push(ProviderChange::Added { list: "vendors", name: name.clone(), detail }),
Err(_) => out.push(refused("vendor-slots-full", format!("{} vendors are already held; restart to add more", crate::hot::PROVIDER_SLOTS))),
},
}
continue;
}
self.vendor_guests.lock().unwrap_or_else(|e| e.into_inner()).remove(name.as_str());
let built = match build_vendor(name, cfg, v, &linked) {
Ok(b) => b,
Err(e) => {
out.push(refused("vendor-refused", format!("{e:#}")));
continue;
}
};
let manifest = built.manifest();
if manifest.vendor != name.as_str() {
out.push(refused("vendor-wrong-name", format!("the block built a vendor that calls itself `{}`", manifest.vendor)));
continue;
}
let detail = built_from(v.guest.as_deref());
match slot {
Some(h) => {
let was = h.is_serving();
h.publish(Arc::from(built), &manifest.markets, source);
out.push(if was { ProviderChange::Replaced { list: "vendors", name: name.clone(), detail } } else { ProviderChange::Added { list: "vendors", name: name.clone(), detail } });
}
None => match self.vendors.push(Arc::new(HotVendor::serving(built, source))) {
Ok(()) => out.push(ProviderChange::Added { list: "vendors", name: name.clone(), detail }),
Err(_) => out.push(refused("vendor-slots-full", format!("{} vendors are already held; restart to add more", crate::hot::PROVIDER_SLOTS))),
},
}
}
for h in self.vendors.iter().filter(|h| h.is_serving() && !h.source().is_empty()) {
if !cfg.vendors.contains_key(h.name()) {
h.retire(String::new());
out.push(ProviderChange::Retired { list: "vendors", name: h.name().to_string() });
}
}
// ── clouds ──
let products: Vec<Arc<Pulled>> = self.slots().map(|p| Arc::clone(&p.product)).collect();
for (name, c) in &cfg.clouds {
let source = cloud_source(name, c);
let slot = self.clouds.iter().find(|h| h.name() == name.as_str()).cloned();
let refused = |clause: &'static str, why: String| ProviderChange::Refused { list: "clouds", name: name.clone(), clause, why };
if !c.enabled {
if let Some(h) = slot.filter(|h| h.is_serving()) {
let _q = h.quiesce();
h.retire(source);
out.push(ProviderChange::Retired { list: "clouds", name: name.clone() });
}
continue;
}
if slot.as_ref().is_some_and(|h| h.is_serving() && h.source() == source) {
continue;
}
// Built OFF the serving path: a guest is compiled and proves its
// credential here, while the old provider keeps buying.
let (inner, ceiling) = match build_cloud_bare(name, cfg, c, &linked, &products, &self.grow_reach) {
Ok(b) => b,
Err(e) => {
out.push(refused("cloud-refused", format!("{e:#}")));
continue;
}
};
let calls_itself = inner.manifest().provider;
if calls_itself != name.as_str() {
out.push(refused("cloud-wrong-name", format!("the block built a cloud that calls itself `{calls_itself}`")));
continue;
}
let detail = built_from(cloud_guest_files(c).0.as_deref());
// Quiesce, wrap, reconcile, publish — in that order, under the gate.
let gate_of = slot.clone();
let quiet = gate_of.as_ref().map(|h| h.quiesce());
let (capped, cap) = match wrap_cloud(name, &cfg.data_dir, inner, ceiling) {
Ok(w) => w,
Err(e) => {
out.push(refused("cloud-cap-refused", format!("{e:#}")));
continue;
}
};
let cap_line = match &cap {
Some(c) => match spent(name, c.ceiling().currency).and_then(|total| c.reconcile_total(total).map(|()| total).map_err(|e| e.to_string())) {
Ok(total) => crate::cli::spend_cap_line(name, total, Some(c.spent_this_month()), c.ceiling()),
Err(e) => {
out.push(refused("cloud-cap-unreconciled", format!("its spend ceiling could not be reconciled against the ledger ({e}); a cap that has not seen the ledger could hand the deployment its whole budget again")));
continue;
}
},
None => "cannot spend".to_string(),
};
let detail = format!("{detail}; {cap_line}");
match slot {
Some(h) => {
let was = h.is_serving();
h.publish(Arc::from(capped), cap, source);
drop(quiet);
out.push(if was { ProviderChange::Replaced { list: "clouds", name: name.clone(), detail } } else { ProviderChange::Added { list: "clouds", name: name.clone(), detail } });
}
None => match self.clouds.push(Arc::new(HotCloud::serving(capped, cap, source))) {
Ok(()) => out.push(ProviderChange::Added { list: "clouds", name: name.clone(), detail }),
Err(_) => out.push(refused("cloud-slots-full", format!("{} clouds are already held; restart to add more", crate::hot::PROVIDER_SLOTS))),
},
}
}
for h in self.clouds.iter().filter(|h| h.is_serving() && !h.source().is_empty()) {
if !cfg.clouds.contains_key(h.name()) {
let _q = h.quiesce();
h.retire(String::new());
out.push(ProviderChange::Retired { list: "clouds", name: h.name().to_string() });
}
}
// ── the default cloud ──
let mut d = self.default_cloud.write().unwrap_or_else(|e| e.into_inner());
if *d != cfg.default_cloud {
out.push(ProviderChange::DefaultCloud { from: d.clone(), to: cfg.default_cloud.clone() });
*d = cfg.default_cloud.clone();
}
out
}
}
/// The `[products.<id>]` block's guest keys, typed, when it names a guest.
fn guest_spec(p: &crate::config::ProductConfig) -> Option<monetize_product::GuestSpec> {
p.guest.as_ref().map(|module| monetize_product::GuestSpec {
module: module.clone(),
digest: p.guest_digest.clone(),
trust_roots: p.guest_trust_roots.clone(),
grants: p.grants.clone(),
http_hosts: p.http_hosts.clone(),
grpc_hosts: p.guest_grpc_hosts.clone(),
fuel: p.guest_fuel,
call_timeout_ms: p.guest_call_timeout_ms,
max_memory_bytes: p.guest_max_memory_bytes,
http_auth: p.http_auth.clone(),
hmac_secrets: p.hmac_secrets.clone(),
secrets_dir: p.secrets_dir.clone(),
plugin: p.plugin.clone(),
})
}
/// The `[vendors.<name>]` block's guest keys, typed, when it names a guest.
fn vendor_guest_spec(cfg: &Config, v: &crate::config::VendorConfig) -> Option<monetize_product::GuestSpec> {
v.guest.as_ref().map(|module| monetize_product::GuestSpec {
module: module.clone(),
digest: v.guest_digest.clone(),
trust_roots: v.guest_trust_roots.clone(),
grants: v.grants.clone(),
http_hosts: v.http_hosts.clone(),
grpc_hosts: Vec::new(),
fuel: v.guest_fuel,
call_timeout_ms: v.guest_call_timeout_ms,
max_memory_bytes: v.guest_max_memory_bytes,
http_auth: v.http_auth.clone(),
hmac_secrets: v.hmac_secrets.clone(),
secrets_dir: Some(cfg.secrets_dir()),
plugin: None,
})
}
/// **The address a PULLED product's plugin is built with** (`[products.<id>] instance`, no
/// `endpoint`): monetize never dials an estate ([`pulled`] wraps the plugin), but a linked
/// plugin builds its lazy channel from an address and refuses an empty one — so the ONE box
/// could not open with gunnar pulled (money-flow 2026-10-10). Never resolvable, never dialled.
pub use monetize_product::PULLED_NEVER_DIALLED;
/// The `[products.<id>]` block in the shape a factory is built from.
fn product_build<'a>(id: &'a str, p: &'a crate::config::ProductConfig, guest: Option<&'a monetize_product::GuestSpec>) -> ProductBuild<'a> {
ProductBuild {
id,
endpoint: if p.instance.is_some() && p.endpoint.trim().is_empty() { PULLED_NEVER_DIALLED } else { &p.endpoint },
identity_key: None,
appliance_key: p.appliance_key.as_deref(),
bearer_token: p.bearer_token.as_deref(),
api_key_env: p.api_key_env.as_deref(),
restoring_from_twin: p.restoring_from_twin.as_deref(),
tls_ca: p.tls_ca.as_deref(),
tls_client: match (p.tls_client_cert.as_deref(), p.tls_client_key.as_deref()) {
(Some(c), Some(k)) => Some((c, k)),
_ => None,
},
guest,
}
}
/// The factory `[products.<id>]` names, or the refusal — worded as it always
/// was, because an operator's config has not changed meaning: a product this
/// binary does not link is not a product this binary can meter.
fn find_product_factory<'a>(id: &str, linked: &'a Linked) -> anyhow::Result<&'a ProductFactory> {
linked.find_product(id).ok_or_else(|| {
anyhow::anyhow!(
"[products.{id}]: no plugin for that product is linked into this server (linked: {:?}). \
The open build links no product; a deployment's binary registers the plugins it ships \
(`Linked::product`), the way it registers its vendors and clouds",
linked.product_names()
)
})
}
/// Build `[products.<id>]` through the factory registered under `id`. The
/// registry matches on no product name: gunnar, holger and every other plugin
/// reach this server the way a paid vendor does, by registration.
///
/// **G15**: `plugin = "<name>"` looks the factory up under `<name>` and serves
/// what it builds under the BLOCK's id ([`HotProduct::served_as`]). A plugin answering an
/// id that is neither the block's nor the named plugin's is refused: it would
/// otherwise serve as a second product of that id.
/// **A linked product's `[products.<id>] appliance_key`**, checked at start: a key that is no
/// ed25519 key stops the server rather than the first customer; none is `None`.
fn linked_ticket_key(id: &str, p: &crate::config::ProductConfig) -> anyhow::Result<Option<String>> {
let Some(key) = p.appliance_key.as_deref().map(str::trim).filter(|k| !k.is_empty()) else {
return Ok(None);
};
if crate::site_ticket::key_of(key).is_none() {
anyhow::bail!("[products.{id}] appliance_key: not an ed25519 public key (64 hex characters), so no ticket its main mints could be checked");
}
Ok(Some(key.to_ascii_lowercase()))
}
/// The built product and, where it is a plugin of another id, the block's id it is served under.
fn build_product(id: &str, p: &crate::config::ProductConfig, linked: &Linked) -> anyhow::Result<(monetize_product::pulled::FactsSource, Option<&'static str>)> {
if p.tls_client_cert.is_some() != p.tls_client_key.is_some() {
anyhow::bail!("[products.{id}]: `tls_client_cert` and `tls_client_key` come together — one without the other presents no identity (f-grpc-tls)");
}
// A guest is linked under its block's id; its own `id` op is checked against `plugin`.
let plugin = if p.guest.is_some() { id } else { p.plugin.as_deref().unwrap_or(id) };
let factory = find_product_factory(plugin, linked)?;
let guest = guest_spec(p);
let built = (factory.build)(&product_build(id, p, guest.as_ref())).map_err(|e| anyhow::anyhow!(e))?;
let answers = built().id;
if answers == id {
Ok((built, None))
} else if p.plugin.is_some() && answers == plugin {
Ok((built, Some(monetize_product_wasm::leak_id(id))))
} else {
anyhow::bail!(
"[products.{id}]: the plugin answers product id `{answers}`, not `{id}` — two blocks would serve one product id. \
Set `plugin = \"{answers}\"` to serve that plugin as `{id}` (G15)"
)
}
}
/// The block's extra keys as the JSON value a factory reads them through.
fn settings_json(table: &toml::Table) -> anyhow::Result<serde_json::Value> {
serde_json::to_value(table).map_err(|e| anyhow::anyhow!("settings: {e}"))
}
/// Build the instance `name` (`<kind>/<instance>`) through the factory
/// linked for its KIND, and hand it out AS the instance: `vendor()` is `name`,
/// every handle is translated ([`monetize_pay::instance::Instance`]).
fn build_vendor(name: &str, cfg: &Config, v: &crate::config::VendorConfig, linked: &Linked) -> anyhow::Result<Box<dyn Payment>> {
let block = monetize_pay::instance::block_of(name);
let kind = monetize_pay::instance::kind_of(name);
let Some(factory) = linked.find_vendor(kind) else {
anyhow::bail!(
"{block} enabled = true, but its kind `{kind}` is not linked into this server (linked: {:?}). \
The open build links `invoice` and `sandbox` behind its cargo features; klarna, stripe and swish are the \
vetra build's, from the private monetize-impl",
linked.vendor_names()
)
};
let settings = settings_json(&v.settings)?;
let ctx = VendorBuild { name, data_dir: &cfg.data_dir, settings: Settings(&settings) };
let inner = (factory.build)(&ctx).map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(Box::new(monetize_pay::instance::Instance::wrap(name, inner).map_err(|e| anyhow::anyhow!(e))?))
}
/// Build one cloud provider from its config block, **wrapped in its guard**.
///
/// The wrap is not optional and not a factory's choice. `ensure` is the only
/// call in monetize that can spend money at a provider, and the pool logic only
/// stops it buying the SAME thing twice — a loop over fifty tenants buys fifty
/// disks, each one correctly. So every provider that CAN spend leaves here
/// inside a `SpendCap`, and one whose factory says it cannot leaves inside a
/// `CannotSpend` that destroys and refuses any purchase it makes anyway. There
/// is no way to ask for a bare one.
fn build_cloud(
name: &str,
cfg: &Config,
c: &crate::config::CloudConfig,
linked: &Linked,
products: &[Arc<Pulled>],
reach: &monetize_cloud::server::GrowReach,
) -> anyhow::Result<GuardedCloud> {
let (inner, ceiling) = build_cloud_bare(name, cfg, c, linked, products, reach)?;
wrap_cloud(name, &cfg.data_dir, inner, ceiling)
}
/// The provider a `[clouds.<name>]` block builds, NOT yet wrapped, and the
/// ceiling it must be wrapped in. Every refusal [`build_cloud`] makes about the
/// block (no cap, a cap that limits nothing, a name nothing links) is made
/// here, before the factory runs.
fn build_cloud_bare(
name: &str,
cfg: &Config,
c: &crate::config::CloudConfig,
linked: &Linked,
products: &[Arc<Pulled>],
reach: &monetize_cloud::server::GrowReach,
) -> anyhow::Result<(Box<dyn Cloud>, Option<monetize_cloud::cap::Ceiling>)> {
let factory = linked.find_cloud(name);
// A name this binary cannot build is still asked for its ceiling first:
// the cap is checked before the provider, deliberately, so the refusal an
// operator sees is about the config's most dangerous omission.
let spends = factory.map(|f| f.spends).unwrap_or(true);
let ceiling = if spends {
// REFUSE FIRST, before a credential is read or a socket is opened: an
// enabled cloud with no ceiling is an automated system with an unbounded
// credit card, and defaulting one would be choosing a number on the
// operator's behalf that they would only discover on an invoice.
let cap = c.cap.ok_or_else(|| {
anyhow::anyhow!(
"[clouds.{name}] is enabled but declares no `cap`. A spend ceiling is required, \
because this is the only place in monetize that spends money at a provider. Write one \
down, e.g. `cap = {{ total_minor = 1000, currency = \"Eur\" }}` for 10 EUR ever, or \
`monthly_minor` for a per-month window; set both and both hold. A ceiling of 0 is a \
valid and useful answer — it refuses every purchase, so the deployment may use only \
pool space that is already paid for."
)
})?;
let ceiling = monetize_cloud::cap::Ceiling {
monthly_minor: cap.monthly_minor,
total_minor: cap.total_minor,
currency: cap.currency.ok_or_else(|| anyhow::anyhow!("[clouds.{name}] cap needs a `currency` (\"Sek\" or \"Eur\")"))?,
};
// Refuse a ceiling that limits nothing HERE, with the config file's own
// vocabulary, rather than letting the constructor say it in the library's.
ceiling.check().map_err(|e| anyhow::anyhow!("[clouds.{name}] cap: {e}"))?;
Some(ceiling)
} else {
None
};
let Some(factory) = factory else {
anyhow::bail!(
"[clouds.{name}] enabled = true, but that provider is not linked into this server (linked: {:?}). \
The open build links `server` always and `sandbox` behind its cargo feature; upcloud and scaleway \
are the vetra build's, from the private monetize-impl",
linked.cloud_names()
)
};
let mut settings = settings_json(&c.settings)?;
// A cloud GUEST's HTTP door reads its credential from the deployment's
// secrets directory — a top-level key, so the block cannot name it.
if c.settings.get("guest").is_some() {
if let Some(obj) = settings.as_object_mut() {
obj.insert("secrets_dir".into(), serde_json::Value::String(cfg.secrets_dir().display().to_string()));
}
}
let ctx = CloudBuild { name, data_dir: &cfg.data_dir, settings: Settings(&settings), ceiling, products };
// The box's own iron counts the set its grow batcher can still enlarge
// (g-dk-market): the `server` cloud is built with this registry's reach.
let inner = if factory.name == monetize_cloud::server::PROVIDER {
monetize_cloud::server::build_with(&ctx, reach.clone())
} else {
(factory.build)(&ctx)
}
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok((inner, ceiling))
}
/// A built cloud, guarded, and the cap's shared ledger beside it (for the slot to hold).
type GuardedCloud = (Box<dyn Cloud>, Option<Arc<monetize_cloud::cap::Cap>>);
/// **The guard every cloud leaves the registry in**: its `SpendCap`, whose
/// running total is read from `<name>-spend.json` NOW — which is why a live
/// replacement calls this only once the old provider's purchases are over.
/// The cap's shared ledger rides beside the guarded cloud, for the slot to hold.
fn wrap_cloud(name: &str, data_dir: &Path, inner: Box<dyn Cloud>, ceiling: Option<monetize_cloud::cap::Ceiling>) -> anyhow::Result<GuardedCloud> {
match ceiling {
Some(ceiling) => {
let spend_path = data_dir.join(format!("{name}-spend.json"));
let capped = monetize_cloud::cap::SpendCap::new(inner, ceiling, spend_path).map_err(|e| anyhow::anyhow!("[clouds.{name}] spend cap: {e}"))?;
let cap = capped.cap();
Ok((Box::new(capped), Some(cap)))
}
// THE ONE EXEMPTION from the ceiling: a provider whose factory says it
// cannot spend. Not trusted — held to it.
None => Ok((Box::new(monetize_cloud::cap::CannotSpend::new(inner)), None)),
}
}
/// The cloud of a server with none linked: refuses to provision, prices nothing.
pub struct NoCloud;
impl NoCloud {
fn none() -> CloudError {
CloudError::Refused("no cloud provider is linked into this server".into())
}
}
/// Every op refused (a destroy names the resource it does not know): a server with no
/// provider is not in a position to claim it owns nothing, nor to buy anything.
impl Cloud for NoCloud {
fn manifest(&self) -> CloudManifest {
CloudManifest { provider: "none".into(), zones: Vec::new(), settings: Vec::new(), grow_refused: Some(NoCloud::none().to_string()) }
}
fn catalog(&self, _zone: &Zone) -> Result<Catalog, CloudError> {
Err(NoCloud::none())
}
fn price(&self, _what: &Priced<'_>) -> Result<Price, CloudError> {
Err(NoCloud::none())
}
fn lay(&self, _plan: &monetize_cloud::lay::EstatePlan, _journal: &monetize_cloud::grow::Journal) -> Result<monetize_cloud::lay::LaidEstate, CloudError> {
Err(NoCloud::none())
}
fn change(&self, _change: &Change, _journal: &monetize_cloud::grow::Journal) -> Result<Changed, CloudError> {
Err(NoCloud::none())
}
fn boot(&self, _boot: &Boot, _journal: &monetize_cloud::grow::Journal) -> Result<Booted, CloudError> {
Err(NoCloud::none())
}
fn firewall(&self, _laid: &monetize_cloud::lay::LaidEstate) -> Result<(), CloudError> {
Err(NoCloud::none())
}
fn destroy(&self, what: &Give<'_>) -> Result<Reclaimed, CloudError> {
match what {
Give::Resource(id) => Err(CloudError::UnknownResource((*id).clone())),
Give::References(_) => Err(NoCloud::none()),
}
}
fn inventory(&self, what: &Listing<'_>) -> Result<Listed, CloudError> {
match what {
Listing::Status(id) => Err(CloudError::UnknownResource((*id).clone())),
Listing::Room { .. } => Ok(Listed::Room(RoomLeft::unwalled())),
Listing::Admits(_) => Ok(Listed::Admitted),
_ => Err(NoCloud::none()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use monetize_cloud::{Ensured, Resource};
use monetize::testing::{MockCloud, MockPay, MockProduct};
/// ★ HENRIK-1: **an operator's own site is the declared `operator_site` estate only**; a
/// customer's declared estate and an undeclared (sold) one are not.
#[test]
fn only_a_declared_operator_site_is_the_operators_own() {
let site = crate::config::EstateConfig { product: "gunnar".into(), owner: String::new(), operator_site: true, ..Default::default() };
let kund = crate::config::EstateConfig { product: "gunnar".into(), owner: "kund".into(), ..Default::default() };
let reg = Registry::new().estate("test2-gunnar-rs", site).estate("kund", kund);
assert!(reg.operator_site("test2-gunnar-rs"), "declared operator_site, even with no owner named");
assert!(!reg.operator_site("kund"));
assert!(!reg.operator_site("rickardtest"));
}
/// ★ G16 (ONE monetize): no estate runs a monetize, a sold one included, so no build
/// links the `shop` cloud a sold estate's own monetize grew through, and a config that
/// still names `[clouds.shop]` is refused at start by name.
#[test]
fn no_build_links_the_shop_cloud_of_a_sold_estates_own_monetize() {
assert!(Linked::open().find_cloud("shop").is_none(), "{:?}", Linked::open().cloud_names());
let cfg = Config::from_toml("data_dir = \"/nonexistent\"\n[clouds.shop]\nenabled = true\ncap = { total_minor = 0, currency = \"Eur\" }\ndoor = \"https://monetize.ignalina.dk\"\ncompany = \"acme\"\n").unwrap();
let e = Registry::from_config(&cfg).err().expect("refused at start").to_string();
assert!(e.contains("[clouds.shop]") && e.contains("not linked"), "{e}");
}
/// A capped cloud GUEST that does not load is skipped by name at start —
/// never the end of the server (owner 2026-09-26: the UpCloud guest on the
/// paid front must not take monetize down with a dead token).
#[test]
fn a_capped_cloud_guest_that_does_not_load_is_skipped_and_the_server_starts() {
let cfg = Config::from_toml("data_dir = \"/nonexistent\"\n[clouds.acloud]\nenabled = true\ncap = { monthly_minor = 5000, currency = \"Eur\" }\nguest = \"/nonexistent/acloud.wasm\"\nguest_digest = \"00\"\n").unwrap();
let reg = Registry::from_config(&cfg).expect("the server starts");
assert!(reg.cloud_names().is_empty());
let said: Vec<String> = reg.vendor_start_refusals().iter().map(|r| r.to_string()).collect();
assert!(said.iter().any(|l| l.contains("cloud-guest-refused-at-start") && l.contains("acloud")), "{said:?}");
// with no cap it is still refused whole, before anything is read
let nocap = Config::from_toml("data_dir = \"/nonexistent\"\n[clouds.acloud]\nenabled = true\nguest = \"/nonexistent/acloud.wasm\"\n").unwrap();
assert!(Registry::from_config(&nocap).is_err());
}
/// stinky monetize-server #23: a config with more vendors or clouds than
/// the registry has slots is a CONFIG error the start returns by name —
/// never a `panic!` that takes the server down.
#[test]
fn more_vendors_or_clouds_than_slots_is_a_config_error_not_a_panic() {
fn mock_vendor(_: &VendorBuild<'_>) -> Result<Box<dyn Payment>, String> {
Ok(Box::new(MockPay::new()))
}
fn mock_cloud(_: &CloudBuild<'_>) -> Result<Box<dyn Cloud>, String> {
Ok(Box::new(MockCloud::new()))
}
let slots = crate::hot::PROVIDER_SLOTS;
let mut linked = Linked::open();
linked.vendor(VendorFactory { name: "mock", config_envs: &[], build: mock_vendor });
let mut vendors = String::from("data_dir = \"/nonexistent\"\n");
for i in 0..=slots {
vendors.push_str(&format!("[vendors.mock.v{i}]\nenabled = true\n"));
}
let cfg = Config::from_toml(&vendors).unwrap();
let said = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Registry::from_config_with(&cfg, &linked).err().map(|e| format!("{e:#}"))))
.expect("too many vendors is a config error, not a panic")
.expect("too many vendors must refuse");
assert!(said.contains(&slots.to_string()) && said.contains("vendors"), "{said}");
let mut linked = Linked::open();
let mut clouds = String::from("data_dir = \"/nonexistent\"\n");
for i in 0..=slots {
let name: &'static str = Box::leak(format!("c{i}").into_boxed_str());
linked.cloud(CloudFactory { name, config_envs: &[], spends: false, build: mock_cloud });
clouds.push_str(&format!("[clouds.{name}]\nenabled = true\n"));
}
let cfg = Config::from_toml(&clouds).unwrap();
let said = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Registry::from_config_with(&cfg, &linked).err().map(|e| format!("{e:#}"))))
.expect("too many clouds is a config error, not a panic")
.expect("too many clouds must refuse");
assert!(said.contains(&slots.to_string()) && said.contains("clouds"), "{said}");
}
/// ★ release-3 (MEASURED on the dwarf mock 2026-09-29, install-orig-5): the
/// paid front's monetize started bare, the feed wrote `[clouds.server]
/// product = "gunnar"` before the start read its config, and the start
/// DIED — `[products.gunnar]` arrives only when gunnar self-registers its
/// plugin, after monetize serves. A cloud whose product is not linked YET
/// is skipped by name at start; the watcher takes it in once the product is.
#[test]
fn a_cloud_whose_product_is_not_linked_yet_is_skipped_and_the_server_starts() {
let cfg = Config::from_toml("data_dir = \"/nonexistent\"\n[clouds.server]\nenabled = true\nproduct = \"gunnar\"\npool_gib = 10\n").unwrap();
let reg = Registry::from_config(&cfg).expect("the server starts");
assert!(reg.cloud_names().is_empty());
let said: Vec<String> = reg.vendor_start_refusals().iter().map(|r| r.to_string()).collect();
assert!(said.iter().any(|l| l.contains("cloud-product-not-linked-at-start") && l.contains("server") && l.contains("gunnar")), "{said:?}");
}
/// **The tax page's jurisdiction comes from the estate's own `[tax]`**
/// (owner 2026-09-28: DK, 25 %, exclusive) when no implementation is
/// linked — not "no jurisdiction is linked" with every box `—`.
#[test]
fn an_open_build_takes_the_jurisdiction_its_tax_table_declares() {
let dir = std::path::Path::new("/nonexistent");
let open = Linked::open();
let dk = open.build_tax(dir, &serde_json::json!({"jurisdiction": "DK", "rate_percent": 25, "prices": "exclusive"})).unwrap();
assert_eq!(dk.jurisdiction(), "DK");
// Declaring none is the ONE box's case (owner 2026-10-11: Stripe decides per
// country, each payment is a row): the totals per buyer country, never `—`.
let per = open.build_tax(dir, &serde_json::json!({})).unwrap();
assert_eq!(per.jurisdiction(), monetize::tax::PER_COUNTRY, "declaring none is the per-country report");
assert!(open.build_tax(dir, &serde_json::json!({"jurisdiction": "Denmark", "rate_percent": 25})).is_err(), "refused by name, never a silent default");
}
/// **c-dead-front: a front's monetize starts before its product registers.**
/// A fresh or laid-again front is fed `[clouds.server] product = "gunnar"`
/// by the installer, and gunnar's primary registers `[products.gunnar]`
/// only later (self_register). The hot reload refused that block by name
/// and kept serving (MEASURED release-2 mock: `cloud REFUSED
/// [cloud-refused]`, then registered); a START on the same config ended
/// the server (`the server stopped … no [products.gunnar] is linked`,
/// MEASURED on the recharge mock, where the front waits for its feed
/// before monetize starts) — and nothing ever started it again. The start
/// skips such a cloud by name, like the reload; the watcher takes it in
/// once the product is there.
#[test]
fn a_cloud_whose_product_is_not_linked_yet_is_skipped_at_start_and_the_server_starts() {
let cfg = Config::from_toml("data_dir = \"/nonexistent\"\n[clouds.server]\nenabled = true\npool_gib = 10\nproduct = \"gunnar\"\n").unwrap();
let reg = Registry::from_config(&cfg).expect("the server starts");
assert!(!reg.cloud_names().contains(&"server"), "not built without its product");
let said: Vec<String> = reg.vendor_start_refusals().iter().map(|r| r.to_string()).collect();
assert!(said.iter().any(|l| l.contains("cloud-product-not-linked-at-start") && l.contains("gunnar")), "{said:?}");
}
#[test]
fn lookups_are_by_name_and_the_default_cloud_wins() {
let reg = Registry::new().product(MockProduct::gunnar()).vendor(MockPay::new()).cloud(MockCloud::new());
assert!(reg.find_product("gunnar").is_some() && reg.find_product("holger").is_none());
assert!(reg.find_vendor("mock").is_some() && reg.find_vendor("acme").is_none());
assert_eq!(monetize_cloud::Cloud::manifest(reg.cloud_for_transactions()).provider, "mockcloud");
let none = Registry::new();
assert_eq!(monetize_cloud::Cloud::manifest(none.cloud_for_transactions()).provider, "none");
assert_eq!(reg.default_cloud_name(), Some("mockcloud"), "the only cloud linked is the default");
assert_eq!(none.default_cloud_name(), None, "NoCloud is not a cloud a console may mark");
// Two linked: only a `default_cloud` that names one of them decides.
let two = || Registry::new().cloud(MockCloud::new()).cloud(MockCloud::new());
assert_eq!(two().default_cloud_name(), None, "two clouds and no default: a sale provisions nowhere");
assert_eq!(two().with_default_cloud(Some("elsewhere".into())).default_cloud_name(), None, "a name nothing linked answers to is not the default");
assert_eq!(two().with_default_cloud(Some("mockcloud".into())).default_cloud_name(), Some("mockcloud"));
assert!(matches!(
monetize_cloud::ask::ensure(&none.cloud_for_transactions(), "r", &Resource::Disk { server: monetize_cloud::ServerId("s".into()), gib: 1 }, None),
Err(CloudError::Refused(_))
));
}
/// ★ **THE APPLIANCE KEY REACHES THE HOST, AND A BAD ONE STOPS THE
/// SERVER RATHER THAN THE FIRST CUSTOMER.**
///
/// `[products.gunnar] appliance_key` is the whole of the tenant path's
/// configuration, and the two things that can go wrong with it are that it
/// is silently dropped (every self-service renewal refused, nobody knows
/// why) and that it is wrong (discovered at the first renewal, by a
/// customer). Both are checked here, at build time.
#[test]
fn the_appliance_key_is_read_from_the_product_block_and_refused_by_name_when_it_is_not_a_key() {
let dir = std::env::temp_dir().join(format!("monetize-appliance-key-{}-{:?}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
std::fs::create_dir_all(&dir).unwrap();
// A real Ed25519 public key, in the 64 hex characters an operator
// pastes out of the appliance.
let key = ed25519_dalek::SigningKey::from_bytes(&[11u8; 32]).verifying_key();
let hex: String = key.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
let cfg = |appliance_key: Option<&str>| crate::config::ProductConfig {
// Never dialled: the plugin connects lazily and nothing here calls it.
endpoint: "127.0.0.1:1".into(),
appliance_key: appliance_key.map(str::to_owned),
..Default::default()
};
// A32-13: the key is the HOST's (the product checks no ticket): read from the block at
// start, refused by name there when it is not a key.
assert_eq!(linked_ticket_key("gunnar", &cfg(Some(&hex))).expect("a configured key is read"), Some(hex.clone()), "the key must reach the host, or every self-service renewal is refused and nobody can say why");
assert_eq!(linked_ticket_key("gunnar", &cfg(None)).expect("a block with no key still builds"), None, "no key configured is no tenant renewal, and it must be the default");
let bad = linked_ticket_key("gunnar", &cfg(Some("not an ed25519 key")));
assert!(bad.is_err(), "a key that is not a key must stop the server, not the first customer");
assert!(format!("{:#}", bad.err().unwrap()).contains("appliance_key"), "and the error must name the setting");
let reg = Registry::new().product(crate::testing::products::TestProduct::gunnar()).ticket_key("gunnar", &hex);
assert_eq!(reg.product_ticket_key("gunnar"), Some(hex.clone()));
let other = ed25519_dalek::SigningKey::from_bytes(&[12u8; 32]).verifying_key().to_bytes().iter().map(|b| format!("{b:02x}")).collect::<String>();
assert!(reg.deliver_product_key("gunnar", &other).unwrap_err().contains("pins"), "the operator's key wins over a delivered one");
assert_eq!(reg.product_ticket_key("gunnar"), Some(hex.clone()));
let open = Registry::new().product(crate::testing::products::TestProduct::gunnar());
assert!(open.deliver_product_key("gunnar", "zz").is_err(), "a delivered key that is no key is refused");
open.deliver_product_key("gunnar", &other).expect("the home estate's key is taken");
assert_eq!(open.product_ticket_key("gunnar"), Some(other));
let _ = std::fs::remove_dir_all(&dir);
}
/// **The open set never contains a private name, and the seam takes one
/// on top without this file changing.** `Linked::open()` is exactly what the
/// features say; registering a foreign factory makes its name buildable and
/// its envs installable; the registry's own source has no string for it.
#[test]
fn the_open_set_is_what_the_features_say_and_a_registered_factory_is_found_by_name() {
let open = Linked::open();
for private in ["klarna", "stripe", "swish"] {
assert!(open.find_vendor(private).is_none(), "{private} must not be in the open set: {:?}", open.vendor_names());
}
for private in ["upcloud", "scaleway"] {
assert!(open.find_cloud(private).is_none(), "{private} must not be in the open set: {:?}", open.cloud_names());
}
assert!(open.find_cloud("server").map(|f| !f.spends).unwrap_or(false), "server is always linked, and cannot spend");
assert_eq!(open.find_vendor("invoice").is_some(), cfg!(feature = "invoice"));
assert_eq!(open.find_vendor("sandbox").is_some(), cfg!(feature = "sandbox"));
assert!(open.product_names().is_empty(), "the open build links no product plugin: {:?}", open.product_names());
assert_eq!(open.find_cloud("sandbox").map(|f| f.spends), cfg!(feature = "sandbox").then_some(true));
let src = include_str!("registry.rs");
for private in ["\"klarna\"", "\"stripe\"", "\"swish\"", "\"upcloud\"", "\"scaleway\""] {
let mentions = src.matches(private).count();
assert_eq!(mentions, 1, "{private} appears in registry.rs outside this assertion: the registry must not know the name");
}
// A foreign factory, registered on top: found, buildable, its envs carried.
fn build(ctx: &VendorBuild<'_>) -> Result<Box<dyn Payment>, String> {
assert_eq!((ctx.name, ctx.kind(), ctx.instance()), ("acme/play", "acme", "play"));
assert_eq!(ctx.settings.get::<String>("environment").unwrap().as_deref(), Some("playground"));
Ok(Box::new(MockPay::new()))
}
let mut linked = Linked::open();
linked.vendor(VendorFactory { name: "acme", config_envs: &["MONETIZE_ACME_SECRET", "MONETIZE_ACME_{INSTANCE}_KEY"], build });
// The open dummies' own envs ride along when their features are on (the money-free open
// default, 2026-10-24); the foreign factory's plain name is listed, its per-instance one is not.
let envs = linked.vendor_config_envs();
assert!(envs.contains(&"MONETIZE_ACME_SECRET"), "{envs:?}");
assert!(!envs.iter().any(|e| e.contains("{INSTANCE}")), "a per-instance name is not a name until it is expanded: {envs:?}");
let cfg = Config::from_toml("[vendors.acme.play]\nenabled = true\nenvironment = \"playground\"").unwrap();
assert_eq!(linked.vendor_instance_envs(&cfg), ["MONETIZE_ACME_PLAY_KEY"]);
let reg = Registry::from_config_with(&cfg, &linked).unwrap();
assert_eq!(reg.vendor_names(), vec!["acme/play"], "the server sees the instance, whatever the factory calls itself");
// Refuse twin: the same config against the OPEN set is refused by name.
let err = Registry::from_config(&cfg).err().map(|e| e.to_string()).expect("acme is not linked");
assert!(err.contains("[vendors.acme.play]") && err.contains("not linked"), "{err}");
}
/// **An enabled cloud with no ceiling must not start.** It is the only place
/// in monetize that spends money at a provider, and defaulting a number here
/// would be choosing the operator's credit limit for them — discovered on an
/// invoice. Absence is not unlimited, exactly as zero is not unlimited.
///
/// A name this binary cannot build is refused on the MISSING CAP rather than
/// on the missing provider, because the cap is checked first and deliberately so.
#[test]
fn refuse_twin_an_enabled_cloud_without_a_spend_ceiling_refuses_to_start() {
let no_cap = Config::from_toml("[clouds.upcloud]\nenabled = true").unwrap();
let err = Registry::from_config(&no_cap).err().map(|e| e.to_string()).expect("no ceiling must refuse");
assert!(err.contains("no `cap`") || err.contains("declares no"), "the reason must name the cap: {err}");
assert!(err.contains("monthly_minor"), "and show how to write one: {err}");
assert!(err.contains("0 is a valid") || err.contains("monthly_minor = 0"), "and say that zero is a real answer: {err}");
// A ZERO ceiling is accepted — it is the useful rehearsal setting, not a
// misconfiguration. (The provider is not linked into the open build, so
// it still fails — on the provider, which proves the cap check passed.)
let zero = Config::from_toml("[clouds.upcloud]\nenabled = true\ncap = { monthly_minor = 0, currency = \"Eur\" }").unwrap();
let err = Registry::from_config(&zero).err().map(|e| e.to_string()).expect("not linked into the open build");
assert!(!err.contains("cap") && err.contains("not linked"), "a zero ceiling is valid; the failure must be about the provider: {err}");
// A cloud that is NOT enabled needs no ceiling at all.
let off = Config::from_toml("[clouds.upcloud]\nenabled = false").unwrap();
assert!(Registry::from_config(&off).is_ok(), "a disabled cloud is not asked for a ceiling");
// The SANDBOX cloud is refused on the same missing cap, before its own
// constructor is reached: rule 4, it sits behind the ceiling like any provider.
let sb_no_cap = Config::from_toml("[clouds.sandbox]\nenabled = true\nprices = { disk_minor_per_gib_month = 23 }").unwrap();
let err = Registry::from_config(&sb_no_cap).err().map(|e| e.to_string()).expect("the sandbox cloud with no ceiling must refuse");
assert!(err.contains("[clouds.sandbox]") && err.contains("no `cap`"), "{err}");
}
/// The rehearsal cloud, both ways round so neither arm is vacuous: a build
/// that HAS it builds it capped and marked, and a build that does not must
/// refuse the name rather than fall through to something real.
#[test]
fn the_sandbox_cloud_is_built_capped_or_refused_by_name() {
let dir = std::env::temp_dir().join(format!("monetize-registry-sandbox-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let toml = format!(
"data_dir = {:?}\n[clouds.sandbox]\nenabled = true\ncap = {{ total_minor = 1000, currency = \"Eur\" }}\npool_gib = 0\n\
prices = {{ disk_minor_per_gib_month = 23, plan_minor_per_month = {{ \"STARTER-2xCPU-4GB\" = 1304 }} }}\n",
dir.display()
);
let cfg = Config::from_toml(&toml).unwrap();
#[cfg(feature = "sandbox")]
{
let reg = Registry::from_config(&cfg).unwrap();
assert_eq!(reg.cloud_names(), vec!["sandbox"]);
let cloud = reg.transaction_slot().expect("a cloud a sale spends at");
assert_eq!(cloud.spend_ceiling(), Some(monetize_cloud::cap::Ceiling::total(1000, monetize_pay::Currency::Eur)), "inside the cap");
// The applied output: a purchase through the registry's cloud lands as
// marked fake iron in the data dir, at the declared price.
let res = Resource::Disk { server: monetize_cloud::ServerId("gunnar-se-sto1".into()), gib: 10 };
match monetize_cloud::ask::ensure(&cloud, "gunnar/alice/2026-09", &res, None).unwrap() {
Ensured::Provisioned { id, cost } => {
assert_eq!(id.provider, "sandbox");
assert!(id.id.starts_with("sandbox:disk:"), "{}", id.id);
assert_eq!(cost.minor_per_month, 230, "23 × 10, declared");
assert_eq!(cost.currency, monetize_pay::Currency::Eur, "the table without a currency takes the cap's");
}
other => panic!("{other:?}"),
}
let state = std::fs::read_to_string(dir.join("sandbox-cloud.json")).expect("the registry gives a deployment a home for its iron");
assert!(state.contains("\"monetize_provenance\": \"sandbox\""), "{state}");
assert!(std::fs::read_to_string(dir.join("sandbox-spend.json")).unwrap().contains("230"), "the cap's total moved");
// And the install carries its env into the unit.
assert!(Linked::open().cloud_config_envs().contains(&"MONETIZE_SANDBOX_CLOUD_STATE"), "{:?}", Linked::open().cloud_config_envs());
// Refuse twin: a zero in the declared table cannot start.
let zero = Config::from_toml(&toml.replace("disk_minor_per_gib_month = 23", "disk_minor_per_gib_month = 0")).unwrap();
let err = Registry::from_config(&zero).err().map(|e| e.to_string()).expect("a zero price table must refuse");
assert!(err.contains("[clouds.sandbox]") && err.contains("disk at 0"), "{err}");
// A key that does not parse is a refusal naming the key, never "unset".
let bad = Config::from_toml(&toml.replace("pool_gib = 0", "pool_gib = \"lots\"")).unwrap();
let err = Registry::from_config(&bad).err().map(|e| e.to_string()).expect("a mistyped knob must refuse");
assert!(err.contains("[clouds.sandbox]") && err.contains("`pool_gib` does not parse"), "{err}");
}
#[cfg(not(feature = "sandbox"))]
{
let err = Registry::from_config(&cfg).err().map(|e| e.to_string()).expect("a build without the feature must refuse it");
assert!(err.contains("[clouds.sandbox]") && err.contains("not linked"), "{err}");
assert!(!Linked::open().cloud_config_envs().contains(&"MONETIZE_SANDBOX_CLOUD_STATE"));
}
}
/// ★ **Several sites of one product, each its own account** (owner 2026-10-11: the ONE
/// box sells gunnar for gunnar.rs AND test2.gunnar.rs, each through its own Stripe). With
/// `[estates]` declared for a product an order routes by ITS estate — the tenant's
/// (`@<estate>/<ns>`, else the product's own `instance`) or the calling estate's — and an
/// order with no estate, or one not declared, or declared for another product, is refused
/// by name: never the other site's account. A product with none declared keeps its route.
#[test]
fn several_sites_of_one_product_each_bill_through_their_own_account_and_no_other() {
use monetize_pay::{Currency, Market, Money};
let priced = Money { minor: 22_000, currency: Currency::Sek };
struct Named(&'static str);
impl Named {
pub fn vendor(&self) -> &'static str {
self.0
}
pub fn markets(&self) -> &[Market] {
&[Market::Se]
}
pub fn reserve(&self, _: &monetize_pay::ChargeRequest) -> Result<monetize_pay::ChargeHandle, monetize_pay::PayError> {
unreachable!()
}
pub fn draw(&self, _: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
unreachable!()
}
pub fn unlock(&self, _: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
unreachable!()
}
pub fn charge_status(&self, _: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
unreachable!()
}
pub fn parse_callback(&self, _: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
unreachable!()
}
pub fn give_back(&self, _: &monetize_pay::ChargeHandle, _: Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
unreachable!()
}
pub fn settled(&self, _: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
None
}
pub fn redirect(&self, _: &monetize_pay::ChargeHandle) -> Option<String> {
None
}
pub fn start_signup(&self, _: &monetize_pay::signup::SignupOrder) -> Result<Option<monetize_pay::signup::SignupCheckout>, monetize_pay::PayError> {
Ok(None)
}
}
impl monetize_pay::payment::Payment for Named {
fn manifest(&self) -> monetize_pay::payment::PayManifest {
monetize_pay::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
}
fn page(&self, charge: &monetize_pay::payment::Charge<'_>, _back: &monetize_pay::payment::Return) -> Result<monetize_pay::payment::Page, monetize_pay::PayError> {
Ok(match charge {
monetize_pay::payment::Charge::Order(req) => monetize_pay::payment::Page::Order { handle: self.reserve(req)?, url: None },
monetize_pay::payment::Charge::Signup(o) => monetize_pay::payment::Page::Signup(self.start_signup(o)?),
monetize_pay::payment::Charge::CardCheck(_o) => monetize_pay::payment::Page::CardCheck(None),
monetize_pay::payment::Charge::TestSignup(_o) => monetize_pay::payment::Page::TestSignup(None),
})
}
fn status(&self, of: &monetize_pay::payment::Of<'_>) -> Result<monetize_pay::payment::Standing, monetize_pay::PayError> {
Ok(match of {
monetize_pay::payment::Of::Charge(h) => monetize_pay::payment::Standing::Charge(self.charge_status(h)?),
monetize_pay::payment::Of::Page(h) => monetize_pay::payment::Standing::Page(self.redirect(h)),
monetize_pay::payment::Of::Signup { session: _ } => monetize_pay::payment::Standing::Signup(None),
monetize_pay::payment::Of::CardCheck { session: _ } => monetize_pay::payment::Standing::CardCheck(None),
})
}
fn capture(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.draw(h)
}
fn release(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.unlock(h)
}
fn refund(&self, h: &monetize_pay::ChargeHandle, amount: monetize_pay::Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.give_back(h, amount)
}
fn callback(&self, raw: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
self.parse_callback(raw)
}
fn settlement(&self, h: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
self.settled(h)
}
fn paid_since(&self, _since_unix_ms: i64) -> Result<Vec<monetize_pay::reconcile::PaidAtVendor>, monetize_pay::PayError> {
Err(monetize_pay::reconcile::cannot_be_asked())
}
fn livemode(&self) -> Option<bool> {
None
}
}
let site = |vendor: &str, site: &str| crate::config::EstateConfig { product: "gunnar".into(), vendor: vendor.into(), owner: "ignalina".into(), site: site.into(), ..Default::default() };
let r = Registry::new()
.vendor(Named("stripe/gunnar"))
.vendor(Named("stripe/gunnar-test2"))
.vendor(Named("stripe/holger"))
.route("gunnar", "stripe/gunnar")
.route("holger", "stripe/holger")
.home("gunnar", "gunnar-rs")
.estate("gunnar-rs", site("stripe/gunnar", "gunnar.rs"))
.estate("test2-gunnar-rs", site("stripe/gunnar-test2", "test2.gunnar.rs"));
let by = |estate: Option<&str>| r.vendor_for_estate(&priced, Market::Se, "gunnar", estate, "").map(|v| v.name);
assert_eq!(by(Some("test2-gunnar-rs")), Some("stripe/gunnar-test2"), "test2's own account");
assert_eq!(by(Some("gunnar-rs")), Some("stripe/gunnar"));
assert_eq!(by(None), None, "no estate named: refused, never the product's route");
assert_eq!(by(Some("acme-gunnar-rs")), None, "an estate not declared: refused, never another site's");
let e = r.route_for("gunnar", Some("acme-gunnar-rs")).unwrap_err();
assert!(e.contains("acme-gunnar-rs") && e.contains("[estates"), "{e}");
assert!(r.route_for("gunnar", None).unwrap_err().contains("which estate"), "the refusal says what is missing");
assert_eq!(r.vendor_for_estate(&priced, Market::Se, "gunnar", Some("test2-gunnar-rs"), "stripe/gunnar").map(|v| v.name), None, "asking for the other site's account is not substituted");
// ★ i54a run30: the console names the vendor KIND (`stripe`), the ONE box routes the
// calling site's INSTANCE — the kind is that instance, another kind never is.
let kind = "stripe/gunnar".split_once('/').unwrap().0; // the registry itself never names a vendor
assert_eq!(r.vendor_for_estate(&priced, Market::Se, "gunnar", Some("test2-gunnar-rs"), kind).map(|v| v.name), Some("stripe/gunnar-test2"), "the console's `stripe` is the site's own account");
assert_eq!(r.vendor_for_estate(&priced, Market::Se, "gunnar", Some("gunnar-rs"), kind).map(|v| v.name), Some("stripe/gunnar"));
assert_eq!(r.vendor_for_estate(&priced, Market::Se, "gunnar", Some("test2-gunnar-rs"), "other").map(|v| v.name), None, "another kind is not substituted");
assert!(r.route_for("holger", Some("test2-gunnar-rs")).is_err(), "an estate declared for gunnar never bills holger");
assert_eq!(r.vendor_for_estate(&priced, Market::Se, "holger", None, "").map(|v| v.name), Some("stripe/holger"), "no estates for holger: its own route, as before");
// A tenant's estate: `@<estate>/<ns>`, else the product's own.
assert_eq!(r.estate_of_tenant("gunnar", "@test2-gunnar-rs/nina").as_deref(), Some("test2-gunnar-rs"));
assert_eq!(r.estate_of_tenant("gunnar", "nina").as_deref(), Some("gunnar-rs"));
assert_eq!(r.vendor_for_tenant(&priced, Market::Se, "gunnar", "@test2-gunnar-rs/nina", "").map(|v| v.name), Some("stripe/gunnar-test2"));
assert_eq!(r.vendor_for_tenant(&priced, Market::Se, "gunnar", "nina", "").map(|v| v.name), Some("stripe/gunnar"));
assert_eq!(r.vendor_for_tenant(&priced, Market::Se, "gunnar", "@acme/nina", "").map(|v| v.name), None);
}
/// `[estates.<estate>]` at load: a product, an ENABLED vendor instance, one account per site.
#[test]
fn a_declared_estate_names_a_product_and_its_own_enabled_account() {
let base = "[products.gunnar]\nfree = true\nvendor = \"stripe/gunnar\"\n[vendors.stripe.gunnar]\nenabled = true\n[vendors.stripe.gunnar-test2]\nenabled = true\n";
let ok = format!("{base}[estates.gunnar-rs]\nproduct = \"gunnar\"\nvendor = \"stripe/gunnar\"\nowner = \"ignalina\"\nsite = \"gunnar.rs\"\n[estates.test2-gunnar-rs]\nproduct = \"gunnar\"\nvendor = \"stripe/gunnar-test2\"\nowner = \"ignalina\"\nsite = \"test2.gunnar.rs\"\n");
let cfg = Config::from_toml(&ok).expect("two sites, two accounts");
assert_eq!(cfg.estates["test2-gunnar-rs"].vendor, "stripe/gunnar-test2");
// ST-3: each site's own main's ticket key (64 hex), kept with its estate.
let key = "0b".repeat(32);
let keyed = Config::from_toml(&ok.replace("site = \"test2.gunnar.rs\"", &format!("site = \"test2.gunnar.rs\"\nappliance_key = \"{key}\""))).expect("a site's appliance key");
assert_eq!(keyed.estates["test2-gunnar-rs"].appliance_key, key);
for (bad, word) in [
(ok.replace("vendor = \"stripe/gunnar-test2\"", "vendor = \"stripe/nobody\""), "stripe/nobody"),
(ok.replace("vendor = \"stripe/gunnar-test2\"", "vendor = \"stripe/gunnar\""), "one account per site"),
(ok.replace("product = \"gunnar\"\nvendor = \"stripe/gunnar-test2\"", "product = \"\"\nvendor = \"stripe/gunnar-test2\""), "names no product"),
// ST-3: a site's appliance key that is no ed25519 public key is refused at load, by name.
(ok.replace("site = \"test2.gunnar.rs\"", "site = \"test2.gunnar.rs\"\nappliance_key = \"zz\""), "appliance_key"),
] {
let e = Config::from_toml(&bad).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("[estates.") && e.contains(word), "{word}: {e}");
}
}
/// **The vendor for an order is decided by the PRICE first**: nobody at
/// price 0, else the instance the PRODUCT is routed to — and `None` when
/// it has no route, the route serves nothing or not the market, or the
/// order names another, so the caller refuses by name rather than picking.
#[test]
fn the_vendor_for_an_order_is_nobody_at_price_zero_and_the_products_route_otherwise() {
use monetize_pay::{Currency, Market, Money};
let free = Money { minor: 0, currency: Currency::Sek };
let priced = Money { minor: 22_000, currency: Currency::Sek };
let none = Registry::new();
assert_eq!(none.vendor_for(&free, Market::Se, "gunnar", "acme").map(|v| v.name), Some("none"), "price 0 ignores the name: nobody is called");
assert!(none.vendor_for(&priced, Market::Se, "gunnar", "").is_none(), "220 SEK and nobody linked");
assert!(none.vendor_for(&priced, Market::Se, "gunnar", "acme").is_none());
let one = Registry::new().vendor(MockPay::new());
assert!(one.vendor_for(&priced, Market::Se, "gunnar", "").is_none(), "no route: the only vendor is NOT chosen");
let one = one.route("gunnar", "mock");
assert_eq!(one.vendor_for(&priced, Market::Se, "gunnar", "").map(|v| v.name), Some("mock"), "gunnar's route");
assert_eq!(one.vendor_for(&priced, Market::Fi, "gunnar", "mock").map(|v| v.name), Some("mock"));
assert!(one.vendor_for(&priced, Market::Se, "gunnar", "acme").is_none(), "a name that is not the route is not substituted");
assert!(one.vendor_for(&priced, Market::Se, "holger", "").is_none(), "another product's route is not borrowed");
// A route to a vendor that does not serve the market takes nothing.
struct Second;
impl Second {
pub fn vendor(&self) -> &'static str {
"second"
}
pub fn markets(&self) -> &[Market] {
&[Market::Se]
}
pub fn reserve(&self, _: &monetize_pay::ChargeRequest) -> Result<monetize_pay::ChargeHandle, monetize_pay::PayError> {
unreachable!()
}
pub fn draw(&self, _: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
unreachable!()
}
pub fn unlock(&self, _: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
unreachable!()
}
pub fn charge_status(&self, _: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
unreachable!()
}
pub fn parse_callback(&self, _: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
unreachable!()
}
pub fn give_back(&self, _: &monetize_pay::ChargeHandle, _: Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
unreachable!()
}
pub fn settled(&self, _: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
None
}
pub fn redirect(&self, _: &monetize_pay::ChargeHandle) -> Option<String> {
None
}
pub fn start_signup(
&self,
_: &monetize_pay::signup::SignupOrder,
) -> Result<Option<monetize_pay::signup::SignupCheckout>, monetize_pay::PayError> {
Ok(None)
}
}
impl monetize_pay::payment::Payment for Second {
fn manifest(&self) -> monetize_pay::payment::PayManifest {
monetize_pay::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
}
fn page(&self, charge: &monetize_pay::payment::Charge<'_>, _back: &monetize_pay::payment::Return) -> Result<monetize_pay::payment::Page, monetize_pay::PayError> {
Ok(match charge {
monetize_pay::payment::Charge::Order(req) => monetize_pay::payment::Page::Order { handle: self.reserve(req)?, url: None },
monetize_pay::payment::Charge::Signup(o) => monetize_pay::payment::Page::Signup(self.start_signup(o)?),
monetize_pay::payment::Charge::CardCheck(_o) => monetize_pay::payment::Page::CardCheck(None),
monetize_pay::payment::Charge::TestSignup(_o) => monetize_pay::payment::Page::TestSignup(None),
})
}
fn status(&self, of: &monetize_pay::payment::Of<'_>) -> Result<monetize_pay::payment::Standing, monetize_pay::PayError> {
Ok(match of {
monetize_pay::payment::Of::Charge(h) => monetize_pay::payment::Standing::Charge(self.charge_status(h)?),
monetize_pay::payment::Of::Page(h) => monetize_pay::payment::Standing::Page(self.redirect(h)),
monetize_pay::payment::Of::Signup { session: _ } => monetize_pay::payment::Standing::Signup(None),
monetize_pay::payment::Of::CardCheck { session: _ } => monetize_pay::payment::Standing::CardCheck(None),
})
}
fn capture(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.draw(h)
}
fn release(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.unlock(h)
}
fn refund(&self, h: &monetize_pay::ChargeHandle, amount: monetize_pay::Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.give_back(h, amount)
}
fn callback(&self, raw: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
self.parse_callback(raw)
}
fn settlement(&self, h: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
self.settled(h)
}
fn paid_since(&self, _since_unix_ms: i64) -> Result<Vec<monetize_pay::reconcile::PaidAtVendor>, monetize_pay::PayError> {
Err(monetize_pay::reconcile::cannot_be_asked())
}
fn livemode(&self) -> Option<bool> {
None
}
}
let two = Registry::new().vendor(MockPay::new()).vendor(Second).route("gunnar", "mock").route("holger", "second");
assert_eq!(two.vendor_for(&priced, Market::Se, "gunnar", "").map(|v| v.name), Some("mock"));
assert_eq!(two.vendor_for(&priced, Market::Se, "holger", "").map(|v| v.name), Some("second"));
assert!(two.vendor_for(&priced, Market::Fi, "holger", "").is_none(), "second does not serve FI");
assert!(two.vendor_for(&priced, Market::Se, "holger", "mock").is_none(), "holger's order never reaches gunnar's vendor");
}
#[test]
fn refuse_twin_an_enabled_vendor_that_is_not_linked_fails_at_start() {
let cfg = Config::from_toml("[vendors.acme.x]\nenabled = true").unwrap();
let err = Registry::from_config(&cfg).err().map(|e| e.to_string()).expect("must fail");
assert!(err.contains("acme") && err.contains("not linked"), "{err}");
let cfg = Config::from_toml("[products.zork]\nendpoint = \"x\"").unwrap();
assert!(Registry::from_config(&cfg).is_err());
let cfg = Config::from_toml("[products.holger]\nendpoint = \"127.0.0.1:1\"").unwrap();
// The open build links NO product: the same block builds only through a
// registered factory, and the refusal names what IS linked.
let err = Registry::from_config(&cfg).err().map(|e| e.to_string()).expect("holger is not linked into the open build");
assert!(err.contains("[products.holger]") && err.contains("no plugin") && err.contains("linked: []"), "{err}");
let mut with_holger = Linked::open();
with_holger.product(crate::testing::products::HOLGER_FACTORY);
assert_eq!(Registry::from_config_with(&cfg, &with_holger).unwrap().product_ids(), vec!["holger"]);
// The sandbox vendor, both ways round, so neither arm is vacuous: a build
// that HAS it can select it, and a build that does not must refuse the
// name rather than fall through to something real.
let sb = Config::from_toml("[vendors.sandbox.play]\nenabled = true").unwrap();
#[cfg(feature = "sandbox")]
{
assert_eq!(Registry::from_config(&sb).unwrap().vendor_names(), vec!["sandbox/play"]);
// A DEPLOYED sandbox must not be the in-memory one: its handles would
// evaporate on restart and a captured attempt become unrefundable.
let mut with_dir = sb.clone();
with_dir.data_dir = std::path::PathBuf::from("/var/lib/monetize");
let _ = Registry::from_config(&with_dir).unwrap();
let sbx = monetize_pay::sandbox::SandboxConfig::from_env();
assert!(sbx.state_path.is_none(), "the ENV default is in-memory; the registry is what gives a deployment a home");
}
#[cfg(not(feature = "sandbox"))]
{
let err = Registry::from_config(&sb).err().map(|e| e.to_string()).expect("a build without the feature must refuse it");
assert!(err.contains("sandbox") && err.contains("not linked"), "{err}");
}
}
/// A linked factory named `name` whose product answers `gunnar`, as the
/// gunnar plugin does whatever block it is built for.
fn answers_gunnar(name: &'static str) -> ProductFactory {
ProductFactory { name, build: |_| Ok(Arc::new(|| MockProduct::gunnar().facts())), mint_identity: |_| Err("no identity".into()), guardian: None }
}
/// **G15: two estates of one product are two products.** gunnar.rs and
/// test2.gunnar.rs both ran the gunnar plugin and both answered `gunnar`;
/// `[products.gunnar-test2] plugin = "gunnar"` serves the same plugin as
/// product `gunnar-test2`, and every lookup keyed on the id finds its own.
#[test]
fn a_block_naming_its_plugin_is_served_under_its_own_id() {
let cfg = Config::from_toml("[products.gunnar]\n[products.gunnar-test2]\nplugin = \"gunnar\"\n").unwrap();
let linked = { let mut l = Linked::open(); l.product(answers_gunnar("gunnar")); l };
let reg = Registry::from_config_with(&cfg, &linked).expect("two blocks, one plugin");
let mut ids: Vec<&str> = reg.products().map(|p| p.id()).collect();
ids.sort();
assert_eq!(ids, ["gunnar", "gunnar-test2"]);
assert_eq!(reg.find_product("gunnar-test2").map(|p| p.id()), Some("gunnar-test2"));
// A guest block carries the name to the guest host the same way.
let g = Config::from_toml("[products.gunnar-test2]\nplugin = \"gunnar\"\nguest = \"/x/gunnar.wasm\"\n").unwrap();
assert_eq!(guest_spec(&g.products["gunnar-test2"]).and_then(|s| s.plugin), Some("gunnar".into()));
}
/// **G15: one product id, two blocks, is refused at load** — by name, with
/// the way out. A plugin that answers an id other than its block's would
/// otherwise serve as a SECOND `gunnar`: facts, prices and the ledger for
/// two estates under one id.
#[test]
fn two_blocks_serving_one_product_id_are_refused_at_load() {
let cfg = Config::from_toml("[products.gunnar]\n[products.gunnar2]\n").unwrap();
let linked = { let mut l = Linked::open(); l.product(answers_gunnar("gunnar")); l.product(answers_gunnar("gunnar2")); l };
let e = format!("{:#}", Registry::from_config_with(&cfg, &linked).err().expect("one id, two blocks"));
assert!(e.contains("[products.gunnar2]") && e.contains("`gunnar`") && e.contains("plugin"), "{e}");
// Alone, the mismatch is refused too: the block's id is the product id.
let alone = Config::from_toml("[products.gunnar2]\n").unwrap();
assert!(Registry::from_config_with(&alone, &linked).is_err(), "a block whose plugin answers another id");
}
}