1use std::collections::{HashMap, HashSet};
2use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
3use std::sync::{Arc, Mutex};
4
5use crate::cache::entitlements::EntitlementsRepo;
6use crate::cache::etag::EtagRepo;
7use crate::cache::CacheStore;
8use crate::error::{RovenueError, RovenueResult};
9use crate::identity::IdentityManager;
10use crate::observer::{ChangeEvent, ObserverBus};
11use crate::time::Clock;
12use crate::transport::api::ApiEnvelope;
13use crate::transport::http_client::HttpClient;
14use crate::transport::types::HttpRequest;
15
16use super::api::map_to_rows;
17use super::types::{Entitlement, EntitlementWire, EntitlementsResponse};
18
19const RESOURCE: &str = "entitlements";
20
21const EXPIRY_SKEW_MS: u64 = 5 * 60 * 1000;
24
25pub const DEFAULT_MAX_OFFLINE_GRACE_MS: u64 = 7 * 24 * 60 * 60 * 1000;
32
33pub(crate) fn is_stale(now: u64, last: u64, staleness_ms: u64) -> bool {
36 now.saturating_sub(last) > staleness_ms
37}
38
39pub struct EntitlementReader {
40 store: Arc<CacheStore>,
41 identity: Arc<IdentityManager>,
42 http: Option<Arc<HttpClient>>,
43 bus: Option<Arc<ObserverBus>>,
44 clock: Option<Arc<dyn Clock>>,
45 last_refresh_ms: AtomicU64,
46 refreshing: AtomicBool,
47 max_offline_grace_ms: u64,
48 lapse_notified: Mutex<HashSet<String>>,
52}
53
54impl EntitlementReader {
55 pub fn new(store: Arc<CacheStore>, identity: Arc<IdentityManager>) -> Self {
56 Self {
57 store,
58 identity,
59 http: None,
60 bus: None,
61 clock: None,
62 last_refresh_ms: AtomicU64::new(0),
63 refreshing: AtomicBool::new(false),
64 max_offline_grace_ms: DEFAULT_MAX_OFFLINE_GRACE_MS,
65 lapse_notified: Mutex::new(HashSet::new()),
66 }
67 }
68
69 pub fn with_http(mut self, http: Arc<HttpClient>) -> Self {
70 self.http = Some(http);
71 self
72 }
73 pub fn with_observer_bus(mut self, bus: Arc<ObserverBus>) -> Self {
74 self.bus = Some(bus);
75 self
76 }
77 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
78 self.clock = Some(clock);
79 self
80 }
81 pub fn with_max_offline_grace_ms(mut self, grace_ms: u64) -> Self {
85 self.max_offline_grace_ms = grace_ms;
86 self
87 }
88
89 pub fn get(&self, id: &str) -> RovenueResult<Option<Entitlement>> {
93 let scope = self.identity.current_user_scope();
94 let repo = EntitlementsRepo::new(&self.store);
95 Ok(repo
96 .get(&scope, id)?
97 .map(|row| self.apply_offline_policy(&scope, row)))
98 }
99
100 pub fn list_all(&self) -> RovenueResult<Vec<Entitlement>> {
104 let scope = self.identity.current_user_scope();
105 let repo = EntitlementsRepo::new(&self.store);
106 Ok(repo
107 .list(&scope)?
108 .into_iter()
109 .map(|row| self.apply_offline_policy(&scope, row))
110 .collect())
111 }
112
113 fn apply_offline_policy(
126 &self,
127 scope: &str,
128 row: crate::cache::entitlements::EntitlementRow,
129 ) -> Entitlement {
130 let mut ent = row_to_entitlement(&row);
131 if !ent.is_active {
132 return ent;
133 }
134 let now = match &self.clock {
135 Some(c) => c.now_unix_ms(),
136 None => return ent,
137 };
138 let expired = row
139 .expires_at_ms
140 .is_some_and(|t| now.saturating_sub(t) > EXPIRY_SKEW_MS);
141 let grace_lapsed =
142 now.saturating_sub(row.updated_at_ms) > self.max_offline_grace_ms;
143 if !expired && !grace_lapsed {
144 return ent;
145 }
146 ent.is_active = false;
147 let key = format!("{scope}\0{}", row.entitlement_id);
148 let first = self
149 .lapse_notified
150 .lock()
151 .map(|mut notified| notified.insert(key))
152 .unwrap_or(false);
153 if first {
154 if let Some(bus) = &self.bus {
155 bus.emit(ChangeEvent::EntitlementsChanged);
156 }
157 }
158 ent
159 }
160
161 fn rearm_lapse(&self, scope: &str) {
164 if let Ok(mut notified) = self.lapse_notified.lock() {
165 let prefix = format!("{scope}\0");
166 notified.retain(|k| !k.starts_with(&prefix));
167 }
168 }
169
170 pub fn refresh(&self) -> RovenueResult<()> {
171 let http = self.http.as_ref().ok_or(RovenueError::Internal())?;
172 let clock = self.clock.as_ref().ok_or(RovenueError::Internal())?;
173
174 let scope = self.identity.current_user_scope();
178 let wire_id = self.identity.rovenue_id();
179 let etag_repo = EtagRepo::new(&self.store);
180 let prior_etag = etag_repo.get(RESOURCE)?;
181
182 let mut req = HttpRequest::new("/v1/me/entitlements").user_scope(&wire_id);
183 if let Some(ref e) = prior_etag {
184 req = req.etag(e);
185 }
186
187 let resp = http.get_json::<ApiEnvelope<EntitlementsResponse>>(req)?;
188
189 if resp.status == 304 {
190 self.last_refresh_ms
191 .store(clock.now_unix_ms(), Ordering::Relaxed);
192 return Ok(());
193 }
194
195 let body = resp.body.ok_or(RovenueError::Internal())?;
196 let now = clock.now_unix_ms();
197 let rows = map_to_rows(body.data.entitlements, now);
198 EntitlementsRepo::new(&self.store).upsert_many(&scope, &rows)?;
199 self.rearm_lapse(&scope);
200 if let Some(etag) = resp.etag {
201 etag_repo.put(RESOURCE, &etag, now)?;
202 }
203 self.last_refresh_ms.store(now, Ordering::Relaxed);
204 if let Some(bus) = &self.bus {
205 bus.emit(ChangeEvent::EntitlementsChanged);
206 }
207 Ok(())
208 }
209
210 pub fn hydrate(
213 &self,
214 scope: &str,
215 map: HashMap<String, EntitlementWire>,
216 now: u64,
217 ) -> RovenueResult<()> {
218 let rows = map_to_rows(map, now);
219 EntitlementsRepo::new(&self.store).upsert_many(scope, &rows)?;
220 self.rearm_lapse(scope);
221 self.last_refresh_ms.store(now, Ordering::Relaxed);
222 if let Some(bus) = &self.bus {
223 bus.emit(ChangeEvent::EntitlementsChanged);
224 }
225 Ok(())
226 }
227
228 pub fn maybe_refresh_async(self: &std::sync::Arc<Self>, staleness_ms: u64) {
232 let now = match &self.clock {
233 Some(c) => c.now_unix_ms(),
234 None => return,
235 };
236 if !is_stale(
237 now,
238 self.last_refresh_ms.load(Ordering::Relaxed),
239 staleness_ms,
240 ) {
241 return;
242 }
243 if self
244 .refreshing
245 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
246 .is_err()
247 {
248 return; }
250 let this = std::sync::Arc::clone(self);
251 std::thread::spawn(move || {
252 struct ClearOnDrop<'a>(&'a std::sync::atomic::AtomicBool);
255 impl Drop for ClearOnDrop<'_> {
256 fn drop(&mut self) {
257 self.0.store(false, std::sync::atomic::Ordering::Release);
258 }
259 }
260 let _guard = ClearOnDrop(&this.refreshing);
261 let _ = this.refresh();
262 });
263 }
264}
265
266fn row_to_entitlement(r: &crate::cache::entitlements::EntitlementRow) -> Entitlement {
267 Entitlement {
268 id: r.entitlement_id.clone(),
269 is_active: r.is_active,
270 product_identifier: r.product_identifier.clone(),
271 store: r.store.clone(),
272 expires_iso: r.expires_iso.clone(),
273 }
274}
275
276#[cfg(test)]
277mod stale_tests {
278 use super::is_stale;
279 #[test]
280 fn staleness_decision() {
281 assert!(is_stale(100_000, 0, 60_000));
282 assert!(is_stale(100_000, 30_000, 60_000));
283 assert!(!is_stale(100_000, 50_000, 60_000));
284 assert!(!is_stale(100_000, 100_000, 60_000));
285 }
286}
287
288#[cfg(test)]
289mod panic_safety_tests {
290 use super::EntitlementReader;
291 use crate::cache::CacheStore;
292 use crate::identity::IdentityManager;
293 use crate::observer::{ChangeEvent, Observer, ObserverBus};
294 use crate::time::{Clock, SystemClock};
295 use crate::transport::http_client::HttpClient;
296 use std::sync::atomic::Ordering;
297 use std::sync::Arc;
298
299 struct PanickingObserver;
302 impl Observer for PanickingObserver {
303 fn on_change(&self, _event: ChangeEvent) {
304 panic!("boom from observer");
305 }
306 }
307
308 #[test]
314 fn maybe_refresh_async_clears_flag_even_if_refresh_panics() {
315 let mut server = mockito::Server::new();
316 let _m = server
319 .mock("GET", "/v1/me/entitlements")
320 .with_status(200)
321 .with_header("content-type", "application/json")
322 .with_body(r#"{"data":{"entitlements":{}}}"#)
323 .create();
324
325 let store = Arc::new(CacheStore::open_in_memory().unwrap());
326 let bus = Arc::new(ObserverBus::default());
327 bus.register(Arc::new(PanickingObserver));
328 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
329 let identity = Arc::new(IdentityManager::new(
330 Arc::clone(&store),
331 Arc::clone(&bus),
332 Arc::clone(&clock),
333 ));
334 let http = Arc::new(HttpClient::new(server.url(), "pk_test".to_string()));
335 let reader = Arc::new(
336 EntitlementReader::new(Arc::clone(&store), Arc::clone(&identity))
337 .with_http(http)
338 .with_observer_bus(Arc::clone(&bus))
339 .with_clock(clock),
340 );
341
342 reader.maybe_refresh_async(60_000);
344
345 std::thread::sleep(std::time::Duration::from_millis(500));
347
348 assert!(
349 !reader.refreshing.load(Ordering::SeqCst),
350 "refreshing flag must be cleared even when the background refresh() panics"
351 );
352 assert!(
354 reader
355 .refreshing
356 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
357 .is_ok(),
358 "flag must be claimable again after a panicking refresh"
359 );
360 }
361}
362
363#[cfg(test)]
364mod wire_identity_tests {
365 use super::EntitlementReader;
366 use crate::cache::CacheStore;
367 use crate::identity::IdentityManager;
368 use crate::observer::ObserverBus;
369 use crate::time::{Clock, SystemClock};
370 use crate::transport::http_client::HttpClient;
371 use std::sync::Arc;
372
373 #[test]
379 fn refresh_sends_rovenue_id_not_app_user_id_after_identify() {
380 let store = Arc::new(CacheStore::open_in_memory().unwrap());
381 let bus = Arc::new(ObserverBus::default());
382 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
383 let identity = Arc::new(IdentityManager::new(
384 Arc::clone(&store),
385 Arc::clone(&bus),
386 Arc::clone(&clock),
387 ));
388
389 let rovenue_id = identity.rovenue_id();
390 identity.set_app_user_id("user_42".to_string()).unwrap();
391 assert_eq!(identity.current_user_scope(), "user_42");
393 assert_eq!(identity.rovenue_id(), rovenue_id);
395
396 let mut server = mockito::Server::new();
397 let m = server
401 .mock("GET", "/v1/me/entitlements")
402 .match_header("X-Rovenue-App-User-Id", rovenue_id.as_str())
403 .with_status(200)
404 .with_header("content-type", "application/json")
405 .with_body(r#"{"data":{"entitlements":{}}}"#)
406 .expect(1)
407 .create();
408
409 let http = Arc::new(HttpClient::new(server.url(), "pk_test".to_string()));
410 let reader = EntitlementReader::new(Arc::clone(&store), Arc::clone(&identity))
411 .with_http(http)
412 .with_clock(Arc::clone(&clock));
413
414 reader
415 .refresh()
416 .expect("refresh must hit the rovenue_id-scoped endpoint and succeed");
417 m.assert();
418 }
419}