feat: the module skeleton and every bundle seam

module-rust, id 'rust', built from the Integration Kit's template. Phase 1's job
is the kit's own argument: get every seam working at once with almost nothing in
them, so that afterwards you break exactly one at a time.

What is here:

* /rust on all three tiers, because the loader holds module.json's mounts against
  what is registered in BOTH directions -- so the declaration and the
  registration land together or not at all. The player tier is honestly thin: it
  answers the server list on the authenticated tier, delegating to the same model
  the public tier uses so the two cannot drift while they are meant to be the
  same. It is the address the app will call, registered now rather than moved
  later.
* Two tables. rust_servers is configuration an operator writes; rust_server_state
  is what a sidecar reported. Separate tables because they have different
  writers, lifetimes and audiences -- and because purging observed state while
  keeping the configuration is a thing an operator will want.
* Per-server sidecar tokens through ctx.secretBox, write-only in the API. The
  admin list reports hasToken and never the credential, and an empty token on a
  save leaves the stored one alone -- a form that posts its own blank field would
  otherwise erase a credential every time somebody renamed a server.
* A real sidecar client. It never throws: every call answers {ok, status, data},
  and the status is what tells a wrong URL from a wrong token from a mismatched
  protocol -- all three present as 'the site says my server is offline' and each
  has a different fix.
* The five guards, green: check:imports, check:swagger, check:externals, and both
  suites.

What is deliberately NOT registered: the Team provider, triggers, audiences,
engagement seeds, notification streams, the four event catalogues, and the two
extension slots. Each arrives with the phase that has something real to put in
it, and a test asserts their absence so that removing it is deliberate. A
declared trigger nothing emits and a declared slot nothing fills are both
surfaces an operator can configure and then wait on, which is worse than an
absent one because the absence is visible.

Two corrections to the kit's template, both feedback for a later phase:

* registration.test.js read one page BY NAME to check declared slots are
  rendered, so a module declaring none dies on ENOENT before reaching the loop
  that would have been empty. It now scans every file under src/routes.
* test/_fakes.js supplied validator: {}. An admin router that builds validation
  chains at file scope cannot be required with that, so the fake holds the real
  express-validator -- for the same reason it holds a real express Router.

The kit was right about noGameConnection.test.js: its header predicts that a
module adding a sidecar client will see the check go red, names sidecarClient.js
as the file to allow, and says narrow it rather than delete it. That is exactly
what happened on the first run, and the fix was the one line the header names.

Installed into a real core and verified: the module reaches 'started', publishes
its capability, serves its chunk, and renders a server whose server.hello
originated in a live Rust server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-15 19:54:08 -05:00
parent 883438009d
commit 862c328176
43 changed files with 7814 additions and 0 deletions

146
server/boot.js Normal file
View File

@@ -0,0 +1,146 @@
// ── The lifecycle hooks ───────────────────────────────────────────────────
//
// `register()` may not touch the database (MODULE_API.md §2.2). This file is
// where everything it could not do goes.
//
// core schema → this module's schema fragment → onBoot(ctx) → the listener binds
//
// So by the time `onBoot` runs the tables exist, core's settings are seeded, and
// nothing is serving traffic yet.
//
// **`onBoot` has no timeout.** Shutdown races the process being killed; boot does
// not. A slow `onBoot` delays the listener, which is the promise above rather
// than a problem to be timed out.
//
// **If `onBoot` throws, the module is `startup_failed` and the site still comes
// up.** Its routes stay mounted but answer 503, because a module that failed to
// warm up serving half-initialised data is worse than one that says it is down.
// There is then NO `onShutdown` — being handed a half-built world to tear down is
// worse than not closing cleanly. Which is why the poll below catches everything:
// a sidecar that is not there yet is the ordinary state of a fresh install, and
// letting that fail the boot would make installing the module before installing
// the bridge impossible.
//
// ── Polling, in phase 1 ───────────────────────────────────────────────────
//
// This is a poll, and the live feed it will become is a later phase's work. The
// poll is not a placeholder for it: a sidecar's store-backed reads are exactly
// what answers while a game server is off, and the module will keep reading them
// on an interval to notice a server that went away without saying anything.
// What the feed adds is latency, not coverage.
const core = require('./core')
const db = require('./model/servers/servers.db')
const servers = require('./model/servers/servers.model')
const sidecar = require('./sidecarClient')
const log = core.logger('boot')
let refreshTimer = null
const REFRESH_MS = 30 * 1000
/**
* Ask every configured sidecar how its server is doing, and store what it said.
*
* **Every server is polled independently and one failure never stops the
* others.** `Promise.allSettled`, not `Promise.all`: six servers behind one
* unreachable host would otherwise mean the whole fleet stops updating because
* one of them does, and the site would report five healthy servers offline.
*/
async function refresh() {
let rows
try {
rows = await servers.listForPolling()
} catch (err) {
log.warn('could not read the server list', { error: err.message })
return
}
await Promise.allSettled(rows.map(refreshOne))
}
async function refreshOne(server) {
try {
const board = await sidecar.serverBoard(server)
// Three outcomes, and collapsing any two of them loses something an operator
// needs:
//
// • the sidecar answered with a frame → the server has connected at least once
// • the sidecar answered 204 (`empty`) → the sidecar is up and the game never connected
// • the sidecar did not answer → the bridge is unreachable
//
// The middle case is the one that is easy to lose. It is a fresh install
// whose plugin is not loaded yet, and reporting it as unreachable sends the
// operator to look at the network instead of at the game server.
if (!board.ok) {
await db.putState({ serverId: server.id, reachable: false, online: false })
return
}
const frame = board.data
if (!frame) {
await db.putState({ serverId: server.id, reachable: true, online: false })
return
}
await db.putState({
serverId: server.id,
reachable: true,
// A stored `server.hello` means the game connected; whether it is connected
// NOW is a different question, and `/health` is what answers it. The board
// alone cannot say, which is why `online` is not simply `true` here — it is
// decided by freshness in the model, from `updated_at`.
online: true,
players: Number(frame.players) || 0,
maxPlayers: Number(frame.maxPlayers) || 0,
hostname: frame.hostname || null,
level: frame.level || null,
seed: frame.seed === undefined ? null : Number(frame.seed),
worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize),
bootId: frame.bootId || null,
saveCreatedAt: frame.saveCreatedAt || null,
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
raw: frame,
})
} catch (err) {
// A failure here is one server's, and it must not reach `Promise.allSettled`
// as a rejection that hides which one. Log with the id and carry on.
log.warn('could not refresh a server', { server: server.id, error: err.message })
}
}
/**
* Runs once, after the schema and before the listener binds.
*
* Receives the same frozen `ctx` `register()` was given — not a second object
* built to look like it — so a module that only needs core at boot time can skip
* `core.init` entirely and use this argument.
*/
async function onBoot() {
await refresh()
refreshTimer = setInterval(refresh, REFRESH_MS)
// Node keeps the process alive for a pending timer. Core's own intervals are
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
log.info('booted', { refreshMs: REFRESH_MS })
}
/**
* Runs on SIGINT/SIGTERM, before core closes anything of its own.
*
* The database pool, the push dispatcher and the SSE fan-out are all still open,
* because flushing through them is the only thing this hook is for. There is a
* five-second budget per module, after which the hook is abandoned — abandoned
* rather than cancelled, since nothing can stop a promise that is still running.
*/
async function onShutdown() {
if (refreshTimer) clearInterval(refreshTimer)
refreshTimer = null
log.info('shut down')
}
module.exports = { onBoot, onShutdown, refresh, refreshOne, REFRESH_MS }

149
server/core.js Normal file
View File

@@ -0,0 +1,149 @@
// ── Everything this module reaches in core ─────────────────────────────────
//
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
// code beneath it — models, controllers, utilities — is ordinary Node that
// requires its dependencies at file scope, the way any Node file does. This file
// is what lets both of those be true at the same time.
//
// **Every export is a lazy accessor, not a stored reference, and that is the
// whole point.** A model writes
//
// const { query } = require('../../core')
//
// at require time, which is before `register()` has been called and therefore
// before any `ctx` exists. Handing out `ctx.db.query` at that moment would hand
// out `undefined`, permanently, and the failure would surface much later as a
// TypeError inside a model with no clue pointing here. So each member resolves
// `ctx` when it is CALLED. Require order stops mattering for everything except
// `core.init()` itself, which `index.js` runs first.
//
// The same rule in the other direction: **never destructure off `ctx` at init
// time.** Core is free to hand over a getter — `ctx.site.baseUrl` is one — and a
// value captured once is a value that cannot change.
//
// If `ctx` is missing every accessor throws the same message. The only ways to
// reach one before `register()` are a require cycle or a test that forgot to call
// `init`, and both want naming rather than `undefined`.
//
// ── This file is a NARROWING, on purpose ───────────────────────────────────
//
// §2.3 lists everything core hands over. What is re-exported below is only what
// this module actually uses, which is the discipline worth copying: the file is
// then an honest statement of what your module depends on, and a test double for
// it (see `test/_fakes.js`) is a complete one. Add a member here when you reach
// for it — not in advance.
let ctx = null
function need() {
if (!ctx) {
throw new Error('rust: core accessed before register() — see server/core.js')
}
return ctx
}
/** Called once, first thing in `register()`. */
function init(value) {
ctx = value
}
/** Test seam. Nothing in the module calls this; there is no de-registration. */
function _reset() {
ctx = null
}
// A logger that can be taken at require time and used after `register()`.
//
// A file writes `const log = require('../core').logger('servers')` at file scope,
// so the object returned has to exist before `ctx` does. It is a façade whose
// four methods each resolve the real logger when called. Core namespaces the
// output with your module id, so these come out as `[rust:servers]`.
function logger(namespace) {
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
}
module.exports = {
init,
_reset,
logger,
// Shared server dependencies. Core owns exactly one express, as it owns
// exactly one React on the client, and for the same reason: a second copy in
// the process is a second Router prototype and a second set of `instanceof`
// checks. A module could not resolve these for itself even if it were allowed
// to — it lives outside core's `server/` (§7.2).
get express() { return need().express },
get validator() { return need().validator },
// The database. `query(sql, params)` is what every `*.db.js` file uses; raw
// parameterised SQL, no ORM, the same as core. `pool` is there for the rare
// case that needs a connection it can hold (a streamed import, say).
query: (...args) => need().db.query(...args),
get pool() { return need().db.pool },
// Read-only access to who is asking. Minting a session is core's job; a module
// that needs an identity needs to *read* one.
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
// Core's middleware, taken as values rather than wrapped: express stores the
// function reference at mount time, so a wrapper is what would end up in the
// stack. Routers are built inside `register()`, so `ctx` is set by then.
get middleware() { return need().middleware },
// Firing a declared event (MODULE_API.md §2.3). Wrapped as a call rather than
// exposed as `get events()`, so that `require('../core').emit` taken at file
// scope still resolves `ctx` at call time like everything else here.
//
// **It returns nothing, and in production it never throws at the caller.** The
// emit is the end of this module's involvement: core validates the payload
// against the declared contract, decides which rules match, resolves who they
// reach and sends. A module cannot address a person, choose a channel or write
// a subject line, and this seam is deliberately too narrow to try (§2.7).
//
// Outside production a bad payload throws here rather than being logged, which
// is the point: you meet the mismatch in your own tests instead of in an
// operator's log six weeks later.
emit: (triggerId, envelope) => need().events.emit(triggerId, envelope),
// Secrets at rest (MODULE_API.md §2.3). Core's AES-256-GCM box, keyed by the
// deployment's `SECRET_ENC_KEY` — the same one that protects core's own OAuth
// client secrets and the uo-link token.
//
// **The sidecar token goes through this and nothing else.** It is the
// credential that reaches a game host, and it is stored encrypted and returned
// to no client ever: the admin API accepts a new value and reports only
// whether one is set. Returned as the box rather than as two wrapped functions
// so that `encrypt`/`decrypt` stay a matched pair at the call site.
secretBox: () => need().secretBox,
// The admin activity log (MODULE_API.md §2.3, 1.1.0). Every write on this
// module's admin tier goes through it, because the rows it writes are the
// credentials that reach a game host — "who changed the sidecar URL" is a
// question an operator will eventually need answered, and there is no second
// place it is recorded.
activity: { log: (...args) => need().activity.log(...args) },
// Telling core the game restarted (MODULE_API.md §2.3, 1.10.0). The one thing
// the event contract adds to `ctx`, and it is here for a reason worth carrying:
// **core has no concept of the game being up.** It sees `{ ok: false, retry: true }`
// and cannot tell a wedged sidecar from a shard that rebooted and lost every
// creature an event spawned. Only this module knows, because only this module
// watches the feed the boot id arrives on.
//
// Calling it asks core to sweep its resource ledger and put the question back
// to this module's actions, as `reconcile({ runId, resources })`. Fire and
// forget: it returns at once and the sweep happens on core's own time.
//
// See `boot.js` for the watch that calls it, and `config/eventActions.js` for
// the answer. Named longer than the `ctx` member it wraps because this object
// is flat — `core.emit` is already a little ambiguous and `core.reconcile()`
// would be worse, since a module has more than one thing it could reconcile.
reconcileEvents: () => need().events.reconcile(),
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
// only correct way to find a file you shipped, because the working directory is
// core's and the module's location is the loader's business.
get moduleRoot() { return need().paths.moduleRoot },
get moduleId() { return need().moduleId },
}

