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
196 lines
7.7 KiB
JavaScript
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,
|
|
}
|