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

@@ -10,6 +10,7 @@
const uoLinkClient = require('../../../utils/uoLinkClient')
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
const shardState = require('../../../model/shardState/shardState.model')
const settings = require('../../../model/settings/settings.model')
const { salesForAccounts } = require('../../../utils/shardSales')
const activity = require('../../../model/activity/activity.model')
@@ -147,4 +148,58 @@ async function getSales(req, res) {
}
}
module.exports = { link, listAccounts, roster, vendors, getChar, getSales }
// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response.
// The password is never echoed anywhere; only the mapped reason is returned.
function mapCreateAccountError(res, result) {
const reason = (result.data && result.data.reason) || ''
switch (result.status) {
case 409:
return res.status(409).json({ message: 'That account name is already taken.' })
case 429:
return res.status(429).json({ message: 'The account limit for your network has been reached.' })
case 403:
return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' })
case 400:
return res.status(400).json({ message: reason || 'The account name or password was not accepted.' })
case 503:
case 0:
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
default:
return res.status(502).json({ message: 'Could not reach the shard to create the account.' })
}
}
// POST /player/shard/account — provision a GAME account for the signed-in website
// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the
// invite-accept "create game account" step alike (both act as the signed-in user).
// actor + websiteUserId are stamped from the session; the browser IP (req.ip,
// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is
// never logged. Gated by the game_account_signup setting AND the shard's own mode.
async function createGameAccount(req, res) {
const { account, password } = req.body
try {
if (!(await settings.isGameAccountSignupEnabled())) {
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
}
const result = await uoLinkClient.createAccount({
actor: req.user.username,
account,
password,
websiteUserId: req.user.id,
ip: req.ip,
})
if (result.ok) {
// Mirror the link locally so the portal lists the account immediately.
await shardLinks.link({ account, userId: req.user.id })
await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } })
log.info('game account created', { account, userId: req.user.id, ip: req.ip })
return res.status(201).json({ account, linked: true })
}
return mapCreateAccountError(res, result)
} catch (err) {
log.error('player.shard.createGameAccount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, createGameAccount }