crates/monetize-signup-guest/src/lib.rs
//! **The guest side of a monetize SIGNUP sink** — the plugin that CREATES a
//! paying customer's account on the product (owner decision 2026-09-26: the
//! signup is a signed WASM plugin inserted at install, like the vendors).
//!
//! A paid signup arrives from a stranger through a vendor's webhook. The
//! engine (`monetize-server::signup`) has checked the money; the SINK makes the
//! account — and only a sink that really makes one may answer
//! [`SignupOutcome::Created`]. The open server holds no sink of its own: with
//! no guest loaded it refuses to sell a signup at all.
//!
//! | piece | what it is |
//! |---|---|
//! | [`SignupGuest`] | the trait: the name verdict (before any money moves) and the creation (the principal, then the entitlement the HOST signed) |
//! | [`ops`] | one number per method, 300..399 (products 1..99, vendors 100..199, clouds 200..299) |
//! | [`wire`] | [`wire::Call`] (the block's settings + the op's request) and [`wire::CreateReq`] |
//! | [`dispatch`] | the op switch, used by the wasm export and by native tests |
//! | [`export_signup!`] | the exports, over the scaffolding every guest kind shares |
//!
//! # What the guest never holds
//!
//! * **monetize's signing key.** The entitlement a signup grants is computed
//! and SIGNED by the host from `[signups.<product>.plans]`; the guest delivers the
//! signed fact to the product exactly as it arrived.
//! * **The product credential.** The host presents the block's bearer on every gRPC call; the
//! guest never reads it. (The `sign` grant and its identity key went in monetize v2.0 T13.)
pub use monetize_pay;
pub use monetize_pay::signup::{NameVerdict, PaidSignup, SignupError, SignupOutcome};
pub use monetize_product_guest as product_guest;
pub use monetize_product_guest::{abi, grants, kinds, EntitlementFact, Host, Manifest, NoHost};
/// **The signup op table.** Numbers are the wire; names are for logs.
pub mod ops {
/// The id — the same op number every kind answers.
pub const ID: u32 = monetize_product_guest::ops::ID;
/// The block's settings are acceptable (asked once at load).
pub const CHECK: u32 = 300;
/// Is this name one a stranger may buy? Asked before any card form.
pub const NAME_VERDICT: u32 = 310;
/// A PAID signup: create the account, deliver the signed entitlement, and
/// only then answer `Created`.
pub const CREATE: u32 = 311;
pub const ALL: &[(u32, &str)] = &[(ID, "id"), (CHECK, "check"), (NAME_VERDICT, "name_verdict"), (CREATE, "create")];
pub fn name(op: u32) -> Option<&'static str> {
ALL.iter().find(|(n, _)| *n == op).map(|(_, s)| *s)
}
}
/// **The wire**: JSON both ways, the product guest's encoding.
pub mod wire {
use super::*;
pub use monetize_product_guest::wire::{decode, encode};
/// **Every op's request**: the `[signups.<product>]` block's settings (the keys the
/// host does not read itself — never a secret, never the plans' prices)
/// and the op's own request.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Call<T> {
pub settings: serde_json::Value,
pub req: T,
}
/// [`ops::CREATE`]'s request: the verified paid signup, and the entitlement
/// the host computed from the plan and SIGNED — delivered as it is.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CreateReq {
pub signup: PaidSignup,
pub fact: EntitlementFact,
}
/// Every op's answer envelope: [`SignupError`]'s arms kept, because the
/// engine answers a stranger by the ARM (`NameTaken` lets the return page
/// offer another name; `Unavailable` keeps the signup pending and retried).
pub type Answer<T> = Result<T, SignupError>;
}
use wire::{Call, CreateReq};
/// **A signup sink, as a guest writes it.**
pub trait SignupGuest {
/// `Manifest::of_kind(kinds::SIGNUP, "<product>", &[grants…])`: the product
/// is the `[signups.<product>]` name it creates accounts on.
fn manifest(&self) -> Manifest;
/// Are these settings ones this sink can create an account with? Asked once
/// at load, so a block that could never make a customer is refused where an
/// operator is looking, not when the first stranger has paid.
fn check(&self, host: &dyn Host, settings: &serde_json::Value) -> Result<(), String> {
let _ = (host, settings);
Ok(())
}
/// The name, before any money moves. It reserves nothing.
fn name_verdict(&self, host: &dyn Host, settings: &serde_json::Value, username: &str) -> Result<NameVerdict, SignupError>;
/// **The paid signup**: make the account, deliver `fact`, answer `Created`
/// ONLY when both are on the product. Idempotent: the engine retries an
/// `Unavailable`, and a retry of a signup this sink already created must
/// finish it, not refuse its own customer's name.
fn create(&self, host: &dyn Host, settings: &serde_json::Value, signup: &PaidSignup, fact: &EntitlementFact) -> Result<SignupOutcome, SignupError>;
}
/// **The op switch** — one implementation, used by the wasm export and by a
/// native test. An unknown op or an undecodable request is `Unavailable` in the
/// envelope, never a trap and never a `Created`.
pub fn dispatch(guest: &dyn SignupGuest, host: &dyn Host, op: u32, payload: &[u8]) -> Vec<u8> {
fn call<T: serde::de::DeserializeOwned>(op: u32, payload: &[u8]) -> Result<Call<T>, SignupError> {
wire::decode(payload).map_err(|e| SignupError::Unavailable(format!("op {op} ({}): undecodable request: {e}", ops::name(op).unwrap_or("?"))))
}
fn ans<T: serde::Serialize>(r: Result<T, SignupError>) -> Vec<u8> {
wire::encode(&r)
}
match op {
ops::ID => ans::<String>(Ok(guest.manifest().product)),
ops::CHECK => ans(call::<()>(op, payload).and_then(|c| guest.check(host, &c.settings).map_err(SignupError::Refused))),
ops::NAME_VERDICT => ans(call::<String>(op, payload).and_then(|c| guest.name_verdict(host, &c.settings, &c.req))),
ops::CREATE => ans(call::<CreateReq>(op, payload).and_then(|c| guest.create(host, &c.settings, &c.req.signup, &c.req.fact))),
other => ans::<()>(Err(SignupError::Unavailable(format!("op {other} is not in this signup guest's op table")))),
}
}
/// Generate the exports for a [`SignupGuest`] — the product guest's scaffolding
/// with this crate's [`dispatch`].
///
/// ```ignore
/// monetize_signup_guest::export_signup!(HolgerSignup, grants = [grpc, state]);
/// ```
#[macro_export]
macro_rules! export_signup {
($guest:ty, grants = [$($grant:ident),* $(,)?] $(,)?) => {
$crate::product_guest::__export_guest!($guest, $crate::SignupGuest, $crate::dispatch, grants = [$($grant),*]);
};
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Canned;
impl SignupGuest for Canned {
fn manifest(&self) -> Manifest {
Manifest::of_kind(kinds::SIGNUP, "gunnar", &[])
}
fn name_verdict(&self, _: &dyn Host, _: &serde_json::Value, username: &str) -> Result<NameVerdict, SignupError> {
Ok(if username == "taken" { NameVerdict::Taken("taken".into()) } else { NameVerdict::Free })
}
fn create(&self, _: &dyn Host, settings: &serde_json::Value, s: &PaidSignup, fact: &EntitlementFact) -> Result<SignupOutcome, SignupError> {
if s.username == "down" {
return Err(SignupError::Unavailable("gunnar off".into()));
}
assert_eq!(settings["product"], "gunnar");
Ok(SignupOutcome::Created { namespace: fact.tenant.0.clone(), username: s.username.clone() })
}
}
fn signup(user: &str) -> PaidSignup {
let m = monetize_pay::Money { minor: 10000, currency: monetize_pay::Currency::Sek };
PaidSignup { vendor: "stripe".into(), session: "cs_1".into(), reference: String::new(), email: String::new(), username: user.into(), product: "gunnar".into(), plan: "gunnar/10gb-1m".into(), amount: m, tax: monetize_pay::Money { minor: 0, currency: monetize_pay::Currency::Sek }, paid_at_unix_ms: 1, customer_country: None }
}
fn fact(tenant: &str) -> EntitlementFact {
EntitlementFact { tenant: monetize_product_guest::TenantId(tenant.into()), plan: "p".into(), state: monetize_product_guest::State::Paid, paid_until_unix_ms: Some(2), caps: Default::default(), source: "payment:stripe:p".into(), signature: vec![1], issued_unix_ms: None, issued_signature: Vec::new() }
}
#[test]
fn every_op_round_trips_and_errors_keep_their_arm() {
let settings = serde_json::json!({"product": "gunnar"});
let v: wire::Answer<NameVerdict> = wire::decode(&dispatch(&Canned, &NoHost, ops::NAME_VERDICT, &wire::encode(&Call { settings: settings.clone(), req: "taken".to_string() }))).unwrap();
assert!(matches!(v, Ok(NameVerdict::Taken(_))));
let fact = fact("bob");
let ok: wire::Answer<SignupOutcome> = wire::decode(&dispatch(&Canned, &NoHost, ops::CREATE, &wire::encode(&Call { settings: settings.clone(), req: CreateReq { signup: signup("bob"), fact: fact.clone() } }))).unwrap();
assert_eq!(ok, Ok(SignupOutcome::Created { namespace: "bob".into(), username: "bob".into() }));
let down: wire::Answer<SignupOutcome> = wire::decode(&dispatch(&Canned, &NoHost, ops::CREATE, &wire::encode(&Call { settings, req: CreateReq { signup: signup("down"), fact } }))).unwrap();
assert_eq!(down, Err(SignupError::Unavailable("gunnar off".into())));
let unknown: wire::Answer<()> = wire::decode(&dispatch(&Canned, &NoHost, 999, b"{}")).unwrap();
assert!(matches!(unknown, Err(SignupError::Unavailable(m)) if m.contains("999")), "never a Created by accident");
let bad: wire::Answer<SignupOutcome> = wire::decode(&dispatch(&Canned, &NoHost, ops::CREATE, b"not json")).unwrap();
assert!(matches!(bad, Err(SignupError::Unavailable(m)) if m.contains("undecodable")));
assert_eq!(Canned.manifest().kind, kinds::SIGNUP);
}
}