Skip to main content

hypercall_api/
username_service.rs

1//! Username service -- Postgres CRUD with optional Redis cache/publish.
2//!
3//! Each wallet can set exactly one display name.  Usernames are unique
4//! case-insensitively (stored as-is, compared via `LOWER()`).
5
6use anyhow::{Context, Result};
7use redis::aio::ConnectionManager;
8use redis::AsyncCommands;
9use std::sync::Arc;
10use tracing::{debug, info, warn};
11
12use hypercall_db::{UsernameRecord, UsernameWriteError, UsernameWriter};
13
14// Re-export for handler compatibility
15pub use hypercall_db::UsernameWriteError as UsernameServiceError;
16
17// ---------------------------------------------------------------------------
18// Validation
19// ---------------------------------------------------------------------------
20
21/// Maximum username length.
22const MAX_LEN: usize = 20;
23/// Minimum username length.
24const MIN_LEN: usize = 3;
25const RESERVED_USERNAMES: &[&str] = &[
26    "admin",
27    "administrator",
28    "api",
29    "help",
30    "hypercall",
31    "moderator",
32    "root",
33    "staff",
34    "support",
35    "system",
36];
37
38/// Validate a username against the platform rules.
39///
40/// Returns `Ok(())` when the username is acceptable, or `Err(message)` with a
41/// human-readable reason.
42pub fn validate_username(name: &str) -> std::result::Result<(), String> {
43    if name.trim() != name {
44        return Err("Username cannot start or end with whitespace".to_string());
45    }
46    if name.len() < MIN_LEN || name.len() > MAX_LEN {
47        return Err(format!(
48            "Username must be between {} and {} characters",
49            MIN_LEN, MAX_LEN
50        ));
51    }
52
53    let normalized = name.to_ascii_lowercase();
54    if RESERVED_USERNAMES.contains(&normalized.as_str()) {
55        return Err("Username is reserved".to_string());
56    }
57    if normalized.starts_with("0x") {
58        return Err("Username cannot look like a wallet address".to_string());
59    }
60
61    // Must not contain ".hl"
62    if normalized.contains(".hl") {
63        return Err("Username cannot contain \".hl\"".to_string());
64    }
65
66    // Only alphanumeric and underscores. Keep this aligned with the profile
67    // username request path so direct username writes cannot bypass review
68    // rules.
69    for ch in name.chars() {
70        if !ch.is_ascii_alphanumeric() && ch != '_' {
71            return Err(format!(
72                "Username can only contain alphanumeric characters and underscores (found '{}')",
73                ch
74            ));
75        }
76    }
77
78    // Must start with alphanumeric
79    if let Some(first) = name.chars().next() {
80        if !first.is_ascii_alphanumeric() {
81            return Err("Username must start with an alphanumeric character".to_string());
82        }
83    }
84
85    Ok(())
86}
87
88// ---------------------------------------------------------------------------
89// Redis key helpers
90// ---------------------------------------------------------------------------
91
92fn redis_wallet_key(wallet: &str) -> String {
93    format!("username:wallet:{}", wallet.to_lowercase())
94}
95
96fn redis_reverse_key(username: &str) -> String {
97    format!("username:reverse:{}", username.to_lowercase())
98}
99
100// ---------------------------------------------------------------------------
101// Service
102// ---------------------------------------------------------------------------
103
104pub struct UsernameService {
105    db: Arc<dyn UsernameWriter>,
106    redis: Option<tokio::sync::Mutex<ConnectionManager>>,
107}
108
109impl UsernameService {
110    /// Create a new `UsernameService`.
111    ///
112    /// * `db` -- trait object for username persistence.
113    /// * `redis_client` -- optional Redis client (from the Upstash publisher).
114    ///   When `None`, Redis publish is silently skipped.
115    pub async fn new(db: Arc<dyn UsernameWriter>, redis_client: Option<redis::Client>) -> Self {
116        let redis = match redis_client {
117            Some(client) => match ConnectionManager::new(client).await {
118                Ok(conn) => {
119                    info!("UsernameService: Redis cache enabled");
120                    Some(tokio::sync::Mutex::new(conn))
121                }
122                Err(e) => {
123                    warn!("UsernameService: failed to connect to Redis, cache disabled: {e}");
124                    None
125                }
126            },
127            None => {
128                info!("UsernameService: no Redis client provided, cache disabled");
129                None
130            }
131        };
132
133        Self { db, redis }
134    }
135
136    // -- Postgres CRUD -------------------------------------------------------
137
138    /// Look up the username for a given wallet address (case-insensitive wallet
139    /// match).
140    pub async fn get_by_wallet(&self, wallet: &str) -> Result<Option<UsernameRecord>> {
141        self.db
142            .get_username_by_wallet(wallet)
143            .await
144            .with_context(|| "Failed to query username by wallet")
145    }
146
147    /// Reverse-lookup: find the wallet that owns a given username
148    /// (case-insensitive).
149    pub async fn get_by_username(&self, username: &str) -> Result<Option<UsernameRecord>> {
150        self.db
151            .get_username_by_name(username)
152            .await
153            .with_context(|| "Failed to query username by name")
154    }
155
156    /// Set (insert or update) the username for a wallet.
157    ///
158    /// Returns the final row on success.  Publishes to Redis on success.
159    /// Returns [`UsernameServiceError::UniqueViolation`] when the requested
160    /// username is already taken by another wallet.
161    pub async fn set_username(
162        &self,
163        wallet: &str,
164        username: &str,
165    ) -> std::result::Result<UsernameRecord, UsernameServiceError> {
166        validate_username(username)
167            .map_err(|msg| UsernameWriteError::Internal(anyhow::anyhow!(msg)))?;
168
169        let now = chrono::Utc::now();
170
171        let (row, old_username) = self.db.set_username(wallet, username, now).await?;
172
173        // If the wallet previously had a different username, delete the stale
174        // reverse key so Redis doesn't return incorrect ownership data.
175        if let Some(ref old_name) = old_username {
176            if old_name.to_lowercase() != row.username.to_lowercase() {
177                self.delete_reverse_key(old_name).await;
178            }
179        }
180
181        // Publish to Redis (best-effort)
182        self.publish_set(&row.wallet_address, &row.username).await;
183
184        Ok(row)
185    }
186
187    /// Delete the username for a wallet.  Returns `true` if a row was deleted.
188    pub async fn delete_username(&self, wallet: &str) -> Result<bool> {
189        let deleted_row = self
190            .db
191            .delete_username(wallet)
192            .await
193            .with_context(|| "Failed to delete username")?;
194
195        if let Some(row) = deleted_row {
196            self.publish_delete(&row.wallet_address, &row.username)
197                .await;
198            Ok(true)
199        } else {
200            Ok(false)
201        }
202    }
203
204    // -- Redis publish -------------------------------------------------------
205
206    async fn publish_set(&self, wallet: &str, username: &str) {
207        let Some(ref redis_mutex) = self.redis else {
208            return;
209        };
210        let mut redis = redis_mutex.lock().await;
211
212        let wallet_key = redis_wallet_key(wallet);
213        let reverse_key = redis_reverse_key(username);
214
215        // SET wallet -> username and reverse mapping
216        if let Err(e) = redis.set::<_, _, ()>(&wallet_key, username).await {
217            warn!("UsernameService: failed to SET {wallet_key}: {e}");
218        }
219        if let Err(e) = redis.set::<_, _, ()>(&reverse_key, wallet).await {
220            warn!("UsernameService: failed to SET {reverse_key}: {e}");
221        }
222
223        debug!("UsernameService: published SET {wallet_key}={username}, {reverse_key}={wallet}");
224    }
225
226    /// Delete only the reverse key for a username (used when renaming to clean
227    /// up the old `username:reverse:<old_name>` mapping).
228    async fn delete_reverse_key(&self, username: &str) {
229        let Some(ref redis_mutex) = self.redis else {
230            return;
231        };
232        let mut redis = redis_mutex.lock().await;
233        let reverse_key = redis_reverse_key(username);
234
235        if let Err(e) = redis.del::<_, ()>(&reverse_key).await {
236            warn!("UsernameService: failed to DEL stale reverse key {reverse_key}: {e}");
237        }
238        debug!("UsernameService: deleted stale reverse key {reverse_key}");
239    }
240
241    async fn publish_delete(&self, wallet: &str, username: &str) {
242        let Some(ref redis_mutex) = self.redis else {
243            return;
244        };
245        let mut redis = redis_mutex.lock().await;
246
247        let wallet_key = redis_wallet_key(wallet);
248        let reverse_key = redis_reverse_key(username);
249
250        if let Err(e) = redis.del::<_, ()>(&wallet_key).await {
251            warn!("UsernameService: failed to DEL {wallet_key}: {e}");
252        }
253        if let Err(e) = redis.del::<_, ()>(&reverse_key).await {
254            warn!("UsernameService: failed to DEL {reverse_key}: {e}");
255        }
256
257        debug!("UsernameService: published DEL {wallet_key}, {reverse_key}");
258    }
259}
260
261// ---------------------------------------------------------------------------
262// Tests
263// ---------------------------------------------------------------------------
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn valid_usernames() {
271        let good = [
272            "alice",
273            "Bob",
274            "a12",
275            "user_name",
276            "abc_123",
277            "a2345678901234567890", // 20 chars
278        ];
279        for name in &good {
280            assert!(validate_username(name).is_ok(), "expected OK for '{name}'");
281        }
282    }
283
284    #[test]
285    fn rejects_too_short() {
286        assert!(validate_username("a").is_err());
287    }
288
289    #[test]
290    fn rejects_too_long() {
291        let long = "a".repeat(21);
292        assert!(validate_username(&long).is_err());
293    }
294
295    #[test]
296    fn rejects_dots_and_dot_hl() {
297        assert!(validate_username("user.name").is_err());
298        assert!(validate_username("alice.hl").is_err());
299        assert!(validate_username("alice.HL").is_err());
300        assert!(validate_username("a.hlx").is_err());
301    }
302
303    #[test]
304    fn rejects_invalid_chars() {
305        assert!(validate_username("ali ce").is_err()); // space
306        assert!(validate_username(" alice").is_err());
307        assert!(validate_username("alice ").is_err());
308        assert!(validate_username("ali@ce").is_err()); // @
309        assert!(validate_username("ali!ce").is_err()); // !
310        assert!(validate_username("user-name").is_err()); // hyphen
311    }
312
313    #[test]
314    fn rejects_reserved_and_wallet_like_names() {
315        assert!(validate_username("support").is_err());
316        assert!(validate_username("Hypercall").is_err());
317        assert!(validate_username("0xalice").is_err());
318    }
319
320    #[test]
321    fn rejects_non_alnum_start_or_end() {
322        assert!(validate_username("_alice").is_err());
323        assert!(validate_username("-alice").is_err());
324        assert!(validate_username(".alice").is_err());
325    }
326
327    #[test]
328    fn redis_key_format() {
329        assert_eq!(redis_wallet_key("0xAbC123"), "username:wallet:0xabc123");
330        assert_eq!(redis_reverse_key("Alice"), "username:reverse:alice");
331    }
332}