Player accounts backend: schema, registration, self-service, SSO provision

- Widen users.role enum to include 'player'; make password_hash nullable;
  add email/email_verified/status/last_login_ip; pin username _ci collation.
- POST /auth/register (honeypot + registerLimiter + botScore, reserved-name
  blocklist, duplicate->409, auto-login). player_registration setting gates it.
- SSO auto-provision in finishLogin (setting-gated); return/portal-aware SSO
  redirects for the player portal; status refusal on login + requireAuth.
- New /player self-service group (account, change username/password, TOTP,
  identities), reusing account.controller; accountChangeLimiter.
- Admin: 'player' role + status/email on user create/update, role/status audit,
  player_registration enum validation, derived public registration flags.
- usernamePolicy module (reserved, sanitize, derive, dedup) + unit tests;
  extend SSO callback tests. 133 server tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
2026-07-06 01:36:51 -05:00
parent cdd916e199
commit f8bcc7f6a3
18 changed files with 934 additions and 55 deletions

View File

@@ -454,6 +454,13 @@ async function updateSettings(req, res) {
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return res.status(400).json({ message: 'Expected an object of key/value settings' })
}
// Enum-constrained keys are validated here (the store itself is schemaless).
if (
settings.REGISTRATION_KEY in updates &&
!settings.REGISTRATION_MODES.includes(updates[settings.REGISTRATION_KEY])
) {
return res.status(400).json({ message: 'Invalid player_registration value' })
}
try {
await settings.setMany(updates, req.user.id)
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
@@ -493,8 +500,14 @@ async function createUser(req, res) {
username: req.body.username,
password: req.body.password,
role: req.body.role || 'admin',
email: req.body.email || null,
status: req.body.status || 'active',
})
await activity.log({
req,
action: 'user.create',
detail: { id: user.id, username: user.username, role: user.role },
})
await activity.log({ req, action: 'user.create', detail: { id: user.id, username: user.username } })
return res.status(201).json(user)
} catch (err) {
log.error('createUser', err)
@@ -525,8 +538,26 @@ async function updateUser(req, res) {
username: req.body.username,
password: req.body.password,
role: req.body.role,
email: req.body.email,
status: req.body.status,
})
await activity.log({ req, action: 'user.update', detail: { id } })
// Distinct audit trail for the security-sensitive fields (role & status),
// so a promotion/ban is greppable beyond the generic user.update entry.
if (req.body.role && req.body.role !== target.role) {
await activity.log({
req,
action: 'admin.user.role_change',
detail: { id, from: target.role, to: req.body.role },
})
}
if (req.body.status && req.body.status !== target.status) {
await activity.log({
req,
action: 'admin.user.status_change',
detail: { id, from: target.status, to: req.body.status },
})
}
return res.json(user)
} catch (err) {
log.error('updateUser', err)