Restrict public presence to staff + let admins view any character

Public "Online now" now lists only players whose game account is linked
to a STAFF website user (admin/editor/moderator) — linked players are no
longer exposed publicly with their name and location. listOnlineLinked
joins through to users and filters on role; the section is relabeled
"Staff online".

Character/roster/vendor reads gain an admin bypass: admins may view any
character's data, while players (and editor/moderator staff) stay limited
to accounts they have personally linked. The bypass lives in the shared
player controller and only ever widens access for genuine admins.

Also finalizes the uo-link character/vendor front end (player + admin
character sheets, VendorSales component, ShardChar removed) and
regenerates swagger-output.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
This commit is contained in:
2026-07-11 09:15:18 -05:00
parent 49d0c1bd11
commit c4245e3f6a
20 changed files with 643 additions and 216 deletions

View File

@@ -33,6 +33,26 @@ async function countOnline() {
const listOnline = () =>
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
// Staff roles whose online presence is shown on the public Shard page. Players
// who link an account are NOT surfaced publicly — only staff opt into visibility
// by virtue of being staff.
const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
// Online players whose game account is linked to a STAFF website user. Joined
// against shard_account_links (not the sidecar-supplied web_id) so a link takes
// effect immediately, regardless of whether the player has re-logged since
// linking, then through to users so only staff roles are surfaced publicly.
const listOnlineLinked = () =>
query(
`SELECT ${ONLINE_COLS.split(', ').map((c) => `o.${c}`).join(', ')}
FROM shard_online o
JOIN shard_account_links l ON l.account = o.acct
JOIN users u ON u.id = l.user_id
WHERE u.role IN (${PUBLIC_ONLINE_ROLES.map(() => '?').join(', ')})
ORDER BY o.name ASC`,
PUBLIC_ONLINE_ROLES,
)
// ── Economy supply series ────────────────────────────────────────────────
const insertEconomy = ({ accounts, gold, t }) =>
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
@@ -75,6 +95,7 @@ module.exports = {
clearOnline,
countOnline,
listOnline,
listOnlineLinked,
insertEconomy,
listEconomy,
latestEconomy,

View File

@@ -44,6 +44,35 @@ const setOffline = (serial) => db.removeOnline(serial)
const clearOnline = () => db.clearOnline()
const onlineCount = () => db.countOnline()
function shapeOnline(r) {
return {
serial: r.serial,
name: r.name,
acct: r.acct,
webId: r.web_id,
map: r.map,
x: r.x,
y: r.y,
z: r.z,
hits: r.hits,
hitsMax: r.hits_max,
mana: r.mana,
manaMax: r.mana_max,
stam: r.stam,
stamMax: r.stam_max,
str: r.str,
dex: r.dex,
int: r.int,
updatedAt: r.updated_at,
}
}
// Only players whose account is linked to a website user (opt-in visibility).
async function listOnlineLinked() {
const rows = await db.listOnlineLinked()
return rows.map(shapeOnline)
}
async function listOnline() {
const rows = await db.listOnline()
return rows.map((r) => ({
@@ -133,6 +162,7 @@ module.exports = {
clearOnline,
onlineCount,
listOnline,
listOnlineLinked,
addEconomySample,
listEconomy,
latestEconomy,

View File

@@ -138,7 +138,7 @@ adminRouter.get(
adminRouter.get(
'/shard/roster/:account',
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Character roster for a linked account (self)'
// #swagger.summary = 'Character roster for an account (self; admins: any account)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
@@ -150,7 +150,7 @@ adminRouter.get(
adminRouter.get(
'/shard/vendors/:account',
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Player vendors for a linked account (self)'
// #swagger.summary = 'Player vendors for an account (self; admins: any account)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
@@ -159,6 +159,26 @@ adminRouter.get(
validate,
selfShard.vendors,
)
adminRouter.get(
'/shard/char/:serial',
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('serial').matches(/^0x[0-9a-fA-F]+$/),
validate,
selfShard.getChar,
)
adminRouter.get(
'/shard/sales',
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Recent player-vendor sales for the callers linked accounts (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
selfShard.getSales,
)
// ── Image uploads (screenshots/gallery) ───────────────────────────────
const UPLOAD_DIR =

View File

@@ -182,5 +182,26 @@ playerRouter.get(
validate,
shard.vendors,
)
playerRouter.get(
'/shard/char/:serial',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Character sheet — only for a character on the callers linked account'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('serial').matches(/^0x[0-9a-fA-F]+$/),
validate,
shard.getChar,
)
playerRouter.get(
'/shard/sales',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Recent player-vendor sales for the callers linked accounts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
shard.getSales,
)
module.exports = playerRouter

View File

@@ -9,10 +9,13 @@
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
@@ -51,12 +54,18 @@ async function listAccounts(req, res) {
}
}
// 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 = await shardLinks.ownsAccount(account, req.user.id)
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)
@@ -78,4 +87,58 @@ const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'r
// 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 }
// 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 }

View File

@@ -165,7 +165,7 @@ publicRouter.get(
publicRouter.get(
'/shard/online',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Players online now (name + serial + map only)'
// #swagger.summary = 'Staff online now (linked staff accounts; name + serial + map only)'
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
shard.getOnline,
)
@@ -176,19 +176,6 @@ publicRouter.get(
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getIdoc,
)
publicRouter.get(
'/shard/char/:serial',
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Live character sheet by serial (cached; degrades on shard restart)'
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Invalid serial', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Character not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard restarting — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('serial').matches(/^0x[0-9a-fA-F]+$/),
validate,
shard.getChar,
)
publicRouter.get(
'/shard/stream',
// #swagger.tags = ['Public · Shard']

View File

@@ -12,19 +12,10 @@
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
const shardState = require('../../../model/shardState/shardState.model')
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkClient = require('../../../utils/uoLinkClient')
const broadcast = require('../../../utils/shardBroadcast')
const log = require('../../../utils/logger')('public-shard')
// Serials are opaque hex keys like "0x24C" — validate before hitting the sidecar.
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
// Tiny in-memory cache for live character sheets (the sidecar warns these hit the
// live shard, so cache them). Keyed by serial; short TTL.
const CHAR_TTL_MS = 20000
const charCache = new Map()
// GET /public/shard/status — connection state + online count + latest economy.
async function getStatus(req, res) {
try {
@@ -78,13 +69,13 @@ async function getEconomy(req, res) {
}
}
// GET /public/shard/online — who is online now (redacted: name + serial + map,
// no coordinates, vitals or account). Feeds the public "online now" list, which
// links to the public character sheet.
// GET /public/shard/online — players online now whose account is linked to a
// STAFF website user (admin/editor/moderator). Shows name + location (map +
// coordinates); no vitals or account. Non-staff players are never listed.
async function getOnline(req, res) {
try {
const rows = await shardState.listOnline()
return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map })))
const rows = await shardState.listOnlineLinked()
return res.json(rows.map((r) => ({ serial: r.serial, name: r.name, map: r.map, x: r.x, y: r.y, z: r.z })))
} catch (err) {
log.error('shard.getOnline', err)
return res.status(500).json({ message: 'Internal Server Error' })
@@ -101,43 +92,9 @@ async function getIdoc(req, res) {
}
}
// GET /public/shard/char/:serial — live character sheet (cached briefly). A 503
// from the sidecar means the shard is restarting: report it as such so the UI
// can show a retry banner instead of an error.
async function getChar(req, res) {
const { serial } = req.params
if (!SERIAL_RE.test(serial)) {
return res.status(400).json({ message: 'Invalid serial.' })
}
const cached = charCache.get(serial)
if (cached && Date.now() - cached.at < CHAR_TTL_MS) {
return res.json(cached.data)
}
try {
const result = await uoLinkClient.getCharBySerial(serial)
if (result.ok) {
charCache.set(serial, { at: Date.now(), data: result.data })
return res.json(result.data)
}
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
if (result.status === 503) {
// Serve a stale cache if we have one; otherwise the restart banner.
if (cached) return res.json(cached.data)
return res.status(503).json({ message: 'The game server is restarting — try again shortly.' })
}
if (result.status === 0) return res.status(503).json({ message: 'Shard data is unavailable right now.' })
return res.status(502).json({ message: 'Could not reach the shard.' })
} catch (err) {
log.error('shard.getChar', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
function stream(req, res) {
broadcast.subscribe(req, res, 'public')
}
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChar, stream }
module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, stream }

View File

@@ -15,9 +15,10 @@
const log = require('./logger')('shard-broadcast')
// Kinds safe to expose to unauthenticated browsers.
// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
// deliberately NOT here — sales are owner-private (a linked player sees only
// their own, via /player/shard/sales).
const PUBLIC_KINDS = new Set([
'vendor.sale',
'player.death',
'player.murdered',
'mob.killed',