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>
381 lines
17 KiB
JavaScript
381 lines
17 KiB
JavaScript
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
|
|
// Every DB call this suite would make is monkeypatched.
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const { test, after, afterEach, beforeEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
// Unit-test the visibility framework's INVARIANTS — the rules that make it a
|
|
// security boundary rather than a convenience filter (docs/link/v3.md §3):
|
|
//
|
|
// 1. acct / webId are admin-only ALWAYS and cannot be configured down.
|
|
// 2. A kind absent from KIND_FEATURE reaches nobody below admin (fail closed).
|
|
// 3. The compiled defaults reproduce pre-v3 behavior, so installing this
|
|
// module changes nothing until an admin edits the config.
|
|
// 4. The ladder is ordered and each rung implies the ones below it.
|
|
|
|
const visibility = require('../src/utils/shardVisibility')
|
|
const model = require('../src/model/shardVisibility/shardVisibility.model')
|
|
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
const originals = { listAll: model.listAll, listForUser: shardLinks.listForUser }
|
|
|
|
// Default both DB reads to "no rows" so a test that doesn't care never blocks on
|
|
// the dead pool (each such call would otherwise burn the 10s acquire timeout).
|
|
// Tests that exercise stored config or a DB failure override these.
|
|
beforeEach(() => {
|
|
model.listAll = async () => []
|
|
shardLinks.listForUser = async () => []
|
|
visibility.invalidate()
|
|
})
|
|
|
|
afterEach(() => {
|
|
model.listAll = originals.listAll
|
|
shardLinks.listForUser = originals.listForUser
|
|
visibility.invalidate()
|
|
})
|
|
|
|
// Stub the stored config; the framework merges rows over compiled defaults.
|
|
function withRows(rows) {
|
|
model.listAll = async () => rows
|
|
visibility.invalidate()
|
|
}
|
|
|
|
// ── The ladder ─────────────────────────────────────────────────────────────
|
|
|
|
test('ladder is ordered and each rung implies the ones below it', () => {
|
|
assert.deepEqual(visibility.LADDER, ['anonymous', 'logged_in', 'player', 'staff', 'admin'])
|
|
for (let i = 0; i < visibility.LADDER.length; i += 1) {
|
|
for (let j = 0; j <= i; j += 1) {
|
|
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), true)
|
|
}
|
|
for (let j = i + 1; j < visibility.LADDER.length; j += 1) {
|
|
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), false)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('an unknown rung always loses, on BOTH sides of the comparison', () => {
|
|
assert.equal(visibility.isLevel('not-a-rung'), false)
|
|
|
|
// An unknown REQUIREMENT is satisfied by nobody below admin...
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
assert.equal(visibility.meets(level, 'not-a-rung'), false, `${level} vs unknown requirement`)
|
|
}
|
|
assert.equal(visibility.meets('admin', 'not-a-rung'), true)
|
|
|
|
// ...and an unknown VIEWER level grants nothing. This is the direction that
|
|
// matters: a shared admin fallback would have made a garbage viewer level
|
|
// pass every gate.
|
|
for (const required of visibility.LADDER.slice(1)) {
|
|
assert.equal(visibility.meets('not-a-rung', required), false, `unknown viewer vs ${required}`)
|
|
assert.equal(visibility.meets(undefined, required), false, `undefined viewer vs ${required}`)
|
|
assert.equal(visibility.meets(null, required), false, `null viewer vs ${required}`)
|
|
}
|
|
})
|
|
|
|
test('an unknown viewer level cannot see a gated kind or a locked field', async () => {
|
|
const config = await visibility.getConfig()
|
|
assert.equal(visibility.kindVisibleTo('champ.update', 'not-a-rung', config), true) // anonymous-tier: fine
|
|
assert.equal(visibility.kindVisibleTo('audit.command', 'not-a-rung', config), false)
|
|
const out = visibility.projectFeature(
|
|
'guilds',
|
|
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
|
|
'not-a-rung',
|
|
config,
|
|
)
|
|
assert.equal('acct' in out.leader, false)
|
|
assert.equal('webId' in out.leader, false)
|
|
})
|
|
|
|
// ── Rule 1: locked fields ──────────────────────────────────────────────────
|
|
|
|
test('acct and webId are stripped below admin regardless of feature config', () => {
|
|
const config = visibility.compileDefaults()
|
|
const frame = {
|
|
kind: 'guild.update',
|
|
name: 'The Nameless',
|
|
leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
|
|
}
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
const out = visibility.projectFeature('guilds', frame, level, config)
|
|
assert.equal(out.leader.name, 'Darrow', `${level} keeps the character name`)
|
|
assert.equal(out.leader.serial, '0x1A2B')
|
|
assert.equal('acct' in out.leader, false, `${level} must not see acct`)
|
|
assert.equal('webId' in out.leader, false, `${level} must not see webId`)
|
|
}
|
|
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
|
|
assert.equal(asAdmin.leader.acct, 'whitlocktech')
|
|
assert.equal(asAdmin.leader.webId, '42')
|
|
})
|
|
|
|
test('a stored rule trying to loosen a locked field is ignored', async () => {
|
|
withRows([
|
|
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
|
|
])
|
|
const config = await visibility.getConfig()
|
|
const out = visibility.projectFeature(
|
|
'guilds',
|
|
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
|
|
'anonymous',
|
|
config,
|
|
)
|
|
assert.equal('acct' in out.leader, false)
|
|
assert.equal('webId' in out.leader, false)
|
|
})
|
|
|
|
test('rule 1 matches FLATTENED spellings, not just the two canonical keys', () => {
|
|
const config = visibility.compileDefaults()
|
|
// shapeHouse/shapeGuild flatten the actor into `<role>Acct` / `<role>WebId`.
|
|
// An exact-key check missed every one of these, which is how GET
|
|
// /public/shard/idoc served the owner's game account to anonymous callers.
|
|
const row = {
|
|
serial: '0x1',
|
|
name: 'Marble Tower',
|
|
ownerAcct: 'cadmus_acct',
|
|
leaderWebId: 42,
|
|
governorAcct: 'blackthorn_acct',
|
|
}
|
|
const out = visibility.projectFeature('houses', row, 'staff', config)
|
|
assert.equal('ownerAcct' in out, false, 'staff must not see a flattened acct')
|
|
assert.equal('leaderWebId' in out, false)
|
|
assert.equal('governorAcct' in out, false)
|
|
assert.equal(out.name, 'Marble Tower', 'ordinary fields are untouched')
|
|
|
|
const asAdmin = visibility.projectFeature('houses', row, 'admin', config)
|
|
assert.equal(asAdmin.ownerAcct, 'cadmus_acct')
|
|
})
|
|
|
|
test('isLockedField locks acct/webId and their suffixed forms, and nothing else', () => {
|
|
for (const key of ['acct', 'webId', 'WEBID', 'ownerAcct', 'leaderWebId', 'governorAcct']) {
|
|
assert.equal(visibility.isLockedField(key), true, `${key} must be locked`)
|
|
}
|
|
// Must not over-match: these are ordinary public fields.
|
|
for (const key of ['name', 'serial', 'ownerName', 'price', 'contact', 'region']) {
|
|
assert.equal(visibility.isLockedField(key), false, `${key} must stay configurable`)
|
|
}
|
|
})
|
|
|
|
test('a Date survives projection instead of collapsing to {}', () => {
|
|
const config = visibility.compileDefaults()
|
|
const when = new Date('2026-07-06T19:32:29.000Z')
|
|
// The DB-backed read models carry real Date columns; rebuilding one key-by-key
|
|
// yields `{}` because a Date has no enumerable own properties.
|
|
const out = visibility.projectFeature('houses', { name: 'Keep', updatedAt: when }, 'anonymous', config)
|
|
assert.ok(out.updatedAt instanceof Date)
|
|
assert.equal(out.updatedAt.toISOString(), when.toISOString())
|
|
})
|
|
|
|
test('visibleKinds tracks live config and stays independent of the stream flag', async () => {
|
|
const config = visibility.compileDefaults()
|
|
assert.ok(visibleIncludes(config, 'anonymous', 'guild.update'))
|
|
// `stream: false` suppresses SSE fan-out only — the stored history stays readable.
|
|
assert.ok(visibleIncludes(config, 'anonymous', 'vendor.listing'))
|
|
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
|
|
|
|
const gated = { ...config, guilds: { ...config.guilds, audience: 'staff' } }
|
|
assert.equal(visibleIncludes(gated, 'anonymous', 'guild.update'), false)
|
|
assert.ok(visibleIncludes(gated, 'staff', 'guild.update'))
|
|
|
|
const off = { ...config, guilds: { ...config.guilds, enabled: false } }
|
|
assert.equal(visibleIncludes(off, 'admin', 'guild.update'), false)
|
|
// Rule 2 still holds: an unmapped kind is in nobody's readable set.
|
|
assert.equal(visibleIncludes(config, 'admin', 'staff.audit'), false)
|
|
})
|
|
|
|
const visibleIncludes = (config, level, kind) => visibility.visibleKinds(level, config).includes(kind)
|
|
|
|
test('projection recurses into arrays and nested actors', () => {
|
|
const config = visibility.compileDefaults()
|
|
const rows = [
|
|
{ city: 'Britain', governor: { name: 'A', acct: 'a', webId: '1' } },
|
|
{ city: 'Vesper', governor: { name: 'B', acct: 'b' } },
|
|
]
|
|
const out = visibility.projectFeature('governors', rows, 'anonymous', config)
|
|
assert.equal(out.length, 2)
|
|
assert.equal(out[0].governor.name, 'A')
|
|
assert.equal('acct' in out[0].governor, false)
|
|
assert.equal('webId' in out[0].governor, false)
|
|
assert.equal('acct' in out[1].governor, false)
|
|
})
|
|
|
|
// ── Rule 2: fail closed on unmapped kinds ──────────────────────────────────
|
|
|
|
test('an unmapped kind reaches nobody below admin', async () => {
|
|
const config = await visibility.getConfig()
|
|
for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'gold.change', 'made.up.kind']) {
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
assert.equal(visibility.kindVisibleTo(kind, level, config), false, `${kind} @ ${level}`)
|
|
}
|
|
assert.equal(visibility.kindVisibleTo(kind, 'admin', config), true, `${kind} @ admin`)
|
|
}
|
|
})
|
|
|
|
test('the full house registry stays off the kind map (owner/price are staff-only)', () => {
|
|
assert.equal(visibility.KIND_FEATURE.has('house.update'), false)
|
|
assert.equal(visibility.KIND_FEATURE.has('house.remove'), false)
|
|
// house.decay — the IDOC signal the public page renders — IS mapped.
|
|
assert.equal(visibility.KIND_FEATURE.get('house.decay'), 'houses')
|
|
})
|
|
|
|
test('vendor.sale is not public (sales are owner-private)', async () => {
|
|
const config = await visibility.getConfig()
|
|
assert.equal(visibility.kindVisibleTo('vendor.sale', 'anonymous', config), false)
|
|
assert.equal(visibility.PUBLIC_KINDS.has('vendor.sale'), false)
|
|
})
|
|
|
|
// ── Rule 3: defaults reproduce pre-v3 behavior ─────────────────────────────
|
|
|
|
// The exact allowlist that shipped in shardBroadcast.js before v3. If a change
|
|
// makes the derived PUBLIC_KINDS differ from this, it is a deliberate widening
|
|
// or narrowing of what anonymous visitors see and must be reviewed as such.
|
|
const PRE_V3_PUBLIC_KINDS = [
|
|
'player.death',
|
|
'player.murdered',
|
|
'mob.killed',
|
|
'house.decay',
|
|
'quest.complete',
|
|
'skill.gain',
|
|
'fame.change',
|
|
'karma.change',
|
|
'mob.login',
|
|
'mob.logout',
|
|
'economy.supply',
|
|
'server.hello',
|
|
'server.shutdown',
|
|
'server.crashed',
|
|
'champ.update',
|
|
'champ.remove',
|
|
'guild.update',
|
|
'guild.remove',
|
|
'guild.join',
|
|
'city.update',
|
|
'presence.online',
|
|
'region.enter',
|
|
]
|
|
|
|
// The kinds v3 deliberately ADDS to the anonymous set. vendor.listing is
|
|
// pointedly not among them (its feature ships with stream off).
|
|
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
|
|
|
|
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
|
|
assert.deepEqual(
|
|
[...visibility.PUBLIC_KINDS].sort(),
|
|
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
|
|
)
|
|
})
|
|
|
|
test('no pre-v3 public kind was dropped', () => {
|
|
for (const kind of PRE_V3_PUBLIC_KINDS) {
|
|
assert.equal(visibility.PUBLIC_KINDS.has(kind), true, `${kind} fell out of the public set`)
|
|
}
|
|
})
|
|
|
|
test('the market stream is off by default but its REST feature is not', async () => {
|
|
const config = await visibility.getConfig()
|
|
assert.equal(config.market.enabled, true)
|
|
assert.equal(config.market.audience, 'anonymous')
|
|
assert.equal(config.market.stream, false)
|
|
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
|
|
assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
|
|
})
|
|
|
|
test('presence location defaults to staff, matching the old admin/moderator gate', async () => {
|
|
const config = await visibility.getConfig()
|
|
assert.equal(config.presence.fields.location, 'staff')
|
|
assert.equal(visibility.meets('player', 'staff'), false)
|
|
assert.equal(visibility.meets('staff', 'staff'), true)
|
|
})
|
|
|
|
test('every mapped kind names a real feature', () => {
|
|
for (const [kind, feature] of visibility.KIND_FEATURE) {
|
|
assert.equal(visibility.isFeature(feature), true, `${kind} → unknown feature ${feature}`)
|
|
}
|
|
})
|
|
|
|
// ── Config merge ───────────────────────────────────────────────────────────
|
|
|
|
test('a disabled feature is invisible to everyone below admin', async () => {
|
|
withRows([{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} }])
|
|
const config = await visibility.getConfig()
|
|
assert.equal(config.champs.enabled, false)
|
|
assert.equal(visibility.kindVisibleTo('champ.update', 'anonymous', config), false)
|
|
assert.equal(visibility.kindVisibleTo('champ.update', 'staff', config), false)
|
|
assert.equal(visibility.visibleFeatures('staff', config).includes('champs'), false)
|
|
})
|
|
|
|
test('raising a feature audience gates the lower rungs out', async () => {
|
|
withRows([{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} }])
|
|
const config = await visibility.getConfig()
|
|
assert.equal(visibility.kindVisibleTo('guild.update', 'anonymous', config), false)
|
|
assert.equal(visibility.kindVisibleTo('guild.update', 'logged_in', config), false)
|
|
assert.equal(visibility.kindVisibleTo('guild.update', 'player', config), true)
|
|
assert.equal(visibility.kindVisibleTo('guild.update', 'staff', config), true)
|
|
})
|
|
|
|
test('an unknown stored feature name is ignored, not resurrected', async () => {
|
|
withRows([{ feature: 'sekrit', enabled: true, audience: 'anonymous', stream: true, fieldRules: {} }])
|
|
const config = await visibility.getConfig()
|
|
assert.equal('sekrit' in config, false)
|
|
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
|
|
})
|
|
|
|
test('an invalid stored rung falls back to the default rather than failing open', async () => {
|
|
withRows([{ feature: 'houses', enabled: true, audience: 'nonsense', stream: true, fieldRules: { owner: 'nonsense' } }])
|
|
const config = await visibility.getConfig()
|
|
assert.equal(config.houses.audience, 'anonymous') // the compiled default
|
|
assert.equal(config.houses.fields.owner, 'staff') // the compiled default
|
|
})
|
|
|
|
test('a DB failure degrades to compiled defaults, not to everything-public', async () => {
|
|
model.listAll = async () => {
|
|
throw new Error('db down')
|
|
}
|
|
visibility.invalidate()
|
|
const config = await visibility.getConfig()
|
|
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
|
|
assert.equal(config.presence.fields.location, 'staff')
|
|
assert.equal(visibility.kindVisibleTo('audit.command', 'anonymous', config), false)
|
|
})
|
|
|
|
// ── Viewer level ───────────────────────────────────────────────────────────
|
|
|
|
test('viewerLevel resolves the ladder from role and link status', async () => {
|
|
shardLinks.listForUser = async () => []
|
|
assert.equal(await visibility.viewerLevel({}), 'anonymous')
|
|
|
|
visibility.forgetUser(1)
|
|
assert.equal(await visibility.viewerLevel({ user: { id: 1, role: 'admin' } }), 'admin')
|
|
visibility.forgetUser(2)
|
|
assert.equal(await visibility.viewerLevel({ user: { id: 2, role: 'moderator' } }), 'staff')
|
|
|
|
// A member with no linked game account sits at logged_in...
|
|
visibility.forgetUser(3)
|
|
assert.equal(await visibility.viewerLevel({ user: { id: 3, role: 'player' } }), 'logged_in')
|
|
|
|
// ...and reaches `player` once a link exists.
|
|
shardLinks.listForUser = async () => [{ account: 'whitlocktech' }]
|
|
visibility.forgetUser(4)
|
|
assert.equal(await visibility.viewerLevel({ user: { id: 4, role: 'player' } }), 'player')
|
|
})
|
|
|
|
test('editor is a content role and gets no shard privilege', async () => {
|
|
// Mapping editor to `staff` here would silently widen what editors can see;
|
|
// today's modAccess gate is admin|moderator only.
|
|
shardLinks.listForUser = async () => []
|
|
visibility.forgetUser(5)
|
|
assert.equal(await visibility.viewerLevel({ user: { id: 5, role: 'editor' } }), 'logged_in')
|
|
})
|
|
|
|
test('a link lookup failure downgrades rather than escalating', async () => {
|
|
shardLinks.listForUser = async () => {
|
|
throw new Error('db down')
|
|
}
|
|
visibility.forgetUser(6)
|
|
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
|
|
})
|