// ── What the bridge can say, and who may hear it ────────────────────────── // // One file, because these two questions have to be answered together or the // second one rots: which frame kinds exist, and which of them a member of the // public may see. // // ── The boundary ────────────────────────────────────────────────────────── // // Protocol 2's catalogue includes frames carrying **IP addresses** (a login // attempt, an approval, a ban) and **one player's complaint about another** (a // report), and one — a destroyed structure — that names where somebody lives. // They are stored, because an operator chasing ban evasion needs them and // because the sidecar persists what it is told. They must never reach a public // page. // // **The boundary is enforced HERE, on the side that serves, and not on the wire.** // The plugin could have stamped a `class` on every frame and saved this file the // trouble; it deliberately does not (PROTOCOL.md §8.5). A boundary declared by // the sender is a boundary a compromised — or merely out-of-date — game host can // widen. Core's own shard fan-out works the same way: a public stream with an // allowlist of kinds, and an admin stream that adds the rest. // // ── Default deny, and why it is not paranoia ────────────────────────────── // // `isPublic` answers `false` for a kind it has never heard of. That matters // because of the shape of the mistake it prevents: the next protocol version // adds a kind, this module ingests it happily (`rust_events` stores what it is // given), and a page that filtered by a DENY list would publish it the day it // first arrived — before anybody had decided whether it should be public. With // an allowlist the new kind is invisible until somebody adds it here, which is // the same moment they think about it. // // The test holds this list against `docs/rust-link/PROTOCOL.md` §8.4's table, so // adding a kind to the spec without classifying it fails a build rather than // shipping an address to a public page. /** * Kinds a public, signed-out visitor may see. * * Each entry is a decision. `player.chat` is here because a shard's chat is * public by the same logic that makes a killfeed public — it happened in front * of everyone who was on the server — and an operator who disagrees turns the * feature off rather than relying on this list being wrong. */ const PUBLIC_KINDS = Object.freeze([ 'player.connected', 'player.disconnected', 'player.respawned', 'player.death', 'player.chat', 'player.tally', 'server.wipe', 'server.initialized', 'server.shutdown', ]) /** * Kinds an admin may see and nobody else. * * Listed rather than implied by absence, so that "we know about this kind and it * is restricted" is distinguishable from "nobody has classified this kind" — the * second is a finding, and a bare allowlist cannot tell you which you are * looking at. */ const STAFF_KINDS = Object.freeze([ 'entity.destroyed', 'player.reported', 'player.banned', 'player.unbanned', 'player.login.attempt', 'player.approved', // Protocol 3's two account frames. Neither carries a code — the code travels // through the player, which is what makes typing it proof — but both name a // Steam id ALONGSIDE a website account's activity, which is exactly the join a // public page must not be able to make: "this player is that person" is a fact // about somebody's identity, not about what happened on the server. 'account.link.requested', 'account.unlinked', // Protocol 4. Who holds which privilege in game, and the fact that somebody // changed it by hand — a question about a person's standing and about an // operator's own console, neither of which is a public page's business. 'perm.drift', // Protocol 6. Clan membership, which the org lead made members-only (D49): // who joined which clan, and who threw whom out, is the clan's business. It // reaches a clan's own members through core's Team feed, where core resolves // who is a member, and it reaches the server's public feed not at all. 'clan.created', 'clan.disbanded', 'clan.member.added', 'clan.member.left', 'clan.member.kicked', ]) /** * The public kinds that say a NAMED player was on the server at a given moment. * * A subset of `PUBLIC_KINDS`, not a third list: these are public-page material * whose audience an operator chooses (`model/visibility`), where the rest of * `PUBLIC_KINDS` is public by construction. The org lead's rule, settled * 2026-09-22: **nothing tells who is online by default** — the narrowest * audience (staff) unless an operator widens it, and a count is never a name. * * `player.death` and `player.chat` are here, and that was decided rather than * overlooked. They are the killfeed and the chat — the content a feed exists * for — and each one says "this person was on at 12:03" as plainly as a connect * frame does. `player.tally` is a per-minute flush that is only ever sent for a * player who is playing, which makes it a roll call with extra steps. * * What is left in the public set once these are removed is the server's own * story — a wipe, a start, a shutdown — which names nobody. */ const PRESENCE_KINDS = Object.freeze([ 'player.connected', 'player.disconnected', 'player.respawned', 'player.death', 'player.chat', 'player.tally', ]) /** Every kind the protocol defines, through protocol 6. */ const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS]) const PUBLIC = new Set(PUBLIC_KINDS) const STAFF = new Set(STAFF_KINDS) const PRESENCE = new Set(PRESENCE_KINDS) /** * May a signed-out visitor see this kind? * * Default deny: an unknown kind is not public. Callers pass whatever arrived on * the wire, including a kind from a newer protocol this build has never seen. */ function isPublic(kind) { return PUBLIC.has(kind) } /** Is this a kind this build knows about at all? */ function isKnown(kind) { return PUBLIC.has(kind) || STAFF.has(kind) } /** Does this kind name a player who was on the server at the time? */ function isPresence(kind) { return PRESENCE.has(kind) } /** * Narrows a list of requested kinds to the ones a viewer may have. * * Returning the allowlist itself when nothing was requested is what makes the * public route safe by construction rather than by remembering to filter: there * is no code path where "no filter" means "everything". * * `presence` defaults to `false` for the same reason `admin` does: a caller that * forgets to say what the viewer may see gets the narrowest answer. The route * resolves it from the operator's setting (`model/visibility`); nothing else * should be passing `true`. */ function kindsFor({ admin = false, presence = false, requested = null } = {}) { const permitted = admin ? ALL_KINDS : PUBLIC_KINDS.filter((k) => presence || !PRESENCE.has(k)) if (!requested || requested.length === 0) return [...permitted] const allowed = new Set(permitted) return requested.filter((k) => allowed.has(k)) } module.exports = { PUBLIC_KINDS, STAFF_KINDS, PRESENCE_KINDS, ALL_KINDS, isPublic, isKnown, isPresence, kindsFor, }