Files
Module-Rust/server/model/servers/servers.db.js
wtclaude 285db0baa7 feat(rust): notifications and engagement (phase 10, protocol 7)
Registers the engagement set R7 put in v1: thirteen triggers, four push
streams, three audiences, four bodies (two triggers, email and in-app)
and thirteen disabled rules in seven groups (PLAN.md §25, D59-D68).

The raid alert goes to everyone authorised on the tool cupboard, one
emit per linked person with ownerUserId, so the owner ceiling holds per
emit. It covers doors and walls (protocol 7), never names the raider,
alerts nobody when there is no cupboard, and carries ownerOnline so
"offline only" is the seeded rule's condition rather than code.

The fan-out runs off ingest before a frame is applied, since applying a
disband deletes the roster the notice is sent to. A replayed event is
told only while it is news: 15 minutes for broadcasts, 24 hours for
personal and staff events. Dedupe keys come from the event, not the
sidecar's row id. Server online/offline and a new kills leader are
in-memory transitions, never on first sight, and a tie is not a lead.
A login with no approval within a minute becomes a staff notice via a
query, so a restart loses nothing.

Also fixes a phase-4 gap (D68): the refresh now asks /health, so a game
that hung, or whose bridge was unloaded, while the sidecar stayed up no
longer reads as online. It stops naming players as online, and a stale
board no longer moves "last seen".

engagement-triggers.json is the committed freeze of all of it, checked
in CI with line endings normalised. The check was verified by breaking
it both ways.

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

196 lines
8.0 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,
last_seen_at AS lastSeenAt, updated_at AS updatedAt
FROM ${STATE}`,
)
}
/** One server's observed state, or `null`. The single-row twin of `listState`. */
async function getState(serverId) {
const rows = await 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,
last_seen_at AS lastSeenAt, updated_at AS updatedAt
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/**
* Mark a server unreachable **without forgetting what it last said**.
*
* `putState` replaces the row whole, which is right when a sidecar answered: the
* frame it answered with is the complete truth about that server. It is wrong
* when nothing answered. A refresh that cannot reach a sidecar knows exactly one
* new fact — that it could not reach it — and writing the whole row from that
* one fact sets `hostname`, `level`, `seed`, `world_size` and `wipe_id` to NULL.
*
* The site's whole premise is that it renders the last thing each server said
* while every server is off. A row blanked the first time a game host reboots
* cannot do that: the page loses the map, the size, the seed and the wipe, and
* what it shows is not "offline, here is what we know" but "offline, and we have
* never heard of it". It is invisible in every test that stubs a reachable
* sidecar, and it shows up as a page that was complete an hour ago.
*
* So: three columns move, and the description stays where it is.
*/
async function markUnreachable(serverId, reachable = false) {
await core.query(
`INSERT INTO ${STATE} (server_id, reachable, online, players, updated_at)
VALUES (?, ?, 0, 0, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable),
online = 0,
players = 0,
updated_at = CURRENT_TIMESTAMP`,
[serverId, reachable ? 1 : 0],
)
}
/**
* 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, last_seen_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, IF(?, CURRENT_TIMESTAMP, NULL), 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),
-- Only a CONNECTED game moves this; an unreachable write leaves it alone,
-- and so does a board the sidecar kept after the game went away (D68).
-- That is what lets a page say how long a server has been down rather
-- than how recently we failed to reach it.
last_seen_at = IF(?, CURRENT_TIMESTAMP, last_seen_at),
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,
state.seen === false ? 0 : 1,
state.seen === false ? 0 : 1,
],
)
}
module.exports = {
SERVERS,
STATE,
listServers,
getServer,
upsertServer,
deleteServer,
listState,
getState,
markUnreachable,
putState,
}