PLAN_REDESIGNS section 1. - Every sync reads the store (perm.inventory), reconciles it against the site's record and its ledger, and pushes. A change made in the game is settled by the server's policy (D161): auto-adopt (default), adopt, or revoke. The first read of a server imports everything (D198). - Groups belong to one server unless an admin shares them (D189), in new id-keyed tables; the old ones are copied once at boot and left unread. Holders may be a Steam account nobody linked (D188). - An in-game change affects that server only (D190): a grant that reaches further gains an exception, a shared group is split. - Never judged: a permission the server does not register right now (an unloaded plugin is not a revocation), and a pair an event lease holds. - A new admin API (server view, grant/revoke with everywhere-or-here, groups by id, share/split, members, drift answers) and a screen on PermissionsManager's flow with a state on every toggle (D162, D163, U-1). - The announcement voice names a group by id; old name settings still read. Walked on both rigs against the walk core: import on an existing install, auto-adopt of a grant and a revoke, a fleet grant's exception, Kits unloaded without loss, a shared group split, adopt and revoke policies. Server 420/420, client 58/58, swagger, imports and route manifest current. Refs #21 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
385 lines
18 KiB
JavaScript
385 lines
18 KiB
JavaScript
// ── Admin · Rust · Permissions ────────────────────────────────────────────
|
||
//
|
||
// Mounted under the admin tier's `/rust` prefix, so every path here is
|
||
// `/api/v1/admin/rust/permissions…`. The permission manager (PLAN_REDESIGNS §1):
|
||
// the site owns every permission and group on every server (D160), a server at a
|
||
// time on the screen (D162), with each server's policy for a change made in the
|
||
// game (D161).
|
||
//
|
||
// **Every route is `requireRole('admin')`.** The admin tier's own gate admits
|
||
// editors and moderators, and a moderator being able to grant themselves
|
||
// `kits.admin` on six servers is the whole of R1's "a weak link is now a
|
||
// privilege-escalation path" arriving through the front door instead.
|
||
|
||
const core = require('../../core')
|
||
|
||
const express = core.express
|
||
const permissions = require('./permissions.controller')
|
||
const { requireRole, validate } = core.middleware
|
||
const { body, param, query } = core.validator
|
||
|
||
const permissionsRouter = express.Router()
|
||
|
||
/** A permission or group name, as both mod frameworks store them. */
|
||
const NAME = /^[a-z0-9][a-z0-9._-]{0,127}$/i
|
||
|
||
/** A Steam id: seventeen digits in practice, digits always. */
|
||
const STEAM = /^\d{5,20}$/
|
||
|
||
const serverParam = param('serverId').isString().isLength({ min: 1, max: 64 })
|
||
const groupParam = param('id').isInt({ min: 1 }).toInt()
|
||
|
||
/** A subject: a Steam id, a website account id, or a username. */
|
||
const subject = [
|
||
body('steamId').optional().isString().matches(STEAM),
|
||
body('userId').optional().isInt({ min: 1 }).toInt(),
|
||
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
|
||
]
|
||
|
||
/** "Here only" on a shared group: split this server's copy off first (D190). */
|
||
const hereOnly = [
|
||
body('onlyHere').optional().isBoolean().toBoolean(),
|
||
body('serverId').optional().isString().isLength({ min: 1, max: 64 }),
|
||
]
|
||
|
||
permissionsRouter.get(
|
||
'/',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'The permission manager: servers, policies and what waits for a person'
|
||
// #swagger.description = 'Every configured server with its policy for a change made in the game (`auto-adopt`, `adopt`, `revoke`; D161) and its sync state, and every row waiting for a person: each change under `adopt`, an event’s grant removed in the game, a hand-edited style field, and notices of a shared group split off for one server (D190). `chatFields` lists the twelve BetterChat fields for the style editor.'
|
||
/* #swagger.responses[200] = { description: 'The overview', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionOverview" } } } } */
|
||
requireRole('admin'),
|
||
permissions.overview,
|
||
)
|
||
|
||
permissionsRouter.get(
|
||
'/catalogue',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Permission names the servers have registered'
|
||
// #swagger.description = 'Every permission name the loaded plugins on each server registered, from the last inventory, with the plugin that registered it (`owner`) — null for a name no plugin owns, which on Carbon is its built-in modules’.'
|
||
/* #swagger.responses[200] = { description: 'Every registered name, which servers know it, and who registered it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */
|
||
requireRole('admin'),
|
||
permissions.catalogue,
|
||
)
|
||
|
||
permissionsRouter.get(
|
||
'/servers/:serverId',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'One server’s permissions, as the screen shows them'
|
||
// #swagger.description = 'Plugins grouped by the plugin that registered each permission, never by prefix; the groups on the server and where else each one is (D189); every player holding anything there, named by linked account and in-game name, or Steam id (D163); what the site wants and why, what has landed, and what the last report said did not — the facts every toggle’s state is read from (U-1).'
|
||
/* #swagger.responses[200] = { description: 'The server view', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionServer" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||
requireRole('admin'),
|
||
serverParam,
|
||
validate,
|
||
permissions.server,
|
||
)
|
||
|
||
permissionsRouter.get(
|
||
'/servers/:serverId/players',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Find a player seen on a server'
|
||
// #swagger.description = 'By in-game name, Steam id or linked account name, among the players this server has seen — to grant to somebody who holds nothing yet. At most 25, newest first.'
|
||
/* #swagger.parameters['q'] = { in: 'query', description: 'Part of a name, Steam id or account name', type: 'string' } */
|
||
/* #swagger.responses[200] = { description: 'Matching players' } */
|
||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||
requireRole('admin'),
|
||
serverParam,
|
||
query('q').optional().isString().isLength({ max: 64 }),
|
||
validate,
|
||
permissions.players,
|
||
)
|
||
|
||
permissionsRouter.put(
|
||
'/servers/:serverId/policy',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Set what a change made in the game becomes'
|
||
// #swagger.description = '`auto-adopt` (the default) makes it the site’s own for that server; `adopt` puts each one to a person; `revoke` undoes it (D161).'
|
||
/* #swagger.responses[204] = { description: 'Saved' } */
|
||
/* #swagger.responses[400] = { description: 'Not a policy' } */
|
||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||
requireRole('admin'),
|
||
serverParam,
|
||
body('policy').isString().isIn(['auto-adopt', 'adopt', 'revoke']),
|
||
validate,
|
||
permissions.setPolicy,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/servers/:serverId/grant',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Grant permissions to one player (a toggle, or Grant all)'
|
||
// #swagger.description = 'On this server, or with `everywhere` on every server. A linked Steam id is granted as its website account and reaches every account the person links (D28); an unlinked one as itself (D188). A grant kept off this server by an exception has the exception removed instead.'
|
||
/* #swagger.responses[200] = { description: 'How many were granted, and how many exceptions removed' } */
|
||
/* #swagger.responses[400] = { description: 'No subject named' } */
|
||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||
requireRole('admin'),
|
||
serverParam,
|
||
...subject,
|
||
body('permissions').isArray({ min: 1, max: 500 }),
|
||
body('permissions.*').isString().matches(NAME),
|
||
body('everywhere').optional().isBoolean().toBoolean(),
|
||
validate,
|
||
permissions.grant,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/servers/:serverId/revoke',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Revoke permissions from one player (a toggle, or Revoke all)'
|
||
// #swagger.description = 'Every direct grant that puts the permission on this server stops doing so: one scoped to this server is deleted; one that reaches further is deleted with `everywhere`, and otherwise gains an exception for this server (D190). What the player holds through a group or from an event is reported back in `untouched`, not changed.'
|
||
/* #swagger.responses[200] = { description: 'How many grants changed, and what could not be' } */
|
||
/* #swagger.responses[400] = { description: 'No subject named' } */
|
||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||
requireRole('admin'),
|
||
serverParam,
|
||
...subject,
|
||
body('permissions').isArray({ min: 1, max: 500 }),
|
||
body('permissions.*').isString().matches(NAME),
|
||
body('everywhere').optional().isBoolean().toBoolean(),
|
||
validate,
|
||
permissions.revoke,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/servers/:serverId/groups',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Create a group on one server'
|
||
// #swagger.description = 'A group belongs to one server unless an admin shares it (D189). A server cannot have two groups of one name.'
|
||
/* #swagger.responses[201] = { description: 'Created; the body carries its id' } */
|
||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||
/* #swagger.responses[409] = { description: 'This server already has a group of that name' } */
|
||
requireRole('admin'),
|
||
serverParam,
|
||
body('name').isString().matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'),
|
||
body('title').optional().isString().isLength({ max: 120 }),
|
||
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
|
||
body('parent').optional().isString().isLength({ max: 64 }),
|
||
validate,
|
||
permissions.createGroup,
|
||
)
|
||
|
||
permissionsRouter.patch(
|
||
'/groups/:id',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Change a group’s title, rank, parent or chat style'
|
||
// #swagger.description = 'The title is kept verbatim. `chat` is the group’s BetterChat style — all twelve fields as text; `null` removes it; absent leaves it. With `onlyHere` and `serverId` on a shared group, that server’s copy is split off first and only it changes (D190).'
|
||
/* #swagger.responses[200] = { description: 'Saved; `id` is the group that changed, a new copy if it was split' } */
|
||
/* #swagger.responses[400] = { description: 'An invalid style' } */
|
||
/* #swagger.responses[404] = { description: 'No such group' } */
|
||
requireRole('admin'),
|
||
groupParam,
|
||
body('title').optional().isString().isLength({ max: 120 }),
|
||
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
|
||
body('parent').optional().isString().isLength({ max: 64 }),
|
||
body('chat').optional({ values: 'null' }).isObject().withMessage('chat is an object of BetterChat fields, or null'),
|
||
...hereOnly,
|
||
validate,
|
||
permissions.updateGroup,
|
||
)
|
||
|
||
permissionsRouter.delete(
|
||
'/groups/:id',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Delete a group'
|
||
// #swagger.description = 'From the site and, at the next sync, from every server it is on. A built-in group (`default`, `admin`, Carbon’s `moderator`) is never removed from a game.'
|
||
/* #swagger.responses[204] = { description: 'Deleted' } */
|
||
/* #swagger.responses[404] = { description: 'No such group' } */
|
||
requireRole('admin'),
|
||
groupParam,
|
||
validate,
|
||
permissions.deleteGroup,
|
||
)
|
||
|
||
permissionsRouter.put(
|
||
'/groups/:id/permissions',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Replace what a group carries'
|
||
// #swagger.description = 'The whole list: the screen’s toggles, Grant all and Revoke all each send it. With `onlyHere` and `serverId` on a shared group, that server’s copy is split off first (D190).'
|
||
/* #swagger.responses[200] = { description: 'Saved; `id` is the group that changed' } */
|
||
/* #swagger.responses[404] = { description: 'No such group' } */
|
||
requireRole('admin'),
|
||
groupParam,
|
||
body('permissions').isArray({ max: 2000 }),
|
||
body('permissions.*').isString().matches(NAME),
|
||
...hereOnly,
|
||
validate,
|
||
permissions.setGroupPermissions,
|
||
)
|
||
|
||
permissionsRouter.put(
|
||
'/groups/:id/servers',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Share a group, or stop sharing it'
|
||
// #swagger.description = '`allServers` puts it on every server, including servers added later; otherwise `servers` lists them (D189). A chosen server that already has its own group of this name answers 409 with each one’s permissions; repeat with `replace` listing the ids to replace.'
|
||
/* #swagger.responses[200] = { description: 'Saved' } */
|
||
/* #swagger.responses[404] = { description: 'No such group' } */
|
||
/* #swagger.responses[409] = { description: 'A chosen server has its own group of this name' } */
|
||
requireRole('admin'),
|
||
groupParam,
|
||
body('allServers').optional().isBoolean().toBoolean(),
|
||
body('servers').optional().isArray({ max: 200 }),
|
||
body('servers.*').isString().isLength({ min: 1, max: 64 }),
|
||
body('replace').optional().isArray({ max: 200 }),
|
||
body('replace.*').isInt({ min: 1 }).toInt(),
|
||
validate,
|
||
permissions.setGroupServers,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/groups/:id/split',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Give one server its own copy of a shared group'
|
||
// #swagger.description = 'The copy carries the same permissions, members and style, on that server only, and the shared group stops covering it (D190).'
|
||
/* #swagger.responses[200] = { description: 'Split; `id` is the copy' } */
|
||
/* #swagger.responses[404] = { description: 'No such group' } */
|
||
/* #swagger.responses[409] = { description: 'That group is not on that server' } */
|
||
requireRole('admin'),
|
||
groupParam,
|
||
body('serverId').isString().isLength({ min: 1, max: 64 }),
|
||
validate,
|
||
permissions.splitGroup,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/groups/:id/members',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Put a player in a group'
|
||
// #swagger.description = 'A Steam id is a member as itself (D188); a website account reaches every Steam id it links (D28). A member who has never connected to a server is pending there until their first connection.'
|
||
/* #swagger.responses[204] = { description: 'Added' } */
|
||
/* #swagger.responses[400] = { description: 'No subject named' } */
|
||
/* #swagger.responses[404] = { description: 'No such group' } */
|
||
requireRole('admin'),
|
||
groupParam,
|
||
...subject,
|
||
...hereOnly,
|
||
validate,
|
||
permissions.addMember,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/groups/:id/members/remove',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Take a player out of a group'
|
||
// #swagger.description = 'Both ways they can be in it: as the Steam id, and as the website account it is linked to.'
|
||
/* #swagger.responses[204] = { description: 'Removed' } */
|
||
/* #swagger.responses[404] = { description: 'No such group' } */
|
||
requireRole('admin'),
|
||
groupParam,
|
||
...subject,
|
||
...hereOnly,
|
||
validate,
|
||
permissions.removeMember,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/groups/:id/members/clear',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Remove every member of a group'
|
||
/* #swagger.responses[204] = { description: 'Emptied' } */
|
||
/* #swagger.responses[404] = { description: 'No such group' } */
|
||
requireRole('admin'),
|
||
groupParam,
|
||
...hereOnly,
|
||
validate,
|
||
permissions.clearMembers,
|
||
)
|
||
|
||
permissionsRouter.delete(
|
||
'/exceptions/:id',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Give a grant back to the one server it was kept off'
|
||
/* #swagger.responses[204] = { description: 'Removed' } */
|
||
/* #swagger.responses[404] = { description: 'No such exception' } */
|
||
requireRole('admin'),
|
||
param('id').isInt({ min: 1 }).toInt(),
|
||
validate,
|
||
permissions.removeException,
|
||
)
|
||
|
||
const driftId = param('id').isInt({ min: 1 }).toInt()
|
||
|
||
permissionsRouter.post(
|
||
'/drift/:id/adopt',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Adopt a change made in the game'
|
||
// #swagger.description = 'An addition (or a changed group) becomes the site’s own for that server — the same write auto-adopt makes. For a `chat-field` row, the game’s value becomes the group’s style; 409 when the group has no style or the value is not one the site accepts.'
|
||
/* #swagger.responses[204] = { description: 'Adopted' } */
|
||
/* #swagger.responses[400] = { description: 'That change was a removal' } */
|
||
/* #swagger.responses[404] = { description: 'No such change' } */
|
||
requireRole('admin'),
|
||
driftId,
|
||
validate,
|
||
permissions.adoptDrift,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/drift/:id/revoke',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Undo an addition made in the game'
|
||
// #swagger.description = 'Queued: a server that is down keeps the instruction until it comes back. For a `chat-field` row it puts the site’s value back.'
|
||
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
|
||
/* #swagger.responses[400] = { description: 'Only an addition can be revoked' } */
|
||
/* #swagger.responses[404] = { description: 'No such change' } */
|
||
requireRole('admin'),
|
||
driftId,
|
||
validate,
|
||
permissions.revokeDrift,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/drift/:id/accept',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Accept a removal made in the game'
|
||
// #swagger.description = 'The site stops giving it on that server: deleted when it was that server’s alone, an exception when it reached further, a split when it was a shared group’s (D190).'
|
||
/* #swagger.responses[204] = { description: 'Accepted' } */
|
||
/* #swagger.responses[400] = { description: 'Only a removal can be accepted' } */
|
||
/* #swagger.responses[404] = { description: 'No such change' } */
|
||
requireRole('admin'),
|
||
driftId,
|
||
validate,
|
||
permissions.acceptDrift,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/drift/:id/restore',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Put back what the game removed or changed'
|
||
// #swagger.description = 'The next sync pushes the site’s version again.'
|
||
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
|
||
/* #swagger.responses[400] = { description: 'Only a removal or a changed group can be put back' } */
|
||
/* #swagger.responses[404] = { description: 'No such change' } */
|
||
requireRole('admin'),
|
||
driftId,
|
||
validate,
|
||
permissions.restoreDrift,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/drift/:id/dismiss',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Dismiss a notice'
|
||
// #swagger.description = 'A split notice (D190) or an event’s grant that was pushed back. Nothing in any game changes.'
|
||
/* #swagger.responses[204] = { description: 'Dismissed' } */
|
||
/* #swagger.responses[404] = { description: 'No such notice' } */
|
||
requireRole('admin'),
|
||
driftId,
|
||
validate,
|
||
permissions.dismissDrift,
|
||
)
|
||
|
||
permissionsRouter.post(
|
||
'/sync',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Read, reconcile and push now'
|
||
// #swagger.description = 'Runs the loop’s pass immediately for one server or all of them — read the store, settle what changed in the game by the server’s policy, push — and answers with each server’s state.'
|
||
/* #swagger.responses[200] = { description: 'The state of every server after the pass', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionSyncResult" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||
requireRole('admin'),
|
||
body('serverId').optional().isString().isLength({ min: 1, max: 64 }),
|
||
validate,
|
||
permissions.syncNow,
|
||
)
|
||
|
||
module.exports = permissionsRouter
|