const bcrypt = require('bcryptjs') const usersDb = require('./users.db') const SALT_ROUNDS = 10 // Strip secrets (password hash, TOTP secret) before sending a user anywhere. function sanitize(user) { if (!user) return null const { password_hash, totp_secret, ...safe } = user return safe } // password may be omitted/null — an SSO-provisioned player has no password until // they set one (a null hash makes password login impossible, see validatePassword). async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) { const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified }) return sanitize(await usersDb.findById(id)) } // ── Telling the two unique constraints apart ─────────────────────────────── // // `users` has had one unique index (username) for its whole life, so a bare // "is this a duplicate-key error" test was enough. Engagement Phase 1b adds a // second (email, via the generated email_norm column), and the moment it exists // an undiscriminating test starts LYING: a duplicate email would be reported to // the user as a taken username, and SSO provisioning would retry usernames // forever against a conflict no username can clear (§0.6 finding 2). // // The violated index name is available ONLY in the driver's message text — the // mariadb connector exposes no structured field for it — so this reads it back // out. Verified against MariaDB 11.8: // "(conn:60, no: 1062, SQLState: 23000) Duplicate entry 'x' for key 'username'" // // NOTE the message also embeds the bound parameters, so on an email collision it // contains the address. That is fine in a server log and is exactly why these // errors must never be echoed to a client (the anti-enumeration rule below). const EMAIL_UNIQUE_KEY = 'uq_users_email_norm' function isDuplicateKeyError(err) { return Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) } // The name of the unique index that was violated, or null if this is not a // duplicate-key error (or the driver phrased it in a way we do not recognise). function duplicateKey(err) { if (!isDuplicateKeyError(err)) return null const m = /for key '([^']+)'/.exec(err.sqlMessage || err.message || '') return m ? m[1] : null } // True when the collision was on the email uniqueness index. function isDuplicateEmail(err) { return duplicateKey(err) === EMAIL_UNIQUE_KEY } // True when a DB error is a unique-index violation that is NOT the email one (the // atomic backstop for the username-uniqueness race). Callers translate this into // a 409 rather than doing a check-then-write. // // Deliberately "not email" rather than "is username": on a database whose index // happens to carry a different name, the old permissive behaviour is preserved // and nothing newly falls through to a 500. Only the case we can positively // identify — email — is carved out. function isDuplicateUsername(err) { return isDuplicateKeyError(err) && !isDuplicateEmail(err) } // Returns the raw row (incl. hash) — used by login only. async function getRawByUsername(username) { return usersDb.findByUsername(username) } async function getById(id) { return sanitize(await usersDb.findById(id)) } // Raw rows (incl. email/status) for every active account on an email address. // Server-side only (password-reset request). Unique since Phase 1b, so this // return several. Never sent to a client. async function getActiveByEmail(email) { if (!email) return [] return usersDb.findActiveByEmail(String(email).trim()) } // Raw row incl. totp_secret — server-side only (TOTP setup/verify). Never sent // to a client; sanitize() strips the secret from anything user-facing. async function getRawById(id) { return usersDb.findById(id) } async function setTotpSecret(id, secret) { return usersDb.setTotpSecret(id, secret) } async function enableTotp(id) { return usersDb.enableTotp(id) } async function disableTotp(id) { return usersDb.disableTotp(id) } async function validatePassword(user, password) { if (!user || !user.password_hash) return false return bcrypt.compare(password, user.password_hash) } async function list() { return usersDb.listUsers() } async function update(id, { username, password, role, email, status, emailVerified }) { const fields = {} if (username !== undefined) fields.username = username if (role !== undefined) fields.role = role if (email !== undefined) fields.email = email if (status !== undefined) fields.status = status if (emailVerified !== undefined) fields.email_verified = emailVerified ? 1 : 0 if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS) await usersDb.updateUser(id, fields) // A password change must revoke existing sessions ("change password to log // everyone out"), so bump the cutoff whenever the hash was rotated. if (password) await usersDb.bumpTokensValidAfter(id) return getById(id) } // ── Pending email address (engagement Phase 1b) ──────────────────────────── // A requested address is staged rather than installed: `email` keeps working // until the verification link proves the new one. See the users table comments. const setPendingEmail = (id, email) => usersDb.setPendingEmail(id, email) const clearPendingEmail = (id) => usersDb.clearPendingEmail(id) // Promote a proved address. Returns true only if it actually landed; false means // the guard rejected it (the user has since asked for a different address, so the // token in hand is stale). Throws the duplicate-key error if the address was // claimed by someone else in the meantime — the caller answers that generically. async function promotePendingEmail(id, email) { return (await usersDb.promotePendingEmail(id, email)) === 1 } // Invalidate every session token this user currently holds ("log out everywhere") // by advancing their tokens_valid_after cutoff to now. async function invalidateSessions(id) { return usersDb.bumpTokensValidAfter(id) } // Set the session cutoff to an explicit instant. Used by the self password-change // flow to keep the caller's freshly re-issued session alive (see users.db). async function setSessionCutoff(id, when) { return usersDb.setTokensValidAfter(id, when) } async function remove(id) { return usersDb.deleteUser(id) } async function count() { return usersDb.countUsers() } async function countAdmins() { return usersDb.countAdmins() } async function recordLogin(id, ip = null) { return usersDb.touchLastLogin(id, ip) } module.exports = { createUser, isDuplicateUsername, isDuplicateEmail, duplicateKey, EMAIL_UNIQUE_KEY, getRawByUsername, getById, getActiveByEmail, setPendingEmail, clearPendingEmail, promotePendingEmail, getRawById, validatePassword, list, update, invalidateSessions, setSessionCutoff, remove, count, countAdmins, recordLogin, setTotpSecret, enableTotp, disableTotp, }