Files
Module-Rust/server/router/admin/usersRust.router.js
wtclaude 43147b796a feat(rust): site-owned permissions — the site is the author, the game is the cache
R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: a name in the store that is not in the
desired set is either something the site retired or something a human granted, and
those two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 18:28:32 -05:00

132 lines
7.6 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 users 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 users Steam link (admin only)'
// #swagger.description = 'Staff release a link on this users 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 users 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 mirrors 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 elses privilege. The revoke reaches the game on the mirrors 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,
)
module.exports = usersRustRouter