feat: ingest protocol 2, and keep the record a wipe cannot erase
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
This commit is contained in:
@@ -48,14 +48,22 @@ const log = core.logger('sidecar')
|
||||
const TIMEOUT_MS = 12000
|
||||
|
||||
/**
|
||||
* The wire version this module speaks. Declared in three places that must agree:
|
||||
* here, `PROTOCOL_VERSION` in the sidecar, and `overlay.toml` in Rust-Plugins.
|
||||
* The wire version this module speaks. Declared in FOUR places that must agree:
|
||||
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
|
||||
* plugin, and `protocol` in its `overlay.toml`.
|
||||
*
|
||||
* **2 — the read path.** The bump lands here in the same change as the emitters,
|
||||
* even though this module does not yet consume any of the new frames: the
|
||||
* sidecar refuses a client declaring a different version with a `409`, so a
|
||||
* module left on 1 would stop being able to read the server board it has been
|
||||
* reading all along. A constant that lags the deployment is not a safe default;
|
||||
* it is an outage with a version number on it.
|
||||
*
|
||||
* It is sent on every request as `X-RustLink-Version`, which turns a mismatched
|
||||
* deployment into a `409` naming both numbers instead of a parse failure three
|
||||
* layers further in.
|
||||
*/
|
||||
const PROTOCOL_VERSION = 1
|
||||
const PROTOCOL_VERSION = 2
|
||||
|
||||
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||||
function reply(ok, status, data = null) {
|
||||
@@ -163,6 +171,24 @@ const serverBoard = (server) => request(server, '/server')
|
||||
/** A live round trip through the sidecar to the game. Fails when the game is down, by design. */
|
||||
const liveStatus = (server) => request(server, '/status')
|
||||
|
||||
/** Every board at once: what is true now, before following what happens next. */
|
||||
const boards = (server) => request(server, '/boards')
|
||||
|
||||
/**
|
||||
* The ingest cursor: events after `since`, oldest first.
|
||||
*
|
||||
* **`since` is required here, unlike on the wire.** The sidecar treats an omitted
|
||||
* cursor as "tell me where the end is", which is a genuinely useful question and
|
||||
* a catastrophic default for an ingest loop that would silently store nothing
|
||||
* and advance past everything. So the question is asked explicitly, by name, and
|
||||
* a caller cannot get it by forgetting an argument.
|
||||
*/
|
||||
const feed = (server, since, limit = 200) =>
|
||||
request(server, `/feed?since=${encodeURIComponent(since)}&limit=${encodeURIComponent(limit)}`)
|
||||
|
||||
/** Where the sidecar's history currently ends. What a new server's cursor starts at. */
|
||||
const feedTail = (server) => request(server, '/feed')
|
||||
|
||||
module.exports = {
|
||||
TIMEOUT_MS,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -170,5 +196,8 @@ module.exports = {
|
||||
health,
|
||||
serverBoard,
|
||||
liveStatus,
|
||||
boards,
|
||||
feed,
|
||||
feedTail,
|
||||
joinUrl,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user