crates/monetize-product-wasm/src/signup.rs
//! **The signup guest host** — `[signups.<product>] guest = "<name>.wasm"`:
//! the sink that CREATES a paying customer's account for that product (one
//! guest per product; the server hands each block its `product`), as a signed plugin taken in at install
//! (owner decision 2026-09-26), behind the same
//! [`monetize_pay::signup::SignupSink`] the engine already calls.
//!
//! ```text
//! [signups.<p>] guest = "…wasm" ──▶ GuestSignup::build (the product guest's load order,
//! guest_digest / trust_roots kind = "signup", named for `product`)
//! grants = ["grpc","state"] ──▶ exactly those doors (no `sign`, no identity: T13)
//! endpoint / bearer_token ──▶ the product's control plane, the bearer on every call
//! plans ──▶ the HOST's: the entitlement is computed and SIGNED here
//! every other key ──▶ the guest's own settings (product, reserved, …)
//! ```
//!
//! # What the host keeps
//!
//! * **The fact.** The server hands a closure that turns a paid signup into
//! its SIGNED entitlement (`[signups.<product>.plans]` + monetize's own key); the guest
//! delivers it as it arrived and never holds the key.
//! * **The state**, durably: the guest's claims (which session made which
//! account) live in `<data_dir>/signup-guest-state.<product>.json`, one per product, so a retry after a
//! restart finishes its own customer instead of refusing their name.
//! * **One call at a time.**
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use guest_host::module::{Instantiated, LoadedModule};
use guest_host::wasmtime::Linker;
use monetize_pay::signup::{NameVerdict, PaidSignup, SignupError, SignupOutcome, SignupSink};
use monetize_product::{EntitlementFact, GuestSpec};
use monetize_signup_guest::ops;
use monetize_signup_guest::wire::{Call, CreateReq};
use crate::doors::{self, CallState, DoorSpec, Doors};
use crate::{Block, LoadedGuest};
/// The signup kind's fuel ceiling per call: a name check and two control-plane
/// calls are small.
pub const SIGNUP_FUEL_CEILING: u64 = 5_000_000_000;
/// The wall-clock ceiling per call.
pub const SIGNUP_CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// One control-plane call through the door.
pub const SIGNUP_DOOR_TIMEOUT: Duration = Duration::from_secs(30);
/// **The keys of `[signups.<product>]` that are the HOST's** — never sent to the guest.
pub const HOST_KEYS: &[&str] = &[
"guest",
"guest_digest",
"guest_trust_roots",
"grants",
"http_hosts",
"http_auth",
"secrets_dir",
"guest_fuel",
"guest_call_timeout_ms",
"guest_max_memory_bytes",
"endpoint",
"bearer_token",
"tls_ca",
"plans",
"max_sold_percent_of_capacity",
];
/// Does this `[signups.<product>]` block name a guest?
pub fn is_guest_block(settings: &serde_json::Value) -> bool {
settings.get("guest").is_some_and(|v| !v.is_null())
}
fn get<T: serde::de::DeserializeOwned>(s: &serde_json::Value, k: &str) -> Result<Option<T>, String> {
match s.get(k) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| format!("signup guest block: `{k}` does not parse: {e}")),
}
}
/// The block's guest keys, typed.
pub fn guest_spec(settings: &serde_json::Value) -> Result<Option<GuestSpec>, String> {
if !is_guest_block(settings) {
return Ok(None);
}
Ok(Some(GuestSpec {
module: get::<PathBuf>(settings, "guest")?.unwrap_or_default(),
digest: get(settings, "guest_digest")?,
trust_roots: get(settings, "guest_trust_roots")?.unwrap_or_default(),
grants: get(settings, "grants")?.unwrap_or_default(),
http_hosts: get(settings, "http_hosts")?.unwrap_or_default(),
grpc_hosts: Vec::new(),
fuel: get(settings, "guest_fuel")?,
call_timeout_ms: get(settings, "guest_call_timeout_ms")?,
max_memory_bytes: get(settings, "guest_max_memory_bytes")?,
http_auth: get(settings, "http_auth")?.unwrap_or_default(),
hmac_secrets: Vec::new(),
secrets_dir: get(settings, "secrets_dir")?,
plugin: None,
}))
}
/// **The product's bearer** (`bearer_token`), which the HOST presents on every
/// call through the gRPC door — the credential of a product that authenticates by
/// bearer (holger's control plane; the front writes it). Blank is none. Never in
/// the guest's settings ([`HOST_KEYS`]).
pub fn door_bearer(settings: &serde_json::Value) -> Result<Option<String>, String> {
Ok(get::<String>(settings, "bearer_token")?.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()))
}
/// The block as the GUEST sees it: every key but [`HOST_KEYS`].
pub fn guest_settings(settings: &serde_json::Value) -> serde_json::Value {
let mut out = serde_json::Map::new();
if let Some(obj) = settings.as_object() {
for (k, v) in obj {
if !HOST_KEYS.contains(&k.as_str()) {
out.insert(k.clone(), v.clone());
}
}
}
serde_json::Value::Object(out)
}
/// **The signed entitlement a paid signup grants** — the server's to compute
/// (it holds the plans and the key); `Err` names why there is none.
pub type FactFor = Box<dyn Fn(&PaidSignup) -> Result<EntitlementFact, String> + Send + Sync>;
/// **A signup guest, as the server holds it.**
pub struct GuestSignup {
product: String,
module: LoadedModule,
doors: Arc<Doors>,
settings: serde_json::Value,
fact_for: FactFor,
state_file: PathBuf,
saved: Mutex<BTreeMap<String, Vec<u8>>>,
serial: Mutex<()>,
}
impl std::fmt::Debug for GuestSignup {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GuestSignup").field("product", &self.product).field("module", &self.module.path()).field("digest", &self.module.digest_hex()).finish()
}
}
/// One state file per product: two products' guests never share claims.
fn state_path(data_dir: &Path, product: &str) -> PathBuf {
data_dir.join(format!("signup-guest-state.{product}.json"))
}
fn load_state(path: &Path) -> Result<BTreeMap<String, Vec<u8>>, String> {
match std::fs::read(path) {
Ok(b) => serde_json::from_slice(&b).map_err(|e| format!("signup guest state {} is unreadable: {e}; refused rather than started from nothing, which could refuse a customer the account they paid for", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
Err(e) => Err(format!("signup guest state {}: {e}", path.display())),
}
}
fn save_state(path: &Path, state: &BTreeMap<String, Vec<u8>>) -> Result<(), String> {
let bytes = serde_json::to_vec(state).map_err(|e| e.to_string())?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| format!("signup guest state {}: {e}", path.display()))
}
impl GuestSignup {
/// The whole load order (kind `signup`, named for the block's `product`),
/// the doors, the state, then `check`. The block arrives with `product`
/// set from its name (`[signups.<product>]`); one without is refused, never
/// defaulted to a product it might not be.
pub fn build(data_dir: &Path, settings: &serde_json::Value, fact_for: FactFor) -> Result<GuestSignup, String> {
let product: String = get::<String>(settings, "product")?
.filter(|p| !p.trim().is_empty())
.ok_or("a signup guest block names no `product`: the server hands every [signups.<product>] its product, and a guest is never defaulted to one")?;
let blk = format!("[signups.{product}]");
let spec = guest_spec(settings)?.ok_or_else(|| format!("{blk} is built by the signup guest host but names no `guest = \"<path>.wasm\"`"))?;
let block = Block { section: "signups", id: &product, spec: Some(&spec), kind: monetize_product_guest::kinds::SIGNUP };
let loaded = LoadedGuest::load_block_within(block, SIGNUP_FUEL_CEILING, SIGNUP_CALL_TIMEOUT)?;
let endpoint: String = get(settings, "endpoint")?.unwrap_or_default();
let tls_ca: Option<PathBuf> = get(settings, "tls_ca")?;
let bearer = door_bearer(settings)?;
if loaded.spec.grants.iter().any(|g| g == monetize_product_guest::grants::GRPC) && endpoint.trim().is_empty() {
return Err(format!("{blk} its guest reaches the product's control plane (`grpc`) and the block names no `endpoint`"));
}
let secrets_dir = loaded.spec.secrets_dir.clone().unwrap_or_else(|| data_dir.join("secrets"));
let call_timeout = loaded.module.limits().call_timeout.min(SIGNUP_CALL_TIMEOUT);
let doors = Doors::open_with(DoorSpec {
product: &product,
endpoint: &endpoint,
bearer: bearer.as_deref(),
twin: None,
tls_ca: tls_ca.as_deref(),
tls_client: None,
grants: &loaded.spec.grants,
http_hosts: &loaded.spec.http_hosts,
grpc_hosts: &[],
call_timeout: SIGNUP_DOOR_TIMEOUT.min(call_timeout),
http_auth: &loaded.spec.http_auth,
hmac_secrets: &[],
secrets_dir: Some(&secrets_dir),
http_resolve: &[],
})
.map_err(|e| format!("{blk} {e}"))?;
let state_file = state_path(data_dir, &product);
let initial = load_state(&state_file)?;
*doors.state.lock().unwrap_or_else(|p| p.into_inner()) = initial.clone();
let g = GuestSignup {
product,
module: loaded.module,
doors: Arc::new(doors),
settings: guest_settings(settings),
fact_for,
state_file,
saved: Mutex::new(initial),
serial: Mutex::new(()),
};
g.call::<(), ()>(ops::CHECK, &()).map_err(|e| format!("{blk} its guest refused its own settings: {e:?}"))?;
Ok(g)
}
pub fn digest_hex(&self) -> &str {
self.module.digest_hex()
}
pub fn signer(&self) -> Option<&str> {
self.module.signer()
}
pub fn product(&self) -> &str {
&self.product
}
fn instantiate(&self) -> Result<Instantiated<CallState>, String> {
let mut linker = Linker::new(self.module.engine());
doors::register(&mut linker, &self.doors.granted_imports)?;
let state = CallState::new(Arc::clone(&self.doors), self.module.limits().limiter());
self.module.instantiate(state, &linker).map_err(|e| e.to_string())
}
/// **One op**, serialised, the state written back whatever the answer. A
/// HOST-side failure (a bound, a trap, a shape) is `Unavailable` — the
/// engine keeps the signup pending and retries; never a `Created`.
fn call<A: serde::Serialize, T: serde::de::DeserializeOwned>(&self, op: u32, req: &A) -> Result<T, SignupError> {
let _one = self.serial.lock().unwrap_or_else(|p| p.into_inner());
let unavailable = |e: String| SignupError::Unavailable(format!("[signups.{}] guest {}: op {op} ({}): {e}", self.product, self.module.path().display(), ops::name(op).unwrap_or("?")));
let payload = monetize_signup_guest::wire::encode(&Call { settings: self.settings.clone(), req });
let raw = self.instantiate().map_err(unavailable).and_then(|mut inst| self.module.call_op(&mut inst, op, &payload).map_err(|e| unavailable(e.to_string())));
self.doors.sweep_streams();
self.persist().map_err(unavailable)?;
let raw = raw?;
let answer: Result<T, SignupError> = monetize_signup_guest::wire::decode(&raw).map_err(|e| unavailable(format!("the guest answered undecodable bytes: {e}")))?;
answer
}
fn persist(&self) -> Result<(), String> {
let now = self.doors.state.lock().unwrap_or_else(|p| p.into_inner()).clone();
let mut saved = self.saved.lock().unwrap_or_else(|p| p.into_inner());
if *saved != now {
save_state(&self.state_file, &now)?;
*saved = now;
}
Ok(())
}
}
impl SignupSink for GuestSignup {
fn paid(&self, s: &PaidSignup) -> Result<SignupOutcome, SignupError> {
// The shape rule costs no call, and a refusal that costs no call
// reaches the customer with their own words in it.
if let Some(why) = monetize_pay::signup::username_refusal(&s.username) {
return Err(SignupError::NameTaken(why));
}
let fact = (self.fact_for)(s).map_err(SignupError::Refused)?;
let out: SignupOutcome = self.call(ops::CREATE, &CreateReq { signup: s.clone(), fact })?;
Ok(out)
}
fn name_verdict(&self, username: &str) -> Result<NameVerdict, SignupError> {
if let Some(why) = monetize_pay::signup::username_refusal(username) {
return Ok(NameVerdict::Taken(why));
}
self.call(ops::NAME_VERDICT, &username.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **G4: a signup guest serves the product its block names, and no
/// other.** The server hands every `[signups.<product>]` its `product`; a
/// block without one is refused BEFORE any module is read — never defaulted
/// to gunnar, which made holger's signup gunnar's.
#[test]
fn a_block_with_no_product_is_refused_and_never_defaulted_to_gunnar() {
let dir = std::env::current_dir().unwrap();
let fact_for: FactFor = Box::new(|_| Err("not asked".to_string()));
let e = GuestSignup::build(&dir, &serde_json::json!({"guest": "/nowhere/signup.wasm"}), fact_for).unwrap_err();
assert!(e.contains("names no `product`"), "{e}");
assert_ne!(state_path(&dir, "gunnar"), state_path(&dir, "holger"), "two products' guests never share their claims");
}
/// ★ **The product's bearer is the HOST's** (`bearer_token`, which the front
/// writes into `[signups.<product>]` for a product that authenticates by bearer —
/// holger's control plane): presented on every call through the gRPC door,
/// never handed to the guest in its settings.
#[test]
fn the_bearer_is_the_host_s_and_never_the_guest_s() {
let settings = serde_json::json!({"guest": "/g.wasm", "product": "holger", "bearer_token": "SENTINEL-bearer", "reserved": ["x"]});
let g = guest_settings(&settings);
assert!(g.get("bearer_token").is_none(), "{g}");
assert_eq!(g.get("product").and_then(|p| p.as_str()), Some("holger"));
assert_eq!(door_bearer(&settings).unwrap().as_deref(), Some("SENTINEL-bearer"));
assert_eq!(door_bearer(&serde_json::json!({"guest": "/g.wasm"})).unwrap(), None);
assert_eq!(door_bearer(&serde_json::json!({"bearer_token": " "})).unwrap(), None, "blank is none");
}
}