From aa2177715e953de359e954092957d9fc28cb118f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 21:18:52 -0500 Subject: [PATCH] fix(shard): restrict staff in-game location to admins/moderators The public "Staff online" list on the Shard page exposed each staff member's in-game location (map + coordinates) to everyone, including logged-in players and unauthenticated visitors. Location is now privileged data: - Server: getOnline inspects the caller's role via getUserFromRequest (the same non-rejecting helper siteMode uses on public routes) and only includes map/x/y/z for admin/moderator callers. For everyone else the fields are omitted from the JSON entirely, so they can't be read from the network tab. serial + name (online status) still shown. - Client: Shard.jsx gates the location span on the viewer's role from useAuth() (same pattern as RoleGate); non-privileged viewers see who is online but no location field is rendered. Tests: publicShardOnline.test.js covers admin + moderator (location included), player + unauthenticated + editor (location omitted). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe --- client/src/routes/public/Shard.jsx | 16 ++- server/src/router/v1/public/public.routes.js | 2 +- .../src/router/v1/public/shard.controller.js | 26 ++++- server/test/publicShardOnline.test.js | 105 ++++++++++++++++++ 4 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 server/test/publicShardOnline.test.js diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx index 431df8b..7758251 100644 --- a/client/src/routes/public/Shard.jsx +++ b/client/src/routes/public/Shard.jsx @@ -8,6 +8,7 @@ import { describe } from '../../lib/shardEvents.js' import { ago } from '../../lib/format.js' import { api } from '../../api/client.js' import PlayersOnline from '../../components/PlayersOnline.jsx' +import { useAuth } from '../../contexts/AuthContext.jsx' // ── Gold-supply sparkline ─────────────────────────────────────────────────── function Sparkline({ series }) { @@ -54,6 +55,11 @@ export default function Shard() { ]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })), ) const { events, connected } = useShardFeed({ max: 30 }) + const { user } = useAuth() + // Staff in-game location is privileged: only admins/moderators see it. Players + // and the public see that staff are online but not where. The server enforces + // this too (it omits the location fields entirely for non-privileged callers). + const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator' const status = data?.status const online = status?.pluginConnected @@ -119,7 +125,7 @@ export default function Shard() { - {/* Staff online — linked staff accounts only, with location */} + {/* Staff online — linked staff accounts only; location is admin/mod-only */}
Staff online @@ -134,9 +140,11 @@ export default function Shard() { {p.name || p.serial} - - {p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''} - + {canSeeLocation && ( + + {p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''} + + )}
))} diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index e172df5..a11504e 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -165,7 +165,7 @@ publicRouter.get( publicRouter.get( '/shard/online', // #swagger.tags = ['Public · Shard'] - // #swagger.summary = 'Staff online now (linked staff accounts; name + serial + map only)' + // #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)' /* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */ shard.getOnline, ) diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js index 642e9a8..64fbdf1 100644 --- a/server/src/router/v1/public/shard.controller.js +++ b/server/src/router/v1/public/shard.controller.js @@ -13,6 +13,7 @@ const shardEvents = require('../../../model/shardEvents/shardEvents.model') const shardState = require('../../../model/shardState/shardState.model') const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model') const broadcast = require('../../../utils/shardBroadcast') +const auth = require('../../../utils/auth') const log = require('../../../utils/logger')('public-shard') @@ -70,12 +71,31 @@ async function getEconomy(req, res) { } // 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. +// STAFF website user (admin/editor/moderator). Everyone sees that a staff member +// is online (name + serial); their in-game location (map + coordinates) is only +// included for privileged viewers (admin/moderator) so it is never exposed to +// players or the public via the network tab. Non-staff players are never listed. +function canSeeStaffLocation(req) { + const viewer = auth.getUserFromRequest(req) + return !!viewer && (viewer.role === 'admin' || viewer.role === 'moderator') +} + async function getOnline(req, res) { try { 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 }))) + const showLocation = canSeeStaffLocation(req) + return res.json( + rows.map((r) => { + const entry = { serial: r.serial, name: r.name } + if (showLocation) { + entry.map = r.map + entry.x = r.x + entry.y = r.y + entry.z = r.z + } + return entry + }), + ) } catch (err) { log.error('shard.getOnline', err) return res.status(500).json({ message: 'Internal Server Error' }) diff --git a/server/test/publicShardOnline.test.js b/server/test/publicShardOnline.test.js new file mode 100644 index 0000000..bbfa3e1 --- /dev/null +++ b/server/test/publicShardOnline.test.js @@ -0,0 +1,105 @@ +// Staff-location visibility on GET /public/shard/online. The endpoint is +// token-free, so it inspects the caller's session (getUserFromRequest) and only +// includes each staff member's in-game location (map/x/y/z) for admins and +// moderators. Players and the public still see who is online, but not where. +// +// Point the DB at a closed port BEFORE requiring anything that builds the pool, +// so any stray query fails fast instead of hanging. The model + auth are stubbed, +// so the DB is never actually hit. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const ctrl = require('../src/router/v1/public/shard.controller') +const shardState = require('../src/model/shardState/shardState.model') +const auth = require('../src/utils/auth') +const db = require('../src/utils/db') + +after(() => db.close()) + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { + this.statusCode = c + return this + }, + json(b) { + this.body = b + return this + }, + } +} + +// One online staff member with a location the model would return. +const ONLINE_ROW = { serial: '0x1', name: 'Lady Mod', map: 'Felucca', x: 1495, y: 1628, z: 10 } + +const originals = { + listOnlineLinked: shardState.listOnlineLinked, + getUserFromRequest: auth.getUserFromRequest, +} +afterEach(() => { + shardState.listOnlineLinked = originals.listOnlineLinked + auth.getUserFromRequest = originals.getUserFromRequest +}) + +// Stub the model to return the staff member, and the session to the given viewer. +function setup(viewer) { + shardState.listOnlineLinked = async () => [ONLINE_ROW] + auth.getUserFromRequest = () => viewer +} + +const LOCATION_KEYS = ['map', 'x', 'y', 'z'] + +for (const role of ['admin', 'moderator']) { + test(`getOnline includes location for a ${role}`, async () => { + setup({ id: 1, username: 'staff', role }) + const res = mockRes() + await ctrl.getOnline({}, res) + assert.equal(res.statusCode, 200) + assert.equal(res.body.length, 1) + const entry = res.body[0] + assert.equal(entry.name, 'Lady Mod') + assert.equal(entry.serial, '0x1') + assert.equal(entry.map, 'Felucca') + assert.equal(entry.x, 1495) + assert.equal(entry.y, 1628) + assert.equal(entry.z, 10) + }) +} + +test('getOnline omits location for a logged-in player', async () => { + setup({ id: 2, username: 'joe', role: 'player' }) + const res = mockRes() + await ctrl.getOnline({}, res) + assert.equal(res.statusCode, 200) + const entry = res.body[0] + // Still shows they are online… + assert.equal(entry.name, 'Lady Mod') + assert.equal(entry.serial, '0x1') + // …but the location fields are absent entirely (not null/placeholder). + for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`) +}) + +test('getOnline omits location for an unauthenticated request', async () => { + setup(null) // getUserFromRequest returns null for anon callers + const res = mockRes() + await ctrl.getOnline({}, res) + assert.equal(res.statusCode, 200) + const entry = res.body[0] + assert.equal(entry.name, 'Lady Mod') + assert.equal(entry.serial, '0x1') + for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`) +}) + +// An editor is staff but not admin/moderator — they should not see location. +test('getOnline omits location for an editor', async () => { + setup({ id: 3, username: 'ed', role: 'editor' }) + const res = mockRes() + await ctrl.getOnline({}, res) + const entry = res.body[0] + for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`) +}) -- 2.49.1