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

nordisk/monetize

crates/monetize-proto/src/lib.rs

//! # monetize-proto: the gRPC contract
//!
//! The `.proto` files in `proto/monetize/v1/` and their tonic-build output, plus
//! the hand-written conversions between the wire messages and the Rust
//! contracts they mirror 1:1. No behaviour: the services live in
//! `monetize-server`.
//!
//! | service | what it owns |
//! |---|---|
//! | [`entitlement_client`] / [`entitlement_server`] | the signed verdict per tenant: Get, List, Set, Snapshot |
//! | [`purchase_client`] / [`purchase_server`] | the transaction: Bundles (the priced shop window), Quote, Start, Status, Abandon, History (the vendor callback is HTTP) |
//! | [`usage_client`] / [`usage_server`] | the poller's readings per tenant: Read, ReadAll, History (a kept series) |
//! | [`products_client`] / [`products_server`] | which plugins are linked in, their meters, the price lists and the policy |
//! | [`ledger_client`] / [`ledger_server`] | the append-only ledger, read side |
//! | [`signup_client`] / [`signup_server`] | what became of a vendor checkout session that carried a PAID SIGNUP: one read-only `Status`, safe to poll from a return page |
//! | [`signup_admin_client`] / [`signup_admin_server`] | the operator's list of the same store, refusals included |
//! | [`callback_admin_client`] / [`callback_admin_server`] | the operator's list of REFUSED vendor callbacks — the bounded ring in `monetize_server::refusals` |
//! | [`reconcile_admin_client`] / [`reconcile_admin_server`] | the operator's list of payments the VENDOR says it took and monetize did not fulfil — `monetize_server::reconcile`'s durable rows |
//! | [`grow_admin_client`] / [`grow_admin_server`] | the data-set growth's console: status, quote, the operator's APPROVAL, resume — `monetize_server::grow`, the outside actor of `DATA-SET-GROWTH-FLOW.md` T6 |
//! | [`estate_admin_client`] / [`estate_admin_server`] | the estate DRAWN: one board, one chip per machine — `monetize_server::estate_svg`, rendered where both halves of it live |
//! | [`estate_order_client`] / [`estate_order_server`] | a private gunnar ordered: the hold, the lay-estate job, the capture — `monetize_server::estate_order` |
//! | [`accounts_client`] / [`accounts_server`] | ONE monetize's accounts: every `(product, tenant)`, the vendor instance that bills it, and its rows — subscription, purchases, private instances — `monetize_server::services::accounts` |
//! | [`fleet_client`] / [`fleet_server`] | the PULL channel (5.4): an estate's boxes report, pull the signed orders queued for them, and ask — signed per call by the box's own key, no bearer — `monetize_server::fleet` |
//! | [`guardian_client`] / [`guardian_server`] | the estate guardian's console: cases per estate, the buttons (strong session, principal recorded), the auto-fix opt-in — `monetize_server::fleet` |
//!
//! # Conversions
//!
//! [`EntitlementFact`], [`UsageReport`] and [`Resource`] convert to and from
//! `monetize_product::EntitlementFact`, `monetize_product::Usage` and
//! `monetize_cloud::Resource` via `monetize_server::convert` — that lives in
//! `monetize-server` so this crate stays a leaf the console and the CLI can link
//! without redb or the vendors.

#[allow(clippy::doc_overindented_list_items, clippy::doc_lazy_continuation, clippy::large_enum_variant)]
mod generated {
    tonic::include_proto!("monetize.v1");
}

pub use generated::*;

/// `monetize/<version>`, the agent string.
pub const AGENT: &str = concat!("monetize/", env!("CARGO_PKG_VERSION"));

impl Event {
    /// The [`EventKind`] a payload belongs to.
    ///
    /// `Event.kind` is a separate field from the `oneof` so a subscriber can
    /// filter without matching, and two sources of truth is exactly the shape
    /// that drifts. This is the one writer both go through (one implementation);
    /// [`Event::kind_matches_payload`] is the guard that says they agree.
    pub fn kind_of(payload: &event::Payload) -> EventKind {
        match payload {
            event::Payload::EntitlementChanged(_) => EventKind::EntitlementChanged,
            event::Payload::LedgerAppended(_) => EventKind::LedgerAppended,
            event::Payload::UsageMeasured(_) => EventKind::UsageMeasured,
            event::Payload::Gap(_) => EventKind::Gap,
            event::Payload::StreamOpened(_) => EventKind::StreamOpened,
            event::Payload::CallbackRefused(_) => EventKind::CallbackRefused,
            event::Payload::PaidAtVendor(_) => EventKind::PaidAtVendor,
            event::Payload::SetupLine(_) => EventKind::SetupLine,
        }
    }

