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

nordisk/monetize

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