The site half of §11.3.2, on protocol 14:
- `rust.backpack.size` ("Bigger backpack"): a kit reward with a different
permission (D337) — one rust_perm_run_grants row per website user with
backpacks.size.N, no kit, no credit; the perm mirror pushes it and the run's
revert deletes it. risk change, reversible ledger, a new `rust.backpacks`
budget, rust.kit.entitle's recipient modes including `linked`. Refused with
"Backpacks is not installed on this server" in the dry run too (D199), and a
size the server does not offer is refused by name. Its message is never
silent: empty is the take-back warning (D338).
- `rust.options.backpacksizes`: each server's sizes above its smallest, from
the last hello, grouped by server.
- GET /player/rust/backpacks (own links only, 20 reads a minute) and
GET /admin/users/:id/rust/backpacks (that user's links only). Live, never
stored; a stranger's id is a 404 and never reaches the game.
- Client: a shared Backpack panel (pick server and account, open, read once)
on the account page and the admin user page; the grid draws pages of
pageSize, each slot named by the game (D340, no icons), blueprints by what
they make (D341), overflow marked. Backpacks joins the server's mod line.
Server 548/548 (13 new in test/backpacks.test.js), client 79/79 (5 new),
routes.manifest regenerated against the pinned core (89 routes), swagger
fragment regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
388 lines
21 KiB
JavaScript
388 lines
21 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`),
|
|
|
|
// RunicNPC (runicnpc stage 4). The profiles a leaderboard can rank by, one
|
|
// profile's ranking counted as the profile says (D247, D250), and one
|
|
// player's kills by profile, for an opened leaderboard row (D252).
|
|
npcProfiles: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/npc-profiles`),
|
|
npcLeaderboard: (id, { profile, wipe = null, limit = null } = {}) =>
|
|
req(`/public/rust/servers/${encodeURIComponent(id)}/npc-leaderboard${query({ profile, wipe, limit })}`),
|
|
npcKills: (id, steamId, { wipe = null } = {}) =>
|
|
req(`/public/rust/servers/${encodeURIComponent(id)}/players/${encodeURIComponent(steamId)}/npc-kills${query({ wipe })}`),
|
|
}
|
|
|
|
// 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'),
|
|
}
|
|
|
|
// Your own kills of each RunicNPC profile, current wipes, across your linked accounts (D252).
|
|
export const playerNpcKills = {
|
|
list: () => req('/player/rust/npc-kills'),
|
|
}
|
|
|
|
// 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'),
|
|
}
|
|
|
|
// Your coin balances on every server, per linked account (PLAN_REDESIGNS §11.2.2):
|
|
// live where a server answers, the last one it reported where it does not.
|
|
export const playerEconomics = {
|
|
list: () => req('/player/rust/economics'),
|
|
}
|
|
|
|
// Your own backpack on one server, from one of your linked accounts
|
|
// (PLAN_REDESIGNS §11.3.2): read live from the game, once per open.
|
|
export const playerBackpacks = {
|
|
read: (serverId, steamId) => req(`/player/rust/backpacks${query({ serverId, steamId })}`),
|
|
}
|
|
|
|
// ── 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 }),
|
|
// PLAN_REDESIGNS §5.5 (D175): a category's title for the whole site; '' resets it.
|
|
saveTitleCategory: (stat, text) =>
|
|
req(`/admin/rust/title-categories/${encodeURIComponent(stat)}`, { method: 'PUT', body: { text } }),
|
|
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.
|
|
const P = '/admin/rust/permissions'
|
|
const enc = encodeURIComponent
|
|
|
|
export const adminPermissions = {
|
|
overview: () => req(P),
|
|
catalogue: () => req(`${P}/catalogue`),
|
|
|
|
// One server, as PermissionsManager shows one (D162).
|
|
server: (serverId) => req(`${P}/servers/${enc(serverId)}`),
|
|
players: (serverId, q) => req(`${P}/servers/${enc(serverId)}/players?q=${enc(q || '')}`),
|
|
setPolicy: (serverId, policy) => req(`${P}/servers/${enc(serverId)}/policy`, { method: 'PUT', body: { policy } }),
|
|
|
|
// A subject is `{ steamId }` or `{ userId }`; `everywhere` reaches every server.
|
|
grant: (serverId, subject, permissions, everywhere = false) =>
|
|
req(`${P}/servers/${enc(serverId)}/grant`, { method: 'POST', body: { ...subject, permissions, everywhere } }),
|
|
revoke: (serverId, subject, permissions, everywhere = false) =>
|
|
req(`${P}/servers/${enc(serverId)}/revoke`, { method: 'POST', body: { ...subject, permissions, everywhere } }),
|
|
removeException: (id) => req(`${P}/exceptions/${enc(id)}`, { method: 'DELETE' }),
|
|
|
|
// Groups by id (D189). `here` is `{ onlyHere: true, serverId }` to split a
|
|
// shared group's copy off first (D190), or null to change it everywhere.
|
|
createGroup: (serverId, body) => req(`${P}/servers/${enc(serverId)}/groups`, { method: 'POST', body }),
|
|
updateGroup: (id, body, here = null) => req(`${P}/groups/${enc(id)}`, { method: 'PATCH', body: { ...body, ...(here || {}) } }),
|
|
deleteGroup: (id) => req(`${P}/groups/${enc(id)}`, { method: 'DELETE' }),
|
|
setGroupPermissions: (id, permissions, here = null) =>
|
|
req(`${P}/groups/${enc(id)}/permissions`, { method: 'PUT', body: { permissions, ...(here || {}) } }),
|
|
setGroupServers: (id, body) => req(`${P}/groups/${enc(id)}/servers`, { method: 'PUT', body }),
|
|
splitGroup: (id, serverId) => req(`${P}/groups/${enc(id)}/split`, { method: 'POST', body: { serverId } }),
|
|
addMember: (id, subject, here = null) =>
|
|
req(`${P}/groups/${enc(id)}/members`, { method: 'POST', body: { ...subject, ...(here || {}) } }),
|
|
removeMember: (id, subject, here = null) =>
|
|
req(`${P}/groups/${enc(id)}/members/remove`, { method: 'POST', body: { ...subject, ...(here || {}) } }),
|
|
clearMembers: (id, here = null) => req(`${P}/groups/${enc(id)}/members/clear`, { method: 'POST', body: { ...(here || {}) } }),
|
|
|
|
// What waits for a person (D161).
|
|
adoptDrift: (id) => req(`${P}/drift/${enc(id)}/adopt`, { method: 'POST' }),
|
|
revokeDrift: (id) => req(`${P}/drift/${enc(id)}/revoke`, { method: 'POST' }),
|
|
acceptDrift: (id) => req(`${P}/drift/${enc(id)}/accept`, { method: 'POST' }),
|
|
restoreDrift: (id) => req(`${P}/drift/${enc(id)}/restore`, { method: 'POST' }),
|
|
dismissDrift: (id) => req(`${P}/drift/${enc(id)}/dismiss`, { method: 'POST' }),
|
|
|
|
sync: (serverId = null) => req(`${P}/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 · zone presets (PLAN_REDESIGNS §3.1, D210) ───────────────────────
|
|
//
|
|
// An admin's named sets of ZoneManager flags and settings. `read` also carries
|
|
// what each server's last hello said about ZoneManager and ZoneDomes, so the
|
|
// page works while a server is off.
|
|
export const adminZones = {
|
|
read: () => req('/admin/rust/zones'),
|
|
create: (body) => req('/admin/rust/zones/presets', { method: 'POST', body }),
|
|
update: (id, body) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
|
remove: (id) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
}
|
|
|
|
// ── admin · NPCs (runicnpc stage 4) ─────────────────────────────────────────
|
|
//
|
|
// The site's RunicNPC profiles (per server, shared or fleet), pushed to each
|
|
// server; and each server's placements, which live on the server (D222), so
|
|
// every placement call is a live round trip that fails while the game is off.
|
|
const npcServer = (serverId) => `/admin/rust/npcs/servers/${encodeURIComponent(serverId)}`
|
|
export const adminNpcs = {
|
|
read: () => req('/admin/rust/npcs'),
|
|
setFactions: (factions) => req('/admin/rust/npcs/factions', { method: 'PUT', body: { factions } }),
|
|
create: (body) => req('/admin/rust/npcs/profiles', { method: 'POST', body }),
|
|
update: (id, body) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
|
remove: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
restore: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}/restore`, { method: 'POST' }),
|
|
push: (serverId) => req(`${npcServer(serverId)}/push`, { method: 'POST' }),
|
|
placements: (serverId) => req(`${npcServer(serverId)}/placements`),
|
|
place: (serverId, body) => req(`${npcServer(serverId)}/placements`, { method: 'POST', body }),
|
|
setPlacement: (serverId, id, body) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
|
removePlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
renamePlacement: (serverId, id, to) =>
|
|
req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/rename`, { method: 'POST', body: { to } }),
|
|
respawnPlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/respawn`, { method: 'POST' }),
|
|
}
|
|
|
|
// ── 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' },
|
|
),
|
|
}
|
|
|
|
// The same panel's Economics half (§11.2.2): this person's coins, and staff's
|
|
// hand on them. `key` is one per submission, so a resent form moves nothing.
|
|
export const adminUserEconomics = {
|
|
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`),
|
|
adjust: (userId, body) =>
|
|
req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`, { method: 'POST', body }),
|
|
}
|
|
|
|
// …and its Backpacks half (§11.3.2): what this person carries, read live. There
|
|
// is no write, here or in the game (R2).
|
|
export const adminUserBackpacks = {
|
|
read: (userId, serverId, steamId) =>
|
|
req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`),
|
|
}
|
|
|
|
// ── core's public event calendar ───────────────────────────────────────────
|
|
// The one CORE route this module reads, and core published `runId` on it for
|
|
// exactly this: a map marker holds a run id and nothing else, and the calendar is
|
|
// how a client finds the event it belongs to (website EVENTS.md §I, Phase 14a).
|
|
// It lists only announced events, so an unannounced one stays unnamed here too.
|
|
export const coreEvents = {
|
|
calendar: () => req('/public/events'),
|
|
}
|
|
|
|
// 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,
|
|
playerEconomics,
|
|
playerBackpacks,
|
|
admin,
|
|
adminPermissions,
|
|
adminConfig,
|
|
adminVisibility,
|
|
adminZones,
|
|
adminNpcs,
|
|
playerNpcKills,
|
|
adminUserLinks,
|
|
adminUserPermissions,
|
|
adminUserEconomics,
|
|
adminUserBackpacks,
|
|
coreEvents,
|
|
BASE,
|
|
}
|