    /// Build an event whose `kind` cannot disagree with its payload.
    pub fn new(seq: u64, at_unix_ms: u64, payload: event::Payload) -> Self {
        Event { seq, at_unix_ms, kind: Self::kind_of(&payload) as i32, payload: Some(payload) }
    }

    /// False when `kind` and the payload variant disagree, or the payload is
    /// missing. A cheap self-check for the bus and its tests.
    pub fn kind_matches_payload(&self) -> bool {
        match &self.payload {
            Some(payload) => self.kind == Self::kind_of(payload) as i32,
            None => false,
        }
    }

    /// The product this event is about, when it is about one. Neither
    /// [`EventGap`] nor [`StreamOpened`] ever is: both are facts about the
    /// STREAM, not about a product.
    pub fn product(&self) -> Option<&str> {
        match self.payload.as_ref()? {
            event::Payload::EntitlementChanged(e) => Some(&e.product),
            event::Payload::LedgerAppended(e) => Some(&e.product),
            event::Payload::UsageMeasured(e) => Some(&e.product),
            event::Payload::SetupLine(e) => Some(&e.product),
            // A refused callback is about a VENDOR, not a product: it never got
            // past `parse_callback`, so nothing in it names one, and inventing a
            // product here would let a product filter hide it. A payment found
            // at the vendor carries a `product` LABEL in its metadata, if any —
            // the vendor repeating what it was told, not a fact monetize has
            // established — and it belongs to no product axis for the same
            // reason: a console watching one product must not be able to
            // filter away the money.
            event::Payload::Gap(_) | event::Payload::StreamOpened(_) | event::Payload::CallbackRefused(_) | event::Payload::PaidAtVendor(_) => None,
        }
    }

    /// The tenant this event is about, when it is about one.
    pub fn tenant(&self) -> Option<&str> {
        match self.payload.as_ref()? {
            event::Payload::EntitlementChanged(e) => Some(&e.tenant),
            event::Payload::LedgerAppended(e) => Some(&e.tenant),
            event::Payload::UsageMeasured(e) => Some(&e.tenant),
            event::Payload::SetupLine(e) => Some(&e.tenant),
            // Same as [`Event::product`]: a callback that was refused never
            // named a tenant we would believe, so it belongs to no tenant axis.
            // A console filtering by tenant must still be shown it.
            event::Payload::Gap(_) | event::Payload::StreamOpened(_) | event::Payload::CallbackRefused(_) | event::Payload::PaidAtVendor(_) => None,
        }
    }
}

