// ── 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: {} }, // `name` is the ranked character's name inside points.board's `top` entries, and // it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it // ("characterName"). projectValue matches on the literal JSON key, so a rule // named for the field's meaning rather than its key silently does nothing — the // same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a // leaderboards payload `name` can only be a character name: the board's own // display name arrives as `nameString`/`nameNumber`. leaderboards: { audience: 'anonymous', fields: { name: '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. // // `ownerName` and `location` were pre-wired here by Part A, before the frame // existed; both were re-checked against the real `vendor.listing` and both are // genuine keys on it (unlike leaderboards' `characterName`, which was inert). // `location` is a NESTED object on the wire and on the read model precisely so // that one rule hides map, coordinates, region and house together — five flat // keys would be five rules that drift apart. // // `ownerSerial` is listed alongside `ownerName` for the same reason `houses` // lists both: an admin who hides the owner's name and is left with a serial // that every other board resolves back to that name has not hidden anything. market: { audience: 'anonymous', fields: { ownerName: 'anonymous', ownerSerial: '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, }