Files
website/server/src/router/v1/admin/usersShard.controller.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

143 lines
6.3 KiB
JavaScript

// ── Admin: a single user's shard (uo-link) footprint ──────────────────────────
//
// Backs the /admin/users/:id detail page. Every read is scoped to the target
// user's linked game accounts (from the local shard_account_links mirror): their
// vendor sales, houses, and currently-online characters. The live character
// rosters are fetched separately by the client through the existing admin-bypass
// /admin/shard/* endpoints, so nothing here round-trips the sidecar — these are
// fast, DB-backed reads. Admin-only (registered under adminOnly in the router).
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')
// Resolve the target user's linked game accounts, or null if the user id is
// unknown (so the handler can 404 rather than silently returning an empty set).
async function accountsForUser(id) {
const user = await users.getById(id)
if (!user) return null
const links = await shardLinks.listForUser(id)
return { user, links, accounts: links.map((l) => l.account) }
}
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
async function getUser(req, res) {
try {
const user = await users.getById(Number(req.params.id))
if (!user) return res.status(404).json({ message: 'Not found' })
return res.json(user)
} catch (err) {
log.error('getUser', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
async function listAccounts(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json(ctx.links)
} catch (err) {
log.error('listAccounts', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/sales — recent vendor sales on the user's accounts.
async function getSales(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json(await salesForAccounts(ctx.accounts))
} catch (err) {
log.error('getSales', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/houses — houses owned by the user's accounts.
async function getHouses(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json(await shardState.listHousesForAccounts(ctx.accounts))
} catch (err) {
log.error('getHouses', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/online — the user's characters currently online.
async function getOnline(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json(await shardState.listOnlineForAccounts(ctx.accounts))
} catch (err) {
log.error('getOnline', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links:
// city governorships they currently hold and guilds they lead. Both are reliable
// current-state lookups on the user's linked accounts.
async function getStanding(req, res) {
try {
const ctx = await accountsForUser(Number(req.params.id))
if (!ctx) return res.status(404).json({ message: 'Not found' })
const [governorOf, guildsLed] = await Promise.all([
shardState.listGovernorshipsForAccounts(ctx.accounts),
shardState.listGuildsLedForAccounts(ctx.accounts),
])
return res.json({ governorOf, guildsLed })
} catch (err) {
log.error('getStanding', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// 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 }