Files
Module-Rust/server/router/admin/permissions.controller.js
wtclaude 1b70cef5be feat(rust): chat titles, BetterChat group styles, the voice and popups (phase 17)
PLAN.md §33, D134-D143. Protocol 12.

- Chat titles (D135-D137): per-server rules (stat, top N, text, colour)
  that rank the current wipe, and a mode (first | all | up to N). Worked
  out once in model/titles and read three ways: pushed whole to the game by
  a new titleSync loop (on change, restart or wipe), and on every
  leaderboard row as `titles`. Admin: PUT /servers/:id/titles.
- Group styles (D138, D139): a site group may carry all twelve BetterChat
  fields (rust_perm_group_chat). They ride perm.sync with `expect` from the
  pushed ledger, which gains a value column; a field changed in game is a
  `chat-field` drift row with the game's value, adopted into the style or
  put back. A withdrawn style is one `chat-group` retirement, never for
  `default`, cleared from the ledger only once BetterChat removed it.
- The voice (D140): one fleet setting naming a styled group; news and
  rust.announce chat lines carry its format and the plugin says them with
  no sender. Admin: GET/PUT /voice.
- Popups (D141, D142): rust.announce gains `delivery` (still version 1,
  from rust.options.delivery); each server gains news_delivery beside the
  news switch; `popup-unavailable` is not retried.
- GET /servers/:id/integrations reads, live, which optional mods a server
  has loaded. README lists BetterChat and PopupNotifications as optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 17:48:22 -05:00

509 lines
18 KiB
JavaScript

// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// The authoring surface for R2. Everything here writes to the site's own tables
// and marks the affected servers dirty; nothing here talks to a game. The push
// is `permSync.js`'s loop, which is deliberate — a form that wrote to six game
// hosts inside the request would fail differently for each of them and have no
// honest status code to answer with.
//
// **The one exception is "sync now"**, which runs the loop's pass for one server
// and waits for it. It exists because an operator who has just changed something
// wants to see it land, and because waiting thirty seconds to find out that a
// server is unreachable is a bad way to learn it.
//
// Every write logs an activity row. These rows decide who may do what inside
// somebody's game server, which is the one thing on this module's admin tier
// more consequential than the sidecar credential.
const core = require('../../core')
const chatStyle = require('../../model/permissions/chatStyle')
const db = require('../../model/permissions/permissions.db')
const model = require('../../model/permissions/permissions.model')
const permSync = require('../../permSync')
const servers = require('../../model/servers/servers.model')
const log = core.logger('admin:permissions')
/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */
async function overview(req, res) {
try {
// The twelve BetterChat fields travel with the model, so the form's editor
// is built from the same list the server validates against (D138).
res.json({ ...(await model.overview()), chatFields: chatStyle.FIELDS })
} catch (err) {
log.error('failed to read the permission model', { error: err.message })
res.status(500).json({ message: 'Failed to read the permission model' })
}
}
/**
* Create or update a group.
*
* The permission list is part of the same write, because that is how the form
* edits it: a group and what it carries are one idea on the screen, and two
* requests would leave a group briefly carrying the wrong set.
*/
async function putGroup(req, res) {
const name = model.normaliseName(req.params.name)
const scope = String(req.body.scope || model.FLEET)
try {
if (scope !== model.FLEET && !(await knownServer(scope))) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
// Phase 17: `chat` is the group's BetterChat style — all twelve fields, or
// null to take the style away. Absent leaves it as it is, so a client that
// predates styles cannot erase one by saving a group.
let style
if (req.body.chat !== undefined && req.body.chat !== null) {
const checked = chatStyle.validateStyle(req.body.chat)
if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
style = checked.fields
} else if (req.body.chat === null) {
style = null
}
const previous = await db.getGroup(name)
await db.upsertGroup({
name,
title: String(req.body.title || name),
rank: Number(req.body.rank) || 0,
scope,
})
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
await db.setGroupPermissions(name, permissions)
if (style !== undefined) await db.setGroupChat(name, style)
// Both scopes: a group that moved from one server to another has to be
// retired from where it was as well as applied where it now is, and only the
// old scope knows the first half.
await db.markDirty(scope)
if (previous && previous.scope !== scope) await db.markDirty(previous.scope)
await core.activity.log({
req,
action: previous ? 'rust.perm.group.update' : 'rust.perm.group.create',
detail: {
group: name,
scope,
permissions: permissions.length,
...(style !== undefined ? { chatStyle: style ? 'set' : 'removed' } : {}),
},
})
return res.status(204).end()
} catch (err) {
log.error('failed to save a group', { group: name, error: err.message })
return res.status(500).json({ message: 'Failed to save that group' })
}
}
async function deleteGroup(req, res) {
const name = model.normaliseName(req.params.name)
try {
const existing = await db.getGroup(name)
if (!existing) return res.status(404).json({ message: 'No such group' })
await db.deleteGroup(name)
await db.markDirty(existing.scope)
await core.activity.log({ req, action: 'rust.perm.group.delete', detail: { group: name } })
return res.status(204).end()
} catch (err) {
log.error('failed to delete a group', { group: name, error: err.message })
return res.status(500).json({ message: 'Failed to delete that group' })
}
}
async function addMember(req, res) {
const name = model.normaliseName(req.params.name)
try {
const group = await db.getGroup(name)
if (!group) return res.status(404).json({ message: 'No such group' })
const userId = await resolveUser(req.body)
if (!userId) return res.status(404).json({ message: 'No account on this site has that name' })
await db.addGroupMember(name, userId, req.user ? req.user.id : null)
await db.markDirty(group.scope)
await core.activity.log({
req,
action: 'rust.perm.member.add',
detail: { group: name, userId },
})
return res.status(204).end()
} catch (err) {
// A user id that names nobody fails on the foreign key rather than on a
// check of our own: the row is the constraint, and one round trip is
// cheaper than two.
log.error('failed to add a member', { group: name, userId, error: err.message })
return res.status(400).json({ message: 'That account could not be added to the group' })
}
}
async function removeMember(req, res) {
const name = model.normaliseName(req.params.name)
const userId = Number(req.params.userId)
try {
const group = await db.getGroup(name)
if (!group) return res.status(404).json({ message: 'No such group' })
const removed = await db.removeGroupMember(name, userId)
if (!removed) return res.status(404).json({ message: 'That account is not in the group' })
await db.markDirty(group.scope)
await core.activity.log({
req,
action: 'rust.perm.member.remove',
detail: { group: name, userId },
})
return res.status(204).end()
} catch (err) {
log.error('failed to remove a member', { group: name, userId, error: err.message })
return res.status(500).json({ message: 'Failed to remove that account from the group' })
}
}
/**
* Grant one permission to one person.
*
* `source` is fixed at `admin` here and is not accepted from the body: the
* column exists so phase 13's event actions can write their own rows through the
* same table, and a route that let a caller choose would make "who gave this"
* unanswerable the first time somebody passed the wrong string.
*/
async function addGrant(req, res) {
const permission = model.normaliseName(req.body.permission)
const scope = String(req.body.scope || model.FLEET)
let userId = null
try {
if (scope !== model.FLEET && !(await knownServer(scope))) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
userId = await resolveUser(req.body)
if (!userId) return res.status(404).json({ message: 'No account on this site has that name' })
const { inserted } = await db.insertGrant({
userId,
permission,
scope,
source: 'admin',
note: req.body.note ? String(req.body.note).slice(0, 255) : null,
grantedBy: req.user ? req.user.id : null,
})
if (inserted) {
await db.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', { userId, permission, error: err.message })
return res.status(400).json({ message: 'That permission could not be granted' })
}
}
async function removeGrant(req, res) {
const id = Number(req.params.id)
try {
const grant = await db.getGrant(id)
if (!grant) return res.status(404).json({ message: 'No such grant' })
await db.deleteGrant(id)
await db.markDirty(grant.scope)
await core.activity.log({
req,
action: 'rust.perm.revoke',
detail: { userId: grant.userId, permission: grant.permission, scope: grant.scope },
})
return res.status(204).end()
} catch (err) {
log.error('failed to revoke a grant', { grant: id, error: err.message })
return res.status(500).json({ message: 'Failed to remove that grant' })
}
}
/**
* Adopt a hand edit: the site records it as its own.
*
* It is only possible for a `grant` whose Steam id belongs to a website account,
* and the refusal says so — because the alternative is authoring privilege
* against a game account no person on this site holds, which is precisely the
* thing D28 decided not to do.
*/
async function adoptDrift(req, res) {
const id = Number(req.params.id)
try {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
if (row.kind === 'chat-field') return adoptStyleField(req, res, row)
if (row.kind !== 'grant' && row.kind !== 'member') {
return res.status(400).json({
message: 'Only a grant or a membership can be adopted. A permission on a group is edited on the group itself.',
})
}
const holder = await holderOf(row.subject)
if (!holder) {
return res.status(409).json({
message:
'That Steam account is not linked to any account on this site, so there is nobody to author this against. Revoke it instead, or ask the player to link.',
})
}
if (row.kind === 'grant') {
await db.insertGrant({
userId: holder.userId,
permission: row.object,
scope: row.serverId,
source: 'adopted',
note: 'Adopted from a hand edit',
grantedBy: req.user ? req.user.id : null,
})
} else {
const group = await db.getGroup(row.object)
if (!group) return res.status(409).json({ message: 'That group is not authored on this site' })
await db.addGroupMember(row.object, holder.userId, req.user ? req.user.id : null)
}
// Already in the game, so it is already pushed — recorded as such rather
// than left for the next sync to "apply". Without this the row would be
// desired-but-not-pushed, which is a state the loop would happily write
// again and the game would report as already correct: harmless, and a lie in
// the one table that exists to say what this site put there.
await db.addPushed(row.serverId, [{ kind: row.kind, subject: row.subject, object: row.object }])
await db.deleteDrift(id)
await db.markDirty(row.serverId)
await core.activity.log({
req,
action: 'rust.perm.drift.adopt',
detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object },
})
return res.status(204).end()
} catch (err) {
log.error('failed to adopt drift', { drift: id, error: err.message })
return res.status(500).json({ message: 'Failed to adopt that change' })
}
}
/**
* Revoke a hand edit.
*
* Queued rather than sent: the server may be down, and an instruction that is
* dropped because a game host was restarting is exactly the behaviour a site
* claiming to be the author of record must not have. The next successful sync
* carries it and the queue row goes.
*/
async function revokeDrift(req, res) {
const id = Number(req.params.id)
try {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
if (row.kind === 'chat-field') return revokeStyleField(req, res, row)
await db.queueRevocation({
serverId: row.serverId,
kind: row.kind,
subject: row.subject,
object: row.object,
requestedBy: req.user ? req.user.id : null,
})
await db.deleteDrift(id)
await db.markDirty(row.serverId)
await core.activity.log({
req,
action: 'rust.perm.drift.revoke',
detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object },
})
return res.status(202).json({ queued: true })
} catch (err) {
log.error('failed to queue a revocation', { drift: id, error: err.message })
return res.status(500).json({ message: 'Failed to queue that revocation' })
}
}
/**
* Adopt a hand edit to a style field: the game's value becomes the site's.
*
* The style belongs to the GROUP, and a group may reach every server — so the
* value adopted from one server is the value every server in its scope is
* pushed next. That is what adopting means for a fleet-wide group, and the
* activity row names the server it came from.
*/
async function adoptStyleField(req, res, row) {
const style = await db.getGroupChat(row.subject)
if (!style || style[row.object] === undefined) {
return res.status(409).json({ message: 'That group has no chat style on this site to adopt the change into' })
}
const field = chatStyle.FIELDS.find((f) => f.name === row.object)
const checked = field ? chatStyle.checkField(field, row.detail === null ? '' : row.detail) : { error: 'unknown field' }
if (checked.error) {
return res.status(409).json({
message: `The game's value cannot be adopted: ${checked.error}. Revoke it instead, or edit the style.`,
})
}
await db.setGroupChatField(row.subject, row.object, checked.value)
// Already in that game, so already pushed there — the same reasoning as a grant.
await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
await db.deleteDrift(row.id)
await db.markDirty(model.FLEET)
await core.activity.log({
req,
action: 'rust.perm.drift.adopt',
detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object, value: checked.value },
})
return res.status(204).end()
}
/**
* Revoke a hand edit to a style field: put the site's value back.
*
* Not a queued revocation — there is nothing to remove, only a value to
* overwrite. The ledger is told the game's value is this site's own, so the
* next sync expects to find it and writes over it. That is a person choosing to
* overwrite, which R2 allows (§33.2).
*/
async function revokeStyleField(req, res, row) {
await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
await db.deleteDrift(row.id)
await db.markDirty(row.serverId)
await core.activity.log({
req,
action: 'rust.perm.drift.revoke',
detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object },
})
return res.status(202).json({ queued: true })
}
/** Run the loop's pass now, for one server or for all of them, and report what happened. */
async function syncNow(req, res) {
const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null
try {
if (serverId && !(await knownServer(serverId))) {
return res.status(404).json({ message: 'No such server' })
}
await db.markDirty(serverId || model.FLEET)
await permSync.tick({ force: serverId })
await core.activity.log({
req,
action: 'rust.perm.sync',
detail: { server: serverId || 'all' },
})
const state = await model.overview()
return res.json({ servers: state.servers, drift: state.drift })
} catch (err) {
log.error('a forced sync failed', { server: serverId, error: err.message })
return res.status(500).json({ message: 'Failed to run the sync' })
}
}
/** Every permission name any configured server has registered, with which ones know it. */
async function catalogue(req, res) {
try {
const rows = await db.listCatalogue()
res.json({ permissions: groupCatalogue(rows) })
} catch (err) {
log.error('failed to read the catalogue', { error: err.message })
res.status(500).json({ message: 'Failed to read the permission catalogue' })
}
}
function groupCatalogue(rows) {
const byPermission = new Map()
for (const row of rows) {
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
byPermission.get(row.permission).push(row.serverId)
}
return [...byPermission.entries()]
.map(([permission, serverIds]) => ({ permission, servers: serverIds }))
.sort((a, b) => a.permission.localeCompare(b.permission))
}
/**
* The user id a write is about, from either an id or a username.
*
* The form sends a name, because a form that made an operator type a numeric id
* would be a form nobody could use. The id form stays accepted because the
* client already holds one on the panel inside core's user page, and looking a
* name back up from it would be a round trip to answer a question it has
* already answered.
*/
async function resolveUser(body) {
if (body.userId) return Number(body.userId)
if (!body.username) return null
const user = await db.findUserByUsername(String(body.username).trim())
return user ? user.id : null
}
/** Whether a scope names a server row. A disabled server still counts — it exists. */
async function knownServer(id) {
const rows = await servers.listForAdmin()
return rows.some((row) => row.id === id)
}
/** The website account that holds a Steam id, or null. */
async function holderOf(steamId) {
const links = await db.listLinks()
return links.find((link) => link.steamId === steamId) || null
}
module.exports = {
overview,
putGroup,
deleteGroup,
addMember,
removeMember,
addGrant,
removeGrant,
adoptDrift,
revokeDrift,
syncNow,
catalogue,
}