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>
48 lines
1.6 KiB
JavaScript
48 lines
1.6 KiB
JavaScript
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 }
|