crates/monetize-embed/src/lib.rs
//! **monetize-embed — what a product compiles in.** One struct, one question:
//! *does this tenant get to do this, right now?* Answered from verified facts held in
//! memory, in nanoseconds, with monetize down or not.
//!
//! ```text
//! monetize-server ──signed Snapshot file──▶ EntitlementCache::refresh (start, catch-up)
//! monetize-server ──signed EntitlementFact─▶ EntitlementCache::push (every change)
//! product choke point ──────────────────▶ EntitlementCache::allows (every request)
//! ```
//!
//! # The ladder table, as the product sees it
//!
//! | state | Write (push, LFS upload) | Read (owner clone/fetch) | AnonymousRead | Admin (UI) |
//! |---|---|---|---|---|
//! | Free, Paid | allow | allow | allow (product still applies Public) | allow |
//! | Grace | allow, [`EntitlementCache::notice`] has the warning line | allow | allow | allow |
//! | Suspended | **refuse**, named reason + URL | allow | **refuse** | refuse: pay page only |
//! | Retention | refuse | allow (export) | refuse | refuse: pay page + export |
//!
//! Two rules, not negotiable: **never hold data hostage** — `Read` is allowed in every
//! state, a lapsed tenant can always clone their own repositories; and **refuse before
//! the bytes** — the product asks at `authorise_service`, before a pack is read, and
//! puts [`Verdict::Refuse::reason_line`] in report-status.
//!
//! # What is, and is not, decided here
//!
//! * The `state` in a fact is monetize's verdict at push time. The cache does **not**
//! re-derive it as the clock moves — it does not know the policy's day counts, and the
//! product contract says a product keeps serving on its cached entitlement when
//! monetize is unreachable. Ladder transitions arrive as new pushes.
//! * An unknown tenant is `Free`: the product's default, never a refusal.
//! * Visibility (`Public`) and quotas (`caps`) are the product's own checks; this crate
//! only says whether the entitlement allows the *kind* of action.
//!
//! # Cost of the read path
//!
//! [`EntitlementCache::allows`] is one `ArcSwap::load` (an atomic increment on a
//! debt-slot, ~2 ns, no lock, no allocation), one `BTreeMap` lookup on the tenant name,
//! and a match. A refusal allocates its two strings; an allow allocates nothing. Writes
//! (`push`, `refresh`) clone the map and swap the pointer — O(tenants), taken by the
//! rare path on purpose so the hot path never contends.
pub mod civil;
pub mod fleet;
pub mod signing;
pub mod sold;
pub mod ticket;
use std::collections::BTreeMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
/// Re-exported so a caller that must CHECK a signature — a product, or the
/// operator console — links one crate and cannot end up on a different
/// ed25519-dalek than the one the canonical form was verified against.
pub use ed25519_dalek::VerifyingKey;
pub use monetize_product::{EntitlementFact, State, TenantId};
pub use signing::{SignatureError, Snapshot};
/// The appliance→monetize direction of the seam: the product's own box vouching
/// that a human may act for a tenant. See [`ticket`] for why it points that way.
pub use ticket::{
verify_ticket, ActorTicket, SeenNonces, PURPOSE_CANCEL, PURPOSE_ESTATE, PURPOSE_ORDER,
PURPOSE_READ, PURPOSE_RENEW,
};
/// What the product is about to do on behalf of (or to) a tenant.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Action {
/// `git push`, LFS upload — anything that grows the tenant's data.
Write,
/// The owner's clone/fetch. Allowed in every state.
Read,
/// Anonymous browse/clone of a public repository.
AnonymousRead,
/// The tenant's own settings UI.
Admin,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum Verdict {
Allow,
Refuse {
state: State,
/// Goes into report-status verbatim: `order … expired 2026-10-01; renew at …`.
reason_line: String,
/// The pay page for this tenant.
url: String,
},
}
impl Verdict {
pub fn is_allowed(&self) -> bool {
matches!(self, Verdict::Allow)
}
}
#[derive(Debug, thiserror::Error)]
pub enum RefreshError {
#[error("snapshot is not valid JSON: {0}")]
Parse(#[from] serde_json::Error),
#[error(transparent)]
Signature(#[from] SignatureError),
#[error("snapshot issued at {offered} is older than the {held} the cache holds")]
Stale { offered: u64, held: u64 },
}
#[derive(Debug, thiserror::Error)]
#[error("public key is not a valid Ed25519 point")]
pub struct KeyError;
struct Facts {
issued_unix_ms: u64,
by_tenant: BTreeMap<TenantId, EntitlementFact>,
}
pub struct EntitlementCache {
key: VerifyingKey,
/// `{tenant}` is replaced by the tenant name.
billing_url: String,
facts: ArcSwap<Facts>,
}
impl EntitlementCache {
/// `public_key` is monetize's 32-byte Ed25519 verifying key; `billing_url` is the
/// pay page template, e.g. `https://gunnar.rs/billing/{tenant}`.
pub fn new(public_key: &[u8; 32], billing_url: &str) -> Result<Self, KeyError> {
let key = VerifyingKey::from_bytes(public_key).map_err(|_| KeyError)?;
Ok(Self {
key,
billing_url: billing_url.to_string(),
facts: ArcSwap::from_pointee(Facts {
issued_unix_ms: 0,
by_tenant: BTreeMap::new(),
}),
})
}
pub fn public_key(&self) -> &VerifyingKey {
&self.key
}
/// The hot path. See the crate doc for its cost.
pub fn allows(&self, tenant: &TenantId, action: Action) -> Verdict {
let facts = self.facts.load();
let Some(fact) = facts.by_tenant.get(tenant) else {
return Verdict::Allow; // unknown tenant = Free = the product's default
};
match (fact.state, action) {
(State::Free | State::Paid | State::Grace, _) => Verdict::Allow,
(State::Suspended | State::Retention, Action::Read) => Verdict::Allow,
(state, action) => Verdict::Refuse {
state,
reason_line: self.reason_line(fact, action),
url: self.url_for(tenant),
},
}
}
/// The Grace banner / report-status warning, if the tenant is in Grace.
pub fn notice(&self, tenant: &TenantId) -> Option<String> {
let facts = self.facts.load();
let fact = facts.by_tenant.get(tenant)?;
if fact.state != State::Grace {
return None;
}
Some(format!(
"{}; renew at {}",
self.expiry_phrase(fact),
self.url_for(tenant)
))
}
/// The state the cache holds for `tenant`; `Free` when unknown.
pub fn state(&self, tenant: &TenantId) -> State {
self.facts
.load()
.by_tenant
.get(tenant)
.map_or(State::Free, |f| f.state)
}
/// A copy of the whole fact (caps, paid_until, source) for the product's own checks.
pub fn fact(&self, tenant: &TenantId) -> Option<EntitlementFact> {
self.facts.load().by_tenant.get(tenant).cloned()
}
/// The issue time of the snapshot the cache holds (0 before the first refresh).
pub fn issued_unix_ms(&self) -> u64 {
self.facts.load().issued_unix_ms
}
pub fn len(&self) -> usize {
self.facts.load().by_tenant.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Replace everything with a signed [`Snapshot`] (its JSON bytes). Nothing changes
/// unless the envelope and every fact verify and the snapshot is not older than
/// the one held. Returns the number of tenants now known.
pub fn refresh(&self, snapshot_bytes: &[u8]) -> Result<usize, RefreshError> {
let snap: Snapshot = serde_json::from_slice(snapshot_bytes)?;
signing::verify_snapshot(&snap, &self.key)?;
let held = self.facts.load().issued_unix_ms;
if snap.issued_unix_ms < held {
return Err(RefreshError::Stale {
offered: snap.issued_unix_ms,
held,
});
}
let by_tenant: BTreeMap<TenantId, EntitlementFact> = snap
.facts
.into_iter()
.map(|f| (f.tenant.clone(), f))
.collect();
let n = by_tenant.len();
self.facts.store(Arc::new(Facts {
issued_unix_ms: snap.issued_unix_ms,
by_tenant,
}));
Ok(n)
}
/// One pushed fact. Verified before it replaces the tenant's current fact.
pub fn push(&self, fact: EntitlementFact) -> Result<(), SignatureError> {
signing::verify_fact(&fact, &self.key)?;
let current = self.facts.load_full();
let mut by_tenant = current.by_tenant.clone();
by_tenant.insert(fact.tenant.clone(), fact);
self.facts.store(Arc::new(Facts {
issued_unix_ms: current.issued_unix_ms,
by_tenant,
}));
Ok(())
}
fn url_for(&self, tenant: &TenantId) -> String {
self.billing_url.replace("{tenant}", &tenant.0)
}
/// `fact.plan` is the ORDER the verdict came from (a ledger reference), or
/// an operator's label, or nothing — the field keeps its wire name because
/// it is in the signed form; the sentence a user reads names the order.
fn expiry_phrase(&self, fact: &EntitlementFact) -> String {
let what = if fact.plan.is_empty() {
"entitlement".to_owned()
} else {
format!("order {}", fact.plan)
};
match fact.paid_until_unix_ms {
Some(until) => format!("{what} expired {}", civil::iso_date(until)),
None => format!("{what} suspended by operator"),
}
}
fn reason_line(&self, fact: &EntitlementFact, action: Action) -> String {
let url = self.url_for(&fact.tenant);
let expiry = self.expiry_phrase(fact);
let what = match (fact.state, action) {
(State::Retention, Action::Write) => "writes closed, data kept for export",
(_, Action::Write) => "writes closed",
(_, Action::AnonymousRead) => "anonymous access closed",
(_, Action::Admin) => "settings closed",
(_, Action::Read) => unreachable!("Read is allowed in every state"),
};
format!("{expiry}: {what}; renew at {url}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
fn key() -> SigningKey {
SigningKey::generate(&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng))
}
fn fact(tenant: &str, state: State, paid_until: Option<u64>) -> EntitlementFact {
EntitlementFact {
tenant: TenantId(tenant.into()),
plan: "10gb".into(),
state,
paid_until_unix_ms: paid_until,
caps: BTreeMap::from([("pack_bytes".to_string(), 10 << 30)]),
source: "payment:mock:gunnar/team/sub/2026-09-03".into(),
signature: vec![],
issued_unix_ms: None,
issued_signature: Vec::new(),
}
}
/// The V1 form — no issue time — which is what a fact signed before
/// 2026-09-17 looks like and what the cache must go on accepting.
fn signed(key: &SigningKey, mut f: EntitlementFact) -> EntitlementFact {
f.issued_unix_ms = None;
f.issued_signature = Vec::new();
f.signature = key.sign(&signing::fact_message(&f)).to_bytes().to_vec();
f
}
fn snapshot(key: &SigningKey, issued: u64, facts: Vec<EntitlementFact>) -> Vec<u8> {
let signature = key
.sign(&signing::snapshot_message(issued, &facts))
.to_bytes()
.to_vec();
serde_json::to_vec(&Snapshot {
issued_unix_ms: issued,
facts,
signature,
})
.unwrap()
}
fn cache(key: &SigningKey) -> EntitlementCache {
EntitlementCache::new(
&key.verifying_key().to_bytes(),
"https://gunnar.rs/billing/{tenant}",
)
.unwrap()
}
const OCT_1_2026: u64 = 1_790_812_800_000;
#[test]
fn genuine_snapshot_is_accepted_and_answers() {
let k = key();
let c = cache(&k);
let bytes = snapshot(
&k,
10,
vec![signed(
&k,
fact("team/sub", State::Suspended, Some(OCT_1_2026)),
)],
);
assert_eq!(c.refresh(&bytes).unwrap(), 1);
assert_eq!(c.state(&TenantId("team/sub".into())), State::Suspended);
assert_eq!(c.issued_unix_ms(), 10);
}
#[test]
fn forged_snapshot_is_rejected_and_changes_nothing() {
let k = key();
let forger = key();
let c = cache(&k);
let t = TenantId("team/sub".into());
// Envelope signed by the right key, one fact forged: the whole snapshot is refused.
let mut forged = signed(&forger, fact("team/sub", State::Paid, Some(OCT_1_2026)));
let bytes = snapshot(&k, 10, vec![forged.clone()]);
assert!(matches!(
c.refresh(&bytes),
Err(RefreshError::Signature(SignatureError::Fact(_)))
));
// Envelope signed by the wrong key.
forged = signed(&k, forged);
let bytes = snapshot(&forger, 10, vec![forged.clone()]);
assert!(matches!(
c.refresh(&bytes),
Err(RefreshError::Signature(SignatureError::Envelope))
));
// A genuine fact whose fields were edited after signing.
forged.state = State::Free;
assert!(matches!(c.push(forged), Err(SignatureError::Fact(_))));
// Garbage.
assert!(matches!(
c.refresh(b"not json"),
Err(RefreshError::Parse(_))
));
assert!(c.is_empty());
assert_eq!(c.state(&t), State::Free);
}
#[test]
fn stale_snapshot_is_refused_newer_one_is_taken() {
let k = key();
let c = cache(&k);
c.refresh(&snapshot(&k, 20, vec![])).unwrap();
assert!(matches!(
c.refresh(&snapshot(&k, 19, vec![])),
Err(RefreshError::Stale {
offered: 19,
held: 20
})
));
c.refresh(&snapshot(&k, 21, vec![])).unwrap();
assert_eq!(c.issued_unix_ms(), 21);
}
#[test]
fn push_replaces_one_tenant_and_keeps_the_rest() {
let k = key();
let c = cache(&k);
let a = TenantId("a".into());
let b = TenantId("b".into());
c.refresh(&snapshot(
&k,
1,
vec![
signed(&k, fact("a", State::Paid, None)),
signed(&k, fact("b", State::Paid, None)),
],
))
.unwrap();
c.push(signed(&k, fact("a", State::Suspended, Some(OCT_1_2026))))
.unwrap();
assert_eq!(c.state(&a), State::Suspended);
assert_eq!(c.state(&b), State::Paid);
assert_eq!(c.len(), 2);
}
/// The ladder table, row by row, column by column.
#[test]
fn verdict_table() {
use Action::*;
let k = key();
let c = cache(&k);
let t = TenantId("team/sub".into());
let rows: [(State, [bool; 4]); 5] = [
(State::Free, [true, true, true, true]),
(State::Paid, [true, true, true, true]),
(State::Grace, [true, true, true, true]),
(State::Suspended, [false, true, false, false]),
(State::Retention, [false, true, false, false]),
];
for (state, expect) in rows {
c.push(signed(&k, fact("team/sub", state, Some(OCT_1_2026))))
.unwrap();
for (action, allowed) in [Write, Read, AnonymousRead, Admin].into_iter().zip(expect) {
let v = c.allows(&t, action);
assert_eq!(v.is_allowed(), allowed, "{state:?} / {action:?} gave {v:?}");
if let Verdict::Refuse {
state: s,
reason_line,
url,
} = &v
{
assert_eq!(*s, state);
assert_eq!(url, "https://gunnar.rs/billing/team/sub");
assert!(
reason_line.contains("order 10gb expired 2026-10-01"),
"{reason_line}"
);
assert!(
reason_line.ends_with("; renew at https://gunnar.rs/billing/team/sub"),
"{reason_line}"
);
}
}
assert_eq!(
c.notice(&t).is_some(),
state == State::Grace,
"{state:?} notice"
);
}
}
#[test]
fn refuse_twin_suspended_write_names_the_reason_and_unknown_tenant_is_free() {
let k = key();
let c = cache(&k);
let t = TenantId("team/sub".into());
c.push(signed(
&k,
fact("team/sub", State::Suspended, Some(OCT_1_2026)),
))
.unwrap();
assert_eq!(
c.allows(&t, Action::Write),
Verdict::Refuse {
state: State::Suspended,
reason_line: "order 10gb expired 2026-10-01: writes closed; renew at https://gunnar.rs/billing/team/sub".into(),
url: "https://gunnar.rs/billing/team/sub".into(),
}
);
let op = signed(&k, fact("ops", State::Suspended, None));
c.push(op).unwrap();
assert!(
matches!(c.allows(&TenantId("ops".into()), Action::Write), Verdict::Refuse { reason_line, .. } if reason_line.starts_with("order 10gb suspended by operator"))
);
assert_eq!(
c.allows(&TenantId("nobody".into()), Action::Write),
Verdict::Allow
);
assert_eq!(c.state(&TenantId("nobody".into())), State::Free);
}
}
/// Test support (`--features testing`): [`testing::pulling::PullingBox`].
#[cfg(feature = "testing")]
pub mod testing;