diff --git a/server/scripts/checkImports.js b/server/scripts/checkImports.js
index 958c67c..b24aa4c 100644
--- a/server/scripts/checkImports.js
+++ b/server/scripts/checkImports.js
@@ -45,11 +45,18 @@ const { isBuiltin } = require('module')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
-// Dependencies this half is allowed to resolve for itself. Empty, and that is
-// the design: everything the server half needs comes from `ctx` (§2.3). A new
-// entry here is a real decision — it becomes a package an operator's install
-// has to carry — so it should be argued for in a PR, not added in passing.
-const ALLOWED_PACKAGES = new Set([])
+// Packages the SHIPPED half may resolve for itself: this package's declared
+// `dependencies`, and nothing else. Read from package.json rather than listed
+// here, so adding one is a visible, reviewable edit to the manifest that also
+// changes what CI installs and what the release tarball carries.
+//
+// Adding a dependency is a real decision. §2.7 permits a module its own, and the
+// release tarball carries `server/node_modules` because an operator never builds
+// — so every entry is weight in the artifact and a package the operator's
+// deployment now runs. Anything core already owns must come from `ctx` instead:
+// a second express is a second Router prototype, a second express-rate-limit is
+// a second store, and a limit enforced by two independent counters is not the
+// limit either of them states.
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
@@ -58,9 +65,9 @@ const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
-const devDependencies = new Set(
- Object.keys(JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')).devDependencies || {}),
-)
+const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
+const dependencies = new Set(Object.keys(manifest.dependencies || {}))
+const devDependencies = new Set(Object.keys(manifest.devDependencies || {}))
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
// need a dependency, and a specifier this pattern misses is a specifier written
@@ -135,7 +142,7 @@ function* walk(dir) {
* has never been shown to fail is a check nobody knows the state of — and this
* one guards the acceptance criterion for the whole contract.
*/
-function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, dev = devDependencies } = {}) {
+function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) {
const violations = []
for (const file of walk(root)) {
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
@@ -151,7 +158,7 @@ function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, dev = devDe
const pkg = specifier.startsWith('@')
? specifier.split('/').slice(0, 2).join('/')
: specifier.split('/')[0]
- const allowed = ALLOWED_PACKAGES.has(pkg) || (!shipped(file) && dev.has(pkg))
+ const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg))
// The `node:` prefix can only ever name a builtin, so it never reaches
// node_modules and is safe whatever this Node version enumerates.
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
diff --git a/server/test/_fakes.js b/server/test/_fakes.js
index a971e05..3f13c62 100644
--- a/server/test/_fakes.js
+++ b/server/test/_fakes.js
@@ -28,6 +28,10 @@ function fakeLog() {
}
function fakeCtx(overrides = {}) {
+ // `freeze: false` is for _setup.js, which installs one process-wide ctx a test
+ // may adjust. Core always freezes; the unfrozen variant is a test seam and
+ // never a claim about what a module is handed in production.
+ const { freeze = true, ...rest } = overrides
const logs = []
const ctx = {
moduleId: 'uo',
@@ -50,10 +54,19 @@ function fakeCtx(overrides = {}) {
siteMode: (req, res, next) => next(),
validate: (req, res, next) => next(),
noindex: (req, res, next) => next(),
+ // API 1.1.0. The factory returns a pass-through rather than a real
+ // limiter: a test that tripped a rate limit would be a test whose result
+ // depended on how many times the suite had run.
+ rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
+ accountChangeLimiter: (req, res, next) => next(),
},
uploads: { upload: {}, UPLOAD_DIR: '/tmp', MIME_EXT: {} },
posts: { listAll: spy(Promise.resolve([])), getById: spy(Promise.resolve(null)), linkAnnounceJob: spy(Promise.resolve()), markAnnounced: spy(Promise.resolve()) },
- ...overrides,
+ // The three §2.3 members API 1.1.0 added for this extraction.
+ activity: { log: spy(Promise.resolve()) },
+ users: { getById: spy(Promise.resolve(null)) },
+ site: { baseUrl: 'http://localhost:5173' },
+ ...rest,
}
// Non-enumerable, and that is not tidiness. Core freezes every object value on
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
@@ -62,6 +75,7 @@ function fakeCtx(overrides = {}) {
// faithful: a module iterating `ctx` sees exactly §2.3's members and nothing
// a test put there.
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
+ if (!freeze) return ctx
for (const value of Object.values(ctx)) {
if (value && typeof value === 'object') Object.freeze(value)
}
diff --git a/server/test/_helper.js b/server/test/_helper.js
new file mode 100644
index 0000000..b61aa1e
--- /dev/null
+++ b/server/test/_helper.js
@@ -0,0 +1,32 @@
+// Test helper: start a throwaway Express app on an ephemeral port and return its
+// base URL + a close(). Uses the built-in fetch (Node 18+) so tests need no
+// extra HTTP dependency. Tests here exercise middleware in isolation and do NOT
+// touch the database.
+// The module takes express off ctx in shipped code; a test may resolve it
+// directly, because test/ never runs inside core's process (checkImports.js
+// allows devDependencies there). Same express either way — this repo pins the
+// version core declares.
+const express = require('express')
+
+async function startApp(configure) {
+ const app = express()
+ app.use(express.json())
+ configure(app)
+ const server = await new Promise((resolve) => {
+ const s = app.listen(0, '127.0.0.1', () => resolve(s))
+ })
+ const { port } = server.address()
+ return {
+ url: `http://127.0.0.1:${port}`,
+ // `server.close()` stops accepting and waits for open connections to end on
+ // their own — and node's global fetch keeps its sockets alive, so nothing
+ // ever ends them. The listener then outlives the test that made it, which
+ // used to be invisible because the pool held the process open anyway.
+ close: () => new Promise((resolve) => {
+ server.closeAllConnections()
+ server.close(resolve)
+ }),
+ }
+}
+
+module.exports = { startApp }
diff --git a/server/test/_setup.js b/server/test/_setup.js
new file mode 100644
index 0000000..f3fc1dc
--- /dev/null
+++ b/server/test/_setup.js
@@ -0,0 +1,27 @@
+// Initialise `core` once, before any test file is required.
+//
+// Loaded via `node --test --require ./test/_setup.js`, the same arrangement
+// core's own suite uses. It exists because of the one structural difference
+// between testing this module and testing the code when it lived in core:
+// nothing here can be stubbed by monkey-patching a core module, because there
+// are no core modules to patch. `require('../utils/db')` does not exist. What a
+// test controls instead is the `ctx` core would have handed over — which is a
+// better seam anyway, since it is exactly the surface the contract promises and
+// nothing wider.
+//
+// The ctx installed here is deliberately NOT frozen. Core freezes what it hands
+// a module, and `entry.test.js` asserts the module behaves against a frozen one;
+// but a test that needs `settings.get` to return a particular value has to be
+// able to say so, and a frozen ctx would mean re-initialising core per test. The
+// mutable copy is a test seam, not a claim about what core does.
+
+const core = require('../core')
+const { fakeCtx } = require('./_fakes')
+
+const ctx = fakeCtx({ freeze: false })
+core.init(ctx)
+
+// Exposed so a test can reach the same object it is running against, e.g.
+// `testCtx.settings.get = async () => '/srv/uo'`. There is one ctx per process,
+// as there is in core, so a test that changes a member should put it back.
+module.exports = { ctx }
diff --git a/server/test/adminUserShard.test.js b/server/test/adminUserShard.test.js
new file mode 100644
index 0000000..536707c
--- /dev/null
+++ b/server/test/adminUserShard.test.js
@@ -0,0 +1,154 @@
+// 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 the pool,
+// so any stray query fails fast instead of hanging the runner. These tests stub
+// every model method the controller touches, so the DB is never actually hit.
+
+const { test, after, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const ctrl = require('../router/admin/usersShard.controller')
+const { ctx } = require('./_setup')
+const users = ctx.users
+const shardLinks = require('../model/shardLinks/shardLinks.model')
+const shardState = require('../model/shardState/shardState.model')
+const shardEvents = require('../model/shardEvents/shardEvents.model')
+const { salesForAccounts } = require('../utils/shardSales')
+
+
+function mockRes() {
+ return {
+ statusCode: 200,
+ body: null,
+ status(c) {
+ this.statusCode = c
+ return this
+ },
+ json(b) {
+ this.body = b
+ return this
+ },
+ }
+}
+
+// Save/restore the originals so each test's monkeypatches don't leak.
+const originals = {
+ getById: users.getById,
+ listForUser: shardLinks.listForUser,
+ listHousesForAccounts: shardState.listHousesForAccounts,
+ listOnlineForAccounts: shardState.listOnlineForAccounts,
+ eventsList: shardEvents.list,
+}
+afterEach(() => {
+ users.getById = originals.getById
+ shardLinks.listForUser = originals.listForUser
+ shardState.listHousesForAccounts = originals.listHousesForAccounts
+ shardState.listOnlineForAccounts = originals.listOnlineForAccounts
+ shardEvents.list = originals.eventsList
+})
+
+// ── salesForAccounts util ──────────────────────────────────────────────────
+test('salesForAccounts returns [] for an empty account set without hitting the log', async () => {
+ let called = false
+ shardEvents.list = async () => {
+ called = true
+ return []
+ }
+ assert.deepEqual(await salesForAccounts([]), [])
+ assert.equal(called, false)
+})
+
+test('salesForAccounts keeps only sales owned by the given accounts, newest 50', async () => {
+ const events = []
+ // 60 sales owned by "mine", plus some owned by "other".
+ for (let i = 0; i < 60; i++) {
+ events.push({ t: i, payload: { ownerAcct: 'mine', itemType: 'sword', amount: 1, price: 10, commission: 1 } })
+ }
+ events.push({ t: 999, payload: { ownerAcct: 'other', itemType: 'shield', amount: 1, price: 5 } })
+ shardEvents.list = async () => events
+
+ const rows = await salesForAccounts(['mine'])
+ assert.equal(rows.length, 50) // capped
+ assert.ok(rows.every((r) => r.ownerAcct === 'mine')) // never leaks "other"
+ assert.deepEqual(Object.keys(rows[0]).sort(), ['amount', 'commission', 'itemType', 'ownerAcct', 'price', 't'])
+})
+
+// ── Controller: unknown user → 404 ─────────────────────────────────────────
+for (const handler of ['listAccounts', 'getSales', 'getHouses', 'getOnline']) {
+ test(`${handler} returns 404 when the user does not exist`, async () => {
+ users.getById = async () => null
+ const res = mockRes()
+ await ctrl[handler]({ params: { id: '404' } }, res)
+ assert.equal(res.statusCode, 404)
+ })
+}
+
+// ── Controller: scoping to the user's accounts ─────────────────────────────
+test('listAccounts returns the user’s linked accounts', async () => {
+ users.getById = async () => ({ id: 7, username: 'bob', role: 'player' })
+ shardLinks.listForUser = async (id) => {
+ assert.equal(id, 7)
+ return [{ account: 'acctA' }, { account: 'acctB' }]
+ }
+ const res = mockRes()
+ await ctrl.listAccounts({ params: { id: '7' } }, res)
+ assert.equal(res.statusCode, 200)
+ assert.deepEqual(res.body, [{ account: 'acctA' }, { account: 'acctB' }])
+})
+
+test('getHouses passes exactly the user’s accounts to the model', async () => {
+ users.getById = async () => ({ id: 7 })
+ shardLinks.listForUser = async () => [{ account: 'acctA' }, { account: 'acctB' }]
+ let received = null
+ shardState.listHousesForAccounts = async (accounts) => {
+ received = accounts
+ return [{ serial: '0x1', isIdoc: true }]
+ }
+ const res = mockRes()
+ await ctrl.getHouses({ params: { id: '7' } }, res)
+ assert.deepEqual(received, ['acctA', 'acctB'])
+ assert.deepEqual(res.body, [{ serial: '0x1', isIdoc: true }])
+})
+
+test('getOnline passes exactly the user’s accounts to the model', async () => {
+ users.getById = async () => ({ id: 7 })
+ shardLinks.listForUser = async () => [{ account: 'acctA' }]
+ let received = null
+ shardState.listOnlineForAccounts = async (accounts) => {
+ received = accounts
+ return [{ serial: '0x2', name: 'Zoe' }]
+ }
+ const res = mockRes()
+ await ctrl.getOnline({ params: { id: '7' } }, res)
+ assert.deepEqual(received, ['acctA'])
+ assert.deepEqual(res.body, [{ serial: '0x2', name: 'Zoe' }])
+})
+
+test('a user with no linked accounts yields empty sales/houses/online', async () => {
+ users.getById = async () => ({ id: 7 })
+ shardLinks.listForUser = async () => []
+ shardState.listHousesForAccounts = async (a) => (a.length ? [{}] : [])
+ shardState.listOnlineForAccounts = async (a) => (a.length ? [{}] : [])
+ shardEvents.list = async () => [{ payload: { ownerAcct: 'someoneElse' } }]
+
+ const sales = mockRes()
+ const houses = mockRes()
+ const online = mockRes()
+ await ctrl.getSales({ params: { id: '7' } }, sales)
+ await ctrl.getHouses({ params: { id: '7' } }, houses)
+ await ctrl.getOnline({ params: { id: '7' } }, online)
+
+ assert.deepEqual(sales.body, [])
+ assert.deepEqual(houses.body, [])
+ assert.deepEqual(online.body, [])
+})
+
+// getUser is NOT here any more: reading a user is core semantics that had ended
+// up in this controller by proximity, and PR 4 moved it back to
+// admin.controller.js behind the extension slot (MODULE_SYSTEM.md §1.9). It is
+// covered by test/adminUsers.test.js.
diff --git a/server/test/atlasController.test.js b/server/test/atlasController.test.js
new file mode 100644
index 0000000..0ff5bc0
--- /dev/null
+++ b/server/test/atlasController.test.js
@@ -0,0 +1,242 @@
+// 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 the controllers (their 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.
+
+const { test, after, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+// The spawn atlas API, public and admin. What is worth asserting here is not the
+// SQL (that is the parser suite's job) but the contracts the two surfaces make:
+//
+// • the public reads project through the visibility framework — v3.md §3.6.1's
+// standing rule is that a read path returning shard data and not calling
+// projectFeature is a bug, and `atlas` declaring no sensitive fields TODAY is
+// exactly why the call has to be there before one does;
+// • the public /meta route reports the game world only, never the operator's
+// filesystem — the ServUO path, the per-file hashes and any pending refresh
+// stay on the admin route;
+// • a missing creature is a 404, not an empty 200;
+// • an unreadable ServUO tree is a 200 carrying `status: 'unavailable'`, NOT a
+// 500. The refresh contract reports outcomes rather than throwing (so boot is
+// never blocked by a bad tree), and the admin needs to be told what is wrong
+// with their path;
+// • a model failure degrades to a 500 rather than a thrown/uncaught error.
+const pub = require('../router/public/atlas.controller')
+const admin = require('../router/admin/shardAtlas.controller')
+const atlas = require('../model/shardAtlas/shardAtlas.model')
+const { ctx } = require('./_setup')
+const activity = ctx.activity
+const visibility = require('../utils/shardVisibility')
+
+
+// Stub the visibility MODEL rather than the util's exports: project() calls the
+// module-internal getConfig, which an exports-level stub would not intercept — it
+// would hit the closed DB port and cost a ~10s pool timeout per test before
+// falling back to these same defaults.
+const visibilityModel = require('../model/shardVisibility/shardVisibility.model')
+visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
+visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'
+
+// The admin controller logs every action; keep it off the DB.
+activity.log = async () => {}
+
+function mockRes() {
+ return {
+ statusCode: 200,
+ body: null,
+ status(c) {
+ this.statusCode = c
+ return this
+ },
+ json(b) {
+ this.body = b
+ return this
+ },
+ }
+}
+
+const originals = {
+ searchCreatures: atlas.searchCreatures,
+ getCreature: atlas.getCreature,
+ listRegions: atlas.listRegions,
+ listLandmarks: atlas.listLandmarks,
+ listChampions: atlas.listChampions,
+ publicMeta: atlas.publicMeta,
+ status: atlas.status,
+ refresh: atlas.refresh,
+ approvePending: atlas.approvePending,
+ rejectPending: atlas.rejectPending,
+ setServuoPath: atlas.setServuoPath,
+}
+afterEach(() => Object.assign(atlas, originals))
+
+// ── Public reads ────────────────────────────────────────────────────────
+test('getCreatures passes the search through and returns the page shape', async () => {
+ let seen = null
+ atlas.searchCreatures = async (opts) => {
+ seen = opts
+ return { total: 1, limit: 50, offset: 0, creatures: [{ slug: 'lizardman', name: 'Lizardman' }] }
+ }
+ const res = mockRes()
+ await pub.getCreatures({ query: { q: ' lizard ', facet: 'Felucca', limit: '10', offset: '20' } }, res)
+ assert.deepEqual(seen, { q: 'lizard', facet: 'Felucca', limit: 10, offset: 20 })
+ assert.equal(res.body.total, 1)
+ assert.equal(res.body.creatures[0].slug, 'lizardman')
+})
+
+test('getCreatures falls back to the documented defaults when nothing is passed', async () => {
+ let seen = null
+ atlas.searchCreatures = async (opts) => {
+ seen = opts
+ return { total: 0, limit: 50, offset: 0, creatures: [] }
+ }
+ await pub.getCreatures({ query: {} }, mockRes())
+ assert.deepEqual(seen, { q: '', facet: '', limit: 50, offset: 0 })
+})
+
+test('an unknown creature is a 404, not an empty 200', async () => {
+ atlas.getCreature = async () => null
+ const res = mockRes()
+ await pub.getCreature({ params: { slug: 'nosuchthing' }, query: {} }, res)
+ assert.equal(res.statusCode, 404)
+})
+
+test('getCreature returns places and spawners, and `points` stays the COUNT', async () => {
+ atlas.getCreature = async () => ({
+ slug: 'lizardman',
+ name: 'Lizardman',
+ total: 214,
+ points: 62,
+ places: [{ facet: 'Trammel', label: 'Shrines', spawners: 7, maxAlive: 21 }],
+ spawners: [{ id: 1, facet: 'Trammel', label: 'Shrines', x: 1, y: 2 }],
+ spawnersTruncated: false,
+ alsoHere: [],
+ })
+ const res = mockRes()
+ await pub.getCreature({ params: { slug: 'lizardman' }, query: {} }, res)
+ // The list route uses `points` as a number; the detail route must not quietly
+ // turn the same key into an array.
+ assert.equal(typeof res.body.points, 'number')
+ assert.ok(Array.isArray(res.body.spawners))
+ assert.equal(res.body.places[0].label, 'Shrines')
+})
+
+// ── The projection rule (§3.6.1) ────────────────────────────────────────
+test('public reads run through projectFeature, so a locked field can never survive', async () => {
+ // `atlas` declares no sensitive fields, so nothing here is stripped by a
+ // FEATURE rule. acct/webId are stripped anyway — they are locked by meaning,
+ // for every feature, and this is what proves the read path projects at all.
+ atlas.searchCreatures = async () => ({
+ total: 1,
+ limit: 50,
+ offset: 0,
+ creatures: [{ slug: 'lizardman', name: 'Lizardman', acct: 'someacct', ownerWebId: 7 }],
+ })
+ const res = mockRes()
+ await pub.getCreatures({ query: {}, viewerLevel: 'anonymous' }, res)
+ const row = res.body.creatures[0]
+ assert.equal(row.name, 'Lizardman')
+ assert.ok(!('acct' in row), 'acct must never reach an anonymous caller')
+ assert.ok(!('ownerWebId' in row), 'a flattened webId spelling is locked too')
+})
+
+test('getMeta reports the game world only — never the operator’s filesystem', async () => {
+ // The model is what enforces this; the assertion documents the boundary so a
+ // future "just return status() here" shortcut fails loudly.
+ atlas.publicMeta = async () => ({
+ importedAt: '2026-07-28T00:00:00.000Z',
+ generatedAt: '2026-07-28T00:00:00.000Z',
+ counts: { points: 6455, creatures: 800 },
+ facets: ['Felucca', 'Trammel'],
+ })
+ const res = mockRes()
+ await pub.getMeta({ query: {} }, res)
+ assert.deepEqual(Object.keys(res.body).sort(), ['counts', 'facets', 'generatedAt', 'importedAt'])
+ assert.ok(!('path' in res.body))
+ assert.ok(!('pending' in res.body))
+})
+
+test('a model failure degrades to a 500 rather than throwing', async () => {
+ atlas.listChampions = async () => {
+ throw new Error('table is gone')
+ }
+ const res = mockRes()
+ await pub.getChampions({ query: {} }, res)
+ assert.equal(res.statusCode, 500)
+})
+
+// ── Admin ───────────────────────────────────────────────────────────────
+test('an unreadable tree answers 200 with the reason, not a 500', async () => {
+ atlas.refresh = async () => ({ status: 'unavailable', reason: 'no Spawns directory', path: '/bad' })
+ const res = mockRes()
+ await admin.importAtlas({ body: {}, user: { id: 1 } }, res)
+ assert.equal(res.statusCode, 200)
+ assert.equal(res.body.status, 'unavailable')
+ assert.equal(res.body.reason, 'no Spawns directory')
+})
+
+test('import passes `force` through and coerces it to a boolean', async () => {
+ let seen = null
+ atlas.refresh = async (opts) => {
+ seen = opts
+ return { status: 'unchanged' }
+ }
+ await admin.importAtlas({ body: { force: true }, user: { id: 1 } }, mockRes())
+ assert.deepEqual(seen, { force: true })
+})
+
+test('approve applies a staged refresh (facet loss included)', async () => {
+ let called = false
+ atlas.approvePending = async () => {
+ called = true
+ return { status: 'imported', removedFacets: ['Malas'], counts: { points: 6162 } }
+ }
+ const res = mockRes()
+ await admin.approve({ user: { id: 1 } }, res)
+ assert.ok(called)
+ assert.equal(res.body.status, 'imported')
+})
+
+test('rejecting when nothing is staged is a 404', async () => {
+ atlas.rejectPending = async () => ({ status: 'none' })
+ const res = mockRes()
+ await admin.reject({ user: { id: 1 } }, res)
+ assert.equal(res.statusCode, 404)
+})
+
+test('setPath trims, persists, and answers with fresh status — it does not import', async () => {
+ let saved = null
+ let imported = false
+ atlas.setServuoPath = async (value) => {
+ saved = value
+ }
+ atlas.refresh = async () => {
+ imported = true
+ return { status: 'imported' }
+ }
+ atlas.status = async () => ({ configured: true, path: '/srv/servuo', treeReadable: true })
+ const res = mockRes()
+ await admin.setPath({ body: { path: ' /srv/servuo ' }, user: { id: 3 } }, res)
+ assert.equal(saved, '/srv/servuo')
+ assert.equal(imported, false, 'changing the path must not reload the atlas as a side effect')
+ assert.equal(res.body.path, '/srv/servuo')
+})
+
+test('setPath accepts a blank path (clearing it turns the atlas off)', async () => {
+ let saved = 'unset'
+ atlas.setServuoPath = async (value) => {
+ saved = value
+ }
+ atlas.status = async () => ({ configured: false, path: '' })
+ const res = mockRes()
+ await admin.setPath({ body: {}, user: { id: 3 } }, res)
+ assert.equal(saved, '')
+ assert.equal(res.statusCode, 200)
+})
diff --git a/server/test/clilocParse.test.js b/server/test/clilocParse.test.js
new file mode 100644
index 0000000..a428a37
--- /dev/null
+++ b/server/test/clilocParse.test.js
@@ -0,0 +1,227 @@
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+
+const {
+ ClilocFormatError,
+ parseCliloc,
+ parseClilocBinary,
+ parseClilocText,
+ isCompressedCliloc,
+ displayText,
+ isPlaceholderOnly,
+} = require('../utils/clilocParse')
+
+// These parsers are pure and fs-free precisely so this suite can run in CI,
+// where there is no UO client and no converted cliloc file. Every fixture below
+// is built from the real layout, and the strings are verbatim entries from a
+// real Cliloc.enu (123,490 entries) rather than invented ones.
+
+// ── Fixture builders ───────────────────────────────────────────────────────
+
+/** Build a plain-format cliloc buffer: 6-byte header, then records. */
+function buildBinary(entries, { header1 = 2, header2 = 1 } = {}) {
+ const parts = [Buffer.alloc(6)]
+ parts[0].writeInt32LE(header1, 0)
+ parts[0].writeUInt16LE(header2, 4)
+ for (const e of entries) {
+ const text = Buffer.from(e.text, 'utf8')
+ const head = Buffer.alloc(7)
+ head.writeInt32LE(e.number, 0)
+ head.writeUInt8(e.flag ?? 0, 4)
+ head.writeUInt16LE(text.length, 5)
+ parts.push(head, text)
+ }
+ return Buffer.concat(parts)
+}
+
+// ── Binary ─────────────────────────────────────────────────────────────────
+
+test('parseClilocBinary: reads a plain-format table', () => {
+ const buf = buildBinary([
+ { number: 1015012, text: 'Greater Heal' },
+ { number: 1023721, text: 'quarter staff' },
+ { number: 1025913, flag: 1, text: 'bonnet' },
+ ])
+ assert.deepEqual(parseClilocBinary(buf), [
+ { number: 1015012, flag: 0, text: 'Greater Heal' },
+ { number: 1023721, flag: 0, text: 'quarter staff' },
+ { number: 1025913, flag: 1, text: 'bonnet' },
+ ])
+})
+
+test('parseClilocBinary: length is UNSIGNED 16-bit', () => {
+ // ServUO's own SDK reads this field into a signed short, which turns any
+ // string over 32 KB into a negative length. Real tables top out around 12 KB
+ // so nothing is broken today, but the field is written unsigned and reading it
+ // that way costs nothing.
+ const text = 'x'.repeat(40000)
+ const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text }]))
+ assert.equal(entry.text.length, 40000)
+})
+
+test('parseClilocBinary: multi-byte UTF-8 survives (length is in BYTES)', () => {
+ const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text: 'Ilshenar — Ver Lor Reg' }]))
+ assert.equal(entry.text, 'Ilshenar — Ver Lor Reg')
+})
+
+test('parseClilocBinary: a truncated record body throws rather than importing short', () => {
+ // The realistic corruption is a half-copied file. It must fail loudly: a
+ // silently short table renders as "some items named, some not", which is
+ // indistinguishable from having no table at all.
+ const buf = buildBinary([{ number: 1023721, text: 'quarter staff' }])
+ const truncated = buf.subarray(0, buf.length - 4)
+ assert.throws(() => parseClilocBinary(truncated), (err) => {
+ assert.ok(err instanceof ClilocFormatError)
+ assert.equal(err.code, 'TRUNCATED')
+ return true
+ })
+})
+
+test('parseClilocBinary: a truncated record HEADER throws too', () => {
+ const buf = Buffer.concat([buildBinary([{ number: 1023721, text: 'quarter staff' }]), Buffer.alloc(3)])
+ assert.throws(() => parseClilocBinary(buf), (err) => err.code === 'TRUNCATED')
+})
+
+test('parseClilocBinary: an empty table (header only) is valid', () => {
+ assert.deepEqual(parseClilocBinary(buildBinary([])), [])
+})
+
+// ── Compressed detection ───────────────────────────────────────────────────
+
+test('isCompressedCliloc: recognises the Mythic marker', () => {
+ // Every cliloc the client ships opens with a DWORD whose high byte is 0x8E.
+ // Real first bytes of Cliloc.enu (e8 79 67 8e) and Cliloc.deu (99 5d 26 8e).
+ assert.equal(isCompressedCliloc(Buffer.from([0xe8, 0x79, 0x67, 0x8e])), true)
+ assert.equal(isCompressedCliloc(Buffer.from([0x99, 0x5d, 0x26, 0x8e])), true)
+ assert.equal(isCompressedCliloc(buildBinary([])), false)
+})
+
+test('parseCliloc: a compressed file is rejected by NAME, not parsed into nonsense', () => {
+ // This is the whole reason the marker check exists. Without it the plain
+ // parser reads compressed bytes as ~19k records of negative ids and 60 KB
+ // "strings" before dying somewhere in the middle — and the resulting error
+ // names truncation, which is the wrong problem to hand an operator.
+ const compressed = Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(64, 0x41)])
+ assert.throws(() => parseCliloc(compressed), (err) => {
+ assert.equal(err.code, 'COMPRESSED')
+ assert.match(err.message, /CLILOCS\.md/)
+ return true
+ })
+})
+
+// ── Text ───────────────────────────────────────────────────────────────────
+
+test('parseClilocText: tab-delimited, skipping a header row', () => {
+ const entries = parseClilocText('number\ttext\n1023721\tquarter staff\n1015012\tGreater Heal\n')
+ assert.deepEqual(entries, [
+ { number: 1023721, flag: 0, text: 'quarter staff' },
+ { number: 1015012, flag: 0, text: 'Greater Heal' },
+ ])
+})
+
+test('parseClilocText: splits on the FIRST separator only', () => {
+ // Cliloc text is full of commas. Splitting on all of them would truncate every
+ // such entry at its first one.
+ const [entry] = parseClilocText('1044000,a scroll of magery, unfinished\n')
+ assert.equal(entry.text, 'a scroll of magery, unfinished')
+})
+
+test('parseClilocText: unwraps quoted CSV fields and doubled quotes', () => {
+ const [entry] = parseClilocText('1023721,"a ""quarter"" staff, plain"\n')
+ assert.equal(entry.text, 'a "quarter" staff, plain')
+})
+
+test('parseClilocText: reads an optional flag column', () => {
+ const [entry] = parseClilocText('1025913\t1\tbonnet\n')
+ assert.deepEqual(entry, { number: 1025913, flag: 1, text: 'bonnet' })
+})
+
+test('parseClilocText: text that is itself a number stays the text', () => {
+ // `number,text` where text is "100" is indistinguishable from `number,flag`
+ // with an empty text. Keeping it as the text is the safer miss — the other way
+ // silently deletes a real entry.
+ const [entry] = parseClilocText('1000000,100\n')
+ assert.equal(entry.text, '100')
+})
+
+test('parseClilocText: blank lines and # comments are ignored', () => {
+ const entries = parseClilocText('# exported by hand\n\n1023721\tquarter staff\n\n')
+ assert.equal(entries.length, 1)
+})
+
+test('parseClilocText: a file with no entries is an error, not an empty table', () => {
+ assert.throws(() => parseClilocText('nothing here\nnor here\n'), (err) => err.code === 'EMPTY')
+})
+
+test('parseClilocText: an empty leading field is skipped, not imported as id 0', () => {
+ // `Number('')` is 0, not NaN, so a line that merely starts with a separator
+ // would otherwise become a bogus cliloc 0.
+ assert.throws(() => parseClilocText('\tstray text\n,another\n'), (err) => err.code === 'EMPTY')
+})
+
+test('parseClilocText: keeps entries whose text is EMPTY', () => {
+ // About half of a real table is empty strings (unused ids). They must survive
+ // parsing — the import layer decides whether to store them, and both input
+ // formats have to agree on what the file contained.
+ const entries = parseClilocText('1005008\t\n1023721\tquarter staff\n')
+ assert.equal(entries.length, 2)
+ assert.deepEqual(entries[0], { number: 1005008, flag: 0, text: '' })
+})
+
+// ── Sniffing ───────────────────────────────────────────────────────────────
+
+test('parseCliloc: sniffs binary vs text from the header, not the extension', () => {
+ assert.equal(parseCliloc(buildBinary([{ number: 1023721, text: 'quarter staff' }]))[0].text, 'quarter staff')
+ assert.equal(parseCliloc(Buffer.from('1023721\tquarter staff\n'))[0].text, 'quarter staff')
+})
+
+test('parseCliloc: a binary-looking header that is not 2/1 falls through to text', () => {
+ // The recoverable guess: a mis-sniffed text file says "no entries found",
+ // while a mis-sniffed binary yields plausible nonsense.
+ assert.throws(() => parseCliloc(Buffer.from([9, 0, 0, 0, 9, 0, 65, 66])), (err) => err.code === 'EMPTY')
+})
+
+// ── Display ────────────────────────────────────────────────────────────────
+
+test('displayText: drops interpolated arguments we never receive', () => {
+ // The bridge sends a cliloc id, never the property packet that carries the
+ // arguments, so a name containing them has to be reduced to what is knowable.
+ assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
+ assert.equal(displayText('~1_NAME~ the ~2_TITLE~'), 'the')
+})
+
+test('displayText: a string that is nothing but arguments resolves to nothing', () => {
+ assert.equal(displayText('[~1_stuff~]'), '')
+ assert.equal(isPlaceholderOnly('[~1_stuff~]'), true)
+ assert.equal(isPlaceholderOnly('quarter staff'), false)
+})
+
+test('displayText: a trailing % is only stripped when a placeholder was removed', () => {
+ // "cold damage ~1_val~%" loses its % because that % was the unit belonging to
+ // the number we never had. A string that genuinely ends in one keeps it.
+ assert.equal(displayText('50%'), '50%')
+ assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
+})
+
+test('displayText: ordinary names pass through untouched', () => {
+ assert.equal(displayText('quarter staff'), 'quarter staff')
+ assert.equal(displayText('a scroll of magery, unfinished'), 'a scroll of magery, unfinished')
+ assert.equal(displayText(' spiked collar '), 'spiked collar')
+})
+
+test('displayText: punctuation is only tidied when a placeholder was removed', () => {
+ // A shard's custom "Runic Gateway Sigil (v2)" came back as "(v2" while the
+ // bracket trim was unconditional. A string with no placeholder has no debris
+ // to clean, so it is left alone apart from whitespace.
+ assert.equal(displayText('Runic Gateway Sigil (v2)'), 'Runic Gateway Sigil (v2)')
+ assert.equal(displayText('scroll of power - greater'), 'scroll of power - greater')
+ assert.equal(displayText('[Companion] Great Dane'), '[Companion] Great Dane')
+ // …but the debris a placeholder leaves behind is still cleaned.
+ assert.equal(displayText('[~1_stuff~]'), '')
+ assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
+})
+
+test('displayText: null and undefined are empty, not "null"', () => {
+ assert.equal(displayText(null), '')
+ assert.equal(displayText(undefined), '')
+})
diff --git a/server/test/clilocSource.test.js b/server/test/clilocSource.test.js
new file mode 100644
index 0000000..6daf8b8
--- /dev/null
+++ b/server/test/clilocSource.test.js
@@ -0,0 +1,194 @@
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const os = require('node:os')
+const path = require('node:path')
+
+const {
+ ClilocSourceError,
+ CUSTOM_DIR,
+ resolveBase,
+ listCustom,
+ readSources,
+ hashSources,
+ sameSources,
+ missingSources,
+ readCliloc,
+} = require('../utils/clilocSource')
+
+// The fs layer, exercised against real temp directories rather than mocks —
+// the behaviours that matter here (which file wins, what a directory listing
+// yields, what happens when one vanishes) are precisely the ones a mock would
+// define away.
+
+function tmpdir() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cliloc-'))
+ return dir
+}
+
+const tsv = (entries) => entries.map(([n, t]) => `${n}\t${t}`).join('\n') + '\n'
+
+function write(dir, name, contents) {
+ const file = path.join(dir, name)
+ fs.mkdirSync(path.dirname(file), { recursive: true })
+ fs.writeFileSync(file, contents)
+ return file
+}
+
+// ── Resolution ─────────────────────────────────────────────────────────────
+
+test('resolveBase: a directory picks the most specific candidate', () => {
+ const dir = tmpdir()
+ // A converted file sitting next to the client's own compressed one must win —
+ // otherwise pointing at a client folder finds the file that will be rejected.
+ write(dir, 'cliloc.enu', 'ignored')
+ write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
+ assert.equal(path.basename(resolveBase(dir).base), 'clilocs.tsv')
+})
+
+test('resolveBase: a file path roots overlays at its DIRECTORY', () => {
+ // An operator who pointed at a file should not have to re-point at its folder
+ // just to add a custom/ directory beside it.
+ const dir = tmpdir()
+ const file = write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
+ assert.deepEqual(resolveBase(file), { root: dir, base: file })
+})
+
+test('resolveBase: a missing path and an empty path are different errors', () => {
+ assert.throws(() => resolveBase(''), (err) => err.code === 'NO_PATH')
+ assert.throws(() => resolveBase(path.join(tmpdir(), 'nope')), (err) => err.code === 'NOT_FOUND')
+})
+
+test('resolveBase: a directory with no cliloc file names what it looked for', () => {
+ assert.throws(() => resolveBase(tmpdir()), (err) => {
+ assert.ok(err instanceof ClilocSourceError)
+ assert.equal(err.code, 'NO_FILE')
+ assert.match(err.message, /clilocs\.tsv/)
+ return true
+ })
+})
+
+// ── Overlays ───────────────────────────────────────────────────────────────
+
+test('listCustom: no overlay directory is normal, not an error', () => {
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
+ assert.deepEqual(listCustom(dir), [])
+})
+
+test('listCustom: sorted, and only recognised extensions', () => {
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
+ write(dir, `${CUSTOM_DIR}/b.tsv`, tsv([[2, 'b']]))
+ write(dir, `${CUSTOM_DIR}/a.csv`, tsv([[3, 'c']]))
+ write(dir, `${CUSTOM_DIR}/notes.md`, 'ignore me')
+ assert.deepEqual(listCustom(dir).map((f) => path.basename(f)), ['a.csv', 'b.tsv'])
+})
+
+test('readSources: base first, then overlays, with root-relative labels', () => {
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
+ write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
+ const { files } = readSources(dir)
+ // Forward-slashed so the same directory read on Windows and Linux fingerprints
+ // identically — otherwise every boot on one of them looks like a change.
+ assert.deepEqual(files.map((f) => [f.label, f.kind]), [
+ ['clilocs.tsv', 'base'],
+ ['custom/shard.tsv', 'custom'],
+ ])
+})
+
+// ── Merging ────────────────────────────────────────────────────────────────
+
+test('readCliloc: an overlay ADDS ids the base never had', () => {
+ // The whole point: shards add items, and those carry cliloc ids no stock
+ // client table has.
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
+ write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1180001, 'Runic Gateway Sigil']]))
+ const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
+ assert.equal(byNumber.get(1023721), 'quarter staff')
+ assert.equal(byNumber.get(1180001), 'Runic Gateway Sigil')
+})
+
+test('readCliloc: an overlay OVERRIDES a stock id', () => {
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
+ write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1023721, 'gnarled staff of testing']]))
+ const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
+ assert.equal(byNumber.get(1023721), 'gnarled staff of testing')
+})
+
+test('readCliloc: later overlays beat earlier ones, deterministically', () => {
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[7, 'base']]))
+ write(dir, `${CUSTOM_DIR}/01-first.tsv`, tsv([[7, 'first']]))
+ write(dir, `${CUSTOM_DIR}/02-second.tsv`, tsv([[7, 'second']]))
+ const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
+ assert.equal(byNumber.get(7), 'second')
+})
+
+test('readCliloc: reports what each source contributed', () => {
+ // An operator who adds an overlay wants to see it took effect; "overrode: 0"
+ // on a file meant to re-label stock items says it did not.
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[1, 'a'], [2, 'b']]))
+ write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'B!'], [3, 'c']]))
+ const { source } = readCliloc(dir)
+ assert.deepEqual(source.sources, [
+ { label: 'clilocs.tsv', kind: 'base', entries: 2, added: 2, overrode: 0 },
+ { label: 'custom/shard.tsv', kind: 'custom', entries: 2, added: 1, overrode: 1 },
+ ])
+})
+
+test('readCliloc: a malformed overlay names the file it came from', () => {
+ // "Which of my six overlay files is broken" is otherwise a guessing game.
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
+ write(dir, `${CUSTOM_DIR}/broken.tsv`, 'no separators here\nnor here\n')
+ assert.throws(() => readCliloc(dir), (err) => {
+ assert.equal(err.code, 'EMPTY')
+ assert.match(err.message, /^custom\/broken\.tsv: /)
+ return true
+ })
+})
+
+test('readCliloc: a compressed BASE is still rejected by name', () => {
+ const dir = tmpdir()
+ write(dir, 'cliloc.enu', Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(32, 0x41)]))
+ assert.throws(() => readCliloc(dir), (err) => err.code === 'COMPRESSED')
+})
+
+// ── Drift over the SET ─────────────────────────────────────────────────────
+
+test('hashSources: fingerprints every source, and counts the overlays', () => {
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
+ write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
+ const fp = hashSources(dir)
+ assert.deepEqual(Object.keys(fp.hashes).sort(), ['clilocs.tsv', 'custom/shard.tsv'])
+ assert.equal(fp.customCount, 1)
+})
+
+test('sameSources: adding or editing an overlay counts as drift', () => {
+ const dir = tmpdir()
+ write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
+ const before = hashSources(dir).hashes
+
+ write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
+ const added = hashSources(dir).hashes
+ assert.equal(sameSources(before, added), false, 'a new overlay is drift')
+
+ write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b changed']]))
+ const edited = hashSources(dir).hashes
+ assert.equal(sameSources(added, edited), false, 'an edited overlay is drift')
+ assert.equal(sameSources(edited, hashSources(dir).hashes), true, 'an untouched set is not')
+})
+
+test('missingSources: a vanished source is detected, an added one is not "missing"', () => {
+ const loaded = { 'clilocs.tsv': 'aaa', 'custom/shard.tsv': 'bbb' }
+ assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, loaded), ['custom/shard.tsv'])
+ assert.deepEqual(missingSources({ ...loaded, 'custom/new.tsv': 'ccc' }, loaded), [])
+ // Nothing loaded yet (a first import) is not a vanished source.
+ assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, null), [])
+})
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index f54f728..cc4ee2d 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -30,14 +30,59 @@ test('touches no database at registration time', () => {
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
})
-test('registers nothing in slice 0', () => {
+test('registers exactly what module.json declares', () => {
+ // The loader compares these two in BOTH directions and rejects a mismatch
+ // either way, so a prefix registered without being declared and a prefix
+ // declared without being registered are both module-breaking. Asserting
+ // against the manifest rather than a literal list means the test cannot drift
+ // from the file core actually reads.
const api = fakeApi()
register(fakeCtx(), api)
- assert.strictEqual(api.record.routes, null)
- assert.strictEqual(api.record.streams, null)
- assert.deepStrictEqual(api.record.extensions, [])
- assert.deepStrictEqual(api.record.legs, [])
- assert.deepStrictEqual(api.record.hooks, {})
+
+ const manifest = require('../../module.json')
+ for (const tier of ['public', 'admin', 'player']) {
+ assert.deepStrictEqual(
+ Object.keys(api.record.routes[tier]).sort(),
+ [...manifest.mounts[tier]].sort(),
+ `${tier} mounts disagree with module.json`,
+ )
+ for (const router of Object.values(api.record.routes[tier])) {
+ assert.strictEqual(typeof router, 'function', `${tier} router is not a router`)
+ }
+ }
+
+ assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
+ assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
+ assert.ok(api.record.streams.length > 0)
+ assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
+ assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
+})
+
+test('every registered stream is namespaced or grandfathered', () => {
+ // Core rejects a stream id that carries neither this module's prefix nor a
+ // §6.5 grandfathered name. The seven legacy ids are stored in
+ // `notification_subs` and read by a shipped Android client, so they are
+ // allowlisted rather than renamed — but a NEW id must be namespaced, and this
+ // is where that is caught before an install refuses to load the module.
+ // Copied from core's loader (LEGACY_STREAM_IDS), deliberately rather than
+ // imported — this repo has no dependency on core's source, and a copy that
+ // drifts is caught by the module failing to load, which is the failure this
+ // test exists to move earlier.
+ const GRANDFATHERED = new Set([
+ 'server.status', 'idoc.warning', 'champ.start', 'governor.election',
+ 'vendor.sale', 'house.idoc', 'account.login',
+ ])
+ const api = fakeApi()
+ register(fakeCtx(), api)
+ for (const s of api.record.streams) {
+ assert.ok(
+ s.id.startsWith('uo.') || GRANDFATHERED.has(s.id),
+ `stream "${s.id}" is neither namespaced "uo." nor grandfathered`,
+ )
+ assert.ok(s.label && s.description, `stream "${s.id}" is missing its wire shape`)
+ assert.strictEqual(typeof s.personal, 'boolean')
+ assert.strictEqual(typeof s.requiresLinkedAccount, 'boolean')
+ }
})
test('takes a frozen ctx and does not try to write to it', () => {
diff --git a/server/test/manifest.test.js b/server/test/manifest.test.js
index 61697d3..557b483 100644
--- a/server/test/manifest.test.js
+++ b/server/test/manifest.test.js
@@ -71,11 +71,17 @@ test('declared mounts are single lowercase segments in known tiers', () => {
}
})
-test('notification stream ids and announce legs stay namespaced or grandfathered', () => {
- // Nothing to check yet — slice 0 registers neither. The assertion that matters
- // is that the manifest does not quietly claim capabilities the module does not
- // serve, since `GET /api/v1/public/modules` publishes them to clients.
- assert.deepStrictEqual(manifest.capabilities || [], [])
- assert.deepStrictEqual(manifest.mounts || {}, {})
- assert.deepStrictEqual(manifest.extensions || [], [])
+test('the manifest claims a coherent bundle', () => {
+ // `capabilities` is published by GET /api/v1/public/modules and is what a
+ // client feature-detects against — the SPA and the Android app both read it —
+ // so it must not claim something the module does not serve. Checked as a shape
+ // rather than a list: which capabilities exist is a product decision, that
+ // they are non-empty opaque strings is the contract.
+ for (const c of manifest.capabilities || []) {
+ assert.match(c, /^[a-z][a-z0-9-]*$/, `capability "${c}" is not an opaque lowercase id`)
+ }
+ // Declaring a mount is what makes the prefix this module's; the loader checks
+ // the declaration against what register() actually registers (entry.test.js).
+ assert.ok(Object.keys(manifest.mounts).length > 0, 'a module that mounts nothing serves nothing')
+ assert.deepStrictEqual(manifest.extensions, ['admin.users.detail'])
})
diff --git a/server/test/newsGump.test.js b/server/test/newsGump.test.js
new file mode 100644
index 0000000..b25e265
--- /dev/null
+++ b/server/test/newsGump.test.js
@@ -0,0 +1,77 @@
+// 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.
+
+const { test, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+// Exercise the News-gump sync decisions against a fake sidecar client by
+// monkeypatching the shared modules newsGump require()s (same instance) — no DB,
+// no network.
+const uoLinkClient = require('../utils/uoLinkClient')
+const { ctx } = require('./_setup')
+const settings = ctx.settings
+const newsGump = require('../utils/newsGump')
+
+let calls
+const saved = {}
+
+beforeEach(() => {
+ calls = { post: [], del: [] }
+ saved.postNews = uoLinkClient.postNews
+ saved.deleteNews = uoLinkClient.deleteNews
+ saved.get = settings.get
+ uoLinkClient.postNews = async (article) => { calls.post.push(article); return { ok: true, status: 200 } }
+ uoLinkClient.deleteNews = async (id) => { calls.del.push(id); return { ok: true, status: 200 } }
+ settings.get = async () => null // no gump image configured
+})
+
+afterEach(() => {
+ uoLinkClient.postNews = saved.postNews
+ uoLinkClient.deleteNews = saved.deleteNews
+ settings.get = saved.get
+})
+
+const newsPost = (over = {}) => ({ id: 42, category: 'news', published: true, title: 'Double XP Weekend', excerpt: 'Starts Friday.', body: null, ...over })
+
+test('buildArticle centres the title, links the news list, and respects announce', async () => {
+ const a = await newsGump.buildArticle(newsPost(), { announce: false })
+ assert.equal(a.id, '42')
+ assert.match(a.body, /
Double XP Weekend<\/CENTER>/)
+ assert.match(a.body, /Starts Friday\./)
+ assert.match(a.url, /\/site\/news$/)
+ assert.equal(a.announce, false)
+})
+
+test('a fresh publish into news pushes with announce=true', async () => {
+ await newsGump.syncPost(newsPost(), { wasPublished: false, wasNews: false })
+ assert.equal(calls.post.length, 1)
+ assert.equal(calls.post[0].announce, true)
+ assert.equal(calls.del.length, 0)
+})
+
+test('an edit of already-published news refreshes silently (announce=false)', async () => {
+ await newsGump.syncPost(newsPost({ title: 'Edited' }), { wasPublished: true, wasNews: true })
+ assert.equal(calls.post.length, 1)
+ assert.equal(calls.post[0].announce, false)
+})
+
+test('unpublishing published news pulls the article from the gump', async () => {
+ await newsGump.syncPost(newsPost({ published: false }), { wasPublished: true, wasNews: true })
+ assert.equal(calls.post.length, 0)
+ assert.deepEqual(calls.del, ['42'])
+})
+
+test('a draft never-published news post does nothing', async () => {
+ await newsGump.syncPost(newsPost({ published: false }), { wasPublished: false, wasNews: false })
+ assert.equal(calls.post.length, 0)
+ assert.equal(calls.del.length, 0)
+})
+
+test('a non-news post (e.g. screenshot) is never pushed', async () => {
+ await newsGump.syncPost(newsPost({ category: 'screenshot' }), { wasPublished: false, wasNews: false })
+ assert.equal(calls.post.length, 0)
+})
diff --git a/server/test/publicShardOnline.test.js b/server/test/publicShardOnline.test.js
new file mode 100644
index 0000000..41c92f8
--- /dev/null
+++ b/server/test/publicShardOnline.test.js
@@ -0,0 +1,109 @@
+// 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.
+
+// Staff-location visibility on GET /public/shard/online. The endpoint is
+// token-free, so it inspects the caller's session (getUserFromRequest) and only
+// includes each staff member's in-game location (map/x/y/z) for admins and
+// moderators. Players and the public still see who is online, but not where.
+//
+// Point the DB at a closed port BEFORE requiring anything that builds the pool,
+// so any stray query fails fast instead of hanging. The model + auth are stubbed,
+// so the DB is never actually hit.
+
+const { test, after, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const ctrl = require('../router/public/shard.controller')
+const shardState = require('../model/shardState/shardState.model')
+const { ctx } = require('./_setup')
+const auth = ctx.auth
+
+
+function mockRes() {
+ return {
+ statusCode: 200,
+ body: null,
+ status(c) {
+ this.statusCode = c
+ return this
+ },
+ json(b) {
+ this.body = b
+ return this
+ },
+ }
+}
+
+// One online staff member with a location the model would return.
+const ONLINE_ROW = { serial: '0x1', name: 'Lady Mod', map: 'Felucca', x: 1495, y: 1628, z: 10 }
+
+const originals = {
+ listOnlineLinked: shardState.listOnlineLinked,
+ getUserFromRequest: auth.getUserFromRequest,
+}
+afterEach(() => {
+ shardState.listOnlineLinked = originals.listOnlineLinked
+ auth.getUserFromRequest = originals.getUserFromRequest
+})
+
+// Stub the model to return the staff member, and the session to the given viewer.
+function setup(viewer) {
+ shardState.listOnlineLinked = async () => [ONLINE_ROW]
+ auth.getUserFromRequest = () => viewer
+}
+
+const LOCATION_KEYS = ['map', 'x', 'y', 'z']
+
+for (const role of ['admin', 'moderator']) {
+ test(`getOnline includes location for a ${role}`, async () => {
+ setup({ id: 1, username: 'staff', role })
+ const res = mockRes()
+ await ctrl.getOnline({}, res)
+ assert.equal(res.statusCode, 200)
+ assert.equal(res.body.length, 1)
+ const entry = res.body[0]
+ assert.equal(entry.name, 'Lady Mod')
+ assert.equal(entry.serial, '0x1')
+ assert.equal(entry.map, 'Felucca')
+ assert.equal(entry.x, 1495)
+ assert.equal(entry.y, 1628)
+ assert.equal(entry.z, 10)
+ })
+}
+
+test('getOnline omits location for a logged-in player', async () => {
+ setup({ id: 2, username: 'joe', role: 'player' })
+ const res = mockRes()
+ await ctrl.getOnline({}, res)
+ assert.equal(res.statusCode, 200)
+ const entry = res.body[0]
+ // Still shows they are online…
+ assert.equal(entry.name, 'Lady Mod')
+ assert.equal(entry.serial, '0x1')
+ // …but the location fields are absent entirely (not null/placeholder).
+ for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
+})
+
+test('getOnline omits location for an unauthenticated request', async () => {
+ setup(null) // getUserFromRequest returns null for anon callers
+ const res = mockRes()
+ await ctrl.getOnline({}, res)
+ assert.equal(res.statusCode, 200)
+ const entry = res.body[0]
+ assert.equal(entry.name, 'Lady Mod')
+ assert.equal(entry.serial, '0x1')
+ for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
+})
+
+// An editor is staff but not admin/moderator — they should not see location.
+test('getOnline omits location for an editor', async () => {
+ setup({ id: 3, username: 'ed', role: 'editor' })
+ const res = mockRes()
+ await ctrl.getOnline({}, res)
+ const entry = res.body[0]
+ for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
+})
diff --git a/server/test/shardBroadcast.visibility.test.js b/server/test/shardBroadcast.visibility.test.js
new file mode 100644
index 0000000..d25bd20
--- /dev/null
+++ b/server/test/shardBroadcast.visibility.test.js
@@ -0,0 +1,227 @@
+// 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.
+
+const { test, after, afterEach, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+const { EventEmitter } = require('node:events')
+
+// The SSE fan-out is the security boundary (docs/link/v3.md §3.6). Before v3 it
+// was a static kind allowlist; now each subscriber carries the audience rung it
+// resolved to at subscribe time, and every frame is gated + field-projected per
+// viewer. These tests pin the properties that must hold no matter how the config
+// is set:
+//
+// - the admin channel always gets the frame verbatim;
+// - a public subscriber never receives an unmapped kind;
+// - acct / webId never reach a public subscriber, at any rung below admin;
+// - two subscribers at different rungs get different frames from one event;
+// - a viewer's rung is frozen at subscribe time, not re-read per frame;
+// - if the visibility config can't be read, nothing goes out on public.
+
+const broadcast = require('../utils/shardBroadcast')
+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,
+ getConfig: visibility.getConfig,
+ viewerLevel: visibility.viewerLevel,
+}
+
+beforeEach(() => {
+ model.listAll = async () => []
+ shardLinks.listForUser = async () => []
+ visibility.invalidate()
+})
+
+afterEach(() => {
+ broadcast.closeAll()
+ model.listAll = originals.listAll
+ shardLinks.listForUser = originals.listForUser
+ visibility.getConfig = originals.getConfig
+ visibility.viewerLevel = originals.viewerLevel
+ visibility.invalidate()
+})
+
+// A fake req/res pair that records everything written to the stream.
+function fakeClient() {
+ const req = new EventEmitter()
+ const writes = []
+ const res = {
+ writeHead() {},
+ write(chunk) {
+ writes.push(chunk)
+ },
+ end() {},
+ on() {},
+ }
+ // Frames only — drop the SSE comments/retry preamble and keepalive pings.
+ const frames = () =>
+ writes
+ .filter((w) => w.startsWith('data: '))
+ .map((w) => JSON.parse(w.slice('data: '.length).trim()))
+ return { req, res, frames }
+}
+
+async function subscribeAt(level, channel = 'public') {
+ const client = fakeClient()
+ visibility.viewerLevel = async () => level
+ await broadcast.subscribe(client.req, client.res, channel)
+ return client
+}
+
+const GUILD_FRAME = {
+ kind: 'guild.update',
+ id: 7,
+ name: 'The Nameless',
+ abbr: 'TN',
+ leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
+}
+
+test('the admin channel receives the frame verbatim, acct and webId included', async () => {
+ const admin = await subscribeAt('admin', 'admin')
+ await broadcast.broadcast(GUILD_FRAME)
+ const [frame] = admin.frames()
+ assert.deepEqual(frame, GUILD_FRAME)
+})
+
+test('a public subscriber never sees acct or webId, at any rung below admin', async () => {
+ for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
+ const client = await subscribeAt(level)
+ await broadcast.broadcast(GUILD_FRAME)
+ const [frame] = client.frames()
+ assert.ok(frame, `${level} should receive the guild frame`)
+ assert.equal(frame.leader.name, 'Darrow')
+ assert.equal('acct' in frame.leader, false, `${level} must not see acct`)
+ assert.equal('webId' in frame.leader, false, `${level} must not see webId`)
+ broadcast.closeAll()
+ }
+})
+
+test('an unmapped kind reaches the admin channel and nobody else', async () => {
+ const anon = await subscribeAt('anonymous')
+ const staff = await subscribeAt('staff')
+ const admin = await subscribeAt('admin', 'admin')
+
+ for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'vendor.sale']) {
+ await broadcast.broadcast({ kind, secret: true })
+ }
+
+ assert.deepEqual(anon.frames(), [])
+ assert.deepEqual(staff.frames(), [])
+ assert.equal(admin.frames().length, 4)
+})
+
+test('one event yields different frames for subscribers at different rungs', async () => {
+ model.listAll = async () => [
+ {
+ feature: 'guilds',
+ enabled: true,
+ audience: 'anonymous',
+ stream: true,
+ fieldRules: { abbr: 'staff' },
+ },
+ ]
+ visibility.invalidate()
+
+ const anon = await subscribeAt('anonymous')
+ const staff = await subscribeAt('staff')
+ await broadcast.broadcast(GUILD_FRAME)
+
+ assert.equal('abbr' in anon.frames()[0], false)
+ assert.equal(staff.frames()[0].abbr, 'TN')
+ // Both still lose the locked fields.
+ assert.equal('acct' in staff.frames()[0].leader, false)
+})
+
+test('raising a feature audience cuts off the lower rungs mid-stream', async () => {
+ const anon = await subscribeAt('anonymous')
+ const player = await subscribeAt('player')
+
+ await broadcast.broadcast(GUILD_FRAME)
+ assert.equal(anon.frames().length, 1)
+ assert.equal(player.frames().length, 1)
+
+ // Config changes DO take effect live — only the viewer's rung is frozen.
+ model.listAll = async () => [
+ { feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} },
+ ]
+ visibility.invalidate()
+
+ await broadcast.broadcast(GUILD_FRAME)
+ assert.equal(anon.frames().length, 1, 'anonymous stops receiving')
+ assert.equal(player.frames().length, 2, 'player keeps receiving')
+})
+
+test("a subscriber's rung is frozen at subscribe time", async () => {
+ const client = await subscribeAt('anonymous')
+ // Even if the resolver would now say "admin", the open connection must not
+ // gain privilege — its level was captured when it subscribed.
+ visibility.viewerLevel = async () => 'admin'
+ await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
+ assert.deepEqual(client.frames(), [])
+})
+
+test('an unresolvable viewer subscribes as anonymous, not as privileged', async () => {
+ const client = fakeClient()
+ visibility.viewerLevel = async () => {
+ throw new Error('session lookup exploded')
+ }
+ await broadcast.subscribe(client.req, client.res, 'public')
+ await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
+ assert.deepEqual(client.frames(), [])
+
+ // ...but it still receives ordinary public traffic.
+ await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
+ assert.equal(client.frames().length, 1)
+})
+
+test('an unreadable visibility config withholds every public frame', async () => {
+ const client = await subscribeAt('anonymous')
+ visibility.getConfig = async () => {
+ throw new Error('db down')
+ }
+ await broadcast.broadcast(GUILD_FRAME)
+ assert.deepEqual(client.frames(), [])
+})
+
+test('a disabled feature stops its kinds without touching others', async () => {
+ model.listAll = async () => [
+ { feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} },
+ ]
+ visibility.invalidate()
+
+ const client = await subscribeAt('anonymous')
+ await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
+ await broadcast.broadcast({ kind: 'guild.update', id: 7 })
+
+ const kinds = client.frames().map((f) => f.kind)
+ assert.deepEqual(kinds, ['guild.update'])
+})
+
+test('a dead client is dropped rather than repeatedly retried', async () => {
+ const client = fakeClient()
+ visibility.viewerLevel = async () => 'anonymous'
+ await broadcast.subscribe(client.req, client.res, 'public')
+ assert.equal(broadcast.stats().publicClients, 1)
+
+ client.res.write = () => {
+ throw new Error('EPIPE')
+ }
+ await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
+ assert.equal(broadcast.stats().publicClients, 0)
+})
+
+test('broadcast is a no-op for a malformed event', async () => {
+ const client = await subscribeAt('anonymous')
+ await broadcast.broadcast(null)
+ await broadcast.broadcast({})
+ assert.deepEqual(client.frames(), [])
+})
diff --git a/server/test/shardControllerPublic.test.js b/server/test/shardControllerPublic.test.js
new file mode 100644
index 0000000..ae25f88
--- /dev/null
+++ b/server/test/shardControllerPublic.test.js
@@ -0,0 +1,415 @@
+// 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 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.
+
+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('../router/public/shard.controller')
+const shardEvents = require('../model/shardEvents/shardEvents.model')
+const shardState = require('../model/shardState/shardState.model')
+const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
+const broadcast = require('../utils/shardBroadcast')
+const visibility = require('../utils/shardVisibility')
+
+
+// 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('../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')
+ }
+})
+
+// ── points boards ──────────────────────────────────────────────────────
+const BOARD = {
+ system: 'QueensLoyalty',
+ nameString: "Queen's Loyalty",
+ nameNumber: 1114938,
+ maxPoints: 30000,
+ players: 842,
+ top: [
+ { rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
+ { rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
+ ],
+}
+
+test('getPointsBoards serves every board with its ranked list intact', async () => {
+ shardState.listPointsBoards = async () => [BOARD]
+ const res = mockRes()
+ await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
+ assert.equal(res.body.length, 1)
+ assert.equal(res.body[0].system, 'QueensLoyalty')
+ // The ranked list is an ARRAY through projection, not an object keyed 0/1 —
+ // the same trap the ruleset's rankThresholds assertion guards.
+ assert.ok(Array.isArray(res.body[0].top))
+ assert.equal(res.body[0].top[1].name, 'Mireille')
+})
+
+test('getPointsBoards serves an empty list before the shard has published any', async () => {
+ shardState.listPointsBoards = async () => []
+ const res = mockRes()
+ await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
+ assert.deepEqual(res.body, [])
+ assert.equal(res.statusCode, 200)
+})
+
+// §3.6.1's rule again: a shard read that does not project is a bug. Boards carry
+// no actor today — they write entries inline as {serial, name} precisely so they
+// never carry acct/webId — but the gate is what keeps that true if the shape grows.
+test('getPointsBoard projects: acct/webId never survive below admin', async () => {
+ shardState.getPointsBoard = async () => ({
+ system: 'QueensLoyalty',
+ top: [{ rank: 1, name: 'Darrow', acct: 'darrow_acct', webId: 9, points: 1 }],
+ })
+ for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
+ const res = mockRes()
+ await ctrl.getPointsBoard({ params: { system: 'QueensLoyalty' }, viewerLevel: level }, res)
+ assert.equal(res.body.top[0].acct, undefined, `${level} saw acct`)
+ assert.equal(res.body.top[0].webId, undefined, `${level} saw webId`)
+ assert.equal(res.body.top[0].name, 'Darrow', 'the ranked name is public by default')
+ }
+})
+
+// "No such system" and "a board nobody has scored in" are different answers.
+test('getPointsBoard 404s for a system the shard has never published', async () => {
+ shardState.getPointsBoard = async () => null
+ const res = mockRes()
+ await ctrl.getPointsBoard({ params: { system: 'NoSuchSystem' }, viewerLevel: 'anonymous' }, res)
+ assert.equal(res.statusCode, 404)
+})
+
+test('getPointsBoard rejects a malformed system name before touching the model', async () => {
+ let queried = false
+ shardState.getPointsBoard = async () => { queried = true; return null }
+ for (const system of ['../etc', 'a'.repeat(64), '', 'has space', '1leading']) {
+ const res = mockRes()
+ await ctrl.getPointsBoard({ params: { system }, viewerLevel: 'anonymous' }, res)
+ assert.equal(res.statusCode, 400, `${JSON.stringify(system)} should be rejected`)
+ }
+ assert.equal(queried, false, 'a malformed name must never reach the query')
+})
+
+test('getPointsBoards degrades to a 500 when the model fails, without throwing', async () => {
+ shardState.listPointsBoards = async () => {
+ throw new Error('pool down')
+ }
+ const res = mockRes()
+ await ctrl.getPointsBoards({ viewerLevel: 'anonymous' }, res)
+ assert.equal(res.statusCode, 500)
+ assert.equal(res.body.message, 'Internal Server Error')
+})
+
+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')
+})
diff --git a/server/test/shardIngest.champsPages.test.js b/server/test/shardIngest.champsPages.test.js
new file mode 100644
index 0000000..de3d93c
--- /dev/null
+++ b/server/test/shardIngest.champsPages.test.js
@@ -0,0 +1,71 @@
+const { test, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const shardIngest = require('../utils/shardIngest')
+
+// Build a set of stub deps that record the champ/page/state calls the dispatcher
+// makes, plus a spy shardEvents.append and broadcast. Only the methods the tested
+// kinds touch need to be real; the rest are no-op async so ingest() never throws.
+function makeDeps() {
+ const calls = { champUpsert: [], champRemove: [], pageUpsert: [], pageRemove: [], appended: [], broadcast: [] }
+ const noop = async () => {}
+ return {
+ calls,
+ shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
+ shardState: {
+ upsertChamp: async (ev) => { calls.champUpsert.push(ev) },
+ removeChamp: async (serial) => { calls.champRemove.push(serial) },
+ upsertPage: async (ev) => { calls.pageUpsert.push(ev) },
+ removePage: async (id) => { calls.pageRemove.push(id) },
+ // Unused by these kinds but present so any stray routing is a no-op.
+ clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, addEconomySample: noop,
+ },
+ uoLinkConfig: { recordStatus: noop },
+ broadcast: (ev) => { calls.broadcast.push(ev) },
+ // No-op push fan-out so ingest() stays hermetic (no real relay/DB).
+ pushDispatch: async () => {},
+ log: { warn() {}, info() {}, error() {} },
+ }
+}
+
+beforeEach(() => shardIngest.reset())
+
+test('champ.update routes to shardState.upsertChamp and is not written to the event log', async () => {
+ const deps = makeDeps()
+ const ev = { kind: 'champ.update', serial: '0x1', category: 'champion', name: 'Abyss', status: 'active', t: 1 }
+ const r = await shardIngest.ingest(ev, deps)
+ assert.equal(deps.calls.champUpsert.length, 1)
+ assert.equal(deps.calls.champUpsert[0].serial, '0x1')
+ assert.equal(r.logged, false) // champ.* is state-only, not appended to shard_events
+ assert.equal(deps.calls.appended.length, 0)
+ assert.equal(deps.calls.broadcast.length, 1) // still broadcast live
+})
+
+test('champ.remove routes to shardState.removeChamp', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ kind: 'champ.remove', serial: '0x2', t: 2 }, deps)
+ assert.deepEqual(deps.calls.champRemove, ['0x2'])
+})
+
+test('page.new and page.updated upsert the page; page.closed removes it', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ kind: 'page.new', pageId: '0x24C', type: 'Bug', sender: { name: 'Al' }, t: 3 }, deps)
+ await shardIngest.ingest({ kind: 'page.updated', pageId: '0x24C', handled: true, t: 4 }, deps)
+ await shardIngest.ingest({ kind: 'page.closed', pageId: '0x24C', t: 5 }, deps)
+ assert.equal(deps.calls.pageUpsert.length, 2)
+ assert.deepEqual(deps.calls.pageRemove, ['0x24C'])
+})
+
+test('admin.audit is appended to the event log (moderation history)', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest({ kind: 'admin.audit', action: 'ban', actor: 'web:jane', target: 'griefer', t: 6 }, deps)
+ assert.equal(r.logged, true)
+ assert.equal(deps.calls.appended.length, 1)
+ assert.equal(deps.calls.appended[0].kind, 'admin.audit')
+})
+
+test('champ.remove without a serial is a harmless no-op', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ kind: 'champ.remove', t: 7 }, deps)
+ assert.deepEqual(deps.calls.champRemove, [undefined])
+})
diff --git a/server/test/shardIngest.market.test.js b/server/test/shardIngest.market.test.js
new file mode 100644
index 0000000..58990af
--- /dev/null
+++ b/server/test/shardIngest.market.test.js
@@ -0,0 +1,114 @@
+const { test, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const shardIngest = require('../utils/shardIngest')
+
+// Protocol 3.0 vendor.listing / vendor.listing.remove routing. Same shape as
+// shardIngest.points.test.js: stubbed deps, asserting where the dispatcher sends
+// the frame and whether it is appended to the event log.
+function makeDeps() {
+ const calls = { upserts: [], removes: [], appended: [], broadcast: [] }
+ const noop = async () => {}
+ return {
+ calls,
+ shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
+ shardState: {
+ // Present so any stray routing is a harmless no-op rather than a crash.
+ clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
+ addEconomySample: noop, setRuleset: noop, upsertPointsBoard: noop,
+ },
+ shardMarket: {
+ upsertVendor: async (ev) => { calls.upserts.push(ev) },
+ removeVendor: async (serial) => { calls.removes.push(serial) },
+ },
+ shardLinks: { removeByAccount: noop },
+ uoLinkConfig: { recordStatus: noop },
+ broadcast: (ev) => { calls.broadcast.push(ev) },
+ pushDispatch: async () => {},
+ log: { warn() {}, info() {}, error() {} },
+ }
+}
+
+const FRAME = {
+ kind: 'vendor.listing',
+ t: 1000,
+ serial: '0x40001234',
+ shopName: "Darrow's Bargains",
+ ownerSerial: '0x1A2B',
+ ownerName: 'Darrow',
+ location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" },
+ count: 2,
+ total: 2,
+ truncated: false,
+ items: [
+ { serial: '0x40012ABC', itemId: 3922, hue: 0, amount: 1, price: 25000, name: null, cliloc: 1023721 },
+ { serial: '0x40012ABD', itemId: 7026, hue: 1157, amount: 3, price: 500, name: 'a shard sigil', cliloc: 1041243 },
+ ],
+}
+
+beforeEach(() => shardIngest.reset())
+
+test('vendor.listing routes to the market model with the whole frame', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ assert.equal(deps.calls.upserts.length, 1)
+ const stored = deps.calls.upserts[0]
+ assert.equal(stored.serial, '0x40001234')
+ assert.equal(stored.location.region, 'Britain')
+ assert.equal(stored.items.length, 2)
+})
+
+test('vendor.listing.remove routes to removeVendor with the serial', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ kind: 'vendor.listing.remove', t: 2000, serial: '0x40001234' }, deps)
+ assert.deepEqual(deps.calls.removes, ['0x40001234'])
+ assert.equal(deps.calls.upserts.length, 0)
+})
+
+// The market IS the state. One frame carries up to 250 listings and the sweep
+// re-emits a shop on any price change, so logging would turn shard_events into a
+// price history nobody reads — the strongest case of the three v3 kinds.
+test('neither market kind is appended to the event log', async () => {
+ const deps = makeDeps()
+ const a = await shardIngest.ingest(FRAME, deps)
+ const b = await shardIngest.ingest({ kind: 'vendor.listing.remove', serial: '0x40001234' }, deps)
+ assert.equal(a.logged, false)
+ assert.equal(b.logged, false)
+ assert.equal(deps.calls.appended.length, 0)
+ assert.equal(shardIngest.LOGGED_KINDS.has('vendor.listing'), false)
+ assert.equal(shardIngest.LOGGED_KINDS.has('vendor.listing.remove'), false)
+})
+
+// Broadcast is unconditional at this layer — whether it actually reaches anyone
+// is shardBroadcast's call, and the market feature ships with its stream off.
+test('vendor.listing is handed to the broadcaster', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ assert.equal(deps.calls.broadcast.length, 1)
+ assert.equal(deps.calls.broadcast[0].kind, 'vendor.listing')
+})
+
+test('a backfilled vendor.listing still stores but does not broadcast', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
+ assert.equal(deps.calls.upserts.length, 1)
+ assert.equal(deps.calls.broadcast.length, 0)
+})
+
+// The reconnect backfill replays the whole index through this path, so a single
+// bad vendor must not abort it.
+test('an upsertVendor failure does not throw or stop the broadcast', async () => {
+ const deps = makeDeps()
+ deps.shardMarket.upsertVendor = async () => { throw new Error('db down') }
+ const r = await shardIngest.ingest(FRAME, deps)
+ assert.equal(r.logged, false)
+ assert.equal(deps.calls.broadcast.length, 1)
+})
+
+// Each vendor is its own row; the frame is authoritative for that vendor only.
+test('two vendors are stored independently', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ await shardIngest.ingest({ ...FRAME, serial: '0x40009999', shopName: 'Second Shop' }, deps)
+ assert.deepEqual(deps.calls.upserts.map((v) => v.serial), ['0x40001234', '0x40009999'])
+})
diff --git a/server/test/shardIngest.points.test.js b/server/test/shardIngest.points.test.js
new file mode 100644
index 0000000..38c04ee
--- /dev/null
+++ b/server/test/shardIngest.points.test.js
@@ -0,0 +1,111 @@
+const { test, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const shardIngest = require('../utils/shardIngest')
+
+// Protocol 3.0 points.board routing. Same shape as shardIngest.ruleset.test.js:
+// stubbed deps, asserting where the dispatcher sends the frame and whether it is
+// appended to the event log.
+function makeDeps() {
+ const calls = { boards: [], appended: [], broadcast: [] }
+ const noop = async () => {}
+ return {
+ calls,
+ shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
+ shardState: {
+ upsertPointsBoard: async (ev) => { calls.boards.push(ev) },
+ // Present so any stray routing is a harmless no-op.
+ clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
+ addEconomySample: noop, setRuleset: noop,
+ },
+ shardLinks: { removeByAccount: noop },
+ uoLinkConfig: { recordStatus: noop },
+ broadcast: (ev) => { calls.broadcast.push(ev) },
+ pushDispatch: async () => {},
+ log: { warn() {}, info() {}, error() {} },
+ }
+}
+
+const FRAME = {
+ kind: 'points.board',
+ t: 1000,
+ system: 'QueensLoyalty',
+ nameString: "Queen's Loyalty",
+ nameNumber: 1114938,
+ maxPoints: 30000,
+ showOnGump: true,
+ players: 842,
+ top: [
+ { rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
+ { rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
+ ],
+}
+
+beforeEach(() => shardIngest.reset())
+
+test('points.board routes to upsertPointsBoard with the whole frame', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ assert.equal(deps.calls.boards.length, 1)
+ const stored = deps.calls.boards[0]
+ assert.equal(stored.system, 'QueensLoyalty')
+ assert.equal(stored.nameNumber, 1114938)
+ assert.equal(stored.players, 842)
+ // The ranked list must survive intact — the read model serves it from the payload.
+ assert.equal(stored.top.length, 2)
+ assert.equal(stored.top[0].name, 'Darrow')
+})
+
+// A board is state, not an event. The shard emits a frame every time anyone's
+// score moves the top ten, so logging would grow shard_events without bound for
+// something whose only interesting value is its latest version — the same call
+// guild.update already makes.
+test('points.board is NOT appended to the event log', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest(FRAME, deps)
+ assert.equal(r.logged, false)
+ assert.equal(deps.calls.appended.length, 0)
+ assert.equal(shardIngest.LOGGED_KINDS.has('points.board'), false)
+})
+
+test('points.board is broadcast (the leaderboards page updates live)', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ assert.equal(deps.calls.broadcast.length, 1)
+ assert.equal(deps.calls.broadcast[0].kind, 'points.board')
+})
+
+test('a backfilled points.board still stores but does not broadcast', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
+ assert.equal(deps.calls.boards.length, 1)
+ assert.equal(deps.calls.broadcast.length, 0)
+})
+
+// Each system is its own row, so two systems must not collide — this is the whole
+// reason the frame is per-system rather than one board of everything.
+test('two systems are stored independently', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ await shardIngest.ingest({ ...FRAME, system: 'CleanUpBritannia', nameString: null }, deps)
+ assert.deepEqual(deps.calls.boards.map((b) => b.system), ['QueensLoyalty', 'CleanUpBritannia'])
+})
+
+// A re-emitted board is an overwrite of one row, never an append.
+test('a repeated points.board overwrites rather than accumulating', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ await shardIngest.ingest({ ...FRAME, t: 2000, players: 843 }, deps)
+ assert.equal(deps.calls.appended.length, 0)
+ assert.equal(deps.calls.boards.length, 2) // two writes...
+ assert.equal(deps.calls.boards[1].system, 'QueensLoyalty') // ...of the same row
+})
+
+// A model write that throws must not kill the feed.
+test('an upsertPointsBoard failure does not throw or stop the broadcast', async () => {
+ const deps = makeDeps()
+ deps.shardState.upsertPointsBoard = async () => { throw new Error('db down') }
+ const r = await shardIngest.ingest(FRAME, deps)
+ assert.equal(r.logged, false)
+ assert.equal(deps.calls.broadcast.length, 1)
+})
diff --git a/server/test/shardIngest.protocol2.test.js b/server/test/shardIngest.protocol2.test.js
new file mode 100644
index 0000000..aa238c4
--- /dev/null
+++ b/server/test/shardIngest.protocol2.test.js
@@ -0,0 +1,121 @@
+const { test, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const shardIngest = require('../utils/shardIngest')
+
+// Stub deps recording the Protocol 2.0 board calls the dispatcher makes. Only the
+// methods the tested kinds touch need to be real; the rest are no-op async so
+// ingest() never throws on an unrelated kind.
+function makeDeps() {
+ const calls = {
+ guildUpsert: [], guildRemove: [],
+ governorUpsert: [],
+ presenceSet: [],
+ houseRegistry: [], houseRemove: [],
+ linkRemove: [],
+ appended: [], broadcast: [],
+ }
+ const noop = async () => {}
+ return {
+ calls,
+ shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
+ shardState: {
+ upsertGuild: async (ev) => { calls.guildUpsert.push(ev) },
+ removeGuild: async (id) => { calls.guildRemove.push(id) },
+ upsertGovernor: async (ev) => { calls.governorUpsert.push(ev) },
+ setPresence: async (ev) => { calls.presenceSet.push(ev) },
+ upsertHouseRegistry: async (ev) => { calls.houseRegistry.push(ev) },
+ removeHouse: async (serial) => { calls.houseRemove.push(serial) },
+ // Present so any stray routing is a harmless no-op.
+ clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
+ addEconomySample: noop,
+ },
+ shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } },
+ uoLinkConfig: { recordStatus: noop },
+ broadcast: (ev) => { calls.broadcast.push(ev) },
+ // No-op push fan-out so ingest() stays hermetic (no real relay/DB).
+ pushDispatch: async () => {},
+ log: { warn() {}, info() {}, error() {} },
+ }
+}
+
+beforeEach(() => shardIngest.reset())
+
+test('guild.update routes to upsertGuild and is not logged; guild.remove routes to removeGuild', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest({ kind: 'guild.update', id: 1042, name: 'TSH', t: 1 }, deps)
+ assert.equal(deps.calls.guildUpsert.length, 1)
+ assert.equal(deps.calls.guildUpsert[0].id, 1042)
+ assert.equal(r.logged, false) // board state, not appended to shard_events
+ await shardIngest.ingest({ kind: 'guild.remove', id: 1042, t: 2 }, deps)
+ assert.deepEqual(deps.calls.guildRemove, [1042])
+})
+
+test('guild.join is appended to the event log (real-time joins feed) and broadcast', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest(
+ { kind: 'guild.join', id: 1042, who: { name: 'Bran' }, t: 3 }, deps)
+ assert.equal(r.logged, true)
+ assert.equal(deps.calls.appended.length, 1)
+ assert.equal(deps.calls.appended[0].kind, 'guild.join')
+ assert.equal(deps.calls.broadcast.length, 1)
+})
+
+test('city.update routes to upsertGovernor (which also captures term history)', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(
+ { kind: 'city.update', city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 4 }, deps)
+ assert.equal(deps.calls.governorUpsert.length, 1)
+ assert.equal(deps.calls.governorUpsert[0].city, 'Britain')
+})
+
+test('presence.online routes to setPresence and is not logged', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest(
+ { kind: 'presence.online', count: 42, byRegion: { Britain: 18 }, t: 5 }, deps)
+ assert.equal(deps.calls.presenceSet.length, 1)
+ assert.equal(deps.calls.presenceSet[0].count, 42)
+ assert.equal(r.logged, false)
+})
+
+test('house.update routes to upsertHouseRegistry; house.remove routes to removeHouse', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ kind: 'house.update', serial: '0x40001234', name: 'Anvil', t: 6 }, deps)
+ assert.equal(deps.calls.houseRegistry.length, 1)
+ assert.equal(deps.calls.houseRegistry[0].serial, '0x40001234')
+ await shardIngest.ingest({ kind: 'house.remove', serial: '0x40001234', t: 7 }, deps)
+ assert.deepEqual(deps.calls.houseRemove, ['0x40001234'])
+})
+
+test('region.enter is broadcast-only — not logged, no state side effect', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest(
+ { kind: 'region.enter', from: 'Britain', to: 'Despise', who: { name: 'Darrow' }, t: 8 }, deps)
+ assert.equal(r.logged, false)
+ assert.equal(deps.calls.appended.length, 0)
+ assert.equal(deps.calls.broadcast.length, 1) // still surfaced live
+})
+
+test('account.unlinked reconciles the local link mirror and is logged', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest(
+ { kind: 'account.unlinked', origin: 'in-game', account: 'bob', websiteUserId: '9931', t: 9 }, deps)
+ assert.deepEqual(deps.calls.linkRemove, ['bob']) // mirror dropped
+ assert.equal(r.logged, true) // provisioning audit trail
+ assert.equal(deps.calls.appended[0].kind, 'account.unlinked')
+})
+
+test('account.audit is logged (provisioning history) but has no state side effect', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest(
+ { kind: 'account.audit', origin: 'web', action: 'create', actor: 'web:jane', target: 'bob', t: 10 }, deps)
+ assert.equal(r.logged, true)
+ assert.equal(deps.calls.linkRemove.length, 0)
+ assert.equal(deps.calls.appended[0].kind, 'account.audit')
+})
+
+test('account.audit / account.unlinked are NOT on the public SSE allowlist', () => {
+ const broadcast = require('../utils/shardBroadcast')
+ assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false)
+ assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false)
+})
diff --git a/server/test/shardIngest.ruleset.test.js b/server/test/shardIngest.ruleset.test.js
new file mode 100644
index 0000000..754074d
--- /dev/null
+++ b/server/test/shardIngest.ruleset.test.js
@@ -0,0 +1,161 @@
+const { test, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const shardIngest = require('../utils/shardIngest')
+
+// Protocol 3.0 world.ruleset routing. Same shape as shardIngest.protocol2.test.js:
+// stubbed deps, asserting where the dispatcher sends the frame and whether it is
+// appended to the event log.
+function makeDeps() {
+ const calls = { rulesetSet: [], appended: [], broadcast: [] }
+ const noop = async () => {}
+ return {
+ calls,
+ shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
+ shardState: {
+ setRuleset: async (ev) => { calls.rulesetSet.push(ev) },
+ // Present so any stray routing is a harmless no-op.
+ clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
+ addEconomySample: noop,
+ },
+ shardLinks: { removeByAccount: noop },
+ uoLinkConfig: { recordStatus: noop },
+ broadcast: (ev) => { calls.broadcast.push(ev) },
+ pushDispatch: async () => {},
+ log: { warn() {}, info() {}, error() {} },
+ }
+}
+
+const FRAME = {
+ kind: 'world.ruleset',
+ t: 1000,
+ rev: '1a2b3c4d',
+ shard: 'UOMysticmoon',
+ expansion: 'EJ',
+ systems: { cityLoyalty: true, vvv: true, factions: false },
+ caps: { skill: 1000, totalSkill: 7000 },
+}
+
+beforeEach(() => shardIngest.reset())
+
+test('world.ruleset routes to setRuleset with the whole frame', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ assert.equal(deps.calls.rulesetSet.length, 1)
+ const stored = deps.calls.rulesetSet[0]
+ assert.equal(stored.rev, '1a2b3c4d')
+ assert.equal(stored.expansion, 'EJ')
+ // The nested blocks must survive intact — the read model serves the frame whole.
+ assert.equal(stored.systems.vvv, true)
+ assert.equal(stored.caps.totalSkill, 7000)
+})
+
+// The shard re-emits world.ruleset on EVERY sidecar connect. Logging it would put
+// a duplicate row in shard_events per reconnect, and server.hello already marks
+// each of those — so this assertion is the guard on that decision.
+test('world.ruleset is NOT appended to the event log', async () => {
+ const deps = makeDeps()
+ const r = await shardIngest.ingest(FRAME, deps)
+ assert.equal(r.logged, false)
+ assert.equal(deps.calls.appended.length, 0)
+ assert.equal(shardIngest.LOGGED_KINDS.has('world.ruleset'), false)
+})
+
+test('world.ruleset is broadcast (the rules page updates live)', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ assert.equal(deps.calls.broadcast.length, 1)
+ assert.equal(deps.calls.broadcast[0].kind, 'world.ruleset')
+})
+
+// A backfill replay must reach the store but must NOT re-animate the live ticker.
+test('a backfilled world.ruleset still stores but does not broadcast', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
+ assert.equal(deps.calls.rulesetSet.length, 1)
+ assert.equal(deps.calls.broadcast.length, 0)
+})
+
+// A re-emitted identical ruleset is an overwrite, not an append: two ingests of
+// the same rev leave one stored frame's worth of state, never a growing log.
+test('a repeated world.ruleset overwrites rather than accumulating', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, deps)
+ await shardIngest.ingest({ ...FRAME, t: 2000 }, deps)
+ assert.equal(deps.calls.appended.length, 0)
+ assert.equal(deps.calls.rulesetSet.length, 2) // two writes...
+ assert.equal(deps.calls.rulesetSet[1].rev, '1a2b3c4d') // ...of the same singleton
+})
+
+// A model write that throws must not kill the feed — ingest swallows it and the
+// frame is still broadcast.
+test('a setRuleset failure does not throw or stop the broadcast', async () => {
+ const deps = makeDeps()
+ deps.shardState.setRuleset = async () => { throw new Error('db down') }
+ const r = await shardIngest.ingest(FRAME, deps)
+ assert.equal(r.logged, false)
+ assert.equal(deps.calls.broadcast.length, 1)
+})
+
+// ── Shard name fallback ────────────────────────────────────────────────────
+//
+// ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it
+// publishes that verbatim, which says "unnamed" rather than naming anything — so
+// the site answers with its own instance name instead of printing the stock
+// default under a header carrying the real one.
+//
+// Applied at INGEST, not on read, because world.ruleset is also broadcast live:
+// the same object goes to the SSE fan-out, so a read-time fix would be undone by
+// the next reconnect's frame. These tests assert both halves.
+
+function withSettings(deps, name) {
+ return { ...deps, settings: { getInstanceName: async () => name } }
+}
+
+test('the stock ServUO shard name is replaced with the instance name', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
+ assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
+})
+
+test('the substituted name reaches the live broadcast, not just the store', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
+ assert.equal(deps.calls.broadcast.length, 1)
+ assert.equal(deps.calls.broadcast[0].shard, 'UOMysticmoon')
+})
+
+test('a missing or blank shard name gets the same treatment', async () => {
+ for (const shard of [undefined, null, '', ' ']) {
+ const deps = makeDeps()
+ await shardIngest.ingest({ ...FRAME, shard }, withSettings(deps, 'UOMysticmoon'))
+ assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
+ }
+})
+
+// The match is on the whole value, case- and padding-insensitive. A shard that
+// deliberately calls itself "My Shard Reborn" has named itself and keeps it.
+test('a real name that merely contains the stock one is left alone', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest({ ...FRAME, shard: 'My Shard Reborn' }, withSettings(deps, 'UOMysticmoon'))
+ assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard Reborn')
+
+ const padded = makeDeps()
+ await shardIngest.ingest({ ...FRAME, shard: ' MY SHARD ' }, withSettings(padded, 'UOMysticmoon'))
+ assert.equal(padded.calls.rulesetSet[0].shard, 'UOMysticmoon')
+})
+
+test('a shard that named itself is never overridden by the brand', async () => {
+ const deps = makeDeps()
+ await shardIngest.ingest(FRAME, withSettings(deps, 'Some Other Brand'))
+ assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
+})
+
+// The settings read is a DB call on a path that must never fail ingest.
+test('a settings read failure leaves the frame storable', async () => {
+ const deps = makeDeps()
+ const boom = { ...deps, settings: { getInstanceName: async () => { throw new Error('db down') } } }
+ await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, boom)
+ assert.equal(deps.calls.rulesetSet.length, 1)
+ assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard')
+})
diff --git a/server/test/shardMarket.model.test.js b/server/test/shardMarket.model.test.js
new file mode 100644
index 0000000..70c18c9
--- /dev/null
+++ b/server/test/shardMarket.model.test.js
@@ -0,0 +1,263 @@
+// 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.
+// Nothing here reaches the database: these are the model's PURE parts — the
+// flatten/shape rules the frame passes through on the way in and out — plus the
+// visibility projection over the shapes they produce.
+
+const { test, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const market = require('../model/shardMarket/shardMarket.model')
+const clilocs = require('../model/shardClilocs/shardClilocs.model')
+const clilocDb = require('../model/shardClilocs/shardClilocs.db')
+const visibility = require('../utils/shardVisibility')
+
+
+// Stand in for the cliloc table. Without this each unresolved lookup waits out
+// the pool's 10s acquire timeout against the dead port — the model swallows the
+// failure exactly as it would in production (an operator who never converted a
+// cliloc file is in a supported state), so the RESULT is the same either way;
+// this only stops the suite spending half a minute proving it.
+const TABLE = new Map([[1023721, 'quarter staff']])
+clilocDb.lookup = async (numbers) =>
+ numbers.filter((n) => TABLE.has(n)).map((n) => ({ number: n, text: TABLE.get(n) }))
+
+const FRAME = {
+ kind: 'vendor.listing',
+ t: 1000,
+ serial: '0x40001234',
+ shopName: "Darrow's Bargains",
+ ownerSerial: '0x1A2B',
+ ownerName: 'Darrow',
+ location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" },
+ count: 2,
+ total: 2,
+ truncated: false,
+ items: [],
+}
+
+// ── flattenFrame ───────────────────────────────────────────────────────────
+
+test('flattenFrame lifts the nested location into columns', () => {
+ const v = market.flattenFrame(FRAME)
+ assert.equal(v.serial, '0x40001234')
+ assert.equal(v.map, 'Trammel')
+ assert.equal(v.x, 1421)
+ assert.equal(v.region, 'Britain')
+ assert.equal(v.house, "Darrow's Villa")
+})
+
+// A vendor standing in the street has no house, and a frame from an older plugin
+// may have no location at all. Neither is an error.
+test('flattenFrame tolerates a missing location entirely', () => {
+ const v = market.flattenFrame({ serial: '0x1', shopName: null })
+ assert.equal(v.map, null)
+ assert.equal(v.x, null)
+ assert.equal(v.region, null)
+ assert.equal(v.house, null)
+})
+
+// `total` is what the SHOP holds; `count` is what the frame carried. A truncated
+// shop must not report its published slice as its size, or the page says
+// "showing 250 of 250" for a vendor holding three thousand stacks.
+test('flattenFrame keeps the shop total separate from the published count', () => {
+ const v = market.flattenFrame({ ...FRAME, count: 250, total: 3104, truncated: true })
+ assert.equal(v.itemTotal, 3104)
+ assert.equal(v.truncated, true)
+})
+
+// An older plugin sends no `total`. Falling back to `count` is right — it is the
+// only number available and it is correct whenever nothing was truncated.
+test('flattenFrame falls back to count when total is absent', () => {
+ const v = market.flattenFrame({ ...FRAME, count: 7, total: undefined })
+ assert.equal(v.itemTotal, 7)
+})
+
+test('flattenFrame clips over-length strings rather than letting the insert fail', () => {
+ const v = market.flattenFrame({ ...FRAME, ownerName: 'x'.repeat(200) })
+ assert.equal(v.ownerName.length, 64)
+})
+
+// ── shapeItems ─────────────────────────────────────────────────────────────
+//
+// resolveMany never throws and, with no cliloc table reachable, resolves nothing
+// — which is exactly the state of a shard whose operator never converted one, so
+// these run against the real function rather than a stub.
+
+test('shapeItems prefers the item\'s literal name over its cliloc', async () => {
+ const items = await market.shapeItems({
+ items: [{ serial: '0x1', itemId: 3922, price: 100, name: 'a shard sigil', cliloc: 1023721 }],
+ })
+ assert.equal(items[0].displayName, 'a shard sigil')
+ // The cliloc is kept regardless, so a later import can still re-resolve it.
+ assert.equal(items[0].cliloc, 1023721)
+})
+
+test('shapeItems resolves the cliloc when the item has no literal name', async () => {
+ const items = await market.shapeItems({
+ items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 1023721 }],
+ })
+ assert.equal(items[0].displayName, 'quarter staff')
+})
+
+// The supported state for a shard whose operator never converted a cliloc file:
+// no name, not a fabricated one. Clients render the item id, exactly as they did
+// before the table existed.
+test('shapeItems leaves displayName null for an unknown cliloc', async () => {
+ const items = await market.shapeItems({
+ items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 9999999 }],
+ })
+ assert.equal(items[0].displayName, null)
+})
+
+// Unpriced rows are inventory, not listings. The shard drops them too; enforcing
+// it here as well means a plugin that stops doing so cannot put un-buyable rows
+// on the market page.
+test('shapeItems drops unpriced listings', async () => {
+ const items = await market.shapeItems({
+ items: [
+ { serial: '0x1', itemId: 1, price: 0 },
+ { serial: '0x2', itemId: 2, price: -1 },
+ { serial: '0x3', itemId: 3, price: 5 },
+ ],
+ })
+ assert.deepEqual(items.map((i) => i.serial), ['0x3'])
+})
+
+test('shapeItems caps a pathological frame', async () => {
+ const many = Array.from({ length: market.MAX_ITEMS_PER_VENDOR + 50 }, (_, i) => ({
+ serial: `0x${i}`,
+ itemId: 1,
+ price: 1,
+ }))
+ const items = await market.shapeItems({ items: many })
+ assert.equal(items.length, market.MAX_ITEMS_PER_VENDOR)
+})
+
+test('shapeItems tolerates a frame with no items array', async () => {
+ assert.deepEqual(await market.shapeItems({}), [])
+})
+
+// ── Visibility projection ──────────────────────────────────────────────────
+//
+// The regression that matters. Part A pre-wired `market.ownerName` and
+// `market.location` before the frame existed, and the sibling rule it pre-wired
+// for leaderboards (`characterName`) turned out to be INERT because projectValue
+// matches literal JSON keys. These assert the market rules actually bite — on the
+// read model AND on the wire frame, which is why both carry the same key names.
+
+const config = visibility.compileDefaults()
+
+const listing = market.shapeListing({
+ serial: '0x40012ABC',
+ item_id: 3922,
+ hue: 0,
+ amount: 1,
+ price: 25000,
+ name: null,
+ cliloc: 1023721,
+ display_name: 'quarter staff',
+ child: 0,
+ vendor_serial: '0x40001234',
+ shop_name: "Darrow's Bargains",
+ owner_serial: '0x1A2B',
+ owner_name: 'Darrow',
+ map: 'Trammel',
+ x: 1421,
+ y: 1699,
+ z: 0,
+ region: 'Britain',
+ house: "Darrow's Villa",
+ updated_at: new Date(0),
+})
+
+test('market defaults expose owner and location (they are already public in game)', () => {
+ const out = visibility.projectFeature('market', listing, 'anonymous', config)
+ assert.equal(out.vendor.ownerName, 'Darrow')
+ assert.equal(out.vendor.location.region, 'Britain')
+})
+
+test('tightening market.ownerName hides it from below that rung', () => {
+ const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'staff' } } }
+ const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
+ const staff = visibility.projectFeature('market', listing, 'staff', tightened)
+ assert.equal('ownerName' in anon.vendor, false)
+ assert.equal(staff.vendor.ownerName, 'Darrow')
+ // The shop name is a separate field and must survive — hiding the owner is not
+ // the same as hiding the shop.
+ assert.equal(anon.vendor.shopName, "Darrow's Bargains")
+})
+
+// The whole reason `location` is one nested object: a single rule has to take the
+// facet, the coordinates, the region and the house together. Five flat keys would
+// be five rules that drift apart.
+test('tightening market.location hides the whole location object at once', () => {
+ const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, location: 'player' } } }
+ const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
+ const player = visibility.projectFeature('market', listing, 'player', tightened)
+ assert.equal('location' in anon.vendor, false)
+ assert.equal(player.vendor.location.map, 'Trammel')
+})
+
+// The same rules must bite on the LIVE frame, not just the stored read model —
+// the market's SSE stream is off by default but an admin can turn it on, and a
+// field rule that only worked on one of the two paths is exactly the leak §3.6.1
+// records.
+test('the same rules apply to the raw vendor.listing frame', () => {
+ const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'admin', location: 'admin' } } }
+ const out = visibility.projectFeature('market', FRAME, 'anonymous', tightened)
+ assert.equal('ownerName' in out, false)
+ assert.equal('location' in out, false)
+ assert.equal(out.shopName, "Darrow's Bargains")
+})
+
+// Rule 1 is not configurable and does not depend on the market rules at all: a
+// frame that somehow carried an account name must never publish it.
+test('acct and webId are stripped from a market payload regardless of config', () => {
+ const out = visibility.projectFeature(
+ 'market',
+ { serial: '0x1', ownerAcct: 'darrow', webId: '42', shopName: 'Shop' },
+ 'staff',
+ config,
+ )
+ assert.equal('ownerAcct' in out, false)
+ assert.equal('webId' in out, false)
+ assert.equal(out.shopName, 'Shop')
+})
+
+// Both kinds must be attributed to a feature, or rule 2 makes them admin-only by
+// omission — which would be a silent failure rather than a loud one.
+test('both market kinds are mapped to the market feature', () => {
+ assert.equal(visibility.KIND_FEATURE.get('vendor.listing'), 'market')
+ assert.equal(visibility.KIND_FEATURE.get('vendor.listing.remove'), 'market')
+})
+
+// The market's live firehose is off by default (a page of whole vendor
+// inventories is the site's biggest bandwidth item and no page needs it live),
+// but the REST reads are unaffected — which is what `visibleKinds` ignoring the
+// stream flag encodes.
+test('market kinds are stream-suppressed by default but still readable', () => {
+ assert.equal(visibility.DEFAULT_STREAM_OFF.has('market'), true)
+ assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
+ assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
+ assert.ok(visibility.visibleKinds('anonymous', config).includes('vendor.listing'))
+})
+
+test('an admin who enables the stream gets the frames', () => {
+ const on = { ...config, market: { ...config.market, stream: true } }
+ assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', on), true)
+})
+
+// Guards the stub above against silently doing nothing: if the model stopped
+// going through db.lookup, every shapeItems assertion would still "pass" by
+// resolving nothing, which is also what a real miss looks like.
+test('the cliloc resolver is the path shapeItems resolves through', async () => {
+ const found = await clilocs.resolveMany([1023721])
+ assert.equal(found.get(1023721), 'quarter staff')
+})
diff --git a/server/test/shardState.governorTerms.test.js b/server/test/shardState.governorTerms.test.js
new file mode 100644
index 0000000..22cc6a1
--- /dev/null
+++ b/server/test/shardState.governorTerms.test.js
@@ -0,0 +1,77 @@
+const { test, beforeEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+// Term capture lives in the model (shardState.model.upsertGovernor →
+// recordGovernorTransition) and talks to the db module. We exercise the real
+// logic against an in-memory fake by monkeypatching the shared db module object
+// (same instance the model require()s) — no DB, no mocking library.
+const db = require('../model/shardState/shardState.db')
+const model = require('../model/shardState/shardState.model')
+
+let terms // in-memory shard_governor_terms
+let nextId
+const saved = {}
+
+beforeEach(() => {
+ terms = []
+ nextId = 1
+ for (const k of ['currentGovernorTerm', 'closeGovernorTerm', 'openGovernorTerm', 'upsertGovernor']) {
+ saved[k] = db[k]
+ }
+ db.currentGovernorTerm = async (city) =>
+ terms.find((t) => t.city === city && t.ended_at === null) || null
+ db.closeGovernorTerm = async (id, endedAt) => {
+ const row = terms.find((t) => t.id === id)
+ if (row) row.ended_at = endedAt
+ }
+ db.openGovernorTerm = async ({ city, serial, name, acct, webId, startedAt }) => {
+ terms.push({ id: nextId++, city, governor_serial: serial, governor_name: name,
+ governor_acct: acct, governor_web_id: webId, started_at: startedAt, ended_at: null })
+ }
+ db.upsertGovernor = async () => {} // snapshot write — irrelevant to term capture
+})
+
+function restore() {
+ for (const k of Object.keys(saved)) db[k] = saved[k]
+}
+
+test('a repeated city.update with the same governor does NOT open a second term', async () => {
+ await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
+ await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 200 })
+ const open = terms.filter((t) => t.ended_at === null)
+ assert.equal(terms.length, 1)
+ assert.equal(open.length, 1)
+ assert.equal(open[0].governor_serial, '0x1')
+ assert.equal(open[0].started_at, 100)
+ restore()
+})
+
+test('a governor change closes the old term and opens a new one', async () => {
+ await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
+ await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x2', name: 'Mira' }, t: 300 })
+ assert.equal(terms.length, 2)
+ const [first, second] = terms
+ assert.equal(first.governor_serial, '0x1')
+ assert.equal(first.ended_at, 300) // closed at the transition time
+ assert.equal(second.governor_serial, '0x2')
+ assert.equal(second.ended_at, null) // now current
+ assert.equal(second.started_at, 300)
+ restore()
+})
+
+test('a seat going vacant closes the term without opening a new one', async () => {
+ await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
+ await model.upsertGovernor({ city: 'Britain', governor: null, t: 400 })
+ assert.equal(terms.length, 1)
+ assert.equal(terms[0].ended_at, 400)
+ restore()
+})
+
+test('terms are tracked independently per city', async () => {
+ await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 100 })
+ await model.upsertGovernor({ city: 'Minoc', governor: { serial: '0x9' }, t: 120 })
+ await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 200 }) // dup, no-op
+ assert.equal(terms.length, 2)
+ assert.equal(terms.filter((t) => t.ended_at === null).length, 2)
+ restore()
+})
diff --git a/server/test/shardState.model.test.js b/server/test/shardState.model.test.js
new file mode 100644
index 0000000..1b439e7
--- /dev/null
+++ b/server/test/shardState.model.test.js
@@ -0,0 +1,253 @@
+// 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.
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+// Unit-test the shard-state model's mapping/derivation rules against a fake
+// shardState.db (no DB). These are the transforms the ingest dispatcher and the
+// public read endpoints both depend on:
+// - a partial online refresh (char.vitals) only writes the keys it carries, so
+// it never clobbers login-only fields with undefined;
+// - is_idoc is DERIVED from the decay stage, not trusted from the wire;
+// - the economy series is clamped, returned oldest→newest, and gold coerced to
+// a JS number (mariadb hands back BigInt-ish strings for large gold totals);
+// - an empty presence table reads as a well-formed zero snapshot, not null;
+// - champ/guild/governor rows fall back to hoisted columns when payload is absent;
+// - remove/upsert guard against missing identifiers instead of hitting the DB.
+const db = require('../model/shardState/shardState.db')
+const shardState = require('../model/shardState/shardState.model')
+
+// Records the (id, fields) the model hands to each db write, and serves canned
+// rows back for reads.
+let calls
+const saved = {}
+const DB_KEYS = [
+ 'upsertOnline', 'removeOnline', 'clearOnline', 'insertEconomy', 'listEconomy', 'latestEconomy',
+ 'upsertHouse', 'removeHouse', 'listIdocHouses', 'listRegistryHouses', 'setPresence', 'latestPresence',
+ 'upsertChamp', 'removeChamp', 'listChamps', 'upsertGuild', 'removeGuild', 'listGuilds',
+ 'upsertGovernor', 'listGovernors', 'listGovernorTerms', 'listOnline', 'listOnlineLinked', 'listPages',
+]
+
+beforeEach(() => {
+ calls = {}
+ for (const k of DB_KEYS) {
+ saved[k] = db[k]
+ calls[k] = []
+ db[k] = async (...args) => {
+ calls[k].push(args)
+ }
+ }
+})
+
+afterEach(() => {
+ for (const k of DB_KEYS) db[k] = saved[k]
+})
+
+// ── partial online refresh must not clobber ─────────────────────────────
+test('upsertOnline drops undefined keys so a vitals refresh keeps login fields', async () => {
+ // A char.vitals event carries hits but not name/acct — those must not be sent as
+ // undefined columns (which would overwrite the login row).
+ await shardState.upsertOnline({ serial: 5, hits: 40, hitsMax: 100 })
+ const [serial, fields] = calls.upsertOnline[0]
+ assert.equal(serial, 5)
+ assert.deepEqual(fields, { hits: 40, hits_max: 100 })
+ assert.ok(!('name' in fields), 'name not written when absent from the event')
+})
+
+test('upsertOnline maps camelCase vitals to snake_case columns', async () => {
+ await shardState.upsertOnline({ serial: 9, name: 'Bob', webId: 3, hitsMax: 90, manaMax: 50, stamMax: 70 })
+ const [, fields] = calls.upsertOnline[0]
+ assert.equal(fields.web_id, 3)
+ assert.equal(fields.hits_max, 90)
+ assert.equal(fields.mana_max, 50)
+ assert.equal(fields.stam_max, 70)
+})
+
+test('upsertOnline ignores an event with no serial (never touches the DB)', async () => {
+ await shardState.upsertOnline({ name: 'Nobody' })
+ await shardState.upsertOnline(null)
+ assert.equal(calls.upsertOnline.length, 0)
+})
+
+// ── is_idoc is derived, not trusted ─────────────────────────────────────
+test('upsertHouse derives is_idoc=1 only for the IDOC stage (case-insensitive)', async () => {
+ await shardState.upsertHouse({ serial: 1, stage: 'IDOC' })
+ await shardState.upsertHouse({ serial: 2, stage: 'idoc' })
+ await shardState.upsertHouse({ serial: 3, stage: 'Slightly' })
+ assert.equal(calls.upsertHouse[0][1].is_idoc, 1)
+ assert.equal(calls.upsertHouse[1][1].is_idoc, 1)
+ assert.equal(calls.upsertHouse[2][1].is_idoc, 0)
+})
+
+test('upsertHouseRegistry writes in_registry=1 and flattens the owner actor', async () => {
+ await shardState.upsertHouseRegistry({ serial: 7, name: 'Keep', owner: { serial: 20, acct: 'a', name: 'Liege' } })
+ const [serial, fields] = calls.upsertHouse[0]
+ assert.equal(serial, 7)
+ assert.equal(fields.in_registry, 1)
+ assert.equal(fields.owner_serial, 20)
+ assert.equal(fields.owner_name, 'Liege')
+})
+
+test('upsertHouseRegistry tolerates an abandoned house (null owner)', async () => {
+ await shardState.upsertHouseRegistry({ serial: 8, name: 'Ruin', owner: null })
+ const [, fields] = calls.upsertHouse[0]
+ assert.equal(fields.owner_serial, null)
+ assert.equal(fields.owner_name, null)
+ assert.equal(fields.in_registry, 1)
+})
+
+// ── economy series shaping ──────────────────────────────────────────────
+test('listEconomy clamps the limit, reverses to oldest→newest, and coerces gold to Number', async () => {
+ // db.listEconomy returns newest-first; the model reverses for charting.
+ db.listEconomy = async (n) => {
+ assert.equal(n, 1000, 'limit is clamped to MAX_ECONOMY')
+ return [
+ { accounts: 3, gold: '9000000000', t: 30 },
+ { accounts: 2, gold: '20', t: 20 },
+ { accounts: 1, gold: null, t: 10 },
+ ]
+ }
+ const out = await shardState.listEconomy(999999)
+ assert.deepEqual(out.map((r) => r.t), [10, 20, 30], 'oldest first')
+ assert.equal(out[2].gold, 9000000000)
+ assert.equal(typeof out[2].gold, 'number')
+ assert.equal(out[0].gold, null, 'null gold stays null, not 0')
+})
+
+test('listEconomy floors a non-positive limit to the default', async () => {
+ let seen
+ db.listEconomy = async (n) => {
+ seen = n
+ return []
+ }
+ await shardState.listEconomy(0)
+ assert.equal(seen, 100)
+})
+
+// ── presence defaults ───────────────────────────────────────────────────
+test('latestPresence returns a well-formed zero snapshot when nothing is stored', async () => {
+ db.latestPresence = async () => null
+ const out = await shardState.latestPresence()
+ assert.deepEqual(out, { count: 0, byFacet: {}, byRegion: {}, t: null })
+})
+
+test('latestPresence parses JSON string columns from the DB', async () => {
+ db.latestPresence = async () => ({ count: '12', by_facet: '{"felucca":5}', by_region: '{"Britain":3}', t: '99' })
+ const out = await shardState.latestPresence()
+ assert.equal(out.count, 12)
+ assert.deepEqual(out.byFacet, { felucca: 5 })
+ assert.equal(out.t, 99)
+})
+
+// ── payload fallback shaping ────────────────────────────────────────────
+test('listChamps returns the stored payload verbatim when present', async () => {
+ const payload = { kind: 'champ.update', serial: 1, name: 'Barracoon', custom: 'field' }
+ db.listChamps = async () => [{ serial: 1, payload: JSON.stringify(payload) }]
+ const out = await shardState.listChamps()
+ assert.deepEqual(out[0], payload)
+})
+
+test('listChamps falls back to hoisted columns for a legacy row with no payload', async () => {
+ db.listChamps = async () => [{ serial: 2, name: 'Rikktor', active: 1, boss_up: 0, payload: null }]
+ const out = await shardState.listChamps()
+ assert.equal(out[0].kind, 'champ.update')
+ assert.equal(out[0].name, 'Rikktor')
+ assert.equal(out[0].active, true)
+ assert.equal(out[0].bossUp, false)
+})
+
+test('listGuilds falls back to a shaped leader object when payload is absent', async () => {
+ db.listGuilds = async () => [{ id: 1, name: 'Order', leader_serial: 5, leader_name: 'Cap', payload: null }]
+ const out = await shardState.listGuilds()
+ assert.equal(out[0].leader.serial, 5)
+ assert.equal(out[0].leader.name, 'Cap')
+})
+
+// ── guards against missing identifiers ──────────────────────────────────
+test('remove helpers are no-ops on a falsy id (never call the DB)', async () => {
+ await shardState.removeChamp(undefined)
+ await shardState.removeHouse('')
+ await shardState.removeGuild(null)
+ assert.equal(calls.removeChamp.length, 0)
+ assert.equal(calls.removeHouse.length, 0)
+ assert.equal(calls.removeGuild.length, 0)
+})
+
+test('removeGuild treats id 0 as a real id (0 != null) but skips null/undefined', async () => {
+ await shardState.removeGuild(0)
+ assert.equal(calls.removeGuild.length, 1, 'guild id 0 is valid')
+})
+
+test('upsertChamp/upsertGuild/upsertGovernor ignore events missing their key', async () => {
+ await shardState.upsertChamp({ name: 'no serial' })
+ await shardState.upsertGuild({ name: 'no id' })
+ await shardState.upsertGovernor({ governor: {} }) // no city
+ assert.equal(calls.upsertChamp.length, 0)
+ assert.equal(calls.upsertGuild.length, 0)
+ assert.equal(calls.upsertGovernor.length, 0)
+})
+
+// ── read-shaping locks the camelCase API/app contract ───────────────────
+// A field-name regression in these serializers silently breaks the public site
+// and the Android client, so pin the shapes the read endpoints emit.
+test('listOnline maps snake_case columns to the camelCase player shape', async () => {
+ db.listOnline = async () => [
+ { serial: 1, name: 'A', acct: 'acc', web_id: 7, hits: 10, hits_max: 100, mana_max: 50, stam_max: 60, updated_at: 'ts' },
+ ]
+ const [p] = await shardState.listOnline()
+ assert.equal(p.webId, 7)
+ assert.equal(p.hitsMax, 100)
+ assert.equal(p.manaMax, 50)
+ assert.equal(p.stamMax, 60)
+ assert.equal(p.updatedAt, 'ts')
+ assert.ok(!('web_id' in p), 'no snake_case leaks into the API shape')
+})
+
+test('listIdoc shapes houses and coerces isIdoc/price', async () => {
+ db.listIdocHouses = async () => [{ serial: 3, is_idoc: 1, price: '5000', in_registry: 1, owner_serial: 2 }]
+ const [h] = await shardState.listIdoc()
+ assert.equal(h.isIdoc, true)
+ assert.equal(h.price, 5000)
+ assert.equal(typeof h.price, 'number')
+ assert.equal(h.inRegistry, true)
+})
+
+test('listPages folds the sender columns into a nested actor and coerces flags', async () => {
+ db.listPages = async () => [
+ { page_id: 42, type: 'gm', sender_name: 'Help', sender_acct: 'x', web_id: 9, handled: 0, sent_ms: '1234', payload: null },
+ ]
+ const [pg] = await shardState.listPages()
+ assert.equal(pg.pageId, 42)
+ assert.deepEqual(pg.sender, { serial: 42, name: 'Help', acct: 'x', webId: 9 })
+ assert.equal(pg.handled, false)
+ assert.equal(pg.sentMs, 1234)
+})
+
+test('listGovernors falls back to a shaped governor object when payload is absent', async () => {
+ db.listGovernors = async () => [
+ { city: 'Britain', governor_serial: 5, governor_name: 'Lord', governor_acct: 'a', election_phase: 'none', payload: null },
+ ]
+ const [g] = await shardState.listGovernors()
+ assert.equal(g.kind, 'city.update')
+ assert.equal(g.city, 'Britain')
+ assert.equal(g.governor.name, 'Lord')
+ assert.equal(g.governorElect, null)
+})
+
+test('listGovernorHistory coerces started/ended timestamps to numbers and clamps the limit', async () => {
+ let seenLimit
+ db.listGovernorTerms = async (city, n) => {
+ seenLimit = n
+ return [{ city, governor_serial: 1, governor_name: 'X', started_at: '100', ended_at: null, votes: 3 }]
+ }
+ const out = await shardState.listGovernorHistory('Trinsic', 99999)
+ assert.equal(seenLimit, 500) // clamped to the 500 max
+ assert.equal(out[0].startedAt, 100)
+ assert.equal(typeof out[0].startedAt, 'number')
+ assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
+})
diff --git a/server/test/shardStreams.test.js b/server/test/shardStreams.test.js
new file mode 100644
index 0000000..a60c6c4
--- /dev/null
+++ b/server/test/shardStreams.test.js
@@ -0,0 +1,151 @@
+// `mapShardEvent` and the shard-event push fan-out, moved out of core's
+// pushDispatch.test.js in Phase 3 (MODULE_SYSTEM.md §2.7.1).
+//
+// Core keeps the push INFRASTRUCTURE and its tests — the SSRF guard on an ntfy
+// endpoint, and `publish()` sending a content-free tickle. What is here is the
+// CATALOG and the mapping into it: which shard event becomes which stream, which
+// kinds are owner-keyed, and which must never produce a public target. That last
+// one is a security boundary and it is module-owned by design (MODULE_API.md
+// §2.4) — the kinds, the streams and the public-safety filter are one file that
+// moves together.
+
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+
+const { fakeCtx } = require('./_fakes')
+require('../core').init(fakeCtx())
+
+const { mapShardEvent, createTracker } = require('../config/shardStreams')
+const shardPush = require('../utils/shardPush')
+
+// Moved with the fromShardEvent tests. They set NTFY_* because the push
+// dispatcher they hand results to reads them — core's variables, read by core's
+// code, which is why this stays a plain env helper rather than becoming
+// something on ctx: the module never reads these itself.
+// ── mapShardEvent ───────────────────────────────────────────────────────────
+
+test('server.hello / shutdown / crashed map to the public server.status stream', () => {
+ const t = createTracker()
+ assert.deepEqual(mapShardEvent({ kind: 'server.hello', bootId: 'b1' }, t), [
+ { streamId: 'server.status', ref: 'up:b1' },
+ ])
+ assert.deepEqual(mapShardEvent({ kind: 'server.shutdown' }, t), [{ streamId: 'server.status', ref: 'down' }])
+ assert.deepEqual(mapShardEvent({ kind: 'server.crashed' }, t), [{ streamId: 'server.status', ref: 'down' }])
+})
+
+test('house.decay INTO idoc yields the public idoc.warning AND the owner-keyed house.idoc', () => {
+ const t = createTracker()
+ const out = mapShardEvent({ kind: 'house.decay', to: 'IDOC', serial: '0x40', ownerAcct: 'bob' }, t)
+ assert.deepEqual(out, [
+ { streamId: 'idoc.warning', ref: '0x40' },
+ { streamId: 'house.idoc', ref: '0x40', ownerAccount: 'bob' },
+ ])
+ // A non-IDOC decay stage produces nothing.
+ assert.deepEqual(mapShardEvent({ kind: 'house.decay', to: 'Fairly', serial: '0x41' }, t), [])
+})
+
+test('champ.update fires champ.start only on the inactive→active transition', () => {
+ const t = createTracker()
+ // First sight active → start.
+ assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
+ { streamId: 'champ.start', ref: 'c1' },
+ ])
+ // Still active → no re-fire.
+ assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [])
+ // Goes inactive, then active again → fires again.
+ assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: false }, t), [])
+ assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
+ { streamId: 'champ.start', ref: 'c1' },
+ ])
+})
+
+test('city.update fires governor.election only on a real governor change, never on first sight', () => {
+ const t = createTracker()
+ // First sight of the city → no election (could be a reconnect snapshot).
+ assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
+ // Same governor → nothing.
+ assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
+ // New governor → election.
+ assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x2' } }, t), [
+ { streamId: 'governor.election', ref: 'Britain' },
+ ])
+})
+
+test('personal streams are owner-keyed and sensitive kinds never yield a public target', () => {
+ const t = createTracker()
+ const sale = mapShardEvent({ kind: 'vendor.sale', ownerAcct: 'bob', t: 7 }, t)
+ assert.deepEqual(sale, [{ streamId: 'vendor.sale', ref: '7', ownerAccount: 'bob' }])
+
+ const login = mapShardEvent({ kind: 'account.login.attempt', acct: 'bob', ip: '1.2.3.4', t: 9 }, t)
+ assert.deepEqual(login, [{ streamId: 'account.login', ref: '9', ownerAccount: 'bob' }])
+
+ // Every personal target carries an ownerAccount (never a bare public push).
+ for (const target of [...sale, ...login]) assert.ok(target.ownerAccount, 'personal target must be owner-keyed')
+
+ // A truly sensitive, unmapped kind produces nothing at all.
+ assert.deepEqual(mapShardEvent({ kind: 'cheat.fastwalk', acct: 'bob' }, t), [])
+ assert.deepEqual(mapShardEvent({ kind: 'admin.audit', actor: 'staff' }, t), [])
+})
+
+
+// ── fromShardEvent (owner resolution) ───────────────────────────────────────
+//
+// **These two tests changed shape in the move, and the change is the boundary.**
+// In core they asserted through `publish()` into `pushDevices` and a captured
+// fetch — which endpoints were hit, how many requests went out. None of that is
+// this module's any more: `publish` is `ctx.push.publish`, core's, and the device
+// registry and the relay are behind it. Reaching for them from here would mean
+// reaching past `ctx`, which is exactly what §5.1 forbids.
+//
+// What remains is what the module actually owns, and it is the part worth
+// guarding: a game account resolves to a website user through `shardLinks`, a
+// personal target that resolves to nobody is dropped rather than published, and
+// a public target publishes with no owner. Core's own pushDispatch tests still
+// cover the fan-out on the other side of the seam.
+
+/** Record what the module asked core to publish. */
+function capturePublish() {
+ const calls = []
+ return { calls, publish: async (streamId, opts) => { calls.push({ streamId, ...opts }) } }
+}
+
+test('fromShardEvent resolves a personal event to the owning user, or drops it if unlinked', async () => {
+ const { calls, publish } = capturePublish()
+ const shardLinks = { getByAccount: async (acct) => (acct === 'mine' ? { userId: 42 } : null) }
+ const deps = { shardLinks, publish, tracker: createTracker() }
+
+ await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].streamId, 'vendor.sale')
+ assert.equal(calls[0].ownerUserId, 42, 'the game account must resolve to the website user')
+
+ // Unlinked account → nobody to notify → nothing published. Not an error: a
+ // player who never linked their account is the ordinary case, not a fault.
+ calls.length = 0
+ await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
+ assert.equal(calls.length, 0)
+})
+
+test('fromShardEvent publishes a public shard event with no owner', async () => {
+ const { calls, publish } = capturePublish()
+ await shardPush.fromShardEvent(
+ { kind: 'server.hello', bootId: 'b1' },
+ { publish, tracker: createTracker(), shardLinks: { getByAccount: async () => null } },
+ )
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].streamId, 'server.status')
+ assert.equal(calls[0].ownerUserId, undefined, 'a public target must not be owner-keyed')
+})
+
+test('a sensitive kind never reaches publish at all', async () => {
+ // The public-safety filter is module-internal by design (MODULE_API.md §2.4):
+ // the kinds, the streams and the filter are one file that moves together, so
+ // core never holds a rule about data only this module defines. Which makes
+ // this the right side of the boundary for the test too.
+ const { calls, publish } = capturePublish()
+ const deps = { publish, tracker: createTracker(), shardLinks: { getByAccount: async () => null } }
+ for (const kind of ['cheat.fastwalk', 'admin.audit']) {
+ await shardPush.fromShardEvent({ kind, acct: 'bob', actor: 'staff' }, deps)
+ }
+ assert.equal(calls.length, 0)
+})
diff --git a/server/test/shardVisibility.test.js b/server/test/shardVisibility.test.js
new file mode 100644
index 0000000..6a49368
--- /dev/null
+++ b/server/test/shardVisibility.test.js
@@ -0,0 +1,426 @@
+// 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('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 `Acct` / `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']
+
+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')
+})
diff --git a/server/test/spawnAtlas.parse.test.js b/server/test/spawnAtlas.parse.test.js
new file mode 100644
index 0000000..76d4bbc
--- /dev/null
+++ b/server/test/spawnAtlas.parse.test.js
@@ -0,0 +1,601 @@
+const { test } = require('node:test')
+const assert = require('node:assert/strict')
+
+const {
+ parseXml,
+ parseObjects2,
+ parsePoints,
+ parseRegions,
+ parseLocations,
+ parseChampions,
+ buildPlacementIndex,
+ resolveRegion,
+ facetKey,
+ buildFacetIndex,
+ resolveFacetName,
+ slugify,
+ decodeEntities,
+} = require('../utils/spawnAtlasParse')
+
+// These parsers are pure and fs-free precisely so this suite can run in CI,
+// where there is no ServUO tree. Every fixture below is a literal excerpt of a
+// real shard file, trimmed — not invented shapes.
+
+// ── parseObjects2 ──────────────────────────────────────────────────────────
+
+test('parseObjects2: single type', () => {
+ const types = parseObjects2('Jacob:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1')
+ assert.deepEqual(types, [{ type: 'Jacob', max: 1 }])
+})
+
+test('parseObjects2: splits six types on :OBJ= and keeps each MX', () => {
+ // Verbatim from trammel.xml — the case that a naive split(':') destroys.
+ const raw =
+ 'Gazer:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
+ ':OBJ=Giantspider:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
+ ':OBJ=Harpy:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
+ ':OBJ=Headlessone:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
+ ':OBJ=Lizardman:MX=3:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
+ ':OBJ=Mongbat:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1'
+ const types = parseObjects2(raw)
+ assert.equal(types.length, 6)
+ assert.deepEqual(
+ types.map((t) => t.type),
+ ['Gazer', 'Giantspider', 'Harpy', 'Headlessone', 'Lizardman', 'Mongbat'],
+ )
+ // MX is per type, not per spawner: the lizardman entry carries 3.
+ assert.equal(types.find((t) => t.type === 'Lizardman').max, 3)
+ assert.equal(types.find((t) => t.type === 'Gazer').max, 1)
+})
+
+test('parseObjects2: empty and whitespace values yield no types', () => {
+ assert.deepEqual(parseObjects2(''), [])
+ assert.deepEqual(parseObjects2(' '), [])
+ assert.deepEqual(parseObjects2(null), [])
+ assert.deepEqual(parseObjects2(undefined), [])
+})
+
+test('parseObjects2: strips XmlSpawner property directives after "/"', () => {
+ // Left in place these become creatures that do not exist.
+ assert.deepEqual(parseObjects2('Agralem/Name/Agralem:MX=1'), [{ type: 'Agralem', max: 1 }])
+ assert.deepEqual(parseObjects2('alchemist/z/-50:MX=1'), [{ type: 'alchemist', max: 1 }])
+ assert.deepEqual(parseObjects2('GargishRefugee/hue/34532'), [
+ { type: 'GargishRefugee', max: 1 },
+ ])
+})
+
+test('parseObjects2: strips argument lists after ","', () => {
+ assert.deepEqual(parseObjects2('Fairy,{RND,4,8}:MX=1'), [{ type: 'Fairy', max: 1 }])
+ assert.deepEqual(parseObjects2('GargishRouser,1'), [{ type: 'GargishRouser', max: 1 }])
+ assert.deepEqual(parseObjects2('greatape,true'), [{ type: 'greatape', max: 1 }])
+})
+
+test('parseObjects2: a directive-laden token slugs the same as the bare one', () => {
+ // The bug this closes: `Fairy` and `Fairy,{RND,4,8}` slugged apart and showed
+ // as two different creatures on the same page.
+ const bare = parseObjects2('Fairy:MX=1')[0]
+ const decorated = parseObjects2('Fairy,{RND,4,8}:MX=1')[0]
+ assert.equal(slugify(decorated.type), slugify(bare.type))
+})
+
+test('parseObjects2: strips a long EQUIP directive chain containing "<" and ">"', () => {
+ const raw =
+ 'xmlquestnpc/UNEQUIP,Innertorso/UNEQUIP,MiddleTorso/EQUIP//blessed/true/name/lord blackthorne/z/:MX=1'
+ assert.deepEqual(parseObjects2(raw), [{ type: 'xmlquestnpc', max: 1 }])
+})
+
+test('parseObjects2: a token that is only a directive yields nothing', () => {
+ assert.deepEqual(parseObjects2('/Name/Foo:MX=1'), [])
+ assert.deepEqual(parseObjects2(',1:MX=1'), [])
+})
+
+test('parseObjects2: a type with no MX token defaults to 1', () => {
+ assert.deepEqual(parseObjects2('Orc'), [{ type: 'Orc', max: 1 }])
+ assert.deepEqual(parseObjects2('Orc:SB=0:RT=0'), [{ type: 'Orc', max: 1 }])
+})
+
+// ── parsePoints ────────────────────────────────────────────────────────────
+
+const POINTS_XML = `
+
+ CovetousSpawner26
+ 001a34e5-0efa-46de-9c93-b6a163d96370
+
+ 5412
+ 1970
+ 10
+ 10
+ 5
+ 3
+ 5
+ 10
+ 500
+ 0
+ 0
+ 0
+ True
+ Lizardman:MX=3:SB=0
+
+
+ Disabled
+
+ 100
+ 200
+ 1
+ False
+ Orc:MX=1
+
+`
+
+test('parsePoints: reads the kept fields and drops the rest', () => {
+ const points = parsePoints(POINTS_XML)
+ assert.equal(points.length, 2)
+ const covetous = points[0]
+ assert.equal(covetous.name, 'CovetousSpawner26')
+ assert.equal(covetous.facet, 'Trammel')
+ assert.equal(covetous.x, 5412)
+ assert.equal(covetous.y, 1970)
+ assert.equal(covetous.width, 10)
+ assert.equal(covetous.range, 5)
+ assert.equal(covetous.maxCount, 3)
+ // Delays are normalised to seconds; this record carries no DelayInSec, which
+ // means minutes.
+ assert.equal(covetous.minDelay, 300)
+ assert.equal(covetous.maxDelay, 600)
+ assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
+ // Dropped fields must not survive into the artifact — this is what keeps it
+ // under 1 MB.
+ assert.equal(covetous.uniqueId, undefined)
+ assert.equal(covetous.proximityTriggerSound, undefined)
+})
+
+test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => {
+ const points = parsePoints(POINTS_XML)
+ assert.equal(points[0].running, true)
+ assert.equal(points[1].running, false)
+})
+
+test('parsePoints: facet comes from