Files
website/server/src/utils/shardVisibility.js
wtclaude 8771a1cf6c feat(shard): the player-vendor marketplace
Protocol 3.0 §8, the website half. Ingests vendor.listing / vendor.listing.remove
into shard_vendors + shard_vendor_items, serves a searchable public API over
them, and ships /site/market and /site/market/vendors/:serial.

Three things the pages have to say out loud, all consequences of how the data is
gathered:

- The prices are NOT live. The shard sweeps vendors round-robin, so a shop can be
  a full cycle behind. The banner is driven by the OLDEST vendor row, not the
  newest — the one stale shop is the one that wastes somebody's trip.
- A shop can be truncated. `total` exceeding `count` means the shop holds more
  than the shard publishes per frame; the vendor page says "showing 250 of 3,104"
  rather than presenting a partial shop as complete.
- An item may have no name. On a shard with no cliloc table the honest render is
  the item id, never an invented label.

## The pre-wired visibility rules, re-checked

Part A pre-wired market.ownerName and market.location before the frame existed,
and the sibling rule it pre-wired for leaderboards (`characterName`) turned out
to be INERT because projectValue matches literal JSON keys. Both market rules
were checked against the real frame this time:

- `ownerName` is a real key. Kept.
- `location` is a real key ONLY because the frame nests it. Flat map/x/y/region
  would have made the rule match nothing — the same failure, one part later. It
  is nested on the wire and on the read model so one rule hides the facet, the
  coordinates, the region and the house together; five flat keys would be five
  rules that drift apart.
- `ownerSerial` was ADDED. An admin who hides the owner's name and leaves a
  serial that the leaderboards and guild boards resolve back to that same name
  has not hidden anything.

Tests assert all three bite, on the stored read model AND on the raw frame —
the market's SSE stream is off by default but an admin can turn it on, and a rule
that worked on only one path is exactly the leak §3.6.1 records.

## Notable

- **No payload column on shard_vendors**, unlike shard_points_boards next door.
  The board's top-N is a fixed-size list read whole; here the items ARE the
  searchable rows, so they are normalized and nothing is left worth duplicating.
- **display_name is denormalized at ingest** (literal name preferred over the
  cliloc — a player set it, so it is more specific). Resolving at query time
  would put the cliloc table on the hot path and make search-by-name impossible.
  Because the shard's diff sweep will not re-send an unchanged shop just because
  the site learned what its items are called, a cliloc import now triggers a bulk
  re-resolution — 50 ms per thousand rows, never throws.
- **updated_at is written explicitly** on every upsert. MariaDB does not fire ON
  UPDATE CURRENT_TIMESTAMP when every column is written back unchanged, and a
  shop re-published identically is still freshly confirmed — without this the
  staleness banner would age a perfectly current shop forever.
- **LIKE wildcards in `q` are escaped.** `%` and `_` are LIKE metacharacters, not
  SQL ones, so parameterization does not neutralize them: `?q=%` would otherwise
  match every listing on the shard.
- **Rate-limited** (60/min/IP), the only limited public read. Every other public
  GET is an indexed lookup of bounded size; this is a LIKE scan plus a COUNT over
  the largest shard_* table, anonymous by default.
- Reconnect backfill pages /market, bounded by MARKET_SNAPSHOT_MAX = 5000 and
  stopping on a short page as well as on `total`, so a concurrent sweep shrinking
  the index cannot spin the walk.

## How it was tested

673 server tests pass (27 new). Client builds clean; swagger-output.json,
routes.manifest.json and routes.guards.json regenerated.

Verified full-stack against the live MariaDB and a real shard, not only units:

- 27 real vendors / 1,040 listings swept off the ServUO tree, through the Rust
  sidecar, into the site — names resolving through the cliloc table ("longsword",
  "katana"), real facets and regions in the filters.
- `?q=sword` 682, `?q=%` and `?q=_` **0** (the escape), map/region/price/sort
  filters, paging, and the vendor detail route.
- Visibility live: fields gated to staff vanish for an anonymous caller while
  shopName and price survive; audience=player 403s; enabled=0 404s; and
  /shard/features correctly drops `market` so the nav hides it.
- Re-publishing a shop smaller leaves no orphan items; an identical re-publish
  moves updated_at.
- The limiter fires (38x200 then 32x429 on a 70-request burst).

Not covered by an automated test: the two React pages are presentational and this
repo's client suite covers pure-logic modules only. They were driven against the
live API above, but not rendered in a DOM harness.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:50 -05:00

436 lines
19 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: {} },
// `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,
}