// ── 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 } } /** Every enabled server, with tokens, for the poller. */ async function listForPolling() { const rows = await db.listServers({ enabledOnly: true }) 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 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, updatedAt: updatedAt ? updatedAt.toISOString() : null, stale, } } /** * 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, listPublic, listForAdmin, shapePublic, encryptToken, }