Registers the engagement set R7 put in v1: thirteen triggers, four push streams, three audiences, four bodies (two triggers, email and in-app) and thirteen disabled rules in seven groups (PLAN.md §25, D59-D68). The raid alert goes to everyone authorised on the tool cupboard, one emit per linked person with ownerUserId, so the owner ceiling holds per emit. It covers doors and walls (protocol 7), never names the raider, alerts nobody when there is no cupboard, and carries ownerOnline so "offline only" is the seeded rule's condition rather than code. The fan-out runs off ingest before a frame is applied, since applying a disband deletes the roster the notice is sent to. A replayed event is told only while it is news: 15 minutes for broadcasts, 24 hours for personal and staff events. Dedupe keys come from the event, not the sidecar's row id. Server online/offline and a new kills leader are in-memory transitions, never on first sight, and a tie is not a lead. A login with no approval within a minute becomes a staff notice via a query, so a restart loses nothing. Also fixes a phase-4 gap (D68): the refresh now asks /health, so a game that hung, or whose bridge was unloaded, while the sidecar stayed up no longer reads as online. It stops naming players as online, and a stale board no longer moves "last seen". engagement-triggers.json is the committed freeze of all of it, checked in CI with line endings normalised. The check was verified by breaking it both ways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
297 lines
13 KiB
JavaScript
297 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`.
|
|
*
|
|
* **7 — the raid frame.** 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 the first that writes to the game HOST'S
|
|
* FILESYSTEM; 6 adds the `clans` board and five clan events core's Teams are
|
|
* built from, and no route at all; **7** widens `entity.destroyed` to doors,
|
|
* walls and the cupboard and names who is authorised there, which is what the
|
|
* raid alert is sent to (PLAN.md §25). 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 = 7
|
|
|
|
/** 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,
|
|
}
|