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:
358
server/src/utils/shardVisibility.js
Normal file
358
server/src/utils/shardVisibility.js
Normal file
@@ -0,0 +1,358 @@
|
||||
// ── Shard feature visibility ───────────────────────────────────────────────
|
||||
//
|
||||
// Admin-configurable, per-feature and per-field audience control over every
|
||||
// shard-derived surface on the site. Replaces the hardcoded split that used to
|
||||
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
|
||||
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
|
||||
//
|
||||
// Design rules (docs/link/v3.md §3):
|
||||
//
|
||||
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
|
||||
// is a dumb forwarder: it accepts frames, stores them, forwards them
|
||||
// verbatim, and serves store-backed reads. It defines no audiences.
|
||||
// • Every default reproduces the behavior that shipped before this module, so
|
||||
// installing it changes nothing until an admin edits the config.
|
||||
// • Two rules an admin CANNOT override:
|
||||
// 1. `acct` / `webId` are admin-only, always. They are not in-game
|
||||
// visible (unlike a character name) and are not configurable fields.
|
||||
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
|
||||
// Fail closed — this is what keeps the kind map a security boundary
|
||||
// rather than a convenience filter.
|
||||
//
|
||||
// The audience ladder is ordered; each rung implies the ones below it.
|
||||
|
||||
const db = require('../model/shardVisibility/shardVisibility.model')
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const auth = require('./auth')
|
||||
const log = require('./logger')('shard-visibility')
|
||||
|
||||
// ── The ladder ─────────────────────────────────────────────────────────────
|
||||
|
||||
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
|
||||
const RANK = new Map(LADDER.map((level, i) => [level, i]))
|
||||
|
||||
const isLevel = (level) => RANK.has(level)
|
||||
|
||||
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
|
||||
// point: an unrecognised value must always lose. A single shared fallback cannot
|
||||
// do that — whichever direction it picks, it fails open on one side. So:
|
||||
//
|
||||
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
|
||||
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
|
||||
//
|
||||
// With one `rank()` defaulting to admin, a viewer level that fell through (a
|
||||
// typo, a future rung this build doesn't know, a value from a caller that
|
||||
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
|
||||
const viewerRank = (level) => RANK.get(level) ?? 0
|
||||
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
|
||||
|
||||
// True when a viewer at `viewer` satisfies a requirement of `required`.
|
||||
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
|
||||
|
||||
// Exported for tests/diagnostics; `meets` is what callers should use.
|
||||
const rank = viewerRank
|
||||
|
||||
// ── Features ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
|
||||
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
|
||||
// not listed here is visible whenever the feature itself is.
|
||||
//
|
||||
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
|
||||
|
||||
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
|
||||
|
||||
const FEATURES = {
|
||||
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||
status: { audience: 'anonymous', fields: {} },
|
||||
activity: { audience: 'anonymous', fields: {} },
|
||||
champs: { audience: 'anonymous', fields: {} },
|
||||
guilds: { audience: 'anonymous', fields: {} },
|
||||
governors: { audience: 'anonymous', fields: {} },
|
||||
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||
houses: { audience: 'anonymous', fields: { owner: 'staff', price: 'staff' } },
|
||||
// /public/shard/online listed linked staff to everyone but gated location to
|
||||
// admin+moderator — which is exactly the `staff` rung.
|
||||
presence: { audience: 'anonymous', fields: { location: 'staff' } },
|
||||
|
||||
// ── New in v3. ──
|
||||
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
|
||||
atlas: { audience: 'anonymous', fields: {} },
|
||||
leaderboards: { audience: 'anonymous', fields: { characterName: 'anonymous' } },
|
||||
// Shop name, owner character name and vendor location are already globally
|
||||
// visible in-game via the stock Vendor Search gump, so publishing them is not
|
||||
// a new disclosure — but they stay configurable so an admin can tighten them.
|
||||
market: { audience: 'anonymous', fields: { ownerName: 'anonymous', location: 'anonymous' } },
|
||||
}
|
||||
|
||||
const FEATURE_NAMES = Object.keys(FEATURES)
|
||||
const isFeature = (name) => Object.hasOwn(FEATURES, name)
|
||||
|
||||
// ── Kind → feature ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every event kind that may ever leave the admin channel must appear here.
|
||||
// Anything else is admin-only by omission (rule 2). This map is seeded from
|
||||
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
|
||||
// same kinds it did — now attributed to a feature that an admin can re-gate.
|
||||
|
||||
const KIND_FEATURE = new Map(
|
||||
Object.entries({
|
||||
// status / lifecycle
|
||||
'server.hello': 'status',
|
||||
'server.shutdown': 'status',
|
||||
'server.crashed': 'status',
|
||||
'economy.supply': 'status',
|
||||
// activity feed
|
||||
'player.death': 'activity',
|
||||
'player.murdered': 'activity',
|
||||
'mob.killed': 'activity',
|
||||
'quest.complete': 'activity',
|
||||
'skill.gain': 'activity',
|
||||
'fame.change': 'activity',
|
||||
'karma.change': 'activity',
|
||||
'mob.login': 'activity',
|
||||
'mob.logout': 'activity',
|
||||
// boards
|
||||
'champ.update': 'champs',
|
||||
'champ.remove': 'champs',
|
||||
'guild.update': 'guilds',
|
||||
'guild.remove': 'guilds',
|
||||
'guild.join': 'guilds',
|
||||
'city.update': 'governors',
|
||||
'presence.online': 'presence',
|
||||
'region.enter': 'presence',
|
||||
// house.decay is the IDOC signal the public Houses page renders. The full
|
||||
// registry (house.update / house.remove — owner, price, co-owners) stays
|
||||
// off the map deliberately, so it remains admin-only exactly as before.
|
||||
'house.decay': 'houses',
|
||||
// v3
|
||||
'world.ruleset': 'ruleset',
|
||||
'points.board': 'leaderboards',
|
||||
// vendor.listing IS mapped, but the market feature ships with its stream
|
||||
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
|
||||
// inventories would be the site's biggest bandwidth consumer and no page
|
||||
// needs it live. An admin can turn it on.
|
||||
'vendor.listing': 'market',
|
||||
'vendor.listing.remove': 'market',
|
||||
}),
|
||||
)
|
||||
|
||||
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
|
||||
// are unaffected; only the live stream is suppressed.
|
||||
const DEFAULT_STREAM_OFF = new Set(['market'])
|
||||
|
||||
// Back-compat: the set of kinds that reach an anonymous viewer under the default
|
||||
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
|
||||
// this. Derived from the map above rather than hand-maintained, so the two can
|
||||
// no longer drift.
|
||||
const PUBLIC_KINDS = new Set(
|
||||
[...KIND_FEATURE.entries()]
|
||||
.filter(([, feature]) => {
|
||||
if (DEFAULT_STREAM_OFF.has(feature)) return false
|
||||
return FEATURES[feature].audience === 'anonymous'
|
||||
})
|
||||
.map(([kind]) => kind),
|
||||
)
|
||||
|
||||
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
|
||||
|
||||
const CONFIG_TTL_MS = 5000
|
||||
let cache = null
|
||||
let cachedAt = 0
|
||||
|
||||
// Merge a stored row over its compiled default. Unknown feature names in the DB
|
||||
// are ignored (a stale row from a removed feature must not resurrect it), and an
|
||||
// invalid rung falls back to the default rather than failing open.
|
||||
function applyRow(name, row) {
|
||||
const base = FEATURES[name]
|
||||
const audience = isLevel(row?.audience) ? row.audience : base.audience
|
||||
const fields = { ...base.fields }
|
||||
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
|
||||
if (Object.hasOwn(LOCKED_FIELDS, field)) continue // rule 1: not configurable
|
||||
if (isLevel(level)) fields[field] = level
|
||||
}
|
||||
return {
|
||||
enabled: row ? !!row.enabled : true,
|
||||
audience,
|
||||
fields,
|
||||
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
|
||||
}
|
||||
}
|
||||
|
||||
function compileDefaults() {
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
|
||||
return out
|
||||
}
|
||||
|
||||
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
|
||||
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
|
||||
// "what the site did before" rather than to "everything is public".
|
||||
async function getConfig() {
|
||||
const now = Date.now()
|
||||
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
|
||||
try {
|
||||
const rows = await db.listAll()
|
||||
const byName = new Map(rows.map((r) => [r.feature, r]))
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
|
||||
cache = out
|
||||
cachedAt = now
|
||||
} catch (err) {
|
||||
log.error('getConfig; falling back to defaults', err)
|
||||
cache = cache || compileDefaults()
|
||||
cachedAt = now
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
const invalidate = () => {
|
||||
cache = null
|
||||
cachedAt = 0
|
||||
}
|
||||
|
||||
// ── Viewer level ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// anonymous no session
|
||||
// logged_in authenticated, no linked game account
|
||||
// player authenticated with a linked game account
|
||||
// staff admin | moderator — the same set as the existing `modAccess` gate.
|
||||
// `editor` is a CONTENT role with no shard privilege today, so it
|
||||
// resolves by link status like any other member; mapping it to staff
|
||||
// here would silently widen what editors can see.
|
||||
// admin admin
|
||||
//
|
||||
// Staff always satisfy the `player` rung (rank order guarantees it) even without
|
||||
// a linked account, matching the existing rule that /player/* is role-agnostic
|
||||
// self-service.
|
||||
|
||||
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
|
||||
// (or newly relinked) account must not keep the old answer for long. Anonymous,
|
||||
// staff and admin callers short-circuit before this runs, so the lookup only
|
||||
// costs a query on the logged-in-member path.
|
||||
const LINK_TTL_MS = CONFIG_TTL_MS
|
||||
const linkCache = new Map() // userId → { hasLink, at }
|
||||
|
||||
async function hasLinkedAccount(userId) {
|
||||
const hit = linkCache.get(userId)
|
||||
const now = Date.now()
|
||||
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
|
||||
let hasLink = false
|
||||
try {
|
||||
const links = await shardLinks.listForUser(userId)
|
||||
hasLink = Array.isArray(links) && links.length > 0
|
||||
} catch (err) {
|
||||
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
|
||||
}
|
||||
linkCache.set(userId, { hasLink, at: now })
|
||||
return hasLink
|
||||
}
|
||||
|
||||
// Drop a user's cached link status (called when a link is created or removed so
|
||||
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
|
||||
const forgetUser = (userId) => linkCache.delete(userId)
|
||||
|
||||
async function viewerLevel(req) {
|
||||
const viewer = req.user || auth.getUserFromRequest(req)
|
||||
if (!viewer) return 'anonymous'
|
||||
if (viewer.role === 'admin') return 'admin'
|
||||
if (viewer.role === 'moderator') return 'staff'
|
||||
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
|
||||
}
|
||||
|
||||
// ── Enforcement ────────────────────────────────────────────────────────────
|
||||
|
||||
// Route gate. 404 when the feature is disabled (do not leak that it exists);
|
||||
// 403 when it exists but the viewer sits below its audience. Stashes the
|
||||
// resolved level on the request so controllers can project without re-resolving.
|
||||
function requireFeature(name) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
const feature = config[name]
|
||||
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
|
||||
const level = await viewerLevel(req)
|
||||
req.viewerLevel = level
|
||||
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
|
||||
return next()
|
||||
} catch (err) {
|
||||
log.error(`requireFeature(${name})`, err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the fields a viewer at `level` may not see. Applies the locked rules
|
||||
// first (so acct/webId can never survive below admin), then the feature's
|
||||
// configured field rules. Recurses into arrays and nested objects because the
|
||||
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
|
||||
function projectValue(value, rules, level) {
|
||||
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
|
||||
if (!value || typeof value !== 'object') return value
|
||||
const out = {}
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
const required = rules[key]
|
||||
if (required && !meets(level, required)) continue
|
||||
out[key] = projectValue(v, rules, level)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Project a payload for one feature. `level` defaults to admin-equivalent only
|
||||
// when explicitly passed; callers should always pass a resolved level.
|
||||
function projectFeature(name, payload, level, config) {
|
||||
const feature = config?.[name]
|
||||
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
|
||||
return projectValue(payload, rules, level)
|
||||
}
|
||||
|
||||
// Convenience for controllers: resolve config once, project, return.
|
||||
async function project(name, payload, req) {
|
||||
const config = await getConfig()
|
||||
const level = req.viewerLevel || (await viewerLevel(req))
|
||||
return projectFeature(name, payload, level, config)
|
||||
}
|
||||
|
||||
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
|
||||
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
|
||||
function kindVisibleTo(kind, level, config) {
|
||||
if (level === 'admin') return true
|
||||
const name = KIND_FEATURE.get(kind)
|
||||
if (!name) return false // rule 2: unmapped ⇒ admin-only
|
||||
const feature = config?.[name]
|
||||
if (!feature || !feature.enabled || !feature.stream) return false
|
||||
return meets(level, feature.audience)
|
||||
}
|
||||
|
||||
// The features a viewer at `level` can actually see — drives SPA nav so it never
|
||||
// renders a link that would 403.
|
||||
function visibleFeatures(level, config) {
|
||||
return FEATURE_NAMES.filter((name) => {
|
||||
const feature = config[name]
|
||||
return feature.enabled && meets(level, feature.audience)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LADDER,
|
||||
FEATURES,
|
||||
FEATURE_NAMES,
|
||||
LOCKED_FIELDS,
|
||||
KIND_FEATURE,
|
||||
PUBLIC_KINDS,
|
||||
DEFAULT_STREAM_OFF,
|
||||
isLevel,
|
||||
isFeature,
|
||||
rank,
|
||||
meets,
|
||||
getConfig,
|
||||
invalidate,
|
||||
compileDefaults,
|
||||
viewerLevel,
|
||||
forgetUser,
|
||||
requireFeature,
|
||||
projectFeature,
|
||||
project,
|
||||
kindVisibleTo,
|
||||
visibleFeatures,
|
||||
}
|
||||
Reference in New Issue
Block a user