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

@@ -14,6 +14,7 @@ const emailConfig = require('./emailConfig.controller')
const uoLink = require('./uoLink.controller')
const shardOps = require('./shardOps.controller')
const usersShard = require('./usersShard.controller')
const invites = require('./invites.controller')
const selfShard = require('../player/shard.controller')
const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller')
@@ -1267,6 +1268,61 @@ adminRouter.get(
validate,
usersShard.getStanding,
)
adminRouter.delete(
'/users/:id/shard/link/:account',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Unlink a game account from this user (admin only)'
// #swagger.description = 'Severs a game accounts tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('id').isInt(),
param('account').matches(SHARD_ACCOUNT_RE),
validate,
usersShard.unlinkAccount,
)
// ── Email invites (admin only) ─────────────────────────────────────────────
adminRouter.post(
'/invites',
// #swagger.tags = ['Admin · Invites']
// #swagger.summary = 'Create and email an account invite at a chosen access level'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
adminOnly,
body('email').isEmail().isLength({ max: 255 }),
body('role').isIn(['admin', 'editor', 'moderator', 'player']),
validate,
invites.create,
)
adminRouter.get(
'/invites',
// #swagger.tags = ['Admin · Invites']
// #swagger.summary = 'List recent invites (no tokens)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
adminOnly,
invites.list,
)
adminRouter.delete(
'/invites/:id',
// #swagger.tags = ['Admin · Invites']
// #swagger.summary = 'Revoke a pending invite'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' }
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('id').isInt(),
validate,
invites.revoke,
)
// ── uo-link sidecar control (admin only) ──────────────────────────────────
// Connection config (base/ws URL + token + protocol + enabled) and the town

View File

@@ -0,0 +1,84 @@
// ── Admin: email invites ───────────────────────────────────────────────────
//
// Admin-only. A staff member invites someone by email at a pre-chosen access
// level; the invitee accepts via a tokened link (auth/invite.controller) which
// creates their website user at that role. The plaintext token exists only in the
// emailed link and in the create response (so the admin can copy the link if email
// isn't configured); the DB stores only its hash.
const invites = require('../../../model/invites/invites.model')
const activity = require('../../../model/activity/activity.model')
const mailer = require('../../../utils/mailer')
const log = require('../../../utils/logger')('admin-invites')
const ROLES = ['admin', 'editor', 'moderator', 'player']
function baseUrl() {
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
}
function acceptUrl(token) {
return `${baseUrl()}/invite/${token}`
}
// POST /admin/invites — create an invite and email it.
async function create(req, res) {
const email = String(req.body.email || '').trim()
const role = req.body.role
if (!email || !ROLES.includes(role)) {
return res.status(400).json({ message: 'A valid email and role are required.' })
}
try {
const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id })
const url = acceptUrl(token)
// Send the email; if mail isn't configured, hand the link back so the admin
// can share it manually. A send failure doesn't delete the invite — surface it.
let emailed = false
let emailError = null
try {
const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username })
emailed = Boolean(result.sent)
} catch (err) {
emailError = err.message
log.warn('invite email failed (invite still created)', { id: invite.id, message: err.message })
}
await activity.log({ req, userId: req.user.id, action: 'invite.create', detail: { email, role, emailed } })
log.info('invite created', { id: invite.id, email, role, emailed, by: req.user.username })
// The accept link is returned only when email did not deliver, so the admin
// can copy it. When emailed, we don't echo the token.
return res.status(201).json({ invite, emailed, acceptUrl: emailed ? undefined : url, emailError })
} catch (err) {
log.error('create invite', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/invites — recent invites (no tokens).
async function list(req, res) {
try {
return res.json(await invites.list(req.query.limit))
} catch (err) {
log.error('list invites', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// DELETE /admin/invites/:id — revoke a pending invite.
async function revoke(req, res) {
const id = Number(req.params.id)
try {
const changed = await invites.revoke(id)
if (!changed) return res.status(404).json({ message: 'No pending invite to revoke.' })
await activity.log({ req, userId: req.user.id, action: 'invite.revoke', detail: { id } })
return res.json({ id, revoked: true })
} catch (err) {
log.error('revoke invite', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { create, list, revoke }

View File

@@ -10,6 +10,8 @@
const users = require('../../../model/users/users.model')
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
const shardState = require('../../../model/shardState/shardState.model')
const uoLinkClient = require('../../../utils/uoLinkClient')
const activity = require('../../../model/activity/activity.model')
const { salesForAccounts } = require('../../../utils/shardSales')
const log = require('../../../utils/logger')('admin-user-shard')
@@ -101,4 +103,40 @@ async function getStanding(req, res) {
}
}
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding }
// DELETE /admin/users/:id/shard/link/:account — unlink a game account from this
// user, site-side. `actor` is stamped from the session (never the browser). On
// success the sidecar clears the WebsiteUserId tag on the shard and we drop the
// local mirror so attribution stops immediately.
async function unlinkAccount(req, res) {
const { account } = req.params
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
// Only unlink an account actually linked to THIS user (avoid cross-user unlink).
if (!ctx.accounts.includes(account)) {
return res.status(404).json({ message: 'That account is not linked to this user.' })
}
const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account })
if (result.ok) {
await shardLinks.removeByAccount(account)
await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } })
log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username })
return res.json({ account, unlinked: true })
}
if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' })
if (result.status === 404) {
// Not linked on the shard — reconcile our mirror anyway so the two agree.
await shardLinks.removeByAccount(account)
return res.status(404).json({ message: 'That account is not linked.' })
}
if (result.status === 503 || result.status === 0) {
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
}
return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' })
} catch (err) {
log.error('unlinkAccount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }