Files
Module-Rust/server/model/servers/servers.model.js
wtclaude cc185db26b feat(rust): the rewards — tally, kit reward, chat and the news leg (phase 13b, protocol 10)
Four event verbs and the announce leg, per PLAN.md §29:

- rust.participation.open / .collect: the plugin counts who takes part
  (seconds, kills or both, in a zone this run opened or the whole server)
  and collect files them as the run's participants, keyed by Steam id.
- rust.kit.entitle: the five recipient modes (D101), rows in the new
  rust_perm_run_grants (D84) unioned into the permission push, one extra
  use of the kit per reward as site-held credits on perm.sync (D103),
  and the rust.kit.entitled notice deferred from phase 10 (D64).
- rust.announce: one server or every server (D105).
- rust.chat announce leg, speaking only on servers whose new news switch
  is on (D104) - a card on Admin -> Rust visibility (D106).

Budgets rust.grants and rust.announcements; the kit source and four
fixed-choice sources (core has no enum param type). rust_perm_run_grants
carries core's idempotency key so a revert of a lost answer can find its
rows. Protocol 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 07:00:44 -05:00

196 lines
7.7 KiB
JavaScript

// ── The logic half ────────────────────────────────────────────────────────
//
// Shapes what the database returned into what a client should see, and holds the
// one rule that matters most in this module: **what leaves this file is never the
// sidecar's credential.**
//
// It is a separate file from the SQL so that it is testable without a database,
// and the suite next door tests it that way.
//
// The other decision worth pointing at: **a module answers when the game is
// unreachable rather than failing.** The website is the internet-facing process
// and the game is not; a game being down, or a sidecar being mid-restart, is an
// ordinary Tuesday. A page that renders "offline, last seen 20 minutes ago" is
// right; a page that 500s because a socket is closed is a module that has made
// the site's availability depend on the game's.
const core = require('../../core')
const db = require('./servers.db')
const log = core.logger('servers')
// Past this, the last thing a server said stops being news and starts being
// history. Presentation, so the number lives with the code that shapes the
// response rather than in the client.
const STALE_AFTER_MS = 5 * 60 * 1000
/**
* A configured server with its token decrypted, for this module's own use.
*
* **Never hand the result of this to a controller.** It is the input to
* `sidecarClient`, and the only shape in this module that holds a plaintext
* secret.
*
* A token that will not decrypt is returned as `null` rather than throwing: the
* usual cause is a `SECRET_ENC_KEY` that changed, and the right behaviour is a
* server that reports itself unconfigured with a line in the log — not a module
* that fails to boot and takes every other server down with it.
*/
function withToken(row) {
if (!row) return null
let token = null
if (row.sidecarTokenEnc) {
try {
token = core.secretBox().decrypt(row.sidecarTokenEnc)
} catch (err) {
log.error('could not decrypt a sidecar token', { server: row.id, error: err.message })
}
}
return {
id: row.id,
name: row.name,
baseUrl: row.sidecarBaseUrl,
token,
protocol: row.protocol,
// D104: whether a published news post is said in this server's chat.
announceNews: Boolean(Number(row.announceNews)),
}
}
/**
* How many servers were enabled when this process last looked. An action's
* `cost()` is synchronous and cannot ask the database, so `rust.announce` to
* every server is priced at this — refreshed by every poll, which runs
* continuously. One until the first poll, which is the least a fleet can be.
*/
let enabledCount = 1
function lastEnabledCount() {
return enabledCount
}
/** Every enabled server, with tokens, for the poller. */
async function listForPolling() {
const rows = await db.listServers({ enabledOnly: true })
enabledCount = rows.length
return rows.map(withToken)
}
/**
* The public view: every enabled server and what it last said.
*
* Nothing here is conditional on who is asking, which is the point of it being
* the public shape. What a *player* or an *admin* additionally sees is added by
* their own tier's controller, never removed by this one.
*/
async function listPublic(now = Date.now()) {
const [servers, states] = await Promise.all([db.listServers({ enabledOnly: true }), db.listState()])
const byId = new Map(states.map((s) => [s.serverId, s]))
return servers.map((row) => shapePublic(row, byId.get(row.id), now))
}
function shapePublic(row, state, now) {
const updatedAt = state && state.updatedAt ? new Date(state.updatedAt) : null
const lastSeenAt = state && state.lastSeenAt ? new Date(state.lastSeenAt) : null
const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS
return {
id: row.id,
name: row.name,
// A stale row cannot claim a server is up. The row says what was true when it
// was written, and nothing has written it since.
online: Boolean(state && state.online) && !stale,
players: stale ? 0 : Number(state && state.players) || 0,
maxPlayers: Number(state && state.maxPlayers) || 0,
hostname: (state && state.hostname) || null,
level: (state && state.level) || null,
worldSize: state && state.worldSize != null ? Number(state.worldSize) : null,
seed: state && state.seed != null ? Number(state.seed) : null,
// The CURRENT wipe, from the state row rather than from the newest row in
// `rust_wipes`. The two usually agree and the state row is the one that is
// right when they do not: a wipe list is derived from events that have been
// ingested, so a server that has just wiped and said nothing since has a new
// wipe id here and no row there at all.
wipeId: (state && state.wipeId) || null,
wipedAt: (state && state.saveCreatedAt) || null,
// Two timestamps, because they are two facts. `lastSeenAt` is when a frame
// last arrived and is what a page means by "last reported"; `updatedAt` is
// when this module last wrote the row, and is what `stale` is computed from.
// Reading the second as the first is what made an offline server claim it had
// reported just now, on every failed poll, for as long as it stayed down.
lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null,
updatedAt: updatedAt ? updatedAt.toISOString() : null,
stale,
}
}
/**
* One enabled server, or `null`.
*
* It exists because `/rust/servers/:id` is a page and a page needs to be able to
* 404. A detail view built by fetching the list and finding the row in it cannot
* tell "no such server" from "a server that has said nothing" — both are an
* absence — and renders an empty page under a heading for a server that does not
* exist. Filtering happens here, where `enabled = 0` and "never configured" are
* the same answer on purpose: a disabled server is not a 403, it is not there.
*/
async function getPublic(id, now = Date.now()) {
if (!id) return null
const row = await db.getServer(id)
if (!row || !row.enabled) return null
return shapePublic(row, await db.getState(row.id), now)
}
/**
* The admin view: configuration plus reachability, and **no token**.
*
* `hasToken` rather than the token, because the credential is write-only in the
* API: the admin form accepts a new value and never shows the stored one. An
* operator still needs to know whether one is set — a blank field means both
* "unset" and "set, and not being shown you" otherwise.
*/
async function listForAdmin(now = Date.now()) {
const [servers, states] = await Promise.all([db.listServers(), db.listState()])
const byId = new Map(states.map((s) => [s.serverId, s]))
return servers.map((row) => {
const state = byId.get(row.id)
return {
// The public shape first, so the admin-only fields below cannot be
// overwritten by a key the public shape happens to share.
...shapePublic(row, state, now),
sidecarBaseUrl: row.sidecarBaseUrl,
hasToken: Boolean(row.sidecarTokenEnc),
protocol: Number(row.protocol),
enabled: Boolean(row.enabled),
sortOrder: Number(row.sortOrder),
reachable: Boolean(state && state.reachable),
bootId: (state && state.bootId) || null,
sidecarProtocol: state && state.protocol != null ? Number(state.protocol) : null,
}
})
}
/** Encrypt a token for storage. `null`/empty means "leave whatever is stored alone". */
function encryptToken(token) {
if (token === null || token === undefined || token === '') return null
return core.secretBox().encrypt(String(token))
}
module.exports = {
STALE_AFTER_MS,
withToken,
listForPolling,
lastEnabledCount,
listPublic,
getPublic,
listForAdmin,
shapePublic,
encryptToken,
}