feat(shard)!: Protocol 3.0 cutover — visibility framework, spawn atlas, marketplace #118
@@ -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
|
// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
|
||||||
// staff/sensitive kinds. limit is clamped by the model.
|
// staff/sensitive kinds. limit is clamped by the model.
|
||||||
async function list({ kind, kinds, limit }) {
|
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) {
|
if (kinds && kinds.length) {
|
||||||
const placeholders = kinds.map(() => '?').join(', ')
|
const placeholders = kinds.map(() => '?').join(', ')
|
||||||
return query(
|
return query(
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ async function putVisibility(req, res) {
|
|||||||
|
|
||||||
const fieldRules = {}
|
const fieldRules = {}
|
||||||
for (const [field, level] of Object.entries(patch.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` })
|
return res.status(400).json({ message: `Field '${field}' is admin-only and cannot be configured` })
|
||||||
}
|
}
|
||||||
if (!visibility.isLevel(level)) {
|
if (!visibility.isLevel(level)) {
|
||||||
|
|||||||
@@ -39,21 +39,49 @@ async function getStatus(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /public/shard/feed?kind=&limit= — recent notable events from the log,
|
// 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.
|
// 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) {
|
async function getFeed(req, res) {
|
||||||
try {
|
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
|
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
|
let events
|
||||||
if (kind) {
|
if (kind) {
|
||||||
// A specific kind is only served if it is itself public-safe.
|
if (!allowed.has(kind)) return res.json([])
|
||||||
if (!broadcast.PUBLIC_KINDS.has(kind)) return res.json([])
|
|
||||||
events = await shardEvents.list({ kind, limit })
|
events = await shardEvents.list({ kind, limit })
|
||||||
} else {
|
} 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) {
|
} catch (err) {
|
||||||
log.error('shard.getFeed', err)
|
log.error('shard.getFeed', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
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).
|
// 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) {
|
async function getIdoc(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.listIdoc())
|
return res.json(await visibility.project('houses', await shardState.listIdoc(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getIdoc', err)
|
log.error('shard.getIdoc', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
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.
|
// the public SSE stream so the page can update in place.
|
||||||
async function getChamps(req, res) {
|
async function getChamps(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.listChamps())
|
return res.json(await visibility.project('champs', await shardState.listChamps(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getChamps', err)
|
log.error('shard.getChamps', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
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.
|
// + per-region). Live via presence.online on the public SSE stream.
|
||||||
async function getPresence(req, res) {
|
async function getPresence(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.latestPresence())
|
return res.json(await visibility.project('presence', await shardState.latestPresence(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getPresence', err)
|
log.error('shard.getPresence', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
@@ -194,7 +227,10 @@ async function getHouses(req, res) {
|
|||||||
z: h.z,
|
z: h.z,
|
||||||
isIdoc: true,
|
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) {
|
} catch (err) {
|
||||||
log.error('shard.getHouses', err)
|
log.error('shard.getHouses', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ shardRouter.get(
|
|||||||
requireFeature('activity'),
|
requireFeature('activity'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
// #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.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" } } } } } */
|
/* #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 }),
|
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
||||||
@@ -74,6 +75,7 @@ shardRouter.get(
|
|||||||
requireFeature('houses'),
|
requireFeature('houses'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
// #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" } } } } } */
|
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
shard.getIdoc,
|
shard.getIdoc,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,6 +62,23 @@ const rank = viewerRank
|
|||||||
|
|
||||||
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
|
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 = {
|
const FEATURES = {
|
||||||
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||||
status: { audience: 'anonymous', fields: {} },
|
status: { audience: 'anonymous', fields: {} },
|
||||||
@@ -70,7 +87,14 @@ const FEATURES = {
|
|||||||
guilds: { audience: 'anonymous', fields: {} },
|
guilds: { audience: 'anonymous', fields: {} },
|
||||||
governors: { audience: 'anonymous', fields: {} },
|
governors: { audience: 'anonymous', fields: {} },
|
||||||
// The public Houses page showed IDOC location only; owner/price were staff.
|
// 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
|
// /public/shard/online listed linked staff to everyone but gated location to
|
||||||
// admin+moderator — which is exactly the `staff` rung.
|
// admin+moderator — which is exactly the `staff` rung.
|
||||||
presence: { audience: 'anonymous', fields: { location: 'staff' } },
|
presence: { audience: 'anonymous', fields: { location: 'staff' } },
|
||||||
@@ -168,7 +192,7 @@ function applyRow(name, row) {
|
|||||||
const audience = isLevel(row?.audience) ? row.audience : base.audience
|
const audience = isLevel(row?.audience) ? row.audience : base.audience
|
||||||
const fields = { ...base.fields }
|
const fields = { ...base.fields }
|
||||||
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
|
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
|
if (isLevel(level)) fields[field] = level
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -286,12 +310,25 @@ function requireFeature(name) {
|
|||||||
// first (so acct/webId can never survive below admin), then the feature's
|
// first (so acct/webId can never survive below admin), then the feature's
|
||||||
// configured field rules. Recurses into arrays and nested objects because the
|
// configured field rules. Recurses into arrays and nested objects because the
|
||||||
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
|
// 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) {
|
function projectValue(value, rules, level) {
|
||||||
if (Array.isArray(value)) return value.map((v) => projectValue(v, 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 = {}
|
const out = {}
|
||||||
for (const [key, v] of Object.entries(value)) {
|
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
|
if (required && !meets(level, required)) continue
|
||||||
out[key] = projectValue(v, rules, level)
|
out[key] = projectValue(v, rules, level)
|
||||||
}
|
}
|
||||||
@@ -324,6 +361,23 @@ function kindVisibleTo(kind, level, config) {
|
|||||||
return meets(level, feature.audience)
|
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
|
// The features a viewer at `level` can actually see — drives SPA nav so it never
|
||||||
// renders a link that would 403.
|
// renders a link that would 403.
|
||||||
function visibleFeatures(level, config) {
|
function visibleFeatures(level, config) {
|
||||||
@@ -343,6 +397,7 @@ module.exports = {
|
|||||||
DEFAULT_STREAM_OFF,
|
DEFAULT_STREAM_OFF,
|
||||||
isLevel,
|
isLevel,
|
||||||
isFeature,
|
isFeature,
|
||||||
|
isLockedField,
|
||||||
rank,
|
rank,
|
||||||
meets,
|
meets,
|
||||||
getConfig,
|
getConfig,
|
||||||
@@ -354,5 +409,6 @@ module.exports = {
|
|||||||
projectFeature,
|
projectFeature,
|
||||||
project,
|
project,
|
||||||
kindVisibleTo,
|
kindVisibleTo,
|
||||||
|
visibleKinds,
|
||||||
visibleFeatures,
|
visibleFeatures,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11030,7 +11030,7 @@
|
|||||||
"Public · Shard"
|
"Public · Shard"
|
||||||
],
|
],
|
||||||
"summary": "Recent notable shard events (from the ingested log)",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "kind",
|
"name": "kind",
|
||||||
@@ -11039,7 +11039,7 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"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",
|
"name": "limit",
|
||||||
@@ -11244,7 +11244,7 @@
|
|||||||
"Public · Shard"
|
"Public · Shard"
|
||||||
],
|
],
|
||||||
"summary": "Houses currently in danger (IDOC)",
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "IDOC houses",
|
"description": "IDOC houses",
|
||||||
|
|||||||
@@ -20,10 +20,22 @@ const shardEvents = require('../src/model/shardEvents/shardEvents.model')
|
|||||||
const shardState = require('../src/model/shardState/shardState.model')
|
const shardState = require('../src/model/shardState/shardState.model')
|
||||||
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const broadcast = require('../src/utils/shardBroadcast')
|
const broadcast = require('../src/utils/shardBroadcast')
|
||||||
|
const visibility = require('../src/utils/shardVisibility')
|
||||||
const db = require('../src/utils/db')
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
after(() => db.close())
|
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() {
|
function mockRes() {
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
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)
|
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
|
let seen
|
||||||
shardEvents.list = async (opts) => {
|
shardEvents.list = async (opts) => {
|
||||||
seen = opts
|
seen = opts
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
await ctrl.getFeed({ query: {} }, mockRes())
|
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.
|
// Sanity: a known admin-only kind is absent from what the public feed queries.
|
||||||
assert.ok(!seen.kinds.includes('staff.audit'))
|
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 ─────────────
|
// ── 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)
|
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 ─────────────────────────────────────
|
// ── getStatus assembles the summary ─────────────────────────────────────
|
||||||
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
|
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
|
||||||
uoLinkConfig.getSafe = async () => ({
|
uoLinkConfig.getSafe = async () => ({
|
||||||
|
|||||||
@@ -128,6 +128,67 @@ test('a stored rule trying to loosen a locked field is ignored', async () => {
|
|||||||
assert.equal('webId' in out.leader, false)
|
assert.equal('webId' in out.leader, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('rule 1 matches FLATTENED spellings, not just the two canonical keys', () => {
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
// shapeHouse/shapeGuild flatten the actor into `<role>Acct` / `<role>WebId`.
|
||||||
|
// An exact-key check missed every one of these, which is how GET
|
||||||
|
// /public/shard/idoc served the owner's game account to anonymous callers.
|
||||||
|
const row = {
|
||||||
|
serial: '0x1',
|
||||||
|
name: 'Marble Tower',
|
||||||
|
ownerAcct: 'cadmus_acct',
|
||||||
|
leaderWebId: 42,
|
||||||
|
governorAcct: 'blackthorn_acct',
|
||||||
|
}
|
||||||
|
const out = visibility.projectFeature('houses', row, 'staff', config)
|
||||||
|
assert.equal('ownerAcct' in out, false, 'staff must not see a flattened acct')
|
||||||
|
assert.equal('leaderWebId' in out, false)
|
||||||
|
assert.equal('governorAcct' in out, false)
|
||||||
|
assert.equal(out.name, 'Marble Tower', 'ordinary fields are untouched')
|
||||||
|
|
||||||
|
const asAdmin = visibility.projectFeature('houses', row, 'admin', config)
|
||||||
|
assert.equal(asAdmin.ownerAcct, 'cadmus_acct')
|
||||||
|
})
|
||||||
|
|
||||||
|
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', () => {
|
test('projection recurses into arrays and nested actors', () => {
|
||||||
const config = visibility.compileDefaults()
|
const config = visibility.compileDefaults()
|
||||||
const rows = [
|
const rows = [
|
||||||
|
|||||||
Reference in New Issue
Block a user