Files
Module-Rust/server/router/admin/usersRust.router.js
wtclaude 845caef817
All checks were successful
PR Checks / server-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Successful in -1m29s
feat(rust): Economics — pay coins, balances, staff adjust, the richest (PLAN_REDESIGNS §11.2)
The site half of §11.2.2, on protocol 14:

- `rust.coins.pay` ("Pay coins"): irreversible, reversible none (D335). The
  recipient modes of rust.kit.entitle including `linked`, one payment per
  website user (D103's rule), an optional message with {amount}, a new
  `rust.coins` budget. Refused with "Economics is not installed on this
  server" in the dry run too (D199). Core's key goes to the plugin, so a
  retried step answers `repeat` and pays nobody twice.
- `rust_player_wipe_stats.balance`: the tally's balance, written as a value.
  A nameless balance frame (an offline player) moves nobody's last-seen.
  `economics.wiped` clears the wipe's balances, except at a new save.
- The richest: a Coins leaderboard column on one wipe of a server that
  reports Economics, `sort=balance`, and a `richest` title category
  (Magnate); both rank only above the starting balance (D336).
- GET /player/rust/economics (live, falling back to the last known), and
  GET/POST /admin/users/:id/rust/economics for staff — scoped to the user's
  own links, keyed per submission, an activity row with before and after.
- Client: the account page's Coins, the admin user page's Coins panel with
  its form, the leaderboard column, and Economics in the server's mod line
  (saying when its config could not be read).

Server 535/535 (14 new in test/economics.test.js), client 74/74,
routes.manifest regenerated against the pinned core (87 routes), swagger
fragment regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-12 07:07:05 -05:00

175 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 } = 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,
)
module.exports = usersRustRouter