Files
Module-Rust/server/sidecarClient.js
wtclaude 43147b796a feat(rust): site-owned permissions — the site is the author, the game is the cache
R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: a name in the store that is not in the
desired set is either something the site retired or something a human granted, and
those two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 18:28:32 -05:00

257 lines
11 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`.
*
* **4 — the permission mirror.** Protocol 2 was the read path, 3 the first
* message the WEBSITE originates (`link.confirm`); 4 is the first that WRITES
* to the game — the whole permission set the site authors for one server, and
* the report the plugin sends back. 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 = 4
/** 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 })
module.exports = {
TIMEOUT_MS,
PROTOCOL_VERSION,
request,
health,
serverBoard,
liveStatus,
boards,
feed,
feedTail,
confirmLink,
permCatalogue,
permSync,
joinUrl,
}