23
server/db/purge.sql Normal file
View File

@@ -0,0 +1,23 @@
-- ── The teardown ──────────────────────────────────────────────────────────
--
-- Destructive, and run ONLY by an explicit admin purge (MODULE_API.md §2.6).
-- Nothing on the boot path executes this file, and uninstalling the module does
-- not either: removing an operator's data is a second decision they make on
-- purpose, offered inside the uninstall flow and confirmed separately.
--
-- It exists because `schema.sql` does. A module that can create tables and
-- cannot drop them leaves an operator with orphaned data and no supported way to
-- remove it, so core refuses to load a module that declares one without the
-- other.
--
-- **Drop in the reverse of creation order**, which this file depends on:
-- `rust_server_state` carries a foreign key into `rust_servers`, so dropping the
-- parent first fails on the constraint — and a purge that fails halfway leaves
-- exactly the orphaned data it exists to remove.
--
-- What does NOT belong here: rows written into core's tables. Core prunes what
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
DROP TABLE IF EXISTS rust_server_state;
DROP TABLE IF EXISTS rust_servers;

99
server/db/schema.sql Normal file
View File

@@ -0,0 +1,99 @@
-- ── The schema fragment ───────────────────────────────────────────────────
--
-- Core replays this file on EVERY boot, statement by statement, immediately
-- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6).
--
-- There is no migration runner anywhere in this project. A module's schema is
-- not a sequence of changes to apply once — it is a statement of what the tables
-- should look like, written so that running it against a database that already
-- matches does nothing. Every CREATE carries IF NOT EXISTS; **changing a table
-- is an ALTER below the CREATE, never an edit to the CREATE**, because
-- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already
-- there and an edited column would reach fresh installs only.
--
-- Every table here is prefixed `rust_`, which is this module's id and the only
-- prefix it may create under.
--
-- ── Two tables, and the split between them is the whole design ────────────
--
-- `rust_servers` is CONFIGURATION: rows an operator writes, from Admin → Rust.
-- `rust_server_state` is OBSERVED STATE: rows this module writes from what a
-- sidecar reported. They are separate tables rather than columns on one because
-- they have different writers, different lifetimes and different audiences —
-- and because a purge of observed state while keeping the configuration is a
-- thing an operator will eventually want.
--
-- Teardown is `purge.sql`, which no boot ever runs.
-- ── The configured servers ────────────────────────────────────────────────
--
-- One row per Rust game server, and therefore one row per sidecar: the bridge is
-- one server to one sidecar, on that server's own host (R8). A community running
-- six servers has six rows here, each with its own base URL and its own token.
--
-- `id` is the operator's own slug and is what every URL under `/rust/servers/`
-- carries. It is deliberately NOT auto-increment: it appears in links people
-- share, and a row rebuilt after a mistake should be able to keep its address.
--
-- `sidecar_token_enc` holds the sidecar's shared secret **encrypted at rest**
-- through `ctx.secretBox` (MODULE_API.md §2.3), like every other secret this
-- platform stores. It is write-only in the API: the admin surface accepts a new
-- value and never returns the stored one, so a compromised admin session cannot
-- read back the credential that reaches the game host.
--
-- `protocol` records the wire version this row was configured against. It is
-- stored rather than assumed because a fleet is upgraded one host at a time, and
-- an operator needs to see WHICH server disagrees rather than that one does.
CREATE TABLE IF NOT EXISTS rust_servers (
id VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
sidecar_base_url VARCHAR(255) NOT NULL,
sidecar_token_enc TEXT NULL,
protocol INT UNSIGNED NOT NULL DEFAULT 1,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ── What each server last said about itself ───────────────────────────────
--
-- One row per configured server, replaced whole each time this module reads a
-- sidecar. It is the table that lets the site render while every game server is
-- off, which is the point of the sidecar holding a store at all.
--
-- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That
-- clause fires only when an UPDATE actually CHANGES a value, so a writer sending
-- the same numbers back — which is exactly what a quiet server looks like —
-- would leave the timestamp frozen at the first write and the row would look
-- stale while nothing was wrong. The writer sets the column explicitly instead.
--
-- `boot_id` is the game process's own identity, not the sidecar's and not the
-- plugin's. It changes when the world started over and at no other time, which
-- is what makes it the thing to watch: a reconnect of either bridge component
-- loses nothing, and a game restart loses everything an event put in the world.
--
-- `raw` keeps the whole frame. This module indexes the columns it serves and
-- stores the rest verbatim, so a protocol version that adds a field needs no
-- migration here — the same dumb-forwarder property the sidecar has, one hop
-- further along.
CREATE TABLE IF NOT EXISTS rust_server_state (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
reachable TINYINT(1) NOT NULL DEFAULT 0,
online TINYINT(1) NOT NULL DEFAULT 0,
players INT UNSIGNED NOT NULL DEFAULT 0,
max_players INT UNSIGNED NOT NULL DEFAULT 0,
hostname VARCHAR(191) NULL,
level VARCHAR(120) NULL,
seed BIGINT NULL,
world_size INT UNSIGNED NULL,
boot_id VARCHAR(64) NULL,
save_created_at VARCHAR(32) NULL,
protocol INT UNSIGNED NULL,
raw LONGTEXT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_server_state_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
);

105
server/index.js Normal file
View File

@@ -0,0 +1,105 @@
// ── The server entry point ─────────────────────────────────────────────────
//
// Core requires this file once, synchronously, while its own `app.js` is still
// being required, and calls the exported function with `(ctx, api)`. That is the
// entire server-side handshake: everything this module can reach arrives on
// `ctx`, and everything it can offer is registered through `api`.
//
// Normative: MODULE_API.md §2.2 (the entry point) and §2.4 (what you register).
//
// ── Three rules, and each one has a failure behind it ──────────────────────
//
// 1. **No `await`, and no database.** Core requires `app.js` in two build tools
// with the connection pool pointed at a dead port — the route-manifest
// generator and the OpenAPI generator both do it — so a module that queried
// at registration time would hang both. Anything that needs a live database
// goes in `onBoot`, which runs after the schema is up.
//
// 2. **Never resolve what core owns.** This module lives at
// `<website>/modules/rust/`, outside core's `server/`, so Node's resolver
// never reaches core's `node_modules` and `require('express')` from here
// simply fails. express, express-validator, the database, the logger and the
// middleware all arrive on `ctx` (§2.3) and are re-exported by `./core`. A
// second express in the process would be a second `Router` prototype, exactly
// as a second React would be a second renderer.
//
// 3. **Never reach into core's tree.** No relative path may escape this module's
// root. `scripts/checkImports.js` enforces it (§5.1) and CI runs it.
//
// ── Why the requires are INSIDE the function ───────────────────────────────
//
// Every file below reaches core through `./core`, whose members resolve `ctx`
// when they are CALLED. But a router writes `const express = core.express` at its
// own file scope, and that runs the moment the file is required. So
// `core.init(ctx)` has to happen before the first `require` of anything under
// `router/`. Hoisting these to the top of the file breaks the module with an
// error about a missing `ctx`, thrown from a file that never mentions one.
//
// Node caches modules, so requiring here costs nothing after the first call.
const core = require('./core')
/**
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
* @param {object} api what the module registers (§2.4)
*/
module.exports = function register(ctx, api) {
core.init(ctx)
/* eslint-disable global-require */
const publicRust = require('./router/public/rust.router')
const playerRust = require('./router/player/rust.router')
const adminRust = require('./router/admin/rust.router')
const boot = require('./boot')
/* eslint-enable global-require */
const log = core.logger()
// One prefix, on each of the three tiers (R14). The keys here must match
// `module.json`'s `mounts` exactly — the loader compares the two and rejects a
// mismatch in EITHER direction, so a route never declared and a prefix declared
// and never registered both fail loudly at boot rather than quietly at runtime.
//
// Each router sits INSIDE its tier router, so it structurally cannot reach
// above its prefix, and the tier's gate is already applied: `public` is behind
// nothing by design, `admin` behind `noindex, isLoggedIn, requireRole(...)` and
// `player` behind `noindex, requireAuth`. Per-route gates go on top; the tier
// gate is never re-implemented.
//
// **Prefixes share ONE namespace with core's own, and the collision probe
// cannot see all of it.** Core answers several public routes mounted at the
// tier root rather than under a prefix — `/status` and `/version` among them —
// and the loader's check cannot find those. `/rust` collides with nothing on
// any of the three tiers, checked against core's mount tables rather than
// assumed.
api.registerRoutes({
public: { '/rust': publicRust },
player: { '/rust': playerRust },
admin: { '/rust': adminRust },
})
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
// that must not serve traffic until it has warmed a cache gets that for free.
// It has no timeout, deliberately: a slow boot delays the listener, which is the
// guarantee rather than a problem to be timed out.
//
// `onShutdown` runs while core's database pool and push dispatcher are still
// open, because flushing through them is the only thing it is for. It gets a
// five-second budget and is abandoned past it.
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
// Everything else this module will register — the Team provider, the event
// triggers and audiences, the engagement seeds, the four event catalogues, the
// notification streams, the slash commands and the two extension slots — is
// deliberately absent. Each arrives with the phase that has something real to
// put in it. A registration with nothing behind it is worse than a missing one:
// a declared trigger nothing emits and a declared slot nothing fills are both
// surfaces an operator can configure and then wait on.
log.info('registered', {
version: require('../module.json').version,
routes: 'public:/rust player:/rust admin:/rust',
})
}

View File

@@ -0,0 +1,137 @@
// ── 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, protocol, updated_at AS updatedAt
FROM ${STATE}`,
)
}
/**
* 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, protocol, raw, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 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), protocol = VALUES(protocol),
raw = VALUES(raw), 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.protocol === undefined ? null : state.protocol,
state.raw ? JSON.stringify(state.raw) : null,
],
)
}
module.exports = {
SERVERS,
STATE,
listServers,
getServer,
upsertServer,
deleteServer,
listState,
putState,
}

View File

@@ -0,0 +1,139 @@
// ── 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,
}

1088
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
server/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "rust-module-server",
"version": "0.1.0",
"private": true,
"description": "Server half of the Rust module — routers, models and the schema fragment core loads at boot",
"license": "GPL-3.0-or-later",
"main": "index.js",
"scripts": {
"test": "node --test",
"check:imports": "node scripts/checkImports.js",
"swagger": "node scripts/swaggerFragment.js",
"check:swagger": "node scripts/swaggerFragment.js --check"
},
"engines": {
"node": ">=20"
},
"//dependencies": "There are none, and that is the shape to aim for: everything the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger and the middleware are all core-owned and handed over. If you do add one, remember an operator never builds: your release CI runs npm ci --omit=dev and packs server/node_modules into the tarball, so every dependency is weight in the artifact and a package the operator now runs. scripts/checkImports.js reads this file to decide what the shipped half may resolve.",
"devDependencies": {
"express": "^4.19.2",
"express-validator": "^7.1.0",
"swagger-autogen": "^2.23.7"
},
"//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express Router and a REAL express-validator, because a fake of either would only ever test the fake - the admin router builds its validation chains at file scope, so a stubbed validator is not something it can be required with. swagger-autogen generates the OpenAPI fragment; pin it to the same major core uses, so the fragment and the spec it merges into come out of one tool."
}

View File

@@ -0,0 +1,132 @@
// ── Admin · Rust — the handlers ───────────────────────────────────────────
//
// The write side of the module. Three things every handler here owes:
//
// 1. **Never return the token.** Not in a response, not in an error, not in an
// activity-log detail. It is accepted, encrypted and forgotten.
// 2. **Record the change.** `core.activity.log` writes core's own admin audit
// row. These handlers edit the credential that reaches a game host; "who
// changed this" has no second place it is recorded.
// 3. **Answer rather than throw.** An unhandled rejection reaches core's error
// handler and gets core blamed for a fault in this module.
const core = require('../../core')
const db = require('../../model/servers/servers.db')
const servers = require('../../model/servers/servers.model')
const sidecar = require('../../sidecarClient')
const log = core.logger('admin')
async function listServers(req, res) {
try {
res.json({ servers: await servers.listForAdmin() })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ error: 'Failed to read the server list' })
}
}
async function putServer(req, res) {
const { id } = req.params
const { name, sidecarBaseUrl, sidecarToken, protocol, enabled, sortOrder } = req.body
try {
const existing = await db.getServer(id)
// A NEW server with no token is a row that can never reach its sidecar, and
// the operator will read the resulting "unreachable" as a network problem.
// Refusing it up front costs one round trip and saves that hunt. An EXISTING
// row is a different case: omitting the token is how you say "leave it".
if (!existing && !sidecarToken) {
return res.status(400).json({ error: 'A new server needs its sidecar token' })
}
await db.upsertServer({
id,
name,
sidecarBaseUrl,
// `encryptToken` returns null for an empty value, and `upsertServer` reads
// null as "do not write this column". The two halves of that rule are in
// different files on purpose: the model decides what a blank means, the SQL
// decides what null does, and neither has to know the other's reason.
sidecarTokenEnc: servers.encryptToken(sidecarToken),
protocol: protocol === undefined ? sidecar.PROTOCOL_VERSION : protocol,
enabled: enabled === undefined ? true : enabled,
sortOrder: sortOrder === undefined ? 0 : sortOrder,
})
await core.activity.log({
req,
action: 'rust.server.save',
detail: {
server: id,
created: !existing,
sidecarBaseUrl,
// Whether the credential was rotated, never the credential.
tokenChanged: Boolean(sidecarToken),
},
})
return res.status(204).end()
} catch (err) {
log.error('failed to save a server', { server: id, error: err.message })
return res.status(500).json({ error: 'Failed to save the server' })
}
}
async function deleteServer(req, res) {
const { id } = req.params
try {
const existing = await db.getServer(id)
if (!existing) return res.status(404).json({ error: 'No such server' })
await db.deleteServer(id)
await core.activity.log({ req, action: 'rust.server.delete', detail: { server: id } })
return res.status(204).end()
} catch (err) {
log.error('failed to delete a server', { server: id, error: err.message })
return res.status(500).json({ error: 'Failed to delete the server' })
}
}
/**
* Probe one sidecar and report what came back.
*
* This is the route that tells a wrong URL from a wrong token from a mismatched
* protocol, and that distinction is the whole reason it exists: all three present
* to an operator as "the site says my server is offline", and each has a
* different fix. The status string from `sidecarClient` is carried through
* verbatim so the panel can say which.
*/
async function testServer(req, res) {
const { id } = req.params
try {
const row = await db.getServer(id)
if (!row) return res.status(404).json({ error: 'No such server' })
const result = await sidecar.health(servers.withToken(row))
await core.activity.log({
req,
action: 'rust.server.test',
detail: { server: id, ok: result.ok, status: result.status },
})
return res.json({
ok: result.ok,
status: result.status,
// `data` is the sidecar's own health document on success and the mismatch
// detail on a 409. Both are safe to show: neither carries a credential.
sidecar: result.data || null,
})
} catch (err) {
log.error('failed to probe a sidecar', { server: id, error: err.message })
return res.status(500).json({ error: 'Failed to probe the sidecar' })
}
}
module.exports = { listServers, putServer, deleteServer, testServer }

View File

@@ -0,0 +1,89 @@
// ── Admin · Rust ──────────────────────────────────────────────────────────
//
// Mounted at `/api/v1/admin/rust`. The tier's gate is already applied: `admin`
// sits behind `noindex, isLoggedIn, requireRole('admin','editor','moderator')`.
//
// **That gate is broader than these routes should be.** Editing a server row
// means editing the credential that reaches a game host, which is an
// administrator's job and not a moderator's — so the routes that write add
// `requireRole('admin')` on top of the tier. A module adds per-route gates over
// the tier gate and never re-implements it; this is what adding one looks like.
//
// ── The token is write-only ───────────────────────────────────────────────
//
// `sidecarToken` is accepted and never returned. The list route reports
// `hasToken` instead, because a blank field otherwise means both "unset" and
// "set, and not being shown to you". An empty string on a save leaves the stored
// value alone — an operator renaming a server must not have to re-paste a
// credential, and a form that posts its own blank field would otherwise erase one
// on every unrelated edit.
const core = require('../../core')
const express = core.express
const admin = require('./rust.controller')
const { requireRole, validate } = core.middleware
const { body, param } = core.validator
const adminRustRouter = express.Router()
adminRustRouter.get(
'/servers',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Every configured Rust server'
// #swagger.description = 'The operators server rows with their sidecar URLs, whether a token is stored, and whether each sidecar was reachable on the last poll. The token itself is never returned.'
/* #swagger.responses[200] = { description: 'The configured servers', content: { "application/json": { schema: { $ref: "#/components/schemas/RustAdminServerList" } } } } */
admin.listServers,
)
adminRustRouter.put(
'/servers/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a Rust server'
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries.'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body' } */
requireRole('admin'),
param('id')
.matches(/^[a-z0-9][a-z0-9-]{0,63}$/)
.withMessage('id must be lowercase letters, digits and hyphens'),
body('name').isString().trim().isLength({ min: 1, max: 120 }),
// A base URL is validated for SHAPE and not for reachability: an operator
// configures a sidecar before installing it about half the time, and refusing
// the row because nothing answers yet would make the obvious order of
// operations impossible.
body('sidecarBaseUrl').isURL({ require_tld: false, protocols: ['http', 'https'] }),
body('sidecarToken').optional({ values: 'falsy' }).isString().isLength({ max: 512 }),
body('protocol').optional().isInt({ min: 1, max: 1000 }).toInt(),
body('enabled').optional().isBoolean().toBoolean(),
body('sortOrder').optional().isInt({ min: -1000, max: 1000 }).toInt(),
validate,
admin.putServer,
)
adminRustRouter.delete(
'/servers/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Remove a Rust server'
// #swagger.description = 'Deletes the server row and the observed state that hangs off it. It does not touch the sidecar or the game host — those are removed with the installer.'
/* #swagger.responses[204] = { description: 'Deleted' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
validate,
admin.deleteServer,
)
adminRustRouter.post(
'/servers/:id/test',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Probe a servers sidecar'
// #swagger.description = 'Calls the sidecars health endpoint with the stored credential and reports what came back — whether it answered, whether the bridge plugin is connected to it, and which protocol version it speaks. This is the one route that tells a wrong URL from a wrong token from a mismatched version.'
/* #swagger.responses[200] = { description: 'What the sidecar said', content: { "application/json": { schema: { $ref: "#/components/schemas/RustSidecarProbe" } } } } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
validate,
admin.testServer,
)
module.exports = adminRustRouter

View File

@@ -0,0 +1,22 @@
// ── Player · Rust — the handlers ──────────────────────────────────────────
//
// See the router for why this tier is thin in phase 1. The one thing it must not
// do is reshape the list itself: it calls the same model the public tier does, so
// the two answers cannot drift while they are meant to be the same.
const core = require('../../core')
const servers = require('../../model/servers/servers.model')
const log = core.logger('player')
async function listServers(req, res) {
try {
res.json({ servers: await servers.listPublic() })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ error: 'Failed to read the server list' })
}
}
module.exports = { listServers }

View File

@@ -0,0 +1,41 @@
// ── Player · Rust ─────────────────────────────────────────────────────────
//
// Mounted at `/api/v1/player/rust`. The tier's gate is already applied: `player`
// sits behind `noindex, requireAuth`, so every handler here has a signed-in user
// and none of them re-implements that check.
//
// ── Why this tier exists in phase 1, and what it honestly holds ───────────
//
// R14 puts this module on all three tiers from the start, and the loader holds
// `module.json`'s `mounts` against what is actually registered in **both**
// directions — a declared prefix that never gets a router fails the load. So the
// declaration and the registration land together or not at all.
//
// What this tier will carry is the signed-in view of a server: the viewer's own
// linked Steam identity, their own presence, their own entitlements. None of that
// exists yet — identity is a later phase — so the one route here answers the
// server list as the signed-in caller sees it, which is currently the same list
// the public tier serves.
//
// That is deliberately a real route and not a placeholder: it is the URL the app
// and the SPA will call, and it starts answering correctly now rather than
// changing address later. What it must not become is a second copy of the public
// shape — it delegates to the same model, so the two cannot drift.
const core = require('../../core')
const express = core.express
const servers = require('./rust.controller')
const playerRustRouter = express.Router()
playerRustRouter.get(
'/servers',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'The Rust servers, for a signed-in player'
// #swagger.description = 'The same servers the public list carries, answered on the authenticated tier. It is the address a signed-in client calls, so that per-player detail can be added here without moving it. Requires a session.'
/* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */
servers.listServers,
)
module.exports = playerRustRouter

View File

@@ -0,0 +1,27 @@
// ── Public · Rust — the handlers ──────────────────────────────────────────
//
// Thin on purpose: read the request, call a model, answer. Everything worth
// testing is in the model, which needs no express and no database to test.
//
// **A handler must not throw past express.** Core mounts this router inside its
// own tier router, so an unhandled rejection here reaches core's error handler
// and answers 500 — survivable, but it means an operator sees core blamed for a
// fault in this module. Catch, log through `core.logger` (so the line carries the
// module id), and answer something honest.
const core = require('../../core')
const servers = require('../../model/servers/servers.model')
const log = core.logger('public')
async function listServers(req, res) {
try {
res.json({ servers: await servers.listPublic() })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ error: 'Failed to read the server list' })
}
}
module.exports = { listServers }

View File

@@ -0,0 +1,44 @@
// ── Public · Rust ─────────────────────────────────────────────────────────
//
// Mounted at `/api/v1/public/rust` by `index.js`. One express Router, built from
// CORE's express (`core.express`) — never from a `require('express')` of your
// own, which would not resolve from here anyway (MODULE_API.md §7.2).
//
// **The tier's gate is already on.** This router sits inside core's public tier,
// which is behind nothing by design. Per-route middleware goes on top, and
// `siteMode` is the one worth understanding: it is what makes a route respect the
// operator's maintenance switch. Core applies it to its own content routes and
// deliberately does not apply it to its status endpoints, because status is
// exactly what an operator wants visible *during* maintenance.
//
// The server list is content, not status — it is the module's landing page — so
// it takes `siteMode`.
//
// ── About the `#swagger` comments ─────────────────────────────────────────
//
// They are not documentation *of* the code; they are the source the OpenAPI
// fragment is generated from (`npm run swagger`, §2.8). swagger-autogen reads
// them as JavaScript literals it evaluates, so a QUOTE CHARACTER inside a
// single-quoted description ends the string early — and the failure is silent:
// the value is truncated at that character while the generator prints success.
// Use a typographic apostrophe () in prose. A backtick is fine.
const core = require('../../core')
const express = core.express
const servers = require('./rust.controller')
const { siteMode } = core.middleware
const rustRouter = express.Router()
rustRouter.get(
'/servers',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Every Rust server this site follows'
// #swagger.description = 'The operators configured Rust servers and what each one last reported. Answers with `online: false` and `stale: true` rather than failing when a game server or its sidecar is unreachable — the sites availability does not depend on the games.'
/* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */
siteMode,
servers.listServers,
)
module.exports = rustRouter

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env node
// ── §5.1 — zero internal imports ───────────────────────────────────────────
//
// The acceptance test for the whole module contract. A module that reaches into
// core's tree still works — right up until core moves a file — and the boundary
// this workstream exists to build is worth exactly as much as this check is.
//
// MODULE_API.md §5.1 sketches it as a grep for `../../`. That is the shape of
// the violation but not the rule, and the difference matters in both directions:
// a grep says nothing about `require('../../../../etc/passwd')` from a deeply
// nested file (which it catches by accident) and false-alarms on a legitimate
// `require('../module.json')` from `server/` (which it catches wrongly). So this
// RESOLVES each specifier against the file that wrote it and asks whether the
// result is still inside the module root — the actual rule, stated once.
//
// Bare specifiers are checked too, and against a stricter list than "is it
// installed": core hands the module express, express-validator, the database and
// the logger on `ctx` precisely so the module never resolves them, and Node's
// resolver cannot reach core's `node_modules` from here anyway. A bare
// `require` that is not a Node builtin is therefore a module that will fail to
// load on a real install, with a message about a missing package rather than
// about the rule it broke.
//
// **That second check applies to SHIPPED code only.** `test/` and `scripts/`
// never run inside core's process — the fakes in `test/_fakes.js` build a real
// `express` router precisely so the module's routers are exercised for real —
// so they may use devDependencies. The containment check applies everywhere,
// because a test that reaches into core's tree is a test that passes on this
// machine and nowhere else.
//
// Run over the SERVER half. The client half's equivalents are its Vite build,
// which fails if a shared dependency resolves into node_modules, and
// client/scripts/checkExternals.js, which asks the built chunk whether any bare
// specifier survived.
const fs = require('fs')
const path = require('path')
// Node's own answer, not a list reconstructed from `builtinModules`. That list
// omits `test` on Node 20 and includes it on Node 24, so a suite that requires
// `node:test` passed locally and failed in CI on the very first run — reported
// as the module boundary being broken, which it was not. `isBuiltin` is the
// authoritative check and handles the `node:` prefix itself.
const { isBuiltin } = require('module')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
// Packages the SHIPPED half may resolve for itself: this package's declared
// `dependencies`, and nothing else. Read from package.json rather than listed
// here, so adding one is a visible, reviewable edit to the manifest that also
// changes what CI installs and what the release tarball carries.
//
// Adding a dependency is a real decision. §2.7 permits a module its own, and the
// release tarball carries `server/node_modules` because an operator never builds
// — so every entry is weight in the artifact and a package the operator's
// deployment now runs. Anything core already owns must come from `ctx` instead:
// a second express is a second Router prototype, a second express-rate-limit is
// a second store, and a limit enforced by two independent counters is not the
// limit either of them states.
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
// Directories whose contents never run inside core's process, and may therefore
// resolve this package's devDependencies.
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
const dependencies = new Set(Object.keys(manifest.dependencies || {}))
const devDependencies = new Set(Object.keys(manifest.devDependencies || {}))
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
// need a dependency, and a specifier this pattern misses is a specifier written
// to be missed, which review catches and a stricter regexp would not.
const SPECIFIER = /(?:require\(|from\s+|import\()\s*['"]([^'"]+)['"]/g
/**
* Blank out comments and template literals before scanning.
*
* Not a nicety — without it this file fails on ITSELF, because the comments
* above name `require('../../../../etc/passwd')` as an example of what to
* catch, and index.js explains in prose why it must never `require('express')`.
* A boundary check that cannot survive being described is a check people stop
* writing comments around.
*
* A character walk rather than a regexp, because the two get in each other's
* way: `'https://x'` contains a line-comment opener inside a string, and
* `// don't` contains a quote inside a comment. Tracking the state is shorter
* than the regexp that would almost handle it. Content is replaced with spaces
* rather than removed so nothing else has to care.
*/
function stripCommentsAndTemplates(src) {
let out = ''
let i = 0
const keep = (n) => { out += src.slice(i, i + n); i += n }
const blank = (end) => { out += src.slice(i, end).replace(/[^\n]/g, ' '); i = end }
while (i < src.length) {
const two = src.slice(i, i + 2)
if (two === '//') {
const nl = src.indexOf('\n', i)
blank(nl === -1 ? src.length : nl)
} else if (two === '/*') {
const end = src.indexOf('*/', i + 2)
blank(end === -1 ? src.length : end + 2)
} else if (src[i] === '"' || src[i] === "'") {
// Strings are KEPT — they are where the specifiers live.
const quote = src[i]
keep(1)
while (i < src.length && src[i] !== quote) keep(src[i] === '\\' ? 2 : 1)
keep(1)
} else if (src[i] === '`') {
// Template literals are blanked: nothing may `require` a template, and a
// template holding SQL or HTML is a rich source of false positives.
i += 1
out += ' '
while (i < src.length && src[i] !== '`') {
if (src[i] === '\\') { out += ' '; i += 2 } else { out += src[i] === '\n' ? '\n' : ' '; i += 1 }
}
i += 1
out += ' '
} else {
keep(1)
}
}
return out
}
function* walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name))
} else if (/\.(js|mjs|cjs)$/.test(entry.name)) {
yield path.join(dir, entry.name)
}
}
}
/**
* Every boundary violation under `root`, resolved against `moduleRoot`.
*
* Exported so `test/checkImports.test.js` can point it at fixtures. A check that
* has never been shown to fail is a check nobody knows the state of — and this
* one guards the acceptance criterion for the whole contract.
*/
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) {
const violations = []
for (const file of walk(root)) {
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
for (const [, specifier] of source.matchAll(SPECIFIER)) {
if (specifier.startsWith('.')) {
const resolved = path.resolve(path.dirname(file), specifier)
if (resolved !== moduleRoot && !resolved.startsWith(moduleRoot + path.sep)) {
violations.push({ file, specifier, why: 'escapes the module root' })
}
} else if (path.isAbsolute(specifier)) {
violations.push({ file, specifier, why: 'absolute path' })
} else {
const pkg = specifier.startsWith('@')
? specifier.split('/').slice(0, 2).join('/')
: specifier.split('/')[0]
const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg))
// The `node:` prefix can only ever name a builtin, so it never reaches
// node_modules and is safe whatever this Node version enumerates.
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
if (!builtin && !allowed) {
violations.push({ file, specifier, why: 'undeclared bare specifier — should this come from ctx?' })
}
}
}
}
return violations
}
module.exports = { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT }
// Required by a test, or run as the check? Only the second one exits.
if (require.main !== module) return
const violations = scan(SERVER_ROOT)
if (violations.length) {
console.error(`\n${violations.length} import(s) break the module boundary (MODULE_API.md §5.1):\n`)
for (const v of violations) {
console.error(` ${path.relative(MODULE_ROOT, v.file)}\n "${v.specifier}" — ${v.why}`)
}
console.error('')
process.exit(1)
}
console.log(`OK — no import escapes the module root (${SERVER_ROOT}).`)

