Skip to main content

monetize_embed/
lib.rs

1//! **monetize-embed — what a product compiles in.** One struct, one question:
2//! *does this tenant get to do this, right now?* Answered from verified facts held in
3//! memory, in nanoseconds, with monetize down or not.
4//!
5//! ```text
6//!   monetize-server ──signed Snapshot file──▶ EntitlementCache::refresh   (start, catch-up)
7//!   monetize-server ──signed EntitlementFact─▶ EntitlementCache::push      (every change)
8//!   product choke point ──────────────────▶ EntitlementCache::allows     (every request)
9//! ```
10//!
11//! # The ladder table, as the product sees it
12//!
13//! | state | Write (push, LFS upload) | Read (owner clone/fetch) | AnonymousRead | Admin (UI) |
14//! |---|---|---|---|---|
15//! | Free, Paid | allow | allow | allow (product still applies Public) | allow |
16//! | Grace | allow, [`EntitlementCache::notice`] has the warning line | allow | allow | allow |
17//! | Suspended | **refuse**, named reason + URL | allow | **refuse** | refuse: pay page only |
18//! | Retention | refuse | allow (export) | refuse | refuse: pay page + export |
19//!
20//! Two rules, not negotiable: **never hold data hostage** — `Read` is allowed in every
21//! state, a lapsed tenant can always clone their own repositories; and **refuse before
22//! the bytes** — the product asks at `authorise_service`, before a pack is read, and
23//! puts [`Verdict::Refuse::reason_line`] in report-status.
24//!
25//! # What is, and is not, decided here
26//!
27//! * The `state` in a fact is monetize's verdict at push time. The cache does **not**
28//!   re-derive it as the clock moves — it does not know the policy's day counts, and the
29//!   product contract says a product keeps serving on its cached entitlement when
30//!   monetize is unreachable. Ladder transitions arrive as new pushes.
31//! * An unknown tenant is `Free`: the product's default, never a refusal.
32//! * Visibility (`Public`) and quotas (`caps`) are the product's own checks; this crate
33//!   only says whether the entitlement allows the *kind* of action.
34//!
35//! # Cost of the read path
36//!
37//! [`EntitlementCache::allows`] is one `ArcSwap::load` (an atomic increment on a
38//! debt-slot, ~2 ns, no lock, no allocation), one `BTreeMap` lookup on the tenant name,
39//! and a match. A refusal allocates its two strings; an allow allocates nothing. Writes
40//! (`push`, `refresh`) clone the map and swap the pointer — O(tenants), taken by the
41//! rare path on purpose so the hot path never contends.
42
43pub mod civil;
44pub mod signing;
45pub mod ticket;
46
47use std::collections::BTreeMap;
48use std::sync::Arc;
49
50use arc_swap::ArcSwap;
51/// Re-exported so a caller that must CHECK a signature — a product, or the
52/// operator console — links one crate and cannot end up on a different
53/// ed25519-dalek than the one the canonical form was verified against.
54pub use ed25519_dalek::VerifyingKey;
55pub use monetize_product::{EntitlementFact, State, TenantId};
56pub use signing::{SignatureError, Snapshot};
57/// The appliance→monetize direction of the seam: the product's own box vouching
58/// that a human may act for a tenant. See [`ticket`] for why it points that way.
59pub use ticket::{
60    verify_ticket, ActorTicket, SeenNonces, PURPOSE_ORDER, PURPOSE_READ, PURPOSE_RENEW,
61};
62
63/// What the product is about to do on behalf of (or to) a tenant.
64#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65pub enum Action {
66    /// `git push`, LFS upload — anything that grows the tenant's data.
67    Write,
68    /// The owner's clone/fetch. Allowed in every state.
69    Read,
70    /// Anonymous browse/clone of a public repository.
71    AnonymousRead,
72    /// The tenant's own settings UI.
73    Admin,
74}
75
76#[derive(Clone, PartialEq, Eq, Debug)]
77pub enum Verdict {
78    Allow,
79    Refuse {
80        state: State,
81        /// Goes into report-status verbatim: `order … expired 2026-10-01; renew at …`.
82        reason_line: String,
83        /// The pay page for this tenant.
84        url: String,
85    },
86}
87
88impl Verdict {
89    pub fn is_allowed(&self) -> bool {
90        matches!(self, Verdict::Allow)
91    }
92}
93
94#[derive(Debug, thiserror::Error)]
95pub enum RefreshError {
96    #[error("snapshot is not valid JSON: {0}")]
97    Parse(#[from] serde_json::Error),
98    #[error(transparent)]
99    Signature(#[from] SignatureError),
100    #[error("snapshot issued at {offered} is older than the {held} the cache holds")]
101    Stale { offered: u64, held: u64 },
102}
103
104#[derive(Debug, thiserror::Error)]
105#[error("public key is not a valid Ed25519 point")]
106pub struct KeyError;
107
108struct Facts {
109    issued_unix_ms: u64,
110    by_tenant: BTreeMap<TenantId, EntitlementFact>,
111}
112
113pub struct EntitlementCache {
114    key: VerifyingKey,
115    /// `{tenant}` is replaced by the tenant name.
116    billing_url: String,
117    facts: ArcSwap<Facts>,
118}
119
120impl EntitlementCache {
121    /// `public_key` is monetize's 32-byte Ed25519 verifying key; `billing_url` is the
122    /// pay page template, e.g. `https://gunnar.rs/billing/{tenant}`.
123    pub fn new(public_key: &[u8; 32], billing_url: &str) -> Result<Self, KeyError> {
124        let key = VerifyingKey::from_bytes(public_key).map_err(|_| KeyError)?;
125        Ok(Self {
126            key,
127            billing_url: billing_url.to_string(),
128            facts: ArcSwap::from_pointee(Facts { issued_unix_ms: 0, by_tenant: BTreeMap::new() }),
129        })
130    }
131
132    pub fn public_key(&self) -> &VerifyingKey {
133        &self.key
134    }
135
136    /// The hot path. See the crate doc for its cost.
137    pub fn allows(&self, tenant: &TenantId, action: Action) -> Verdict {
138        let facts = self.facts.load();
139        let Some(fact) = facts.by_tenant.get(tenant) else {
140            return Verdict::Allow; // unknown tenant = Free = the product's default
141        };
142        match (fact.state, action) {
143            (State::Free | State::Paid | State::Grace, _) => Verdict::Allow,
144            (State::Suspended | State::Retention, Action::Read) => Verdict::Allow,
145            (state, action) => Verdict::Refuse {
146                state,
147                reason_line: self.reason_line(fact, action),
148                url: self.url_for(tenant),
149            },
150        }
151    }
152
153    /// The Grace banner / report-status warning, if the tenant is in Grace.
154    pub fn notice(&self, tenant: &TenantId) -> Option<String> {
155        let facts = self.facts.load();
156        let fact = facts.by_tenant.get(tenant)?;
157        if fact.state != State::Grace {
158            return None;
159        }
160        Some(format!("{}; renew at {}", self.expiry_phrase(fact), self.url_for(tenant)))
161    }
162
163    /// The state the cache holds for `tenant`; `Free` when unknown.
164    pub fn state(&self, tenant: &TenantId) -> State {
165        self.facts.load().by_tenant.get(tenant).map_or(State::Free, |f| f.state)
166    }
167
168    /// A copy of the whole fact (caps, paid_until, source) for the product's own checks.
169    pub fn fact(&self, tenant: &TenantId) -> Option<EntitlementFact> {
170        self.facts.load().by_tenant.get(tenant).cloned()
171    }
172
173    /// The issue time of the snapshot the cache holds (0 before the first refresh).
174    pub fn issued_unix_ms(&self) -> u64 {
175        self.facts.load().issued_unix_ms
176    }
177
178    pub fn len(&self) -> usize {
179        self.facts.load().by_tenant.len()
180    }
181
182    pub fn is_empty(&self) -> bool {
183        self.len() == 0
184    }
185
186    /// Replace everything with a signed [`Snapshot`] (its JSON bytes). Nothing changes
187    /// unless the envelope and every fact verify and the snapshot is not older than
188    /// the one held. Returns the number of tenants now known.
189    pub fn refresh(&self, snapshot_bytes: &[u8]) -> Result<usize, RefreshError> {
190        let snap: Snapshot = serde_json::from_slice(snapshot_bytes)?;
191        signing::verify_snapshot(&snap, &self.key)?;
192        let held = self.facts.load().issued_unix_ms;
193        if snap.issued_unix_ms < held {
194            return Err(RefreshError::Stale { offered: snap.issued_unix_ms, held });
195        }
196        let by_tenant: BTreeMap<TenantId, EntitlementFact> =
197            snap.facts.into_iter().map(|f| (f.tenant.clone(), f)).collect();
198        let n = by_tenant.len();
199        self.facts.store(Arc::new(Facts { issued_unix_ms: snap.issued_unix_ms, by_tenant }));
200        Ok(n)
201    }
202
203    /// One pushed fact. Verified before it replaces the tenant's current fact.
204    pub fn push(&self, fact: EntitlementFact) -> Result<(), SignatureError> {
205        signing::verify_fact(&fact, &self.key)?;
206        let current = self.facts.load_full();
207        let mut by_tenant = current.by_tenant.clone();
208        by_tenant.insert(fact.tenant.clone(), fact);
209        self.facts.store(Arc::new(Facts { issued_unix_ms: current.issued_unix_ms, by_tenant }));
210        Ok(())
211    }
212
213    fn url_for(&self, tenant: &TenantId) -> String {
214        self.billing_url.replace("{tenant}", &tenant.0)
215    }
216
217    /// `fact.plan` is the ORDER the verdict came from (a ledger reference), or
218    /// an operator's label, or nothing — the field keeps its wire name because
219    /// it is in the signed form; the sentence a user reads names the order.
220    fn expiry_phrase(&self, fact: &EntitlementFact) -> String {
221        let what = if fact.plan.is_empty() { "entitlement".to_owned() } else { format!("order {}", fact.plan) };
222        match fact.paid_until_unix_ms {
223            Some(until) => format!("{what} expired {}", civil::iso_date(until)),
224            None => format!("{what} suspended by operator"),
225        }
226    }
227
228    fn reason_line(&self, fact: &EntitlementFact, action: Action) -> String {
229        let url = self.url_for(&fact.tenant);
230        let expiry = self.expiry_phrase(fact);
231        let what = match (fact.state, action) {
232            (State::Retention, Action::Write) => "writes closed, data kept for export",
233            (_, Action::Write) => "writes closed",
234            (_, Action::AnonymousRead) => "anonymous access closed",
235            (_, Action::Admin) => "settings closed",
236            (_, Action::Read) => unreachable!("Read is allowed in every state"),
237        };
238        format!("{expiry}: {what}; renew at {url}")
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use ed25519_dalek::{Signer, SigningKey};
246
247    fn key() -> SigningKey {
248        SigningKey::generate(&mut rand::rngs::OsRng)
249    }
250
251    fn fact(tenant: &str, state: State, paid_until: Option<u64>) -> EntitlementFact {
252        EntitlementFact {
253            tenant: TenantId(tenant.into()),
254            plan: "10gb".into(),
255            state,
256            paid_until_unix_ms: paid_until,
257            caps: BTreeMap::from([("pack_bytes".to_string(), 10 << 30)]),
258            source: "payment:mock:gunnar/team/sub/2026-09-03".into(),
259            signature: vec![],
260            issued_unix_ms: None,
261            issued_signature: Vec::new(),
262        }
263    }
264
265    /// The V1 form — no issue time — which is what a fact signed before
266    /// 2026-09-17 looks like and what the cache must go on accepting.
267    fn signed(key: &SigningKey, mut f: EntitlementFact) -> EntitlementFact {
268        f.issued_unix_ms = None;
269        f.issued_signature = Vec::new();
270        f.signature = key.sign(&signing::fact_message(&f)).to_bytes().to_vec();
271        f
272    }
273
274    /// Both forms, stamped: what monetize mints now.
275    fn signed_at(key: &SigningKey, mut f: EntitlementFact, issued: u64) -> EntitlementFact {
276        f.issued_unix_ms = Some(issued);
277        f.signature = key.sign(&signing::fact_message(&f)).to_bytes().to_vec();
278        f.issued_signature = key.sign(&signing::fact_message_issued(&f)).to_bytes().to_vec();
279        f
280    }
281
282    fn snapshot(key: &SigningKey, issued: u64, facts: Vec<EntitlementFact>) -> Vec<u8> {
283        let signature = key.sign(&signing::snapshot_message(issued, &facts)).to_bytes().to_vec();
284        serde_json::to_vec(&Snapshot { issued_unix_ms: issued, facts, signature }).unwrap()
285    }
286
287    fn cache(key: &SigningKey) -> EntitlementCache {
288        EntitlementCache::new(&key.verifying_key().to_bytes(), "https://gunnar.rs/billing/{tenant}").unwrap()
289    }
290
291    const OCT_1_2026: u64 = 1_790_812_800_000;
292
293    #[test]
294    fn genuine_snapshot_is_accepted_and_answers() {
295        let k = key();
296        let c = cache(&k);
297        let bytes = snapshot(&k, 10, vec![signed(&k, fact("team/sub", State::Suspended, Some(OCT_1_2026)))]);
298        assert_eq!(c.refresh(&bytes).unwrap(), 1);
299        assert_eq!(c.state(&TenantId("team/sub".into())), State::Suspended);
300        assert_eq!(c.issued_unix_ms(), 10);
301    }
302
303    #[test]
304    fn forged_snapshot_is_rejected_and_changes_nothing() {
305        let k = key();
306        let forger = key();
307        let c = cache(&k);
308        let t = TenantId("team/sub".into());
309        // Envelope signed by the right key, one fact forged: the whole snapshot is refused.
310        let mut forged = signed(&forger, fact("team/sub", State::Paid, Some(OCT_1_2026)));
311        let bytes = snapshot(&k, 10, vec![forged.clone()]);
312        assert!(matches!(c.refresh(&bytes), Err(RefreshError::Signature(SignatureError::Fact(_)))));
313        // Envelope signed by the wrong key.
314        forged = signed(&k, forged);
315        let bytes = snapshot(&forger, 10, vec![forged.clone()]);
316        assert!(matches!(c.refresh(&bytes), Err(RefreshError::Signature(SignatureError::Envelope))));
317        // A genuine fact whose fields were edited after signing.
318        forged.state = State::Free;
319        assert!(matches!(c.push(forged), Err(SignatureError::Fact(_))));
320        // Garbage.
321        assert!(matches!(c.refresh(b"not json"), Err(RefreshError::Parse(_))));
322        assert!(c.is_empty());
323        assert_eq!(c.state(&t), State::Free);
324    }
325
326    #[test]
327    fn stale_snapshot_is_refused_newer_one_is_taken() {
328        let k = key();
329        let c = cache(&k);
330        c.refresh(&snapshot(&k, 20, vec![])).unwrap();
331        assert!(matches!(c.refresh(&snapshot(&k, 19, vec![])), Err(RefreshError::Stale { offered: 19, held: 20 })));
332        c.refresh(&snapshot(&k, 21, vec![])).unwrap();
333        assert_eq!(c.issued_unix_ms(), 21);
334    }
335
336    #[test]
337    fn push_replaces_one_tenant_and_keeps_the_rest() {
338        let k = key();
339        let c = cache(&k);
340        let a = TenantId("a".into());
341        let b = TenantId("b".into());
342        c.refresh(&snapshot(&k, 1, vec![signed(&k, fact("a", State::Paid, None)), signed(&k, fact("b", State::Paid, None))])).unwrap();
343        c.push(signed(&k, fact("a", State::Suspended, Some(OCT_1_2026)))).unwrap();
344        assert_eq!(c.state(&a), State::Suspended);
345        assert_eq!(c.state(&b), State::Paid);
346        assert_eq!(c.len(), 2);
347    }
348
349    /// The ladder table, row by row, column by column.
350    #[test]
351    fn verdict_table() {
352        use Action::*;
353        let k = key();
354        let c = cache(&k);
355        let t = TenantId("team/sub".into());
356        let rows: [(State, [bool; 4]); 5] = [
357            (State::Free, [true, true, true, true]),
358            (State::Paid, [true, true, true, true]),
359            (State::Grace, [true, true, true, true]),
360            (State::Suspended, [false, true, false, false]),
361            (State::Retention, [false, true, false, false]),
362        ];
363        for (state, expect) in rows {
364            c.push(signed(&k, fact("team/sub", state, Some(OCT_1_2026)))).unwrap();
365            for (action, allowed) in [Write, Read, AnonymousRead, Admin].into_iter().zip(expect) {
366                let v = c.allows(&t, action);
367                assert_eq!(v.is_allowed(), allowed, "{state:?} / {action:?} gave {v:?}");
368                if let Verdict::Refuse { state: s, reason_line, url } = &v {
369                    assert_eq!(*s, state);
370                    assert_eq!(url, "https://gunnar.rs/billing/team/sub");
371                    assert!(reason_line.contains("order 10gb expired 2026-10-01"), "{reason_line}");
372                    assert!(reason_line.ends_with("; renew at https://gunnar.rs/billing/team/sub"), "{reason_line}");
373                }
374            }
375            assert_eq!(c.notice(&t).is_some(), state == State::Grace, "{state:?} notice");
376        }
377    }
378
379    #[test]
380    fn refuse_twin_suspended_write_names_the_reason_and_unknown_tenant_is_free() {
381        let k = key();
382        let c = cache(&k);
383        let t = TenantId("team/sub".into());
384        c.push(signed(&k, fact("team/sub", State::Suspended, Some(OCT_1_2026)))).unwrap();
385        assert_eq!(
386            c.allows(&t, Action::Write),
387            Verdict::Refuse {
388                state: State::Suspended,
389                reason_line: "order 10gb expired 2026-10-01: writes closed; renew at https://gunnar.rs/billing/team/sub".into(),
390                url: "https://gunnar.rs/billing/team/sub".into(),
391            }
392        );
393        let op = signed(&k, fact("ops", State::Suspended, None));
394        c.push(op).unwrap();
395        assert!(matches!(c.allows(&TenantId("ops".into()), Action::Write), Verdict::Refuse { reason_line, .. } if reason_line.starts_with("order 10gb suspended by operator")));
396        assert_eq!(c.allows(&TenantId("nobody".into()), Action::Write), Verdict::Allow);
397        assert_eq!(c.state(&TenantId("nobody".into())), State::Free);
398    }
399}