rovenue/entitlements/
reader.rs

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
21/// Clock-skew tolerance for expiry compares: a device clock running
22/// minutes fast must not cut access early.
23const EXPIRY_SKEW_MS: u64 = 5 * 60 * 1000;
24
25/// Default maximum offline grace. Cached entitlements older than this
26/// read inactive until a refresh succeeds. Fail-closed: an unbounded
27/// window would honor refunded/revoked access forever offline
28/// (finding: offline cache extends access past refund/expiry).
29/// Override per reader with
30/// [`EntitlementReader::with_max_offline_grace_ms`].
31pub const DEFAULT_MAX_OFFLINE_GRACE_MS: u64 = 7 * 24 * 60 * 60 * 1000;
32
33/// True when cached data is older than `staleness_ms`. `last == 0` (never
34/// refreshed, e.g. cold start) is always stale.
35pub(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    /// `scope\0entitlement_id` keys whose lapse already emitted — a lapse
49    /// notifies exactly once per episode, not once per read. Re-armed by
50    /// fresh data (see `refresh`/`hydrate`).
51    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    /// Override the maximum offline grace (see
82    /// [`DEFAULT_MAX_OFFLINE_GRACE_MS`]). Reads older than this report
83    /// inactive until a refresh succeeds.
84    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    /// One entitlement by id. Enforces the offline policy on the cached
90    /// row (see [`Self::apply_offline_policy`]): an expired or
91    /// grace-lapsed row reads back with `is_active == false`.
92    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    /// All cached entitlements. Each row goes through the same offline
101    /// policy as [`Self::get`]; lapsed rows are returned with
102    /// `is_active == false`, never silently dropped.
103    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    /// Offline enforcement (finding: offline cache extends access past
114    /// refund/expiry). The cache is last-known-good: without these
115    /// checks a refunded/expired/revoked user keeps full on-device
116    /// access forever by staying offline.
117    ///
118    /// A stored-active row flips to inactive when its server `expiresDate`
119    /// is past (beyond skew tolerance) or when the row itself is older
120    /// than the max offline grace. The stored row is untouched — only the
121    /// returned view flips — and the first read that observes a lapse
122    /// emits `EntitlementsChanged` so the app can re-gate. Without a
123    /// configured clock there is no time source to evaluate against, so
124    /// the row passes through (production always wires a clock).
125    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    /// Re-arm lapse notifications for a scope after fresh data lands, so
162    /// a future lapse emits again.
163    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        // Cache is namespaced by the identity scope (flips on identify), but the
175        // WIRE identity is always the stable rovenue_id — the server resolves
176        // every inbound header as a rovenueId. See IdentityManager docs.
177        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    /// Write entitlements straight from an in-memory access map (e.g. a receipt
211    /// POST response) — no network. Stamps freshness and emits the observer.
212    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    /// Non-blocking stale-while-revalidate trigger. Returns immediately; if the
229    /// cache is stale and no refresh is in flight, spawns one background refresh
230    /// (coalesced via `refreshing`) that emits the observer on completion.
231    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; // a refresh is already running — coalesce
249        }
250        let this = std::sync::Arc::clone(self);
251        std::thread::spawn(move || {
252            // Clear the coalesce flag even if refresh() unwinds, so a panicking
253            // observer callback can't permanently disable background refresh.
254            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    /// Observer whose callback unwinds — simulates a host (Swift/Kotlin) closure
300    /// panicking across the FFI boundary during `emit`.
301    struct PanickingObserver;
302    impl Observer for PanickingObserver {
303        fn on_change(&self, _event: ChangeEvent) {
304            panic!("boom from observer");
305        }
306    }
307
308    /// Regression: `maybe_refresh_async` spawns a background `refresh()`. If that
309    /// refresh unwinds (e.g. a panicking observer in `emit`), the `refreshing`
310    /// coalesce flag must STILL be cleared — otherwise a single panic would
311    /// permanently disable all future background refreshes. Guarded by an
312    /// RAII `ClearOnDrop` in the spawned thread.
313    #[test]
314    fn maybe_refresh_async_clears_flag_even_if_refresh_panics() {
315        let mut server = mockito::Server::new();
316        // refresh() GETs entitlements, upserts, stamps freshness, THEN emit()s —
317        // so the spawned thread reaches the panicking observer and unwinds.
318        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        // last_refresh_ms == 0 → stale → spawns a refresh that will unwind in emit().
343        reader.maybe_refresh_async(60_000);
344
345        // Let the background thread run, unwind, and drop the ClearOnDrop guard.
346        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        // Directly prove the coalesce slot is reclaimable (not wedged `true`).
353        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    /// Regression (P0): after `identify()`, the network identity sent to the
374    /// server MUST remain the permanent `rovenue_id` device key — NOT the
375    /// `app_user_id`. The server resolves every inbound `X-Rovenue-App-User-Id`
376    /// header as a rovenueId; sending the `app_user_id` would route the request
377    /// to a different (orphan) subscriber row and strand the real entitlements.
378    #[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        // Sanity: the cache scope now flips to the app_user_id...
392        assert_eq!(identity.current_user_scope(), "user_42");
393        // ...but the device key is unchanged.
394        assert_eq!(identity.rovenue_id(), rovenue_id);
395
396        let mut server = mockito::Server::new();
397        // Only matches when the wire header carries the STABLE rovenue_id.
398        // If the code (incorrectly) sends "user_42", this mock never matches,
399        // mockito answers 501, refresh() errors, and m.assert() fails.
400        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}