1pub mod civil;
44pub mod fleet;
45pub mod signing;
46pub mod sold;
47pub mod ticket;
48
49use std::collections::BTreeMap;
50use std::sync::Arc;
51
52use arc_swap::ArcSwap;
53pub use ed25519_dalek::VerifyingKey;
57pub use monetize_product::{EntitlementFact, State, TenantId};
58pub use signing::{SignatureError, Snapshot};
59pub use ticket::{
62 verify_ticket, ActorTicket, SeenNonces, PURPOSE_CANCEL, PURPOSE_ESTATE, PURPOSE_ORDER, PURPOSE_READ, PURPOSE_RENEW,
63};
64
65#[derive(Clone, Copy, PartialEq, Eq, Debug)]
67pub enum Action {
68 Write,
70 Read,
72 AnonymousRead,
74 Admin,
76}
77
78#[derive(Clone, PartialEq, Eq, Debug)]
79pub enum Verdict {
80 Allow,
81 Refuse {
82 state: State,
83 reason_line: String,
85 url: String,
87 },
88}
89
90impl Verdict {
91 pub fn is_allowed(&self) -> bool {
92 matches!(self, Verdict::Allow)
93 }
94}
95
96#[derive(Debug, thiserror::Error)]
97pub enum RefreshError {
98 #[error("snapshot is not valid JSON: {0}")]
99 Parse(#[from] serde_json::Error),
100 #[error(transparent)]
101 Signature(#[from] SignatureError),
102 #[error("snapshot issued at {offered} is older than the {held} the cache holds")]
103 Stale { offered: u64, held: u64 },
104}
105
106#[derive(Debug, thiserror::Error)]
107#[error("public key is not a valid Ed25519 point")]
108pub struct KeyError;
109
110struct Facts {
111 issued_unix_ms: u64,
112 by_tenant: BTreeMap<TenantId, EntitlementFact>,
113}
114
115pub struct EntitlementCache {
116 key: VerifyingKey,
117 billing_url: String,
119 facts: ArcSwap<Facts>,
120}
121
122impl EntitlementCache {
123 pub fn new(public_key: &[u8; 32], billing_url: &str) -> Result<Self, KeyError> {
126 let key = VerifyingKey::from_bytes(public_key).map_err(|_| KeyError)?;
127 Ok(Self {
128 key,
129 billing_url: billing_url.to_string(),
130 facts: ArcSwap::from_pointee(Facts { issued_unix_ms: 0, by_tenant: BTreeMap::new() }),
131 })
132 }
133
134 pub fn public_key(&self) -> &VerifyingKey {
135 &self.key
136 }
137
138 pub fn allows(&self, tenant: &TenantId, action: Action) -> Verdict {
140 let facts = self.facts.load();
141 let Some(fact) = facts.by_tenant.get(tenant) else {
142 return Verdict::Allow; };
144 match (fact.state, action) {
145 (State::Free | State::Paid | State::Grace, _) => Verdict::Allow,
146 (State::Suspended | State::Retention, Action::Read) => Verdict::Allow,
147 (state, action) => Verdict::Refuse {
148 state,
149 reason_line: self.reason_line(fact, action),
150 url: self.url_for(tenant),
151 },
152 }
153 }
154
155 pub fn notice(&self, tenant: &TenantId) -> Option<String> {
157 let facts = self.facts.load();
158 let fact = facts.by_tenant.get(tenant)?;
159 if fact.state != State::Grace {
160 return None;
161 }
162 Some(format!("{}; renew at {}", self.expiry_phrase(fact), self.url_for(tenant)))
163 }
164
165 pub fn state(&self, tenant: &TenantId) -> State {
167 self.facts.load().by_tenant.get(tenant).map_or(State::Free, |f| f.state)
168 }
169
170 pub fn fact(&self, tenant: &TenantId) -> Option<EntitlementFact> {
172 self.facts.load().by_tenant.get(tenant).cloned()
173 }
174
175 pub fn issued_unix_ms(&self) -> u64 {
177 self.facts.load().issued_unix_ms
178 }
179
180 pub fn len(&self) -> usize {
181 self.facts.load().by_tenant.len()
182 }
183
184 pub fn is_empty(&self) -> bool {
185 self.len() == 0
186 }
187
188 pub fn refresh(&self, snapshot_bytes: &[u8]) -> Result<usize, RefreshError> {
192 let snap: Snapshot = serde_json::from_slice(snapshot_bytes)?;
193 signing::verify_snapshot(&snap, &self.key)?;
194 let held = self.facts.load().issued_unix_ms;
195 if snap.issued_unix_ms < held {
196 return Err(RefreshError::Stale { offered: snap.issued_unix_ms, held });
197 }
198 let by_tenant: BTreeMap<TenantId, EntitlementFact> =
199 snap.facts.into_iter().map(|f| (f.tenant.clone(), f)).collect();
200 let n = by_tenant.len();
201 self.facts.store(Arc::new(Facts { issued_unix_ms: snap.issued_unix_ms, by_tenant }));
202 Ok(n)
203 }
204
205 pub fn push(&self, fact: EntitlementFact) -> Result<(), SignatureError> {
207 signing::verify_fact(&fact, &self.key)?;
208 let current = self.facts.load_full();
209 let mut by_tenant = current.by_tenant.clone();
210 by_tenant.insert(fact.tenant.clone(), fact);
211 self.facts.store(Arc::new(Facts { issued_unix_ms: current.issued_unix_ms, by_tenant }));
212 Ok(())
213 }
214
215 fn url_for(&self, tenant: &TenantId) -> String {
216 self.billing_url.replace("{tenant}", &tenant.0)
217 }
218
219 fn expiry_phrase(&self, fact: &EntitlementFact) -> String {
223 let what = if fact.plan.is_empty() { "entitlement".to_owned() } else { format!("order {}", fact.plan) };
224 match fact.paid_until_unix_ms {
225 Some(until) => format!("{what} expired {}", civil::iso_date(until)),
226 None => format!("{what} suspended by operator"),
227 }
228 }
229
230 fn reason_line(&self, fact: &EntitlementFact, action: Action) -> String {
231 let url = self.url_for(&fact.tenant);
232 let expiry = self.expiry_phrase(fact);
233 let what = match (fact.state, action) {
234 (State::Retention, Action::Write) => "writes closed, data kept for export",
235 (_, Action::Write) => "writes closed",
236 (_, Action::AnonymousRead) => "anonymous access closed",
237 (_, Action::Admin) => "settings closed",
238 (_, Action::Read) => unreachable!("Read is allowed in every state"),
239 };
240 format!("{expiry}: {what}; renew at {url}")
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use ed25519_dalek::{Signer, SigningKey};
248
249 fn key() -> SigningKey {
250 SigningKey::generate(&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng))
251 }
252
253 fn fact(tenant: &str, state: State, paid_until: Option<u64>) -> EntitlementFact {
254 EntitlementFact {
255 tenant: TenantId(tenant.into()),
256 plan: "10gb".into(),
257 state,
258 paid_until_unix_ms: paid_until,
259 caps: BTreeMap::from([("pack_bytes".to_string(), 10 << 30)]),
260 source: "payment:mock:gunnar/team/sub/2026-09-03".into(),
261 signature: vec![],
262 issued_unix_ms: None,
263 issued_signature: Vec::new(),
264 }
265 }
266
267 fn signed(key: &SigningKey, mut f: EntitlementFact) -> EntitlementFact {
270 f.issued_unix_ms = None;
271 f.issued_signature = Vec::new();
272 f.signature = key.sign(&signing::fact_message(&f)).to_bytes().to_vec();
273 f
274 }
275
276 fn snapshot(key: &SigningKey, issued: u64, facts: Vec<EntitlementFact>) -> Vec<u8> {
277 let signature = key.sign(&signing::snapshot_message(issued, &facts)).to_bytes().to_vec();
278 serde_json::to_vec(&Snapshot { issued_unix_ms: issued, facts, signature }).unwrap()
279 }
280
281 fn cache(key: &SigningKey) -> EntitlementCache {
282 EntitlementCache::new(&key.verifying_key().to_bytes(), "https://gunnar.rs/billing/{tenant}").unwrap()
283 }
284
285 const OCT_1_2026: u64 = 1_790_812_800_000;
286
287 #[test]
288 fn genuine_snapshot_is_accepted_and_answers() {
289 let k = key();
290 let c = cache(&k);
291 let bytes = snapshot(&k, 10, vec![signed(&k, fact("team/sub", State::Suspended, Some(OCT_1_2026)))]);
292 assert_eq!(c.refresh(&bytes).unwrap(), 1);
293 assert_eq!(c.state(&TenantId("team/sub".into())), State::Suspended);
294 assert_eq!(c.issued_unix_ms(), 10);
295 }
296
297 #[test]
298 fn forged_snapshot_is_rejected_and_changes_nothing() {
299 let k = key();
300 let forger = key();
301 let c = cache(&k);
302 let t = TenantId("team/sub".into());
303 let mut forged = signed(&forger, fact("team/sub", State::Paid, Some(OCT_1_2026)));
305 let bytes = snapshot(&k, 10, vec![forged.clone()]);
306 assert!(matches!(c.refresh(&bytes), Err(RefreshError::Signature(SignatureError::Fact(_)))));
307 forged = signed(&k, forged);
309 let bytes = snapshot(&forger, 10, vec![forged.clone()]);
310 assert!(matches!(c.refresh(&bytes), Err(RefreshError::Signature(SignatureError::Envelope))));
311 forged.state = State::Free;
313 assert!(matches!(c.push(forged), Err(SignatureError::Fact(_))));
314 assert!(matches!(c.refresh(b"not json"), Err(RefreshError::Parse(_))));
316 assert!(c.is_empty());
317 assert_eq!(c.state(&t), State::Free);
318 }
319
320 #[test]
321 fn stale_snapshot_is_refused_newer_one_is_taken() {
322 let k = key();
323 let c = cache(&k);
324 c.refresh(&snapshot(&k, 20, vec![])).unwrap();
325 assert!(matches!(c.refresh(&snapshot(&k, 19, vec![])), Err(RefreshError::Stale { offered: 19, held: 20 })));
326 c.refresh(&snapshot(&k, 21, vec![])).unwrap();
327 assert_eq!(c.issued_unix_ms(), 21);
328 }
329
330 #[test]
331 fn push_replaces_one_tenant_and_keeps_the_rest() {
332 let k = key();
333 let c = cache(&k);
334 let a = TenantId("a".into());
335 let b = TenantId("b".into());
336 c.refresh(&snapshot(&k, 1, vec![signed(&k, fact("a", State::Paid, None)), signed(&k, fact("b", State::Paid, None))])).unwrap();
337 c.push(signed(&k, fact("a", State::Suspended, Some(OCT_1_2026)))).unwrap();
338 assert_eq!(c.state(&a), State::Suspended);
339 assert_eq!(c.state(&b), State::Paid);
340 assert_eq!(c.len(), 2);
341 }
342
343 #[test]
345 fn verdict_table() {
346 use Action::*;
347 let k = key();
348 let c = cache(&k);
349 let t = TenantId("team/sub".into());
350 let rows: [(State, [bool; 4]); 5] = [
351 (State::Free, [true, true, true, true]),
352 (State::Paid, [true, true, true, true]),
353 (State::Grace, [true, true, true, true]),
354 (State::Suspended, [false, true, false, false]),
355 (State::Retention, [false, true, false, false]),
356 ];
357 for (state, expect) in rows {
358 c.push(signed(&k, fact("team/sub", state, Some(OCT_1_2026)))).unwrap();
359 for (action, allowed) in [Write, Read, AnonymousRead, Admin].into_iter().zip(expect) {
360 let v = c.allows(&t, action);
361 assert_eq!(v.is_allowed(), allowed, "{state:?} / {action:?} gave {v:?}");
362 if let Verdict::Refuse { state: s, reason_line, url } = &v {
363 assert_eq!(*s, state);
364 assert_eq!(url, "https://gunnar.rs/billing/team/sub");
365 assert!(reason_line.contains("order 10gb expired 2026-10-01"), "{reason_line}");
366 assert!(reason_line.ends_with("; renew at https://gunnar.rs/billing/team/sub"), "{reason_line}");
367 }
368 }
369 assert_eq!(c.notice(&t).is_some(), state == State::Grace, "{state:?} notice");
370 }
371 }
372
373 #[test]
374 fn refuse_twin_suspended_write_names_the_reason_and_unknown_tenant_is_free() {
375 let k = key();
376 let c = cache(&k);
377 let t = TenantId("team/sub".into());
378 c.push(signed(&k, fact("team/sub", State::Suspended, Some(OCT_1_2026)))).unwrap();
379 assert_eq!(
380 c.allows(&t, Action::Write),
381 Verdict::Refuse {
382 state: State::Suspended,
383 reason_line: "order 10gb expired 2026-10-01: writes closed; renew at https://gunnar.rs/billing/team/sub".into(),
384 url: "https://gunnar.rs/billing/team/sub".into(),
385 }
386 );
387 let op = signed(&k, fact("ops", State::Suspended, None));
388 c.push(op).unwrap();
389 assert!(matches!(c.allows(&TenantId("ops".into()), Action::Write), Verdict::Refuse { reason_line, .. } if reason_line.starts_with("order 10gb suspended by operator")));
390 assert_eq!(c.allows(&TenantId("nobody".into()), Action::Write), Verdict::Allow);
391 assert_eq!(c.state(&TenantId("nobody".into())), State::Free);
392 }
393}
394
395#[cfg(feature = "testing")]
397pub mod testing;