Protocol 3.0 Part A follow-up, found by the live five-rung smoke test.
Part A implemented the visibility framework correctly on the SSE path
and on /guilds + /governors, but the remaining public REST reads never
called into it. The result was that one event was projected live and
served verbatim from history:
* GET /public/shard/feed returned the stored payload as-is, so
actor.acct and actor.webId were readable ANONYMOUSLY for every
logged kind - player.death, player.murdered, mob.killed,
quest.complete, skill.gain, fame/karma.change, mob.login/logout,
guild.join. Broader than the guild-leader leak Part A set out to
close, since it covers every player rather than board holders.
* GET /public/shard/idoc returned ownerAcct - the house owner's game
account - to anonymous callers.
* The `houses` field rules (owner/price -> staff) were dead config:
neither getIdoc nor getHouses projected, so an admin could set them
in the panel and nothing happened.
* /feed filtered on PUBLIC_KINDS, a module-load constant derived from
the compiled DEFAULTS, so live audience changes did not reach it.
With `guilds` moved to staff, /guilds 403'd while /feed happily
served guild.join to anonymous.
Four fixes, all at the root rather than per-route:
1. Rule 1 now matches a field's MEANING, not one spelling. The wire
nests actors (leader.acct) but the read models flatten them
(shapeHouse -> ownerAcct, shapeGuild -> leaderWebId), and an
exact-key check missed every flattened one. isLockedField() locks a
key that is or ends in acct/webId, case-insensitively, so it fails
closed for shapes not yet written. The admin PUT rejects those
spellings too - `ownerAcct` is no longer configurable.
2. visibleKinds(level, config) resolves readable kinds from the LIVE
config; getFeed uses it and projects each row against its own kind's
feature. Deliberately independent of the `stream` flag, which governs
SSE fan-out only - so market history stays readable with its firehose
off. This makes the set a superset of PUBLIC_KINDS by exactly the two
vendor kinds.
3. getIdoc/getHouses/getChamps/getPresence project, so every shard
surface honours the same config.
4. shardEvents.db.list treats an EMPTY kinds array as "serve nothing".
It previously fell through to the unfiltered query, so a fully-gated
config would have dumped the whole event log, staff audit included.
Also fixes a bug introduced while wiring this up: projectValue recursed
into any object, so a Date column came back as {}. It now walks arrays
and plain objects only. The unit tests used JSON fixtures and could not
have caught it - the live /idoc read did.
Verified live against MariaDB + a stub sidecar, all five rungs: 13
routes x 5 rungs, defaults reproducing pre-v3 access exactly, zero
acct/webId below admin on any read, unmapped kinds (staff.command,
cheat.detect, login.attempt) reaching only admin on SSE, and audience /
enabled / stream changes taking effect live on an already-open stream.
Tests: 487 server (+9). Swagger regenerated; route manifest unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
415 lines
18 KiB
JavaScript
415 lines
18 KiB
JavaScript
// ── 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' }
|
|
|
|
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
|
|
// frames nest actors (`leader.acct`), but several read models flatten them
|
|
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
|
|
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
|
|
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
|
|
// anonymous callers while the same account name was correctly stripped from the
|
|
// live `house.decay` frame.
|
|
//
|
|
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
|
|
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
|
|
// fail closed for shapes nobody has written yet.
|
|
const LOCKED_SUFFIXES = ['acct', 'webid']
|
|
const isLockedField = (key) => {
|
|
const k = String(key).toLowerCase()
|
|
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
|
|
}
|
|
|
|
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.
|
|
// `owner` is the actor object on the house.decay/house.update frames;
|
|
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
|
|
// REST read models. Both are listed so one rule covers the wire and the read
|
|
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
|
|
houses: {
|
|
audience: 'anonymous',
|
|
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: '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 (isLockedField(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).
|
|
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
|
|
// instance is a value, not a bag of fields: rebuilding one key-by-key would
|
|
// return `{}` (a Date has no enumerable own properties), which is how the DB-
|
|
// backed read models — whose rows carry real Date columns — differ from the
|
|
// pure-JSON wire frames the projection was first written against.
|
|
const isPlainObject = (v) => {
|
|
if (v === null || typeof v !== 'object') return false
|
|
const proto = Object.getPrototypeOf(v)
|
|
return proto === Object.prototype || proto === null
|
|
}
|
|
|
|
function projectValue(value, rules, level) {
|
|
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
|
|
if (!isPlainObject(value)) return value
|
|
const out = {}
|
|
for (const [key, v] of Object.entries(value)) {
|
|
// Locked fields are checked by meaning first, so no configured rule (and no
|
|
// flattened spelling) can widen them past `admin`.
|
|
const required = isLockedField(key) ? 'admin' : 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 event kinds a viewer at `level` may read under the CURRENT config. This is
|
|
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
|
|
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
|
|
// this kind, given what the admin has configured?".
|
|
//
|
|
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
|
|
// feature whose live firehose is off (market) is still readable from the stored
|
|
// history. Unmapped kinds are absent by construction (rule 2).
|
|
function visibleKinds(level, config) {
|
|
return [...KIND_FEATURE.entries()]
|
|
.filter(([, name]) => {
|
|
const feature = config?.[name]
|
|
return !!feature && feature.enabled && meets(level, feature.audience)
|
|
})
|
|
.map(([kind]) => kind)
|
|
}
|
|
|
|
// 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,
|
|
isLockedField,
|
|
rank,
|
|
meets,
|
|
getConfig,
|
|
invalidate,
|
|
compileDefaults,
|
|
viewerLevel,
|
|
forgetUser,
|
|
requireFeature,
|
|
projectFeature,
|
|
project,
|
|
kindVisibleTo,
|
|
visibleKinds,
|
|
visibleFeatures,
|
|
}
|