1pub mod civil;
44pub mod signing;
45pub mod ticket;
46
47use std::collections::BTreeMap;
48use std::sync::Arc;
49
50use arc_swap::ArcSwap;
51pub use ed25519_dalek::VerifyingKey;
55pub use monetize_product::{EntitlementFact, State, TenantId};
56pub use signing::{SignatureError, Snapshot};
57pub use ticket::{
60 verify_ticket, ActorTicket, SeenNonces, PURPOSE_ORDER, PURPOSE_READ, PURPOSE_RENEW,
61};
62
63#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65pub enum Action {
66 Write,
68 Read,
70 AnonymousRead,
72 Admin,
74}
75
76#[derive(Clone, PartialEq, Eq, Debug)]
77pub enum Verdict {
78 Allow,
79 Refuse {
80 state: State,
81 reason_line: String,
83 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 billing_url: String,
117 facts: ArcSwap<Facts>,
118}
119
120impl EntitlementCache {
121 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 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; };
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 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 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 pub fn fact(&self, tenant: &TenantId) -> Option<EntitlementFact> {
170 self.facts.load().by_tenant.get(tenant).cloned()
171 }
172
173 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 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 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 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 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 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 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 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 forged.state = State::Free;
319 assert!(matches!(c.push(forged), Err(SignatureError::Fact(_))));
320 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 #[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}