/// Default gRPC port. gunnar is 50051; monetize sits ten above it.
pub const DEFAULT_PORT: u16 = 50061;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_services_and_messages_generate() {
        let fact = EntitlementFact {
            tenant: "team/sub".into(),
            plan: "gunnar/team/sub/2026-09-03+pack_bytes=10737418240".into(),
            state: State::Paid as i32,
            paid_until_unix_ms: Some(1),
            caps: [("pack_bytes".to_string(), 10u64 << 30)].into(),
            source: "operator".into(),
            signature: vec![1; 64],
            issued_unix_ms: Some(1_790_000_000_000),
            issued_signature: vec![2; 64],
        };
        assert_eq!(fact.state(), State::Paid);
        assert_eq!(fact.issued_unix_ms, Some(1_790_000_000_000));
        let row = LedgerRow { reference: "gunnar/team/sub/2026-09-03+pack_bytes=10737418240".into(), seq: 0, at_unix_ms: 1, kind: "Reserved".into(), json: "{}".into() };
        assert_eq!(row.kind, "Reserved");
        let q = QueryLedgerRequest { by: Some(query_ledger_request::By::Unfinished(true)) };
        assert!(matches!(q.by, Some(query_ledger_request::By::Unfinished(true))));
        assert_eq!(DEFAULT_PORT, 50061);
    }

    /// ★ **The actor ticket rides on a field number nothing else has ever
    /// used**, and this test says so in the only way that cannot rot: by
    /// reading the tag off the wire.
    ///
    /// A field number reused after a release is the one proto mistake that is
    /// silent — an old client's `renew` would be decoded as somebody's ticket,
    /// or worse the reverse — and a comment saying "9 is free" is not evidence.
    /// `StartPurchaseRequest.renew` was 8 and `QuoteRequest.renew` was 6, so
    /// these are the next two.
    #[test]
    fn the_actor_ticket_is_field_nine_on_start_and_field_seven_on_quote() {
        use prost::Message;

        let start = StartPurchaseRequest { product: "gunnar".into(), tenant: "alice".into(), renew: true, actor_ticket: b"tkt".to_vec(), ..Default::default() };
        // key = (field << 3) | wire type; 2 is length-delimited.
        assert!(start.encode_to_vec().windows(5).any(|w| w == [(9 << 3) | 2, 3, b't', b'k', b't']), "the ticket must ride on field 9");

        let quote = QuoteRequest { product: "gunnar".into(), tenant: "alice".into(), renew: true, actor_ticket: b"tkt".to_vec(), ..Default::default() };
        assert!(quote.encode_to_vec().windows(5).any(|w| w == [(7 << 3) | 2, 3, b't', b'k', b't']), "the ticket must ride on field 7");

        // Absent is EMPTY and not a zero-length something: the operator path
        // sends no ticket at all, and every reader of it tests `is_empty`.
        assert!(StartPurchaseRequest::default().actor_ticket.is_empty());
        assert!(QuoteRequest::default().actor_ticket.is_empty());
    }

    /// The capability the console reads to decide whether the route exists at
    /// all. Default false: a `ListProducts` from a monetize that predates this
    /// field must not read as a product that can verify tickets.
    #[test]
    fn a_product_that_says_nothing_cannot_do_tenant_renewal() {
        assert!(!ProductInfo::default().tenant_renewal);
    }
}

/// **Who may call monetize's control API from OUTSIDE the estate** — the
/// shared operator console (owner 2026-09-28: one admin for every estate's
/// monetize, reaching each through its `monetize.<apex>`).
///
/// A caller signs EVERY call with its own OpenSSH key (an SSHSIG, method-bound,
/// single-use, ±5 min): exactly gunnar's control-plane model, and the same
/// code (`nornir_auth_sshsig`, edda). monetize holds only the PUBLIC key of
/// each admin principal (`control_principals` in its config, fed at install as
/// `monetize_principal.monetize-ui`); revoking the console for one estate is
/// removing that line there. The bearer stays for the console IN the estate
/// (loopback), and the front never forwards a bearer from outside.
///
/// ```text
/// monetize-authorization: monetize-sshsig-v1 <fingerprint> <issued_at_ms> <nonce> <Service.Method> <armor>
/// signed (namespace monetize-grpc): monetize-grpc-auth-v1\n<fp>\n<issued_at_ms>\n<nonce>\n<Service.Method>\n
/// ```
///
/// Its own namespace and scheme token: a credential minted for gunnar never
/// verifies here, and one minted here never verifies at gunnar.
pub mod control_auth {
    pub use nornir_auth_sshsig as sshsig;

    /// monetize's scheme. Bound credentials only: there is no method-blind form.
    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
    pub struct MonetizeScheme;

    impl sshsig::Scheme for MonetizeScheme {
        const METADATA_KEY: &'static str = METADATA_KEY;
        const SCHEME: &'static str = "monetize-sshsig-v1";
        const SCHEME_UNBOUND: Option<&'static str> = None;
        const NAMESPACE: &'static str = "monetize-grpc";
        const PREAMBLE: &'static str = "monetize-grpc-auth-v1";
        const PREAMBLE_UNBOUND: &'static str = "";
        type Error = sshsig::Refusal;
    }

    /// The metadata key the credential travels in.
    pub const METADATA_KEY: &str = "monetize-authorization";

    /// A monetize control credential.
    pub type Credential = sshsig::Credential<MonetizeScheme>;

    /// Sign one call to `method` (`Service.Method`) with `key`.
    pub fn sign(key: &sshsig::ssh_key::PrivateKey, issued_at_unix_ms: i64, nonce: &str, method: &str) -> Result<Credential, String> {
        sshsig::sign::<MonetizeScheme>(key, issued_at_unix_ms, nonce, Some(method)).map_err(|e| e.to_string())
    }
}