Files
Module-Rust/server/router/admin/usersRust.controller.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

279 lines
10 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` slot's handlers ──────────────────────────────
//
// What an operator can see and do about one website user's Rust identity. The
// user id is the PARENT's — `req.params.id` off core's `/admin/users/:id` — and
// every statement here is scoped by it, so a panel opened on one user cannot
// read or write another's rows by editing a path segment.
const crypto = require('node:crypto')
const core = require('../../core')
const economics = require('../../model/economics/economics.model')
const links = require('../../model/links/links.model')
const permissionsDb = require('../../model/permissions/permissions.db')
const permissions = require('../../model/permissions/permissions.model')
const servers = require('../../model/servers/servers.model')
const log = core.logger('admin')
/**
* GET /admin/users/:id/rust/links
*
* The linked Steam accounts and, per server, what this module knows about the
* player behind them — all-time rather than this wipe's, because an operator
* looking at a user wants their history and the public leaderboard already
* answers the other question.
*
* **An empty array is an answer.** Most users have no Rust link at all, and the
* panel renders nothing rather than an error for them.
*/
async function listLinks(req, res) {
try {
res.json({ links: await links.forAdmin(req.params.id) })
} catch (err) {
log.error('failed to read a user’s Rust links', { error: err.message })
res.status(500).json({ message: 'Failed to read this user’s Rust accounts' })
}
}
/**
* DELETE /admin/users/:id/rust/links/:steamId — staff sever a link (D25).
*
* **This is the counterweight to D23.** The site refuses to move a Steam id that
* another website account already holds, and the player's own way out is
* `/unlink` in game — which is no way out at all for somebody who has lost access
* to that Steam account, or to the site account holding it. Staff are that route.
*
* Scoped by the parent user id in the statement rather than checked first: the
* ownership test and the deletion are one operation, and a link that belongs to a
* different user answers 404 from the page it was not on.
*/
async function removeLink(req, res) {
const { steamId } = req.params
const userId = req.params.id
try {
const removed = await links.unlinkOwned(steamId, userId)
if (!removed) return res.status(404).json({ message: 'That account is not linked to this user' })
// The one write this panel has, so it is the one thing here worth an audit
// row: after phase 7 a link is what permissions are granted against, and
// "who severed it" stops being a curiosity.
await core.activity.log({
req,
action: 'rust.account.unlink.staff',
detail: { steamId, userId: Number(userId) },
})
return res.json({ unlinked: true })
} catch (err) {
log.error('failed to unlink a Steam account', { error: err.message })
return res.status(500).json({ message: 'Failed to unlink that account' })
}
}
/**
* GET /admin/users/:id/rust/permissions
*
* What this person may do in game, and — the part that is easy to leave out —
* whether any of it reaches anybody. A grant against an account with no linked
* Steam id is authored, stored, pushed nowhere and looks identical to a working
* one on every screen that does not say so.
*/
async function listPermissions(req, res) {
const userId = Number(req.params.id)
try {
const [groups, groupServers, groupPermissions, members, grants, allLinks] = await Promise.all([
permissionsDb.listGroups(),
permissionsDb.listGroupServers(),
permissionsDb.listGroupPermissions(),
permissionsDb.listGroupMembers(),
permissionsDb.listGrants({ userId }),
permissionsDb.listLinks(),
])
const theirs = new Set(members.filter((row) => row.userId === userId).map((row) => row.groupId))
const carried = new Map()
for (const row of groupPermissions) {
if (!carried.has(row.groupId)) carried.set(row.groupId, [])
carried.get(row.groupId).push(row.permission)
}
// A group is on one server unless it is shared (D189): `scope` says `*`
// for every server, or lists the servers it is on.
const on = new Map()
for (const row of groupServers) {
if (!row.included) continue
if (!on.has(row.groupId)) on.set(row.groupId, [])
on.get(row.groupId).push(row.serverId)
}
res.json({
groups: groups
.filter((group) => theirs.has(group.id))
.map((group) => ({
id: group.id,
name: group.name,
title: group.title,
scope: group.allServers ? permissions.FLEET : (on.get(group.id) || []).join(','),
permissions: carried.get(group.id) || [],
})),
grants: permissions.collapseGrants(grants).map((grant) => ({
id: grant.id,
permission: grant.permission,
scope: grant.scope,
source: grant.source,
note: grant.note,
grantedAt: grant.grantedAt,
})),
reaches: allLinks.filter((link) => link.userId === userId).map((link) => link.steamId),
})
} catch (err) {
log.error('failed to read a user’s Rust permissions', { error: err.message })
res.status(500).json({ message: 'Failed to read this user’s Rust permissions' })
}
}
/** POST /admin/users/:id/rust/permissions/grants */
async function addGrant(req, res) {
const userId = Number(req.params.id)
const permission = permissions.normaliseName(req.body.permission)
const scope = String(req.body.scope || permissions.FLEET)
try {
if (scope !== permissions.FLEET) {
const known = await servers.listForAdmin()
if (!known.some((row) => row.id === scope)) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
}
const { inserted } = await permissionsDb.insertGrant({
userId,
permission,
scope,
source: 'admin',
note: null,
grantedBy: req.user ? req.user.id : null,
})
if (inserted) {
await permissionsDb.markDirty(scope)
await core.activity.log({
req,
action: 'rust.perm.grant',
detail: { userId, permission, scope },
})
}
return res.status(inserted ? 201 : 200).json({ granted: inserted })
} catch (err) {
log.error('failed to grant a permission', { userId, permission, error: err.message })
return res.status(400).json({ message: 'That permission could not be granted' })
}
}
/**
* DELETE /admin/users/:id/rust/permissions/grants/:grantId
*
* **Scoped by the user as well as by the grant**, like every other write in this
* panel: a grant id belonging to somebody else answers `404` rather than
* removing a privilege from a person whose page nobody was looking at.
*/
async function removeGrant(req, res) {
const userId = Number(req.params.id)
const grantId = Number(req.params.grantId)
try {
const grant = await permissionsDb.getGrant(grantId)
if (!grant || grant.userId !== userId) {
return res.status(404).json({ message: 'That grant does not belong to this user' })
}
await permissionsDb.deleteGrant(grantId)
await permissionsDb.markDirty(grant.scope)
await core.activity.log({
req,
action: 'rust.perm.revoke',
detail: { userId, permission: grant.permission, scope: grant.scope },
})
return res.status(204).end()
} catch (err) {
log.error('failed to remove a grant', { userId, grant: grantId, error: err.message })
return res.status(500).json({ message: 'Failed to remove that permission' })
}
}
/** GET /admin/users/:id/rust/economics — this user's balances (§11.2.2), as the player sees them. */
async function listEconomics(req, res) {
try {
const held = await links.listForUser(Number(req.params.id))
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
} catch (err) {
log.error('failed to read a user’s balances', { error: err.message })
res.status(500).json({ message: 'Failed to read this user’s balances' })
}
}
/**
* POST /admin/users/:id/rust/economics — staff deposit, withdraw or set coins.
*
* **Scoped by the user**, like every write in this panel: the Steam id must be
* one this user has linked, so a wrong id on the form moves nobody's coins
* rather than a stranger's. The key is the form's, one per submission; a form
* that sent none gets one, and is then only as safe as a single click.
*/
async function adjustEconomics(req, res) {
const userId = Number(req.params.id)
const { serverId, steamId, op, amount } = req.body
const key = req.body.key ? `adj-${req.body.key}` : `adj-${crypto.randomUUID()}`
try {
const held = await links.listForUser(userId)
const link = held.find((l) => String(l.steamId) === String(steamId))
if (!link) return res.status(404).json({ message: 'That Steam account is not linked to this user' })
const result = await economics.adjust({ serverId, steamId: String(steamId), op, amount, key })
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
// A repeat moved nothing, so it is not a second row in the log.
if (!result.repeat) {
await core.activity.log({
req,
action: 'rust.economics.adjust',
detail: {
userId,
serverId,
steamId: String(steamId),
op,
amount: result.amount,
before: result.before,
after: result.after,
summary: economics.adjustmentSummary({
op,
amount: result.amount,
name: link.name || String(steamId),
server: result.server,
before: result.before,
after: result.after,
}),
},
})
}
return res.json({ before: result.before, after: result.after, repeat: result.repeat })
} catch (err) {
log.error('failed to adjust a balance', { userId, serverId, op, error: err.message })
return res.status(500).json({ message: 'Failed to adjust that balance' })
}
}
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics }