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
233 lines
11 KiB
JavaScript
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,
|
|
}
|