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:
47
server/src/model/invites/invites.db.js
Normal file
47
server/src/model/invites/invites.db.js
Normal 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 }
|
||||
73
server/src/model/invites/invites.model.js
Normal file
73
server/src/model/invites/invites.model.js
Normal 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 }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user