The module half of the read path. Seven tables, an ingest cursor, four public routes, and one file whose only job is deciding who may see what. **The record and the window are different things.** `rust_player_wipe_stats` and `rust_gather_totals` are permanent and per-wipe, so all-time is those rows SUMmed rather than a second set of counters that can disagree with them — that is R12's "per-wipe detail plus all-time rollups" in one table instead of two. `rust_events` is a bounded 30-day window of raw frames for the killfeed, and `rust_presence` is a board: replaced wholesale, never appended. **The feed is a cursor, not a socket, and the header says why.** Core runs Node 20, where a global WebSocket is still behind a flag, so a socket means taking `ws` — against a release that asserts it has no runtime dependencies (D5). The deciding argument is the other one though: a socket needs a cursor anyway, for whatever it missed while the module was restarting, and the catch-up path is the one that has to be right. A cursor alone is one mechanism exercised every five seconds rather than two where the second only runs after an outage. **The cursor advances after the batch, never before.** A crash between the two re-reads events already counted, which inflates a total; the other order loses them silently and for ever. One is visible and bounded, the other is invisible and permanent, so the code fails in the visible direction. A server with no cursor starts at the sidecar's current END rather than at zero — replaying a fortnight of deaths into stats for wipes the site never saw is not a catch-up. **`catalogue.js` is a security boundary, default-deny.** Protocol 2 carries IP addresses (login attempts, approvals, bans), one player's report about another, and the grid reference of somebody's base. They are stored, because an operator chasing ban evasion needs them; they are not served below the admin tier. The allowlist lives here rather than as a field on the wire, because a boundary declared by the sender is one a compromised or merely out-of-date game host can widen — the same reason core's own shard fan-out filters on the serving side. A kind this build has never heard of is not public, and a test holds the list against PROTOCOL.md §8.4 so that adding a kind to the protocol without classifying it fails a build. `PROTOCOL_VERSION` goes to 2 here in the same change as the emitters, though this module consumes none of the new frames yet: the sidecar refuses a mismatched client with a 409, so a module left on 1 would stop being able to read the board it has been reading all along. A constant that lags the deployment is an outage with a version number on it. 95 server tests, 20 client tests, every guard green, and `routes.manifest.json` regenerated against a real core at the pinned ref: 10 routes, all documented, none of core's moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
145 lines
4.6 KiB
JavaScript
145 lines
4.6 KiB
JavaScript
// ── The read path's logic ─────────────────────────────────────────────────
|
|
//
|
|
// The model decides what a caller gets. Two properties are worth more than the
|
|
// rest, and both are about a caller who did something slightly wrong:
|
|
//
|
|
// • a route that forgets to say who is asking gets the PUBLIC view;
|
|
// • a caller asking for a million rows gets two hundred.
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
|
|
const { fakeCtx } = require('./_fakes')
|
|
|
|
function withCore() {
|
|
require('../core')._reset()
|
|
require('../core').init(fakeCtx())
|
|
}
|
|
|
|
test('the limit is bounded, whatever was asked for', () => {
|
|
withCore()
|
|
const model = require('../model/events/events.model')
|
|
|
|
assert.equal(model.boundedLimit(10), 10)
|
|
assert.equal(model.boundedLimit(undefined), 50)
|
|
assert.equal(model.boundedLimit('nonsense'), 50)
|
|
assert.equal(model.boundedLimit(-5), 50)
|
|
assert.equal(model.boundedLimit(0), 50)
|
|
assert.equal(model.boundedLimit(1e9), model.MAX_LIMIT)
|
|
assert.equal(model.boundedLimit(12.9), 12)
|
|
})
|
|
|
|
test('kinds parse from one name or a list, and nothing means "not specified"', () => {
|
|
withCore()
|
|
const model = require('../model/events/events.model')
|
|
|
|
assert.deepEqual(model.parseKinds('player.death'), ['player.death'])
|
|
assert.deepEqual(model.parseKinds('player.death, player.chat'), ['player.death', 'player.chat'])
|
|
|
|
// Null rather than an empty list: "I did not ask" and "I asked for nothing"
|
|
// are different, and only the first means "whatever I am allowed".
|
|
assert.equal(model.parseKinds(''), null)
|
|
assert.equal(model.parseKinds(undefined), null)
|
|
assert.equal(model.parseKinds(' , , '), null)
|
|
})
|
|
|
|
test('a reader who does not say who they are gets the public view', async () => {
|
|
withCore()
|
|
|
|
const db = require('../model/events/events.db')
|
|
const model = require('../model/events/events.model')
|
|
const original = db.recentEvents
|
|
let asked = null
|
|
|
|
db.recentEvents = async (args) => {
|
|
asked = args
|
|
return []
|
|
}
|
|
|
|
try {
|
|
await model.recent({ serverId: 'main' })
|
|
|
|
assert.ok(!asked.kinds.includes('player.banned'), 'no IP-carrying kind by default')
|
|
assert.ok(asked.kinds.includes('player.death'))
|
|
|
|
await model.recent({ serverId: 'main', admin: true })
|
|
assert.ok(asked.kinds.includes('player.banned'), 'an admin who says so gets them')
|
|
} finally {
|
|
db.recentEvents = original
|
|
}
|
|
})
|
|
|
|
test('asking only for kinds you may not see answers with nothing, and queries nothing', async () => {
|
|
withCore()
|
|
|
|
const db = require('../model/events/events.db')
|
|
const model = require('../model/events/events.model')
|
|
const original = db.recentEvents
|
|
let called = false
|
|
|
|
db.recentEvents = async () => {
|
|
called = true
|
|
return []
|
|
}
|
|
|
|
try {
|
|
const rows = await model.recent({ serverId: 'main', kind: 'player.banned,player.approved' })
|
|
|
|
assert.deepEqual(rows, [])
|
|
assert.equal(called, false, 'a query with no permitted kinds must not reach the database')
|
|
} finally {
|
|
db.recentEvents = original
|
|
}
|
|
})
|
|
|
|
test('a row whose stored frame will not parse still answers with its envelope', async () => {
|
|
withCore()
|
|
|
|
const db = require('../model/events/events.db')
|
|
const model = require('../model/events/events.model')
|
|
const original = db.recentEvents
|
|
|
|
db.recentEvents = async () => [
|
|
{ id: 7, kind: 'player.death', t: 12, wipeId: 'w-1', steamId: 'p1', raw: '{not json' },
|
|
]
|
|
|
|
try {
|
|
const [row] = await model.recent({ serverId: 'main' })
|
|
|
|
// One unreadable row must not fail a whole page. What is known is still
|
|
// reported; the body is empty rather than absent.
|
|
assert.equal(row.id, 7)
|
|
assert.equal(row.kind, 'player.death')
|
|
assert.deepEqual(row.frame, {})
|
|
} finally {
|
|
db.recentEvents = original
|
|
}
|
|
})
|
|
|
|
test('the leaderboard answers numbers, never nulls', async () => {
|
|
withCore()
|
|
|
|
const db = require('../model/events/events.db')
|
|
const model = require('../model/events/events.model')
|
|
const original = db.leaderboard
|
|
|
|
// SUM() over no rows is NULL in SQL, and a JOIN with no player row gives a
|
|
// null name. A page that has to defend against both is a page with the
|
|
// defence in three places.
|
|
db.leaderboard = async () => [
|
|
{ steamId: 'p1', name: null, kills: null, deaths: '3', npcKills: null, playtimeSec: null },
|
|
]
|
|
|
|
try {
|
|
const [row] = await model.leaderboard({ serverId: 'main' })
|
|
|
|
assert.equal(row.kills, 0)
|
|
assert.equal(row.deaths, 3)
|
|
assert.equal(row.npcKills, 0)
|
|
assert.equal(row.playtimeSec, 0)
|
|
assert.equal(row.name, null)
|
|
} finally {
|
|
db.leaderboard = original
|
|
}
|
|
})
|