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
87 lines
3.0 KiB
JavaScript
87 lines
3.0 KiB
JavaScript
// ── Public · Rust — the handlers ──────────────────────────────────────────
|
|
//
|
|
// Thin on purpose: read the request, call a model, answer. Everything worth
|
|
// testing is in the model, which needs no express and no database to test.
|
|
//
|
|
// **A handler must not throw past express.** Core mounts this router inside its
|
|
// own tier router, so an unhandled rejection here reaches core's error handler
|
|
// and answers 500 — survivable, but it means an operator sees core blamed for a
|
|
// fault in this module. Catch, log through `core.logger` (so the line carries the
|
|
// module id), and answer something honest.
|
|
|
|
const core = require('../../core')
|
|
|
|
const events = require('../../model/events/events.model')
|
|
const servers = require('../../model/servers/servers.model')
|
|
|
|
const log = core.logger('public')
|
|
|
|
async function listServers(req, res) {
|
|
try {
|
|
res.json({ servers: await servers.listPublic() })
|
|
} catch (err) {
|
|
log.error('failed to read the server list', { error: err.message })
|
|
res.status(500).json({ error: 'Failed to read the server list' })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The killfeed, and everything else public that happened on one server.
|
|
*
|
|
* **`admin` is not passed, and that is the whole security posture of this
|
|
* handler.** `events.recent` takes the viewer explicitly and defaults to the
|
|
* public allowlist, so the way to leak an IP address from here is to add an
|
|
* argument rather than to forget one.
|
|
*/
|
|
async function listEvents(req, res) {
|
|
try {
|
|
res.json({
|
|
events: await events.recent({
|
|
serverId: req.params.id,
|
|
kind: req.query.kind,
|
|
wipeId: req.query.wipe || null,
|
|
limit: req.query.limit,
|
|
}),
|
|
})
|
|
} catch (err) {
|
|
log.error('failed to read events', { server: req.params.id, error: err.message })
|
|
res.status(500).json({ error: 'Failed to read events' })
|
|
}
|
|
}
|
|
|
|
async function listLeaderboard(req, res) {
|
|
try {
|
|
res.json({
|
|
leaderboard: await events.leaderboard({
|
|
serverId: req.params.id,
|
|
wipeId: req.query.wipe || null,
|
|
sort: req.query.sort,
|
|
limit: req.query.limit,
|
|
}),
|
|
})
|
|
} catch (err) {
|
|
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
|
|
res.status(500).json({ error: 'Failed to read the leaderboard' })
|
|
}
|
|
}
|
|
|
|
async function listWipes(req, res) {
|
|
try {
|
|
res.json({ wipes: await events.wipes(req.params.id) })
|
|
} catch (err) {
|
|
log.error('failed to read wipes', { server: req.params.id, error: err.message })
|
|
res.status(500).json({ error: 'Failed to read wipes' })
|
|
}
|
|
}
|
|
|
|
async function listOnline(req, res) {
|
|
try {
|
|
res.json({ players: await events.online(req.params.id) })
|
|
} catch (err) {
|
|
log.error('failed to read presence', { server: req.params.id, error: err.message })
|
|
res.status(500).json({ error: 'Failed to read who is online' })
|
|
}
|
|
}
|
|
|
|
module.exports = { listServers, listEvents, listLeaderboard, listWipes, listOnline }
|