Files
website/server/src/model/invites/invites.model.js
Claude 91c206bf76 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>
2026-07-17 15:50:49 -05:00

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 }