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}