R18's two tiers: a form generated from a config file's own values, and raw JSON for what a form cannot express. Admin → Rust mod config, one live round trip per action, nothing cached between a browser and a game host's disk. `configEdit.js` is the part that could not be done naively. JavaScript cannot tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C# classes — so a read-modify-write silently rewrites every whole-numbered float as an integer on fields nobody touched, and a plugin that then throws at load does not come back. It never parses, mutates and re-serialises: it records the SOURCE SPAN of every value and splices literals into them, so an untouched `1.0` is still `1.0` and a number an admin types travels as text the whole way (D35/D36). The bridge's own config is editable with `Host`, `Port` and `ServerId` locked, in the form and in the raw tier, because either would cut the link carrying the edit or strand every row this site holds (D38). Credentials render masked with a reveal; the raw tier shows them (D37) and the audit trail never does. `rust_config_writes` records every save including the refused and the rolled back — an operator asking why a setting is not what they set needs to see that somebody tried. Three defects a browser walk found that 179 green tests did not: * every save of the bridge's own config was refused while the page said the opposite — a `<select>` whose value matches no `<option>` shows the first one, so the reload guess `RunicGateway` was on the wire and "nothing" was on the screen; * `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every secondary button in this module has rendered as a primary one since phase 7 — here it made the open file and the active tier indistinguishable; * a save's refusal rendered at the top of a long form, far from the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
295 lines
13 KiB
JavaScript
295 lines
13 KiB
JavaScript
// ── 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 FOUR places that must agree:
|
|
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
|
|
* plugin, and `protocol` in its `overlay.toml`.
|
|
*
|
|
* **5 — configuration from the site.** Protocol 2 was the read path, 3 the
|
|
* first message the WEBSITE originates (`link.confirm`), 4 the first that
|
|
* writes to the game's permission store; 5 is the first that writes to the game
|
|
* HOST'S FILESYSTEM — a plugin's settings, and a reload watched closely enough
|
|
* to be undone. The bump lands here in the same change as the emitters,
|
|
* because the sidecar refuses a client declaring a different version with a
|
|
* `409`: a module left on 2 would stop being able to read the server board it
|
|
* has been reading all along. A constant that lags the deployment is not a safe
|
|
* default; it is an outage with a version number on it.
|
|
*
|
|
* 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 = 5
|
|
|
|
/** 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')
|
|
|
|
/** Every board at once: what is true now, before following what happens next. */
|
|
const boards = (server) => request(server, '/boards')
|
|
|
|
/**
|
|
* The ingest cursor: events after `since`, oldest first.
|
|
*
|
|
* **`since` is required here, unlike on the wire.** The sidecar treats an omitted
|
|
* cursor as "tell me where the end is", which is a genuinely useful question and
|
|
* a catastrophic default for an ingest loop that would silently store nothing
|
|
* and advance past everything. So the question is asked explicitly, by name, and
|
|
* a caller cannot get it by forgetting an argument.
|
|
*/
|
|
const feed = (server, since, limit = 200) =>
|
|
request(server, `/feed?since=${encodeURIComponent(since)}&limit=${encodeURIComponent(limit)}`)
|
|
|
|
/** Where the sidecar's history currently ends. What a new server's cursor starts at. */
|
|
const feedTail = (server) => request(server, '/feed')
|
|
|
|
/**
|
|
* Redeem a one-time link code against one server (protocol 3).
|
|
*
|
|
* **The only call in this file that is not a GET**, and the only one that asks
|
|
* the game a question rather than reading what it already said. The sidecar
|
|
* forwards the code to the plugin, which holds the pending codes in memory, and
|
|
* hands back what it answers.
|
|
*
|
|
* **A refused code comes back `{ ok: true }`.** `link.ok` and `link.error` are
|
|
* both answers — the sidecar reserves its own failures for the transport (503
|
|
* when the game is down, 504 when it is up and silent) — and the caller has to
|
|
* tell "that code is wrong" from "the game never replied" to say the right thing
|
|
* to a player. So the discrimination happens on `data.kind`, not on `ok`.
|
|
*
|
|
* A code is spent on the plugin's FIRST lookup whether or not it turns out to be
|
|
* expired, so this must never be called speculatively for its answer alone.
|
|
*/
|
|
const confirmLink = (server, code) =>
|
|
request(server, '/link/confirm', { method: 'POST', body: { code } })
|
|
|
|
/**
|
|
* What one server's loaded plugins have registered, and the groups its store
|
|
* holds (protocol 4).
|
|
*
|
|
* The option source behind the authoring form (D33). It is a live read through
|
|
* to the game rather than anything cached at the sidecar, because the answer
|
|
* changes when an operator loads a plugin — and the whole reason to ask is to
|
|
* offer names that will actually resolve. It therefore fails when the game is
|
|
* down, like `/status` and unlike every store-backed read.
|
|
*/
|
|
const permCatalogue = (server) => request(server, '/permissions/catalogue')
|
|
|
|
/**
|
|
* Push the whole permission set this site authors for one server (protocol 4).
|
|
*
|
|
* **The second call in this file that is not a GET, and the first that changes
|
|
* the game.** The body is the desired set plus what the site has withdrawn; the
|
|
* plugin diffs it against the live store, applies the difference and answers
|
|
* with a report — counts, the names it could not resolve, the memberships that
|
|
* are waiting on a first connection, and every holder the site did not author.
|
|
*
|
|
* **A refusal comes back `{ ok: true }`**, like a refused link code: `perm.error`
|
|
* and `perm.report` are both answers, and the sidecar keeps its own status codes
|
|
* for the transport. The caller discriminates on `data.kind`.
|
|
*/
|
|
const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set })
|
|
|
|
/**
|
|
* Every settings file on one game host, and every plugin loaded to reload one
|
|
* (protocol 5, R18).
|
|
*
|
|
* A description of the tree, never its contents: paths, sizes, which files are
|
|
* too large to edit, and the plugin each one probably belongs to. **Probably**
|
|
* is the operative word and it survives all the way to the form — a folder name
|
|
* is convention, not contract, and reloading the wrong plugin would report
|
|
* success while the edited one never re-read anything.
|
|
*
|
|
* Live, like `/status`: what is on a host's disk has no stale answer worth
|
|
* giving, and a cached one would be an edit an operator made over SSH that the
|
|
* website then overwrote.
|
|
*/
|
|
const configFiles = (server) => request(server, '/config/files')
|
|
|
|
/** One settings file as text, with the version a write has to present back. */
|
|
const configFile = (server, path) =>
|
|
request(server, `/config/file?path=${encodeURIComponent(path)}`)
|
|
|
|
/**
|
|
* Replace a set of settings files and reload what owns them (protocol 5).
|
|
*
|
|
* **The only call in this module that writes to a filesystem**, and the only one
|
|
* whose reply routinely takes seconds: the plugin holds it open across the
|
|
* reload it is watching, and across the rollback if that reload never arrives.
|
|
*
|
|
* Like every other write on this bridge, a refusal comes back `{ ok: true }`
|
|
* with the answer in `data.kind` — `config.report` or `config.error`. The
|
|
* transport keeps its own codes, and a `504` here is the one case worth reading
|
|
* carefully: the plugin writes a whole set or restores a whole set, never half
|
|
* of either, so the state is knowable by re-reading rather than by guessing.
|
|
*/
|
|
const configWrite = (server, body) => request(server, '/config/write', { method: 'POST', body })
|
|
|
|
module.exports = {
|
|
TIMEOUT_MS,
|
|
PROTOCOL_VERSION,
|
|
request,
|
|
health,
|
|
serverBoard,
|
|
liveStatus,
|
|
boards,
|
|
feed,
|
|
feedTail,
|
|
confirmLink,
|
|
permCatalogue,
|
|
permSync,
|
|
configFiles,
|
|
configFile,
|
|
configWrite,
|
|
joinUrl,
|
|
}
|