View File

@@ -0,0 +1,265 @@
#!/usr/bin/env node
// ── §2.8 — the OpenAPI fragment ───────────────────────────────────────────
//
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
// tags and schemas describing every route this module registers. Core merges the
// fragments of *started* modules over its own committed spec at request time and
// serves the result at `/api/docs.json` (MODULE_API.md §6.1a).
//
// ── Why a module has to ship this at all ──────────────────────────────────
//
// Core's own spec generation is STATIC analysis — swagger-autogen parses core's
// `app.js` as text and follows the literal `app.use(...)` chain. Your module
// arrives on a volume after core was built, is required by a filesystem loop, and
// mounts through `api.registerRoutes()`. There is no literal mount for a parser to
// follow, and core does not have your sources anyway. So nothing core can run
// will ever describe your routes.
//
// The failure mode is the dangerous one: swagger-autogen reports success and
// emits a spec with the routes simply absent. It happened twice inside core
// before anyone noticed, and once to the first module — 417 annotations that
// generated nothing at all, for two phases, because nobody had built the
// fragment. If you take one thing from this file, take that a green build is not
// evidence that anything was described.
//
// ── Where the prefixes come from ──────────────────────────────────────────
//
// swagger-autogen is pointed at one router file at a time, so its paths come out
// relative to that router (`/status`, not `/api/v1/public/world/status`) —
// nothing in the file says where it hangs. §6.1a requires fully-qualified paths,
// because core merges the fragment verbatim and never re-derives a prefix.
//
// So this script **runs your own `register()`** against a recording `api` and
// reads the mounts back out. Every prefix is therefore the prefix that router is
// actually registered under — the same call an operator's core will make, rather
// than a table beside it that drifts the first time a mount moves. Which file a
// recorded router object came from is answered by `require.cache`: the module
// whose `exports` IS that router.
//
// The tier base paths are the one thing that cannot be derived here, because they
// are core's and not yours. They are §2.4's normative table, quoted below.
const fs = require('fs')
const os = require('os')
const path = require('path')
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
const { fakeCtx, fakeApi } = require('../test/_fakes')
const doc = require('../swagger/doc')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
// router in core, behind its gate; the base path is core's and fixed.
const TIER_BASE = {
public: '/api/v1/public',
admin: '/api/v1/admin',
player: '/api/v1/player',
}
/**
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
*
* The ctx is the test fakes' — the same one the suite proves the module runs
* against — because registration must not touch a database (§2.2), and this
* script is exactly the kind of no-database caller that rule exists for.
*/
function mountedRouters() {
const register = require('../index')
const api = fakeApi()
register(fakeCtx(), api)
const fileOf = (router) => {
for (const mod of Object.values(require.cache)) {
if (mod && mod.exports === router) return mod.filename
}
return null
}
const mounts = []
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
const base = TIER_BASE[tier]
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
for (const [prefix, router] of Object.entries(byPrefix)) {
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
}
}
return mounts.map(({ router, prefix, what }) => {
const file = fileOf(router)
if (!file) {
// A router built inline in index.js rather than required from its own
// file. swagger-autogen needs a file to read, so there is nothing to
// generate from — put the router in its own module.
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
}
return { file, prefix, what }
})
}
/**
* Run swagger-autogen over one router file. Paths come out router-relative.
*
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
* `console.error`s "Syntax error" or "out of structure", drops that one
* annotation, and prints `Success` in green. So its diagnostics are captured here
* and made fatal. Nothing else will tell you.
*/
async function fragmentFor(file) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-swagger-'))
const out = path.join(dir, 'fragment.json')
const complaints = []
const realError = console.error
console.error = (...args) => {
const line = args.map(String).join(' ')
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
else realError(...args)
}
try {
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
// writes its result back into the object it was handed, so reusing one `doc`
// across several routers re-wraps the previous pass's output every time. The
// first module to hit this produced a 484 MB fragment from six routers.
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
...JSON.parse(JSON.stringify(doc)),
info: { title: 'examplegame fragment', version: '0' },
})
} finally {
console.error = realError
}
if (complaints.length > 0) {
throw new Error(
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
)
}
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
fs.rmSync(dir, { recursive: true, force: true })
return fragment
}
/**
* Re-root a router-relative fragment under the prefix it is mounted at.
*
* Express path params (`:id`) become OpenAPI's (`{id}`), and any param belonging
* to the PREFIX is moved to the front of each operation's parameter list —
* swagger-autogen orders parameters by where they appeared in the path it saw,
* which was only the tail.
*/
function prefixPaths(fragment, prefix) {
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
const paths = {}
for (const [p, item] of Object.entries(fragment.paths || {})) {
for (const operation of Object.values(item)) {
const params = operation && operation.parameters
if (!Array.isArray(params)) continue
const rank = (q) => {
const i = outer.indexOf(q && q.name)
return i === -1 ? outer.length : i
}
operation.parameters = params
.map((q, i) => ({ q, i }))
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
.map(({ q }) => q)
}
// `router.get('/')` under a prefix concatenates to a trailing slash, a URL no
// client calls. Core's generator normalises the same way.
paths[`${oas}${p}`.replace(/\/$/, '')] = item
}
return paths
}
/**
* Build the whole fragment: every mounted router, re-rooted and merged.
*
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a lets a
* fragment carry. `info`, `servers` and the security schemes belong to the merged
* document, which is to say to core.
*/
async function build() {
const spec = { paths: {}, tags: [], components: { schemas: {} } }
let shared = false
for (const { file, prefix, what } of mountedRouters()) {
const generated = await fragmentFor(file)
// Tags and schemas are the same on every pass — each was handed the same
// `doc` — so take them from whichever ran first. What lands in the fragment
// has to be what swagger-autogen PRODUCED and not what it was given: those
// two differ (see fragmentFor), and core merges this file verbatim into a
// spec whose own schemas went through the same mill.
if (!shared) {
spec.tags = generated.tags || []
spec.components.schemas = (generated.components || {}).schemas || {}
shared = true
}
const paths = prefixPaths(generated, prefix)
const count = Object.keys(paths).length
if (count === 0) {
// An empty result is precisely what the silent drop looks like, so it is a
// hard failure rather than a router that happens to declare no routes.
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
}
for (const [p, item] of Object.entries(paths)) {
if (spec.paths[p]) throw new Error(`swagger: two of this module's routers both document ${p}`)
spec.paths[p] = item
}
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix}${what}\n`)
}
// Sorted, because swagger-autogen emits router-traversal order: without this,
// moving a route between files rewrites most of a committed artifact even when
// the API is provably unchanged.
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
return spec
}
async function main() {
const check = process.argv.includes('--check')
const spec = await build()
const json = `${JSON.stringify(spec, null, 2)}\n`
if (!check) {
fs.writeFileSync(FRAGMENT, json)
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)}${Object.keys(spec.paths).length} paths\n`)
return
}
if (!fs.existsSync(FRAGMENT)) {
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
process.exit(1)
}
// Compared with line endings normalised, and that is not fussiness. A default
// Windows clone checks this file out as CRLF while the generator above writes
// LF, so a byte comparison failed on a PRISTINE template and told the reader
// their routes had changed — the kit's acceptance run lost ten minutes to it
// before reaching for `od -c` (docs/modules/kit-acceptance.md, F1). A check may
// only fail for the reason it names; this one names a diagnosis, so it has to
// be right about it. `.gitattributes` stops the CRLF from arriving in the first
// place, and this stops it mattering if it does.
const lf = (s) => s.replace(/\r\n/g, '\n')
if (lf(fs.readFileSync(FRAGMENT, 'utf8')) !== lf(json)) {
process.stderr.write(
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
'so a stale one documents a URL surface this module does not serve.\n',
)
process.exit(1)
}
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
}
if (require.main === module) {
main().catch((err) => {
process.stderr.write(`${err.stack}\n`)
process.exit(1)
})
}
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, FRAGMENT }

