rovenue/
identity.rs

1use std::sync::{Arc, Mutex};
2
3use crate::cache::identity::{IdentityRepo, IdentityRow};
4use crate::cache::CacheStore;
5use crate::error::{RovenueError, RovenueResult};
6use crate::observer::{ChangeEvent, ObserverBus};
7use crate::time::Clock;
8
9/// A pending identify bind read as ONE pair from the persisted row: the
10/// `rovenue_id` the row linked to `app_user_id`. A bind must post this pair,
11/// never the row's `app_user_id` with a separately read `rovenue_id`, or a
12/// `log_out` in between labels the new anonymous device as the old user.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct PendingBind {
15    pub rovenue_id: String,
16    pub app_user_id: String,
17}
18
19#[derive(Debug, Clone)]
20pub struct User {
21    pub rovenue_id: String,
22    pub app_user_id: Option<String>,
23}
24
25pub struct IdentityManager {
26    store: Arc<CacheStore>,
27    bus: Arc<ObserverBus>,
28    clock: Arc<dyn Clock>,
29    cached: Mutex<User>,
30}
31
32impl IdentityManager {
33    pub fn new(store: Arc<CacheStore>, bus: Arc<ObserverBus>, clock: Arc<dyn Clock>) -> Self {
34        let repo = IdentityRepo::new(&store);
35        let row = match repo.load().ok().flatten() {
36            Some(r) => r,
37            None => {
38                let new_row = IdentityRow {
39                    rovenue_id: format!("rov_{}", cuid2::create_id()),
40                    app_user_id: None,
41                    synced: true,
42                    created_at_ms: clock.now_unix_ms(),
43                };
44                repo.save(&new_row).expect("persist initial identity");
45                new_row
46            }
47        };
48        let user = User {
49            rovenue_id: row.rovenue_id,
50            app_user_id: row.app_user_id,
51        };
52        Self {
53            store,
54            bus,
55            clock,
56            cached: Mutex::new(user),
57        }
58    }
59
60    pub fn current_user(&self) -> User {
61        self.cached.lock().expect("identity mutex poisoned").clone()
62    }
63
64    /// The user scope used by the **local cache layer ONLY** — `app_user_id` if
65    /// identified, else `rovenue_id`. So a logged-in user's cached entitlements
66    /// live in their own namespace and flip on identify/log_out.
67    ///
68    /// NEVER send this on the wire. Every server endpoint resolves the inbound
69    /// identity header/field as a `rovenueId` (the `app_user_id` is a
70    /// server-side label attached via `POST /v1/identify`). Use
71    /// [`rovenue_id`](Self::rovenue_id) as the network identity instead;
72    /// sending the `app_user_id` routes requests to an orphan subscriber row.
73    pub fn current_user_scope(&self) -> String {
74        let u = self.cached.lock().expect("identity mutex poisoned");
75        u.app_user_id
76            .clone()
77            .unwrap_or_else(|| u.rovenue_id.clone())
78    }
79
80    /// Optimistic-local identity set. Persists the `app_user_id` with
81    /// `synced: false` (a follow-up `POST /v1/identify` flips it to synced via
82    /// [`mark_synced`]). Emits `IdentityChanged` only when the value actually
83    /// changes. Returns `true` when the identity changed, `false` when it was
84    /// already this `app_user_id` (no write, no emit).
85    pub fn set_app_user_id(&self, app_user_id: String) -> RovenueResult<bool> {
86        if app_user_id.trim().is_empty() {
87            return Err(RovenueError::InvalidArgument()); // dedicated invalid-argument error (distinct from InvalidApiKey)
88        }
89        // The persist happens inside the identity lock, as in `log_out`, so
90        // the persisted row and the in-memory user never disagree: a
91        // concurrent `log_out()` cannot land between the cache write and the
92        // row write and leave a row that pairs this id with a stale device.
93        let changed = {
94            let mut u = self.cached.lock().expect("identity mutex poisoned");
95            if u.app_user_id.as_deref() == Some(app_user_id.as_str()) {
96                false
97            } else {
98                let row = IdentityRow {
99                    rovenue_id: u.rovenue_id.clone(),
100                    app_user_id: Some(app_user_id.clone()),
101                    synced: false,
102                    created_at_ms: self.clock.now_unix_ms(),
103                };
104                IdentityRepo::new(&self.store).save(&row)?;
105                u.app_user_id = Some(app_user_id);
106                true
107            }
108        };
109        if changed {
110            self.bus.emit(ChangeEvent::IdentityChanged);
111        }
112        Ok(changed)
113    }
114
115    /// Marks the persisted identity row as synced (server `POST /v1/identify`
116    /// succeeded) — but ONLY when the row still carries the pair that was
117    /// actually sent. This guards two races: `identify(A)`'s slow POST
118    /// completing after `identify(B)` overwrote the row (stamping `B` synced
119    /// though only `A` was sent), and an old device's POST completing after
120    /// `log_out` + `identify(A)` paired `A` with a new device (stamping the new
121    /// pair synced though only the old one was sent). Runs under the identity
122    /// lock so it cannot write back a row `log_out` just replaced. Does not
123    /// touch the in-memory `User` or emit.
124    pub fn mark_synced(&self, sent: &PendingBind) -> RovenueResult<()> {
125        let _u = self.cached.lock().expect("identity mutex poisoned");
126        let repo = IdentityRepo::new(&self.store);
127        if let Some(mut row) = repo.load()? {
128            if !row.synced
129                && row.rovenue_id == sent.rovenue_id
130                && row.app_user_id.as_deref() == Some(sent.app_user_id.as_str())
131            {
132                row.synced = true;
133                repo.save(&row)?;
134            }
135        }
136        Ok(())
137    }
138
139    /// The pending identify bind, read as one pair from the persisted row
140    /// under the identity lock — `Some` only while the row has
141    /// `synced == false`, carries an `app_user_id`, and still matches the
142    /// in-memory identity. A row the current identity no longer matches (the
143    /// identity changed since it was written) yields `None`: the bind is
144    /// skipped rather than sent for a device that has moved on.
145    pub fn pending_bind(&self) -> Option<PendingBind> {
146        let u = self.cached.lock().expect("identity mutex poisoned");
147        let row = IdentityRepo::new(&self.store).load().ok().flatten()?;
148        if row.synced || row.rovenue_id != u.rovenue_id || row.app_user_id != u.app_user_id {
149            return None;
150        }
151        Some(PendingBind {
152            rovenue_id: row.rovenue_id,
153            app_user_id: row.app_user_id?,
154        })
155    }
156
157    /// Returns the `app_user_id` awaiting server sync — the `app_user_id` of
158    /// [`pending_bind`](Self::pending_bind).
159    pub fn pending_app_user_id(&self) -> Option<String> {
160        self.pending_bind().map(|b| b.app_user_id)
161    }
162
163    /// The current anonymous device id.
164    pub fn rovenue_id(&self) -> String {
165        self.cached
166            .lock()
167            .expect("identity mutex poisoned")
168            .rovenue_id
169            .clone()
170    }
171
172    /// Resets identity to a fresh anonymous user: mints a new `rovenue_id`,
173    /// drops any `app_user_id`, persists, and notifies observers. Callers that
174    /// hold scope-bound caches must clear them after this returns.
175    pub fn log_out(&self) -> RovenueResult<()> {
176        let new_row = IdentityRow {
177            rovenue_id: format!("rov_{}", cuid2::create_id()),
178            app_user_id: None,
179            synced: true,
180            created_at_ms: self.clock.now_unix_ms(),
181        };
182        {
183            // Row and cache swap inside one critical section, so a pending
184            // bind read under the same lock sees either the old pair or no
185            // pending bind — never the old app_user_id with the new device.
186            let mut u = self.cached.lock().expect("identity mutex poisoned");
187            IdentityRepo::new(&self.store).save(&new_row)?;
188            u.rovenue_id = new_row.rovenue_id.clone();
189            u.app_user_id = None;
190        }
191        self.bus.emit(ChangeEvent::IdentityChanged);
192        Ok(())
193    }
194}