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