The plugin now answers a save once the files are written, with pending and a writeId, and reports the reload later as a config.outcome event. The save is recorded as reloading with a settle_by of two plugin ceilings plus slack on the database's clock; ingest settles the row by (server, writeId), only while it is still reloading, so a replay moves nothing and a late outcome still lands. A row past settle_by reads as lost. GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page polls it every two seconds, holds the Save button while it waits, and says whether a rolled-back plugin came back on its old file. config.outcome is a staff kind: it carries the server's log tail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
281 lines
14 KiB
JavaScript
281 lines
14 KiB
JavaScript
// ── This module's own API bindings ────────────────────────────────────────
|
|
//
|
|
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
|
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, and an
|
|
// `ApiError` thrown on any non-2xx. The paths are this module's, because the
|
|
// routes at the other end are — `server/router/**` in this repo serves them.
|
|
//
|
|
// **Do not build your own fetch wrapper.** The primitive is what carries the
|
|
// session cookie, the CSRF handling and the error shape core's `ErrorState`
|
|
// knows how to render. A module that calls `fetch` directly gets none of that
|
|
// and finds out one page at a time.
|
|
//
|
|
// Keeping the bindings in one file, ordered the way the routers are, is
|
|
// convention rather than contract — but the two halves of every call live in
|
|
// different directories and nothing checks them against each other, so anything
|
|
// that makes a mismatch easy to see is worth doing.
|
|
|
|
import rg from './core.js'
|
|
|
|
const { request: req, BASE } = rg.api
|
|
|
|
// ── public ────────────────────────────────────────────────────────────────
|
|
// Token-free, same-origin reads. Paths are relative to `/api/v1`, so this hits
|
|
// `/api/v1/public/rust/servers` — the route `server/router/public/rust.router.js`
|
|
// registers under the `/rust` prefix `module.json` declares.
|
|
export const servers = {
|
|
list: () => req('/public/rust/servers'),
|
|
|
|
// One server, and the only route under `/servers/:id` that can answer "no such
|
|
// server": the four below answer an empty list for an id nobody ever
|
|
// configured, because an unknown server genuinely has no events.
|
|
get: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}`),
|
|
|
|
// `kind` is a comma-separated list and `wipe` a wipe id; both are optional and
|
|
// both are built here rather than in a page, so the query string this module
|
|
// sends exists in one file.
|
|
events: (id, { kinds = null, wipe = null, limit = null } = {}) =>
|
|
req(`/public/rust/servers/${encodeURIComponent(id)}/events${query({
|
|
kind: kinds && kinds.length ? kinds.join(',') : null,
|
|
wipe,
|
|
limit,
|
|
})}`),
|
|
|
|
leaderboard: (id, { wipe = null, sort = null, limit = null } = {}) =>
|
|
req(`/public/rust/servers/${encodeURIComponent(id)}/leaderboard${query({ wipe, sort, limit })}`),
|
|
|
|
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
|
|
|
|
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
|
|
|
|
// Phase 9. The clan list is public (D58): name, colour, score and member count
|
|
// name nobody. `board` says whether the list can be trusted right now.
|
|
clans: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/clans`),
|
|
|
|
// Phase 14. The map's picture address, geometry and which layers this viewer
|
|
// gets; then what moves on it, already cut down to this viewer on the server.
|
|
map: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map`),
|
|
mapLive: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map/live`),
|
|
}
|
|
|
|
// One clan. Its roster comes back only for a viewer inside the operator's roster
|
|
// audience (D48) — clan members and staff by default — and `roster.visible`
|
|
// says which answer this was, so a page can explain an empty roster rather than
|
|
// imply an empty clan.
|
|
//
|
|
// The id carries colons (`<server>:<clan>:<created>`). They are legal in a path
|
|
// segment, and encoded anyway so that a server slug is never read as structure.
|
|
export const clans = {
|
|
get: (externalId) => req(`/public/rust/clans/${encodeURIComponent(externalId)}`),
|
|
}
|
|
|
|
/**
|
|
* A query string from the parameters that have a value, or `''`.
|
|
*
|
|
* **An absent parameter must be absent, not empty.** `?wipe=` is not the same
|
|
* question as no `wipe` at all — the first asks for a wipe whose id is the empty
|
|
* string — and a page that sends one because a `<select>` is on "All time" gets
|
|
* an empty leaderboard and no error.
|
|
*/
|
|
function query(params) {
|
|
const search = new URLSearchParams()
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value !== null && value !== undefined && value !== '') search.set(key, String(value))
|
|
}
|
|
const string = search.toString()
|
|
return string ? `?${string}` : ''
|
|
}
|
|
|
|
// ── player ────────────────────────────────────────────────────────────────
|
|
// The same list, on the authenticated tier. It exists so that per-player detail
|
|
// can be added at an address clients are already calling; today the two answers
|
|
// are identical and the server delegates to one model so they cannot drift.
|
|
export const playerServers = {
|
|
list: () => req('/player/rust/servers'),
|
|
}
|
|
|
|
// R1's identity link, from the signed-in player's side.
|
|
//
|
|
// **The code is the whole of what goes up.** The site has no idea which server
|
|
// minted it — nothing in six characters says — so the server half asks each
|
|
// configured server in turn (D24). A page that asked the player to pick would be
|
|
// asking them a question the site can answer itself, and a wrong pick would come
|
|
// back indistinguishable from a wrong code.
|
|
export const playerLinks = {
|
|
list: () => req('/player/rust/links'),
|
|
confirm: (code) => req('/player/rust/link', { method: 'POST', body: { code } }),
|
|
remove: (steamId) =>
|
|
req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }),
|
|
}
|
|
|
|
// What the site has given the caller in game (phase 8). Read-only, and beside
|
|
// `playerLinks` rather than under it: an entitlement exists whether or not an
|
|
// account is linked yet, which is exactly the state worth showing.
|
|
export const playerPermissions = {
|
|
list: () => req('/player/rust/permissions'),
|
|
}
|
|
|
|
// ── admin ─────────────────────────────────────────────────────────────────
|
|
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
|
// and a save that omits the field leaves the stored credential alone — so an
|
|
// admin form must send it only when the operator typed one, rather than sending
|
|
// its own empty field on every save.
|
|
export const admin = {
|
|
listServers: () => req('/admin/rust/servers'),
|
|
saveServer: (id, body) =>
|
|
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
|
deleteServer: (id) =>
|
|
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
testServer: (id) =>
|
|
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
|
|
// Phase 14: fetch a server's map picture again, or ask a server with no
|
|
// picture to draw one — which stalls that game for seconds (D109).
|
|
fetchMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/fetch`, { method: 'POST' }),
|
|
renderMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/render`, { method: 'POST' }),
|
|
// Phase 17: a server's chat titles, what optional mods it has, and the voice.
|
|
saveTitles: (id, body) => req(`/admin/rust/servers/${encodeURIComponent(id)}/titles`, { method: 'PUT', body }),
|
|
integrations: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/integrations`),
|
|
voice: () => req('/admin/rust/voice'),
|
|
saveVoice: (group) => req('/admin/rust/voice', { method: 'PUT', body: { group } }),
|
|
}
|
|
|
|
// ── admin · permissions (R2) ──────────────────────────────────────────────
|
|
//
|
|
// The authoring surface. Every call here writes to the SITE, and none of them
|
|
// reaches a game server — the mirror's own loop does that on its own cadence.
|
|
// `sync` is the exception and says so in its name: it runs the pass now and
|
|
// answers with what each server reported, which is the only call on this screen
|
|
// that can be slow or fail because a game host is down.
|
|
//
|
|
// A write is followed by a re-read rather than a local edit of the model: what
|
|
// the screen is showing is partly the game's answer, and the honest way to learn
|
|
// the new one is to ask.
|
|
export const adminPermissions = {
|
|
overview: () => req('/admin/rust/permissions'),
|
|
catalogue: () => req('/admin/rust/permissions/catalogue'),
|
|
|
|
saveGroup: (name, body) =>
|
|
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }),
|
|
deleteGroup: (name) =>
|
|
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
|
|
|
addMember: (name, username) =>
|
|
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, {
|
|
method: 'POST',
|
|
body: { username },
|
|
}),
|
|
removeMember: (name, userId) =>
|
|
req(
|
|
`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`,
|
|
{ method: 'DELETE' },
|
|
),
|
|
|
|
grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }),
|
|
revoke: (id) =>
|
|
req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
|
|
adoptDrift: (id) =>
|
|
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }),
|
|
revokeDrift: (id) =>
|
|
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
|
|
|
|
sync: (serverId = null) =>
|
|
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
|
|
}
|
|
|
|
// ── admin · visibility ────────────────────────────────────────────────────
|
|
//
|
|
// Who may see who is online. The org lead's rule is that nothing names who is
|
|
// online by default; this is where an operator deliberately widens it. A save
|
|
// answers the whole new state, so the screen re-renders from the server's word
|
|
// rather than from what it sent.
|
|
export const adminVisibility = {
|
|
read: () => req('/admin/rust/visibility'),
|
|
save: (body) => req('/admin/rust/visibility', { method: 'PUT', body }),
|
|
}
|
|
|
|
// ── admin · mod configuration (R18) ───────────────────────────────────────
|
|
//
|
|
// Every call here is a LIVE round trip to a game host, which makes this the only
|
|
// section of this file where a call can be slow, or fail because a server is
|
|
// off. Nothing is cached anywhere between the browser and the host's disk: a
|
|
// cached config is an edit an operator made over SSH that this website then
|
|
// silently overwrote.
|
|
//
|
|
// `save` carries a `version` the host issued with the file. Send a stale one and
|
|
// the answer is a 409 with the current file attached, rather than an overwrite
|
|
// of whatever somebody else changed in the meantime.
|
|
export const adminConfig = {
|
|
files: (serverId) => req(`/admin/rust/config/${encodeURIComponent(serverId)}/files`),
|
|
|
|
file: (serverId, path) =>
|
|
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file${query({ path })}`),
|
|
|
|
// Two tiers, one route. `edits` is the generated form — pointers and literals,
|
|
// type-preserving — and `text` is the raw document. A number travels as TEXT
|
|
// in both: `1.0` parsed into a JavaScript number and sent back as `1` is the
|
|
// whole failure this feature was designed around.
|
|
save: (serverId, body) =>
|
|
req(`/admin/rust/config/${encodeURIComponent(serverId)}/file`, { method: 'POST', body }),
|
|
|
|
writes: (serverId, limit = null) =>
|
|
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`),
|
|
|
|
// One write, polled while its reload settles (protocol 13). A save that
|
|
// reloads a plugin answers before the reload has finished — behind a cold
|
|
// compile that can be many seconds — and this is where the outcome lands.
|
|
write: (serverId, id) =>
|
|
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes/${encodeURIComponent(id)}`),
|
|
}
|
|
|
|
// ── the admin.users.detail extension slot ─────────────────────────────────
|
|
//
|
|
// The client half of R13's first slot. Core hands the component a `userId` and
|
|
// NOTHING else — not a client — so an extension builds its own bindings for the
|
|
// routes it registered at the other end (§3.5). These two are the only calls in
|
|
// this file whose path is core's rather than this module's: the resource is
|
|
// core's user, and the module's own segment is the part after it.
|
|
export const adminUserLinks = {
|
|
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/links`),
|
|
remove: (userId, steamId) =>
|
|
req(`/admin/users/${encodeURIComponent(userId)}/rust/links/${encodeURIComponent(steamId)}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
}
|
|
|
|
// The same panel's phase 7 half: what this person may do in game. The id in the
|
|
// path is the one the slot handed the component, so these send `userId` rather
|
|
// than a name — the screen already knows who it is looking at.
|
|
export const adminUserPermissions = {
|
|
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`),
|
|
grant: (userId, body) =>
|
|
req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, {
|
|
method: 'POST',
|
|
body,
|
|
}),
|
|
revoke: (userId, grantId) =>
|
|
req(
|
|
`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`,
|
|
{ method: 'DELETE' },
|
|
),
|
|
}
|
|
|
|
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
|
// download link, an EventSource. Reach for `request` first.
|
|
export { BASE, query }
|
|
|
|
export default {
|
|
servers,
|
|
clans,
|
|
playerServers,
|
|
playerLinks,
|
|
playerPermissions,
|
|
admin,
|
|
adminPermissions,
|
|
adminConfig,
|
|
adminVisibility,
|
|
adminUserLinks,
|
|
adminUserPermissions,
|
|
BASE,
|
|
}
|