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

@@ -0,0 +1,47 @@
const { query } = require('../../utils/db')
const COLS =
'id, token_hash, email, role, status, invited_by, accepted_user_id, expires_at, created_at, accepted_at'
async function insert({ tokenHash, email, role, invitedBy, expiresAt }) {
const res = await query(
`INSERT INTO user_invites (token_hash, email, role, invited_by, expires_at)
VALUES (?, ?, ?, ?, ?)`,
[tokenHash, email, role, invitedBy ?? null, expiresAt],
)
return res.insertId
}
async function getById(id) {
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function findByTokenHash(tokenHash) {
const rows = await query(`SELECT ${COLS} FROM user_invites WHERE token_hash = ? LIMIT 1`, [tokenHash])
return rows[0] || null
}
const listRecent = (limit) =>
query(`SELECT ${COLS} FROM user_invites ORDER BY created_at DESC LIMIT ?`, [limit])
// Mark accepted only if still pending (atomic guard against a double-accept race).
// Returns rows changed (1 = we won, 0 = already used/revoked).
async function markAccepted(id, userId) {
const res = await query(
`UPDATE user_invites SET status = 'accepted', accepted_user_id = ?, accepted_at = NOW()
WHERE id = ? AND status = 'pending'`,
[userId, id],
)
return res.affectedRows || 0
}
async function revoke(id) {
const res = await query(
`UPDATE user_invites SET status = 'revoked' WHERE id = ? AND status = 'pending'`,
[id],
)
return res.affectedRows || 0
}
module.exports = { insert, getById, findByTokenHash, listRecent, markAccepted, revoke }

View File

@@ -0,0 +1,73 @@
// Admin email invites. A staff member invites someone by email at a pre-chosen
// access level; the invitee accepts via a tokened link that creates their website
// user at that role. The opaque token lives only in the emailed link — the DB
// stores just its sha256 hash (like mobile refresh tokens), so a DB read never
// yields a usable invite. Invites are single-use and expiring.
const crypto = require('crypto')
const db = require('./invites.db')
const DEFAULT_TTL_DAYS = 7
function hashToken(raw) {
return crypto.createHash('sha256').update(String(raw)).digest('hex')
}
// Public-safe shape (never exposes the token hash).
function toSafe(row) {
if (!row) return null
return {
id: row.id,
email: row.email,
role: row.role,
status: row.status,
invitedBy: row.invited_by,
acceptedUserId: row.accepted_user_id,
expiresAt: row.expires_at,
createdAt: row.created_at,
acceptedAt: row.accepted_at,
expired: new Date(row.expires_at).getTime() < Date.now(),
}
}
// Create an invite. Returns { invite, token } — the plaintext token is returned
// ONCE (for the email link) and never stored or recoverable afterwards.
async function create({ email, role, invitedBy, ttlDays = DEFAULT_TTL_DAYS }) {
const token = crypto.randomBytes(32).toString('base64url')
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000)
const id = await db.insert({ tokenHash: hashToken(token), email, role, invitedBy, expiresAt })
return { invite: toSafe(await db.getById(id)), token }
}
// Resolve a pending, unexpired invite from its plaintext token, else null. Returns
// the RAW row (incl. id) for the accept flow; callers sanitize with publicView.
async function findValidByToken(token) {
if (!token) return null
const row = await db.findByTokenHash(hashToken(token))
if (!row || row.status !== 'pending') return null
if (new Date(row.expires_at).getTime() < Date.now()) return null
return row
}
// Atomically consume a pending invite (double-accept-safe). Returns true if this
// call won the race and bound the invite to userId.
async function accept(id, userId) {
return (await db.markAccepted(id, userId)) === 1
}
const revoke = (id) => db.revoke(id)
async function list(limit = 100) {
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
const rows = await db.listRecent(n)
return rows.map(toSafe)
}
// A minimal, safe view of an invite for the (unauthenticated) accept page —
// only what the form needs, never the token or internal ids.
function publicView(row) {
if (!row) return null
return { email: row.email, role: row.role }
}
module.exports = { create, findValidByToken, accept, revoke, list, publicView, toSafe, hashToken }

View File

@@ -32,6 +32,13 @@ function registrationFlags(mode) {
}
}
// Game-account signup (Protocol 2.0 hybrid mode). Off unless an admin opts in;
// the shard's own signup mode still has the final say when we call the sidecar.
const GAME_SIGNUP_KEY = 'game_account_signup'
async function isGameAccountSignupEnabled() {
return (await settingsDb.get(GAME_SIGNUP_KEY)) === 'enabled'
}
async function get(key) {
return settingsDb.get(key)
}
@@ -78,4 +85,6 @@ module.exports = {
REGISTRATION_MODES,
getRegistrationMode,
registrationFlags,
GAME_SIGNUP_KEY,
isGameAccountSignupEnabled,
}

View File

@@ -33,4 +33,10 @@ async function isOwnedBy(account, userId) {
const remove = (account, userId) =>
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove }
// Drop the mirror for an account regardless of which user held it — used to
// reconcile when the tie is severed at the source (an in-game [unlink →
// account.unlinked event, or a site-side DELETE /link/{account}).
const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [account])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }

View File

@@ -31,4 +31,7 @@ async function getByAccount(account) {
const unlink = (account, userId) => db.remove(account, userId)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink }
// Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }