users.js (8349B)
1 import crypto from 'node:crypto'; 2 import { HttpError } from './http/http-error.js'; 3 4 export function normalizeEmail(email) { 5 return String(email || '').trim().toLowerCase(); 6 } 7 8 export function normalizeUsername(username) { 9 return String(username || '').trim().toLowerCase(); 10 } 11 12 export function emailDomain(email) { 13 const normalized = normalizeEmail(email); 14 const at = normalized.lastIndexOf('@'); 15 return at > 0 && at < normalized.length - 1 ? normalized.slice(at + 1) : null; 16 } 17 18 export async function createUser(db, { username, displayName = null, homeRealmId = null, isSystemAdmin = false }) { 19 const normalized = normalizeUsername(username); 20 if (!normalized) throw new Error('username is required'); 21 const existing = await db.get( 22 'SELECT id FROM users WHERE username_normalized = {username}', 23 { username: normalized } 24 ); 25 if (existing) throw new Error(`username is already taken: ${username}`); 26 27 const id = crypto.randomUUID(); 28 const now = Date.now(); 29 await db.run( 30 `INSERT INTO users 31 (id, username, username_normalized, display_name, status, home_realm_id, is_system_admin, profile, created_at, updated_at) 32 VALUES 33 ({id}, {username}, {normalized}, {displayName}, 'active', {homeRealmId}, {isSystemAdmin}, '{}', {createdAt}, {updatedAt})`, 34 { 35 id, 36 username, 37 normalized, 38 displayName: displayName || username, 39 homeRealmId, 40 isSystemAdmin: isSystemAdmin ? 1 : 0, 41 createdAt: now, 42 updatedAt: now, 43 } 44 ); 45 return id; 46 } 47 48 export async function findUserById(db, id) { 49 return db.get( 50 `SELECT id, username, username_normalized, display_name, status, home_realm_id, 51 is_system_admin, profile, last_login_at, created_at, updated_at 52 FROM users 53 WHERE id = {id}`, 54 { id } 55 ); 56 } 57 58 export async function findUserByEmail(db, email) { 59 const normalized = normalizeEmail(email); 60 return db.get( 61 `SELECT u.id, u.username, u.username_normalized, u.display_name, u.status, 62 u.home_realm_id, u.is_system_admin, u.profile, u.last_login_at, 63 e.email, e.email_normalized 64 FROM users u 65 JOIN user_emails e ON e.user_id = u.id 66 WHERE e.email_normalized = {email} AND e.verified = 1`, 67 { email: normalized } 68 ); 69 } 70 71 export async function findUserByUsername(db, username) { 72 const normalized = normalizeUsername(username); 73 return db.get( 74 `SELECT id, username, username_normalized, display_name, status, 75 home_realm_id, is_system_admin, profile, last_login_at 76 FROM users 77 WHERE username_normalized = {username}`, 78 { username: normalized } 79 ); 80 } 81 82 export async function findUserByIdentifier(db, identifier) { 83 const value = String(identifier || '').trim(); 84 return value.includes('@') 85 ? findUserByEmail(db, value) 86 : findUserByUsername(db, value); 87 } 88 89 export async function getUserEmails(db, userId) { 90 return db.all( 91 `SELECT id, email, email_normalized, verified, is_primary, created_at, verified_at 92 FROM user_emails 93 WHERE user_id = {userId} 94 ORDER BY is_primary DESC, created_at ASC`, 95 { userId } 96 ); 97 } 98 99 export async function getPrimaryEmail(db, userId) { 100 return db.get( 101 `SELECT email, email_normalized, verified 102 FROM user_emails 103 WHERE user_id = {userId} 104 ORDER BY is_primary DESC, created_at ASC 105 LIMIT 1`, 106 { userId } 107 ); 108 } 109 110 export async function addUserEmail(db, userId, email, { verified = false, primary = false } = {}) { 111 const normalized = normalizeEmail(email); 112 const existing = await db.get( 113 'SELECT user_id FROM user_emails WHERE email_normalized = {email}', 114 { email: normalized } 115 ); 116 if (existing) throw new Error(`email is already in use: ${normalized}`); 117 118 const id = crypto.randomUUID(); 119 const now = Date.now(); 120 await db.transaction(async (tx) => { 121 if (primary) { 122 await tx.run('UPDATE user_emails SET is_primary = 0 WHERE user_id = {userId}', { userId }); 123 } 124 await tx.run( 125 `INSERT INTO user_emails 126 (id, user_id, email, email_normalized, verified, is_primary, created_at, verified_at) 127 VALUES 128 ({id}, {userId}, {email}, {normalized}, {verified}, {primary}, {createdAt}, {verifiedAt})`, 129 { 130 id, 131 userId, 132 email, 133 normalized, 134 verified: verified ? 1 : 0, 135 primary: primary ? 1 : 0, 136 createdAt: now, 137 verifiedAt: verified ? now : null, 138 } 139 ); 140 }); 141 return id; 142 } 143 144 export async function setPasswordCredential(db, userId, secretHash) { 145 const existing = await db.get( 146 'SELECT id FROM credentials WHERE user_id = {userId} AND type = {type}', 147 { userId, type: 'password' } 148 ); 149 const now = Date.now(); 150 if (existing) { 151 await db.run( 152 `UPDATE credentials 153 SET secret_hash = {secretHash}, updated_at = {now} 154 WHERE id = {id}`, 155 { id: existing.id, secretHash, now } 156 ); 157 return existing.id; 158 } 159 160 const id = crypto.randomUUID(); 161 await db.run( 162 `INSERT INTO credentials 163 (id, user_id, type, secret_hash, created_at, updated_at) 164 VALUES 165 ({id}, {userId}, 'password', {secretHash}, {createdAt}, {updatedAt})`, 166 { id, userId, secretHash, createdAt: now, updatedAt: now } 167 ); 168 return id; 169 } 170 171 export async function getPasswordCredential(db, userId) { 172 return db.get( 173 `SELECT id, secret_hash 174 FROM credentials 175 WHERE user_id = {userId} AND type = 'password'`, 176 { userId } 177 ); 178 } 179 180 export async function setSystemAdmin(db, userId, value = true) { 181 await db.run( 182 'UPDATE users SET is_system_admin = {value}, updated_at = {now} WHERE id = {userId}', 183 { value: value ? 1 : 0, now: Date.now(), userId } 184 ); 185 } 186 187 export async function updateLastLogin(db, userId, now = Date.now()) { 188 await db.run( 189 'UPDATE users SET last_login_at = {now}, updated_at = {now} WHERE id = {userId}', 190 { now, userId } 191 ); 192 } 193 194 export async function assignHomeRealmForEmail(db, userId, email) { 195 const domain = emailDomain(email); 196 if (!domain) return null; 197 198 const owner = await db.get( 199 `SELECT realm_id 200 FROM realm_email_domains 201 WHERE domain = {domain} AND verified = 1`, 202 { domain } 203 ); 204 if (!owner) return null; 205 206 const user = await findUserById(db, userId); 207 if (!user) throw new Error(`unknown user: ${userId}`); 208 if (!user.home_realm_id) { 209 await db.run( 210 'UPDATE users SET home_realm_id = {realmId}, updated_at = {now} WHERE id = {userId}', 211 { realmId: owner.realm_id, now: Date.now(), userId } 212 ); 213 return owner.realm_id; 214 } 215 if (user.home_realm_id !== owner.realm_id) { 216 throw new HttpError(409, 'home_realm_conflict', 'This email belongs to a different realm'); 217 } 218 return user.home_realm_id; 219 } 220 221 export async function getMembership(db, userId) { 222 return db.get( 223 'SELECT user_id, status, created_at, updated_at FROM realm_memberships WHERE user_id = {userId}', 224 { userId } 225 ); 226 } 227 228 export async function ensureMembership(db, userId, status = 'active') { 229 const existing = await getMembership(db, userId); 230 const now = Date.now(); 231 if (!existing) { 232 await db.run( 233 `INSERT INTO realm_memberships (user_id, status, created_at, updated_at) 234 VALUES ({userId}, {status}, {createdAt}, {updatedAt})`, 235 { userId, status, createdAt: now, updatedAt: now } 236 ); 237 return; 238 } 239 if (existing.status !== status) { 240 await db.run( 241 'UPDATE realm_memberships SET status = {status}, updated_at = {now} WHERE user_id = {userId}', 242 { status, now, userId } 243 ); 244 } 245 } 246 247 export async function getUserRoles(db, userId) { 248 const rows = await db.all( 249 `SELECT r.slug 250 FROM membership_roles mr 251 JOIN roles r ON r.id = mr.role_id 252 WHERE mr.user_id = {userId} 253 ORDER BY r.slug ASC`, 254 { userId } 255 ); 256 return rows.map((row) => row.slug); 257 } 258 259 export async function assignRole(db, userId, roleSlug) { 260 const role = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug: roleSlug }); 261 if (!role) throw new Error(`unknown role: ${roleSlug}`); 262 const existing = await db.get( 263 'SELECT user_id FROM membership_roles WHERE user_id = {userId} AND role_id = {roleId}', 264 { userId, roleId: role.id } 265 ); 266 if (existing) return; 267 await db.run( 268 'INSERT INTO membership_roles (user_id, role_id) VALUES ({userId}, {roleId})', 269 { userId, roleId: role.id } 270 ); 271 }