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
225 lines
11 KiB
JavaScript
225 lines
11 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`),
|
|
}
|
|
|
|
/**
|
|
* 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' }),
|
|
}
|
|
|
|
// ── 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' }),
|
|
}
|
|
|
|
// ── 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 · 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 })}`),
|
|
}
|
|
|
|
// ── 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,
|
|
playerServers,
|
|
playerLinks,
|
|
admin,
|
|
adminPermissions,
|
|
adminConfig,
|
|
adminUserLinks,
|
|
adminUserPermissions,
|
|
BASE,
|
|
}
|