// ── 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 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') const log = require('../../../utils/logger')('player-shard') const SERIAL_RE = /^0x[0-9a-fA-F]+$/ // Decorate a char.profile with cross-links from our own board data: the guild the // character leads and any city governorship on its account. Best-effort — a // failure here never fails the profile (it's a nicety, not the sheet). async function enrichCharProfile(profile) { if (!profile) return profile try { const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct }) if (guild) profile.guild = guild if (profile.acct) { const govs = await shardState.listGovernorshipsForAccounts([profile.acct]) if (govs.length) profile.governorOf = govs.map((g) => g.city) } } catch (err) { log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message }) } return profile } // 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(await enrichCharProfile(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 = links.map((l) => l.account) return res.json(await salesForAccounts(accounts)) } catch (err) { log.error('player.shard.getSales', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /player/shard/houses — the caller's OWN houses (home status), scoped to // their linked accounts. A player sees their own decay/IDOC standing; never // anyone else's. Full detail is fine here — it's their property. async function getHouses(req, res) { try { const links = await shardLinks.listForUser(req.user.id) const accounts = links.map((l) => l.account) return res.json(await shardState.listHousesForAccounts(accounts)) } catch (err) { log.error('player.shard.getHouses', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // 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, getHouses, createGameAccount }