Files
Module-Rust/server/model/servers/servers.db.js
wtclaude f211969ee1
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 44s
PR Checks / server-tests (pull_request) Successful in 7m57s
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
2026-09-16 08:37:16 -05:00

141 lines
5.6 KiB
JavaScript

// ── SQL, and nothing else ─────────────────────────────────────────────────
//
// Core's own backend is layered `router → controller → model → db`, with models
// in pairs: a `.db.js` holding the SQL and a `.model.js` holding the logic that
// calls it. The split earns its keep here for the same reason it does in core —
// the file with the queries in it has no branching to test, and the file with the
// branching in it has no database to stand up.
//
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always.
const core = require('../../core')
const SERVERS = 'rust_servers'
const STATE = 'rust_server_state'
/**
* Every configured server, in the operator's own order.
*
* **The encrypted token comes back on this read and is never returned to a
* client.** Decryption happens in the model, one layer up; this file's job is to
* fetch a column, not to decide who may see it.
*/
async function listServers({ enabledOnly = false } = {}) {
return core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
FROM ${SERVERS}
${enabledOnly ? 'WHERE enabled = 1' : ''}
ORDER BY sort_order ASC, id ASC`,
)
}
async function getServer(id) {
const rows = await core.query(
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt
FROM ${SERVERS}
WHERE id = ?`,
[id],
)
return rows[0] || null
}
/**
* Create or replace a server row.
*
* **`sidecar_token_enc` is only written when a value is supplied.** An admin form
* that shows a blank token field — which is the only thing it can show, since the
* token is write-only — posts an empty string on every save that did not intend
* to change it. Writing that through would erase the credential every time an
* operator renamed a server, and the failure would present as the bridge going
* down for no reason an hour after an unrelated edit.
*/
async function upsertServer({ id, name, sidecarBaseUrl, sidecarTokenEnc, protocol, enabled, sortOrder }) {
const setToken = sidecarTokenEnc !== null && sidecarTokenEnc !== undefined
await core.query(
`INSERT INTO ${SERVERS}
(id, name, sidecar_base_url, sidecar_token_enc, protocol, enabled, sort_order, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
sidecar_base_url = VALUES(sidecar_base_url),
${setToken ? 'sidecar_token_enc = VALUES(sidecar_token_enc),' : ''}
protocol = VALUES(protocol),
enabled = VALUES(enabled),
sort_order = VALUES(sort_order),
updated_at = CURRENT_TIMESTAMP`,
[id, name, sidecarBaseUrl, setToken ? sidecarTokenEnc : null, protocol, enabled ? 1 : 0, sortOrder],
)
}
async function deleteServer(id) {
await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id])
}
/** The last thing each server said about itself, keyed by server id. */
async function listState() {
return core.query(
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
updated_at AS updatedAt
FROM ${STATE}`,
)
}
/**
* Replace one server's observed state.
*
* **`updated_at` is set explicitly, and it has to be.** MariaDB's
* `ON UPDATE CURRENT_TIMESTAMP` fires only when an UPDATE actually CHANGES a
* value, so an update writing the same numbers back — exactly what a quiet
* server looks like — leaves the timestamp where it was. The row would then
* cross the freshness window and the page would report the server offline while
* it was up and reporting normally. That is invisible to every test and shows up
* as a page that was right when you looked at it and wrong an hour later.
*/
async function putState(state) {
await core.query(
`INSERT INTO ${STATE}
(server_id, reachable, online, players, max_players, hostname, level, seed,
world_size, boot_id, save_created_at, wipe_id, protocol, raw, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players),
max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level),
seed = VALUES(seed), world_size = VALUES(world_size), boot_id = VALUES(boot_id),
save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id),
protocol = VALUES(protocol),
raw = VALUES(raw), updated_at = CURRENT_TIMESTAMP`,
[
state.serverId,
state.reachable ? 1 : 0,
state.online ? 1 : 0,
state.players || 0,
state.maxPlayers || 0,
state.hostname || null,
state.level || null,
state.seed === undefined ? null : state.seed,
state.worldSize === undefined ? null : state.worldSize,
state.bootId || null,
state.saveCreatedAt || null,
state.wipeId || null,
state.protocol === undefined ? null : state.protocol,
state.raw ? JSON.stringify(state.raw) : null,
],
)
}
module.exports = {
SERVERS,
STATE,
listServers,
getServer,
upsertServer,
deleteServer,
listState,
putState,
}