Files
Module-Rust/client/src/api.js
wtclaude 383e89442e feat(rust): what the site has given a player, as the player reads it
Phase 8's website half. Phase 7 made the site the author of in-game
privilege and gave an operator every view of it; this is the other side,
and it is the first time a player can see what they hold without asking
one.

`GET /player/rust/permissions` is self-scoped in SQL and read-only by
construction — a grant a player could change would not be a grant. Three
things make it a different shape from the admin read rather than a
filtered one:

  * the scope arithmetic is answered on the server. A client handed `*`
    would have to know what the fleet is to say anything, and then
    `inScope` exists twice. Each entry carries the servers it reaches,
    already resolved and already marked.
  * `live` is the pushed ledger, never the authored row. A grant is not a
    privilege in a game until a sync confirmed it, and phase 7 is careful
    never to record a push that silently did nothing — so "waiting" is
    honest, and the alternative is the site claiming to have given
    something it has not.
  * nothing says WHY it is waiting. An offline server, a permission no
    loaded plugin registered and a store that has never seen the account
    all look the same from here; telling them apart is an operator's
    diagnosis and an inventory of what is installed.

An entitlement that reaches nobody still lists, and the page says so —
authored against the website account, it exists before a Steam id does,
and hiding it until one turns up is the defect the admin user page
shipped in phase 7 (PLAN.md §20.5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-22 20:09:51 -05:00

233 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' }),
}
// 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' }),
}
// ── 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,
playerPermissions,
admin,
adminPermissions,
adminConfig,
adminUserLinks,
adminUserPermissions,
BASE,
}