Skip to main content

room_cli/
registry.rs

1//! Persistent user registry for cross-room identity.
2//!
3//! [`UserRegistry`] provides daemon-level user management: registration,
4//! token issuance/validation, room membership tracking, and global status.
5//! Data is persisted as JSON in a configurable data directory.
6//!
7//! This module is standalone — it does not depend on broker internals.
8//! The daemon (`roomd`, #251) wraps it in `Arc<Mutex<_>>` for concurrent access.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::PathBuf;
14use uuid::Uuid;
15
16/// A registered user with cross-room identity.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct User {
19    pub username: String,
20    pub created_at: DateTime<Utc>,
21    pub rooms: HashSet<String>,
22    pub status: Option<String>,
23}
24
25/// Persistent storage format — serialized to `users.json`.
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27struct RegistryData {
28    users: HashMap<String, User>,
29    /// Maps token UUID string → username.
30    tokens: HashMap<String, String>,
31}
32
33/// Daemon-level user registry with persistent storage.
34///
35/// Manages user lifecycle, token auth, room membership, and global status.
36/// All mutations auto-save to `{data_dir}/users.json`.
37#[derive(Debug)]
38pub struct UserRegistry {
39    data: RegistryData,
40    data_dir: PathBuf,
41}
42
43const REGISTRY_FILE: &str = "users.json";
44
45impl UserRegistry {
46    /// Create a new empty registry backed by the given directory.
47    ///
48    /// Does **not** load from disk — use [`UserRegistry::load`] for that.
49    pub fn new(data_dir: PathBuf) -> Self {
50        Self {
51            data: RegistryData::default(),
52            data_dir,
53        }
54    }
55
56    /// Load an existing registry from `{data_dir}/users.json`.
57    ///
58    /// Returns a fresh empty registry if the file does not exist.
59    /// Returns an error only if the file exists but cannot be parsed.
60    pub fn load(data_dir: PathBuf) -> Result<Self, String> {
61        let path = data_dir.join(REGISTRY_FILE);
62        if !path.exists() {
63            return Ok(Self::new(data_dir));
64        }
65        let contents =
66            std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
67        let data: RegistryData = serde_json::from_str(&contents)
68            .map_err(|e| format!("parse {}: {e}", path.display()))?;
69        Ok(Self { data, data_dir })
70    }
71
72    /// Persist the registry to `{data_dir}/users.json`.
73    pub fn save(&self) -> Result<(), String> {
74        std::fs::create_dir_all(&self.data_dir)
75            .map_err(|e| format!("create dir {}: {e}", self.data_dir.display()))?;
76        let path = self.data_dir.join(REGISTRY_FILE);
77        let json = serde_json::to_string_pretty(&self.data)
78            .map_err(|e| format!("serialize registry: {e}"))?;
79        std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display()))
80    }
81
82    // ── User CRUD ──────────────────────────────────────────────────
83
84    /// Register a new user. Fails if the username is already taken.
85    pub fn register_user(&mut self, username: &str) -> Result<&User, String> {
86        if username.is_empty() {
87            return Err("username cannot be empty".into());
88        }
89        if self.data.users.contains_key(username) {
90            return Err(format!("username already registered: {username}"));
91        }
92        let user = User {
93            username: username.to_owned(),
94            created_at: Utc::now(),
95            rooms: HashSet::new(),
96            status: None,
97        };
98        self.data.users.insert(username.to_owned(), user);
99        self.save()?;
100        Ok(self.data.users.get(username).unwrap())
101    }
102
103    /// Remove a user and all their tokens.
104    ///
105    /// Returns `true` if the user existed.
106    pub fn remove_user(&mut self, username: &str) -> Result<bool, String> {
107        let existed = self.data.users.remove(username).is_some();
108        if existed {
109            self.data.tokens.retain(|_, u| u != username);
110            self.save()?;
111        }
112        Ok(existed)
113    }
114
115    /// Look up a user by username.
116    pub fn get_user(&self, username: &str) -> Option<&User> {
117        self.data.users.get(username)
118    }
119
120    /// List all registered users.
121    pub fn list_users(&self) -> Vec<&User> {
122        self.data.users.values().collect()
123    }
124
125    // ── Token auth ─────────────────────────────────────────────────
126
127    /// Issue a new token for a registered user.
128    ///
129    /// The user must already be registered via [`register_user`].
130    pub fn issue_token(&mut self, username: &str) -> Result<String, String> {
131        if !self.data.users.contains_key(username) {
132            return Err(format!("user not registered: {username}"));
133        }
134        let token = Uuid::new_v4().to_string();
135        self.data.tokens.insert(token.clone(), username.to_owned());
136        self.save()?;
137        Ok(token)
138    }
139
140    /// Validate a token, returning the associated username.
141    pub fn validate_token(&self, token: &str) -> Option<&str> {
142        self.data.tokens.get(token).map(|s| s.as_str())
143    }
144
145    /// Revoke a specific token. Returns `true` if it existed.
146    pub fn revoke_token(&mut self, token: &str) -> Result<bool, String> {
147        let existed = self.data.tokens.remove(token).is_some();
148        if existed {
149            self.save()?;
150        }
151        Ok(existed)
152    }
153
154    /// Revoke all tokens for a user. Returns the number revoked.
155    pub fn revoke_user_tokens(&mut self, username: &str) -> Result<usize, String> {
156        let before = self.data.tokens.len();
157        self.data.tokens.retain(|_, u| u != username);
158        let revoked = before - self.data.tokens.len();
159        if revoked > 0 {
160            self.save()?;
161        }
162        Ok(revoked)
163    }
164
165    // ── Room membership ────────────────────────────────────────────
166
167    /// Record that a user has joined a room.
168    pub fn join_room(&mut self, username: &str, room_id: &str) -> Result<(), String> {
169        let user = self
170            .data
171            .users
172            .get_mut(username)
173            .ok_or_else(|| format!("user not registered: {username}"))?;
174        user.rooms.insert(room_id.to_owned());
175        self.save()
176    }
177
178    /// Record that a user has left a room.
179    pub fn leave_room(&mut self, username: &str, room_id: &str) -> Result<bool, String> {
180        let user = self
181            .data
182            .users
183            .get_mut(username)
184            .ok_or_else(|| format!("user not registered: {username}"))?;
185        let was_member = user.rooms.remove(room_id);
186        if was_member {
187            self.save()?;
188        }
189        Ok(was_member)
190    }
191
192    // ── Status ─────────────────────────────────────────────────────
193
194    /// Set or clear a user's global status.
195    ///
196    /// Pass `None` to clear. Status applies across all rooms the user is in.
197    pub fn set_status(&mut self, username: &str, status: Option<String>) -> Result<(), String> {
198        let user = self
199            .data
200            .users
201            .get_mut(username)
202            .ok_or_else(|| format!("user not registered: {username}"))?;
203        user.status = status;
204        self.save()
205    }
206
207    /// Return the path to the backing JSON file.
208    pub fn data_path(&self) -> PathBuf {
209        self.data_dir.join(REGISTRY_FILE)
210    }
211
212    /// Return `true` if any token is currently associated with `username`.
213    ///
214    /// Used by daemon auth to detect username collisions without scanning the
215    /// entire token map externally.
216    pub fn has_token_for_user(&self, username: &str) -> bool {
217        self.data.tokens.values().any(|u| u == username)
218    }
219
220    /// Register a user if not already registered; no-op if already present.
221    ///
222    /// Unlike [`register_user`], this is idempotent — calling it for an
223    /// existing user does not return an error. Used by daemon auth so that
224    /// users from a previous session (loaded from `users.json`) can rejoin
225    /// without triggering a registration error.
226    pub fn register_user_idempotent(&mut self, username: &str) -> Result<(), String> {
227        if self.data.users.contains_key(username) {
228            return Ok(());
229        }
230        self.register_user(username)?;
231        Ok(())
232    }
233
234    /// Return a snapshot of all current token → username mappings.
235    ///
236    /// Used at daemon startup to seed the in-memory `TokenMap` from persisted
237    /// registry data so existing tokens remain valid without a fresh join.
238    pub fn token_snapshot(&self) -> std::collections::HashMap<String, String> {
239        self.data.tokens.clone()
240    }
241
242    /// Insert a pre-existing token UUID for a registered user.
243    ///
244    /// Unlike [`issue_token`], which generates a fresh UUID, this method
245    /// preserves the caller-supplied `token` string. It is intended for
246    /// migration paths that read legacy token files (e.g. `/tmp/room-*-*.token`)
247    /// and want existing clients to remain valid without a forced re-join.
248    ///
249    /// Returns `Ok(())` immediately if the token is already present in the
250    /// registry (idempotent). Returns an error if `username` is not registered.
251    pub fn import_token(&mut self, username: &str, token: &str) -> Result<(), String> {
252        if !self.data.users.contains_key(username) {
253            return Err(format!("user not registered: {username}"));
254        }
255        if self.data.tokens.contains_key(token) {
256            return Ok(());
257        }
258        self.data
259            .tokens
260            .insert(token.to_owned(), username.to_owned());
261        self.save()
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn tmp_registry() -> (UserRegistry, tempfile::TempDir) {
270        let dir = tempfile::tempdir().unwrap();
271        let reg = UserRegistry::new(dir.path().to_owned());
272        (reg, dir)
273    }
274
275    // ── User CRUD ──────────────────────────────────────────────────
276
277    #[test]
278    fn register_and_get_user() {
279        let (mut reg, _dir) = tmp_registry();
280        let user = reg.register_user("alice").unwrap();
281        assert_eq!(user.username, "alice");
282        assert!(user.rooms.is_empty());
283        assert!(user.status.is_none());
284
285        let fetched = reg.get_user("alice").unwrap();
286        assert_eq!(fetched.username, "alice");
287    }
288
289    #[test]
290    fn register_duplicate_rejected() {
291        let (mut reg, _dir) = tmp_registry();
292        reg.register_user("alice").unwrap();
293        let err = reg.register_user("alice").unwrap_err();
294        assert!(err.contains("already registered"));
295    }
296
297    #[test]
298    fn register_empty_username_rejected() {
299        let (mut reg, _dir) = tmp_registry();
300        let err = reg.register_user("").unwrap_err();
301        assert!(err.contains("cannot be empty"));
302    }
303
304    #[test]
305    fn remove_user_cleans_tokens() {
306        let (mut reg, _dir) = tmp_registry();
307        reg.register_user("alice").unwrap();
308        let token = reg.issue_token("alice").unwrap();
309        assert!(reg.validate_token(&token).is_some());
310
311        reg.remove_user("alice").unwrap();
312        assert!(reg.get_user("alice").is_none());
313        assert!(reg.validate_token(&token).is_none());
314    }
315
316    #[test]
317    fn remove_nonexistent_user_returns_false() {
318        let (mut reg, _dir) = tmp_registry();
319        assert!(!reg.remove_user("ghost").unwrap());
320    }
321
322    #[test]
323    fn list_users_returns_all() {
324        let (mut reg, _dir) = tmp_registry();
325        reg.register_user("alice").unwrap();
326        reg.register_user("bob").unwrap();
327        let users = reg.list_users();
328        assert_eq!(users.len(), 2);
329        let names: HashSet<&str> = users.iter().map(|u| u.username.as_str()).collect();
330        assert!(names.contains("alice"));
331        assert!(names.contains("bob"));
332    }
333
334    // ── Token auth ─────────────────────────────────────────────────
335
336    #[test]
337    fn issue_and_validate_token() {
338        let (mut reg, _dir) = tmp_registry();
339        reg.register_user("alice").unwrap();
340        let token = reg.issue_token("alice").unwrap();
341        assert_eq!(reg.validate_token(&token), Some("alice"));
342    }
343
344    #[test]
345    fn issue_token_for_unregistered_user_fails() {
346        let (mut reg, _dir) = tmp_registry();
347        let err = reg.issue_token("ghost").unwrap_err();
348        assert!(err.contains("not registered"));
349    }
350
351    #[test]
352    fn validate_unknown_token_returns_none() {
353        let (reg, _dir) = tmp_registry();
354        assert!(reg.validate_token("bad-token").is_none());
355    }
356
357    #[test]
358    fn revoke_token() {
359        let (mut reg, _dir) = tmp_registry();
360        reg.register_user("alice").unwrap();
361        let token = reg.issue_token("alice").unwrap();
362        assert!(reg.revoke_token(&token).unwrap());
363        assert!(reg.validate_token(&token).is_none());
364    }
365
366    #[test]
367    fn revoke_nonexistent_token_returns_false() {
368        let (mut reg, _dir) = tmp_registry();
369        assert!(!reg.revoke_token("nope").unwrap());
370    }
371
372    #[test]
373    fn revoke_user_tokens_removes_all() {
374        let (mut reg, _dir) = tmp_registry();
375        reg.register_user("alice").unwrap();
376        let t1 = reg.issue_token("alice").unwrap();
377        let t2 = reg.issue_token("alice").unwrap();
378        assert_eq!(reg.revoke_user_tokens("alice").unwrap(), 2);
379        assert!(reg.validate_token(&t1).is_none());
380        assert!(reg.validate_token(&t2).is_none());
381    }
382
383    #[test]
384    fn multiple_users_tokens_isolated() {
385        let (mut reg, _dir) = tmp_registry();
386        reg.register_user("alice").unwrap();
387        reg.register_user("bob").unwrap();
388        let ta = reg.issue_token("alice").unwrap();
389        let tb = reg.issue_token("bob").unwrap();
390
391        reg.revoke_user_tokens("alice").unwrap();
392        assert!(reg.validate_token(&ta).is_none());
393        assert_eq!(reg.validate_token(&tb), Some("bob"));
394    }
395
396    // ── Room membership ────────────────────────────────────────────
397
398    #[test]
399    fn join_and_leave_room() {
400        let (mut reg, _dir) = tmp_registry();
401        reg.register_user("alice").unwrap();
402        reg.join_room("alice", "lobby").unwrap();
403        assert!(reg.get_user("alice").unwrap().rooms.contains("lobby"));
404
405        assert!(reg.leave_room("alice", "lobby").unwrap());
406        assert!(!reg.get_user("alice").unwrap().rooms.contains("lobby"));
407    }
408
409    #[test]
410    fn join_multiple_rooms() {
411        let (mut reg, _dir) = tmp_registry();
412        reg.register_user("alice").unwrap();
413        reg.join_room("alice", "room-a").unwrap();
414        reg.join_room("alice", "room-b").unwrap();
415        let rooms = &reg.get_user("alice").unwrap().rooms;
416        assert_eq!(rooms.len(), 2);
417        assert!(rooms.contains("room-a"));
418        assert!(rooms.contains("room-b"));
419    }
420
421    #[test]
422    fn leave_room_not_member_returns_false() {
423        let (mut reg, _dir) = tmp_registry();
424        reg.register_user("alice").unwrap();
425        assert!(!reg.leave_room("alice", "nowhere").unwrap());
426    }
427
428    #[test]
429    fn room_ops_on_unregistered_user_fail() {
430        let (mut reg, _dir) = tmp_registry();
431        assert!(reg.join_room("ghost", "lobby").is_err());
432        assert!(reg.leave_room("ghost", "lobby").is_err());
433    }
434
435    // ── Status ─────────────────────────────────────────────────────
436
437    #[test]
438    fn set_and_clear_status() {
439        let (mut reg, _dir) = tmp_registry();
440        reg.register_user("alice").unwrap();
441        reg.set_status("alice", Some("coding".into())).unwrap();
442        assert_eq!(
443            reg.get_user("alice").unwrap().status.as_deref(),
444            Some("coding")
445        );
446
447        reg.set_status("alice", None).unwrap();
448        assert!(reg.get_user("alice").unwrap().status.is_none());
449    }
450
451    #[test]
452    fn status_on_unregistered_user_fails() {
453        let (mut reg, _dir) = tmp_registry();
454        assert!(reg.set_status("ghost", Some("hi".into())).is_err());
455    }
456
457    // ── Persistence ────────────────────────────────────────────────
458
459    #[test]
460    fn save_and_load_round_trip() {
461        let dir = tempfile::tempdir().unwrap();
462        let token;
463        {
464            let mut reg = UserRegistry::new(dir.path().to_owned());
465            reg.register_user("alice").unwrap();
466            token = reg.issue_token("alice").unwrap();
467            reg.join_room("alice", "lobby").unwrap();
468            reg.set_status("alice", Some("active".into())).unwrap();
469            // save is called by each mutation, but explicit save is fine too
470        }
471
472        let loaded = UserRegistry::load(dir.path().to_owned()).unwrap();
473        let user = loaded.get_user("alice").unwrap();
474        assert_eq!(user.username, "alice");
475        assert!(user.rooms.contains("lobby"));
476        assert_eq!(user.status.as_deref(), Some("active"));
477        assert_eq!(loaded.validate_token(&token), Some("alice"));
478    }
479
480    #[test]
481    fn load_missing_file_returns_empty() {
482        let dir = tempfile::tempdir().unwrap();
483        let reg = UserRegistry::load(dir.path().to_owned()).unwrap();
484        assert!(reg.list_users().is_empty());
485    }
486
487    #[test]
488    fn has_token_for_user_true_when_token_exists() {
489        let (mut reg, _dir) = tmp_registry();
490        reg.register_user("alice").unwrap();
491        reg.issue_token("alice").unwrap();
492        assert!(reg.has_token_for_user("alice"));
493    }
494
495    #[test]
496    fn has_token_for_user_false_when_no_token() {
497        let (mut reg, _dir) = tmp_registry();
498        reg.register_user("alice").unwrap();
499        assert!(!reg.has_token_for_user("alice"));
500    }
501
502    #[test]
503    fn register_user_idempotent_noop_for_existing() {
504        let (mut reg, _dir) = tmp_registry();
505        reg.register_user("alice").unwrap();
506        let token = reg.issue_token("alice").unwrap();
507        // Should not error and should not disturb existing data
508        reg.register_user_idempotent("alice").unwrap();
509        assert_eq!(reg.validate_token(&token), Some("alice"));
510    }
511
512    #[test]
513    fn register_user_idempotent_creates_new_user() {
514        let (mut reg, _dir) = tmp_registry();
515        reg.register_user_idempotent("bob").unwrap();
516        assert!(reg.get_user("bob").is_some());
517    }
518
519    #[test]
520    fn token_snapshot_returns_all_tokens() {
521        let (mut reg, _dir) = tmp_registry();
522        reg.register_user("alice").unwrap();
523        reg.register_user("bob").unwrap();
524        let t1 = reg.issue_token("alice").unwrap();
525        let t2 = reg.issue_token("bob").unwrap();
526        let snap = reg.token_snapshot();
527        assert_eq!(snap.get(&t1).map(String::as_str), Some("alice"));
528        assert_eq!(snap.get(&t2).map(String::as_str), Some("bob"));
529    }
530
531    // ── import_token ───────────────────────────────────────────────
532
533    #[test]
534    fn import_token_preserves_uuid() {
535        let (mut reg, _dir) = tmp_registry();
536        reg.register_user("alice").unwrap();
537        reg.import_token("alice", "legacy-uuid-1234").unwrap();
538        assert_eq!(reg.validate_token("legacy-uuid-1234"), Some("alice"));
539    }
540
541    #[test]
542    fn import_token_noop_if_already_present() {
543        let (mut reg, _dir) = tmp_registry();
544        reg.register_user("alice").unwrap();
545        reg.import_token("alice", "tok-abc").unwrap();
546        // Second call must not error and must not change anything.
547        reg.import_token("alice", "tok-abc").unwrap();
548        assert_eq!(reg.validate_token("tok-abc"), Some("alice"));
549    }
550
551    #[test]
552    fn import_token_fails_for_unregistered_user() {
553        let (mut reg, _dir) = tmp_registry();
554        let err = reg.import_token("ghost", "tok-xyz").unwrap_err();
555        assert!(err.contains("not registered"));
556    }
557
558    #[test]
559    fn load_corrupt_file_returns_error() {
560        let dir = tempfile::tempdir().unwrap();
561        std::fs::write(dir.path().join(REGISTRY_FILE), "not json{{{").unwrap();
562        let err = UserRegistry::load(dir.path().to_owned()).unwrap_err();
563        assert!(err.contains("parse"));
564    }
565
566    #[test]
567    fn persistence_survives_remove_and_reload() {
568        let dir = tempfile::tempdir().unwrap();
569        {
570            let mut reg = UserRegistry::new(dir.path().to_owned());
571            reg.register_user("alice").unwrap();
572            reg.register_user("bob").unwrap();
573            reg.remove_user("alice").unwrap();
574        }
575
576        let loaded = UserRegistry::load(dir.path().to_owned()).unwrap();
577        assert!(loaded.get_user("alice").is_none());
578        assert!(loaded.get_user("bob").is_some());
579    }
580}