// ── 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 shardEvents = require('../../../model/shardEvents/shardEvents.model') const activity = require('../../../model/activity/activity.model') const log = require('../../../utils/logger')('player-shard') const SERIAL_RE = /^0x[0-9a-fA-F]+$/ // 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' }) } } // Admins may view any character's data; everyone else is limited to accounts // they have personally linked. The same handlers back /player/shard (role // `player`, never admin) and /admin/shard (staff), so this bypass only ever // widens access for genuine admins. const isAdmin = (req) => req.user && req.user.role === 'admin' // 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 = isAdmin(req) || (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') // GET /player/shard/char/:serial — a character sheet, but ONLY if the character's // account is linked to the caller. The sidecar returns the owning account in the // profile, which we check against the caller's links before returning anything. async function getChar(req, res) { const { serial } = req.params if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' }) try { const result = await uoLinkClient.getCharBySerial(serial) if (result.ok) { // Admins see any character; others only characters on an account they linked. if (!isAdmin(req)) { const acct = result.data && result.data.acct const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' }) } return res.json(result.data) } if (result.status === 404) return res.status(404).json({ message: 'Character not found.' }) if (result.status === 503 || result.status === 0) { return res.status(503).json({ message: 'The game server is restarting — try again shortly.' }) } return res.status(502).json({ message: 'Could not reach the shard.' }) } catch (err) { log.error('player.shard.getChar', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /player/shard/sales — recent player-vendor sales for the caller's linked // accounts only (as seller/owner). Read from the site's own event log. async function getSales(req, res) { try { const links = await shardLinks.listForUser(req.user.id) const accounts = new Set(links.map((l) => l.account)) if (accounts.size === 0) return res.json([]) const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 }) const mine = events .filter((e) => e.payload && accounts.has(e.payload.ownerAcct)) .slice(0, 50) .map((e) => ({ t: e.t, itemType: e.payload.itemType, amount: e.payload.amount, price: e.payload.price, commission: e.payload.commission, ownerAcct: e.payload.ownerAcct, })) return res.json(mine) } catch (err) { log.error('player.shard.getSales', err) return res.status(500).json({ message: 'Internal Server Error' }) } } module.exports = { link, listAccounts, roster, vendors, getChar, getSales }