The website's half of protocol 6. Every event-driven write now carries the step's idempotency key, and `uo.broadcast` stops being un-retryable. Phase 9 shipped it answering `retry: false` to everything including a 503 from a shard that was merely restarting, with a comment naming the line that would change when the wire could refuse a repeat. This is that line: it defers to `sidecarFailure`, the same helper its two siblings already used, so the hand-rolled variant that forced every outcome terminal is gone rather than re-tuned. One verb was less idempotent than its own id made it look. Both keyed verbs post under a run-scoped id and a repeat replaces — but `news.add` with `announce: true` makes the criers proclaim the title on every post, so a retry replaced the article silently and proclaimed it again. The key stops the second proclamation. `champ.boss.killed` is mapped to the `champs` feature (rule 2 would otherwise fail it closed to admin), with `damagers` a nested `staff` field rule: the kill is public because a champion falling is what the board is for, the ranked roll of who was strong enough to fell it is not. `uo.champ.boss_killed` is declared as a trigger — which is what makes it usable as an event PHASE CONDITION, since a condition is written over a trigger firing — and it carries `damagerCount`, never a damager name, because a trigger variable reaches mail an operator may address to every subscriber. Its seeded rule is its own group, `champ-boss-killed-v1`: `triggers-v1` is stamped once under a settings guard, so appending a 27th entry would have reached fresh installs and nothing else. It also ships email+inapp and NOT push, and the comment says why — no trigger in this module is also a registered stream, so no engagement rule here can push. That is pre-existing in twenty rules and flagged rather than fixed; this one declines to be the twenty-first. Co-Authored-By: Claude <noreply@anthropic.com>
648 lines
30 KiB
JavaScript
648 lines
30 KiB
JavaScript
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
|
// not exist here. What a test controls instead is the `ctx` core would have
|
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
|
// contract actually promises.
|
|
|
|
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
|
|
// Every DB call this suite would make is monkeypatched.
|
|
|
|
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('../utils/shardVisibility')
|
|
const model = require('../model/shardVisibility/shardVisibility.model')
|
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
|
|
|
|
|
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)
|
|
})
|
|
|
|
// ── Protocol 6: the champion defeat ──────────────────────────────────
|
|
|
|
const KILL = {
|
|
kind: 'champ.boss.killed',
|
|
serial: '0x40012345',
|
|
boss: 'Semidar',
|
|
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
|
|
damagers: [
|
|
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', webId: '7', player: true, damage: 900 },
|
|
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
|
|
],
|
|
}
|
|
|
|
test('the kill is public and its damage table is not', () => {
|
|
const config = visibility.compileDefaults()
|
|
// The whole shape of this addition in one assertion: a champion falling is
|
|
// content the public board is FOR, and a ranked roll of who was strong enough
|
|
// to fell it is a performance record nobody published on purpose.
|
|
assert.equal(visibility.kindVisibleTo('champ.boss.killed', 'anonymous', config), true)
|
|
for (const level of ['anonymous', 'logged_in', 'player']) {
|
|
const out = visibility.projectFeature('champs', KILL, level, config)
|
|
assert.equal(out.boss, 'Semidar', `${level} sees which boss fell`)
|
|
assert.equal('damagers' in out, false, `${level} must not see the damage table`)
|
|
}
|
|
assert.equal(visibility.projectFeature('champs', KILL, 'staff', config).damagers.length, 2)
|
|
})
|
|
|
|
test('the killer rides the frame the way mob.killed already publishes one', () => {
|
|
// Deliberately NOT a configurable field. It is one actor, announced in-game to
|
|
// everyone present, and the same disclosure the public activity feed has made
|
|
// through `mob.killed` since before this framework existed.
|
|
const config = visibility.compileDefaults()
|
|
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
|
|
assert.equal(out.killer.name, 'Aldric')
|
|
assert.equal('acct' in out.killer, false, 'rule 1 still applies inside it')
|
|
})
|
|
|
|
test('an admin who lowers the damager rule still cannot see an account inside it', () => {
|
|
// Rule 1 beats a field rule wherever the two meet, and a damager entry is an
|
|
// actor object like any other. An admin who opens the table to everyone has
|
|
// published character names, which is what they chose; they have not published
|
|
// account names, which is not theirs to choose.
|
|
const config = visibility.compileDefaults()
|
|
config.champs.fields = { ...config.champs.fields, damagers: 'anonymous' }
|
|
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
|
|
assert.equal(out.damagers.length, 2)
|
|
assert.equal(out.damagers[0].name, 'Aldric')
|
|
assert.equal(out.damagers[0].damage, 900)
|
|
assert.equal('acct' in out.damagers[0], false)
|
|
assert.equal('webId' in out.damagers[0], 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('acct and webId are stripped from every member of a guild roster (Protocol 4)', () => {
|
|
// A roster is the first frame where the locked fields appear inside an ARRAY of
|
|
// actors rather than one nested actor. The walker recurses into arrays, so this
|
|
// should already hold — this test is here because it is the difference between a
|
|
// public Guilds page listing character names and one publishing 150 account names.
|
|
const config = visibility.compileDefaults()
|
|
const frame = {
|
|
kind: 'guild.roster',
|
|
id: 7,
|
|
total: 3,
|
|
seq: 0,
|
|
more: false,
|
|
members: [
|
|
{ serial: '0x1', name: 'Ada', acct: 'ada_acct', webId: '11', player: true },
|
|
{ serial: '0x2', name: 'Bo', acct: 'bo_acct', player: true },
|
|
{ serial: '0x3', name: 'Cy', player: true }, // a mobile with no account at all
|
|
],
|
|
}
|
|
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
const out = visibility.projectFeature('guilds', frame, level, config)
|
|
assert.equal(out.members.length, 3, `${level} still sees every member`)
|
|
assert.deepEqual(out.members.map((m) => m.name), ['Ada', 'Bo', 'Cy'])
|
|
for (const m of out.members) {
|
|
assert.equal('acct' in m, false, `${level} must not see a member's acct`)
|
|
assert.equal('webId' in m, false, `${level} must not see a member's webId`)
|
|
}
|
|
}
|
|
|
|
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
|
|
assert.equal(asAdmin.members[0].acct, 'ada_acct')
|
|
assert.equal(asAdmin.members[0].webId, '11')
|
|
})
|
|
|
|
test('guild.roster and guild.leave are mapped, so neither falls closed to admin-only', () => {
|
|
// Rule 2 fails an unmapped kind closed. That is the right default, but for these
|
|
// two it would silently keep the public Guilds page from ever seeing a roster.
|
|
const config = visibility.compileDefaults()
|
|
for (const kind of ['guild.roster', 'guild.leave']) {
|
|
assert.equal(
|
|
visibility.kindVisibleTo(kind, 'anonymous', config), true,
|
|
`${kind} should reach an anonymous viewer under the default guilds config`,
|
|
)
|
|
}
|
|
})
|
|
|
|
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')
|
|
})
|
|
|
|
// ── Protocol 3.0 leaderboards ──────────────────────────────────────────────
|
|
//
|
|
// The leaderboards field rule is spelled `name` because that is the key
|
|
// points.board actually puts a ranked character's name under. v3.md §7.4 calls it
|
|
// "characterName", which describes the meaning — and projectValue matches on the
|
|
// literal key, so a rule under that spelling would have been silently inert. This
|
|
// is the same failure mode §3.6.1 records for the flattened `ownerAcct`, and this
|
|
// test is the guard on it: if someone renames the rule back, an admin who tightens
|
|
// character names would get no enforcement and no error.
|
|
test('a tightened leaderboards name rule actually strips ranked character names', async () => {
|
|
withRows([
|
|
{ feature: 'leaderboards', enabled: true, audience: 'anonymous', stream: true, fieldRules: { name: 'logged_in' } },
|
|
])
|
|
const config = await visibility.getConfig()
|
|
const board = {
|
|
system: 'QueensLoyalty',
|
|
nameString: "Queen's Loyalty",
|
|
top: [{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 }],
|
|
}
|
|
|
|
const anon = visibility.projectFeature('leaderboards', board, 'anonymous', config)
|
|
assert.equal('name' in anon.top[0], false, 'anonymous must not see the ranked name')
|
|
assert.equal(anon.top[0].points, 29500, 'the rest of the entry survives')
|
|
// The BOARD's own display name is a different key and must not be caught by it.
|
|
assert.equal(anon.nameString, "Queen's Loyalty")
|
|
|
|
const member = visibility.projectFeature('leaderboards', board, 'logged_in', config)
|
|
assert.equal(member.top[0].name, 'Darrow')
|
|
})
|
|
|
|
// Default config: boards are public, exactly as v3.md §7 specifies.
|
|
test('leaderboards are anonymous-visible by default, names included', () => {
|
|
const config = visibility.compileDefaults()
|
|
const out = visibility.projectFeature(
|
|
'leaderboards',
|
|
{ top: [{ rank: 1, name: 'Darrow', points: 1 }] },
|
|
'anonymous',
|
|
config,
|
|
)
|
|
assert.equal(out.top[0].name, 'Darrow')
|
|
assert.equal(visibility.kindVisibleTo('points.board', 'anonymous', config), true)
|
|
})
|
|
|
|
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']
|
|
|
|
// v4 adds guild membership. Both ride the existing `guilds` feature, which is
|
|
// already anonymous, so they join the public set — carrying character names and
|
|
// serials, never acct/webId, which the locked-field rules strip by suffix even
|
|
// inside the roster's member array (see the roster test above).
|
|
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
|
|
|
|
// v6 adds the champion defeat. It rides the existing `champs` feature, which is
|
|
// already anonymous, so the KIND is public — while the `damagers` table on it is
|
|
// `staff` by field rule. That split is the point: a shard announces that its
|
|
// champion fell without publishing a roll of who was strong enough to fell it.
|
|
const V6_ADDED_PUBLIC_KINDS = ['champ.boss.killed']
|
|
|
|
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3, v4 and v6 additions', () => {
|
|
assert.deepEqual(
|
|
[...visibility.PUBLIC_KINDS].sort(),
|
|
[
|
|
...PRE_V3_PUBLIC_KINDS,
|
|
...V3_ADDED_PUBLIC_KINDS,
|
|
...V4_ADDED_PUBLIC_KINDS,
|
|
...V6_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')
|
|
})
|
|
|
|
// ── Protocol 5 ─────────────────────────────────────────────────────────────
|
|
//
|
|
// Two new nested field groups and one new kind. All three exist as visibility
|
|
// questions before they exist as features, which is the order this framework's
|
|
// rule 2 is designed to force: a v5 field that nobody classified would either
|
|
// leak (if it fell open) or be silently invisible (if it fell closed and nobody
|
|
// noticed). These tests pin the three answers that were actually chosen.
|
|
|
|
test('a vendor fee block is admin-only, and it is the whole block', async () => {
|
|
const config = await visibility.getConfig()
|
|
// The frame as BridgeMarket emits it: the shop's public parts, plus the money.
|
|
const frame = {
|
|
serial: '0x40001234',
|
|
shopName: "Darrow's Bargains",
|
|
ownerName: 'Darrow',
|
|
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
|
|
fees: {
|
|
exempt: false,
|
|
chargePerPeriod: 148,
|
|
funds: 2960,
|
|
periodsRemaining: 20,
|
|
dismissalAt: '2026-09-20T00:00:00.0000000Z',
|
|
},
|
|
}
|
|
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
const out = visibility.projectFeature('market', frame, level, config)
|
|
assert.equal('fees' in out, false, `fees reached ${level}`)
|
|
// The rest of the shop is untouched — this is a field rule, not a feature one.
|
|
assert.equal(out.shopName, "Darrow's Bargains", `${level} lost the shop name`)
|
|
assert.equal(out.location.region, 'Britain', `${level} lost the location`)
|
|
}
|
|
|
|
const asAdmin = visibility.projectFeature('market', frame, 'admin', config)
|
|
assert.equal(asAdmin.fees.funds, 2960)
|
|
assert.equal(asAdmin.fees.dismissalAt, '2026-09-20T00:00:00.0000000Z')
|
|
})
|
|
|
|
// The nesting is the point, not a style choice: projectValue matches literal JSON
|
|
// keys, so seven flat fee keys would be seven rules an admin has to keep in step
|
|
// and a v6 field would default to visible. One nested key cannot drift.
|
|
test('the fee rule is one nested key, so a new fee field inherits the gate', async () => {
|
|
const config = await visibility.getConfig()
|
|
const frame = { serial: '0x1', fees: { exempt: false, somethingAddedLater: 'secret' } }
|
|
const out = visibility.projectFeature('market', frame, 'staff', config)
|
|
assert.equal('fees' in out, false, 'a field added inside fees must not fall out of the gate')
|
|
})
|
|
|
|
// The opposite call, and it is deliberate: the decay countdown is the public IDOC
|
|
// page's entire content, and a house at IDOC is already announced in game.
|
|
test('the decay schedule is anonymous by default but remains configurable', async () => {
|
|
const frame = {
|
|
serial: '0x1',
|
|
to: 'IDOC',
|
|
name: 'Marble Tower',
|
|
schedule: {
|
|
dynamicDecay: true,
|
|
nextStage: '2026-09-02T04:00:00.0000000Z',
|
|
decayPeriodSec: 432000,
|
|
estimatedCollapse: '2026-09-02T04:00:00.0000000Z',
|
|
},
|
|
}
|
|
|
|
const config = await visibility.getConfig()
|
|
const anon = visibility.projectFeature('houses', frame, 'anonymous', config)
|
|
assert.equal(anon.schedule.estimatedCollapse, '2026-09-02T04:00:00.0000000Z')
|
|
|
|
// A shard that considers a precise collapse time an unfair advantage can raise it,
|
|
// and raising the one nested rule takes the whole schedule with it.
|
|
withRows([
|
|
{
|
|
feature: 'houses',
|
|
enabled: true,
|
|
audience: 'anonymous',
|
|
stream: true,
|
|
fieldRules: { schedule: 'staff' },
|
|
},
|
|
])
|
|
const tightened = await visibility.getConfig()
|
|
assert.equal('schedule' in visibility.projectFeature('houses', frame, 'player', tightened), false)
|
|
assert.equal(
|
|
visibility.projectFeature('houses', frame, 'staff', tightened).schedule.decayPeriodSec,
|
|
432000,
|
|
)
|
|
// Tightening the schedule must not have disturbed the owner rules beside it.
|
|
assert.equal(visibility.projectFeature('houses', frame, 'anonymous', tightened).name, 'Marble Tower')
|
|
})
|
|
|
|
// Rule 2, exercised on the kind it was added for. account.login.result says whether
|
|
// a password was accepted and from which IP; it is admin-only by OMISSION, and the
|
|
// omission is the decision. If someone maps it to a feature to "make it visible",
|
|
// this fails and says why.
|
|
test('account.login.result is admin-only, like the attempt it completes', async () => {
|
|
const config = await visibility.getConfig()
|
|
assert.equal(
|
|
visibility.KIND_FEATURE.has('account.login.result'),
|
|
false,
|
|
'mapping this kind to a feature would let an admin widen an IP + auth verdict below admin',
|
|
)
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
assert.equal(visibility.kindVisibleTo('account.login.result', level, config), false)
|
|
}
|
|
assert.equal(visibility.kindVisibleTo('account.login.result', 'admin', config), true)
|
|
assert.equal(visibility.PUBLIC_KINDS.has('account.login.result'), false)
|
|
})
|