feat(provisioning): game-account signup, admin email invites, unlink (2.0)

Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.

- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
  shard (hashed there) and never stored/logged; the end-user browser IP is passed
  for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
  username + password) for the signed-in user and mirrors the link locally. Gated
  by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
  429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
  invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
  mailer.sendInvite (falls back to returning the accept link if email is off);
  public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
  user at the invite's preset role and logs them in, bypassing the registration
  gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
  mirror drop; account.unlinked ingest reconciles the mirror when a player runs
  [unlink in game. account.audit / account.unlinked are logged (admin channel
  only — never on the public SSE allowlist).

Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 15:50:49 -05:00
parent 55a3adea99
commit 91c206bf76
20 changed files with 1150 additions and 8 deletions

View File

@@ -188,4 +188,4 @@ async function me(req, res) {
}
}
module.exports = { login, register, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD }
module.exports = { login, register, loginTotp, logout, me, needsTotp, issueSession, HONEYPOT_FIELD }

View File

@@ -1,7 +1,8 @@
const express = require('express')
const { body } = require('express-validator')
const { body, param } = require('express-validator')
const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller')
const { getInvite, acceptInvite } = require('./invite.controller')
const { isLoggedIn } = require('../../../utils/auth')
const { attachSession } = require('../../../auth/session.middleware')
const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit')
@@ -87,6 +88,38 @@ authRouter.post(
loginTotp,
)
// ── Email-invite acceptance (public, token-gated) ──────────────────────────
authRouter.get(
'/invite/:token',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Look up an email invite by token'
// #swagger.description = 'Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.'
/* #swagger.responses[200] = { description: 'Invite details', content: { "application/json": { schema: { type: "object", properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('token').isString().isLength({ min: 8, max: 128 }),
validate,
getInvite,
)
authRouter.post(
'/invite/:token/accept',
// #swagger.tags = ['Auth']
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
// #swagger.description = 'Creates the website user at the invites pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Username taken or invite already used', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
...loginGuards,
registerLimiter,
param('token').isString().isLength({ min: 8, max: 128 }),
body('username').isString().trim().isLength({ min: 3, max: 32 }),
body('password').isString().isLength({ min: 8, max: 64 }),
body(HONEYPOT_FIELD).optional(),
validate,
acceptInvite,
)
authRouter.post(
'/logout',
// #swagger.tags = ['Auth']

View File

@@ -0,0 +1,82 @@
// ── Invite acceptance (public, token-gated) ────────────────────────────────
//
// The other end of the admin email-invite flow (admin/invites.controller). An
// invitee opens the tokened link, sees their pre-assigned email + role, and sets
// a username + password. Accepting creates their website user AT THE PRESET ROLE
// (bypassing the player_registration gate — the invite is its own authority) and
// logs them straight in. The optional "create game account" step afterwards reuses
// POST /player/shard/account (players only), so it isn't handled here.
const invites = require('../../../model/invites/invites.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const usernamePolicy = require('../../../auth/usernamePolicy')
const { issueSession, HONEYPOT_FIELD } = require('./auth.controller')
const log = require('../../../utils/logger')('auth-invite')
// GET /auth/invite/:token — validate an invite and return what the accept form
// needs (email + role). 404 for anything not currently acceptable so we never
// distinguish "expired" from "revoked" from "never existed".
async function getInvite(req, res) {
try {
const row = await invites.findValidByToken(req.params.token)
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
return res.json(invites.publicView(row))
} catch (err) {
log.error('getInvite', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/invite/:token/accept — create the user at the invite's role and log
// them in. Honeypot + validation mirror register; the invite replaces the
// registration-mode gate.
async function acceptInvite(req, res) {
// Honeypot: a filled hidden field means a bot.
if (req.body[HONEYPOT_FIELD]) {
log.warn('honeypot invite-accept hit', { ip: req.ip })
return res.status(400).json({ message: 'Registration failed.' })
}
try {
const row = await invites.findValidByToken(req.params.token)
if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' })
const check = usernamePolicy.validateUsername(req.body.username)
if (!check.ok) return res.status(400).json({ message: check.message })
let user
try {
user = await users.createUser({
username: check.name,
password: req.body.password,
email: row.email,
role: row.role,
emailVerified: true, // they proved control of the address by using the link
})
} catch (err) {
if (users.isDuplicateUsername(err)) {
return res.status(409).json({ message: 'That username is already taken.' })
}
throw err
}
// Consume the invite atomically. If we lost a double-accept race, roll back the
// user we just created so a spent invite never yields two accounts.
const won = await invites.accept(row.id, user.id)
if (!won) {
await users.remove(user.id).catch(() => {})
return res.status(409).json({ message: 'This invitation has already been used.' })
}
await activity.log({ req, userId: user.id, action: 'invite.accept', detail: { inviteId: row.id, role: row.role } })
log.info('invite accepted', { inviteId: row.id, userId: user.id, role: row.role, ip: req.ip })
// New accounts never have TOTP yet — log straight in.
return issueSession(req, res, user, 'local')
} catch (err) {
log.error('acceptInvite', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getInvite, acceptInvite }