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
161 lines
6.1 KiB
JavaScript
161 lines
6.1 KiB
JavaScript
// ── The servers model ─────────────────────────────────────────────────────
|
|
//
|
|
// No database and no express: the model takes rows and produces the shapes the
|
|
// three tiers answer with, which is the whole reason the SQL lives in a separate
|
|
// file from the logic.
|
|
//
|
|
// Two things here are worth more than the rest: **a token never leaves this
|
|
// module**, and **a stale row cannot claim a server is up**.
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
|
|
const { fakeCtx } = require('./_fakes')
|
|
|
|
function withCore(ctx = fakeCtx()) {
|
|
require('../core')._reset()
|
|
require('../core').init(ctx)
|
|
return ctx
|
|
}
|
|
|
|
const NOW = Date.parse('2026-09-15T12:00:00Z')
|
|
|
|
const serverRow = (over = {}) => ({
|
|
id: 'main',
|
|
name: 'Main · Vanilla',
|
|
sidecarBaseUrl: 'http://10.0.0.5:8090',
|
|
sidecarTokenEnc: 'enc:s3cret',
|
|
protocol: 1,
|
|
enabled: 1,
|
|
sortOrder: 0,
|
|
...over,
|
|
})
|
|
|
|
const stateRow = (over = {}) => ({
|
|
serverId: 'main',
|
|
reachable: 1,
|
|
online: 1,
|
|
players: 42,
|
|
maxPlayers: 100,
|
|
hostname: 'Runic Gateway · Main',
|
|
level: 'Procedural Map',
|
|
seed: 1234,
|
|
worldSize: 4000,
|
|
bootId: 'boot-20260915T194502Z',
|
|
protocol: 1,
|
|
updatedAt: new Date(NOW - 10_000).toISOString(),
|
|
...over,
|
|
})
|
|
|
|
test('a fresh row reports what the server said', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
const shaped = servers.shapePublic(serverRow(), stateRow(), NOW)
|
|
|
|
assert.strictEqual(shaped.online, true)
|
|
assert.strictEqual(shaped.players, 42)
|
|
assert.strictEqual(shaped.stale, false)
|
|
assert.strictEqual(shaped.worldSize, 4000)
|
|
})
|
|
|
|
test('a stale row is reported offline, with no player count', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
|
|
// The row says what was true when it was written and nothing has written it
|
|
// since. Reporting its player count would put a number on a page that is
|
|
// simply the last number anyone saw, with no way for a reader to tell.
|
|
const old = stateRow({ updatedAt: new Date(NOW - servers.STALE_AFTER_MS - 1000).toISOString() })
|
|
const shaped = servers.shapePublic(serverRow(), old, NOW)
|
|
|
|
assert.strictEqual(shaped.stale, true)
|
|
assert.strictEqual(shaped.online, false)
|
|
assert.strictEqual(shaped.players, 0)
|
|
})
|
|
|
|
test('a server with no state row at all is stale rather than absent', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
|
|
// A configured server nothing has polled yet. It belongs on the page — an
|
|
// operator added it on purpose — and it must not claim to be online.
|
|
const shaped = servers.shapePublic(serverRow(), undefined, NOW)
|
|
|
|
assert.strictEqual(shaped.id, 'main')
|
|
assert.strictEqual(shaped.stale, true)
|
|
assert.strictEqual(shaped.online, false)
|
|
assert.strictEqual(shaped.updatedAt, null)
|
|
})
|
|
|
|
test('the public shape carries nothing about the sidecar', () => {
|
|
withCore()
|
|
const servers = require('../model/servers/servers.model')
|
|
const shaped = servers.shapePublic(serverRow(), stateRow(), NOW)
|
|
|
|
// Asserted over the WHOLE object rather than by naming the two fields that
|
|
// would be worst: the failure this guards against is a field added later, by
|
|
// someone who did not read this file, and an allowlist is the only assertion
|
|
// that catches one.
|
|
assert.deepStrictEqual(Object.keys(shaped).sort(), [
|
|
'hostname', 'id', '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)
|
|
})
|