feat(shard): admin-configurable visibility for every shard surface
Protocol 3.0 Part A. Replaces the static PUBLIC_KINDS allowlist - which
was the entire public/admin boundary - with per-feature, per-field
audience control an admin owns from Admin -> Shard Visibility.
Closes a live leak. BridgeJson.Actor() writes acct and webId;
shapeGuild() returned the stored payload verbatim; GET
/api/v1/public/shard/guilds is anonymous. Guild leaders' game account
names and website user ids were readable by anyone, and the same path
existed for governors. Both are now projected.
The ladder is anonymous < logged_in < player < staff < admin, each rung
implying the ones below. Staff satisfy `player` without a linked account
(as /player/* already does); `editor` is a content role and gets no
shard privilege, since mapping it to staff would silently widen what
editors see.
Two invariants are code, not configuration, and both reject rather than
silently ignore:
1. acct/webId are admin-only always - not configurable, discarded on
read as well as rejected on write.
2. A kind absent from KIND_FEATURE never reaches anyone below admin.
Fail closed, so a shard emitting a new event degrades to staff-only
rather than to public.
Enforcement is three points over one config: requireFeature() on routes
(404 disabled, 403 out-of-rung) plus field projection; per-connection
filtering on SSE, where a subscriber's rung is resolved once at subscribe
time and frozen so a long-open stream cannot gain privilege; and
/public/shard/features so the SPA hides links it cannot follow.
PUBLIC_KINDS still exists and is still exported (/feed filtering,
notificationStreams) but is now derived from the kind map, so the two
can no longer drift. Defaults reproduce pre-3.0 behavior exactly - a
test pins the derived set against the old allowlist.
Also fixes an SSE resource leak found while testing: a client dropped
because its write threw was removed from the bucket but its keepalive
interval was never cleared, firing forever on a dead socket. Both paths
now go through one drop().
Tests: 478 server (33 new across shardVisibility + shardBroadcast),
43 client. Route manifest and OpenAPI spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +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 visibility = require('../../../utils/shardVisibility')
|
||||
|
||||
const log = require('../../../utils/logger')('public-shard')
|
||||
|
||||
@@ -72,18 +72,21 @@ async function getEconomy(req, res) {
|
||||
|
||||
// GET /public/shard/online — players online now whose account is linked to a
|
||||
// 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')
|
||||
// is online (name + serial); their in-game location (map + coordinates) is gated
|
||||
// on the `presence` feature's `location` field rule, which defaults to `staff`
|
||||
// — the same admin/moderator set this used to hardcode. Non-staff players are
|
||||
// never listed.
|
||||
async function canSeeStaffLocation(req) {
|
||||
const config = await visibility.getConfig()
|
||||
const required = config.presence?.fields?.location || 'staff'
|
||||
const level = req.viewerLevel || (await visibility.viewerLevel(req))
|
||||
return visibility.meets(level, required)
|
||||
}
|
||||
|
||||
async function getOnline(req, res) {
|
||||
try {
|
||||
const rows = await shardState.listOnlineLinked()
|
||||
const showLocation = canSeeStaffLocation(req)
|
||||
const showLocation = await canSeeStaffLocation(req)
|
||||
return res.json(
|
||||
rows.map((r) => {
|
||||
const entry = { serial: r.serial, name: r.name }
|
||||
@@ -126,9 +129,13 @@ async function getChamps(req, res) {
|
||||
|
||||
// GET /public/shard/guilds — the current guild board. Served from our store;
|
||||
// live via guild.update / guild.remove / guild.join on the public SSE stream.
|
||||
//
|
||||
// Projected: the stored payload is the raw guild.update frame, whose `leader`
|
||||
// actor carries `acct` and `webId`. Those are admin-only and were previously
|
||||
// returned verbatim to anonymous callers.
|
||||
async function getGuilds(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listGuilds())
|
||||
return res.json(await visibility.project('guilds', await shardState.listGuilds(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGuilds', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
@@ -136,10 +143,11 @@ async function getGuilds(req, res) {
|
||||
}
|
||||
|
||||
// GET /public/shard/governors — the current town-governor board (empty on shards
|
||||
// without City Loyalty). Live via city.update on the public SSE stream.
|
||||
// without City Loyalty). Live via city.update on the public SSE stream. Projected
|
||||
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
|
||||
async function getGovernors(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listGovernors())
|
||||
return res.json(await visibility.project('governors', await shardState.listGovernors(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernors', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
@@ -150,7 +158,8 @@ async function getGovernors(req, res) {
|
||||
// (look-back: "who were all the governors of Britain?"), newest first.
|
||||
async function getGovernorHistory(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listGovernorHistory(req.params.city, req.query.limit))
|
||||
const terms = await shardState.listGovernorHistory(req.params.city, req.query.limit)
|
||||
return res.json(await visibility.project('governors', terms, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernorHistory', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
@@ -192,9 +201,25 @@ async function getHouses(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
|
||||
// GET /public/shard/features — the shard features THIS caller can actually see,
|
||||
// so the SPA (and the Android client) can hide nav entries instead of rendering
|
||||
// links that 403. Deliberately reports only what the viewer may reach: the list
|
||||
// itself must not disclose the existence of a feature they're gated out of.
|
||||
async function getFeatures(req, res) {
|
||||
try {
|
||||
const config = await visibility.getConfig()
|
||||
const level = await visibility.viewerLevel(req)
|
||||
return res.json({ level, features: visibility.visibleFeatures(level, config) })
|
||||
} catch (err) {
|
||||
log.error('shard.getFeatures', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/stream — live-event SSE channel. What arrives depends on the
|
||||
// caller's audience rung, resolved once at subscribe time; see shardBroadcast.js.
|
||||
function stream(req, res) {
|
||||
broadcast.subscribe(req, res, 'public')
|
||||
return broadcast.subscribe(req, res, 'public')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -209,5 +234,6 @@ module.exports = {
|
||||
getGovernorHistory,
|
||||
getPresence,
|
||||
getHouses,
|
||||
getFeatures,
|
||||
stream,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user