174
server/sidecarClient.js Normal file
View File

@@ -0,0 +1,174 @@
// ── The near end of a call whose far end is a Rust server ─────────────────
//
// Every other file in this module reads its own tables. This one is different in
// kind: it is the only place that leaves the process.
//
// **The website process never opens a connection to a game server**
// (MODULE_API.md §2.7). It opens one to a `rust-link` sidecar, which owns the
// socket to the game, persists what the game says before forwarding it, and
// answers reads from that store. `test/noGameConnection.test.js` enforces the
// decidable half of that rule and names this file as the one that may reach the
// network:
//
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
//
// ── One client per configured server ──────────────────────────────────────
//
// R8: the bridge is one game server to one sidecar. So this file takes the
// server row as an argument rather than holding a single configured endpoint —
// six servers is six base URLs and six tokens, and core never learns there is
// more than one.
//
// ── TIMEOUT_MS is not a tuning knob. It is half of a rule. ────────────────
//
// An event action declares `budgetMs`, and core's dispatcher enforces it: when
// the budget expires it stops waiting and classifies the failure as **retry**,
// unconditionally, without asking the action — it cannot ask, the action is still
// awaiting a socket. So if core's deadline is shorter than this one, an action
// never gets to classify its own failure and `{ ok: false, retry: false }` is
// unreachable code. `budgetMs` must EXCEED this.
//
// It is also bounded from the other side: the sidecar's own RPC reply timeout is
// ten seconds, so a value below that would give up while the sidecar is still
// legitimately waiting for the game. The ordering is
// `sidecar RPC timeout < TIMEOUT_MS < budgetMs`, and every one of the three
// is written down somewhere the other two can be checked against.
//
// ── This file never throws ────────────────────────────────────────────────
//
// Every call answers `{ ok, status, data }`. A module that let a socket failure
// escape into a controller would hand an exception to a page whose whole job is
// to render while the game is off. The public site degrades; it does not 500.
const core = require('./core')
const log = core.logger('sidecar')
/** How long this client waits before giving up on a sidecar. See the header. */
const TIMEOUT_MS = 12000
/**
* The wire version this module speaks. Declared in three places that must agree:
* here, `PROTOCOL_VERSION` in the sidecar, and `overlay.toml` in Rust-Plugins.
*
* It is sent on every request as `X-RustLink-Version`, which turns a mismatched
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 1
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
return { ok, status, data }
}
/**
* Normalises a configured base URL into something `new URL(path, base)` will not
* surprise anybody with.
*
* A trailing slash on the base and a leading slash on the path is the classic
* way to lose a path segment, and an operator pasting a URL out of a terminal
* supplies the trailing slash about half the time.
*/
function joinUrl(baseUrl, path) {
return `${String(baseUrl).replace(/\/+$/, '')}${path}`
}
/**
* One request to one sidecar.
*
* @param {object} server a `rust_servers` row, token already decrypted
* @param {string} server.baseUrl
* @param {string|null} server.token
* @param {string} path e.g. `/server`
* @param {object} [options]
* @param {string} [options.method]
* @param {object} [options.body]
*/
async function request(server, path, { method = 'GET', body = null } = {}) {
if (!server || !server.baseUrl) return reply(false, 'not-configured')
// A sidecar with auth off does not exist — it generates and persists a token on
// first start — so a missing token here is a half-finished admin form, not a
// sidecar to try unauthenticated. Saying so beats a 401 the operator has to
// interpret.
if (!server.token) return reply(false, 'no-token')
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const res = await fetch(joinUrl(server.baseUrl, path), {
method,
signal: controller.signal,
headers: {
Authorization: `Bearer ${server.token}`,
'X-RustLink-Version': String(PROTOCOL_VERSION),
...(body ? { 'Content-Type': 'application/json' } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
})
// A protocol mismatch is a deployment fault and deserves its own status, not
// to be folded into "the sidecar said no". The operator's fix is an upgrade
// of one component, and the message has to be able to say which.
if (res.status === 409) {
const detail = await safeJson(res)
log.warn('protocol mismatch', {
server: server.id,
module: PROTOCOL_VERSION,
sidecar: detail && detail.sidecar_protocol,
})
return reply(false, 'protocol-mismatch', detail)
}
if (res.status === 401) return reply(false, 'unauthorized')
// 204 is an ANSWER, not an absence of one: the sidecar is up and reports that
// the game has never connected. Collapsing it into a failure would make a
// freshly installed server indistinguishable from an unreachable one.
if (res.status === 204) return reply(true, 'empty', null)
if (!res.ok) return reply(false, `http-${res.status}`)
return reply(true, 'ok', await safeJson(res))
} catch (err) {
// `AbortError` is this client's own deadline firing, and it is worth telling
// apart from a refused connection: one means the sidecar is slow or the game
// is not answering, the other means nothing is listening.
const status = err && err.name === 'AbortError' ? 'timeout' : 'transport-error'
log.warn('sidecar request failed', { server: server.id, path, status, error: err.message })
return reply(false, status)
} finally {
clearTimeout(timer)
}
}
async function safeJson(res) {
try {
return await res.json()
} catch {
// A sidecar that answered 200 with something that is not JSON is a sidecar
// this module cannot use, but it is not a reason to throw at a page.
return null
}
}
/** Liveness, the protocol version, and whether the plugin is connected. Unauthenticated at the far end, but sent authenticated anyway so one code path covers every call. */
const health = (server) => request(server, '/health')
/** The last `server.hello` the sidecar stored. Answers while the game is off. */
const serverBoard = (server) => request(server, '/server')
/** A live round trip through the sidecar to the game. Fails when the game is down, by design. */
const liveStatus = (server) => request(server, '/status')
module.exports = {
TIMEOUT_MS,
PROTOCOL_VERSION,
request,
health,
serverBoard,
liveStatus,
joinUrl,
}

130
server/swagger/doc.js Normal file
View File

@@ -0,0 +1,130 @@
// ── The OpenAPI fragment: the shared half ─────────────────────────────────
//
// The tags and component schemas the `#swagger.*` annotations refer to.
// `scripts/swaggerFragment.js` feeds this to swagger-autogen; the per-endpoint
// detail lives beside each route, exactly as it does in core.
//
// **Two rules about names, and both belong to the MERGED document rather than to
// this file** (MODULE_API.md §6.1a). Core merges every started module's fragment
// over its own committed spec and serves the result at `/api/docs.json`, and core
// wins any key collision:
//
// • **Namespace what you DEFINE.** `RustServerList`, not `ServerList`. A second
// game's module describing the same idea under the same bare name would
// silently clobber this one or be clobbered by it.
// • **Reference what CORE defines by core's name.** `#/components/schemas/Error`
// and `ValidationError` are core's; point at them and do not redefine them.
//
// **swagger-autogen renders `components.schemas` from an EXAMPLE object, not from
// raw OpenAPI.** `{ type: 'object' }` comes back as a meta-description of itself.
// That is uniform across core's committed spec and is the house shape.
module.exports = {
tags: [
{
name: 'Public · Rust',
description: 'The Rust servers this site follows, as each one last reported itself',
},
{
name: 'Player · Rust',
description: 'The Rust surface for a signed-in player',
},
{
name: 'Admin · Rust',
description: 'Configuring the Rust servers and their sidecars',
},
],
components: {
schemas: {
RustServerList: {
type: 'object',
description: 'Every Rust server this site follows (GET /public/rust/servers).',
properties: {
servers: {
type: 'array',
items: { $ref: '#/components/schemas/RustServer' },
},
},
},
RustServer: {
type: 'object',
description: 'One Rust server, as it last reported itself.',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
online: { type: 'boolean', example: true },
players: { type: 'integer', example: 42 },
maxPlayers: { type: 'integer', example: 100 },
hostname: { type: 'string', nullable: true, example: 'Runic Gateway · Main' },
level: { type: 'string', nullable: true, example: 'Procedural Map' },
worldSize: { type: 'integer', nullable: true, example: 4000 },
seed: { type: 'integer', nullable: true, example: 1234 },
updatedAt: { type: 'string', format: 'date-time', nullable: true },
stale: {
type: 'boolean',
description: 'Has nothing reported in longer than the freshness window? A stale row is reported offline.',
example: false,
},
},
},
RustAdminServerList: {
type: 'object',
description: 'The configured servers, with their sidecar settings (GET /admin/rust/servers).',
properties: {
servers: {
type: 'array',
items: { $ref: '#/components/schemas/RustAdminServer' },
},
},
},
RustAdminServer: {
type: 'object',
description: 'One configured server. The sidecar token is never included — `hasToken` reports only whether one is stored.',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
sidecarBaseUrl: { type: 'string', example: 'http://10.0.0.5:8090' },
hasToken: { type: 'boolean', example: true },
protocol: { type: 'integer', example: 1 },
enabled: { type: 'boolean', example: true },
sortOrder: { type: 'integer', example: 0 },
reachable: {
type: 'boolean',
description: 'Did the sidecar answer on the last poll? Separate from `online`, which is about the game rather than the bridge.',
example: true,
},
bootId: { type: 'string', nullable: true, example: 'boot-20260915T194502Z' },
sidecarProtocol: { type: 'integer', nullable: true, example: 1 },
online: { type: 'boolean', example: true },
players: { type: 'integer', example: 42 },
stale: { type: 'boolean', example: false },
},
},
RustSidecarProbe: {
type: 'object',
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
properties: {
ok: { type: 'boolean', example: true },
status: {
type: 'string',
description: 'What happened, in one word — this is what tells a wrong URL from a wrong token from a mismatched protocol. One of `ok`, `no-token`, `unauthorized`, `protocol-mismatch`, `timeout`, `transport-error`, or `http-<code>`.',
example: 'ok',
},
sidecar: {
type: 'object',
nullable: true,
description: 'The sidecars own health document, or the mismatch detail on a protocol disagreement.',
properties: {
status: { type: 'string', example: 'ok' },
protocol: { type: 'integer', example: 1 },
plugin_connected: { type: 'boolean', example: true },
database: { type: 'string', example: 'ok' },
uptime: { type: 'string', example: '3h 2m' },
last_event: { type: 'string', format: 'date-time', nullable: true },
},
},
},
},
},
},
}

