From f30ea66fceec451539d92fbde2e5093b0e59df7d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 10:49:55 -0500 Subject: [PATCH] fix(shard): enforce visibility on the REST reads that bypassed it Protocol 3.0 Part A follow-up, found by the live five-rung smoke test. Part A implemented the visibility framework correctly on the SSE path and on /guilds + /governors, but the remaining public REST reads never called into it. The result was that one event was projected live and served verbatim from history: * GET /public/shard/feed returned the stored payload as-is, so actor.acct and actor.webId were readable ANONYMOUSLY for every logged kind - player.death, player.murdered, mob.killed, quest.complete, skill.gain, fame/karma.change, mob.login/logout, guild.join. Broader than the guild-leader leak Part A set out to close, since it covers every player rather than board holders. * GET /public/shard/idoc returned ownerAcct - the house owner's game account - to anonymous callers. * The `houses` field rules (owner/price -> staff) were dead config: neither getIdoc nor getHouses projected, so an admin could set them in the panel and nothing happened. * /feed filtered on PUBLIC_KINDS, a module-load constant derived from the compiled DEFAULTS, so live audience changes did not reach it. With `guilds` moved to staff, /guilds 403'd while /feed happily served guild.join to anonymous. Four fixes, all at the root rather than per-route: 1. Rule 1 now matches a field's MEANING, not one spelling. The wire nests actors (leader.acct) but the read models flatten them (shapeHouse -> ownerAcct, shapeGuild -> leaderWebId), and an exact-key check missed every flattened one. isLockedField() locks a key that is or ends in acct/webId, case-insensitively, so it fails closed for shapes not yet written. The admin PUT rejects those spellings too - `ownerAcct` is no longer configurable. 2. visibleKinds(level, config) resolves readable kinds from the LIVE config; getFeed uses it and projects each row against its own kind's feature. Deliberately independent of the `stream` flag, which governs SSE fan-out only - so market history stays readable with its firehose off. This makes the set a superset of PUBLIC_KINDS by exactly the two vendor kinds. 3. getIdoc/getHouses/getChamps/getPresence project, so every shard surface honours the same config. 4. shardEvents.db.list treats an EMPTY kinds array as "serve nothing". It previously fell through to the unfiltered query, so a fully-gated config would have dumped the whole event log, staff audit included. Also fixes a bug introduced while wiring this up: projectValue recursed into any object, so a Date column came back as {}. It now walks arrays and plain objects only. The unit tests used JSON fixtures and could not have caught it - the live /idoc read did. Verified live against MariaDB + a stub sidecar, all five rungs: 13 routes x 5 rungs, defaults reproducing pre-v3 access exactly, zero acct/webId below admin on any read, unmapped kinds (staff.command, cheat.detect, login.attempt) reaching only admin on SSE, and audience / enabled / stream changes taking effect live on an already-open stream. Tests: 487 server (+9). Swagger regenerated; route manifest unchanged. Co-Authored-By: Claude --- .../src/model/shardEvents/shardEvents.db.js | 5 + .../v1/admin/shardVisibility.controller.js | 5 +- .../src/router/v1/public/shard.controller.js | 58 ++++++-- server/src/router/v1/public/shard.router.js | 4 +- server/src/utils/shardVisibility.js | 64 ++++++++- server/swagger/swagger-output.json | 6 +- server/test/shardControllerPublic.test.js | 125 +++++++++++++++++- server/test/shardVisibility.test.js | 61 +++++++++ 8 files changed, 306 insertions(+), 22 deletions(-) diff --git a/server/src/model/shardEvents/shardEvents.db.js b/server/src/model/shardEvents/shardEvents.db.js index 6c8f864..5e424f4 100644 --- a/server/src/model/shardEvents/shardEvents.db.js +++ b/server/src/model/shardEvents/shardEvents.db.js @@ -16,6 +16,11 @@ async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) { // `kinds` (IN clause) — the public feed uses the allowlist so it can never leak // staff/sensitive kinds. limit is clamped by the model. async function list({ kind, kinds, limit }) { + // An allowlist that resolved to NOTHING means "serve nothing" — never "serve + // everything". Falling through to the unfiltered query below would have turned + // a fully-gated visibility config into a full dump of the event log, staff + // audit and cheat detections included. + if (kinds && kinds.length === 0) return [] if (kinds && kinds.length) { const placeholders = kinds.map(() => '?').join(', ') return query( diff --git a/server/src/router/v1/admin/shardVisibility.controller.js b/server/src/router/v1/admin/shardVisibility.controller.js index 62a31fa..eab2bd6 100644 --- a/server/src/router/v1/admin/shardVisibility.controller.js +++ b/server/src/router/v1/admin/shardVisibility.controller.js @@ -57,7 +57,10 @@ async function putVisibility(req, res) { const fieldRules = {} for (const [field, level] of Object.entries(patch.fieldRules || {})) { - if (Object.hasOwn(visibility.LOCKED_FIELDS, field)) { + // Matches flattened spellings too (`ownerAcct`, `leaderWebId`), so the + // rejection covers every way the field can be named rather than the two + // canonical keys. + if (visibility.isLockedField(field)) { return res.status(400).json({ message: `Field '${field}' is admin-only and cannot be configured` }) } if (!visibility.isLevel(level)) { diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js index a0851bf..9cc1a7d 100644 --- a/server/src/router/v1/public/shard.controller.js +++ b/server/src/router/v1/public/shard.controller.js @@ -39,21 +39,49 @@ async function getStatus(req, res) { } } -// GET /public/shard/feed?kind=&limit= — recent notable events from the log, -// restricted to the public-safe allowlist so staff audit / cheat / link events -// (which are stored for the admin channel) can never leak to the public. +// GET /public/shard/feed?kind=&limit= — recent notable events from the log. +// +// This is the stored-history twin of the SSE stream, and it must reach the same +// verdict the stream does about the same event. Two things are therefore resolved +// against the LIVE config rather than the compiled defaults: +// +// • which kinds this viewer may read at all — `visibleKinds`, not the static +// PUBLIC_KINDS set (which is fixed at module load, so an admin moving +// `guilds` to `staff` would gate /guilds while /feed kept serving +// guild.join to anonymous callers), and +// • the payload itself, projected per event against ITS OWN kind's feature — +// the rows are a mix of features, and without this the stored frames were +// returned verbatim, `acct`/`webId` and all, on an anonymous endpoint. async function getFeed(req, res) { try { + const config = await visibility.getConfig() + const level = req.viewerLevel || (await visibility.viewerLevel(req)) + const allowed = new Set(visibility.visibleKinds(level, config)) + const { kind, limit } = req.query + // No readable kinds ⇒ nothing to serve. Returning early also keeps us clear + // of `list({ kinds: [] })`, which means "no filter", not "match nothing". + if (allowed.size === 0) return res.json([]) + let events if (kind) { - // A specific kind is only served if it is itself public-safe. - if (!broadcast.PUBLIC_KINDS.has(kind)) return res.json([]) + if (!allowed.has(kind)) return res.json([]) events = await shardEvents.list({ kind, limit }) } else { - events = await shardEvents.list({ kinds: [...broadcast.PUBLIC_KINDS], limit }) + events = await shardEvents.list({ kinds: [...allowed], limit }) } - return res.json(events) + + return res.json( + events.map((ev) => ({ + ...ev, + payload: visibility.projectFeature( + visibility.KIND_FEATURE.get(ev.kind), + ev.payload, + level, + config, + ), + })), + ) } catch (err) { log.error('shard.getFeed', err) return res.status(500).json({ message: 'Internal Server Error' }) @@ -106,9 +134,14 @@ async function getOnline(req, res) { } // GET /public/shard/idoc — houses currently in danger (stage IDOC). +// +// Projected: shapeHouse flattens the owner actor into `ownerSerial`/`ownerAcct`/ +// `ownerName`, so this endpoint used to hand an anonymous caller the house +// owner's GAME ACCOUNT NAME. The public IDOC board only ever needed name, region +// and location — which is all that survives projection below `staff`. async function getIdoc(req, res) { try { - return res.json(await shardState.listIdoc()) + return res.json(await visibility.project('houses', await shardState.listIdoc(), req)) } catch (err) { log.error('shard.getIdoc', err) return res.status(500).json({ message: 'Internal Server Error' }) @@ -120,7 +153,7 @@ async function getIdoc(req, res) { // the public SSE stream so the page can update in place. async function getChamps(req, res) { try { - return res.json(await shardState.listChamps()) + return res.json(await visibility.project('champs', await shardState.listChamps(), req)) } catch (err) { log.error('shard.getChamps', err) return res.status(500).json({ message: 'Internal Server Error' }) @@ -170,7 +203,7 @@ async function getGovernorHistory(req, res) { // + per-region). Live via presence.online on the public SSE stream. async function getPresence(req, res) { try { - return res.json(await shardState.latestPresence()) + return res.json(await visibility.project('presence', await shardState.latestPresence(), req)) } catch (err) { log.error('shard.getPresence', err) return res.status(500).json({ message: 'Internal Server Error' }) @@ -194,7 +227,10 @@ async function getHouses(req, res) { z: h.z, isIdoc: true, })) - return res.json(publicHouses) + // Already a hand-picked safe subset; projected anyway so an admin who + // tightens a `houses` field rule sees it honoured on every houses surface + // rather than on some of them. + return res.json(await visibility.project('houses', publicHouses, req)) } catch (err) { log.error('shard.getHouses', err) return res.status(500).json({ message: 'Internal Server Error' }) diff --git a/server/src/router/v1/public/shard.router.js b/server/src/router/v1/public/shard.router.js index 338cc8b..49da8ca 100644 --- a/server/src/router/v1/public/shard.router.js +++ b/server/src/router/v1/public/shard.router.js @@ -42,7 +42,8 @@ shardRouter.get( requireFeature('activity'), // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'Recent notable shard events (from the ingested log)' - // #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale.' } + // #swagger.description = 'The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\'s audience rung under the live visibility config, and each event\'s payload is field-projected against its own kind\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.' + // #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind.' } // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' } /* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */ query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }), @@ -74,6 +75,7 @@ shardRouter.get( requireFeature('houses'), // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'Houses currently in danger (IDOC)' + // #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\'s field rules (default `staff`), and the owner\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.' /* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ shard.getIdoc, ) diff --git a/server/src/utils/shardVisibility.js b/server/src/utils/shardVisibility.js index 70d8c39..96423ee 100644 --- a/server/src/utils/shardVisibility.js +++ b/server/src/utils/shardVisibility.js @@ -62,6 +62,23 @@ const rank = viewerRank const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' } +// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire +// frames nest actors (`leader.acct`), but several read models flatten them +// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits +// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every +// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to +// anonymous callers while the same account name was correctly stripped from the +// live `house.decay` frame. +// +// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively +// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this +// fail closed for shapes nobody has written yet. +const LOCKED_SUFFIXES = ['acct', 'webid'] +const isLockedField = (key) => { + const k = String(key).toLowerCase() + return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix)) +} + const FEATURES = { // ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ── status: { audience: 'anonymous', fields: {} }, @@ -70,7 +87,14 @@ const FEATURES = { guilds: { audience: 'anonymous', fields: {} }, governors: { audience: 'anonymous', fields: {} }, // The public Houses page showed IDOC location only; owner/price were staff. - houses: { audience: 'anonymous', fields: { owner: 'staff', price: 'staff' } }, + // `owner` is the actor object on the house.decay/house.update frames; + // `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the + // REST read models. Both are listed so one rule covers the wire and the read + // model — the flattened `ownerAcct` needs no entry, being locked by rule 1. + houses: { + audience: 'anonymous', + fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' }, + }, // /public/shard/online listed linked staff to everyone but gated location to // admin+moderator — which is exactly the `staff` rung. presence: { audience: 'anonymous', fields: { location: 'staff' } }, @@ -168,7 +192,7 @@ function applyRow(name, row) { const audience = isLevel(row?.audience) ? row.audience : base.audience const fields = { ...base.fields } for (const [field, level] of Object.entries(row?.fieldRules || {})) { - if (Object.hasOwn(LOCKED_FIELDS, field)) continue // rule 1: not configurable + if (isLockedField(field)) continue // rule 1: not configurable if (isLevel(level)) fields[field] = level } return { @@ -286,12 +310,25 @@ function requireFeature(name) { // first (so acct/webId can never survive below admin), then the feature's // configured field rules. Recurses into arrays and nested objects because the // sensitive fields sit inside actor sub-objects (guild.leader, city.governor). +// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class +// instance is a value, not a bag of fields: rebuilding one key-by-key would +// return `{}` (a Date has no enumerable own properties), which is how the DB- +// backed read models — whose rows carry real Date columns — differ from the +// pure-JSON wire frames the projection was first written against. +const isPlainObject = (v) => { + if (v === null || typeof v !== 'object') return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + function projectValue(value, rules, level) { if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level)) - if (!value || typeof value !== 'object') return value + if (!isPlainObject(value)) return value const out = {} for (const [key, v] of Object.entries(value)) { - const required = rules[key] + // Locked fields are checked by meaning first, so no configured rule (and no + // flattened spelling) can widen them past `admin`. + const required = isLockedField(key) ? 'admin' : rules[key] if (required && !meets(level, required)) continue out[key] = projectValue(v, rules, level) } @@ -324,6 +361,23 @@ function kindVisibleTo(kind, level, config) { return meets(level, feature.audience) } +// The event kinds a viewer at `level` may read under the CURRENT config. This is +// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived +// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see +// this kind, given what the admin has configured?". +// +// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a +// feature whose live firehose is off (market) is still readable from the stored +// history. Unmapped kinds are absent by construction (rule 2). +function visibleKinds(level, config) { + return [...KIND_FEATURE.entries()] + .filter(([, name]) => { + const feature = config?.[name] + return !!feature && feature.enabled && meets(level, feature.audience) + }) + .map(([kind]) => kind) +} + // The features a viewer at `level` can actually see — drives SPA nav so it never // renders a link that would 403. function visibleFeatures(level, config) { @@ -343,6 +397,7 @@ module.exports = { DEFAULT_STREAM_OFF, isLevel, isFeature, + isLockedField, rank, meets, getConfig, @@ -354,5 +409,6 @@ module.exports = { projectFeature, project, kindVisibleTo, + visibleKinds, visibleFeatures, } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index a29ac8f..16d6c0c 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -11030,7 +11030,7 @@ "Public · Shard" ], "summary": "Recent notable shard events (from the ingested log)", - "description": "", + "description": "The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\\'s audience rung under the live visibility config, and each event\\'s payload is field-projected against its own kind\\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.", "parameters": [ { "name": "kind", @@ -11039,7 +11039,7 @@ "schema": { "type": "string" }, - "description": "Filter to a single event kind, e.g. vendor.sale." + "description": "Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind." }, { "name": "limit", @@ -11244,7 +11244,7 @@ "Public · Shard" ], "summary": "Houses currently in danger (IDOC)", - "description": "", + "description": "Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\\'s field rules (default `staff`), and the owner\\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.", "responses": { "200": { "description": "IDOC houses", diff --git a/server/test/shardControllerPublic.test.js b/server/test/shardControllerPublic.test.js index 1f92c24..f1b39f9 100644 --- a/server/test/shardControllerPublic.test.js +++ b/server/test/shardControllerPublic.test.js @@ -20,10 +20,22 @@ const shardEvents = require('../src/model/shardEvents/shardEvents.model') const shardState = require('../src/model/shardState/shardState.model') const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model') const broadcast = require('../src/utils/shardBroadcast') +const visibility = require('../src/utils/shardVisibility') const db = require('../src/utils/db') after(() => db.close()) +// The controller now resolves the visibility config and the caller's rung on +// every read. Stub the MODEL rather than the util's exports: getConfig() and +// project() call the module-internal getConfig, which an exports-level stub does +// not intercept — it would still hit the closed DB port and cost a ~10s pool +// timeout per test before falling back to these same defaults. +const visibilityModel = require('../src/model/shardVisibility/shardVisibility.model') +visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults +visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous' + +const DEFAULTS = visibility.compileDefaults() + function mockRes() { return { statusCode: 200, @@ -81,16 +93,65 @@ test('getFeed serves a specific kind when it IS public-safe', async () => { assert.equal(res.body[0].kind, publicKind) }) -test('getFeed with no kind restricts the query to the whole public allowlist', async () => { +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()) - assert.deepEqual(new Set(seen.kinds), broadcast.PUBLIC_KINDS) + // 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 ───────────── @@ -122,6 +183,66 @@ test('getHouses exposes only IDOC location fields and strips owner/price/decay', 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()) +}) + // ── getStatus assembles the summary ───────────────────────────────────── test('getStatus merges the sidecar config with the online count and latest economy', async () => { uoLinkConfig.getSafe = async () => ({ diff --git a/server/test/shardVisibility.test.js b/server/test/shardVisibility.test.js index e6eb1af..2c7be3d 100644 --- a/server/test/shardVisibility.test.js +++ b/server/test/shardVisibility.test.js @@ -128,6 +128,67 @@ test('a stored rule trying to loosen a locked field is ignored', async () => { 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') +}) + +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 = [ -- 2.49.1