Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.
Server
- shard_ruleset: a singleton table (id = 1) holding the whole frame in
`payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
the frame is a flat description of config read as one page, and splitting it
into columns would mean a schema change every time the shard grows a block.
- shardIngest routes world.ruleset to setRuleset and deliberately does NOT
log it — the shard re-emits the whole ruleset on every sidecar connect, so
logging would append a duplicate row per reconnect, and server.hello already
marks each of those.
- uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
which asserts an array; this covers the order where the sidecar was already
up and holding the ruleset when we reconnected.
- GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
means the shard has never published one — a real answer, distinct from a
published ruleset, and the page says so.
Client
- routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
complete ruleset, not a delta, so the newest one wins outright). Caps are
rendered from tenths — 7000 is 700.0, and showing the raw number would
mislead. A systems key this build doesn't know still renders, humanised, so
a newer plugin can't go invisible against an older client.
- Nav entry gated on the `ruleset` feature, so it hides rather than 403s.
Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.
497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
334 lines
13 KiB
JavaScript
334 lines
13 KiB
JavaScript
// Point the DB at a closed port BEFORE requiring the controller (its models build
|
|
// the pool). Every model call is monkeypatched, so no query runs; db.close() at
|
|
// the end releases the pool so the process exits cleanly.
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const { test, after, afterEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
// Unit-test the public shard controller's SECURITY BOUNDARIES and shaping — the
|
|
// bits that decide what the anonymous public may and may not see:
|
|
// - getFeed serves only kinds on the public allowlist (staff audit / cheat /
|
|
// login events are stored for the admin channel and must never leak here);
|
|
// - getHouses exposes only IDOC houses and only their location — owner, price,
|
|
// co-owners and decay detail are staff-only and must be stripped;
|
|
// - getStatus assembles the connection/economy summary;
|
|
// - a model failure degrades to a 500, never a thrown/uncaught error.
|
|
const ctrl = require('../src/router/v1/public/shard.controller')
|
|
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
|
|
const shardState = require('../src/model/shardState/shardState.model')
|
|
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
|
|
const broadcast = require('../src/utils/shardBroadcast')
|
|
const visibility = require('../src/utils/shardVisibility')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
// The controller now resolves the visibility config and the caller's rung on
|
|
// every read. Stub the MODEL rather than the util's exports: getConfig() and
|
|
// project() call the module-internal getConfig, which an exports-level stub does
|
|
// not intercept — it would still hit the closed DB port and cost a ~10s pool
|
|
// timeout per test before falling back to these same defaults.
|
|
const visibilityModel = require('../src/model/shardVisibility/shardVisibility.model')
|
|
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
|
|
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'
|
|
|
|
const DEFAULTS = visibility.compileDefaults()
|
|
|
|
function mockRes() {
|
|
return {
|
|
statusCode: 200,
|
|
body: null,
|
|
status(c) {
|
|
this.statusCode = c
|
|
return this
|
|
},
|
|
json(b) {
|
|
this.body = b
|
|
return this
|
|
},
|
|
}
|
|
}
|
|
|
|
const originals = {
|
|
eventsList: shardEvents.list,
|
|
listIdoc: shardState.listIdoc,
|
|
onlineCount: shardState.onlineCount,
|
|
latestEconomy: shardState.latestEconomy,
|
|
getRuleset: shardState.getRuleset,
|
|
getSafe: uoLinkConfig.getSafe,
|
|
}
|
|
afterEach(() => {
|
|
shardEvents.list = originals.eventsList
|
|
shardState.listIdoc = originals.listIdoc
|
|
shardState.onlineCount = originals.onlineCount
|
|
shardState.latestEconomy = originals.latestEconomy
|
|
shardState.getRuleset = originals.getRuleset
|
|
uoLinkConfig.getSafe = originals.getSafe
|
|
})
|
|
|
|
// ── getFeed: the public-safe allowlist is a security boundary ───────────
|
|
test('getFeed refuses a kind that is not on the public allowlist (returns [], no query)', async () => {
|
|
let queried = false
|
|
shardEvents.list = async () => {
|
|
queried = true
|
|
return [{ kind: 'staff.audit' }]
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getFeed({ query: { kind: 'staff.audit' } }, res) // an admin-only kind
|
|
assert.deepEqual(res.body, [])
|
|
assert.equal(queried, false, 'a disallowed kind is rejected before any DB read')
|
|
})
|
|
|
|
test('getFeed serves a specific kind when it IS public-safe', async () => {
|
|
const publicKind = [...broadcast.PUBLIC_KINDS][0]
|
|
let seen
|
|
shardEvents.list = async (opts) => {
|
|
seen = opts
|
|
return [{ kind: publicKind }]
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getFeed({ query: { kind: publicKind, limit: 5 } }, res)
|
|
assert.equal(seen.kind, publicKind)
|
|
assert.equal(seen.limit, 5)
|
|
assert.equal(res.body[0].kind, publicKind)
|
|
})
|
|
|
|
test('getFeed with no kind restricts the query to the kinds THIS viewer may read', async () => {
|
|
let seen
|
|
shardEvents.list = async (opts) => {
|
|
seen = opts
|
|
return []
|
|
}
|
|
await ctrl.getFeed({ query: {} }, mockRes())
|
|
// Resolved from the LIVE config, not the module-load PUBLIC_KINDS constant, so
|
|
// an admin re-gating a feature takes effect on the stored history too.
|
|
assert.deepEqual(new Set(seen.kinds), new Set(visibility.visibleKinds('anonymous', DEFAULTS)))
|
|
// Sanity: a known admin-only kind is absent from what the public feed queries.
|
|
assert.ok(!seen.kinds.includes('staff.audit'))
|
|
// The `stream` flag governs SSE fan-out only, so a feature whose live firehose
|
|
// ships off is still readable from history — the one way this set is WIDER
|
|
// than PUBLIC_KINDS.
|
|
for (const kind of broadcast.PUBLIC_KINDS) assert.ok(seen.kinds.includes(kind))
|
|
assert.ok(seen.kinds.includes('vendor.listing'))
|
|
assert.ok(!broadcast.PUBLIC_KINDS.has('vendor.listing'))
|
|
})
|
|
|
|
test('getFeed projects each row against ITS OWN kind\'s feature', async () => {
|
|
shardEvents.list = async () => [
|
|
{
|
|
id: 1,
|
|
kind: 'player.death',
|
|
payload: { kind: 'player.death', actor: { serial: '0x1', name: 'Doomed', acct: 'secret', webId: 99 } },
|
|
},
|
|
{
|
|
id: 2,
|
|
kind: 'guild.join',
|
|
payload: { kind: 'guild.join', actor: { serial: '0x2', name: 'Joiner', acct: 'secret2', webId: 98 } },
|
|
},
|
|
]
|
|
const res = mockRes()
|
|
await ctrl.getFeed({ query: {} }, res)
|
|
for (const row of res.body) {
|
|
assert.equal(row.payload.actor.acct, undefined, `${row.kind} leaked acct`)
|
|
assert.equal(row.payload.actor.webId, undefined, `${row.kind} leaked webId`)
|
|
assert.ok(row.payload.actor.name, 'the in-game name is still public')
|
|
}
|
|
})
|
|
|
|
test('getFeed serves nothing when the viewer may read no kinds at all', async () => {
|
|
let queried = false
|
|
shardEvents.list = async () => {
|
|
queried = true
|
|
return [{ kind: 'staff.audit' }]
|
|
}
|
|
const allGated = Object.fromEntries(
|
|
Object.entries(DEFAULTS).map(([name, f]) => [name, { ...f, enabled: false }]),
|
|
)
|
|
visibility.getConfig = async () => allGated
|
|
const res = mockRes()
|
|
await ctrl.getFeed({ query: {} }, res)
|
|
visibility.getConfig = async () => DEFAULTS
|
|
assert.deepEqual(res.body, [])
|
|
// An empty allowlist must never fall through to an unfiltered "give me
|
|
// everything" query.
|
|
assert.equal(queried, false)
|
|
})
|
|
|
|
// ── getHouses: the public house view must strip owner/price ─────────────
|
|
test('getHouses exposes only IDOC location fields and strips owner/price/decay', async () => {
|
|
shardState.listIdoc = async () => [
|
|
{
|
|
serial: 1,
|
|
name: 'Keep',
|
|
region: 'Britain',
|
|
map: 'Felucca',
|
|
x: 1,
|
|
y: 2,
|
|
z: 3,
|
|
// The following are staff-only and must NOT appear in the public payload:
|
|
ownerName: 'Lord British',
|
|
ownerAcct: 'secret',
|
|
price: 999999,
|
|
coOwners: 'a,b',
|
|
decay: 'IDOC',
|
|
},
|
|
]
|
|
const res = mockRes()
|
|
await ctrl.getHouses({}, res)
|
|
const [h] = res.body
|
|
assert.deepEqual(Object.keys(h).sort(), ['isIdoc', 'map', 'name', 'region', 'serial', 'x', 'y', 'z'])
|
|
assert.equal(h.isIdoc, true)
|
|
assert.equal(h.ownerName, undefined)
|
|
assert.equal(h.price, undefined)
|
|
assert.equal(h.coOwners, undefined)
|
|
})
|
|
|
|
// ── getIdoc: the flattened owner fields are a security boundary too ──────
|
|
test('getIdoc never serves the owner game account to a viewer below admin', async () => {
|
|
shardState.listIdoc = async () => [
|
|
{
|
|
serial: '0x1',
|
|
name: 'Marble Tower',
|
|
region: 'Britain',
|
|
map: 'Felucca',
|
|
x: 1,
|
|
y: 2,
|
|
z: 3,
|
|
ownerSerial: '0x2A01',
|
|
ownerName: 'Sir Cadmus',
|
|
ownerAcct: 'cadmus_acct', // flattened spelling of the locked `acct`
|
|
price: 1250000,
|
|
isIdoc: true,
|
|
},
|
|
]
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
const res = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: level }, res)
|
|
assert.equal(res.body[0].ownerAcct, undefined, `${level} saw the owner's game account`)
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: 'admin' }, res)
|
|
assert.equal(res.body[0].ownerAcct, 'cadmus_acct', 'admin still sees it')
|
|
})
|
|
|
|
test('getIdoc gates owner identity and price at `staff`, but never the location', async () => {
|
|
shardState.listIdoc = async () => [
|
|
{ serial: '0x1', name: 'Marble Tower', region: 'Britain', map: 'Felucca', x: 1, y: 2, z: 3,
|
|
ownerSerial: '0x2A01', ownerName: 'Sir Cadmus', price: 1250000, isIdoc: true },
|
|
]
|
|
const anon = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: 'anonymous' }, anon)
|
|
assert.equal(anon.body[0].ownerName, undefined)
|
|
assert.equal(anon.body[0].ownerSerial, undefined)
|
|
assert.equal(anon.body[0].price, undefined)
|
|
// The public IDOC board still renders: name, region and location survive.
|
|
assert.equal(anon.body[0].name, 'Marble Tower')
|
|
assert.equal(anon.body[0].region, 'Britain')
|
|
assert.equal(anon.body[0].map, 'Felucca')
|
|
|
|
const staff = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: 'staff' }, staff)
|
|
assert.equal(staff.body[0].ownerName, 'Sir Cadmus')
|
|
assert.equal(staff.body[0].price, 1250000)
|
|
})
|
|
|
|
test('getIdoc preserves Date columns rather than flattening them to {}', async () => {
|
|
const when = new Date('2026-07-06T19:32:29.000Z')
|
|
shardState.listIdoc = async () => [
|
|
{ serial: '0x1', name: 'Marble Tower', isIdoc: true, lastRefreshed: when, updatedAt: when },
|
|
]
|
|
const res = mockRes()
|
|
await ctrl.getIdoc({ viewerLevel: 'anonymous' }, res)
|
|
assert.ok(res.body[0].updatedAt instanceof Date, 'a Date must survive projection intact')
|
|
assert.equal(res.body[0].updatedAt.toISOString(), when.toISOString())
|
|
})
|
|
|
|
// ── getRuleset: "never published" is a real answer ──────────────────────
|
|
test('getRuleset serves null when the shard has never published a ruleset', async () => {
|
|
shardState.getRuleset = async () => null
|
|
const res = mockRes()
|
|
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
|
|
// Deliberately null, not {} — the page says "not published yet" rather than
|
|
// rendering an empty ruleset as though the shard had no rules.
|
|
assert.equal(res.body, null)
|
|
assert.equal(res.statusCode, 200)
|
|
})
|
|
|
|
test('getRuleset serves the published ruleset whole, nested blocks intact', async () => {
|
|
shardState.getRuleset = async () => ({
|
|
kind: 'world.ruleset',
|
|
rev: '1a2b3c4d',
|
|
shard: 'UOMysticmoon',
|
|
expansion: 'EJ',
|
|
systems: { cityLoyalty: true, vvv: true, factions: false },
|
|
caps: { skill: 1000, totalSkill: 7000, stat: 225 },
|
|
champions: { powerScrolls: 6, rankThresholds: [5, 10, 13] },
|
|
})
|
|
const res = mockRes()
|
|
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
|
|
assert.equal(res.body.expansion, 'EJ')
|
|
assert.equal(res.body.systems.vvv, true)
|
|
assert.equal(res.body.caps.totalSkill, 7000)
|
|
// Arrays must survive projection as arrays, not become objects.
|
|
assert.deepEqual(res.body.champions.rankThresholds, [5, 10, 13])
|
|
})
|
|
|
|
// §3.6.1's rule: a read path that returns shard data and does not project is a
|
|
// bug. The ruleset frame carries no actor today, but it goes through the same
|
|
// gate — so a future block that does cannot leak.
|
|
test('getRuleset projects: acct/webId never survive below admin', async () => {
|
|
shardState.getRuleset = async () => ({
|
|
expansion: 'EJ',
|
|
connect: 'play.example.com,2593',
|
|
owner: { name: 'Lord British', acct: 'lb_acct', webId: 7 },
|
|
})
|
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
|
const res = mockRes()
|
|
await ctrl.getRuleset({ viewerLevel: level }, res)
|
|
assert.equal(res.body.owner.acct, undefined, `${level} saw acct`)
|
|
assert.equal(res.body.owner.webId, undefined, `${level} saw webId`)
|
|
// `connect` defaults to the anonymous rung: an operator who published it
|
|
// meant it to be readable.
|
|
assert.equal(res.body.connect, 'play.example.com,2593')
|
|
}
|
|
})
|
|
|
|
test('getRuleset degrades to a 500 when the model fails, without throwing', async () => {
|
|
shardState.getRuleset = async () => {
|
|
throw new Error('pool down')
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getRuleset({ viewerLevel: 'anonymous' }, res)
|
|
assert.equal(res.statusCode, 500)
|
|
assert.equal(res.body.message, 'Internal Server Error')
|
|
})
|
|
|
|
// ── getStatus assembles the summary ─────────────────────────────────────
|
|
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
|
|
uoLinkConfig.getSafe = async () => ({
|
|
enabled: true,
|
|
status: 'connected',
|
|
pluginConnected: true,
|
|
lastEventAt: 'ts',
|
|
})
|
|
shardState.onlineCount = async () => 12
|
|
shardState.latestEconomy = async () => ({ gold: 100, accounts: 3, t: 1 })
|
|
const res = mockRes()
|
|
await ctrl.getStatus({}, res)
|
|
assert.equal(res.body.enabled, true)
|
|
assert.equal(res.body.onlineCount, 12)
|
|
assert.equal(res.body.economy.gold, 100)
|
|
})
|
|
|
|
test('getStatus degrades to a 500 when a model call fails, without throwing', async () => {
|
|
uoLinkConfig.getSafe = async () => {
|
|
throw new Error('pool down')
|
|
}
|
|
const res = mockRes()
|
|
await ctrl.getStatus({}, res) // must resolve, not reject
|
|
assert.equal(res.statusCode, 500)
|
|
assert.equal(res.body.message, 'Internal Server Error')
|
|
})
|