154
server/test/_fakes.js Normal file
View File

@@ -0,0 +1,154 @@
// ── Test doubles for what core hands the module ───────────────────────────
//
// Your server half is testable WITHOUT core, and that is not a convenience — it
// is the contract holding. Everything a module may touch arrives on `ctx`
// (MODULE_API.md §2.3), so a `ctx` this file can build is a complete statement of
// what your module depends on. **If a test ever needs something that is not here,
// either your module reached past the boundary or §2.3 needs a new member.** Both
// are worth stopping for.
//
// The fake mirrors §2.3 member for member — including the freezing, so a module
// that assigns to `ctx.something` fails here the way it would in core.
//
// This file lives under `test/`, which `checkImports.js` treats as not-shipped —
// which is why it may `require('express')` when the module's own routers may not.
// It builds a REAL express Router on purpose: a fake Router would only ever test
// the fake.
const express = require('express')
const expressValidator = require('express-validator')
/** Records every call, so a test can assert what the module asked for. */
function spy(returns) {
const fn = (...args) => {
fn.calls.push(args)
return typeof returns === 'function' ? returns(...args) : returns
}
fn.calls = []
return fn
}
function fakeLog() {
return { error: spy(), warn: spy(), info: spy(), debug: spy() }
}
function fakeCtx(overrides = {}) {
// `freeze: false` is a test seam for a suite that wants to adjust the ctx it
// installed. Core always freezes; the unfrozen variant is never a claim about
// what a module is handed in production.
const { freeze = true, ...rest } = overrides
const logs = []
const ctx = {
moduleId: 'rust',
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
express,
// The REAL express-validator, for the same reason express is real: the admin
// router builds its validation chains at file scope, so `{}` here is not
// something that file can even be required with.
validator: expressValidator,
db: { query: spy(Promise.resolve([])), pool: {} },
log: (namespace) => {
const log = fakeLog()
logs.push({ namespace, log })
return log
},
auth: { getUserFromRequest: spy(null) },
// The engagement seam (§2.3). One method, recording, because that is the
// whole of what a module may do with it: fire a declared event and stop.
// Core's own emit is fire-and-forget and returns nothing, so this does too —
// a fake that returned a receipt would invite a module to wait on one.
// `reconcile` joined it at 1.10.0 — the ONE thing the event contract adds to
// `ctx`, because an action is called BY core and is handed what it needs in
// the envelope. Only the module knows when the game restarted, so only the
// module can ask for the sweep.
events: { emit: spy(undefined), reconcile: spy(undefined) },
// A REVERSIBLE fake, not a recording one. Core's box is AES-256-GCM keyed by
// the deployment's SECRET_ENC_KEY; what a test needs from it is that
// `decrypt(encrypt(x)) === x`, because the bug this module could have is a
// token stored under one shape and read under another. A spy returning a
// constant would pass while proving nothing, and the tag makes an accidental
// plaintext leak visible in an assertion.
secretBox: {
encrypt: (s) => `enc:${s}`,
decrypt: (s) => {
if (typeof s !== 'string' || !s.startsWith('enc:')) throw new Error('not encrypted by this box')
return s.slice(4)
},
},
activity: { log: spy(Promise.resolve()) },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
siteMode: (req, res, next) => next(),
validate: (req, res, next) => next(),
noindex: (req, res, next) => next(),
// The factory returns a pass-through rather than a real limiter: a test
// that tripped a rate limit would be a test whose result depended on how
// many times the suite had run.
rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
accountChangeLimiter: (req, res, next) => next(),
},
site: { baseUrl: 'http://localhost:5173' },
...rest,
}
// Non-enumerable, and that is not tidiness. Core freezes every object value on
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
// by the loop below and every `log.info` would throw on push. Keeping it out of
// the enumeration also makes the fake more faithful: a module iterating `ctx`
// sees §2.3's members and nothing a test put there.
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
if (!freeze) return ctx
for (const value of Object.values(ctx)) {
if (value && typeof value === 'object') Object.freeze(value)
}
return Object.freeze(ctx)
}
/**
* The registration api, recording rather than mounting.
*
* Copies core's `once()` rule (§2.4: "calling twice is an error"), so a module
* that registers the same thing twice fails in its own suite rather than first on
* an operator's install.
*/
function fakeApi() {
const record = {
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
triggers: null, audiences: null, engagementSeeds: null,
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
}
const called = new Set()
const once = (name) => {
if (called.has(name)) throw new Error(`${name}() called twice`)
called.add(name)
}
const api = {
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
registerPostHook(hook) { once('registerPostHook'); record.hooks.post = hook },
// `once` here is not the general rule restated — it is a DIFFERENT rule that
// happens to look the same. The others may not be called twice by ONE module;
// this one holds a single value across the whole deployment, so a second
// module registering a provider collides with the first. A fake cannot see
// the second module, and asserting the half it can see is still worth doing.
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
// The event contract (1.10.0). `once` on all four: a batch is a module's
// COMPLETE statement about what it declares, so a second call is a module
// changing its mind halfway through `register()` rather than adding to it.
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}
api.record = record
return api
}
module.exports = { fakeCtx, fakeApi, spy }

