Files
Module-Rust/server/router/admin/permissions.controller.js
wtclaude e0d13e73db
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 7m58s
feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
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
2026-09-28 06:58:59 -05:00

784 lines
33 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.

// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// The permission manager (PLAN_REDESIGNS §1). The site owns every permission and
// group on every server (D160), and this is where a person changes them. Every
// write here goes 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 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, so an operator who just changed something can see it land.
//
// The screen works one server at a time, as uMod PermissionsManager does (D162).
// A write that could reach further says so and asks: a toggle for a fleet-wide
// grant changes it everywhere or on this server alone (an exception), and a
// change to a shared group changes it everywhere or splits this server's copy
// off (D190's own two answers, offered to a person).
//
// Every write logs an activity row. These rows decide who may do what inside
// somebody's game server.
const core = require('../../core')
const apply = require('../../model/permissions/permissions.apply')
const chatStyle = require('../../model/permissions/chatStyle')
const db = require('../../model/permissions/permissions.db')
const model = require('../../model/permissions/permissions.model')
const reconcile = require('../../model/permissions/reconcile')
const view = require('../../model/permissions/permissions.view')
const permSync = require('../../permSync')
const servers = require('../../model/servers/servers.model')
const log = core.logger('admin:permissions')
const by = (req) => (req.user ? req.user.id : null)
/** Whether a server id 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
}
/** The user id a write names, from an id or a username. */
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
}
/** The servers a group is on, for marking them dirty. */
async function serversOfGroup(group) {
const [serverRows, groupServers] = await Promise.all([servers.listForAdmin(), db.listGroupServers()])
return model.groupReach(group, model.serversByGroup(groupServers), serverRows.map((row) => row.id))
}
function fail(res, err, what) {
log.error(`failed to ${what}`, { error: err.message })
return res.status(500).json({ message: `Failed to ${what}` })
}
// ── Reads ────────────────────────────────────────────────────────────────
/** The servers, each one's policy and sync state, and everything waiting for a person. */
async function overview(req, res) {
try {
res.json({ ...(await view.overview()), chatFields: chatStyle.FIELDS, policies: permSync.POLICIES })
} catch (err) {
return fail(res, err, 'read the permission overview')
}
}
/** One server: plugins by owner, groups, players, and the facts behind every toggle's state. */
async function server(req, res) {
try {
const found = await view.serverView(String(req.params.serverId))
if (!found) return res.status(404).json({ message: 'No such server' })
res.json({ ...found, chatFields: chatStyle.FIELDS })
} catch (err) {
return fail(res, err, 'read that server’s permissions')
}
}
/** Players seen on a server, by name, Steam id or linked account — to grant to somebody who holds nothing yet. */
async function players(req, res) {
try {
const serverId = String(req.params.serverId)
if (!(await knownServer(serverId))) return res.status(404).json({ message: 'No such server' })
const rows = await db.searchPlayers(serverId, String(req.query.q || ''), 25)
res.json({
players: rows.map((row) => ({
steamId: row.steamId,
name: row.playerName || null,
account: row.userId ? { userId: row.userId, username: row.username } : null,
})),
})
} catch (err) {
return fail(res, err, 'search the players')
}
}
/** Every permission name any server has registered, with which servers know it and who registered it. */
async function catalogue(req, res) {
try {
const rows = await db.listCatalogue()
const byPermission = new Map()
for (const row of rows) {
if (!byPermission.has(row.permission)) byPermission.set(row.permission, { permission: row.permission, servers: [], owner: null })
const entry = byPermission.get(row.permission)
entry.servers.push(row.serverId)
if (row.owner && !entry.owner) entry.owner = row.owner
}
res.json({ permissions: [...byPermission.values()].sort((a, b) => a.permission.localeCompare(b.permission)) })
} catch (err) {
return fail(res, err, 'read the permission catalogue')
}
}
// ── The server's policy (D161) ─────────────────────────────────────────────
async function setPolicy(req, res) {
const serverId = String(req.params.serverId)
const policy = String(req.body.policy || '')
try {
if (!permSync.POLICIES.includes(policy)) {
return res.status(400).json({ message: `The policy is one of ${permSync.POLICIES.join(', ')}` })
}
if (!(await db.setPolicy(serverId, policy))) return res.status(404).json({ message: 'No such server' })
await db.markDirty(serverId)
await core.activity.log({ req, action: 'rust.perm.policy', detail: { server: serverId, policy } })
return res.status(204).end()
} catch (err) {
return fail(res, err, 'set the policy')
}
}
// ── Grants: the toggles, and Grant all / Revoke all ───────────────────────
/**
* Grant permissions to one subject on one server, or everywhere.
*
* The subject is a Steam id or a website account. A Steam id that is linked is
* granted as its ACCOUNT, reaching every Steam id that person links (D28, D188);
* an unlinked one is granted as itself. A grant kept off this server by an
* exception has its exception removed rather than a second grant written.
*/
async function grant(req, res) {
const serverId = String(req.params.serverId)
const everywhere = req.body.everywhere === true
const scope = everywhere ? model.FLEET : serverId
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
try {
if (!(await knownServer(serverId))) return res.status(404).json({ message: 'No such server' })
let userId = await resolveUser(req.body)
const steamId = req.body.steamId ? String(req.body.steamId) : null
if (!userId && steamId) {
const link = await holderOf(steamId)
if (link) userId = link.userId
}
if (!userId && !steamId) return res.status(400).json({ message: 'Name a Steam id or a website account' })
const holder = userId ? 'user' : 'steam'
const exceptions = (await db.listExceptions()).filter((e) => e.serverId === serverId && e.holder === holder)
// One entry per grant: the user list repeats a grant once per linked account.
const existing = new Map(
(userId ? await db.listGrants({ userId }) : await db.listSteamGrants({ steamId })).map((g) => [g.id, g]),
)
let granted = 0
let restored = 0
for (const permission of permissions) {
// A grant of this permission that reaches this server but is kept off it
// by an exception: the exception is the thing to undo.
const exception = exceptions.find((e) => {
const g = existing.get(Number(e.grantId)) || existing.get(e.grantId)
return g && model.normaliseName(g.permission) === permission && model.inScope(g.scope, serverId)
})
if (exception) {
// eslint-disable-next-line no-await-in-loop
await db.deleteException(exception.id)
restored++
continue
}
// eslint-disable-next-line no-await-in-loop
const result = userId
? await db.insertGrant({ userId, permission, scope, source: 'admin', note: null, grantedBy: by(req) })
: await db.insertSteamGrant({ steamId, permission, scope, source: 'admin', grantedBy: by(req) })
if (result.inserted) granted++
}
await db.markDirty(scope)
await core.activity.log({
req,
action: 'rust.perm.grant',
detail: { server: serverId, scope, userId, steamId: userId ? null : steamId, permissions },
})
return res.json({ granted, restored })
} catch (err) {
return fail(res, err, 'grant those permissions')
}
}
/**
* Take permissions away from one subject on one server, or everywhere.
*
* Every direct grant that puts the permission on this server stops doing so: one
* scoped to this server alone is deleted; one that reaches further is deleted
* with `everywhere`, and otherwise gains an exception for this server (D190).
* What the subject holds THROUGH A GROUP, or from an event, is not a direct grant
* and is reported back rather than touched.
*/
async function revoke(req, res) {
const serverId = String(req.params.serverId)
const everywhere = req.body.everywhere === true
const permissions = new Set([...(req.body.permissions || [])].map(model.normaliseName).filter(Boolean))
try {
if (!(await knownServer(serverId))) return res.status(404).json({ message: 'No such server' })
const userId = await resolveUser(req.body)
let steamIds = req.body.steamId ? [String(req.body.steamId)] : []
if (userId) steamIds = (await db.listLinks()).filter((l) => l.userId === userId).map((l) => l.steamId)
if (!steamIds.length && !userId) return res.status(400).json({ message: 'Name a Steam id or a website account' })
const desired = model.buildDesired(serverId, await model.readAuthored())
const done = new Set()
const untouched = []
let revoked = 0
for (const steamId of steamIds) {
for (const permission of permissions) {
const sources = desired.sources.get(model.rowKey({ kind: 'grant', subject: steamId, object: permission })) || []
for (const source of sources) {
if (source.type === 'runGrant') {
untouched.push({ permission, why: 'an event gave it; its revert takes it back' })
continue
}
const holder = source.type === 'userGrant' ? 'user' : 'steam'
const key = `${holder}:${source.id}`
if (done.has(key)) continue
done.add(key)
/* eslint-disable no-await-in-loop */
if (source.scope === serverId || everywhere) {
if (holder === 'user') await db.deleteGrant(source.id)
else await db.deleteSteamGrant(source.id)
} else {
await db.addException({ holder, grantId: source.id, serverId, createdBy: by(req) })
}
/* eslint-enable no-await-in-loop */
revoked++
}
}
}
await db.markDirty(everywhere ? model.FLEET : serverId)
await core.activity.log({
req,
action: 'rust.perm.revoke',
detail: { server: serverId, everywhere, userId, steamIds, permissions: [...permissions] },
})
return res.json({ revoked, untouched })
} catch (err) {
return fail(res, err, 'revoke those permissions')
}
}
/** Remove an exception: the grant reaches that server again. */
async function removeException(req, res) {
try {
const exceptions = await db.listExceptions()
const found = exceptions.find((e) => Number(e.id) === Number(req.params.id))
if (!found) return res.status(404).json({ message: 'No such exception' })
await db.deleteException(found.id)
await db.markDirty(found.serverId)
await core.activity.log({ req, action: 'rust.perm.exception.remove', detail: { server: found.serverId, holder: found.holder, grantId: found.grantId } })
return res.status(204).end()
} catch (err) {
return fail(res, err, 'remove that exception')
}
}
// ── Groups (D189) ─────────────────────────────────────────────────────────
/** Title, rank, parent and style from a body, validated. `null` fields are left out. */
function groupFields(body) {
const out = {}
if (body.title !== undefined) out.title = String(body.title)
if (body.rank !== undefined) out.rank = Number(body.rank) || 0
if (body.parent !== undefined) out.parent = model.normaliseName(body.parent)
return out
}
/** A new group on one server. The model refuses a second group of a name on a server. */
async function createGroup(req, res) {
const serverId = String(req.params.serverId)
const name = model.normaliseName(req.body.name)
try {
if (!(await knownServer(serverId))) return res.status(404).json({ message: 'No such server' })
if (await apply.groupOn(name, serverId)) {
return res.status(409).json({ message: `This server already has a group called "${name}"` })
}
const fields = groupFields(req.body)
const id = await db.insertGroup({ name, title: fields.title ?? name, rank: fields.rank ?? 0, parent: fields.parent ?? '' })
await db.setGroupServers(id, { allServers: false, servers: [serverId] })
await db.markDirty(serverId)
await core.activity.log({ req, action: 'rust.perm.group.create', detail: { server: serverId, group: name, id } })
return res.status(201).json({ id })
} catch (err) {
return fail(res, err, 'create that group')
}
}
/**
* Change a group's title, rank, parent or style. With `serverId` and a shared
* group, `onlyHere` splits that server's copy off first (D190) and changes the
* copy; otherwise the change is the group's, on every server it is on.
*/
async function updateGroup(req, res) {
try {
let group = await db.getGroup(Number(req.params.id))
if (!group) return res.status(404).json({ message: 'No such 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 dirty = await serversOfGroup(group)
if (req.body.onlyHere && req.body.serverId) {
group = await apply.ownGroup(group.name, String(req.body.serverId))
if (!group) return res.status(409).json({ message: 'That group is not on that server' })
}
const fields = groupFields(req.body)
if (Object.keys(fields).length) {
await db.updateGroup(group.id, {
title: fields.title ?? group.title,
rank: fields.rank ?? group.rank,
parent: fields.parent ?? group.parent,
})
}
if (style !== undefined) await db.setGroupChat(group.id, style)
await db.markDirty(dirty)
await core.activity.log({ req, action: 'rust.perm.group.update', detail: { id: group.id, group: group.name, ...fields, chat: style === undefined ? undefined : Boolean(style) } })
return res.json({ id: group.id })
} catch (err) {
return fail(res, err, 'change that group')
}
}
/** Delete a group everywhere it is. A built-in group is never deleted from the game. */
async function deleteGroup(req, res) {
try {
const group = await db.getGroup(Number(req.params.id))
if (!group) return res.status(404).json({ message: 'No such group' })
const dirty = await serversOfGroup(group)
await db.deleteGroup(group.id)
await db.markDirty(dirty)
await core.activity.log({ req, action: 'rust.perm.group.delete', detail: { id: group.id, group: group.name } })
return res.status(204).end()
} catch (err) {
return fail(res, err, 'delete that group')
}
}
/** Replace what a group carries (the toggles, Grant all, Revoke all) — here only, or everywhere it is. */
async function setGroupPermissions(req, res) {
try {
let group = await db.getGroup(Number(req.params.id))
if (!group) return res.status(404).json({ message: 'No such group' })
const dirty = await serversOfGroup(group)
if (req.body.onlyHere && req.body.serverId) {
group = await apply.ownGroup(group.name, String(req.body.serverId))
if (!group) return res.status(409).json({ message: 'That group is not on that server' })
}
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
await db.setGroupPermissions(group.id, permissions)
await db.markDirty(dirty)
await core.activity.log({ req, action: 'rust.perm.group.permissions', detail: { id: group.id, group: group.name, count: permissions.length } })
return res.json({ id: group.id })
} catch (err) {
return fail(res, err, 'change what that group carries')
}
}
/**
* Share a group, or stop sharing it (D189). `allServers`, or a list of servers.
*
* A server that already has its own group of this name is a conflict: the answer
* is 409 naming them, and the request is repeated with `replace` listing the ids
* the admin chose to replace.
*/
async function setGroupServers(req, res) {
try {
const group = await db.getGroup(Number(req.params.id))
if (!group) return res.status(404).json({ message: 'No such group' })
const serverRows = await servers.listForAdmin()
const known = new Set(serverRows.map((row) => row.id))
const allServers = req.body.allServers === true
const list = [...new Set((req.body.servers || []).map(String))].filter((id) => known.has(id))
const targets = allServers ? [...known] : list
const [groups, groupServers, groupPermissions] = await Promise.all([
db.listGroups(),
db.listGroupServers(),
db.listGroupPermissions(),
])
const byGroup = model.serversByGroup(groupServers)
const conflicts = groups.filter((other) =>
other.id !== group.id && other.name === group.name &&
targets.some((serverId) => model.groupCovers(other, byGroup, serverId)))
const replace = new Set((req.body.replace || []).map(Number))
const unanswered = conflicts.filter((other) => !replace.has(other.id))
if (unanswered.length) {
// What each conflicting group carries, so the screen can show the difference.
const carried = (id) => groupPermissions.filter((row) => row.groupId === id).map((row) => row.permission).sort()
return res.status(409).json({
message: 'Some of those servers already have their own group of this name. Choose which to replace.',
group: { id: group.id, permissions: carried(group.id) },
conflicts: unanswered.map((other) => ({
id: other.id,
title: other.title,
servers: model.groupReach(other, byGroup, [...known]),
permissions: carried(other.id),
})),
})
}
const before = model.groupReach(group, byGroup, [...known])
for (const other of conflicts) {
// eslint-disable-next-line no-await-in-loop
await db.deleteGroup(other.id)
}
await db.setGroupServers(group.id, { allServers, servers: allServers ? [] : list })
await db.markDirty([...new Set([...before, ...targets])])
await core.activity.log({
req,
action: 'rust.perm.group.servers',
detail: { id: group.id, group: group.name, allServers, servers: list, replaced: conflicts.map((c) => c.id) },
})
return res.json({ id: group.id })
} catch (err) {
return fail(res, err, 'change where that group is')
}
}
/** Give one server its own copy of a shared group (D190, asked for by a person). */
async function splitGroup(req, res) {
try {
const group = await db.getGroup(Number(req.params.id))
if (!group) return res.status(404).json({ message: 'No such group' })
const serverId = String(req.body.serverId || '')
if (!(await knownServer(serverId))) return res.status(400).json({ message: 'Name the server to split off' })
const own = await apply.ownGroup(group.name, serverId)
if (!own) return res.status(409).json({ message: 'That group is not on that server' })
await db.markDirty(serverId)
await core.activity.log({ req, action: 'rust.perm.group.split', detail: { id: group.id, group: group.name, server: serverId, copy: own.id } })
return res.json({ id: own.id })
} catch (err) {
return fail(res, err, 'split that group')
}
}
/** Put a subject in a group: a Steam id (as itself, D188) or a website account (D28). */
async function addMember(req, res) {
try {
let group = await db.getGroup(Number(req.params.id))
if (!group) return res.status(404).json({ message: 'No such group' })
const dirty = await serversOfGroup(group)
if (req.body.onlyHere && req.body.serverId) group = await apply.ownGroup(group.name, String(req.body.serverId))
const userId = await resolveUser(req.body)
const steamId = req.body.steamId ? String(req.body.steamId) : null
if (!userId && !steamId) return res.status(400).json({ message: 'Name a Steam id or a website account' })
if (userId) await db.addGroupMember(group.id, userId, by(req))
else await db.addGroupSteamMember(group.id, steamId, { addedBy: by(req) })
await db.markDirty(dirty)
await core.activity.log({ req, action: 'rust.perm.member.add', detail: { id: group.id, group: group.name, userId, steamId } })
return res.status(204).end()
} catch (err) {
return fail(res, err, 'add that member')
}
}
/** Take a subject out of a group — both ways it can be in it, as a Steam id and as that id's account. */
async function removeMember(req, res) {
try {
let group = await db.getGroup(Number(req.params.id))
if (!group) return res.status(404).json({ message: 'No such group' })
const dirty = await serversOfGroup(group)
if (req.body.onlyHere && req.body.serverId) group = await apply.ownGroup(group.name, String(req.body.serverId))
let userId = await resolveUser(req.body)
const steamId = req.body.steamId ? String(req.body.steamId) : null
if (steamId) {
await db.removeGroupSteamMember(group.id, steamId)
const link = await holderOf(steamId)
if (link && !userId) userId = link.userId
}
if (userId) await db.removeGroupMember(group.id, userId)
await db.markDirty(dirty)
await core.activity.log({ req, action: 'rust.perm.member.remove', detail: { id: group.id, group: group.name, userId, steamId } })
return res.status(204).end()
} catch (err) {
return fail(res, err, 'remove that member')
}
}
/** Remove all: every member of a group, both kinds. */
async function clearMembers(req, res) {
try {
let group = await db.getGroup(Number(req.params.id))
if (!group) return res.status(404).json({ message: 'No such group' })
const dirty = await serversOfGroup(group)
if (req.body.onlyHere && req.body.serverId) group = await apply.ownGroup(group.name, String(req.body.serverId))
const [members, steamMembers] = await Promise.all([db.listGroupMembers(), db.listGroupSteamMembers()])
for (const userId of new Set(members.filter((m) => m.groupId === group.id).map((m) => m.userId))) {
// eslint-disable-next-line no-await-in-loop
await db.removeGroupMember(group.id, userId)
}
for (const m of steamMembers.filter((row) => row.groupId === group.id)) {
// eslint-disable-next-line no-await-in-loop
await db.removeGroupSteamMember(group.id, m.steamId)
}
await db.markDirty(dirty)
await core.activity.log({ req, action: 'rust.perm.member.clear', detail: { id: group.id, group: group.name } })
return res.status(204).end()
} catch (err) {
return fail(res, err, 'empty that group')
}
}
// ── What waits for a person (D161's `adopt`, and what no policy settles) ──
/** The reconcile's op for one drift row, as auto-adopt would have run it. */
function opFor(row, desiredSources) {
if (row.kind === 'group') {
if (row.direction === 'removed') return { op: 'dropGroup', group: row.subject }
const attrs = reconcile.parseGroupValue(row.detail) || { title: row.subject, rank: 0, parent: '' }
return { op: 'adoptGroup', name: row.subject, ...attrs, source: 'adopted' }
}
if (row.kind === 'group-permission') return row.direction === 'removed'
? { op: 'dropGroupPermission', group: row.subject, permission: row.object }
: { op: 'adoptGroupPermission', group: row.subject, permission: row.object, source: 'adopted' }
if (row.kind === 'member') return row.direction === 'removed'
? { op: 'dropMember', group: row.object, steamId: row.subject }
: { op: 'adoptMember', group: row.object, steamId: row.subject, source: 'adopted' }
return row.direction === 'removed'
? { op: 'dropGrant', steamId: row.subject, permission: row.object, sources: desiredSources.get(model.rowKey(row)) || [] }
: { op: 'adoptGrant', steamId: row.subject, permission: row.object, source: 'adopted' }
}
/**
* Adopt: an addition made in the game becomes the site's, for that server (the
* same write auto-adopt makes). For a `chat-field` row, the game's value becomes
* the group's style.
*/
async function adoptDrift(req, res) {
try {
const row = await db.getDrift(Number(req.params.id))
if (!row) return res.status(404).json({ message: 'No such change' })
if (row.kind === 'chat-field') return adoptStyleField(req, res, row)
if (row.direction !== 'added' && row.direction !== 'changed') {
return res.status(400).json({ message: 'That change was a removal: accept it, or put it back' })
}
if (row.direction === 'changed') {
// The game's title, rank and parent, carried on the row, become the group's here.
const attrs = reconcile.parseGroupValue(row.detail)
if (!attrs) return res.status(409).json({ message: 'That change no longer says what the game holds' })
await apply.applyOp(row.serverId, { op: 'setGroupAttrs', group: row.subject, ...attrs })
} else {
await apply.applyOp(row.serverId, opFor(row, new Map()))
}
await db.deleteDrift(row.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) {
return fail(res, err, 'adopt that change')
}
}
/** Revoke: an addition made in the game is removed from it at the next sync. */
async function revokeDrift(req, res) {
try {
const row = await db.getDrift(Number(req.params.id))
if (!row) return res.status(404).json({ message: 'No such change' })
if (row.kind === 'chat-field') return revokeStyleField(req, res, row)
if (row.direction !== 'added') return res.status(400).json({ message: 'Only an addition can be revoked' })
await db.queueRevocation({ serverId: row.serverId, kind: row.kind, subject: row.subject, object: row.object, requestedBy: by(req) })
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, subject: row.subject, object: row.object } })
return res.status(202).json({ queued: true })
} catch (err) {
return fail(res, err, 'revoke that change')
}
}
/** Accept a removal made in the game: the site stops giving it on that server (D190). */
async function acceptDrift(req, res) {
try {
const row = await db.getDrift(Number(req.params.id))
if (!row) return res.status(404).json({ message: 'No such change' })
if (row.direction !== 'removed') return res.status(400).json({ message: 'Only a removal can be accepted' })
const desired = model.buildDesired(row.serverId, await model.readAuthored())
await apply.applyOp(row.serverId, opFor(row, desired.sources))
await db.deleteDrift(row.id)
await db.markDirty(row.serverId)
await core.activity.log({ req, action: 'rust.perm.drift.accept', detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object } })
return res.status(204).end()
} catch (err) {
return fail(res, err, 'accept that removal')
}
}
/**
* Put it back: a removal (or a changed group) made in the game is undone at the
* next sync. Forgetting the ledger's row is what does it — a row the site wants
* and has no record of pushing is pushed.
*/
async function restoreDrift(req, res) {
try {
const row = await db.getDrift(Number(req.params.id))
if (!row) return res.status(404).json({ message: 'No such change' })
if (row.direction !== 'removed' && row.direction !== 'changed') {
return res.status(400).json({ message: 'Only a removal or a changed group can be put back' })
}
if (row.direction === 'changed') await db.setPushedValue(row.serverId, { kind: 'group', subject: row.subject, object: '', value: null })
else await db.removePushed(row.serverId, [{ kind: row.kind, subject: row.subject, object: row.object }])
await db.deleteDrift(row.id)
await db.markDirty(row.serverId)
await core.activity.log({ req, action: 'rust.perm.drift.restore', detail: { server: row.serverId, kind: row.kind, subject: row.subject, object: row.object } })
return res.status(202).json({ queued: true })
} catch (err) {
return fail(res, err, 'put that back')
}
}
/** Dismiss a notice — a split (D190) or an event's grant pushed back. */
async function dismissDrift(req, res) {
try {
const row = await db.getDrift(Number(req.params.id))
if (!row) return res.status(404).json({ message: 'No such notice' })
await db.deleteDrift(row.id)
await core.activity.log({ req, action: 'rust.perm.drift.dismiss', detail: { server: row.serverId, kind: row.kind, subject: row.subject, direction: row.direction } })
return res.status(204).end()
} catch (err) {
return fail(res, err, 'dismiss that notice')
}
}
/** A `chat-field` row: the game's value becomes the style of that server's group. */
async function adoptStyleField(req, res, row) {
const group = await apply.groupOn(row.subject, row.serverId)
const style = group ? await db.getGroupChat(group.id) : null
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(group.id, row.object, checked.value)
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(await serversOfGroup(group))
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()
}
/** A `chat-field` row: put the site's value back over the hand edit (§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 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 view.overview()
return res.json({ servers: state.servers, drift: state.drift })
} catch (err) {
return fail(res, err, 'run the sync')
}
}
module.exports = {
overview,
server,
players,
catalogue,
setPolicy,
grant,
revoke,
removeException,
createGroup,
updateGroup,
deleteGroup,
setGroupPermissions,
setGroupServers,
splitGroup,
addMember,
removeMember,
clearMembers,
adoptDrift,
revokeDrift,
acceptDrift,
restoreDrift,
dismissDrift,
syncNow,
}