The website half of the protocol-5 bump. Engagement Phase 10.
Schema — twelve columns and two indexes.
shard_houses gains next_stage, estimated_collapse, decay_period_sec and
dynamic_decay. estimated_collapse is nullable and stays null far more often than
not, deliberately: under dynamic decay ServUO draws each stage at random on entry,
so collapse is knowable only at IDOC. A null means "not knowable", never "not yet
read".
shard_vendors gains owner_acct plus seven fee columns and an index on dismissal_at.
owner_acct is the structural one — the table has carried owner_name since protocol
3, but a character name joins to nothing, and only the game account reaches
shard_account_links. Until now a vendor row named an owner the site could not
resolve to a person. dismissal_at + owner_acct are what let Phase 11's
uo.vendor.expiring find "vendors about to be dismissed" and turn each into a
person, without scanning every shop.
Ingest.
Both new field groups arrive NESTED and are flattened into columns on the way in,
then re-nested on the way out — the same trick shardMarket already uses for
`location`. That is not stylistic: the visibility projection matches literal JSON
keys, so the stored read model and the live wire frame have to spell a group
identically or one admin rule covers only one of the two paths. It also means a
field added inside a group later inherits the group's gate instead of defaulting to
visible; there is a test that adds an imaginary future fee field and asserts exactly
that.
Two write-back asymmetries, both load-bearing:
* ownerName is written ONLY when the frame carries one. house.update also writes
that column, from a different sweep, and a pre-v5 overlay's house.decay carries
no ownerName at all — coalescing to null would let every decay transition erase
a name the registry had already resolved.
* The schedule and fee columns are written UNCONDITIONALLY, including as nulls. A
schedule is a claim about the future and goes stale on its own: roll a shard
back to a pre-v5 overlay, or let a house leave IDOC, and the right stored value
is nothing. A dismissal date nobody is maintaining is worse than none.
dismissalAt is taken from the shard rather than recomputed. The shard resolved it
against ServUO's two vendor systems, whose charge, funds and pay interval all
differ; re-deriving it here would be a second implementation of PlayerVendor's own
rule.
Visibility — three classifications, each chosen rather than inherited.
* house.decay's `schedule` defaults to `anonymous`. The countdown IS the public
IDOC page's content and a house at IDOC is already announced in game. Listed
anyway so a shard that considers a precise collapse time an unfair advantage can
raise it — and one nested rule takes the whole schedule with it.
* vendor.listing's `fees` defaults to `admin`, the only default in the market
feature that does not reproduce prior behaviour, because there is no prior
behaviour to reproduce. Shop name, owner and location are already visible to any
player through the in-game Vendor Search gump, which is the argument for
publishing them. Held gold, daily charge and dismissal date are visible to the
OWNER only, on that vendor's own gump. Publishing them anonymously would be a
new disclosure and a targeting aid — which shops are about to be abandoned, and
how much coin is in each.
* account.login.result is admin-only BY OMISSION. KIND_FEATURE is the map of kinds
an admin may widen, and there is no rung below admin that an IP plus an auth
verdict belongs on. The omission is the decision, and a test says so by name.
owner_acct needs no rule: rule 1 locks it by suffix. And the new columns are in no
REST read model's column list — they exist for Phase 11's server-side trigger and
reach no client at all.
The pin, and the protocol-4 bug seen from the other side.
Both declaration sites go to 5 (the model constant and schema.sql's CREATE default),
plus the one-shot migration, guarded `protocol < 5` so an install that missed an
earlier step is carried the whole way.
The schema test used to assert `DEFAULT 4` at each site. That is exactly how
protocol 4 shipped with the emitters moved and one site left behind: every site
agreed with itself and the test passed. It now reads DEFAULT_PROTOCOL from the
model, so the assertion is "the declarations AGREE", and the one-shot migration
test is written once against the current version instead of being hand-copied per
bump.
470 tests pass, 16 new. Verified end to end on the live rig against a real ServUO
and the release sidecar.
Docs: RunicGateway/docs link/v5.md.
Co-Authored-By: Claude <noreply@anthropic.com>
479 lines
21 KiB
JavaScript
479 lines
21 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('../core')
|
|
const log = require('../core').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.
|
|
// Protocol 5 adds `schedule` — when the next stage lands and, where ServUO can
|
|
// actually know it, when the house collapses. It defaults to `anonymous` because
|
|
// that is what the public IDOC page is FOR: the countdown is the content, and a
|
|
// house at IDOC is already announced in game. It is listed rather than left
|
|
// unconfigurable so a shard that considers a precise collapse time an unfair
|
|
// advantage can raise it, and it is one NESTED key so raising it hides the whole
|
|
// schedule rather than three of its four parts.
|
|
houses: {
|
|
audience: 'anonymous',
|
|
fields: {
|
|
owner: 'staff',
|
|
ownerName: 'staff',
|
|
ownerSerial: 'staff',
|
|
price: 'staff',
|
|
schedule: 'anonymous',
|
|
},
|
|
},
|
|
// /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.
|
|
// Protocol 5 adds `fees`, and it does NOT follow the rest of this feature's
|
|
// defaults. The shop name, the owner and the location are already visible to any
|
|
// player through the stock in-game Vendor Search gump, which is the whole argument
|
|
// for publishing them. A vendor's held gold, daily charge and dismissal date are
|
|
// not: in game they are visible to the OWNER, on that vendor's own gump. Publishing
|
|
// them anonymously would be a genuinely new disclosure and a targeting aid — it
|
|
// says which shops are about to be abandoned and how much coin is sitting in each.
|
|
// So it defaults to `admin`, the only default here that does not reproduce prior
|
|
// behaviour, because there is no prior behaviour to reproduce.
|
|
//
|
|
// Nested for the same reason `location` is: one rule covers all seven parts.
|
|
market: {
|
|
audience: 'anonymous',
|
|
fields: {
|
|
ownerName: 'anonymous',
|
|
ownerSerial: 'anonymous',
|
|
location: 'anonymous',
|
|
fees: 'admin',
|
|
},
|
|
},
|
|
}
|
|
|
|
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',
|
|
// Protocol 4. Both carry actor data — a roster is an array of actor objects
|
|
// and guild.leave names a serial — so they ride the same `guilds` feature and
|
|
// the same locked-field rules: `acct`/`webId` inside a roster member are
|
|
// stripped below admin by suffix, exactly as `guild.leader.acct` already is.
|
|
// Without these two lines rule 2 would fail them closed to admin-only.
|
|
'guild.roster': 'guilds',
|
|
'guild.leave': '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',
|
|
// Protocol 5's `account.login.result` is deliberately NOT here, and the omission
|
|
// is the decision rather than an oversight. Rule 2 fails an unmapped kind closed
|
|
// to admin-only, which is the right answer for a frame that carries an IP address
|
|
// and says whether a password was accepted — the same reasoning that keeps
|
|
// house.update and account.login.attempt off this map. Adding it would mean
|
|
// choosing a feature an admin could then widen, and there is no rung below admin
|
|
// this frame belongs on.
|
|
// 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,
|
|
}
|