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

nordisk/monetize

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