Files
Module-uo/server/test/shardVisibility.test.js
wtclaude 6a276a7ec3
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 8m46s
feat(shard): ingest protocol 5 — decay schedule, vendor fees, login result
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>
2026-08-31 19:20:15 -05:00

585 lines
26 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)
})
// ── 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']
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => {
assert.deepEqual(
[...visibility.PUBLIC_KINDS].sort(),
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_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)
})