From c4245e3f6aae34603bcc355df9557f184a7c16c9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 11 Jul 2026 09:15:18 -0500
Subject: [PATCH] Restrict public presence to staff + let admins view any
character
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
---
client/src/App.jsx | 2 -
client/src/api/client.js | 5 +-
client/src/components/VendorSales.jsx | 41 ++
client/src/lib/shardEvents.js | 4 +-
.../src/routes/admin/views/AdminCharacter.jsx | 10 +-
.../routes/admin/views/AdminCharacters.jsx | 2 +
client/src/routes/player/PlayerCharacter.jsx | 10 +-
client/src/routes/player/PlayerCharacters.jsx | 5 +-
client/src/routes/public/Shard.jsx | 38 +-
client/src/routes/public/ShardChar.jsx | 37 --
server/src/model/shardState/shardState.db.js | 21 +
.../src/model/shardState/shardState.model.js | 30 ++
server/src/router/v1/admin/admin.routes.js | 24 +-
server/src/router/v1/player/player.routes.js | 21 +
.../src/router/v1/player/shard.controller.js | 67 ++-
server/src/router/v1/public/public.routes.js | 15 +-
.../src/router/v1/public/shard.controller.js | 55 +--
server/src/utils/shardBroadcast.js | 5 +-
server/swagger/swagger-output.json | 450 +++++++++++++++---
server/swagger/swagger.js | 17 +-
20 files changed, 643 insertions(+), 216 deletions(-)
create mode 100644 client/src/components/VendorSales.jsx
delete mode 100644 client/src/routes/public/ShardChar.jsx
diff --git a/client/src/App.jsx b/client/src/App.jsx
index e7e98bf..56caeee 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -17,7 +17,6 @@ import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
-import ShardChar from './routes/public/ShardChar.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
@@ -77,7 +76,6 @@ export default function App() {
} />
} />
} />
- } />
} />
} />
{/* CMS pages: top-level /:slug, matched only after the named routes
diff --git a/client/src/api/client.js b/client/src/api/client.js
index b2599c2..a880c5f 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -94,7 +94,6 @@ export const api = {
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
- char: (serial) => req(`/public/shard/char/${encodeURIComponent(serial)}`),
},
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
// fetch-only, so SSE subscribers build the URL from here. The admin stream
@@ -222,6 +221,8 @@ export const api = {
accounts: () => req('/admin/shard/accounts'),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
+ char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
+ sales: () => req('/admin/shard/sales'),
},
// ----- auth providers / SSO config (admin only) -----
@@ -269,6 +270,8 @@ export const api = {
accounts: () => req('/player/shard/accounts'),
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
+ char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
+ sales: () => req('/player/shard/sales'),
},
},
}
diff --git a/client/src/components/VendorSales.jsx b/client/src/components/VendorSales.jsx
new file mode 100644
index 0000000..81cacb7
--- /dev/null
+++ b/client/src/components/VendorSales.jsx
@@ -0,0 +1,41 @@
+import { useEffect, useState } from 'react'
+import { ago } from '../lib/format.js'
+
+// Owner-private recent player-vendor sales. `fetchSales` is the scope method
+// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
+// sales for accounts linked to the caller.
+export default function VendorSales({ fetchSales }) {
+ const [sales, setSales] = useState(null)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ let active = true
+ fetchSales()
+ .then((rows) => active && setSales(rows))
+ .catch(() => active && setError('Could not load your vendor sales.'))
+ return () => { active = false }
+ }, [fetchSales])
+
+ if (error) return null
+ if (!sales) return null
+
+ return (
+
+ Recent vendor sales
+ {sales.length === 0 ? (
+ No vendor sales recorded yet.
+ ) : (
+
+ {sales.map((s, i) => (
+
+
+ {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
+
+ {ago(s.t)}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/client/src/lib/shardEvents.js b/client/src/lib/shardEvents.js
index 45a4305..963cd71 100644
--- a/client/src/lib/shardEvents.js
+++ b/client/src/lib/shardEvents.js
@@ -62,9 +62,11 @@ export function describe(ev) {
}
// Category grouping for the filter tabs.
+// Vendor sales are intentionally NOT a public category — they are owner-private
+// (a linked player sees their own under the portal). The admin live feed still
+// describes vendor.sale via describe() below.
export const CATEGORIES = [
{ id: 'all', label: 'All', kinds: null },
- { id: 'sales', label: 'Vendor sales', kinds: ['vendor.sale'] },
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
diff --git a/client/src/routes/admin/views/AdminCharacter.jsx b/client/src/routes/admin/views/AdminCharacter.jsx
index 1767a84..db54934 100644
--- a/client/src/routes/admin/views/AdminCharacter.jsx
+++ b/client/src/routes/admin/views/AdminCharacter.jsx
@@ -4,12 +4,13 @@ import CharacterSheet from '../../../components/CharacterSheet.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { api } from '../../../api/client.js'
-// A staff member's character sheet inside the admin shell. Character data is
-// public MMO data, so it uses the same cached public endpoint.
+// A staff member's own character sheet inside the admin shell. Owner-checked —
+// the endpoint only returns a sheet for a character on the caller's linked account.
export default function AdminCharacter() {
const { serial } = useParams()
- const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
+ const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
const restarting = error && error.status === 503
+ const forbidden = error && error.status === 403
return (
@@ -20,7 +21,8 @@ export default function AdminCharacter() {
{loading && }
{restarting && }
- {error && !restarting && }
+ {forbidden && }
+ {error && !restarting && !forbidden && }
{!loading && !error && data && }
)
diff --git a/client/src/routes/admin/views/AdminCharacters.jsx b/client/src/routes/admin/views/AdminCharacters.jsx
index b2a7ec2..7167603 100644
--- a/client/src/routes/admin/views/AdminCharacters.jsx
+++ b/client/src/routes/admin/views/AdminCharacters.jsx
@@ -1,4 +1,5 @@
import GameAccounts from '../../../components/GameAccounts.jsx'
+import VendorSales from '../../../components/VendorSales.jsx'
import { api } from '../../../api/client.js'
// Staff link their OWN in-game account and view their characters — the same
@@ -10,6 +11,7 @@ export default function AdminCharacters() {
Link your own game account to view your characters, stats, skills and vendors.
`/admin/characters/${serial}`} />
+
)
}
diff --git a/client/src/routes/player/PlayerCharacter.jsx b/client/src/routes/player/PlayerCharacter.jsx
index 2ddf27e..3205a7a 100644
--- a/client/src/routes/player/PlayerCharacter.jsx
+++ b/client/src/routes/player/PlayerCharacter.jsx
@@ -4,12 +4,13 @@ import CharacterSheet from '../../components/CharacterSheet.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
-// A player's character sheet inside the portal. Character data is public MMO
-// data, so it uses the same cached public endpoint the site does.
+// A player's character sheet inside the portal. Owner-checked: the endpoint only
+// returns a sheet for a character on an account linked to the caller.
export default function PlayerCharacter() {
const { serial } = useParams()
- const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
+ const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
const restarting = error && error.status === 503
+ const forbidden = error && error.status === 403
return (
@@ -20,7 +21,8 @@ export default function PlayerCharacter() {
{loading && }
{restarting && }
- {error && !restarting && }
+ {forbidden && }
+ {error && !restarting && !forbidden && }
{!loading && !error && data && }
)
diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx
index bc48dee..65617b7 100644
--- a/client/src/routes/player/PlayerCharacters.jsx
+++ b/client/src/routes/player/PlayerCharacters.jsx
@@ -1,13 +1,16 @@
import GameAccounts from '../../components/GameAccounts.jsx'
+import VendorSales from '../../components/VendorSales.jsx'
import { api } from '../../api/client.js'
// The logged-in player's characters. Shows the link prompt when no game account
-// is linked, otherwise their characters grouped by account (shared component).
+// is linked, otherwise their characters grouped by account (shared component),
+// plus their own recent vendor sales.
export default function PlayerCharacters() {
return (
Your characters
`/player/char/${serial}`} />
+
)
}
diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx
index 304765d..1f6ea5c 100644
--- a/client/src/routes/public/Shard.jsx
+++ b/client/src/routes/public/Shard.jsx
@@ -47,11 +47,10 @@ export default function Shard() {
const { loading, error, data } = useAsync(() =>
Promise.all([
api.shard.status(),
- api.shard.feed({ kind: 'vendor.sale', limit: 8 }),
api.shard.idoc(),
api.shard.economy(60),
api.shard.online(),
- ]).then(([status, sales, idoc, economy, online]) => ({ status, sales, idoc, economy, online })),
+ ]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
)
const { events, connected } = useShardFeed({ max: 30 })
@@ -115,26 +114,25 @@ export default function Shard() {
- {/* Online now */}
+ {/* Staff online — linked staff accounts only, with location */}
- Online now
+ Staff online
{(!data.online || data.online.length === 0) ? (
- No one is online right now.
+ No staff are online right now.
) : (
-
+
{data.online.map((p) => (
-
-
- {p.name || p.serial}
- {p.map &&
· {p.map} }
-
+
+
+
+ {p.name || p.serial}
+
+
+ {p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
+
+
))}
)}
@@ -150,13 +148,7 @@ export default function Shard() {
)}
-
- {/* Recent vendor sales */}
-
({ id: s.id, text: describe(s), when: s.t }))}
- />
+
{/* Latest IDOC */}
api.shard.char(serial), [serial])
-
- const restarting = error && error.status === 503
- const notFound = error && error.status === 404
-
- return (
-
-
-
-
-
-
- ← Back to shard
-
-
-
- {loading &&
}
- {restarting &&
}
- {notFound &&
}
- {error && !restarting && !notFound &&
}
- {!loading && !error && data &&
}
-
-
- )
-}
diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js
index 0e35276..74384f7 100644
--- a/server/src/model/shardState/shardState.db.js
+++ b/server/src/model/shardState/shardState.db.js
@@ -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,
diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js
index bee0b96..87e31c4 100644
--- a/server/src/model/shardState/shardState.model.js
+++ b/server/src/model/shardState/shardState.model.js
@@ -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,
diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js
index 3134e0a..cab78a4 100644
--- a/server/src/router/v1/admin/admin.routes.js
+++ b/server/src/router/v1/admin/admin.routes.js
@@ -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 caller’s 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 =
diff --git a/server/src/router/v1/player/player.routes.js b/server/src/router/v1/player/player.routes.js
index 7af0cdb..80da8b7 100644
--- a/server/src/router/v1/player/player.routes.js
+++ b/server/src/router/v1/player/player.routes.js
@@ -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 caller’s 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 caller’s 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
diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js
index c6c230f..0f9359c 100644
--- a/server/src/router/v1/player/shard.controller.js
+++ b/server/src/router/v1/player/shard.controller.js
@@ -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 }
diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js
index 3f77764..992a7a1 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 = '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']
diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js
index 28be23d..a62b8f7 100644
--- a/server/src/router/v1/public/shard.controller.js
+++ b/server/src/router/v1/public/shard.controller.js
@@ -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 }
diff --git a/server/src/utils/shardBroadcast.js b/server/src/utils/shardBroadcast.js
index 13f740c..c12637f 100644
--- a/server/src/utils/shardBroadcast.js
+++ b/server/src/utils/shardBroadcast.js
@@ -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',
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 6978c0f..a9090cd 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -1415,7 +1415,7 @@
"tags": [
"Public · Shard"
],
- "summary": "Players online now (name + serial + map only)",
+ "summary": "Staff online now (linked staff accounts; name + serial + map only)",
"description": "",
"responses": {
"200": {
@@ -1464,75 +1464,6 @@
}
}
},
- "/api/v1/public/shard/char/{serial}": {
- "get": {
- "tags": [
- "Public · Shard"
- ],
- "summary": "Live character sheet by serial (cached; degrades on shard restart)",
- "description": "",
- "parameters": [
- {
- "name": "serial",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- },
- "description": "Mobile serial, e.g. 0x24C."
- }
- ],
- "responses": {
- "200": {
- "description": "Character profile",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "additionalProperties": true
- }
- }
- }
- },
- "400": {
- "description": "Invalid serial",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
- }
- }
- }
- },
- "404": {
- "description": "Character not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
- }
- }
- }
- },
- "500": {
- "description": "Internal Server Error"
- },
- "502": {
- "description": "Bad Gateway"
- },
- "503": {
- "description": "Shard restarting — retry",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Error"
- }
- }
- }
- }
- }
- }
- },
"/api/v1/public/shard/stream": {
"get": {
"tags": [
@@ -1984,7 +1915,7 @@
"tags": [
"Admin · Account"
],
- "summary": "Character roster for a linked account (self)",
+ "summary": "Character roster for an account (self; admins: any account)",
"description": "",
"parameters": [
{
@@ -2038,7 +1969,7 @@
"tags": [
"Admin · Account"
],
- "summary": "Player vendors for a linked account (self)",
+ "summary": "Player vendors for an account (self; admins: any account)",
"description": "",
"parameters": [
{
@@ -2087,6 +2018,107 @@
]
}
},
+ "/api/v1/admin/shard/char/{serial}": {
+ "get": {
+ "tags": [
+ "Admin · Account"
+ ],
+ "summary": "Character sheet (self-linked characters; admins: any character)",
+ "description": "",
+ "parameters": [
+ {
+ "name": "serial",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Mobile serial, e.g. 0x24C."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Character profile",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Character not on an account linked to the caller",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ },
+ "502": {
+ "description": "Bad Gateway"
+ },
+ "503": {
+ "description": "Service Unavailable"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/shard/sales": {
+ "get": {
+ "tags": [
+ "Admin · Account"
+ ],
+ "summary": "Recent player-vendor sales for the caller’s linked accounts (self)",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Vendor sales",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ShardVendorSale"
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/dashboard": {
"get": {
"tags": [
@@ -7002,6 +7034,123 @@
}
]
}
+ },
+ "/api/v1/player/shard/char/{serial}": {
+ "get": {
+ "tags": [
+ "Player · Shard"
+ ],
+ "summary": "Character sheet — only for a character on the caller’s linked account",
+ "description": "",
+ "parameters": [
+ {
+ "name": "serial",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Mobile serial, e.g. 0x24C."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Character profile",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Character not on an account linked to the caller",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ },
+ "502": {
+ "description": "Bad Gateway"
+ },
+ "503": {
+ "description": "Shard unavailable — retry",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/player/shard/sales": {
+ "get": {
+ "tags": [
+ "Player · Shard"
+ ],
+ "summary": "Recent player-vendor sales for the caller’s linked accounts",
+ "description": "",
+ "responses": {
+ "200": {
+ "description": "Vendor sales",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ShardVendorSale"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
}
},
"components": {
@@ -10544,7 +10693,7 @@
},
"description": {
"type": "string",
- "example": "A player online now (redacted for the public list)."
+ "example": "A LINKED player online now (only accounts linked to a website user are listed)."
},
"properties": {
"type": "object",
@@ -10591,6 +10740,161 @@
"example": "Trammel"
}
}
+ },
+ "x": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 1402
+ }
+ }
+ },
+ "y": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 1604
+ }
+ }
+ },
+ "z": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 0
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "ShardVendorSale": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "A player-vendor sale (visible only to the linked owner)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "t": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "description": {
+ "type": "string",
+ "example": "Sale time, epoch ms."
+ },
+ "example": {
+ "type": "number",
+ "example": 1783720195626
+ }
+ }
+ },
+ "itemType": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Longsword"
+ }
+ }
+ },
+ "amount": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 1
+ }
+ }
+ },
+ "price": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 100
+ }
+ }
+ },
+ "commission": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "number",
+ "example": 5
+ }
+ }
+ },
+ "ownerAcct": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "whitlocktech"
+ }
+ }
}
}
}
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index 0d245b8..a6838f8 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -546,11 +546,26 @@ const doc = {
},
ShardOnlinePlayer: {
type: 'object',
- description: 'A player online now (redacted for the public list).',
+ description: 'A LINKED player online now (only accounts linked to a website user are listed).',
properties: {
serial: { type: 'string', example: '0x24C' },
name: { type: 'string', example: 'Darrow' },
map: { type: 'string', nullable: true, example: 'Trammel' },
+ x: { type: 'integer', nullable: true, example: 1402 },
+ y: { type: 'integer', nullable: true, example: 1604 },
+ z: { type: 'integer', nullable: true, example: 0 },
+ },
+ },
+ ShardVendorSale: {
+ type: 'object',
+ description: 'A player-vendor sale (visible only to the linked owner).',
+ properties: {
+ t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 },
+ itemType: { type: 'string', example: 'Longsword' },
+ amount: { type: 'integer', example: 1 },
+ price: { type: 'integer', example: 100 },
+ commission: { type: 'integer', nullable: true, example: 5 },
+ ownerAcct: { type: 'string', example: 'whitlocktech' },
},
},
ShardHouse: {