Five read-only commands registered with api.registerSlashCommands: /status, /wipe, /top, /online and /clan (D126). Every refusal is private, and any answer narrower than public (online names, a clan roster) goes to the caller alone (D127). No command asks a sidecar. The next wipe (D128, D130): six nullable columns on rust_servers, a pure nextWipe(row, now) with the zone arithmetic through Intl, computed on every read. The public server shape gains nextWipe; the admin shape gains the stored schedule; PUT /admin/rust/servers/:id takes the six fields and writes them only when wipeRule is present. server/commands joins ci/bundle.json, which checkBundle caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
247 lines
9.9 KiB
JavaScript
247 lines
9.9 KiB
JavaScript
// ── The servers model ─────────────────────────────────────────────────────
|
|
//
|
|
// No database and no express: the model takes rows and produces the shapes the
|
|
// three tiers answer with, which is the whole reason the SQL lives in a separate
|
|
// file from the logic.
|
|
//
|
|
// Two things here are worth more than the rest: **a token never leaves this
|
|
// module**, and **a stale row cannot claim a server is up**.
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
|
|
const { fakeCtx } = require('./_fakes')
|
|
|
|
function withCore(ctx = fakeCtx()) {
|
|
require('../core')._reset()
|
|
require('../core').init(ctx)
|
|
return ctx
|
|
}
|
|
|
|
const NOW = Date.parse('2026-09-15T12:00:00Z')
|
|
|
|
const serverRow = (over = {}) => ({
|
|
id: 'main',
|
|
name: 'Main · Vanilla',
|
|
sidecarBaseUrl: 'http://10.0.0.5:8090',
|
|
sidecarTokenEnc: 'enc:s3cret',
|
|
protocol: 1,
|
|
enabled: 1,
|
|
sortOrder: 0,
|
|
...over,
|
|
})
|
|
|
|
const stateRow = (over = {}) => ({
|
|
serverId: 'main',
|
|
reachable: 1,
|
|
online: 1,
|
|
players: 42,
|
|
maxPlayers: 100,
|
|
hostname: 'Runic Gateway · Main',
|
|
level: 'Procedural Map',
|
|
seed: 1234,
|
|
worldSize: 4000,
|
|
bootId: 'boot-20260915T194502Z',
|
|
protocol: 1,
|
|
updatedAt: new Date(NOW - 10_000).toISOString(),
|
|
...over,
|
|
})
|
|
|
|
test('a fresh row reports what the server said', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
const shaped = servers.shapePublic(serverRow(), stateRow(), NOW)
|
|
|
|
assert.strictEqual(shaped.online, true)
|
|
assert.strictEqual(shaped.players, 42)
|
|
assert.strictEqual(shaped.stale, false)
|
|
assert.strictEqual(shaped.worldSize, 4000)
|
|
})
|
|
|
|
test('a stale row is reported offline, with no player count', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
|
|
// The row says what was true when it was written and nothing has written it
|
|
// since. Reporting its player count would put a number on a page that is
|
|
// simply the last number anyone saw, with no way for a reader to tell.
|
|
const old = stateRow({ updatedAt: new Date(NOW - servers.STALE_AFTER_MS - 1000).toISOString() })
|
|
const shaped = servers.shapePublic(serverRow(), old, NOW)
|
|
|
|
assert.strictEqual(shaped.stale, true)
|
|
assert.strictEqual(shaped.online, false)
|
|
assert.strictEqual(shaped.players, 0)
|
|
})
|
|
|
|
test('a server with no state row at all is stale rather than absent', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
|
|
// A configured server nothing has polled yet. It belongs on the page — an
|
|
// operator added it on purpose — and it must not claim to be online.
|
|
const shaped = servers.shapePublic(serverRow(), undefined, NOW)
|
|
|
|
assert.strictEqual(shaped.id, 'main')
|
|
assert.strictEqual(shaped.stale, true)
|
|
assert.strictEqual(shaped.online, false)
|
|
assert.strictEqual(shaped.updatedAt, null)
|
|
})
|
|
|
|
test('the public shape carries nothing about the sidecar', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
const shaped = servers.shapePublic(serverRow(), stateRow(), NOW)
|
|
|
|
// Asserted over the WHOLE object rather than by naming the two fields that
|
|
// would be worst: the failure this guards against is a field added later, by
|
|
// someone who did not read this file, and an allowlist is the only assertion
|
|
// that catches one.
|
|
assert.deepStrictEqual(Object.keys(shaped).sort(), [
|
|
'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'nextWipe', 'online', 'players', 'seed', 'stale',
|
|
'updatedAt', 'wipeId', 'wipedAt', 'worldSize',
|
|
])
|
|
})
|
|
|
|
test('"last reported" is when a frame arrived, not when we last polled', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
|
|
// The defect the phase-4 page walk found, in one assertion. A refresh that
|
|
// cannot reach a sidecar still writes `updated_at` — it has to, because that is
|
|
// what staleness is computed from — and a page reading it as "last reported"
|
|
// told a reader that a server which had been down for days had reported just
|
|
// now, every thirty seconds, for as long as it stayed down.
|
|
const state = stateRow({
|
|
online: 0,
|
|
reachable: 0,
|
|
updatedAt: new Date(NOW - 5_000).toISOString(),
|
|
lastSeenAt: new Date(NOW - 3 * 86400_000).toISOString(),
|
|
})
|
|
|
|
const shaped = servers.shapePublic(serverRow(), state, NOW)
|
|
assert.strictEqual(shaped.lastSeenAt, new Date(NOW - 3 * 86400_000).toISOString())
|
|
assert.strictEqual(shaped.stale, false, 'the row itself is fresh — it was written five seconds ago')
|
|
assert.strictEqual(shaped.online, false)
|
|
|
|
// A server nothing has ever heard from has no such moment, and `null` is what
|
|
// a page renders as "never" rather than as the epoch.
|
|
assert.strictEqual(servers.shapePublic(serverRow(), undefined, NOW).lastSeenAt, null)
|
|
})
|
|
|
|
test('the public shape carries the current wipe, from the state row', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
|
|
// The wipe id on the STATE row, not the newest row in `rust_wipes`. The two
|
|
// usually agree, and the state row is the one that is right when they do not:
|
|
// the wipe list is derived from events that have been ingested, so a server
|
|
// that has just wiped and said nothing since has a new id here and no row there.
|
|
const shaped = servers.shapePublic(serverRow(), stateRow({ wipeId: 'w-2026-09', saveCreatedAt: '2026-09-04T18:00:00Z' }), NOW)
|
|
assert.strictEqual(shaped.wipeId, 'w-2026-09')
|
|
assert.strictEqual(shaped.wipedAt, '2026-09-04T18:00:00Z')
|
|
|
|
// A server nothing has polled yet has no wipe, and `null` is the honest answer
|
|
// — an empty string would be sent back as `?wipe=`, which asks a different
|
|
// question and answers nothing.
|
|
const never = servers.shapePublic(serverRow(), undefined, NOW)
|
|
assert.strictEqual(never.wipeId, null)
|
|
assert.strictEqual(never.wipedAt, null)
|
|
})
|
|
|
|
test('a disabled server is not there, rather than forbidden', async () => {
|
|
withCore()
|
|
|
|
const db = require('../model/servers/servers.db')
|
|
const model = require('../model/servers/servers.model')
|
|
const originalServer = db.getServer
|
|
const originalState = db.getState
|
|
|
|
db.getState = async () => stateRow()
|
|
|
|
try {
|
|
// The detail route is the only one under `/servers/:id` that can say "no such
|
|
// server" — the other four answer an empty list, because an unknown id
|
|
// genuinely has no events. So what `null` means here decides what a page
|
|
// renders, and a disabled server and a missing one must mean the same thing:
|
|
// an operator who switched a server off did not switch it into a 403.
|
|
db.getServer = async () => ({ ...serverRow(), enabled: 0 })
|
|
assert.strictEqual(await model.getPublic('main', NOW), null)
|
|
|
|
db.getServer = async () => null
|
|
assert.strictEqual(await model.getPublic('nope', NOW), null)
|
|
|
|
// And an id nobody asked about never reaches the database.
|
|
let asked = false
|
|
db.getServer = async () => { asked = true; return null }
|
|
assert.strictEqual(await model.getPublic('', NOW), null)
|
|
assert.strictEqual(asked, false)
|
|
|
|
db.getServer = async () => serverRow()
|
|
const server = await model.getPublic('main', NOW)
|
|
assert.strictEqual(server.id, 'main')
|
|
assert.strictEqual(server.online, true)
|
|
assert.ok(!Object.prototype.hasOwnProperty.call(server, 'sidecarBaseUrl'))
|
|
} finally {
|
|
db.getServer = originalServer
|
|
db.getState = originalState
|
|
}
|
|
})
|
|
|
|
test('the admin shape reports whether a token is stored, never the token', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
|
|
// `listForAdmin` reads the database, so the shape is asserted through the piece
|
|
// that does not: the rule is that `hasToken` is a boolean and no key anywhere
|
|
// in the object holds the ciphertext or the plaintext.
|
|
const row = serverRow()
|
|
const shaped = {
|
|
...servers.shapePublic(row, stateRow(), NOW),
|
|
sidecarBaseUrl: row.sidecarBaseUrl,
|
|
hasToken: Boolean(row.sidecarTokenEnc),
|
|
}
|
|
|
|
assert.strictEqual(shaped.hasToken, true)
|
|
const serialised = JSON.stringify(shaped)
|
|
assert.ok(!serialised.includes('s3cret'), 'the plaintext token reached a response shape')
|
|
assert.ok(!serialised.includes('enc:'), 'the stored ciphertext reached a response shape')
|
|
})
|
|
|
|
test('a token round-trips through the box, and an empty one means “leave it alone”', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
|
|
const enc = servers.encryptToken('s3cret')
|
|
assert.notStrictEqual(enc, 's3cret')
|
|
assert.strictEqual(servers.withToken(serverRow({ sidecarTokenEnc: enc })).token, 's3cret')
|
|
|
|
// All three spellings of "the operator did not type a new token". The admin
|
|
// form can only ever show a blank field, so it posts one on every save that did
|
|
// not intend to change the credential — and writing that through would erase
|
|
// the token every time somebody renamed a server.
|
|
assert.strictEqual(servers.encryptToken(''), null)
|
|
assert.strictEqual(servers.encryptToken(null), null)
|
|
assert.strictEqual(servers.encryptToken(undefined), null)
|
|
})
|
|
|
|
test('a token that will not decrypt reports the server unconfigured rather than throwing', () => {
|
|
const ctx = withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
|
|
// The usual cause is a `SECRET_ENC_KEY` that changed. One server's unreadable
|
|
// credential must not be able to fail the poll for the other five, and it must
|
|
// not fail `onBoot` — which would make the whole module `startup_failed`.
|
|
const shaped = servers.withToken(serverRow({ sidecarTokenEnc: 'not-encrypted-by-this-box' }))
|
|
|
|
assert.strictEqual(shaped.token, null)
|
|
assert.strictEqual(shaped.baseUrl, 'http://10.0.0.5:8090')
|
|
const errors = ctx.logs.flatMap((l) => l.log.error.calls)
|
|
assert.strictEqual(errors.length, 1, 'the failure was swallowed without a word')
|
|
})
|
|
|
|
test('a server with no token stored reads as having none', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
assert.strictEqual(servers.withToken(serverRow({ sidecarTokenEnc: null })).token, null)
|
|
})
|