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>
74 lines
2.7 KiB
JavaScript
74 lines
2.7 KiB
JavaScript
// 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 }
|