// ── Username policy ──────────────────────────────────────────────────────── // // Pure helpers shared by public registration and SSO auto-provisioning: // - a reserved-name blocklist (staff-impersonating / system names), // - normalization (trim; case is preserved for display, uniqueness folds case // at the DB via the column's _ci collation), and // - deriving a valid username from an external SSO profile. // // No I/O — the DB UNIQUE index is the source of truth for collisions; these // helpers only shape/validate candidate names and pick suffixes to retry with. // Allowed characters in a stored username: letters, digits, dot, underscore, // dash. Length 3–32 (matches the register validator + the column width). const USERNAME_RE = /^[A-Za-z0-9_.-]{3,32}$/ const MIN_LEN = 3 const MAX_LEN = 32 // Names that must never belong to a self-registered account because they imply // staff/system authority or are otherwise confusing. Compared case-insensitively. const RESERVED_USERNAMES = new Set([ 'admin', 'administrator', 'root', 'system', 'staff', 'mod', 'moderator', 'owner', 'support', 'help', 'null', 'undefined', 'me', 'anonymous', 'everyone', 'here', ]) // Trim surrounding whitespace. Case is preserved (stored as entered); the DB's // _ci collation folds case for uniqueness + lookup. function normalizeUsername(raw) { return typeof raw === 'string' ? raw.trim() : '' } function isReserved(name) { return RESERVED_USERNAMES.has(String(name || '').trim().toLowerCase()) } function isValidFormat(name) { return USERNAME_RE.test(name) } // Validate a user-chosen username for registration. Returns { ok, message }. function validateUsername(raw) { const name = normalizeUsername(raw) if (!isValidFormat(name)) { return { ok: false, message: 'Username must be 3–32 characters (letters, numbers, . _ -).' } } if (isReserved(name)) { return { ok: false, message: 'That username is not available.' } } return { ok: true, name } } // Reduce an arbitrary string to the allowed charset, clamped to MAX_LEN. Used as // the base for SSO-derived usernames before uniqueness suffixing. function sanitizeToUsername(raw) { let s = String(raw || '') .normalize('NFKD') .replace(/[^A-Za-z0-9_.-]/g, '') .replace(/^[._-]+/, '') // don't start with punctuation .slice(0, MAX_LEN) return s } // Derive a base username from a normalized SSO profile ({ name, email, subject }). // Tries display name, then the email local-part, then a generic 'player' base. // The result is always a valid *base* (>= MIN_LEN, sanitized) but is NOT // guaranteed unique — the caller suffixes + retries against the UNIQUE index. function deriveUsernameBase(profile) { const candidates = [profile && profile.name, profile && (profile.email || '').split('@')[0]] for (const c of candidates) { const s = sanitizeToUsername(c) if (s.length >= MIN_LEN && !isReserved(s)) return s } return 'player' } // Build the Nth candidate username for the dedup retry loop: attempt 0 is the // bare base (padded if short), later attempts append an increasing numeric // suffix, always clamped to MAX_LEN so the suffix survives truncation. function candidateUsername(base, attempt) { const safeBase = base.length >= MIN_LEN ? base : `${base}player`.slice(0, MAX_LEN) if (attempt === 0) return safeBase const suffix = String(attempt + 1) // 2, 3, 4, … return `${safeBase.slice(0, MAX_LEN - suffix.length)}${suffix}` } module.exports = { USERNAME_RE, MIN_LEN, MAX_LEN, RESERVED_USERNAMES, normalizeUsername, isReserved, isValidFormat, validateUsername, sanitizeToUsername, deriveUsernameBase, candidateUsername, }