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
200 lines
13 KiB
JavaScript
200 lines
13 KiB
JavaScript
// ── The `admin.users.detail` extension slot ───────────────────────────────
|
||
//
|
||
// R13's first slot, and the phase criterion in one file: *an operator sees the
|
||
// Steam id inside core's own user page*.
|
||
//
|
||
// MODULE_API.md §2.4's fourth mount shape — module routes hanging off a CORE
|
||
// resource. `/admin/users/:id` is a URL core owns and this module has something
|
||
// to say about it, so the routes cannot move behind a `/rust` prefix and cannot
|
||
// be registered anywhere else either. Core declares the slot; a module fills it,
|
||
// and only one module may.
|
||
//
|
||
// Three things about this router that are not true of the other three:
|
||
//
|
||
// • **`mergeParams: true`**, because the user id belongs to the parent. Without
|
||
// it `req.params.id` is undefined and every statement here silently scopes to
|
||
// nothing.
|
||
// • **The paths keep the module's own segment** (`/rust/links`, not `/links`).
|
||
// Core owns the resource and other modules may fill their own slots on other
|
||
// resources; a bare `/links` would be this module claiming a word on a URL it
|
||
// does not own.
|
||
// • **The gate is stricter than the admin tier's.** Core's users router is
|
||
// `requireRole('admin')` and the slot is mounted inside it, so editors and
|
||
// moderators never reach here — which is right for a surface that can sever
|
||
// what phases 7 and 13 grant against.
|
||
//
|
||
// The client half is registered under the SAME name (`registry.registerExtension`
|
||
// in `entry.jsx`) and builds its own client for these two routes; a slot passes a
|
||
// component `userId` and nothing else.
|
||
|
||
const core = require('../../core')
|
||
|
||
const express = core.express
|
||
const { body, param, query } = core.validator
|
||
|
||
const usersRust = require('./usersRust.controller')
|
||
const { validate } = core.middleware
|
||
|
||
// Same bound the player tier states, for the same reason: nothing but digits
|
||
// reaches a `WHERE steam_id = ?`.
|
||
const STEAM_ID_RE = /^[0-9]{5,32}$/
|
||
|
||
const usersRustRouter = express.Router({ mergeParams: true })
|
||
|
||
usersRustRouter.get(
|
||
'/rust/links',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'A user’s linked Steam accounts and their Rust record (admin only)'
|
||
// #swagger.description = 'Every Steam account linked to this website user, with the display name the game last saw and, per server, all-time kills / deaths / playtime across every wipe. Fills the admin.users.detail extension slot.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustAdminLinkList" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
usersRust.listLinks,
|
||
)
|
||
|
||
usersRustRouter.delete(
|
||
'/rust/links/:steamId',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Sever a user’s Steam link (admin only)'
|
||
// #swagger.description = 'Staff release a link on this user’s behalf. It is the counterweight to the site refusing to move a Steam id another account holds: a player who cannot reach that Steam account in game has no other way back. Recorded in the activity log.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
// #swagger.parameters['steamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Steam id to release.' }
|
||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { unlinked: { type: "boolean", example: true } } } } } } */
|
||
/* #swagger.responses[404] = { description: 'Not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
param('steamId').matches(STEAM_ID_RE),
|
||
validate,
|
||
usersRust.removeLink,
|
||
)
|
||
|
||
// ── Phase 7: what this person may do in game ─────────────────────────────
|
||
//
|
||
// The same panel, one section lower. It is here rather than only on the
|
||
// permissions screen because the question an operator actually has is about a
|
||
// PERSON — "why can this player spawn a kit" is asked on their page, not on a
|
||
// list of groups — and because the slot is already the place this module says
|
||
// everything else it knows about one user.
|
||
//
|
||
// Both writes go through the ordinary authored tables and the ordinary loop. A
|
||
// grant made here reaches the game when the mirror next reconciles, which is
|
||
// seconds, and never inside this request.
|
||
|
||
usersRustRouter.get(
|
||
'/rust/permissions',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'A user’s Rust privileges (admin only)'
|
||
// #swagger.description = 'The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[200] = { description: 'Their groups and grants', content: { "application/json": { schema: { $ref: "#/components/schemas/RustUserPermissions" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
usersRust.listPermissions,
|
||
)
|
||
|
||
usersRustRouter.post(
|
||
'/rust/permissions/grants',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Grant a Rust permission to this user (admin only)'
|
||
// #swagger.description = 'Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirror’s next pass.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[201] = { description: 'Granted' } */
|
||
/* #swagger.responses[200] = { description: 'They already held it' } */
|
||
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
body('permission').isString().matches(/^[a-z0-9][a-z0-9._-]{0,127}$/i),
|
||
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
|
||
validate,
|
||
usersRust.addGrant,
|
||
)
|
||
|
||
usersRustRouter.delete(
|
||
'/rust/permissions/grants/:grantId',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Remove a Rust permission from this user (admin only)'
|
||
// #swagger.description = 'Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody else’s privilege. The revoke reaches the game on the mirror’s next pass.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
// #swagger.parameters['grantId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grant to remove.' }
|
||
/* #swagger.responses[204] = { description: 'Removed' } */
|
||
/* #swagger.responses[404] = { description: 'No such grant for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
param('grantId').isInt({ min: 1 }).toInt(),
|
||
validate,
|
||
usersRust.removeGrant,
|
||
)
|
||
|
||
// ── Economics (PLAN_REDESIGNS §11.2.2): a person's coins, and staff's hand ──
|
||
//
|
||
// Beside their links, because "how many coins does this player have" is asked
|
||
// about a person. The write is the only way coins come back after an event
|
||
// paid them (D335): staff withdraw them by hand, and each adjustment is an
|
||
// activity row with the balance before and after.
|
||
|
||
usersRustRouter.get(
|
||
'/rust/economics',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'A user’s coin balances (admin only)'
|
||
// #swagger.description = 'This user’s Economics balance on every server, per linked Steam account — live where the server answers, else the last balance it reported. A server without Economics is listed with the reason. The same shape as GET /player/rust/economics.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||
param('id').isInt(),
|
||
validate,
|
||
usersRust.listEconomics,
|
||
)
|
||
|
||
usersRustRouter.post(
|
||
'/rust/economics',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Deposit, withdraw or set a user’s coins on one server (admin only)'
|
||
// #swagger.description = 'Through the server’s Economics, for one Steam account this user has linked. A withdrawal above the balance is refused with the balance, because Economics takes all or nothing. `key` makes a resent form harmless: the game answers a key it has seen from the first reply and moves nothing. Recorded in the activity log with the balance before and after.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjust" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Adjusted', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjusted" } } } } */
|
||
/* #swagger.responses[400] = { description: 'A bad op or amount', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Economics is not installed there, or the balance is too low to withdraw that much', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
body('serverId').isString().isLength({ min: 1, max: 64 }),
|
||
body('steamId').isString().matches(STEAM_ID_RE),
|
||
body('op').isIn(['deposit', 'withdraw', 'set']),
|
||
body('amount').isFloat({ min: 0, max: 1000000 }).toFloat(),
|
||
body('key').optional().isString().matches(/^[A-Za-z0-9-]{8,64}$/),
|
||
validate,
|
||
usersRust.adjustEconomics,
|
||
)
|
||
|
||
// ── Backpacks (PLAN_REDESIGNS §11.3.2): what a person carries, read live ──
|
||
//
|
||
// Read only (R2): there is no write beside it, here or in the game. Each open
|
||
// of the panel is one read; nothing is stored.
|
||
|
||
usersRustRouter.get(
|
||
'/rust/backpacks',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'A user’s backpack on one server (admin only)'
|
||
// #swagger.description = 'One of this user’s linked Steam accounts’ Backpacks backpack on one server, read live from the game and never stored. The same shape as GET /player/rust/backpacks. Staff see the overflow too; only the owner’s own open of the backpack moves it.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||
// #swagger.parameters['serverId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'The server.' }
|
||
// #swagger.parameters['steamId'] = { in: 'query', required: true, schema: { type: 'string' }, description: 'One of this user’s linked Steam ids.' }
|
||
/* #swagger.responses[200] = { description: 'The backpack', content: { "application/json": { schema: { $ref: "#/components/schemas/RustBackpack" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Backpacks is not installed on that server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('id').isInt(),
|
||
query('serverId').isString().isLength({ min: 1, max: 64 }),
|
||
query('steamId').isString().matches(STEAM_ID_RE),
|
||
validate,
|
||
usersRust.readBackpack,
|
||
)
|
||
|
||
module.exports = usersRustRouter
|