Add player account linking + roster/vendor reads (phase 3)

Ties an in-game account to a website user and gates reads on ownership.

- schema: shard_account_links (account PK → user_id, char_name, linked_at;
  FK users ON DELETE CASCADE) — the site-side mirror of the sidecar's
  authoritative link.
- model/shardLinks: upsert/list/ownership-check/getByAccount/unlink.
- player/shard.controller.js:
  - POST /player/shard/link — confirm a one-time [link code via
    uoLinkClient.confirmLink(code, req.user.id); on link.ok mirror the link and
    activity.log it; bad/expired codes → 400, shard down → 503.
  - GET /player/shard/accounts — the caller's linked accounts.
  - GET /player/shard/roster/:account and /vendors/:account — live round-trips,
    ownership-checked against the mirror (403 otherwise), 503 on shard restart.
- player.routes.js: mounted under the existing requireRole('player') gate with
  express-validator guards + #swagger annotations; new "Player · Shard" tag and
  ShardLinkRequest/ShardLinkResult/ShardLink schemas; spec regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
2026-07-11 02:13:46 -05:00
parent 523113f013
commit 064f02c4b6
7 changed files with 655 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
// ── Player: game-account linking + reads ───────────────────────────────────
//
// The player-facing surface for the uo-link integration. A logged-in player
// runs [link in game, gets a one-time code, and enters it here — the server
// confirms it with the sidecar (which permanently tags the game account with the
// website user id) and mirrors the link locally. Roster/vendor reads are
// ownership-checked against that mirror so a player can only see accounts they
// have linked. The sidecar token stays server-side throughout.
const uoLinkClient = require('../../../utils/uoLinkClient')
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('player-shard')
// POST /player/shard/link — confirm an in-game link code.
async function link(req, res) {
const { code } = req.body
try {
const result = await uoLinkClient.confirmLink(code, req.user.id)
if (result.ok && result.data && result.data.kind === 'link.ok') {
const account = result.data.account
await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null })
await activity.log({ req, action: 'uoLink.account.link', detail: { account } })
log.info('player linked game account', { user: req.user.username, account })
return res.json({ linked: true, account })
}
// Sidecar reports bad/expired codes as 400 link.error or 404.
if (result.status === 400 || result.status === 404) {
return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' })
}
if (result.status === 503 || result.status === 0) {
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
}
return res.status(502).json({ message: 'Could not confirm the link with the shard.' })
} catch (err) {
log.error('player.shard.link', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /player/shard/accounts — the caller's linked game accounts.
async function listAccounts(req, res) {
try {
return res.json(await shardLinks.listForUser(req.user.id))
} catch (err) {
log.error('player.shard.listAccounts', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the
// uoLinkClient method to call with the account.
async function ownedRoundTrip(req, res, fetcher, label) {
const { account } = req.params
try {
const owns = await shardLinks.ownsAccount(account, req.user.id)
if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' })
const result = await fetcher(account)
if (result.ok) return res.json(result.data)
if (result.status === 404) return res.status(404).json({ message: 'Not found.' })
if (result.status === 503 || result.status === 0) {
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
}
return res.status(502).json({ message: 'Could not reach the shard.' })
} catch (err) {
log.error(`player.shard.${label}`, err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /player/shard/roster/:account — characters on a linked account.
const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster')
// GET /player/shard/vendors/:account — player vendors on a linked account.
const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors')
module.exports = { link, listAccounts, roster, vendors }