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 <noreply@anthropic.com>
47 lines
1.8 KiB
JavaScript
47 lines
1.8 KiB
JavaScript
const { query } = require('../../utils/db')
|
|
|
|
// INSERT IGNORE on the UNIQUE dedupe_key — a re-ingested event (WS-reconnect
|
|
// backfill overlap) is silently skipped rather than duplicated. Returns true if
|
|
// a new row was actually inserted.
|
|
async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
|
|
const res = await query(
|
|
`INSERT IGNORE INTO shard_events (kind, t, boot_id, payload, dedupe_key)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
[kind, t, bootId || null, JSON.stringify(payload), dedupeKey],
|
|
)
|
|
return res.affectedRows > 0
|
|
}
|
|
|
|
// Recent events, newest first. Filter by a single `kind`, or an allowlist of
|
|
// `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(
|
|
`SELECT id, kind, t, boot_id, payload, created_at
|
|
FROM shard_events WHERE kind IN (${placeholders}) ORDER BY t DESC LIMIT ?`,
|
|
[...kinds, limit],
|
|
)
|
|
}
|
|
if (kind) {
|
|
return query(
|
|
`SELECT id, kind, t, boot_id, payload, created_at
|
|
FROM shard_events WHERE kind = ? ORDER BY t DESC LIMIT ?`,
|
|
[kind, limit],
|
|
)
|
|
}
|
|
return query(
|
|
`SELECT id, kind, t, boot_id, payload, created_at
|
|
FROM shard_events ORDER BY t DESC LIMIT ?`,
|
|
[limit],
|
|
)
|
|
}
|
|
|
|
module.exports = { insertIgnore, list }
|