Stage 2 of PLAN_LAST_FIXES (docs#366). - W7/W11: an "is it loaded" refusal is confirmed live before it is given. New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick would), at most once per server per 5 s and waiting at most 3 s, then reads the stored status again. Used by Economics (step and staff page), Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes, TruePVE and RunicNPC. - W3: a world call answered `already-out` (the bridge half comes in stage 3) succeeds as called 0, saying a Travelling Vendor is already out. - W6: "A locked crate dropped by a Chinook is being hacked." - W8: a RaidableBases profile's audit row names RaidableBases, not "@data". - W10: the backpack sizes picker lists each size once, naming its servers. - W13: a RaidableBases name both servers describe differently shows each server's own description. - W14: each RaidableBases note after the first starts its own sentence. - W15: a staff deposit refused at Economics' limit answers 409. - W16: a payment's failedNote explains only the reasons that happened. - W17: the sync state counts player grants that did not land (waitingToLand), and the Permissions page says "in sync · 1 waits for their player to join" instead of plain "in sync". - W18: Guarded Crate's kept note drops the "30 minutes" it could not know. 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, the balance is too low to withdraw that much, or a deposit would pass Economics’ balance limit', 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
|