View File

@@ -0,0 +1,149 @@
// The boundary check, checked.
//
// `scripts/checkImports.js` is the acceptance test for the whole module contract
// (MODULE_API.md §5.1), and a check that has never been shown to fail is a check
// nobody knows the state of. These point it at fixtures that break each rule and
// assert it says so — and at prose that merely *describes* breaking them, which
// is what it got wrong the first time it was run.
//
// **Every fixture is a template literal, and that is load-bearing.** The scanner
// reads the files in this directory too, so an ordinary quoted string holding
// `require('../../x')` would make this file fail the very check it is testing.
// Templates are blanked by the stripper for exactly this class of text: source
// being composed as data is not source being imported.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT } = require('../scripts/checkImports')
/** Write `files` into a throwaway module tree and scan it. */
function scanFixture(files, { dev = new Set() } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-tpl-'))
const src = path.join(root, 'server')
for (const [name, source] of Object.entries(files)) {
const file = path.join(src, name)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, source)
}
try {
return scan(src, root, { shipped: (f) => !f.startsWith(path.join(src, 'test') + path.sep), dev })
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
}
test('the real server half is clean', () => {
assert.deepStrictEqual(scan(SERVER_ROOT, MODULE_ROOT), [])
})
test('catches a relative path that escapes the module root', () => {
const found = scanFixture({ 'a.js': `require('../../server/src/utils/db')` })
assert.strictEqual(found.length, 1)
assert.strictEqual(found[0].why, 'escapes the module root')
})
test('allows a relative path that stays inside it, however deep', () => {
assert.deepStrictEqual(
scanFixture({ 'deep/nested/a.js': `require('../../../module.json')` }),
[],
)
})
test('catches an absolute path', () => {
const found = scanFixture({ 'a.js': `require('/etc/passwd')` })
assert.strictEqual(found[0].why, 'absolute path')
})
test('catches a bare specifier in shipped code, even a devDependency', () => {
// The rule that makes the boundary real: express arrives on ctx. A shipped
// file requiring it would fail on a real install, because a module lives
// outside core's server/ and never reaches core's node_modules.
const found = scanFixture({ 'a.js': `const express = require('express')` }, { dev: new Set(['express']) })
assert.strictEqual(found.length, 1)
assert.match(found[0].why, /should this come from ctx/)
})
test('allows a devDependency in test code, which never runs inside core', () => {
assert.deepStrictEqual(
scanFixture({ 'test/a.js': `const express = require('express')` }, { dev: new Set(['express']) }),
[],
)
})
test('allows node builtins anywhere, with or without the node: prefix', () => {
assert.deepStrictEqual(
scanFixture({ 'a.js': `require('path'); require('node:fs'); import crypto from 'node:crypto'` }),
[],
)
})
test('allows node:test, which older Node versions omit from builtinModules', () => {
// The first CI run failed on exactly this and on nothing else: `builtinModules`
// omits `test` on Node 20 and includes it on Node 24, so every test file in
// this suite was reported as breaking the module boundary. The check asks
// Node (`isBuiltin`) rather than rebuilding the list, and treats the `node:`
// prefix as sufficient on its own — a prefixed specifier can never resolve to
// a package, whatever the running version enumerates.
assert.deepStrictEqual(
scanFixture({ 'a.js': `require('node:test'); require('node:test/reporters')` }),
[],
)
})
test('catches ESM and dynamic forms, not only require()', () => {
const found = scanFixture({
'a.js': [`import db from '../../core/db.js'`, `const x = await import('../../core/other.js')`].join('\n'),
})
assert.strictEqual(found.length, 2)
})
test('ignores a violation that is only DESCRIBED in a comment', () => {
// The first run of this check failed on its own documentation, and on
// index.js's comment explaining why the module must never require('express').
// Prose about the rule must not trip the rule.
assert.deepStrictEqual(
scanFixture({
'a.js': [
`// Never write require("../../server/src/utils/db") - it escapes the module root.`,
`/* Nor import express from "express": core hands it over on ctx. */`,
`const path = require('path')`,
].join('\n'),
}),
[],
)
})
test('ignores a specifier-shaped string inside a template literal', () => {
assert.deepStrictEqual(
scanFixture({ 'a.js': ['const sql = ', '`SELECT 1 -- require("../../x")`'].join('') }),
[],
)
})
test('a comment opener inside a string does not swallow the rest of the file', () => {
// The reason this is a character walk and not a regexp: a URL in a string
// contains `//`, and treating that as a comment would blank everything after
// it — turning the check into one that silently passes.
const found = scanFixture({
'a.js': [`const url = 'https://example.com/x'`, `require('../../escaped')`].join('\n'),
})
assert.strictEqual(found.length, 1, 'the specifier after a URL string was missed')
})
test('a quote inside a comment does not swallow the rest of the file', () => {
const found = scanFixture({
'a.js': [`// don't do this`, `require('../../escaped')`].join('\n'),
})
assert.strictEqual(found.length, 1)
})
test('stripping preserves line numbers', () => {
// Blanked rather than removed, so anything that later reports a line still
// reports the right one.
const src = ['/* a', 'b', 'c */', `require("x")`, ''].join('\n')
assert.strictEqual(stripCommentsAndTemplates(src).split('\n').length, src.split('\n').length)
})

150
server/test/entry.test.js Normal file
View File

@@ -0,0 +1,150 @@
// ── The registration handshake ────────────────────────────────────────────
//
// The one suite every module should have, whatever else it does. Core validates
// all of this at boot and refuses to mount a module that fails — so testing it
// here is the difference between finding out in half a second and finding out on
// an operator's install.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, fakeApi } = require('./_fakes')
const manifest = require('../../module.json')
/** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */
function register(ctx = fakeCtx()) {
require('../core')._reset()
const api = fakeApi()
require('../index')(ctx, api)
return { api, ctx }
}
test('registers exactly the mounts module.json declares', () => {
const { api } = register()
// Core compares these two and rejects a mismatch in EITHER direction: a prefix
// declared and never registered is as fatal as a route registered and never
// declared. Asserting against the manifest rather than against a literal is
// what keeps the test true after a prefix is added.
assert.deepStrictEqual(
Object.keys(api.record.routes).sort(),
Object.keys(manifest.mounts).sort(),
)
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort())
}
})
test('all three tiers are mounted (R14)', () => {
const { api } = register()
// Not the assertion above restated. That one says the manifest and the code
// agree; this one says WHICH answer they agree on, so that deleting a tier from
// both halves at once still fails. R14 puts this module on all three from the
// start precisely so that a later phase adding a player surface does not have
// to move an address clients are already calling.
assert.deepStrictEqual(Object.keys(api.record.routes).sort(), ['admin', 'player', 'public'])
for (const tier of ['admin', 'player', 'public']) {
assert.deepStrictEqual(Object.keys(api.record.routes[tier]), ['/rust'])
}
})
test('every registered mount is a real express router', () => {
const { api } = register()
for (const byPrefix of Object.values(api.record.routes)) {
for (const [prefix, router] of Object.entries(byPrefix)) {
assert.strictEqual(typeof router, 'function', `${prefix} is not a router`)
assert.ok(router.stack, `${prefix} has no middleware stack`)
}
}
})
test('prefixes are one segment, lowercase, no parameters', () => {
// §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a
// module that fails it does not mount at all.
for (const prefixes of Object.values(manifest.mounts)) {
for (const prefix of prefixes) {
assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`)
}
}
})
test('registration touches no database and awaits nothing', () => {
const ctx = fakeCtx()
register(ctx)
// §2.2's first rule. Core requires `app.js` with the pool pointed at a dead
// port in two build tools, so a query here would hang both — and the symptom is
// a build that never finishes rather than an error naming this module.
assert.deepStrictEqual(ctx.db.query.calls, [])
})
test('registers both lifecycle hooks', () => {
const { api } = register()
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
})
test('the manifest declares what the loader requires', () => {
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
assert.ok(manifest.coreApi, 'coreApi is required — it is the version check')
// Declaring a schema without a purge is refused: a module that can create
// tables and cannot drop them leaves an operator with orphaned data.
if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file')
// The chunk must be in a SUBDIRECTORY — the directory it sits in is what core
// serves, so an entry in the module root would publish the whole module.
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
})
test('the manifest declares no extension slot it does not fill', () => {
const { api } = register()
// §11.3 of the plan reads `extensions` as "declared, and held against reality
// by the loader". Only the first half is true: the loader checks that a named
// slot EXISTS (`registries.hasSlot`) and never checks that the module went on
// to fill it — `checkDeclared` covers `mounts` alone. So a declaration with
// nothing behind it loads cleanly and means nothing, which is exactly why this
// module does not write one until it has an extension to register.
//
// The other half of that correction: `admin.users.detail` is the ONLY server
// slot core declares. `site.footer.status` is a CLIENT slot and is registered
// from the chunk — naming it here would fail the load with
// `unknown extension slot "site.footer.status"`.
const declared = manifest.extensions || []
const filled = api.record.extensions.map((e) => e.slot)
assert.deepStrictEqual([...declared].sort(), [...filled].sort())
})
test('nothing is registered that has nothing behind it yet', () => {
const { api } = register()
// The phase-1 statement, written down so that removing it is deliberate. A
// declared trigger nothing emits and a declared slot nothing fills are both
// surfaces an operator can configure and then wait on — worse than an absent
// one, because the absence is visible. Each of these arrives with the phase
// that has something real to put in it, and this assertion is what that phase
// deletes.
assert.strictEqual(api.record.teamProvider, null)
assert.strictEqual(api.record.triggers, null)
assert.strictEqual(api.record.audiences, null)
assert.strictEqual(api.record.engagementSeeds, null)
assert.strictEqual(api.record.streams, null)
assert.strictEqual(api.record.eventBudgets, null)
assert.strictEqual(api.record.eventOptionSources, null)
assert.strictEqual(api.record.eventLeases, null)
assert.strictEqual(api.record.eventActions, null)
})
test('the modules protocol version agrees with the manifest it ships beside', () => {
const sidecar = require('../sidecarClient')
// The wire version is declared in three repos — here, `PROTOCOL_VERSION` in
// the sidecar, and `overlay.toml` in the plugin overlay — and nothing in one
// repo can check the other two. What CAN be checked is that this repo says one
// thing: the number the client sends is the number an operator sees on a
// freshly created server row, so a bump that edits one and not the other
// configures every new server against a version the client does not speak.
assert.strictEqual(typeof sidecar.PROTOCOL_VERSION, 'number')
assert.ok(sidecar.PROTOCOL_VERSION >= 1)
})

View File

@@ -0,0 +1,151 @@
// ── §2.7's last rule, given the CI it does not have ───────────────────────
//
// `book/02-website-module.md` is explicit that "the website process never opens a
// connection to a game server" is the **one boundary rule with no CI behind it**:
// an outbound socket is not statically detectable the way an internal `require`
// is, so in general the rule is held up by review and by understanding it.
//
// True of the general case, and not a reason to check nothing. A module can state
// a narrower, completely decidable property about **itself**, and this one says:
// the shipped server half references no networking primitive at all. Everything
// it knows arrives from its own tables, which its sidecar writes.
//
// Adopted from the kit's acceptance run (`docs/modules/kit-acceptance.md`), where
// a reader building a Rust module wrote it unprompted after reading that the rule
// had no CI — and observed that for Rust in particular, which ships RCON over
// WebSocket, `new WebSocket(rconUrl)` in `boot.js` is about ten lines away.
//
// ── NARROWED, NOT DELETED ─────────────────────────────────────────────────
//
// This module has a real sidecar client, so the check is narrowed to allow that
// one file and keeps the rest of the tree under the ban. Talking to *the sidecar*
// over HTTP is the expected shape and is not what §2.7 forbids — the rule is
// about the **game server**.
//
// const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
//
// What that buys is a test naming the *one* file allowed to reach the network —
// exactly the file a reviewer should read closely, and exactly the place a
// game-server URL would appear if the rule were ever broken. The temptation on a
// red run here is to add a second name; the answer is almost always to move the
// call into `sidecarClient.js` instead.
//
// It is worth saying what this does NOT prove. `sidecarClient.js` is exempt, so
// nothing here stops it being pointed at a game server's own port — it would
// take a URL an operator typed. The decidable half is that no OTHER file can
// reach the network at all, which is what keeps the exempt file small enough to
// read.
//
// Scope: SHIPPED code only. `test/` and `scripts/` never run inside core's process.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const SERVER_ROOT = path.resolve(__dirname, '..')
const NOT_SHIPPED = new Set(['test', 'scripts', 'node_modules', 'swagger'])
/**
* The one shipped file allowed to reach the network. See the header.
*
* Kept as a set of BASENAMES rather than paths, so that moving the file does not
* silently re-ban it — a rename is meant to be a conversation.
*/
const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js'])
/** Every shipped `.js` file under `server/`. */
function shippedFiles(dir = SERVER_ROOT, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (dir === SERVER_ROOT && NOT_SHIPPED.has(entry.name)) continue
if (entry.name === 'node_modules') continue
shippedFiles(path.join(dir, entry.name), out)
} else if (entry.isFile() && entry.name.endsWith('.js')) {
out.push(path.join(dir, entry.name))
}
}
return out
}
/**
* Blank comments, so prose ABOUT the rule does not trip the rule.
*
* This file is itself the proof that it is needed: the paragraphs above say
* "WebSocket" several times. `scripts/checkImports.js` documents hitting exactly
* this on its own documentation, and it is the third time in this project's
* history that a boundary check has failed on the text explaining it.
*
* Blanked rather than deleted, so line numbers in a failure still point at the
* right line.
*/
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
.replace(/^[ \t]*\/\/.*$/gm, '')
}
// Each is a way a Node process opens a socket. Matched as identifiers, so a
// column named `websocket_url` inside a SQL string would not fire.
const NETWORKING = [
/\brequire\(\s*['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]\s*\)/,
/\bfrom\s+['"](?:node:)?(?:net|tls|dgram|http|https|http2)['"]/,
/\brequire\(\s*['"](?:ws|socket\.io-client|undici|axios|node-fetch|got)['"]\s*\)/,
/\bnew\s+WebSocket\b/,
/\bfetch\s*\(/,
/\bXMLHttpRequest\b/,
/\bEventSource\b/,
]
test('no shipped file references a networking primitive (§2.7)', () => {
const offenders = []
for (const file of shippedFiles()) {
if (MAY_OPEN_SOCKETS.has(path.basename(file))) continue
const code = stripComments(fs.readFileSync(file, 'utf8'))
for (const pattern of NETWORKING) {
if (pattern.test(code)) {
offenders.push(`${path.relative(SERVER_ROOT, file)} matches ${pattern}`)
}
}
}
assert.deepStrictEqual(
offenders,
[],
'the website process must never open a connection to a game server. If this is ' +
'your sidecar client, allow that one file rather than removing the check — see ' +
`the header of this file.\n ${offenders.join('\n ')}`,
)
})
test('every name on the allowlist is a file that exists and is shipped', () => {
// A stale allowlist entry is a silent hole: the file it exempted was renamed,
// the ban no longer covers the new name either (because the old one is still
// listed and nothing matches it), and the check goes on passing. Holding the
// list against the tree is what stops an exemption outliving its reason.
const shipped = new Set(shippedFiles().map((f) => path.basename(f)))
for (const name of MAY_OPEN_SOCKETS) {
assert.ok(shipped.has(name), `${name} is allowed to open sockets but is not a shipped file`)
}
})
test('the check can actually fail — it is pointed at a real violation', () => {
// A check that has never been shown to fail is a check nobody knows the state
// of. This is the game-server dial the rule exists to stop.
const violation = "const socket = new WebSocket('ws://10.0.0.5:28016/' + rconPassword)"
assert.ok(
NETWORKING.some((p) => p.test(stripComments(violation))),
'the guard would not have caught a direct game-server dial',
)
})
test('prose describing the rule does not trip it', () => {
const prose = [
'// A game shipping RCON over WebSocket means a module COULD write',
"// const s = new WebSocket(url); require('net')",
'// in about ten lines. It must not.',
'const x = 1',
].join('\n')
for (const pattern of NETWORKING) {
assert.ok(!pattern.test(stripComments(prose)), `${pattern} fired on a comment`)
}
})

116
server/test/schema.test.js Normal file
View File

@@ -0,0 +1,116 @@
// ── The schema fragment, checked against §2.6's rules ─────────────────────
//
// Core validates the fragment at LOAD time and refuses to mount a module that
// breaks a rule — with no tables created and no routes served. That is the right
// behaviour and a slow way to find a typo, so the same rules are checked here.
//
// **This is also the suite that catches a half-finished rename.** Change the id
// in `module.json` and forget a table name, and the prefix assertion below fails
// immediately rather than at an operator's first boot.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const manifest = require('../../module.json')
const read = (rel) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8')
/**
* Split a SQL file into statements the way core does.
*
* Core's own splitter is shared code (`utils/sqlStatements.js`) used by both the
* loader and the schema replay — this is a small stand-in for a test, and it is
* deliberately simple because the fragment it reads is deliberately simple. If
* your schema grows a stored procedure or a string containing a semicolon, stop
* trusting this and read the fragment a different way.
*/
function statements(sql) {
return sql
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n')
.split(';')
.map((s) => s.trim())
.filter(Boolean)
}
const schema = statements(read(manifest.schema))
const purge = statements(read(manifest.purge))
// The allowlist core enforces. Note it is an ALLOWLIST and not a `DROP` denylist:
// this file replays on every boot, so TRUNCATE or DELETE would empty a table on
// every restart — which no denylist naming only DROP would have caught.
const ALLOWED_VERBS = ['CREATE', 'ALTER', 'INSERT', 'UPDATE']
test('every statement starts with an allowed verb', () => {
for (const statement of schema) {
const verb = statement.split(/\s+/)[0].toUpperCase()
assert.ok(ALLOWED_VERBS.includes(verb), `"${verb}" is not one of ${ALLOWED_VERBS.join(', ')}`)
}
})
test('every table is prefixed with the module id', () => {
for (const statement of schema) {
const match = /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(statement)
if (!match) continue
assert.ok(
match[1].startsWith(`${manifest.id}_`),
`table "${match[1]}" is not prefixed "${manifest.id}_" — core will refuse to load this module`,
)
}
})
test('the fragment is idempotent — it replays on every boot', () => {
for (const statement of schema) {
if (/^CREATE\s+TABLE/i.test(statement)) {
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'CREATE TABLE without IF NOT EXISTS')
}
if (/^ALTER\s+TABLE/i.test(statement) && /ADD\s+COLUMN/i.test(statement)) {
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'ADD COLUMN without IF NOT EXISTS')
}
if (/^INSERT\s+INTO/i.test(statement)) {
// A plain INSERT succeeds once and then fails the whole replay on the next
// boot with a duplicate key — the classic "worked until I restarted it".
assert.ok(
/INSERT\s+IGNORE/i.test(statement) || /ON\s+DUPLICATE\s+KEY/i.test(statement),
'INSERT must be IGNORE or carry ON DUPLICATE KEY — it runs again every boot',
)
}
}
})
test('purge drops every table the schema creates', () => {
const created = schema
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
.filter(Boolean)
.map((m) => m[1])
const dropped = purge
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
.filter(Boolean)
.map((m) => m[1])
for (const table of created) {
assert.ok(dropped.includes(table), `${table} is created but never dropped — purge would orphan it`)
}
for (const table of dropped) {
assert.ok(created.includes(table), `${table} is dropped but never created`)
}
})
test('purge drops in the reverse of creation order', () => {
// With one table this proves nothing; with a parent and its children it is the
// difference between a clean teardown and a purge that fails halfway, leaving
// exactly the orphaned data it exists to remove.
const created = schema
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
.filter(Boolean)
.map((m) => m[1])
const dropped = purge
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
.filter(Boolean)
.map((m) => m[1])
assert.deepStrictEqual(dropped, [...created].reverse())
})

160
server/test/servers.test.js Normal file
View File

@@ -0,0 +1,160 @@
// ── 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', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale', 'updatedAt', 'worldSize',
])
})
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)
})