From e0d13e73db6bc619be58856e9806eb633faaf487 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 28 Sep 2026 06:58:59 -0500 Subject: [PATCH] =?UTF-8?q?feat(rust):=20the=20permission=20manager=20?= =?UTF-8?q?=E2=80=94=20the=20site=20owns=20the=20whole=20store=20(D160-D16?= =?UTF-8?q?3,=20D188-D198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/api.js | 61 +- client/src/routes/admin/ChatTitles.jsx | 8 +- client/src/routes/admin/Permissions.jsx | 1245 +++++----- routes.manifest.json | 80 +- server/boot.js | 11 + server/db/purge.sql | 11 + server/db/schema.sql | 152 ++ server/model/permissions/permissions.apply.js | 210 ++ server/model/permissions/permissions.db.js | 700 ++++-- server/model/permissions/permissions.model.js | 672 ++--- server/model/permissions/permissions.view.js | 198 ++ server/model/permissions/reconcile.js | 277 +++ server/model/permissions/voice.js | 88 +- server/permSync.js | 335 +-- server/router/admin/permissions.controller.js | 1215 ++++++---- server/router/admin/permissions.router.js | 359 ++- server/router/admin/usersRust.controller.js | 27 +- server/sidecarClient.js | 66 + server/swagger/doc.js | 220 +- server/test/optionalMods.test.js | 14 +- server/test/permissions.test.js | 36 +- server/test/playerPermissions.test.js | 15 +- server/test/reconcile.test.js | 310 +++ swagger-fragment.json | 2152 ++++++++++++----- 24 files changed, 5873 insertions(+), 2589 deletions(-) create mode 100644 server/model/permissions/permissions.apply.js create mode 100644 server/model/permissions/permissions.view.js create mode 100644 server/model/permissions/reconcile.js create mode 100644 server/test/reconcile.test.js diff --git a/client/src/api.js b/client/src/api.js index 252d1fe..a918173 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -150,37 +150,48 @@ export const admin = { // A write is followed by a re-read rather than a local edit of the model: what // the screen is showing is partly the game's answer, and the honest way to learn // the new one is to ask. +const P = '/admin/rust/permissions' +const enc = encodeURIComponent + export const adminPermissions = { - overview: () => req('/admin/rust/permissions'), - catalogue: () => req('/admin/rust/permissions/catalogue'), + overview: () => req(P), + catalogue: () => req(`${P}/catalogue`), - saveGroup: (name, body) => - req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }), - deleteGroup: (name) => - req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }), + // One server, as PermissionsManager shows one (D162). + server: (serverId) => req(`${P}/servers/${enc(serverId)}`), + players: (serverId, q) => req(`${P}/servers/${enc(serverId)}/players?q=${enc(q || '')}`), + setPolicy: (serverId, policy) => req(`${P}/servers/${enc(serverId)}/policy`, { method: 'PUT', body: { policy } }), - addMember: (name, username) => - req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, { - method: 'POST', - body: { username }, - }), - removeMember: (name, userId) => - req( - `/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`, - { method: 'DELETE' }, - ), + // A subject is `{ steamId }` or `{ userId }`; `everywhere` reaches every server. + grant: (serverId, subject, permissions, everywhere = false) => + req(`${P}/servers/${enc(serverId)}/grant`, { method: 'POST', body: { ...subject, permissions, everywhere } }), + revoke: (serverId, subject, permissions, everywhere = false) => + req(`${P}/servers/${enc(serverId)}/revoke`, { method: 'POST', body: { ...subject, permissions, everywhere } }), + removeException: (id) => req(`${P}/exceptions/${enc(id)}`, { method: 'DELETE' }), - grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }), - revoke: (id) => - req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }), + // Groups by id (D189). `here` is `{ onlyHere: true, serverId }` to split a + // shared group's copy off first (D190), or null to change it everywhere. + createGroup: (serverId, body) => req(`${P}/servers/${enc(serverId)}/groups`, { method: 'POST', body }), + updateGroup: (id, body, here = null) => req(`${P}/groups/${enc(id)}`, { method: 'PATCH', body: { ...body, ...(here || {}) } }), + deleteGroup: (id) => req(`${P}/groups/${enc(id)}`, { method: 'DELETE' }), + setGroupPermissions: (id, permissions, here = null) => + req(`${P}/groups/${enc(id)}/permissions`, { method: 'PUT', body: { permissions, ...(here || {}) } }), + setGroupServers: (id, body) => req(`${P}/groups/${enc(id)}/servers`, { method: 'PUT', body }), + splitGroup: (id, serverId) => req(`${P}/groups/${enc(id)}/split`, { method: 'POST', body: { serverId } }), + addMember: (id, subject, here = null) => + req(`${P}/groups/${enc(id)}/members`, { method: 'POST', body: { ...subject, ...(here || {}) } }), + removeMember: (id, subject, here = null) => + req(`${P}/groups/${enc(id)}/members/remove`, { method: 'POST', body: { ...subject, ...(here || {}) } }), + clearMembers: (id, here = null) => req(`${P}/groups/${enc(id)}/members/clear`, { method: 'POST', body: { ...(here || {}) } }), - adoptDrift: (id) => - req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }), - revokeDrift: (id) => - req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }), + // What waits for a person (D161). + adoptDrift: (id) => req(`${P}/drift/${enc(id)}/adopt`, { method: 'POST' }), + revokeDrift: (id) => req(`${P}/drift/${enc(id)}/revoke`, { method: 'POST' }), + acceptDrift: (id) => req(`${P}/drift/${enc(id)}/accept`, { method: 'POST' }), + restoreDrift: (id) => req(`${P}/drift/${enc(id)}/restore`, { method: 'POST' }), + dismissDrift: (id) => req(`${P}/drift/${enc(id)}/dismiss`, { method: 'POST' }), - sync: (serverId = null) => - req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }), + sync: (serverId = null) => req(`${P}/sync`, { method: 'POST', body: serverId ? { serverId } : {} }), } // ── admin · visibility ──────────────────────────────────────────────────── diff --git a/client/src/routes/admin/ChatTitles.jsx b/client/src/routes/admin/ChatTitles.jsx index ba71cda..3c1deb8 100644 --- a/client/src/routes/admin/ChatTitles.jsx +++ b/client/src/routes/admin/ChatTitles.jsx @@ -196,13 +196,15 @@ export function VoiceCard() { Say them as {data.voice && !current && (

- The group “{data.voice}” no longer has a chat style, so lines are said in plain chat until it has one again or - another voice is chosen. + The chosen group no longer has a chat style, so lines are said in plain chat until it has one again or another + voice is chosen.

)} {current &&

The line: {current.format}

} diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx index ea78bbb..5fc73b5 100644 --- a/client/src/routes/admin/Permissions.jsx +++ b/client/src/routes/admin/Permissions.jsx @@ -1,49 +1,50 @@ // ── Admin · Rust · Permissions ──────────────────────────────────────────── // -// R2's authoring surface, and this module's first admin page. +// The permission manager (PLAN_REDESIGNS §1). The site owns every permission and +// group on every server (D160), and this screen follows uMod PermissionsManager's +// flow (D162): a server, then players ⇄ groups, then a subject, then one plugin's +// permissions with Granted / Revoked, Grant all and Revoke all. // -// **What is on it is decided by what an operator can get wrong**, rather than by -// what the tables contain. Four states are invisible from the game and from a -// list of grants, and every one of them looks exactly like success: +// Three rules shape it: // -// • a grant against somebody who has linked no Steam account — authored, -// stored, pushed nowhere; -// • a permission no loaded plugin has registered — the grant lands silently -// nowhere, because `GrantUserPermission` no-ops for an unregistered name; -// • a group member who has never connected — the store has no user record to -// put in a group yet, and the membership waits for their first connection; -// • a server whose last sync failed — the site is authoritative and the game -// has not heard it. +// • **Every toggle says what is true on THIS server** (U-1): granted, +// waiting for a sync, through a group, waiting for a first connection, not +// registered here, or did not land. A state in a server-level sentence is a +// state nobody reads. +// • **Plugins are who REGISTERED a permission, never its prefix.** +// `zonemanager.ignoreflag.nokits` is ZoneManager's. +// • **Anything that reaches further than this server says so and asks.** A +// fleet-wide grant is revoked everywhere or only here; a shared group is +// changed everywhere or split for this server (D190's two answers, offered +// to a person). // -// So each of those is a sentence on this page rather than a number in a report. -// -// The screen never writes to a game. Every button here writes to the site and -// the mirror's loop reconciles within seconds — except *Sync now*, which runs -// that pass immediately because an operator who has just changed something -// should not have to trust a timer to find out that a host is unreachable. +// Every subject is named by linked account and in-game name, or by Steam id +// when there is neither (D163). The screen never writes to a game: every button +// writes to the site, and the sync loop settles it within seconds — except +// *Sync now*. -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ErrorState, Loading, useAsync } from '../../core.js' import { ago } from '../../lib/format.js' import api from '../../api.js' +import Tabs from '../../components/Tabs.jsx' import ChatStyleSection from './ChatStyle.jsx' -const FLEET = '*' +const POLICY_TEXT = { + 'auto-adopt': 'A change made in the game becomes the site’s own, for that server.', + adopt: 'A change made in the game waits here for a person to adopt or undo it.', + revoke: 'A change made in the game is undone at the next sync.', +} + +// ── Furniture ──────────────────────────────────────────────────────────── -/** Shared furniture. The kit is nine exports and none of them is a table. */ function Card({ title, subtitle, children, actions }) { return (
-
-

- {title} -

- {subtitle && ( - - {subtitle} - - )} +
+

{title}

+ {subtitle && {subtitle}} {actions}
@@ -52,18 +53,25 @@ function Card({ title, subtitle, children, actions }) { ) } -function Row({ children, muted = false }) { +function Row({ children, onClick, selected = false }) { return (
(e.key === 'Enter' ? onClick() : null) : undefined} style={{ display: 'flex', alignItems: 'center', gap: 10, - padding: '8px 0', + padding: '7px 8px', borderTop: '1px solid var(--line-soft)', - fontSize: '0.86rem', - color: muted ? 'var(--ink)' : 'var(--head)', + fontSize: '0.85rem', + color: 'var(--head)', + cursor: onClick ? 'pointer' : undefined, + background: selected ? 'var(--blue)' : undefined, + borderRadius: selected ? 6 : undefined, }} > {children} @@ -71,607 +79,718 @@ function Row({ children, muted = false }) { ) } -function Warn({ children }) { - return ( -

- {children} -

- ) +const TONES = { + good: { color: '#5bb85b', border: '#3d7a3d' }, + warn: { color: '#d08a2a', border: '#8a5c1c' }, + bad: { color: '#e05a5a', border: '#8a3434' }, + info: { color: 'var(--muted)', border: 'var(--line)' }, } -function Scope({ value }) { +function Chip({ tone = 'info', children, title }) { + const t = TONES[tone] return ( - - {value === FLEET ? 'every server' : value} + + {children} ) } -/** - * One server's mirror state. - * - * `unresolved` and `pending` are rendered as sentences rather than counts - * because each is a different problem with a different fix, and both are - * invisible everywhere else on this page. - */ -function ServerState({ row, onSync, busy }) { - const report = row.report || {} - const unresolved = report.unresolved || [] - const pending = report.pending || [] - const notLanded = report.notLanded || [] +function Warn({ children }) { + return

{children}

+} +/** Granted / Revoked, as two buttons. */ +function Toggle({ on, disabled, onChange }) { + const style = (active) => ({ + padding: '2px 10px', + fontSize: '0.74rem', + cursor: disabled ? 'default' : 'pointer', + border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`, + background: active ? 'var(--blue)' : 'transparent', + color: active ? 'var(--accent-bright)' : 'var(--muted)', + }) return ( -
-
- - {row.serverId} - - - {row.inSync ? 'in sync' : row.state === 'failed' ? 'out of sync' : 'pending'} - - - {row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'} - - - -
+ + + + + ) +} - {row.error && ( -

- {row.error} -

- )} +/** A subject's name, as D163 has it: account and in-game name, or the Steam id. */ +function SubjectName({ player }) { + const primary = player.name || player.steamId + return ( + + {primary} + + {player.account ? `site account ${player.account.username}` : 'not linked'} + {player.name ? ` · ${player.steamId}` : ''} + + + ) +} - {unresolved.length > 0 && ( - - {unresolved.join(', ')} — no plugin loaded on this server has registered{' '} - {unresolved.length === 1 ? 'that name' : 'those names'}, so a grant naming{' '} - {unresolved.length === 1 ? 'it' : 'them'} reaches nobody here. It will land by itself when - the plugin is back. - - )} - - {pending.length > 0 && ( - - {pending.length} {pending.length === 1 ? 'membership is' : 'memberships are'} waiting on a - first connection — this server has never seen those players, so it has no account to put - in a group yet. - - )} - - {notLanded.length > 0 && ( - - {notLanded.length} {notLanded.length === 1 ? 'grant was' : 'grants were'} sent and not found - in the game's permission store afterwards ({notLanded.slice(0, 5).join(', ')} - {notLanded.length > 5 ? ', …' : ''}). They are not counted as pushed, and the next sync - tries again. - - )} +/** The plugin buttons: who registered each permission (§0.1). */ +function PluginPicker({ plugins, active, onSelect, countFor }) { + return ( +
+ {plugins.map((p) => { + const n = countFor ? countFor(p) : 0 + const selected = p.key === active + return ( + + ) + })}
) } -/** A hand edit, with the two answers to it. */ -function DriftRow({ row, onAdopt, onRevoke, busy }) { - const subject = row.username ? `${row.username} (${row.subject})` : row.subject +// ── The facts behind every toggle (U-1) ────────────────────────────────── - // Phase 17: a field of a group's chat style somebody changed in game. Adopt - // takes the game's value into the style; Revoke puts the site's value back. - if (row.kind === 'chat-field') { - return ( - - - {row.object}{' '} - - of group {row.subject}’s chat style is {row.detail === null ? '(empty)' : row.detail} in game ·{' '} - {row.serverId} · seen {ago(row.firstSeen)} - - - - - - ) - } - - return ( - - - {row.object}{' '} - - {row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '} - {row.serverId} · seen {ago(row.firstSeen)} - - - - - - ) +function useFacts(view) { + return useMemo(() => { + const landed = new Set(view.landed || []) + const report = view.report || { unresolved: [], pending: [], notLanded: [] } + const unresolved = new Set(report.unresolved.map((p) => String(p).toLowerCase())) + const pending = new Set(report.pending) + const notLanded = new Set(report.notLanded.map((p) => String(p).toLowerCase())) + const groupsByName = new Map(view.groups.map((g) => [g.name, g])) + const registered = new Set(view.plugins.flatMap((p) => p.permissions)) + return { landed, unresolved, pending, notLanded, groupsByName, registered } + }, [view]) } -/** - * The memberships the game could not place yet, as `steamId:group`. - * - * Read out of each server's own report, because it is the only thing that knows: - * a member who has never connected to a server has no user record there to put - * in a group (§12.2 rule 4), and from every other angle they look like a member. - * The server strip says how many; this is what puts it next to the person. - */ -function pendingSet(servers) { - const pending = new Map() +/** One player's state for one permission on this server. */ +function playerState(facts, view, player, permission) { + const direct = player.grants.find((g) => g.permission === permission) + const viaGroups = player.groups.filter((name) => (facts.groupsByName.get(name) || { permissions: [] }).permissions.includes(permission)) + const excepted = (view.excepted || []).find((e) => e.steamId === player.steamId && e.permission === permission) - for (const server of servers) { - for (const entry of (server.report && server.report.pending) || []) { - if (!pending.has(entry)) pending.set(entry, []) - pending.get(entry).push(server.serverId) - } + if (direct) { + const wider = direct.sources.some((s) => (s.type === 'userGrant' || s.type === 'steamGrant') && s.scope !== view.server.id) + const fromEvent = direct.sources.every((s) => s.type === 'runGrant') + let chip + if (!facts.registered.has(permission)) chip = not registered here + else if (facts.notLanded.has(`${player.steamId}:${permission}`)) chip = did not land + else if (facts.landed.has(`grant ${player.steamId} ${permission}`)) chip = granted + else chip = waiting for a sync + return { on: true, chip, wider, fromEvent, excepted: null, viaGroups } } - return pending + if (viaGroups.length) { + return { on: true, readOnly: true, chip: through {viaGroups.join(', ')}, viaGroups } + } + + if (excepted) { + return { on: false, excepted, chip: revoked here only } + } + + return { on: false, chip: null } } -function GroupCard({ group, catalogue, servers, pending, chatFields, onChanged, setError }) { - const [busy, setBusy] = useState(false) - const [member, setMember] = useState('') - const [permission, setPermission] = useState('') +// ── A player (D162: a subject, its plugins, its groups) ────────────────── - const act = async (fn) => { - setBusy(true) - setError('') - try { - await fn() - await onChanged() - } catch (err) { - setError(err.message || 'That did not work.') - } finally { - setBusy(false) +function PlayerPanel({ view, player, act, busy }) { + const facts = useFacts(view) + const [tab, setTab] = useState('permissions') + const [plugin, setPlugin] = useState(view.plugins[0] ? view.plugins[0].key : null) + const [everywhere, setEverywhere] = useState(false) + const [asking, setAsking] = useState(null) + const [addGroup, setAddGroup] = useState('') + + const serverId = view.server.id + const subject = { steamId: player.steamId } + const chosen = view.plugins.find((p) => p.key === plugin) + const held = (p) => p.permissions.filter((perm) => playerState(facts, view, player, perm).on).length + + const toggle = (permission, on) => { + const state = playerState(facts, view, player, permission) + if (on) { + if (state.excepted) return act(() => api.adminPermissions.removeException(state.excepted.id)) + return act(() => api.adminPermissions.grant(serverId, subject, [permission], everywhere)) } + // A grant that reaches further than this server: ask (D190's two answers). + if (state.wider) return setAsking({ permissions: [permission] }) + return act(() => api.adminPermissions.revoke(serverId, subject, [permission])) } - const save = (permissions) => - act(() => - api.adminPermissions.saveGroup(group.name, { - title: group.title, - rank: group.rank, - scope: group.scope, - permissions, - }), - ) - - // The style is saved with the group, like its permissions (D138). Answers - // whether it saved, so the editor stays open on a refusal and shows why. - const saveStyle = async (chat) => { - setBusy(true) - setError('') - try { - await api.adminPermissions.saveGroup(group.name, { - title: group.title, - rank: group.rank, - scope: group.scope, - permissions: group.permissions, - chat, - }) - await onChanged() - return true - } catch (err) { - setError(err.message || 'That style did not save.') - return false - } finally { - setBusy(false) - } + const bulk = (on) => { + if (!chosen) return + const perms = chosen.permissions.filter((perm) => { + const s = playerState(facts, view, player, perm) + return on ? !s.on : s.on && !s.readOnly + }) + if (!perms.length) return + if (on) return act(() => api.adminPermissions.grant(serverId, subject, perms, everywhere)) + if (perms.some((perm) => playerState(facts, view, player, perm).wider)) return setAsking({ permissions: perms }) + return act(() => api.adminPermissions.revoke(serverId, subject, perms)) } + const groupIds = new Map(view.groups.map((g) => [g.name, g.id])) + return ( {group.name} · } - actions={ - - } + title={} + subtitle={`${player.grants.length} direct · ${player.groups.length} group${player.groups.length === 1 ? '' : 's'}`} > -
Permissions
- {group.permissions.length === 0 && ( -

- This group carries nothing, so being in it does nothing. -

- )} - {group.permissions.map((perm) => ( - - {perm} - {!catalogue.some((entry) => entry.permission === perm) && ( - - no server has registered this - + + + {tab === 'permissions' && ( + <> + + + + {asking && ( +
+ {asking.permissions.length === 1 ? {asking.permissions[0]} : `${asking.permissions.length} of these`} reach + {' '}further than {view.server.name}. Revoke: + + + + + +
)} - -
- ))} -
{ - event.preventDefault() - if (!permission.trim()) return - save([...group.permissions, permission.trim().toLowerCase()]) - setPermission('') - }} - > - setPermission(event.target.value)} - style={{ flex: 1 }} - /> - -
- -
- Members -
- {group.members.length === 0 && ( -

- Nobody is in this group. -

+ {chosen && ( + <> +
+ + +
+ {chosen.permissions.map((perm) => { + const s = playerState(facts, view, player, perm) + return ( + + {perm} + {s.fromEvent && from an event} + {s.chip} + toggle(perm, on)} /> + + ) + })} + + )} + {!view.plugins.length &&

This server has not reported its plugins yet. Sync it once.

} + )} - {group.members.map((m) => { - const waiting = m.accounts - .map((account) => pending.get(`${account.steamId}:${group.name}`)) - .filter(Boolean) - .flat() - return ( - - - {m.username} - {m.accounts.length > 0 ? ( - - {' '} - · {m.accounts.map((a) => a.name || a.steamId).join(', ')} - - ) : ( - - {' '} - · has linked no Steam account, so this reaches nobody - + {tab === 'groups' && ( + <> + {player.groups.length === 0 &&

In no group on {view.server.name} (every player is in default).

} + {player.groups.map((name) => { + const waiting = facts.pending.has(`${player.steamId}:${name}`) + const landed = facts.landed.has(`member ${player.steamId} ${name}`) + return ( + + {name} + {waiting ? waiting for their first connection + : landed ? in the group : waiting for a sync} + + + ) + })} +
+ + + {player.groups.length > 0 && ( + )} - {waiting.length > 0 && ( - - {' '} - · waiting on their first connection to {[...new Set(waiting)].join(', ')} - - )} - - - - ) - })} - -
{ - event.preventDefault() - if (!member.trim()) return - act(() => api.adminPermissions.addMember(group.name, member.trim())) - setMember('') - }} - > - setMember(event.target.value)} - style={{ flex: 1 }} - /> - -
- - - - {servers.length > 1 && group.scope !== FLEET && ( -

- This group exists on {group.scope} only. The other servers never receive it. -

+
+ )}
) } +// ── A group (D189, D190) ───────────────────────────────────────────────── + +function GroupPanel({ view, group, act, busy, chatFields }) { + const facts = useFacts(view) + const [tab, setTab] = useState('permissions') + const [plugin, setPlugin] = useState(view.plugins[0] ? view.plugins[0].key : null) + const [scope, setScope] = useState('everywhere') + const [attrs, setAttrs] = useState({ title: group.title, rank: group.rank, parent: group.parent }) + const [member, setMember] = useState('') + const [share, setShare] = useState({ allServers: group.allServers, servers: group.servers }) + const [conflict, setConflict] = useState(null) + + useEffect(() => { + setAttrs({ title: group.title, rank: group.rank, parent: group.parent }) + setShare({ allServers: group.allServers, servers: group.servers }) + setConflict(null) + }, [group]) + + const serverId = view.server.id + const here = group.shared && scope === 'here' ? { onlyHere: true, serverId } : null + const chosen = view.plugins.find((p) => p.key === plugin) + const carried = new Set(group.permissions) + + const setPermissions = (next) => act(() => api.adminPermissions.setGroupPermissions(group.id, [...next].sort(), here)) + const toggle = (perm, on) => { + const next = new Set(carried) + if (on) next.add(perm) + else next.delete(perm) + return setPermissions(next) + } + const bulk = (on) => { + if (!chosen) return + const next = new Set(carried) + for (const perm of chosen.permissions) on ? next.add(perm) : next.delete(perm) + return setPermissions(next) + } + + const saveShare = async (replace = []) => { + const body = share.allServers ? { allServers: true, replace } : { allServers: false, servers: share.servers, replace } + try { + await act(() => api.adminPermissions.setGroupServers(group.id, body), { rethrow: true }) + setConflict(null) + } catch (err) { + if (err && err.status === 409 && err.body && err.body.conflicts) setConflict(err.body) + } + } + + const permState = (perm) => { + if (!facts.registered.has(perm)) return not registered here + if (!carried.has(perm)) return null + if (facts.landed.has(`group-permission ${group.name} ${perm}`)) return carried + return waiting for a sync + } + + const members = [ + ...group.members.map((m) => ({ key: `u${m.userId}`, label: m.username, detail: m.steamIds.length ? m.steamIds.join(', ') : 'no Steam account linked — reaches nobody yet', subject: { userId: m.userId } })), + ...group.steamMembers.map((steamId) => { + const p = view.players.find((x) => x.steamId === steamId) + return { key: `s${steamId}`, label: (p && p.name) || steamId, detail: p && p.account ? `site account ${p.account.username}` : steamId, subject: { steamId } } + }), + ] + + return ( + {group.name}{group.builtin ? ' · built in' : ''} · {group.allServers ? 'every server' : group.servers.join(', ')}{group.source && group.source !== 'admin' ? ` · ${group.source}` : ''}} + actions={!group.builtin && ( + + )} + > + {group.shared && ( +
+ shared + A change here applies + + +
+ )} + + + + {tab === 'permissions' && ( + <> + p.permissions.filter((x) => carried.has(x)).length} /> + {chosen && ( + <> +
+ + +
+ {chosen.permissions.map((perm) => ( + + {perm} + {permState(perm)} + toggle(perm, on)} /> + + ))} + + )} + {group.permissions.filter((p) => !facts.registered.has(p)).length > 0 && ( + + It also carries {group.permissions.filter((p) => !facts.registered.has(p)).join(', ')}, which no plugin on this server registers now. + They land when that plugin loads. + + )} + + )} + + {tab === 'members' && ( + <> + {group.name === 'default' &&

Every player who connects is in default; it is not listed.

} + {members.map((m) => ( + + + {m.label} + {m.detail} + + + + ))} +
+ setMember(e.target.value)} /> + + {members.length > 0 && ( + + )} +
+ + )} + + {tab === 'settings' && ( + <> +
{ e.preventDefault(); act(() => api.adminPermissions.updateGroup(group.id, attrs, here)) }} + style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 8, fontSize: '0.82rem' }} + > + + + + +
+ { + try { + await act(() => api.adminPermissions.updateGroup(group.id, { chat }, here), { rethrow: true }) + return true + } catch { + return false + } + }} + /> + + )} + + {tab === 'servers' && ( +
+

+ A group belongs to one server unless it is shared (D189). A shared group carries the same permissions and players on every server + it is on; a change made in one game gives that server its own copy. +

+ + {!share.allServers && view.servers.map((s) => ( + + ))} + + {conflict && ( +
+

{conflict.message}

+

This group carries: {(conflict.group.permissions || []).join(', ') || 'nothing'}

+ {conflict.conflicts.map((c) => ( +

+ The group on {c.servers.join(', ')} carries: {c.permissions.join(', ') || 'nothing'} +

+ ))} + {' '} + +
+ )} +
+ )} +
+ ) +} + +// ── What waits for a person (D161) ─────────────────────────────────────── + +const DIRECTION_TEXT = { + added: 'added in the game', + removed: 'removed in the game', + changed: 'changed in the game', + split: 'split off', +} + +function DriftList({ rows, act, busy }) { + if (!rows.length) return null + + return ( + + {rows.map((d) => { + const who = d.username ? `${d.playerName || d.subject} (${d.username})` : d.playerName || d.subject + const what = d.kind === 'grant' ? <>{who} holds {d.object} + : d.kind === 'member' ? <>{who} is in {d.object} + : d.kind === 'group-permission' ? <>group {d.subject} carries {d.object} + : d.kind === 'chat-field' ? <>group {d.subject}’s {d.object} is {d.detail} + : <>group {d.subject} + return ( + + + {what} — {DIRECTION_TEXT[d.direction] || d.direction}{d.detail === 'event' ? ' (an event gave it, so it was put back)' : ''} + {d.direction === 'split' && d.detail && {d.detail}} + + {(d.direction === 'added' || d.direction === 'changed' || d.kind === 'chat-field') && d.detail !== 'event' && ( + + )} + {(d.direction === 'added' || d.kind === 'chat-field') && ( + + )} + {d.direction === 'removed' && d.detail !== 'event' && ( + + )} + {(d.direction === 'removed' || d.direction === 'changed') && d.kind !== 'chat-field' && d.detail !== 'event' && ( + + )} + {(d.direction === 'split' || d.detail === 'event') && ( + + )} + + ) + })} + + ) +} + +// ── One server ─────────────────────────────────────────────────────────── + +function ServerPanel({ serverId, chatFields, reloads, act, busy }) { + const { data: view, error } = useAsync(() => api.adminPermissions.server(serverId), [serverId, reloads]) + const [tab, setTab] = useState('players') + const [selected, setSelected] = useState(null) + const [filter, setFilter] = useState('') + const [found, setFound] = useState([]) + const [newGroup, setNewGroup] = useState('') + + useEffect(() => { setSelected(null); setFilter(''); setFound([]) }, [serverId]) + + if (error) return + if (!view) return + + const f = filter.trim().toLowerCase() + const players = view.players.filter((p) => !f || [p.name, p.steamId, p.account && p.account.username].some((v) => v && String(v).toLowerCase().includes(f))) + + // A player found by search who holds nothing yet: shown with empty holdings. + const player = selected && selected.type === 'player' + ? view.players.find((p) => p.steamId === selected.steamId) || { ...selected.found, grants: [], groups: [] } + : null + const group = selected && selected.type === 'group' ? view.groups.find((g) => g.id === selected.id) : null + + return ( + <> + { setTab(t); setSelected(null) }} label="Permissions" /> + +
+
+ {tab === 'players' && ( + <> + setFilter(e.target.value)} + onKeyDown={async (e) => { + if (e.key !== 'Enter' || !filter.trim()) return + const res = await api.adminPermissions.players(serverId, filter.trim()) + setFound(res.players || []) + }} + style={{ width: '100%', marginBottom: 6 }} + /> +

Enter searches every player this server has seen.

+ {players.map((p) => ( + setSelected({ type: 'player', steamId: p.steamId })}> + + + ))} + {found.filter((p) => !view.players.some((x) => x.steamId === p.steamId)).map((p) => ( + setSelected({ type: 'player', steamId: p.steamId, found: p })}> + holds nothing + + ))} + {!players.length && !found.length &&

Nobody holds anything here yet.

} + + )} + {tab === 'groups' && ( + <> + {view.groups.map((g) => ( + setSelected({ type: 'group', id: g.id })}> + {g.name} + {g.shared && shared} + {g.permissions.length} + + ))} +
{ e.preventDefault(); const name = newGroup.trim(); if (!name) return; setNewGroup(''); act(() => api.adminPermissions.createGroup(serverId, { name, title: name })) }} + style={{ display: 'flex', gap: 6, marginTop: 10 }} + > + setNewGroup(e.target.value)} style={{ flex: 1 }} /> + +
+ + )} +
+ +
+ {player && } + {group && } + {!player && !group && ( +

+ Choose a {tab === 'players' ? 'player' : 'group'} on the left. +

+ )} + +
+
+ + ) +} + +// ── The page ───────────────────────────────────────────────────────────── + export default function Permissions() { const [reloads, setReloads] = useState(0) const [busy, setBusy] = useState(false) const [error, setError] = useState('') - const [form, setForm] = useState({ name: '', title: '', scope: FLEET }) - const [grant, setGrant] = useState({ username: '', permission: '', scope: FLEET }) + const [serverId, setServerId] = useState(null) const { data, error: loadError } = useAsync(() => api.adminPermissions.overview(), [reloads]) const reload = useCallback(() => setReloads((n) => n + 1), []) - const act = async (fn) => { + const act = async (fn, { rethrow = false } = {}) => { setBusy(true) setError('') try { await fn() reload() } catch (err) { - setError(err.message || 'That did not work.') + if (!(rethrow && err && err.status === 409)) setError(err.message || 'That did not work.') + if (rethrow) throw err } finally { setBusy(false) } } + useEffect(() => { + if (!serverId && data && data.servers.length) setServerId(data.servers[0].id) + }, [data, serverId]) + if (loadError) return if (!data) return - const servers = data.servers || [] + const server = data.servers.find((s) => s.id === serverId) + const sync = server && server.sync return ( -
- {/* No heading of our own: core's admin chrome already draws the route's - title above the page, and a second one is the same words twice. */} +

- This site is the author of record. Groups and grants written here are pushed into each - server’s own permission store, so every plugin that checks a permission honours them — and a - wipe does not lose them, because they are re-pushed when the server comes back. + This site holds every permission and group on each server — what was there before it, what is made here, and + what is changed in the game. It reads each server’s store and pushes the site’s set back, so a wipe loses nothing.

- {/* The option source, shared by both forms. A datalist rather than a select: - a name that no server has registered is still authorable — the plugin - may simply not be loaded right now — and the warning beside it is the - honest treatment, where a closed list would be a refusal. */} - - {(data.catalogue || []).map((entry) => ( - + {error &&

{error}

} - {error && ( -

- {error} -

- )} - - act(() => api.adminPermissions.sync())}> - Sync all - - } - > - {servers.length === 0 && ( -

- No servers are configured yet, so nothing written here reaches a game. -

- )} - {servers.map((row) => ( - act(() => api.adminPermissions.sync(id))} - /> - ))} -
- - {(data.drift || []).length > 0 && ( - -

- Nothing here is undone automatically. Adopt records it as the site’s - own, so it survives the next wipe; Revoke removes it from the game on - the next sync. A chat style field changed in game is adopted into the style — which then - reaches every server the group does — or put back to the site’s value. -

- {data.drift.map((row) => ( - act(() => api.adminPermissions.adoptDrift(d.id))} - onRevoke={(d) => act(() => api.adminPermissions.revokeDrift(d.id))} - /> - ))} -
- )} - - - {(data.grants || []).length === 0 && ( -

- Nobody holds a permission of their own yet. -

- )} - {(data.grants || []).map((row) => ( - - - {row.username} · {row.permission}{' '} - - {row.accounts.length === 0 && ( - - {' '} - · has linked no Steam account, so this reaches nobody - - )} - {/* The same warning the group's permission list carries, and it - matters more here: a grant naming a permission nothing has - registered is the failure the plugin's pre-check exists for, - and it is invisible on this row without it. */} - {!(data.catalogue || []).some((entry) => entry.permission === row.permission) && ( - - {' '} - · no server has registered this permission - - )} - {row.source !== 'admin' && ( - · {row.source} - )} - - - - ))} - -
{ - event.preventDefault() - if (!grant.username.trim() || !grant.permission.trim()) return - act(() => - api.adminPermissions.grant({ - username: grant.username.trim(), - permission: grant.permission.trim().toLowerCase(), - scope: grant.scope, - }), - ) - setGrant({ username: '', permission: '', scope: FLEET }) - }} - > - setGrant({ ...grant, username: event.target.value })} - style={{ flex: '1 1 160px' }} - /> - setGrant({ ...grant, permission: event.target.value })} - style={{ flex: '1 1 160px' }} - /> - setServerId(e.target.value)}> + {data.servers.map((s) => )} - -
-
+ + {server && ( + <> + + {sync && ( + sync.inSync ? in sync{sync.lastOkAt ? ` · ${ago(sync.lastOkAt)}` : ''} + : sync.state === 'failed' ? last sync failed + : waiting for a sync + )} + {sync && !sync.importedAt && not imported yet} + + + )} +
+ {server &&

{POLICY_TEXT[server.policy]}

} + {sync && sync.state === 'failed' && sync.error && {sync.error}} - {(data.groups || []).map((group) => ( - - ))} - - -
{ - event.preventDefault() - if (!form.name.trim()) return - act(() => - api.adminPermissions.saveGroup(form.name.trim().toLowerCase(), { - title: form.title.trim() || form.name.trim(), - scope: form.scope, - permissions: [], - }), - ) - setForm({ name: '', title: '', scope: FLEET }) - }} - > - setForm({ ...form, name: event.target.value })} - style={{ flex: '1 1 140px' }} - /> - setForm({ ...form, title: event.target.value })} - style={{ flex: '1 1 140px' }} - /> - - -
-

- A group is created in each in-scope game as a real group, so plugins that read group - membership see it. A member who has never connected to a server joins it there on their - first connection — a direct grant reaches them straight away, which is the difference - worth knowing when somebody is waiting. -

-
+ {serverId && } + {!data.servers.length &&

No Rust servers are configured yet.

}
) } diff --git a/routes.manifest.json b/routes.manifest.json index 15ca0aa..42b57e5 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -3,17 +3,12 @@ "routes": [ { "method": "DELETE", - "path": "/api/v1/admin/rust/permissions/grants/:id", + "path": "/api/v1/admin/rust/permissions/exceptions/:id", "tier": "public" }, { "method": "DELETE", - "path": "/api/v1/admin/rust/permissions/groups/:name", - "tier": "public" - }, - { - "method": "DELETE", - "path": "/api/v1/admin/rust/permissions/groups/:name/members/:userId", + "path": "/api/v1/admin/rust/permissions/groups/:id", "tier": "public" }, { @@ -66,6 +61,16 @@ "path": "/api/v1/admin/rust/permissions/catalogue", "tier": "public" }, + { + "method": "GET", + "path": "/api/v1/admin/rust/permissions/servers/:serverId", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/rust/permissions/servers/:serverId/players", + "tier": "public" + }, { "method": "GET", "path": "/api/v1/admin/rust/servers", @@ -166,16 +171,36 @@ "path": "/api/v1/public/rust/servers/:id/wipes", "tier": "public" }, + { + "method": "PATCH", + "path": "/api/v1/admin/rust/permissions/groups/:id", + "tier": "public" + }, { "method": "POST", "path": "/api/v1/admin/rust/config/:serverId/file", "tier": "public" }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/drift/:id/accept", + "tier": "public" + }, { "method": "POST", "path": "/api/v1/admin/rust/permissions/drift/:id/adopt", "tier": "public" }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/drift/:id/dismiss", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/drift/:id/restore", + "tier": "public" + }, { "method": "POST", "path": "/api/v1/admin/rust/permissions/drift/:id/revoke", @@ -183,12 +208,37 @@ }, { "method": "POST", - "path": "/api/v1/admin/rust/permissions/grants", + "path": "/api/v1/admin/rust/permissions/groups/:id/members", "tier": "public" }, { "method": "POST", - "path": "/api/v1/admin/rust/permissions/groups/:name/members", + "path": "/api/v1/admin/rust/permissions/groups/:id/members/clear", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/groups/:id/members/remove", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/groups/:id/split", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/servers/:serverId/grant", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/servers/:serverId/groups", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/rust/permissions/servers/:serverId/revoke", "tier": "public" }, { @@ -223,7 +273,17 @@ }, { "method": "PUT", - "path": "/api/v1/admin/rust/permissions/groups/:name", + "path": "/api/v1/admin/rust/permissions/groups/:id/permissions", + "tier": "public" + }, + { + "method": "PUT", + "path": "/api/v1/admin/rust/permissions/groups/:id/servers", + "tier": "public" + }, + { + "method": "PUT", + "path": "/api/v1/admin/rust/permissions/servers/:serverId/policy", "tier": "public" }, { diff --git a/server/boot.js b/server/boot.js index 0fa2dc3..d654a1f 100644 --- a/server/boot.js +++ b/server/boot.js @@ -49,6 +49,7 @@ const eventWorld = require('./eventWorld') const ingest = require('./ingest') const mapImages = require('./mapImages') const permSync = require('./permSync') +const permissionsDb = require('./model/permissions/permissions.db') const titleSync = require('./titleSync') const servers = require('./model/servers/servers.model') const sidecar = require('./sidecarClient') @@ -257,6 +258,16 @@ async function sweep() { async function onBoot() { await refresh() + // The permission manager's rebuild (PLAN_REDESIGNS §1) keeps groups in new + // tables. Copied once, before the loop can push anything, so no sync ever + // sees a site with its groups missing. A failure is logged, not fatal: the + // copy runs again next boot, and until then the site simply has no groups. + try { + const copied = await permissionsDb.migrateGroups() + if (copied) log.info('permission groups copied into the rebuilt tables', { groups: copied }) + } catch (err) { + log.error('could not copy the permission groups', { error: err.message }) + } // The permission mirror owns its own loop and its own cadence (see // `permSync.js`). It is started rather than run here: a first pass would write // to every configured game server before the website had finished booting, and diff --git a/server/db/purge.sql b/server/db/purge.sql index e80b5f0..e96ce21 100644 --- a/server/db/purge.sql +++ b/server/db/purge.sql @@ -19,6 +19,17 @@ -- it knows this module registered, because it is the side that knows which -- registrant owned what. +-- The permission manager, rebuilt (PLAN_REDESIGNS §1). Children before +-- `rust_permgroups`, which they reference. +DROP TABLE IF EXISTS rust_perm_exceptions; +DROP TABLE IF EXISTS rust_perm_steam_grants; +DROP TABLE IF EXISTS rust_permgroup_chat; +DROP TABLE IF EXISTS rust_permgroup_steam_members; +DROP TABLE IF EXISTS rust_permgroup_members; +DROP TABLE IF EXISTS rust_permgroup_permissions; +DROP TABLE IF EXISTS rust_permgroup_servers; +DROP TABLE IF EXISTS rust_permgroups; + -- Phase 17. `rust_perm_group_chat` before `rust_perm_groups`, which it -- references; the rest of this phase is columns, which go with their tables. DROP TABLE IF EXISTS rust_perm_group_chat; diff --git a/server/db/schema.sql b/server/db/schema.sql index c4e1fac..2588ae4 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1011,3 +1011,155 @@ ALTER TABLE rust_perm_pushed ADD COLUMN IF NOT EXISTS value VARCHAR(255) NULL; -- The value a changed field holds in the game, for a `chat-field` drift row. -- NULL on every other kind: a foreign grant is its own description. ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS detail VARCHAR(255) NULL; + +-- ── The permission manager, rebuilt (PLAN_REDESIGNS §1) ──────────────────── +-- +-- The site owns EVERY permission and group on a server now (D160), read from +-- the plugin's inventory and imported on first contact (D198). Three things the +-- tables above cannot hold made new ones necessary: +-- +-- • A group belongs to ONE server unless an admin shares it (D189). The old +-- `rust_perm_groups` is keyed by name fleet-wide, so two servers' `vip` +-- groups with different contents could not both exist. A group is now a row +-- with its own id; the servers it is on are rows beside it. +-- • A holder may be a Steam account nobody has linked (D188). The authored +-- tables above are keyed by website user (D28), and most of a real store's +-- holders never link. +-- • An in-game change affects that server only (D190), even to a row that +-- reaches more servers — so a fleet-wide grant can carry exceptions. +-- +-- The old group tables stay, unread: `permissions.db.migrateGroups` copies them +-- here once, and a downgrade still finds them as they were. +CREATE TABLE IF NOT EXISTS rust_permgroups ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + -- Verbatim, never trimmed: Carbon's own titles end in a space ("Default "), + -- and a trimmed copy would be "changed" by every sync. + title VARCHAR(120) NOT NULL DEFAULT '', + `rank` INT NOT NULL DEFAULT 0, + -- A group NAME on the same server, or ''. Both frameworks store it by name. + parent VARCHAR(64) NOT NULL DEFAULT '', + -- 1: on every server, including servers added later, except those + -- `rust_permgroup_servers` excludes. 0: on exactly the servers it includes. + all_servers TINYINT(1) NOT NULL DEFAULT 0, + -- `admin` (made on the site), `imported` (the first inventory, D198), + -- `adopted` (a later in-game change, D190), `split` (D190's copy), + -- `migrated` (copied from the old tables). + source VARCHAR(32) NOT NULL DEFAULT 'admin', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_rust_permgroups_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Which servers a group is on. `included` 1 names a server a group is on; 0 +-- takes one server out of an all-servers group — the split D190 makes when that +-- server's copy changed in the game. The model refuses two groups of one name on +-- one server; a unique key cannot say it across `all_servers`. +CREATE TABLE IF NOT EXISTS rust_permgroup_servers ( + group_id INT UNSIGNED NOT NULL, + server_id VARCHAR(64) NOT NULL, + included TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (group_id, server_id), + KEY idx_rust_permgroup_servers_server (server_id), + CONSTRAINT fk_rust_permgroup_servers_group + FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE, + CONSTRAINT fk_rust_permgroup_servers_server + FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- What a group carries, the same on every server it is on. +CREATE TABLE IF NOT EXISTS rust_permgroup_permissions ( + group_id INT UNSIGNED NOT NULL, + permission VARCHAR(128) NOT NULL, + PRIMARY KEY (group_id, permission), + CONSTRAINT fk_rust_permgroup_permissions_group + FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Members who are website accounts, reaching every Steam account they link (D28). +CREATE TABLE IF NOT EXISTS rust_permgroup_members ( + group_id INT UNSIGNED NOT NULL, + user_id INT NOT NULL, + added_by INT NULL, + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (group_id, user_id), + KEY idx_rust_permgroup_members_user (user_id), + CONSTRAINT fk_rust_permgroup_members_group + FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE, + CONSTRAINT fk_rust_permgroup_members_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Members who are one Steam account, linked or not (D188). +CREATE TABLE IF NOT EXISTS rust_permgroup_steam_members ( + group_id INT UNSIGNED NOT NULL, + steam_id VARCHAR(32) NOT NULL, + source VARCHAR(32) NOT NULL DEFAULT 'admin', + added_by INT NULL, + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (group_id, steam_id), + KEY idx_rust_permgroup_steam_members_steam (steam_id), + CONSTRAINT fk_rust_permgroup_steam_members_group + FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- A group's BetterChat style (D138), per group row rather than per name: one +-- server's own `vip` may be styled differently from another server's. +CREATE TABLE IF NOT EXISTS rust_permgroup_chat ( + group_id INT UNSIGNED NOT NULL, + field VARCHAR(32) NOT NULL, + value VARCHAR(255) NOT NULL, + PRIMARY KEY (group_id, field), + CONSTRAINT fk_rust_permgroup_chat_group + FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- A permission held by one Steam account, linked or not (D188). The import and +-- auto-adopt write these, and so does a site toggle for an UNLINKED player. +CREATE TABLE IF NOT EXISTS rust_perm_steam_grants ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + steam_id VARCHAR(32) NOT NULL, + permission VARCHAR(128) NOT NULL, + scope VARCHAR(64) NOT NULL DEFAULT '*', + source VARCHAR(32) NOT NULL DEFAULT 'admin', + note VARCHAR(255) NULL, + granted_by INT NULL, + granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_rust_perm_steam_grant (steam_id, permission, scope), + KEY idx_rust_perm_steam_grant_steam (steam_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- "Everywhere except here" (D190): a grant that reaches more than one server, +-- removed on one of them in the game, keeps reaching the others, including +-- servers added later, which a rewrite into per-server rows would lose. +-- `holder` says which grants table `grant_id` is in: `user` or `steam`. No +-- foreign key can name two tables, so deleting a grant deletes its exceptions +-- in the same model call. +CREATE TABLE IF NOT EXISTS rust_perm_exceptions ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + holder VARCHAR(8) NOT NULL, + grant_id INT UNSIGNED NOT NULL, + server_id VARCHAR(64) NOT NULL, + created_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_rust_perm_exception (holder, grant_id, server_id), + CONSTRAINT fk_rust_perm_exceptions_server + FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The registering plugin (PLAN_REDESIGNS §0.1), from the inventory. NULL for a +-- name no plugin owns; Carbon's built-in modules register theirs that way. +ALTER TABLE rust_perm_catalogue ADD COLUMN IF NOT EXISTS owner VARCHAR(64) NULL; + +-- When this server's store was first imported (D160, D198). NULL until the first +-- inventory completes, and until then every sync imports. +ALTER TABLE rust_perm_sync ADD COLUMN IF NOT EXISTS imported_at DATETIME NULL; + +-- What a change made in the game becomes (D161): `auto-adopt` (the default), +-- `adopt` (a person answers each one), or `revoke` (the site's set wins). +ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS perm_policy VARCHAR(16) NOT NULL DEFAULT 'auto-adopt'; + +-- Which way a "needs a person" row went: `added` or `removed` in the game, or +-- `split` (D190 gave a server its own copy of a shared group, and says so in +-- case the change was meant for every server). +ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS direction VARCHAR(16) NOT NULL DEFAULT 'added'; diff --git a/server/model/permissions/permissions.apply.js b/server/model/permissions/permissions.apply.js new file mode 100644 index 0000000..2c9596d --- /dev/null +++ b/server/model/permissions/permissions.apply.js @@ -0,0 +1,210 @@ +// ── Carrying out what the reconciler decided ────────────────────────────── +// +// `reconcile.plan` says WHAT a change made in the game becomes; this file writes +// it into the site's own record. Every write here is about ONE server (D190): a +// change in one game affects that server and nothing else, even when the site's +// row reaches further. +// +// • A grant that reaches only this server is deleted or written outright. +// • A grant that reaches more (a fleet grant, or a user's grant scoped `*`) +// gains an EXCEPTION for this server, and keeps reaching every other one. +// • A group shared with other servers is SPLIT: this server gets its own copy, +// the change is made to the copy, and the shared group stops covering it. A +// notice says so, in case the change was meant for every server. +// +// Ops are applied one at a time and each re-reads what it needs, because an +// earlier op in the same plan may have split the group a later one writes to. +// `permSync` runs a plan under one lock for the whole fleet, so two servers' +// plans never split the same shared group at once. + +const db = require('./permissions.db') +const model = require('./permissions.model') + +/** The site's group of this name on this server, or null (D189). */ +async function groupOn(name, serverId) { + const [groups, groupServers] = await Promise.all([db.listGroups(), db.listGroupServers()]) + return model.groupsOn(serverId, { groups, groupServers }).find((group) => group.name === name) || null +} + +/** + * The group of this name that belongs to THIS server alone, splitting a shared + * one if that is what covers it (D190). Null when the site has no such group. + */ +async function ownGroup(name, serverId) { + const group = await groupOn(name, serverId) + if (!group) return null + + const groupServers = await db.listGroupServers() + if (!model.isShared(group, model.serversByGroup(groupServers))) return group + + const copy = await db.copyGroup(group.id, 'split') + await db.setGroupServers(copy, { allServers: false, servers: [serverId] }) + await db.removeGroupFromServer(group.id, serverId) + await db.noteSplit(serverId, { + group: name, + detail: `changed in the game on ${serverId}; that server now has its own copy of "${name}"`, + }) + + return db.getGroup(copy) +} + +/** The Steam ids and user linked to one Steam id, for finding a user's grant. */ +async function userOf(steamId) { + const links = await db.listLinks() + const link = links.find((row) => row.steamId === steamId) + return link ? link.userId : null +} + +/** + * A grant the game holds and the site does not. If a grant that reaches this + * server was only kept off it by an EXCEPTION, the exception is what the game + * just undid, so the exception goes. Otherwise a Steam-account grant for this + * server alone is written (D188, D190). + */ +async function adoptGrant(serverId, { steamId, permission, source }) { + const exceptions = (await db.listExceptions()).filter((e) => e.serverId === serverId) + + if (exceptions.length) { + const userId = await userOf(steamId) + const [userGrants, steamGrants] = await Promise.all([ + userId === null ? [] : db.listGrants({ userId }), + db.listSteamGrants({ steamId }), + ]) + + const candidates = [ + ...userGrants.map((g) => ({ holder: 'user', id: g.id, permission: g.permission, scope: g.scope })), + ...steamGrants.map((g) => ({ holder: 'steam', id: g.id, permission: g.permission, scope: g.scope })), + ].filter((g) => model.normaliseName(g.permission) === permission && model.inScope(g.scope, serverId)) + + for (const grant of candidates) { + const exception = exceptions.find((e) => e.holder === grant.holder && Number(e.grantId) === Number(grant.id)) + if (exception) { + await db.deleteException(exception.id) + return + } + } + } + + await db.insertSteamGrant({ steamId, permission, scope: serverId, source }) +} + +/** + * A grant the site holds and the game no longer does. Each source that put it + * on this server stops doing so: one scoped to this server alone is deleted; one + * that reaches further gains an exception here. An event's grant is left to the + * event (the reconciler never sends one here). + */ +async function dropGrant(serverId, { sources = [] }) { + for (const source of sources) { + if (source.type !== 'userGrant' && source.type !== 'steamGrant') continue + + const holder = source.type === 'userGrant' ? 'user' : 'steam' + + if (source.scope === serverId) { + if (holder === 'user') await db.deleteGrant(source.id) + else await db.deleteSteamGrant(source.id) + } else { + await db.addException({ holder, grantId: source.id, serverId }) + } + } +} + +/** Run one op. Returns a short line for the log. */ +async function applyOp(serverId, op) { + switch (op.op) { + case 'adoptGroup': { + // A group of this name may already exist on another server, or be shared + // with every server but this one: either way this server gets its own. + const existing = await groupOn(op.name, serverId) + if (existing) return `group ${op.name}: already the site's` + + const id = await db.insertGroup({ name: op.name, title: op.title, rank: op.rank, parent: op.parent, source: op.source }) + await db.setGroupServers(id, { allServers: false, servers: [serverId] }) + return `group ${op.name}: adopted` + } + + case 'setGroupAttrs': { + const group = await ownGroup(op.group, serverId) + if (!group) return `group ${op.group}: not the site's` + await db.updateGroup(group.id, { title: op.title, rank: op.rank, parent: op.parent }) + return `group ${op.group}: title, rank and parent from the game` + } + + case 'adoptGroupPermission': { + const group = await ownGroup(op.group, serverId) + if (!group) return `group ${op.group}: not the site's` + await db.addGroupPermission(group.id, op.permission) + return `group ${op.group} + ${op.permission}` + } + + case 'dropGroupPermission': { + const group = await ownGroup(op.group, serverId) + if (!group) return `group ${op.group}: not the site's` + await db.removeGroupPermission(group.id, op.permission) + return `group ${op.group} − ${op.permission}` + } + + case 'adoptMember': { + const group = await ownGroup(op.group, serverId) + if (!group) return `group ${op.group}: not the site's` + await db.addGroupSteamMember(group.id, op.steamId, { source: op.source }) + return `${op.steamId} in ${op.group}` + } + + case 'dropMember': { + const group = await ownGroup(op.group, serverId) + if (!group) return `group ${op.group}: not the site's` + + // Whichever way the site had them in it: as a Steam account, and as the + // website account that account is linked to. + await db.removeGroupSteamMember(group.id, op.steamId) + const userId = await userOf(op.steamId) + if (userId !== null) await db.removeGroupMember(group.id, userId) + return `${op.steamId} out of ${op.group}` + } + + case 'adoptGrant': + await adoptGrant(serverId, op) + return `${op.steamId} + ${op.permission}` + + case 'dropGrant': + await dropGrant(serverId, op) + return `${op.steamId} − ${op.permission}` + + case 'dropGroup': { + const group = await groupOn(op.group, serverId) + if (!group) return `group ${op.group}: not the site's` + + const groupServers = await db.listGroupServers() + if (model.isShared(group, model.serversByGroup(groupServers))) { + await db.removeGroupFromServer(group.id, serverId) + return `group ${op.group}: no longer on ${serverId}` + } + + await db.deleteGroup(group.id) + return `group ${op.group}: deleted` + } + + default: + return `unknown op ${op.op}` + } +} + +/** Run a plan's ops in order. One op failing does not stop the rest. */ +async function applyOps(serverId, ops, log = null) { + const done = [] + + for (const op of ops) { + try { + // eslint-disable-next-line no-await-in-loop + done.push(await applyOp(serverId, op)) + } catch (err) { + done.push(`${op.op} failed: ${err.message}`) + if (log) log.warn('permission op failed', { server: serverId, op: op.op, error: err.message }) + } + } + + return done +} + +module.exports = { groupOn, ownGroup, applyOp, applyOps } diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js index b7ac1ea..f31d02f 100644 --- a/server/model/permissions/permissions.db.js +++ b/server/model/permissions/permissions.db.js @@ -1,21 +1,25 @@ // ── SQL for the permission mirror, and nothing else ─────────────────────── // // The tables this file reads are described at length in `db/schema.sql`; what -// matters here is which of them is authoritative for what, because four of the -// eight look similar and answer completely different questions: +// matters here is which of them is authoritative for what, because several look +// similar and answer completely different questions: // -// AUTHORED `rust_perm_groups`, `..._group_permissions`, `..._group_members`, -// `rust_perm_grants` — what an operator (and later an event) says -// should be true. Keyed by WEBSITE USER (D28). +// AUTHORED the site's own record of every permission and group on every +// server (D160). Groups are `rust_permgroups` and the rows beside +// them — one group per server unless an admin shares it (D189). +// Holders are website users (`rust_perm_grants`, +// `rust_permgroup_members`, D28) or single Steam accounts +// (`rust_perm_steam_grants`, `rust_permgroup_steam_members`, D188), +// and a grant that reaches several servers may carry exceptions +// (`rust_perm_exceptions`, D190). // PUSHED `rust_perm_pushed` — what this site has confirmed into one game's // store. Keyed by STEAM ID, because it records what is in the game // and the game has never heard of a website account. -// FOUND `rust_perm_drift` — what a sync found that the site did not -// author. Replaced whole by each report: it is the current -// difference, not a history of differences. +// FOUND `rust_perm_drift` — a change made in the game that waits for a +// person: every one under the `adopt` policy, and the few no policy +// can settle alone (D161, D190). // INSTRUCTED `rust_perm_revocations` — remove this, even though we never put -// it there. The only way to act on drift, since a foreign grant -// often names a Steam id no website account holds. +// it there. // // Raw parameterised SQL through `core.query`, no ORM, like every other `.db.js` // here. Bulk writes are batched into one statement with a generated placeholder @@ -24,11 +28,15 @@ const core = require('../../core') -const GROUPS = 'rust_perm_groups' -const GROUP_PERMISSIONS = 'rust_perm_group_permissions' -const GROUP_MEMBERS = 'rust_perm_group_members' -const GROUP_CHAT = 'rust_perm_group_chat' +const GROUPS = 'rust_permgroups' +const GROUP_SERVERS = 'rust_permgroup_servers' +const GROUP_PERMISSIONS = 'rust_permgroup_permissions' +const GROUP_MEMBERS = 'rust_permgroup_members' +const GROUP_STEAM_MEMBERS = 'rust_permgroup_steam_members' +const GROUP_CHAT = 'rust_permgroup_chat' const GRANTS = 'rust_perm_grants' +const STEAM_GRANTS = 'rust_perm_steam_grants' +const EXCEPTIONS = 'rust_perm_exceptions' const RUN_GRANTS = 'rust_perm_run_grants' const PUSHED = 'rust_perm_pushed' const DRIFT = 'rust_perm_drift' @@ -37,151 +45,249 @@ const SYNC = 'rust_perm_sync' const CATALOGUE = 'rust_perm_catalogue' const LINKS = 'rust_account_links' const SERVERS = 'rust_servers' +const SETTINGS = 'rust_settings' + +// The tables before the rebuild. Read once, by `migrateGroups`, and never again. +const OLD_GROUPS = 'rust_perm_groups' +const OLD_GROUP_PERMISSIONS = 'rust_perm_group_permissions' +const OLD_GROUP_MEMBERS = 'rust_perm_group_members' +const OLD_GROUP_CHAT = 'rust_perm_group_chat' +const MIGRATED_KEY = 'perm.groups.migrated' /** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */ function placeholders(rows, width) { return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',') } -// ---- the authored set ---- +const affected = (result) => Number((result && result.affectedRows) || 0) + +// ---- groups (D189) ---- + +const GROUP_COLUMNS = `id, name, title, \`rank\`, parent, all_servers AS allServers, source, + created_at AS createdAt, updated_at AS updatedAt` async function listGroups() { - return core.query( - `SELECT name, title, \`rank\`, scope, created_at AS createdAt, updated_at AS updatedAt - FROM ${GROUPS} - ORDER BY \`rank\` DESC, name ASC`, + const rows = await core.query(`SELECT ${GROUP_COLUMNS} FROM ${GROUPS} ORDER BY \`rank\` DESC, name ASC, id ASC`) + return rows.map((row) => ({ ...row, allServers: Boolean(Number(row.allServers)) })) +} + +async function getGroup(id) { + const rows = await core.query(`SELECT ${GROUP_COLUMNS} FROM ${GROUPS} WHERE id = ?`, [id]) + return rows[0] ? { ...rows[0], allServers: Boolean(Number(rows[0].allServers)) } : null +} + +/** Every group's server rows: `included` 1 is on, 0 is an all-servers group's exclusion. */ +async function listGroupServers() { + const rows = await core.query(`SELECT group_id AS groupId, server_id AS serverId, included FROM ${GROUP_SERVERS}`) + return rows.map((row) => ({ ...row, included: Boolean(Number(row.included)) })) +} + +async function insertGroup({ name, title = '', rank = 0, parent = '', allServers = false, source = 'admin' }) { + const result = await core.query( + `INSERT INTO ${GROUPS} (name, title, \`rank\`, parent, all_servers, source) VALUES (?, ?, ?, ?, ?, ?)`, + [name, title, rank, parent, allServers ? 1 : 0, source], + ) + return Number(result.insertId) +} + +async function updateGroup(id, { title, rank, parent }) { + await core.query( + `UPDATE ${GROUPS} SET title = ?, \`rank\` = ?, parent = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [title, rank, parent, id], ) } -async function getGroup(name) { - const rows = await core.query( - `SELECT name, title, \`rank\`, scope FROM ${GROUPS} WHERE name = ?`, - [name], - ) - - return rows[0] || null +async function deleteGroup(id) { + return affected(await core.query(`DELETE FROM ${GROUPS} WHERE id = ?`, [id])) > 0 } /** - * Create or update one group. - * - * `ON DUPLICATE KEY UPDATE` rather than a check-then-write: two admins on the - * same screen is not a race worth losing a title over, and the row's identity is - * its name either way. + * Put a group on exactly these servers, or on all of them less `excluded`. + * Replaced whole: the form edits the set as one thing. */ -async function upsertGroup({ name, title, rank, scope }) { +async function setGroupServers(id, { allServers, servers = [], excluded = [] }) { + await core.query(`UPDATE ${GROUPS} SET all_servers = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [allServers ? 1 : 0, id]) + await core.query(`DELETE FROM ${GROUP_SERVERS} WHERE group_id = ?`, [id]) + + const rows = allServers ? excluded.map((s) => [s, 0]) : servers.map((s) => [s, 1]) + if (!rows.length) return + await core.query( - `INSERT INTO ${GROUPS} (name, title, \`rank\`, scope) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE title = VALUES(title), \`rank\` = VALUES(\`rank\`), - scope = VALUES(scope), updated_at = CURRENT_TIMESTAMP`, - [name, title, rank, scope], + `INSERT INTO ${GROUP_SERVERS} (group_id, server_id, included) VALUES ${placeholders(rows, 3)}`, + rows.flatMap(([serverId, included]) => [id, serverId, included]), ) } -async function deleteGroup(name) { - const result = await core.query(`DELETE FROM ${GROUPS} WHERE name = ?`, [name]) - return Number(result.affectedRows || 0) > 0 +/** + * Take one server off a group (D190's split, and a group deleted in one game): + * an all-servers group gains an exclusion, any other loses the server's row. + */ +async function removeGroupFromServer(id, serverId) { + const group = await getGroup(id) + if (!group) return + + if (group.allServers) { + await core.query( + `INSERT INTO ${GROUP_SERVERS} (group_id, server_id, included) VALUES (?, ?, 0) + ON DUPLICATE KEY UPDATE included = 0`, + [id, serverId], + ) + } else { + await core.query(`DELETE FROM ${GROUP_SERVERS} WHERE group_id = ? AND server_id = ?`, [id, serverId]) + } + + await core.query(`UPDATE ${GROUPS} SET updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [id]) } async function listGroupPermissions() { - return core.query( - `SELECT group_name AS groupName, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`, - ) + return core.query(`SELECT group_id AS groupId, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`) } -/** Replace a group's permission list whole. The form edits a list, so the write is a list. */ -async function setGroupPermissions(name, permissions) { - await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_name = ?`, [name]) - +/** Replace a group's permission list whole. */ +async function setGroupPermissions(id, permissions) { + await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_id = ?`, [id]) if (!permissions.length) return await core.query( - `INSERT INTO ${GROUP_PERMISSIONS} (group_name, permission) - VALUES ${placeholders(permissions, 2)}`, - permissions.flatMap((permission) => [name, permission]), + `INSERT IGNORE INTO ${GROUP_PERMISSIONS} (group_id, permission) VALUES ${placeholders(permissions, 2)}`, + permissions.flatMap((permission) => [id, permission]), ) } +async function addGroupPermission(id, permission) { + return affected(await core.query( + `INSERT IGNORE INTO ${GROUP_PERMISSIONS} (group_id, permission) VALUES (?, ?)`, + [id, permission], + )) > 0 +} + +async function removeGroupPermission(id, permission) { + return affected(await core.query( + `DELETE FROM ${GROUP_PERMISSIONS} WHERE group_id = ? AND permission = ?`, + [id, permission], + )) > 0 +} + /** Every group's BetterChat style, one row per field (phase 17, D138). */ async function listGroupChat() { - return core.query( - `SELECT group_name AS groupName, field, value FROM ${GROUP_CHAT} ORDER BY group_name ASC, field ASC`, - ) + return core.query(`SELECT group_id AS groupId, field, value FROM ${GROUP_CHAT} ORDER BY group_id ASC, field ASC`) } -/** - * Replace a group's style whole, or remove it with `null`. A style is all twelve - * fields or none, and the form edits it as one thing. - */ -async function setGroupChat(name, fields) { - await core.query(`DELETE FROM ${GROUP_CHAT} WHERE group_name = ?`, [name]) +/** Replace a group's style whole, or remove it with `null`. */ +async function setGroupChat(id, fields) { + await core.query(`DELETE FROM ${GROUP_CHAT} WHERE group_id = ?`, [id]) const entries = fields ? Object.entries(fields) : [] if (!entries.length) return await core.query( - `INSERT INTO ${GROUP_CHAT} (group_name, field, value) VALUES ${placeholders(entries, 3)}`, - entries.flatMap(([field, value]) => [name, field, value]), + `INSERT INTO ${GROUP_CHAT} (group_id, field, value) VALUES ${placeholders(entries, 3)}`, + entries.flatMap(([field, value]) => [id, field, value]), ) } /** One field of a style, for adopting a hand edit. Returns whether the group has that field. */ -async function setGroupChatField(name, field, value) { - const result = await core.query( - `UPDATE ${GROUP_CHAT} SET value = ? WHERE group_name = ? AND field = ?`, - [value, name, field], - ) - return Number(result.affectedRows || 0) > 0 +async function setGroupChatField(id, field, value) { + return affected(await core.query( + `UPDATE ${GROUP_CHAT} SET value = ? WHERE group_id = ? AND field = ?`, + [value, id, field], + )) > 0 } -async function getGroupChat(name) { - const rows = await core.query(`SELECT field, value FROM ${GROUP_CHAT} WHERE group_name = ?`, [name]) +async function getGroupChat(id) { + const rows = await core.query(`SELECT field, value FROM ${GROUP_CHAT} WHERE group_id = ?`, [id]) return rows.length ? Object.fromEntries(rows.map((r) => [r.field, r.value])) : null } /** - * Every membership, with the member's Steam accounts joined on. - * - * One query rather than a membership read plus a link read per member: the admin - * screen renders both together and the push needs both together, and a fleet's - * worth of members is one round trip either way. + * Members who are website accounts, with each account's linked Steam ids joined + * on — one row per (membership, Steam id), which the push and the screen both want. */ async function listGroupMembers() { return core.query( - `SELECT m.group_name AS groupName, m.user_id AS userId, m.added_at AS addedAt, + `SELECT m.group_id AS groupId, m.user_id AS userId, m.added_at AS addedAt, u.username, l.steam_id AS steamId, p.name AS playerName FROM ${GROUP_MEMBERS} m JOIN users u ON u.id = m.user_id LEFT JOIN ${LINKS} l ON l.user_id = m.user_id LEFT JOIN rust_players p ON p.steam_id = l.steam_id - ORDER BY m.group_name ASC, u.username ASC`, + ORDER BY m.group_id ASC, u.username ASC`, ) } -async function addGroupMember(groupName, userId, addedBy) { - await core.query( - `INSERT IGNORE INTO ${GROUP_MEMBERS} (group_name, user_id, added_by) VALUES (?, ?, ?)`, - [groupName, userId, addedBy], +async function addGroupMember(id, userId, addedBy) { + return affected(await core.query( + `INSERT IGNORE INTO ${GROUP_MEMBERS} (group_id, user_id, added_by) VALUES (?, ?, ?)`, + [id, userId, addedBy], + )) > 0 +} + +async function removeGroupMember(id, userId) { + return affected(await core.query(`DELETE FROM ${GROUP_MEMBERS} WHERE group_id = ? AND user_id = ?`, [id, userId])) > 0 +} + +/** Members who are one Steam account (D188). */ +async function listGroupSteamMembers() { + return core.query( + `SELECT s.group_id AS groupId, s.steam_id AS steamId, s.source, s.added_at AS addedAt, p.name AS playerName + FROM ${GROUP_STEAM_MEMBERS} s + LEFT JOIN rust_players p ON p.steam_id = s.steam_id + ORDER BY s.group_id ASC, s.steam_id ASC`, ) } -async function removeGroupMember(groupName, userId) { - const result = await core.query( - `DELETE FROM ${GROUP_MEMBERS} WHERE group_name = ? AND user_id = ?`, - [groupName, userId], - ) +async function addGroupSteamMember(id, steamId, { source = 'admin', addedBy = null } = {}) { + return affected(await core.query( + `INSERT IGNORE INTO ${GROUP_STEAM_MEMBERS} (group_id, steam_id, source, added_by) VALUES (?, ?, ?, ?)`, + [id, steamId, source, addedBy], + )) > 0 +} - return Number(result.affectedRows || 0) > 0 +async function removeGroupSteamMember(id, steamId) { + return affected(await core.query( + `DELETE FROM ${GROUP_STEAM_MEMBERS} WHERE group_id = ? AND steam_id = ?`, + [id, steamId], + )) > 0 } /** - * Every direct grant, with the holder's accounts joined on. - * - * `username` is on the row because a grant with no linked Steam account still - * has to be listable and nameable — that state is the one the admin screen most - * needs to show, since it looks exactly like a working grant from every other - * angle and reaches nobody. + * A copy of a group — its attributes, permissions, both kinds of member and its + * style — on no server yet. D190's split: the caller puts the copy on the one + * server whose game changed, and takes that server off the original. + */ +async function copyGroup(id, source = 'split') { + const group = await getGroup(id) + if (!group) return null + + const copy = await insertGroup({ name: group.name, title: group.title, rank: group.rank, parent: group.parent, source }) + + await core.query( + `INSERT INTO ${GROUP_PERMISSIONS} (group_id, permission) SELECT ?, permission FROM ${GROUP_PERMISSIONS} WHERE group_id = ?`, + [copy, id], + ) + await core.query( + `INSERT INTO ${GROUP_MEMBERS} (group_id, user_id, added_by, added_at) + SELECT ?, user_id, added_by, added_at FROM ${GROUP_MEMBERS} WHERE group_id = ?`, + [copy, id], + ) + await core.query( + `INSERT INTO ${GROUP_STEAM_MEMBERS} (group_id, steam_id, source, added_by, added_at) + SELECT ?, steam_id, source, added_by, added_at FROM ${GROUP_STEAM_MEMBERS} WHERE group_id = ?`, + [copy, id], + ) + await core.query( + `INSERT INTO ${GROUP_CHAT} (group_id, field, value) SELECT ?, field, value FROM ${GROUP_CHAT} WHERE group_id = ?`, + [copy, id], + ) + + return copy +} + +// ---- grants ---- + +/** + * Every grant to a website user, with the holder's accounts joined on. One row + * per (grant, linked Steam id); a grant with nothing linked still has one row. */ async function listGrants({ userId = null } = {}) { return core.query( @@ -203,39 +309,92 @@ async function getGrant(id) { `SELECT id, user_id AS userId, permission, scope, source FROM ${GRANTS} WHERE id = ?`, [id], ) - return rows[0] || null } -/** - * Add a grant, or leave the one that is already there alone. - * - * `INSERT IGNORE` against the unique key, and the return says which happened — - * the controller needs to tell "granted" from "they already had it" to write an - * honest activity row. - */ +/** Add a grant, or leave the one that is already there. The return says which. */ async function insertGrant({ userId, permission, scope, source, note, grantedBy }) { const result = await core.query( `INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by) VALUES (?, ?, ?, ?, ?, ?)`, [userId, permission, scope, source, note, grantedBy], ) - - return { inserted: Number(result.affectedRows || 0) > 0, id: result.insertId } + return { inserted: affected(result) > 0, id: result.insertId } } +/** Delete a grant and the exceptions it carried, which no foreign key can reach. */ async function deleteGrant(id) { - const result = await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id]) - return Number(result.affectedRows || 0) > 0 + const removed = affected(await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])) > 0 + await deleteExceptionsFor('user', id) + return removed +} + +/** Every grant to one Steam account (D188), with the in-game name when the site has one. */ +async function listSteamGrants({ steamId = null } = {}) { + return core.query( + `SELECT g.id, g.steam_id AS steamId, g.permission, g.scope, g.source, g.note, + g.granted_at AS grantedAt, p.name AS playerName + FROM ${STEAM_GRANTS} g + LEFT JOIN rust_players p ON p.steam_id = g.steam_id + ${steamId === null ? '' : 'WHERE g.steam_id = ?'} + ORDER BY g.steam_id ASC, g.permission ASC`, + steamId === null ? [] : [steamId], + ) +} + +async function getSteamGrant(id) { + const rows = await core.query( + `SELECT id, steam_id AS steamId, permission, scope, source FROM ${STEAM_GRANTS} WHERE id = ?`, + [id], + ) + return rows[0] || null +} + +async function insertSteamGrant({ steamId, permission, scope, source = 'admin', note = null, grantedBy = null }) { + const result = await core.query( + `INSERT IGNORE INTO ${STEAM_GRANTS} (steam_id, permission, scope, source, note, granted_by) + VALUES (?, ?, ?, ?, ?, ?)`, + [steamId, permission, scope, source, note, grantedBy], + ) + return { inserted: affected(result) > 0, id: result.insertId } +} + +async function deleteSteamGrant(id) { + const removed = affected(await core.query(`DELETE FROM ${STEAM_GRANTS} WHERE id = ?`, [id])) > 0 + await deleteExceptionsFor('steam', id) + return removed +} + +// ---- "everywhere except here" (D190) ---- + +async function listExceptions() { + return core.query( + `SELECT id, holder, grant_id AS grantId, server_id AS serverId, created_by AS createdBy, created_at AS createdAt + FROM ${EXCEPTIONS}`, + ) +} + +async function addException({ holder, grantId, serverId, createdBy = null }) { + await core.query( + `INSERT IGNORE INTO ${EXCEPTIONS} (holder, grant_id, server_id, created_by) VALUES (?, ?, ?, ?)`, + [holder, grantId, serverId, createdBy], + ) +} + +async function deleteException(id) { + return affected(await core.query(`DELETE FROM ${EXCEPTIONS} WHERE id = ?`, [id])) > 0 +} + +async function deleteExceptionsFor(holder, grantId) { + await core.query(`DELETE FROM ${EXCEPTIONS} WHERE holder = ? AND grant_id = ?`, [holder, grantId]) } // ---- what events granted (phase 13b) ---- // // `rust_perm_run_grants` is authored by `rust.kit.entitle`, never by a person, -// and it is read beside `rust_perm_grants` rather than merged into it (D84): the -// push unions the two, and a revert deletes exactly one step's rows. +// and it is read beside the grants above rather than merged into them (D84): the +// push unions them, and a revert deletes exactly one step's rows. -/** Every event grant, for the push. Small: one row per recipient per reward step still standing. */ async function listRunGrants() { return core.query( `SELECT run_id AS runId, step_id AS stepId, user_id AS userId, server_id AS serverId, @@ -244,7 +403,6 @@ async function listRunGrants() { ) } -/** One step's rows. A repeated key finds them here and writes nothing new. */ async function listRunGrantsForStep(runId, stepId) { return core.query( `SELECT user_id AS userId, server_id AS serverId, steam_id AS steamId, permission, kit, credit @@ -254,10 +412,6 @@ async function listRunGrantsForStep(runId, stepId) { ) } -/** - * One step's recipients, in one statement. `INSERT IGNORE` against the unique - * key, so a retry that races the first attempt writes each row once. - */ async function insertRunGrants(rows) { if (!rows.length) return 0 @@ -277,15 +431,13 @@ async function insertRunGrants(rows) { ]), ) - return Number(result.affectedRows || 0) + return affected(result) } -/** Withdraw one step's rows. Returns the servers they were on; none is a success. */ async function deleteRunGrantsForStep(runId, stepId) { return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)]) } -/** The same, found by core's idempotency key — the revert of an answer core lost. */ async function deleteRunGrantsForKey(runId, idemKey) { if (!idemKey) return [] return deleteRunGrantsWhere('run_id = ? AND idem_key = ?', [String(runId), String(idemKey)]) @@ -300,56 +452,71 @@ async function deleteRunGrantsWhere(where, params) { } /** - * One website account by name, for the authoring form. - * - * A form that made an operator type a numeric user id would be a form nobody - * could use, and the alternative — calling core's own admin user search from the - * client — would bind this module to the shape of a response the contract does - * not cover. Reading the `users` table is already what every join in this file - * does. - * - * Case-insensitive because the column's collation is: core stores usernames in a - * `_ci` collation and an exact-case lookup would refuse a name the site itself - * considers the same one. + * One website account by name, for the authoring form. Case-insensitive because + * core stores usernames in a `_ci` collation. */ async function findUserByUsername(username) { const rows = await core.query(`SELECT id, username FROM users WHERE username = ? LIMIT 1`, [username]) return rows[0] || null } -/** Which website user holds which Steam account. The join that turns an authored row into a push. */ +/** Which website user holds which Steam account. */ async function listLinks() { return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`) } -// ---- one person's own half of all of it (the player tier) ---- -// -// Every read below is scoped inside the statement rather than filtered after it. -// The admin reads above answer "who holds what"; these answer "what do I hold", -// and the difference between the two is a `WHERE` that must not be somebody -// else's job to remember. - -/** The groups one website user belongs to. Ordered the way the admin list is. */ -async function listGroupsForUser(userId) { +/** Links with the account's name and the in-game name, for naming subjects on the screen (D163). */ +async function listLinksNamed() { return core.query( - `SELECT g.name, g.title, g.\`rank\`, g.scope, m.added_at AS addedAt + `SELECT l.steam_id AS steamId, l.user_id AS userId, u.username, p.name AS playerName + FROM ${LINKS} l + JOIN users u ON u.id = l.user_id + LEFT JOIN rust_players p ON p.steam_id = l.steam_id`, + ) +} + +/** The in-game names the site knows for these Steam ids (D163). */ +async function namesFor(steamIds) { + if (!steamIds.length) return [] + return core.query( + `SELECT steam_id AS steamId, name FROM rust_players WHERE steam_id IN (${steamIds.map(() => '?').join(',')})`, + steamIds, + ) +} + +/** Players seen on one server, for the players list's search. Newest first, bounded. */ +async function searchPlayers(serverId, q, limit = 25) { + const like = `%${String(q || '').replace(/[\\%_]/g, (c) => `\\${c}`)}%` + + return core.query( + `SELECT p.steam_id AS steamId, p.name AS playerName, l.user_id AS userId, u.username + FROM rust_players p + LEFT JOIN ${LINKS} l ON l.steam_id = p.steam_id + LEFT JOIN users u ON u.id = l.user_id + WHERE (p.name LIKE ? OR p.steam_id LIKE ? OR u.username LIKE ?) + AND EXISTS (SELECT 1 FROM rust_player_wipe_stats s WHERE s.steam_id = p.steam_id AND s.server_id = ?) + ORDER BY p.last_seen DESC + LIMIT ${Number(limit) || 25}`, + [like, like, like, serverId], + ) +} + +// ---- one person's own half of all of it (the player tier) ---- + +/** The groups one website user belongs to, by account membership. */ +async function listGroupsForUser(userId) { + const rows = await core.query( + `SELECT g.id, g.name, g.title, g.\`rank\`, g.all_servers AS allServers, m.added_at AS addedAt FROM ${GROUP_MEMBERS} m - JOIN ${GROUPS} g ON g.name = m.group_name + JOIN ${GROUPS} g ON g.id = m.group_id WHERE m.user_id = ? ORDER BY g.\`rank\` DESC, g.name ASC`, [userId], ) + return rows.map((row) => ({ ...row, allServers: Boolean(Number(row.allServers)) })) } -/** - * Every pushed row naming one of these Steam ids, across every server. - * - * The pushed ledger is keyed by Steam id because it records what is in a GAME - * (D28's other half), so this is the one read in the file that starts from an - * account rather than from a user. `kind` is carried through: a direct grant and - * a group membership are different rows about the same person and only the - * caller can say which of them it was looking for. - */ +/** Every pushed row naming one of these Steam ids, across every server. */ async function listPushedForSteamIds(steamIds) { if (!steamIds.length) return [] @@ -365,17 +532,14 @@ async function listPushedForSteamIds(steamIds) { // ---- what is actually out there ---- async function listPushed(serverId) { - return core.query( - `SELECT kind, subject, object, value FROM ${PUSHED} WHERE server_id = ?`, - [serverId], - ) + return core.query(`SELECT kind, subject, object, value FROM ${PUSHED} WHERE server_id = ?`, [serverId]) } /** - * Record rows as landed. A `chat-field` row carries the VALUE that landed, and a - * second landing of the same field moves it: the value is what the next sync - * tells a hand edit from this site's own write by (§33.2). Every other kind has - * no value and is written once. + * Record rows as landed. A row with a VALUE (a `chat-field`, and a `group`'s + * title, rank and parent since protocol 13) moves it on a second landing: the + * value is what the next inventory tells a hand edit from this site's own write + * by. Every other kind has no value and is written once. */ async function addPushed(serverId, rows) { if (!rows.length) return @@ -388,12 +552,6 @@ async function addPushed(serverId, rows) { ) } -/** - * Say that a style field holds `value` in one game as far as this site is - * concerned. It is how a person revokes a hand edit to a style: the next sync - * sends the site's value with this as what it expects to find, which is the - * game's own value — so the plugin writes over it, on purpose (§33.2). - */ async function setPushedValue(serverId, { kind, subject, object, value }) { await addPushed(serverId, [{ kind, subject, object, value }]) } @@ -409,40 +567,54 @@ async function removePushed(serverId, rows) { } /** - * Replace one server's drift list with what the latest report found. + * Replace one server's "needs a person" list with what the latest sync found. * - * Whole, rather than merged, and `first_seen` survives through the - * `ON DUPLICATE KEY UPDATE` — so "this has been here since Tuesday" is still - * answerable while "somebody has since undone it" removes the row. + * Whole, and `first_seen` survives through the `ON DUPLICATE KEY UPDATE`. A + * `split` row is a notice rather than a difference — nothing in the game says it + * any more once it is made — so it is left alone until a person dismisses it. */ async function replaceDrift(serverId, rows) { if (!rows.length) { - await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ?`, [serverId]) + await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ? AND direction <> 'split'`, [serverId]) return } await core.query( - `INSERT INTO ${DRIFT} (server_id, kind, subject, object, detail) - VALUES ${placeholders(rows, 5)} - ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP, detail = VALUES(detail)`, - rows.flatMap((row) => [serverId, row.kind, row.subject, row.object, row.detail === undefined ? null : row.detail]), + `INSERT INTO ${DRIFT} (server_id, kind, subject, object, detail, direction) + VALUES ${placeholders(rows, 6)} + ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP, detail = VALUES(detail), direction = VALUES(direction)`, + rows.flatMap((row) => [ + serverId, + row.kind, + row.subject, + row.object, + row.detail === undefined ? null : row.detail, + row.direction || 'added', + ]), ) - // Anything this report did NOT name is gone from the game, so it goes from - // here. Named explicitly rather than swept by timestamp: two syncs a second - // apart would make a timestamp window either delete live rows or keep dead - // ones, depending on the clock. await core.query( `DELETE FROM ${DRIFT} WHERE server_id = ? + AND direction <> 'split' AND (kind, subject, object) NOT IN (${placeholders(rows, 3)})`, [serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])], ) } +/** A split notice (D190). Kept until a person dismisses it. */ +async function noteSplit(serverId, { group, detail }) { + await core.query( + `INSERT INTO ${DRIFT} (server_id, kind, subject, object, detail, direction) + VALUES (?, 'group', ?, '', ?, 'split') + ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP, detail = VALUES(detail), direction = 'split'`, + [serverId, group, detail === undefined ? null : detail], + ) +} + async function listDrift() { return core.query( - `SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object, d.detail, + `SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object, d.detail, d.direction, d.first_seen AS firstSeen, d.last_seen AS lastSeen, l.user_id AS userId, u.username, p.name AS playerName FROM ${DRIFT} d @@ -455,10 +627,9 @@ async function listDrift() { async function getDrift(id) { const rows = await core.query( - `SELECT id, server_id AS serverId, kind, subject, object, detail FROM ${DRIFT} WHERE id = ?`, + `SELECT id, server_id AS serverId, kind, subject, object, detail, direction FROM ${DRIFT} WHERE id = ?`, [id], ) - return rows[0] || null } @@ -475,33 +646,18 @@ async function queueRevocation({ serverId, kind, subject, object, requestedBy }) } async function listRevocations(serverId) { - return core.query( - `SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`, - [serverId], - ) + return core.query(`SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`, [serverId]) } async function deleteRevocations(ids) { if (!ids.length) return - - await core.query( - `DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`, - ids, - ) + await core.query(`DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`, ids) } // ---- the state of the mirror ---- -/** - * One sync row per configured server, created on demand. - * - * A server added today has no row and must not therefore be skipped for ever, so - * the read inserts what is missing rather than the writer remembering to. - */ async function ensureSyncRows() { - await core.query( - `INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`, - ) + await core.query(`INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`) } async function listSync() { @@ -509,18 +665,14 @@ async function listSync() { `SELECT s.server_id AS serverId, s.state, s.dirty, s.desired_hash AS desiredHash, s.synced_hash AS syncedHash, s.boot_id AS bootId, s.wipe_id AS wipeId, s.last_attempt_at AS lastAttemptAt, s.last_ok_at AS lastOkAt, - s.report, s.error + s.imported_at AS importedAt, s.report, s.error FROM ${SYNC} s ORDER BY s.server_id ASC`, ) } /** - * Mark servers as needing a sync. - * - * `scope` is a server id or `*`; a fleet-wide change dirties every row, which is - * right: the set each server should hold has changed even if only one of them - * will notice a difference. + * Mark servers as needing a sync. `scope` is a server id, `*`, or a list of ids. */ async function markDirty(scope) { if (!scope || scope === '*') { @@ -528,24 +680,18 @@ async function markDirty(scope) { return } + const ids = Array.isArray(scope) ? scope : [scope] + if (!ids.length) return + await core.query( - `UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id = ?`, - [scope], + `UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id IN (${ids.map(() => '?').join(',')})`, + ids, ) } /** - * Record the outcome of one attempt. - * - * **`dirty` is cleared unconditionally, and that is safe because it is an - * optimisation rather than the truth.** Something may well have changed the - * authored set while this sync was in flight, and clearing the flag would then - * lose that change — except that the loop's real condition is - * `desired_hash != synced_hash`, recomputed from the tables on every tick. The - * flag only saves a hash comparison; the hash is what cannot be wrong. - * - * `last_ok_at` moves only on success, and it is passed rather than composed into - * the SQL so the statement is the same string every time. + * Record the outcome of one attempt. `dirty` is cleared unconditionally: the + * loop's real condition is the digest, recomputed every tick. */ async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) { const okAt = state === 'ok' ? new Date() : null @@ -566,38 +712,128 @@ async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, ) } +/** The first complete inventory of a server has been imported (D198). */ +async function markImported(serverId) { + await core.query(`UPDATE ${SYNC} SET imported_at = COALESCE(imported_at, NOW()) WHERE server_id = ?`, [serverId]) +} + +/** Every server's policy for a change made in the game (D161). */ +async function listPolicies() { + return core.query(`SELECT id AS serverId, perm_policy AS policy FROM ${SERVERS}`) +} + +async function setPolicy(serverId, policy) { + return affected(await core.query(`UPDATE ${SERVERS} SET perm_policy = ? WHERE id = ?`, [policy, serverId])) > 0 +} + // ---- the option source ---- -async function putCatalogue(serverId, permissions) { +/** What one server's plugins registered, and which plugin registered each (§0.1). */ +async function putCatalogue(serverId, rows) { await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId]) - - if (!permissions.length) return + if (!rows.length) return await core.query( - `INSERT IGNORE INTO ${CATALOGUE} (server_id, permission) - VALUES ${placeholders(permissions, 2)}`, - permissions.flatMap((permission) => [serverId, permission]), + `INSERT IGNORE INTO ${CATALOGUE} (server_id, permission, owner) VALUES ${placeholders(rows, 3)}`, + rows.flatMap((row) => [serverId, row.permission, row.owner || null]), ) } async function listCatalogue() { return core.query( - `SELECT server_id AS serverId, permission FROM ${CATALOGUE} ORDER BY permission ASC`, + `SELECT server_id AS serverId, permission, owner FROM ${CATALOGUE} ORDER BY permission ASC`, ) } +// ---- the one-time copy out of the old group tables ---- + +/** + * Copy the groups made before the rebuild into the new tables, once. + * + * A group scoped `*` becomes a group on every server, and one scoped to a server + * becomes that server's group, so what each server receives does not change. The + * marker in `rust_settings` is what makes it once: without it, a site whose admin + * later deleted every group would have them all copied back on the next boot. + * Returns how many groups were copied. + */ +async function migrateGroups() { + const done = await core.query(`SELECT value FROM ${SETTINGS} WHERE setting_key = ?`, [MIGRATED_KEY]) + if (done.length) return 0 + + const old = await core.query(`SELECT name, title, \`rank\`, scope FROM ${OLD_GROUPS}`) + + // A copy that failed halfway is finished, not repeated: a group already + // migrated under its name is skipped. + const already = new Set( + (await core.query(`SELECT name FROM ${GROUPS} WHERE source = 'migrated'`)).map((row) => row.name), + ) + + for (const group of old) { + if (already.has(group.name)) continue + + // eslint-disable-next-line no-await-in-loop + const id = await insertGroup({ + name: group.name, + title: group.title || '', + rank: Number(group.rank) || 0, + allServers: group.scope === '*', + source: 'migrated', + }) + + const params = [id, group.name] + /* eslint-disable no-await-in-loop */ + if (group.scope !== '*') { + await core.query( + `INSERT IGNORE INTO ${GROUP_SERVERS} (group_id, server_id, included) + SELECT ?, id, 1 FROM ${SERVERS} WHERE id = ?`, + [id, group.scope], + ) + } + await core.query( + `INSERT IGNORE INTO ${GROUP_PERMISSIONS} (group_id, permission) + SELECT ?, permission FROM ${OLD_GROUP_PERMISSIONS} WHERE group_name = ?`, + params, + ) + await core.query( + `INSERT IGNORE INTO ${GROUP_MEMBERS} (group_id, user_id, added_by, added_at) + SELECT ?, user_id, added_by, added_at FROM ${OLD_GROUP_MEMBERS} WHERE group_name = ?`, + params, + ) + await core.query( + `INSERT IGNORE INTO ${GROUP_CHAT} (group_id, field, value) + SELECT ?, field, value FROM ${OLD_GROUP_CHAT} WHERE group_name = ?`, + params, + ) + /* eslint-enable no-await-in-loop */ + } + + await core.query( + `INSERT IGNORE INTO ${SETTINGS} (setting_key, value) VALUES (?, ?)`, + [MIGRATED_KEY, String(old.length)], + ) + + return old.length +} + module.exports = { GROUPS, GRANTS, + STEAM_GRANTS, RUN_GRANTS, PUSHED, DRIFT, listGroups, getGroup, - upsertGroup, + listGroupServers, + insertGroup, + updateGroup, deleteGroup, + setGroupServers, + removeGroupFromServer, listGroupPermissions, setGroupPermissions, + addGroupPermission, + removeGroupPermission, listGroupChat, setGroupChat, setGroupChatField, @@ -605,10 +841,22 @@ module.exports = { listGroupMembers, addGroupMember, removeGroupMember, + listGroupSteamMembers, + addGroupSteamMember, + removeGroupSteamMember, + copyGroup, listGrants, getGrant, insertGrant, deleteGrant, + listSteamGrants, + getSteamGrant, + insertSteamGrant, + deleteSteamGrant, + listExceptions, + addException, + deleteException, + deleteExceptionsFor, listRunGrants, listRunGrantsForStep, insertRunGrants, @@ -616,6 +864,9 @@ module.exports = { deleteRunGrantsForKey, findUserByUsername, listLinks, + listLinksNamed, + namesFor, + searchPlayers, listGroupsForUser, listPushedForSteamIds, listPushed, @@ -623,6 +874,7 @@ module.exports = { setPushedValue, removePushed, replaceDrift, + noteSplit, listDrift, getDrift, deleteDrift, @@ -633,6 +885,10 @@ module.exports = { listSync, markDirty, putSyncResult, + markImported, + listPolicies, + setPolicy, putCatalogue, listCatalogue, + migrateGroups, } diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js index b9345c5..1611f05 100644 --- a/server/model/permissions/permissions.model.js +++ b/server/model/permissions/permissions.model.js @@ -1,38 +1,49 @@ // ── The authored set, and what it means for one server ──────────────────── // -// This file turns "what an operator wrote on the website" into "what one game -// server's store should contain", which is where four of phase 7's decisions -// actually live: +// This file turns "what the site holds" into "what one game server's store +// should contain". Since the permission manager was rebuilt (PLAN_REDESIGNS §1) +// the site holds EVERYTHING on every server — what was there before it, what an +// admin made, and what was changed in the game (D160) — so the decisions that +// live here are: // -// D28 a grant is authored against a WEBSITE USER and resolved to every Steam -// id they have linked, here, at the moment of the push. -// D29 every authored row carries a scope — one server, or `*` for the fleet — -// and a server sees only what names it. -// D30 groups travel as groups. Membership is a separate wire fact from the -// permissions the group carries, because the game stores them separately -// and one of the two can fail on its own (§12.2 rule 4). -// D31 the difference between the desired set and what this site has already -// pushed is what gets retired. Anything else in the store is drift, and -// drift is reported rather than undone. +// D28 a grant or membership held by a WEBSITE USER reaches every Steam id +// they have linked, resolved here at the moment of the push. +// D188 one held by a STEAM ACCOUNT reaches exactly that account, linked or not. +// D29 a grant carries a scope — one server, or `*` for the fleet — and a +// server sees only what names it. D190 lets a fleet grant carry +// exceptions: "every server except this one". +// D189 a group belongs to one server unless an admin shares it. What it +// carries and who is in it are the group's, and the same on every server +// it is on. +// D31 the difference between the desired set and what this site has pushed +// is what gets retired. // -// Nothing here talks to a sidecar — `permSync.js` does that. The split is the -// usual one and earns its keep twice over here: the whole of the interesting -// logic is a pure function of four tables, so it is tested without a game, a -// sidecar, or a database. +// `buildDesired` also says, for each row, which authored rows produced it (its +// SOURCES). The reconciler needs that to answer a change made in the game: a +// grant removed in the game is deleted when it was this server's alone, and gains +// an exception when it reached further (D190). +// +// Nothing here talks to a sidecar — `permSync.js` does that — and nothing here +// writes: every function below is a pure function of rows, tested without a +// game, a sidecar, or a database. const crypto = require('node:crypto') const db = require('./permissions.db') -/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */ +/** A scope that means every server. */ const FLEET = '*' /** - * Permission and group names, as both frameworks store them. - * - * Lowercased on the way in, because the store lowers them and a site that did - * not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own - * grant as drift for ever. + * Groups neither framework lets go of: they exist on every server by the + * framework's own rule. They are imported and editable, and never retired. + * `moderator` is Carbon's. + */ +const BUILTIN_GROUPS = new Set(['default', 'admin', 'moderator']) + +/** + * Permission and group names, as both frameworks store them. Lowercased on the + * way in, because the store lowers them. */ function normaliseName(value) { return String(value || '').trim().toLowerCase() @@ -44,90 +55,72 @@ function inScope(scope, serverId) { } /** - * Everything the authoring screen renders, in one read. - * - * Assembled here rather than in SQL because the shape is a tree — a group with - * its permissions and its members — and the alternative is either four round - * trips per group or one join that repeats every group row once per member. + * A group's title, rank and parent as one comparable value, stored on its + * `group` ledger row. The title is kept verbatim — Carbon's own end in a space — + * so the value the site pushed and the value the game reports are the same + * string when nothing changed. */ -async function overview() { - const [groups, groupPermissions, members, grants, sync, drift, catalogue, groupChat] = await Promise.all([ - db.listGroups(), - db.listGroupPermissions(), - db.listGroupMembers(), - db.listGrants(), - db.listSync(), - db.listDrift(), - db.listCatalogue(), - db.listGroupChat(), - ]) - - const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [], chat: null }])) - - // Phase 17: a group's BetterChat style, or null for a group without one. - for (const [name, fields] of chatByGroup(groupChat)) { - const group = byGroup.get(name) - if (group) group.chat = fields - } - - for (const row of groupPermissions) { - const group = byGroup.get(row.groupName) - if (group) group.permissions.push(row.permission) - } - - // A member with two linked Steam accounts arrives as two rows from the join, - // and is one person on the screen — holding BOTH accounts, not the first one - // the join happened to return. The screen needs all of them: a membership is - // pushed per account, and it can be waiting on one while it landed on another. - const memberByKey = new Map() - - for (const row of members) { - const group = byGroup.get(row.groupName) - if (!group) continue - - const key = `${row.groupName}:${row.userId}` - let member = memberByKey.get(key) - - if (!member) { - member = { - userId: row.userId, - username: row.username, - accounts: [], - addedAt: row.addedAt, - } - memberByKey.set(key, member) - group.members.push(member) - } - - if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null }) - } - - return { - groups: [...byGroup.values()], - grants: collapseGrants(grants), - servers: sync.map(shapeSync), - drift: drift.map((row) => ({ ...row, detail: row.detail === undefined ? null : row.detail })), - catalogue: catalogueByPermission(catalogue), - } +function groupValue(title, rank, parent) { + return JSON.stringify([String(title == null ? '' : title), Number(rank) || 0, normaliseName(parent)]) } -/** Style rows folded into one object per group: `name → { Field: value }`. */ +/** `groupId → Map(serverId → included)`. */ +function serversByGroup(groupServers) { + const out = new Map() + + for (const row of groupServers || []) { + if (!out.has(row.groupId)) out.set(row.groupId, new Map()) + out.get(row.groupId).set(row.serverId, Boolean(row.included)) + } + + return out +} + +/** Whether a group is on a server (D189). */ +function groupCovers(group, serverRows, serverId) { + const rows = serverRows.get(group.id) + const row = rows ? rows.get(serverId) : undefined + + return group.allServers ? row !== false : row === true +} + +/** The servers a group is on, out of a list of server ids. */ +function groupReach(group, serverRows, serverIds) { + return serverIds.filter((id) => groupCovers(group, serverRows, id)) +} + +/** + * The groups on one server, one per name. The model refuses a second group of a + * name on a server; should the tables ever hold one anyway, the older wins and + * the newer is ignored rather than both being pushed as one. + */ +function groupsOn(serverId, authored) { + const serverRows = serversByGroup(authored.groupServers) + const byName = new Map() + + for (const group of [...authored.groups].sort((a, b) => a.id - b.id)) { + if (!groupCovers(group, serverRows, serverId)) continue + if (!byName.has(group.name)) byName.set(group.name, group) + } + + return [...byName.values()] +} + +/** Style rows folded into one object per group: `groupId → { Field: value }`. */ function chatByGroup(rows) { const out = new Map() for (const row of rows || []) { - if (!out.has(row.groupName)) out.set(row.groupName, {}) - out.get(row.groupName)[row.field] = row.value + if (!out.has(row.groupId)) out.set(row.groupId, {}) + out.get(row.groupId)[row.field] = row.value } return out } /** - * One row per grant, not one per linked account. - * - * The join in `listGrants` multiplies a grant by the holder's accounts, which is - * what the push wants and the opposite of what a screen wants. + * One row per grant, not one per linked account. The join in `listGrants` + * multiplies a grant by the holder's accounts. */ function collapseGrants(rows) { const byId = new Map() @@ -157,13 +150,7 @@ function collapseGrants(rows) { return [...byId.values()] } -/** - * The sync row as a client reads it. - * - * `report` is stored as the JSON the game sent and parsed here rather than on the - * way in, so a report this build cannot read is a rendering problem on one - * screen instead of a write that failed. - */ +/** The sync row as a client reads it. */ function shapeSync(row) { let report = null @@ -182,126 +169,37 @@ function shapeSync(row) { inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok', lastAttemptAt: row.lastAttemptAt, lastOkAt: row.lastOkAt, + importedAt: row.importedAt || null, error: row.error || null, report, } } -/** Which servers know each permission name — the form's option source, and its warning label. */ -function catalogueByPermission(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, servers]) => ({ permission, servers })) - .sort((a, b) => a.permission.localeCompare(b.permission)) -} - -/** - * ── What one person holds, as that person reads it ──────────────────────── - * - * The admin overview answers *who holds what*; this answers *what do I hold*, - * and it is a different shape rather than a filtered one. Three things make it - * different: - * - * 1. **The scope arithmetic is answered here, not sent.** A client handed - * `scope: '*'` would have to know what the fleet is and re-implement - * `inScope` to say anything useful, and then there would be two of it. Each - * entry carries the servers it actually reaches, already resolved. - * 2. **`live` is per server and it is the pushed ledger, not the authored - * row.** A grant made on the website is not a privilege in a game until a - * sync confirmed it, and phase 7 is careful never to record a push that - * silently did nothing (an unregistered permission, a store that has never - * seen the player). So "waiting" here means waiting, and saying otherwise - * would be the site claiming to have given something it has not. - * 3. **Nothing says WHY it is waiting.** Which permission names a server's - * loaded plugins registered is an operator's diagnosis and an inventory of - * what is installed; a player gets the honest state, not the reason. - * - * Every read is scoped to the caller in SQL, and the pushed rows are looked up - * by the caller's OWN Steam ids — so a person with no linked account correctly - * sees entitlements that reach nobody yet, rather than nothing at all (the - * mistake phase 7 shipped on the admin user page, §20.5). - */ -async function forPlayer(userId, steamIds, serverRows) { - const [groups, groupPermissions, grants, pushed] = await Promise.all([ - db.listGroupsForUser(userId), - db.listGroupPermissions(), - db.listGrants({ userId }), - db.listPushedForSteamIds(steamIds), - ]) - - const servers = serverRows.map((row) => ({ id: row.id, name: row.name || row.id })) - - // `kind:object` -> the servers a row of ours landed on. The subject is one of - // this caller's own Steam ids by construction, so it does not enter the key: - // an entitlement is live for the person if it is live for any account they - // hold, which is the same thing the game sees. - const live = new Map() - - for (const row of pushed) { - const key = `${row.kind}:${normaliseName(row.object)}` - if (!live.has(key)) live.set(key, new Set()) - live.get(key).add(row.serverId) - } - - /** The servers a scope reaches, each marked with whether it is there yet. */ - function reach(scope, key) { - const landed = live.get(key) || new Set() - - return servers - .filter((server) => inScope(scope, server.id)) - .map((server) => ({ ...server, live: landed.has(server.id) })) - } - - const permissionsByGroup = new Map() - - for (const row of groupPermissions) { - if (!permissionsByGroup.has(row.groupName)) permissionsByGroup.set(row.groupName, []) - permissionsByGroup.get(row.groupName).push(normaliseName(row.permission)) - } - - return { - groups: groups.map((group) => ({ - name: group.name, - title: group.title || group.name, - scope: group.scope, - since: group.addedAt, - permissions: (permissionsByGroup.get(group.name) || []).sort(), - reach: reach(group.scope, `member:${normaliseName(group.name)}`), - })), - // `collapseGrants` first: the join multiplies a grant by the accounts its - // holder has linked, and this caller may hold two. - grants: collapseGrants(grants) - .map((grant) => ({ - permission: grant.permission, - scope: grant.scope, - source: grant.source, - note: grant.note, - since: grant.grantedAt, - reach: reach(grant.scope, `grant:${normaliseName(grant.permission)}`), - })) - .sort((a, b) => a.permission.localeCompare(b.permission)), - } -} - /** * The whole authored set, read once, in the shape the per-server build wants. - * - * Read once per sync tick rather than once per server: six servers is six - * different answers derived from one set of tables, and re-reading them per - * server is six times the queries for the same rows. */ async function readAuthored() { - const [groups, groupPermissions, members, grants, links, runGrants, groupChat] = await Promise.all([ + const [ + groups, + groupServers, + groupPermissions, + members, + steamMembers, + grants, + steamGrants, + exceptions, + links, + runGrants, + groupChat, + ] = await Promise.all([ db.listGroups(), + db.listGroupServers(), db.listGroupPermissions(), db.listGroupMembers(), + db.listGroupSteamMembers(), db.listGrants(), + db.listSteamGrants(), + db.listExceptions(), db.listLinks(), db.listRunGrants(), db.listGroupChat(), @@ -314,103 +212,158 @@ async function readAuthored() { steamIdsByUser.get(link.userId).push(link.steamId) } - return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser, groupChat } + return { + groups, + groupServers, + groupPermissions, + members, + steamMembers, + grants, + steamGrants, + exceptions, + runGrants, + groupChat, + steamIdsByUser, + } } +/** A row's identity, for set arithmetic against what was pushed. */ +const rowKey = (row) => `${row.kind} ${row.subject} ${row.object}` + /** * What one server's store should contain, and the rows that say so. * - * Returns three things the caller needs together and must not compute twice: - * * `payload` what goes on the wire * `rows` the same set in `rust_perm_pushed`'s shape, for the diff - * `hash` a stable digest of `rows`, which is how the loop knows nothing - * has changed without asking a game server + * `hash` a stable digest of `rows` + * `sources` rowKey → the authored rows that produced it (see the file header) * - * **A user with no linked Steam account contributes nothing and is not an - * error.** They are authored against perfectly well and reach nobody until they - * link — which the admin screen says out loud, because a grant that reaches - * nothing looks exactly like one that worked. + * A user with no linked Steam account contributes nothing and is not an error. */ function buildDesired(serverId, authored) { - const { groups, groupPermissions, members, grants, steamIdsByUser } = authored + const { groupPermissions, members, steamIdsByUser } = authored + const steamMembers = authored.steamMembers || [] + const grants = authored.grants || [] + const steamGrants = authored.steamGrants || [] const runGrants = authored.runGrants || [] + const exceptions = new Set( + (authored.exceptions || []).filter((e) => e.serverId === serverId).map((e) => `${e.holder}:${e.grantId}`), + ) + const serverRows = serversByGroup(authored.groupServers) - const scopedGroups = groups.filter((group) => inScope(group.scope, serverId)) - const groupNames = new Set(scopedGroups.map((group) => group.name)) - - const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []])) - const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []])) - const managed = new Set() const rows = [] + const sources = new Map() + const addSource = (row, source) => { + const key = rowKey(row) + if (!sources.has(key)) sources.set(key, []) + sources.get(key).push(source) + } - for (const group of scopedGroups) - rows.push({ kind: 'group', subject: group.name, object: '' }) + const onServer = groupsOn(serverId, authored) + const groupById = new Map(onServer.map((group) => [group.id, group])) + const shared = (group) => isShared(group, serverRows) + const permissionsByGroup = new Map(onServer.map((group) => [group.id, []])) + const membersByGroup = new Map(onServer.map((group) => [group.id, []])) - for (const row of groupPermissions) { - if (!groupNames.has(row.groupName)) continue + for (const group of onServer) { + const row = { kind: 'group', subject: group.name, object: '', value: groupValue(group.title, group.rank, group.parent) } + rows.push(row) + addSource(row, { type: 'group', groupId: group.id, shared: shared(group) }) + } - const permission = normaliseName(row.permission) - permissionsByGroup.get(row.groupName).push(permission) - managed.add(permission) - rows.push({ kind: 'group-permission', subject: row.groupName, object: permission }) + for (const entry of groupPermissions) { + const group = groupById.get(entry.groupId) + if (!group) continue + + const permission = normaliseName(entry.permission) + if (!permission) continue + + permissionsByGroup.get(group.id).push(permission) + const row = { kind: 'group-permission', subject: group.name, object: permission } + rows.push(row) + addSource(row, { type: 'group', groupId: group.id, shared: shared(group) }) } const seenMember = new Set() + const addMember = (group, steamId, source) => { + const row = { kind: 'member', subject: steamId, object: group.name } + addSource(row, source) - for (const row of members) { - if (!groupNames.has(row.groupName)) continue + const key = `${group.id}:${steamId}` + if (seenMember.has(key)) return + seenMember.add(key) - for (const steamId of steamIdsByUser.get(row.userId) || []) { - const key = `${row.groupName}:${steamId}` - if (seenMember.has(key)) continue - seenMember.add(key) + membersByGroup.get(group.id).push(steamId) + rows.push(row) + } - membersByGroup.get(row.groupName).push(steamId) - rows.push({ kind: 'member', subject: steamId, object: row.groupName }) + for (const entry of members) { + const group = groupById.get(entry.groupId) + if (!group) continue + + // Resolved from the link map, not from the joined row, so a user with two + // accounts is a member twice and a user with none is a member nowhere. + for (const steamId of steamIdsByUser.get(entry.userId) || []) { + addMember(group, steamId, { type: 'userMember', groupId: group.id, userId: entry.userId, shared: shared(group) }) } } + for (const entry of steamMembers) { + const group = groupById.get(entry.groupId) + if (!group) continue + addMember(group, entry.steamId, { type: 'steamMember', groupId: group.id, steamId: entry.steamId, shared: shared(group) }) + } + const permissionsBySteamId = new Map() const seenGrant = new Set() + const addGrant = (steamId, permission, source) => { + const row = { kind: 'grant', subject: steamId, object: permission } + addSource(row, source) + + const key = `${steamId}:${permission}` + if (seenGrant.has(key)) return + seenGrant.add(key) + + if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, []) + permissionsBySteamId.get(steamId).push(permission) + rows.push(row) + } + + // A grant held by a website user (D28), less its exceptions (D190). The + // exception's server is left out, and every other server keeps it. + const seenUserGrant = new Set() for (const row of grants) { if (!inScope(row.scope, serverId)) continue + if (seenUserGrant.has(row.id)) continue + seenUserGrant.add(row.id) + if (exceptions.has(`user:${row.id}`)) continue const permission = normaliseName(row.permission) + if (!permission) continue - // Managed whether or not it reaches anybody: the namespace is what makes a - // hand grant of this permission to somebody else show up as drift, and a - // grant whose holder has linked nothing would otherwise silently narrow it. - managed.add(permission) - - // **Resolved from the link map, not from the row.** `listGrants` joins the - // links and therefore repeats a grant once per linked account, which would - // give the right answer here by accident — until somebody changes that query - // and one of a person's two accounts quietly stops being granted. The map is - // the same source the members above use, and it says what it means. for (const steamId of steamIdsByUser.get(row.userId) || []) { - const key = `${steamId}:${permission}` - if (seenGrant.has(key)) continue - seenGrant.add(key) - - if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, []) - permissionsBySteamId.get(steamId).push(permission) - rows.push({ kind: 'grant', subject: steamId, object: permission }) + addGrant(steamId, permission, { type: 'userGrant', id: row.id, userId: row.userId, scope: row.scope }) } } + // A grant held by one Steam account (D188). + for (const row of steamGrants) { + if (!inScope(row.scope, serverId)) continue + if (exceptions.has(`steam:${row.id}`)) continue + + const permission = normaliseName(row.permission) + if (!permission) continue + + addGrant(row.steamId, permission, { type: 'steamGrant', id: row.id, steamId: row.steamId, scope: row.scope }) + } + // ── What events granted (phase 13b, D84) ────────────────────────────── // - // Unioned with the admin grants above through the same `seenGrant`, so a - // permission held both ways is ONE row in the game — and withdrawing either - // leaves the other standing, because the next build still finds it. - // - // An event grant reaches only the kit's server (D102), and like any grant it - // reaches every account the user has linked (D28). - // - // The CREDIT is different: one win is one extra use, on the account that took - // part, and only while that account is still linked to the user who won it. + // Unioned through the same `seenGrant`, so a permission held both ways is ONE + // row in the game. An event grant reaches only the kit's server (D102), and + // every account the user has linked (D28). The CREDIT is one extra use on the + // account that took part, while it is still linked to the winner. const credits = new Map() for (const row of runGrants) { @@ -420,38 +373,20 @@ function buildDesired(serverId, authored) { const permission = normaliseName(row.permission) if (permission) { - managed.add(permission) - - for (const steamId of linked) { - const key = `${steamId}:${permission}` - if (seenGrant.has(key)) continue - seenGrant.add(key) - - if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, []) - permissionsBySteamId.get(steamId).push(permission) - rows.push({ kind: 'grant', subject: steamId, object: permission }) - } + for (const steamId of linked) addGrant(steamId, permission, { type: 'runGrant', runId: row.runId, stepId: row.stepId }) } if (Number(row.credit) && linked.includes(row.steamId)) { - // A Steam id is digits, so the first bar is always the split; a kit name - // may contain one. const key = `${row.steamId}|${row.kit}` credits.set(key, (credits.get(key) || 0) + 1) } } // ── A group's BetterChat style (phase 17, D138) ─────────────────────── - // - // One ledger row per FIELD (`chat-field`, subject the group, object the - // field), carrying its value: the diff that retires a style is the same - // `pushed − desired` as everything else, and the value is what the next sync - // sends as `expect`. The value is not in the row's identity — a changed value - // is the same field pushed again, not a retirement. const chat = chatByGroup(authored.groupChat) - for (const group of scopedGroups) { - const fields = chat.get(group.name) + for (const group of onServer) { + const fields = chat.get(group.id) if (!fields) continue for (const field of Object.keys(fields).sort()) { @@ -467,79 +402,160 @@ function buildDesired(serverId, authored) { .sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit)) const payload = { - groups: scopedGroups.map((group) => ({ + groups: onServer.map((group) => ({ name: group.name, - title: group.title || group.name, - rank: group.rank, - permissions: permissionsByGroup.get(group.name), - members: membersByGroup.get(group.name), - // The values only; `permSync` adds what each one expects to find, which - // is per server and comes from the ledger. - ...(chat.has(group.name) ? { chat: chat.get(group.name) } : {}), + // Verbatim: an empty title is sent empty, not replaced by the name. + title: group.title == null ? '' : group.title, + rank: Number(group.rank) || 0, + parent: normaliseName(group.parent), + permissions: permissionsByGroup.get(group.id), + members: membersByGroup.get(group.id), + ...(chat.has(group.id) ? { chat: chat.get(group.id) } : {}), })), - grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ - steamId, - permissions, - })), - managed: [...managed].sort(), - // Always sent, even empty: to the plugin an absent field means "this site - // says nothing about credits", and an empty one means "nobody has any" — - // which is what a revert of the last reward must be able to say (D103). + grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ steamId, permissions })), + // Always sent, even empty (D103). credits: creditRows, } - // Credits are in the digest, so a new reward or a revert pushes, but they are - // NOT in `rows`: those are the pushed ledger's, and a use of a kit is not - // something in the permission store to retire. - // - // A style field's VALUE goes into the digest the same way, since it is not in - // the row's identity: a colour changed on the site must push. const hashed = [ - ...rows.map((row) => (row.kind === 'chat-field' ? { ...row, object: `${row.object}=${row.value}` } : row)), + ...rows, ...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })), ] - return { payload, rows, hash: hashRows(hashed) } + return { payload, rows, hash: hashRows(hashed), sources } +} + +/** Whether a group is on more than one server, or on every server. */ +function isShared(group, serverRows) { + if (group.allServers) return true + + const rows = serverRows.get(group.id) + if (!rows) return false + + let on = 0 + for (const included of rows.values()) if (included) on++ + return on > 1 } /** - * A digest of the desired set. - * - * Sorted before hashing, because the rows come out of several queries in an - * order nothing guarantees — an unsorted digest would differ between two reads - * of an unchanged set and push to every game server on every tick. + * A digest of the desired set. Sorted before hashing, and a row's VALUE is in + * it (a style field, a group's title, rank and parent): a change to one must push. */ function hashRows(rows) { const canonical = rows - .map((row) => `${row.kind}${row.subject}${row.object}`) + .map((row) => `${row.kind} ${row.subject} ${row.object}${row.value === undefined || row.value === null ? '' : `=${row.value}`}`) .sort() .join('\n') return crypto.createHash('sha256').update(canonical).digest('hex') } -/** A row's identity, for set arithmetic against what was pushed. */ -const rowKey = (row) => `${row.kind}${row.subject}${row.object}` - /** - * What this site put in a server and has since withdrawn. + * What this site put in a server and has since withdrawn: `pushed − desired`. * - * `pushed − desired`, and it is the one calculation that cannot be replaced by - * asking the game: 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 have - * opposite correct answers (D31). Only the pushed ledger tells them apart. + * Two things are never retired: a built-in group, which the framework keeps + * anyway; and a row in `hold` — a change made in the game that is waiting for a + * person's answer (the `adopt` policy, D161), which is neither the site's to + * push back nor its to remove yet. */ -function retirements(pushed, desiredRows) { +function retirements(pushed, desiredRows, hold = new Set()) { const desired = new Set(desiredRows.map(rowKey)) - return pushed.filter((row) => !desired.has(rowKey(row))) + return pushed.filter((row) => { + const key = rowKey(row) + if (desired.has(key) || hold.has(key)) return false + if (row.kind === 'group' && BUILTIN_GROUPS.has(row.subject)) return false + return true + }) +} + +/** + * ── What one person holds, as that person reads it ──────────────────────── + * + * Unchanged in intent by the rebuild: scope arithmetic answered here, `live` + * per server from the pushed ledger, and no reason given for "waiting". A group + * now reaches the servers it is on (D189) rather than a scope. + */ +async function forPlayer(userId, steamIds, serverRows) { + const [groups, groupServers, groupPermissions, grants, steamGrants, exceptions, pushed] = await Promise.all([ + db.listGroupsForUser(userId), + db.listGroupServers(), + db.listGroupPermissions(), + db.listGrants({ userId }), + Promise.all(steamIds.map((steamId) => db.listSteamGrants({ steamId }))).then((lists) => lists.flat()), + db.listExceptions(), + db.listPushedForSteamIds(steamIds), + ]) + + const servers = serverRows.map((row) => ({ id: row.id, name: row.name || row.id })) + const serverIds = servers.map((s) => s.id) + const byGroup = serversByGroup(groupServers) + const excepted = new Set(exceptions.map((e) => `${e.holder}:${e.grantId}:${e.serverId}`)) + + const live = new Map() + + for (const row of pushed) { + const key = `${row.kind}:${normaliseName(row.object)}` + if (!live.has(key)) live.set(key, new Set()) + live.get(key).add(row.serverId) + } + + const reachOf = (ids, key) => { + const landed = live.get(key) || new Set() + return servers.filter((s) => ids.includes(s.id)).map((s) => ({ ...s, live: landed.has(s.id) })) + } + + const grantReach = (grant, holder) => + serverIds.filter((id) => inScope(grant.scope, id) && !excepted.has(`${holder}:${grant.id}:${id}`)) + + const permissionsByGroup = new Map() + + for (const row of groupPermissions) { + if (!permissionsByGroup.has(row.groupId)) permissionsByGroup.set(row.groupId, []) + permissionsByGroup.get(row.groupId).push(normaliseName(row.permission)) + } + + const shapeGrant = (grant, holder) => ({ + permission: grant.permission, + scope: grant.scope, + source: grant.source, + note: grant.note || null, + since: grant.grantedAt, + reach: reachOf(grantReach(grant, holder), `grant:${normaliseName(grant.permission)}`), + }) + + return { + groups: groups.map((group) => { + const reach = groupReach(group, byGroup, serverIds) + return { + name: group.name, + title: group.title || group.name, + // Kept for older clients: `*` for a group on every server, else the + // servers it is on. + scope: group.allServers ? FLEET : reach.join(','), + since: group.addedAt, + permissions: (permissionsByGroup.get(group.id) || []).sort(), + reach: reachOf(reach, `member:${normaliseName(group.name)}`), + } + }), + grants: [ + ...collapseGrants(grants).map((grant) => shapeGrant(grant, 'user')), + ...steamGrants.map((grant) => shapeGrant(grant, 'steam')), + ].sort((a, b) => a.permission.localeCompare(b.permission)), + } } module.exports = { FLEET, + BUILTIN_GROUPS, normaliseName, inScope, - overview, + groupValue, + serversByGroup, + groupCovers, + groupReach, + groupsOn, + isShared, forPlayer, readAuthored, buildDesired, diff --git a/server/model/permissions/permissions.view.js b/server/model/permissions/permissions.view.js new file mode 100644 index 0000000..9c9bf9b --- /dev/null +++ b/server/model/permissions/permissions.view.js @@ -0,0 +1,198 @@ +// ── What the permission screen reads (D162, D163, U-1) ──────────────────── +// +// The screen follows uMod PermissionsManager's flow — a server, then players ⇄ +// groups, then a subject, then a plugin's permissions with Granted / Revoked — +// and every toggle on it carries its own state on that server. This file +// assembles what that needs in one read per request: +// +// • plugins grouped by the plugin that REGISTERED each permission (§0.1), +// never by the name's prefix — `zonemanager.ignoreflag.nokits` is +// ZoneManager's. A name no plugin owns (Carbon's built-in modules) is +// grouped by its prefix, and says so. +// • the groups on the server (D189), with where else each one is. +// • every subject holding anything there, named by linked account and in-game +// name, or Steam id when there is neither (D163). +// • the raw facts the toggle states are computed from: what the site wants and +// why (its sources), what has landed (the pushed ledger), and what the last +// report said did not. + +const db = require('./permissions.db') +const model = require('./permissions.model') +const servers = require('../servers/servers.model') + +/** The servers, their policy and sync state, and every row waiting for a person. */ +async function overview() { + const [serverRows, sync, policies, drift] = await Promise.all([ + servers.listForAdmin(), + db.listSync(), + db.listPolicies(), + db.listDrift(), + ]) + + const syncById = new Map(sync.map((row) => [row.serverId, model.shapeSync(row)])) + const policyById = new Map(policies.map((row) => [row.serverId, row.policy])) + + return { + servers: serverRows.map((row) => ({ + id: row.id, + name: row.name || row.id, + policy: policyById.get(row.id) || 'auto-adopt', + sync: syncById.get(row.id) || null, + })), + drift: drift.map((row) => ({ ...row, detail: row.detail === undefined ? null : row.detail })), + } +} + +/** A permission's plugin button: its registering plugin, or its prefix. */ +function pluginOf(row) { + if (row.owner) return { key: `plugin:${row.owner}`, label: row.owner, registered: true } + const prefix = row.permission.includes('.') ? row.permission.slice(0, row.permission.indexOf('.')) : row.permission + return { key: `prefix:${prefix}`, label: prefix, registered: false } +} + +/** + * Everything the screen shows for one server. Null for a server the site does + * not have. + */ +async function serverView(serverId) { + const serverRows = await servers.listForAdmin() + const server = serverRows.find((row) => row.id === serverId) + if (!server) return null + + const [authored, catalogue, pushed, sync, policies, drift, links] = await Promise.all([ + model.readAuthored(), + db.listCatalogue(), + db.listPushed(serverId), + db.listSync(), + db.listPolicies(), + db.listDrift(), + db.listLinksNamed(), + ]) + + const serverIds = serverRows.map((row) => row.id) + const desired = model.buildDesired(serverId, authored) + const byGroup = model.serversByGroup(authored.groupServers) + const syncRow = sync.find((row) => row.serverId === serverId) + const linkBySteam = new Map(links.map((row) => [row.steamId, row])) + + // ── Plugins, by who registered each permission ── + const plugins = new Map() + + for (const row of catalogue.filter((r) => r.serverId === serverId)) { + const plugin = pluginOf(row) + if (!plugins.has(plugin.key)) plugins.set(plugin.key, { ...plugin, permissions: [] }) + plugins.get(plugin.key).permissions.push(row.permission) + } + + // ── Groups on this server ── + const chat = model.chatByGroup(authored.groupChat) + const onServer = model.groupsOn(serverId, authored) + const permissionsByGroup = new Map() + for (const row of authored.groupPermissions) { + if (!permissionsByGroup.has(row.groupId)) permissionsByGroup.set(row.groupId, []) + permissionsByGroup.get(row.groupId).push(model.normaliseName(row.permission)) + } + + const members = new Map() + for (const row of authored.members) { + if (!members.has(row.groupId)) members.set(row.groupId, new Map()) + const byUser = members.get(row.groupId) + if (!byUser.has(row.userId)) byUser.set(row.userId, { userId: row.userId, username: row.username, steamIds: [] }) + if (row.steamId) byUser.get(row.userId).steamIds.push(row.steamId) + } + + const groups = onServer.map((group) => { + const reach = model.groupReach(group, byGroup, serverIds) + return { + id: group.id, + name: group.name, + title: group.title, + rank: group.rank, + parent: group.parent, + source: group.source, + builtin: model.BUILTIN_GROUPS.has(group.name), + allServers: group.allServers, + servers: reach, + shared: model.isShared(group, byGroup), + permissions: (permissionsByGroup.get(group.id) || []).sort(), + members: [...((members.get(group.id) || new Map()).values())], + steamMembers: authored.steamMembers.filter((m) => m.groupId === group.id).map((m) => m.steamId), + chat: chat.get(group.id) || null, + } + }) + + // ── Subjects: every Steam id holding anything here, by the desired set ── + const subjects = new Map() + const subject = (steamId) => { + if (!subjects.has(steamId)) subjects.set(steamId, { steamId, grants: [], groups: [] }) + return subjects.get(steamId) + } + + for (const row of desired.rows) { + if (row.kind === 'grant') { + subject(row.subject).grants.push({ permission: row.object, sources: desired.sources.get(model.rowKey(row)) || [] }) + } else if (row.kind === 'member') { + subject(row.subject).groups.push(row.object) + } + } + + // A grant kept off this server by an exception still belongs on the screen: + // it is "on every server except this one", and the toggle can take it back. + const exceptions = authored.exceptions.filter((e) => e.serverId === serverId) + const grantById = new Map(authored.grants.map((g) => [`user:${g.id}`, g])) + for (const g of authored.steamGrants) grantById.set(`steam:${g.id}`, g) + + const excepted = [] + for (const e of exceptions) { + const grant = grantById.get(`${e.holder}:${e.grantId}`) + if (!grant) continue + const steamIds = e.holder === 'steam' ? [grant.steamId] : (authored.steamIdsByUser.get(grant.userId) || []) + for (const steamId of steamIds) { + subject(steamId) + excepted.push({ id: e.id, steamId, permission: model.normaliseName(grant.permission), holder: e.holder, grantId: e.grantId }) + } + } + + const steamIds = [...subjects.keys()] + const names = new Map((await db.namesFor(steamIds)).map((row) => [row.steamId, row.name])) + + const players = [...subjects.values()] + .map((s) => { + const link = linkBySteam.get(s.steamId) + return { + ...s, + name: names.get(s.steamId) || (link && link.playerName) || null, + account: link ? { userId: link.userId, username: link.username } : null, + } + }) + .sort((a, b) => (a.name || a.steamId).localeCompare(b.name || b.steamId)) + + const report = syncRow ? model.shapeSync(syncRow).report : null + const policy = (policies.find((row) => row.serverId === serverId) || {}).policy || 'auto-adopt' + + return { + server: { id: server.id, name: server.name || server.id }, + servers: serverRows.map((row) => ({ id: row.id, name: row.name || row.id })), + policy, + sync: syncRow ? model.shapeSync(syncRow) : null, + plugins: [...plugins.values()].sort((a, b) => Number(b.registered) - Number(a.registered) || a.label.localeCompare(b.label)), + groups, + players, + excepted, + // What has landed on this server: `grant steamId permission`, `member steamId + // group`, `group-permission group permission`. + landed: pushed + .filter((row) => row.kind === 'grant' || row.kind === 'member' || row.kind === 'group-permission') + .map(model.rowKey), + report: report + ? { + unresolved: report.unresolved || [], + pending: report.pending || [], + notLanded: report.notLanded || [], + } + : null, + drift: drift.filter((row) => row.serverId === serverId), + } +} + +module.exports = { overview, serverView, pluginOf } diff --git a/server/model/permissions/reconcile.js b/server/model/permissions/reconcile.js new file mode 100644 index 0000000..d617613 --- /dev/null +++ b/server/model/permissions/reconcile.js @@ -0,0 +1,277 @@ +// ── Three sets, and what a change made in the game becomes ──────────────── +// +// `rust_perm_pushed`'s own comment has always named three sets — what is in the +// game, what this site put there, and what the site wants there. Until protocol +// 13 the plugin could only compute the first for the names the site claimed. +// The inventory (PLAN_REDESIGNS §1.2) gives the site all three, so the whole of +// "what happened, and what do we do about it" is decided here: +// +// in the game pushed desired means +// yes no no ADDED in the game +// no yes yes REMOVED in the game +// yes yes yes a group whose title, rank or parent the +// game holds differently from what was +// pushed: CHANGED in the game +// yes no yes landed by some other hand: recorded +// +// (desired − pushed is the ordinary push, and pushed − desired the ordinary +// retirement; neither is this file's business.) +// +// Then the server's policy (D161) says what each change becomes: the site's own +// (`auto-adopt`, the default), a question for a person (`adopt`), or undone +// (`revoke`). The first inventory of a server imports what it finds whatever the +// policy (D198). +// +// **Two things are never judged, and both are how a site would otherwise throw +// away its own grants.** A permission the server has not REGISTERED right now — +// a plugin unloaded for a minute — is missing from the inventory because the +// plugin that owns it is, not because anybody revoked it. And a group permission +// an event lease holds is the lease's until it ends. +// +// Pure: rows in, a plan out. `permissions.apply.js` carries the plan out. + +const { rowKey, groupValue, normaliseName, BUILTIN_GROUPS } = require('./permissions.model') + +const JUDGED = new Set(['group', 'group-permission', 'member', 'grant']) + +/** The inventory in the pushed ledger's shape. */ +function presentRows(inventory) { + const rows = [] + + for (const group of (inventory && inventory.groups) || []) { + const name = normaliseName(group.name) + if (!name) continue + + rows.push({ kind: 'group', subject: name, object: '', value: groupValue(group.title, group.rank, group.parent) }) + + for (const permission of group.permissions || []) { + rows.push({ kind: 'group-permission', subject: name, object: normaliseName(permission) }) + } + } + + for (const user of (inventory && inventory.users) || []) { + for (const permission of user.permissions || []) { + rows.push({ kind: 'grant', subject: String(user.steamId), object: normaliseName(permission) }) + } + + for (const group of user.groups || []) { + rows.push({ kind: 'member', subject: String(user.steamId), object: normaliseName(group) }) + } + } + + return rows +} + +/** The group attributes a `group` row's value carries. */ +function parseGroupValue(value) { + try { + const [title, rank, parent] = JSON.parse(value) + return { title: String(title == null ? '' : title), rank: Number(rank) || 0, parent: normaliseName(parent) } + } catch { + return null + } +} + +/** + * Sort every row into added, removed, changed or landed. + * + * `registered` is the set of names the server registers right now; `leased` the + * lease-held pairs, as `group-permission` rows. + */ +function classify({ present, pushed, desired, registered, leased = [] }) { + const leasedKeys = new Set(leased.map((row) => rowKey({ kind: 'group-permission', subject: normaliseName(row.subject), object: normaliseName(row.object) }))) + + const judged = (row) => { + if (!JUDGED.has(row.kind)) return false + if ((row.kind === 'grant' || row.kind === 'group-permission') && !registered.has(row.object)) return false + // `default` holds every connected player by the framework's rule (§1.2). + if (row.kind === 'member' && row.object === 'default') return false + if (leasedKeys.has(rowKey(row))) return false + return true + } + + const index = (rows) => new Map(rows.filter(judged).map((row) => [rowKey(row), row])) + const P = index(present) + const U = index(pushed) + const D = index(desired) + + const added = [] + const removed = [] + const changed = [] + const landed = [] + + for (const [key, row] of P) { + if (!U.has(key) && !D.has(key)) added.push(row) + else if (!U.has(key) && D.has(key)) landed.push({ ...D.get(key) }) + else if (row.kind === 'group' && U.has(key) && D.has(key)) { + const pushedValue = U.get(key).value + // A value the site pushed, that the site still wants, and that the game no + // longer holds: somebody changed the group in the game. A ledger row with + // no value (before protocol 13) cannot say, and the site's value is pushed. + if (pushedValue && row.value !== pushedValue && D.get(key).value === pushedValue) changed.push(row) + } + } + + for (const [key, row] of U) { + if (!P.has(key) && D.has(key)) removed.push(row) + } + + // A group removed in the game takes its permissions and members with it; they + // are the group's removal, not changes of their own. + const goneGroups = new Set(removed.filter((row) => row.kind === 'group').map((row) => row.subject)) + const keep = (row) => + row.kind === 'group' || !goneGroups.has(row.kind === 'member' ? row.object : row.subject) + + return { added, removed: removed.filter(keep), changed, landed } +} + +/** + * What the changes become under one server's policy. + * + * Returns: + * `ops` for `permissions.apply.js`, in the order they must run — + * groups before what goes in them + * `drift` "needs a person" rows (D161's `adopt`, and what no policy can + * settle alone) + * `revocations` what the `revoke` policy undoes at this sync + * `hold` row keys this sync must neither push back nor retire nor + * record, because a person has not answered yet + */ +function plan({ classes, policy, importing, sources }) { + const ops = [] + const drift = [] + const revocations = [] + const hold = new Set() + const source = importing ? 'imported' : 'adopted' + + const adoptOp = (row) => { + if (row.kind === 'group') { + const attrs = parseGroupValue(row.value) || { title: '', rank: 0, parent: '' } + return { op: 'adoptGroup', name: row.subject, ...attrs, source } + } + if (row.kind === 'group-permission') return { op: 'adoptGroupPermission', group: row.subject, permission: row.object, source } + if (row.kind === 'member') return { op: 'adoptMember', group: row.object, steamId: row.subject, source } + return { op: 'adoptGrant', steamId: row.subject, permission: row.object, source } + } + + const dropOp = (row) => { + if (row.kind === 'group') return { op: 'dropGroup', group: row.subject } + if (row.kind === 'group-permission') return { op: 'dropGroupPermission', group: row.subject, permission: row.object } + if (row.kind === 'member') { + return { op: 'dropMember', group: row.object, steamId: row.subject, sources: sources.get(rowKey(row)) || [] } + } + return { op: 'dropGrant', steamId: row.subject, permission: row.object, sources: sources.get(rowKey(row)) || [] } + } + + const order = { adoptGroup: 0, setGroupAttrs: 1, adoptGroupPermission: 2, adoptMember: 2, adoptGrant: 2, dropGroupPermission: 3, dropMember: 3, dropGrant: 3, dropGroup: 4 } + + // ── The first inventory: everything present becomes the site's (D160, D198) ── + // + // Additions and changed attributes are imported whatever the policy. A removal + // at import is something this site pushed that the game has since lost — it is + // pushed back, as it always was, rather than deleted on the strength of a + // snapshot taken the moment the site first looked. + if (importing) { + for (const row of classes.added) ops.push(adoptOp(row)) + for (const row of classes.changed) ops.push({ op: 'setGroupAttrs', group: row.subject, ...parseGroupValue(row.value) }) + return { ops: ops.sort((a, b) => order[a.op] - order[b.op]), drift, revocations, hold } + } + + if (policy === 'revoke') { + // The site's set wins. An addition is removed at this sync; a removal or a + // changed group is simply pushed back by the desired set. + for (const row of classes.added) { + // A built-in group cannot be removed; one made in the game under `revoke` + // is — but `default` and `admin` are never "added", the import took them. + if (row.kind === 'group' && BUILTIN_GROUPS.has(row.subject)) continue + revocations.push({ kind: row.kind, subject: row.subject, object: row.object }) + } + return { ops, drift, revocations, hold } + } + + if (policy === 'adopt') { + // Every change waits for a person, and until then the game is left as it is. + // A group made in the game carries its title, rank and parent, so adopting it + // later keeps them. + for (const row of classes.added) { + drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'added', ...(row.kind === 'group' ? { detail: row.value } : {}) }) + } + + for (const row of classes.removed) { + drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'removed' }) + hold.add(rowKey(row)) + } + + for (const row of classes.changed) { + drift.push({ kind: 'group', subject: row.subject, object: '', direction: 'changed', detail: row.value }) + hold.add(rowKey(row)) + } + + return { ops, drift, revocations, hold } + } + + // ── auto-adopt, the default (D161, D190) ── + for (const row of classes.added) ops.push(adoptOp(row)) + + for (const row of classes.removed) { + const op = dropOp(row) + + // A grant only an event gave cannot be adopted away: the event owns it, and + // its revert will withdraw it. It is pushed back, and a person is told. + if (op.op === 'dropGrant' && op.sources.length && op.sources.every((s) => s.type === 'runGrant')) { + drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'removed', detail: 'event' }) + continue + } + + ops.push(op) + } + + for (const row of classes.changed) ops.push({ op: 'setGroupAttrs', group: row.subject, ...parseGroupValue(row.value) }) + + return { ops: ops.sort((a, b) => order[a.op] - order[b.op]), drift, revocations, hold } +} + +/** + * The desired set with the held rows taken out: not in the payload, so the + * plugin does not put them back, and not in `rows`, so the report does not + * record them. A held group keeps its place — only its title, rank and parent + * are left off, which the plugin reads as "leave them". + */ +function withHold(desired, hold) { + if (!hold || !hold.size) return desired + + const heldGroups = new Set() + const payload = { ...desired.payload } + + payload.groups = desired.payload.groups.map((group) => { + const key = rowKey({ kind: 'group', subject: group.name, object: '' }) + const out = { ...group } + + if (hold.has(key)) { + heldGroups.add(group.name) + delete out.title + delete out.rank + delete out.parent + } + + out.permissions = (group.permissions || []).filter((p) => !hold.has(rowKey({ kind: 'group-permission', subject: group.name, object: p }))) + out.members = (group.members || []).filter((s) => !hold.has(rowKey({ kind: 'member', subject: s, object: group.name }))) + return out + }) + + payload.grants = desired.payload.grants + .map((grant) => ({ + ...grant, + permissions: grant.permissions.filter((p) => !hold.has(rowKey({ kind: 'grant', subject: grant.steamId, object: p }))), + })) + .filter((grant) => grant.permissions.length) + + return { + ...desired, + payload, + rows: desired.rows.filter((row) => !hold.has(rowKey(row))), + heldGroups, + } +} + +module.exports = { presentRows, parseGroupValue, classify, plan, withHold } diff --git a/server/model/permissions/voice.js b/server/model/permissions/voice.js index ed6d224..9ebe894 100644 --- a/server/model/permissions/voice.js +++ b/server/model/permissions/voice.js @@ -18,53 +18,89 @@ const model = require('./permissions.model') const VOICE_KEY = 'announce.voice' -/** The chosen group's name, or '' for plain chat. */ +/** + * The chosen group's id as a string, or '' for plain chat. + * + * Since groups became per server (D189) a name can belong to several groups, so + * the setting holds a group's id. A setting written before that holds a NAME, + * and is read as the first styled group of that name until somebody chooses again. + */ async function chosen() { return (await settingsDb.getSetting(VOICE_KEY)) || '' } +/** The chosen group's style fields, or null. */ +async function chosenFields() { + const value = await chosen() + if (!value) return { value, fields: null } + + if (/^\d+$/.test(value)) return { value, fields: await db.getGroupChat(Number(value)) } + + const groups = await db.listGroups() + for (const group of groups.filter((g) => g.name === model.normaliseName(value))) { + // eslint-disable-next-line no-await-in-loop + const fields = await db.getGroupChat(group.id) + if (fields) return { value: String(group.id), fields } + } + + return { value, fields: null } +} + /** * The format a line is said in right now, or null for plain chat — which is * also the answer when the chosen group has since lost its style or gone. */ async function currentFormat() { - const group = await chosen() - if (!group) return null - - const fields = await db.getGroupChat(group) + const { fields } = await chosenFields() return fields ? chatStyle.voiceFormat(fields) : null } /** The setting, and every group that could be a voice, for the admin page. */ async function describe() { - const [voice, rows] = await Promise.all([chosen(), db.listGroupChat()]) + const [{ value }, rows, groups, groupServers] = await Promise.all([ + chosenFields(), + db.listGroupChat(), + db.listGroups(), + db.listGroupServers(), + ]) + const byId = new Map(groups.map((g) => [g.id, g])) const options = [] - for (const [group, fields] of model.chatByGroup(rows)) { - const format = chatStyle.voiceFormat(fields) - if (format) options.push({ group, title: fields.Title || group, format }) + // Which servers each group is on, so two groups of one name can be told apart. + const where = (group) => { + if (group.allServers) return 'all servers' + const ids = groupServers.filter((r) => r.groupId === group.id && r.included).map((r) => r.serverId) + return ids.length ? ids.join(', ') : 'no server' } - return { voice, options: options.sort((a, b) => a.group.localeCompare(b.group)) } -} - -/** - * Choose the voice. A group is accepted only when it has a style a voice can be - * made from; '' goes back to plain chat. Resolves `{ ok }` or - * `{ ok: false, message }`. - */ -async function choose(group, userId = null) { - const name = model.normaliseName(group) - - if (name) { - const fields = await db.getGroupChat(name) - if (!fields || !chatStyle.voiceFormat(fields)) { - return { ok: false, message: `The group "${name}" has no chat style, so it cannot be a voice. Give it one under Permissions first.` } + for (const [groupId, fields] of model.chatByGroup(rows)) { + const format = chatStyle.voiceFormat(fields) + const group = byId.get(groupId) + if (format && group) { + options.push({ group: String(groupId), name: group.name, where: where(group), title: fields.Title || group.name, format }) } } - await settingsDb.setSetting(VOICE_KEY, name, userId) - return { ok: true, voice: name } + return { voice: value, options: options.sort((a, b) => a.name.localeCompare(b.name) || a.group.localeCompare(b.group)) } +} + +/** + * Choose the voice by group id. A group is accepted only when it has a style a + * voice can be made from; '' goes back to plain chat. Resolves `{ ok }` or + * `{ ok: false, message }`. + */ +async function choose(group, userId = null) { + const value = String(group || '').trim() + + if (value) { + const fields = /^\d+$/.test(value) ? await db.getGroupChat(Number(value)) : null + if (!fields || !chatStyle.voiceFormat(fields)) { + return { ok: false, message: 'That group has no chat style, so it cannot be a voice. Give it one under Permissions first.' } + } + } + + await settingsDb.setSetting(VOICE_KEY, value, userId) + return { ok: true, voice: value } } module.exports = { VOICE_KEY, chosen, currentFormat, describe, choose } diff --git a/server/permSync.js b/server/permSync.js index b64270b..6b008cb 100644 --- a/server/permSync.js +++ b/server/permSync.js @@ -1,22 +1,23 @@ -// ── Keeping a game's permission store equal to what the site authored ───── +// ── Keeping a game's permission store and the site's record of it equal ─── // -// R2's whole mechanism, and it is chapter 4's board pointed the other way: the -// site is the single producer of a set, it re-sends the whole thing rather than -// a stream of edits, and the receiver reconciles. What is new is the direction — -// the module telling the game what the site knows, where every earlier phase -// asked the game what it knew. +// R2's whole mechanism, rebuilt in protocol 13 (PLAN_REDESIGNS §1) around one +// fact: **the site owns every permission and group on the server** — those that +// were there before it, those an admin makes, and those changed in the game +// (D160). So a sync is three steps, not one: // -// ── One verb (D32) ──────────────────────────────────────────────────────── +// 1. READ the whole store (`perm.inventory`), in pages, with the plugin that +// registered each permission. +// 2. RECONCILE it against the site's record and what the site last pushed +// (`reconcile.js`): a change made in the game becomes the site's own, a +// question for a person, or undone — the server's policy says which (D161). +// The first read of a server imports everything it finds (D198). +// 3. PUSH the whole desired set (`perm.sync`, D32) with what the site has +// withdrawn, and record what the plugin says landed. // -// A sync sends the whole desired set and the plugin diffs it against the live -// store. The website never holds a copy of the game's permissions, which is the -// point: a second source of truth is stale the moment it lands, and the store is -// the bigger of the two sets. -// -// The delta the site DOES compute is the one the game cannot: what this site put -// there and has since withdrawn (`retirements`). A name in the store that is not -// in the desired set is either that, or a hand edit — and only the pushed ledger -// can tell them apart (D31). +// Step 2 writes to the site's own tables, and a change in one game affects that +// server only (D190) — which can split a group shared with other servers. So it +// runs under ONE lock for the whole fleet: two servers' reconciles never split +// the same shared group at once. Steps 1 and 3 run in parallel across servers. // // ── When it runs ────────────────────────────────────────────────────────── // @@ -25,25 +26,26 @@ // yes. A sync therefore happens when: // // • an operator changed something (the dirty flag, and the digest behind it) -// • the game restarted or wiped (a new boot id or wipe id: the store may have -// been emptied, and R2's promise is that a wipe is not a data-loss event) +// • the game restarted or wiped (a new boot id or wipe id) // • a permission hook fired in the game that we did not cause (`ingest.js` -// marks the server dirty; the authoritative answer is this sync's report) -// • the audit interval elapsed — the backstop that finds drift on a quiet -// server nobody has touched +// marks the server dirty; the inventory then says what changed) +// • the audit interval elapsed — the backstop that finds a hand edit on a quiet +// server // • the last attempt failed, after a backoff // // ── What it never does ──────────────────────────────────────────────────── // -// It does not remove a grant it did not make (D31), it does not invent a -// permission the server has not registered (D33), and it does not treat a -// silent sidecar as a reason to forget anything. A server that is unreachable -// keeps its retirements and its revocations until it comes back. +// It never judges a permission the server has not registered right now (a +// plugin unloaded for a minute is not a revocation), never touches what an event +// lease holds, never invents a permission the server has not registered (D33), +// and never treats a silent sidecar as a reason to forget anything. const core = require('./core') +const apply = require('./model/permissions/permissions.apply') const db = require('./model/permissions/permissions.db') const model = require('./model/permissions/permissions.model') +const reconcile = require('./model/permissions/reconcile') const servers = require('./model/servers/servers.model') const serversDb = require('./model/servers/servers.db') const sidecar = require('./sidecarClient') @@ -54,11 +56,8 @@ const log = core.logger('permissions') const TICK_MS = 30 * 1000 /** - * How long a server may go without a full reconciliation, however quiet it is. - * - * The digest comparison is what keeps the loop cheap, and on its own it would - * also mean a server whose store somebody edited by hand is never asked about - * again. This is the interval at which the question gets asked anyway. + * How long a server may go without a full reconciliation, however quiet it is: + * the interval at which a hand edit on a quiet server is found anyway. */ const AUDIT_MS = 15 * 60 * 1000 @@ -66,16 +65,26 @@ const AUDIT_MS = 15 * 60 * 1000 const FAIL_BACKOFF_MS = 2 * 60 * 1000 /** - * The most rows one sync may carry. - * - * Below the sidecar's line cap and below the plugin's operation ceiling, so the - * refusal happens here — where it can name the server and reach an operator — - * rather than as a `413` or a `too-large` from two processes away. + * The most rows one sync may carry. Below the sidecar's line cap and the + * plugin's operation ceiling, so the refusal happens here, where it can name the + * server and reach an operator. */ const MAX_ROWS = 15000 +/** The policies a server may have (D161). */ +const POLICIES = ['auto-adopt', 'adopt', 'revoke'] + let timer = null +/** The fleet-wide lock the reconcile step runs under (see the header). */ +let lock = Promise.resolve() + +function withLock(fn) { + const run = lock.then(fn, fn) + lock = run.catch(() => {}) + return run +} + function start() { if (timer) return @@ -96,25 +105,26 @@ function stop() { /** * One pass over every enabled server. * - * The authored set is read ONCE and handed to each server's build: six servers - * are six different answers derived from the same four tables, and re-reading - * them per server is six times the queries for identical rows. + * The authored set is read once here, for the cheap "does anything need doing" + * digest. The reconcile re-reads it under the lock, because another server's + * reconcile may have changed it in between. */ async function tick({ force = null } = {}) { await db.ensureSyncRows() - const [rows, state, sync, authored] = await Promise.all([ + const [rows, state, sync, authored, policyRows] = await Promise.all([ servers.listForPolling(), serversDb.listState(), db.listSync(), model.readAuthored(), + db.listPolicies(), ]) const syncById = new Map(sync.map((row) => [row.serverId, row])) const stateById = new Map(state.map((row) => [row.serverId, row])) + const policies = new Map(policyRows.map((row) => [row.serverId, row.policy])) - // `allSettled`, for the same reason the board poll uses it: one unreachable - // host must not stop the other five being reconciled. + // `allSettled`: one unreachable host must not stop the others. await Promise.allSettled( rows .filter((server) => force === null || force === server.id) @@ -124,6 +134,7 @@ async function tick({ force = null } = {}) { sync: syncById.get(server.id) || null, state: stateById.get(server.id) || null, force: force !== null, + policy: policies.get(server.id) || 'auto-adopt', }), ), ) @@ -179,18 +190,99 @@ function age(value) { return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER } -async function syncOne(server, { authored, sync, state, force }) { - const desired = model.buildDesired(server.id, authored) - const reason = reasonToSync({ desiredHash: desired.hash, sync, state, force }) +/** Record a failed attempt, with the reason on the row and in the log (F7). */ +async function failed(server, { reason, error, desiredHash, sync, bootId, wipeId }) { + log.warn('permission sync failed', { server: server.id, reason, error }) + await db.putSyncResult(server.id, { + state: 'failed', + desiredHash, + syncedHash: sync ? sync.syncedHash : null, + bootId, + wipeId, + report: null, + error: String(error).slice(0, 191), + }) +} + +/** + * Read, reconcile and push one server. Returns what happened, for the log and + * the tests: null (nothing to do), 'ok', or why it stopped. + */ +async function syncOne(server, { authored, sync, state, force, policy = 'auto-adopt' }) { + const first = model.buildDesired(server.id, authored) + const reason = reasonToSync({ desiredHash: first.hash, sync, state, force }) if (!reason) return null - const [pushed, revocations] = await Promise.all([ - db.listPushed(server.id), - db.listRevocations(server.id), - ]) + const bootId = state && state.bootId ? state.bootId : null + const wipeId = state && state.wipeId ? state.wipeId : null - const retirements = model.retirements(pushed, desired.rows) + // ── 1. Read the whole store ──────────────────────────────────────────── + const read = await sidecar.readInventory(server) + + if (!read.ok) { + await failed(server, { reason, error: read.error, desiredHash: first.hash, sync, bootId, wipeId }) + return 'inventory' + } + + const { inventory } = read + const registered = new Set(inventory.permissions.map((row) => model.normaliseName(row.name)).filter(Boolean)) + + // The option source, from the same read: what this server registers, and which + // plugin registered each name (PLAN_REDESIGNS §0.1). + await db.putCatalogue( + server.id, + inventory.permissions + .map((row) => ({ permission: model.normaliseName(row.name), owner: row.owner || null })) + .filter((row) => row.permission), + ) + + const importing = !(sync && sync.importedAt) + + // ── 2. Reconcile, under the fleet lock ───────────────────────────────── + const settled = await withLock(async () => { + let current = await model.readAuthored() + let desired = model.buildDesired(server.id, current) + const pushed = await db.listPushed(server.id) + + const classes = reconcile.classify({ + present: reconcile.presentRows(inventory), + pushed, + desired: desired.rows, + registered, + leased: inventory.leased, + }) + + const planned = reconcile.plan({ classes, policy, importing, sources: desired.sources }) + + if (planned.ops.length) { + const done = await apply.applyOps(server.id, planned.ops, log) + log.info(importing ? 'permissions imported from the game' : 'changes made in the game adopted', { + server: server.id, + policy, + ops: done.length, + }) + + current = await model.readAuthored() + desired = model.buildDesired(server.id, current) + } + + for (const row of planned.revocations) { + await db.queueRevocation({ serverId: server.id, ...row, requestedBy: null }) + } + + return { desired, planned, classes } + }) + + const { desired, planned } = settled + const held = reconcile.withHold(desired, planned.hold) + + // ── 3. Push ──────────────────────────────────────────────────────────── + const [pushed, revocations] = await Promise.all([db.listPushed(server.id), db.listRevocations(server.id)]) + + // Retired against the WHOLE desired set: a held row is still wanted, it is + // only not being pushed back while a person decides. + const retirements = model.retirements(pushed, desired.rows, planned.hold) const { styleRetired, sent: styleRetire } = styleRetirements(retirements) const retire = [ ...retirements @@ -200,84 +292,68 @@ async function syncOne(server, { authored, sync, state, force }) { ...revocations.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })), ] - const bootId = state && state.bootId ? state.bootId : null - const wipeId = state && state.wipeId ? state.wipeId : null - - if (desired.rows.length + retire.length > MAX_ROWS) { - // Refused here rather than sent: the sidecar would answer `413` and the - // plugin would answer `too-large`, and neither of those messages reaches the - // person who has to make the set smaller. - const error = `the permission set is too large to push (${desired.rows.length + retire.length} rows, limit ${MAX_ROWS})` - log.error('permission sync refused', { server: server.id, rows: desired.rows.length }) - await db.putSyncResult(server.id, { - state: 'failed', + if (held.rows.length + retire.length > MAX_ROWS) { + await failed(server, { + reason, + error: `the permission set is too large to push (${held.rows.length + retire.length} rows, limit ${MAX_ROWS})`, desiredHash: desired.hash, - syncedHash: sync ? sync.syncedHash : null, + sync, bootId, wipeId, - report: null, - error, }) - return 'too-large' } log.info('syncing permissions', { server: server.id, reason, - rows: desired.rows.length, + policy, + importing, + rows: held.rows.length, retire: retire.length, + held: planned.hold.size, }) const result = await sidecar.permSync(server, { setId: desired.hash, - groups: withExpect(desired.payload.groups, pushed), - grants: desired.payload.grants, - managed: desired.payload.managed, - credits: desired.payload.credits, + groups: withExpect(held.payload.groups, pushed), + grants: held.payload.grants, + credits: held.payload.credits, retire, }) if (!result.ok) { - // Said in the log as well as on the row (F7): the first walk's restart sync - // timed out with nothing in the log at all, while the titles push that failed - // beside it did log. - log.warn('permission sync failed', { server: server.id, reason, status: result.status }) - await db.putSyncResult(server.id, { - state: 'failed', - desiredHash: desired.hash, - syncedHash: sync ? sync.syncedHash : null, - bootId, - wipeId, - report: null, - error: result.status, - }) - + await failed(server, { reason, error: result.status, desiredHash: desired.hash, sync, bootId, wipeId }) return result.status } const report = result.data || {} - // The plugin refuses a whole sync with `perm.error` — `busy` while an earlier - // one is still draining, `too-large` past its own ceiling. Both are answers - // rather than transport failures, exactly like a refused link code, so they - // arrive as a 200 and are told apart by `kind`. + // `perm.error` (`busy`, `too-large`) is an answer, not a transport failure. if (report.kind === 'perm.error') { - log.warn('permission sync refused by the game', { server: server.id, reason, refused: report.reason || 'unknown' }) - await db.putSyncResult(server.id, { - state: 'failed', + await failed(server, { + reason, + error: `the game refused the sync: ${report.reason || 'unknown'}`, desiredHash: desired.hash, - syncedHash: sync ? sync.syncedHash : null, + sync, bootId, wipeId, - report: null, - error: `the game refused the sync: ${report.reason || 'unknown'}`, }) - return report.reason || 'refused' } - await applyReport(server, { desired, retire, styleRetired, report, bootId, wipeId }) + await applyReport(server, { + desired: held, + hash: desired.hash, + retire, + styleRetired, + report, + bootId, + wipeId, + drift: planned.drift, + }) + + if (importing) await db.markImported(server.id) return 'ok' } @@ -324,31 +400,20 @@ function styleRetirements(retirements) { } /** - * Record what the game said it did. + * Record what the game said it did, and what waits for a person. * - * Three writes, and the order matters only in that all three are safe to repeat: - * a sync that crashes here is re-run next tick and reaches the same place, which - * is the property that lets this loop be the only writer. + * Every write is safe to repeat: a sync that crashes here is re-run next tick and + * reaches the same place. */ -async function applyReport(server, { desired, retire, styleRetired = [], report, bootId, wipeId }) { +async function applyReport(server, { desired, hash, retire, styleRetired = [], report, bootId, wipeId, drift = [] }) { const unresolved = new Set((report.unresolved || []).map(model.normaliseName)) const pending = new Set(report.pending || []) - // Grants the plugin made and then did not find in the store when it read it - // back (D85). Before protocol 9 there was no such read-back, and on Oxide every - // grant of another plugin's permission landed nowhere while this site recorded - // it as pushed (PLAN.md §27.6). + // Grants the plugin made and then did not find when it read them back (D85); + // since protocol 13 also `group:parent:name` for a parent that would not set. const notLanded = new Set((report.notLanded || []).map((entry) => String(entry).toLowerCase())) - // A grant naming a permission this server has not registered did NOT land — - // `GrantUserPermission` no-ops silently for an unregistered name, which is - // why the plugin pre-checks and says so. Recording it as pushed would make the - // site believe it had given a privilege it had not. - // - // The same for a member the store could not place: the membership is waiting - // on their first connection, and it is not in the game yet. // Protocol 12. A style field landed when BetterChat was there to take it and - // the report names it neither drift nor failed. With BetterChat absent none - // did, and each is sent again with the same `expect` next time (§33.2). + // the report names it neither drift nor failed (§33.2). const chat = report.chat && typeof report.chat === 'object' ? report.chat : null const chatLoaded = Boolean(chat && chat.loaded === true) const fieldKey = (group, field) => `${group} ${String(field || '').toLowerCase()}` @@ -366,19 +431,24 @@ async function applyReport(server, { desired, retire, styleRetired = [], report, return !unresolved.has(row.object) && !notLanded.has(`${row.subject}:${row.object}`.toLowerCase()) } if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`) + if (row.kind === 'group') { + // The value landed only if the parent did; a group whose parent would not + // set is recorded without a value, so the next sync pushes it again. + if ([...notLanded].some((entry) => entry.startsWith(`${row.subject}:parent:`))) { + row.value = null + } + } return true }) await db.addPushed(server.id, landed) // Everything retired is gone from the game whether the plugin removed it or - // found it already absent, so it stops being something this site put there. - // A `chat-group` is not a ledger row; its fields are, below. + // found it already absent. A `chat-group` is not a ledger row; its fields are. await db.removePushed(server.id, retire.filter((row) => row.kind !== 'chat-group')) - // A style's fields leave the ledger only once BetterChat has removed the group - // — or for `default`, which is never removed — so a style withdrawn while - // BetterChat was absent is retired by the first sync that can (D139). + // A style's fields leave the ledger only once BetterChat has removed the group, + // or for `default`, which is never removed (D139). const removed = new Set((chatLoaded && chat.removed) || []) await db.removePushed( server.id, @@ -388,51 +458,42 @@ async function applyReport(server, { desired, retire, styleRetired = [], report, const revocations = await db.listRevocations(server.id) await db.deleteRevocations(revocations.map((row) => row.id)) + // What waits for a person: the reconcile's (D161's `adopt`, and an event's + // grant removed in the game), and a style field somebody changed by hand (D138). await db.replaceDrift(server.id, [ - ...(report.foreign || []).map((row) => ({ + ...drift.map((row) => ({ kind: String(row.kind || ''), subject: String(row.subject || ''), object: String(row.object || ''), + detail: row.detail === undefined || row.detail === null ? null : String(row.detail).slice(0, 255), + direction: row.direction || 'added', })), - // A style field somebody changed by hand, with what it holds now (D138). ...((chatLoaded && chat.drift) || []).map((row) => ({ kind: 'chat-field', subject: String(row.group || ''), object: String(row.field || ''), detail: row.game === undefined || row.game === null ? null : String(row.game).slice(0, 255), + direction: 'changed', })), ]) await db.putSyncResult(server.id, { state: 'ok', - desiredHash: desired.hash, - syncedHash: desired.hash, + desiredHash: hash, + syncedHash: hash, bootId, wipeId, report: JSON.stringify(report), error: null, }) - // The option source, refreshed from the same server that just answered. It is - // a second round trip and it is worth it: the form must not offer a name that - // stopped being registered when somebody uninstalled a plugin, because a grant - // against one is a privilege nobody ever gets and nothing ever reports. - const catalogue = await sidecar.permCatalogue(server) - - if (catalogue.ok && catalogue.data && Array.isArray(catalogue.data.permissions)) { - await db.putCatalogue( - server.id, - catalogue.data.permissions.map(model.normaliseName).filter(Boolean), - ) - } - log.info('permissions synced', { server: server.id, applied: report.applied, unresolved: (report.unresolved || []).length, - foreign: (report.foreign || []).length, pending: (report.pending || []).length, notLanded: (report.notLanded || []).length, + waiting: drift.length, ...(chat ? { betterChat: chatLoaded, @@ -452,6 +513,7 @@ module.exports = { AUDIT_MS, FAIL_BACKOFF_MS, MAX_ROWS, + POLICIES, start, stop, tick, @@ -460,4 +522,5 @@ module.exports = { applyReport, withExpect, styleRetirements, + withLock, } diff --git a/server/router/admin/permissions.controller.js b/server/router/admin/permissions.controller.js index 8587f71..6638093 100644 --- a/server/router/admin/permissions.controller.js +++ b/server/router/admin/permissions.controller.js @@ -1,487 +1,40 @@ // ── 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 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. 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. +// 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, which is the one thing on this module's admin tier -// more consequential than the sidecar credential. +// 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') -/** 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' }) - } -} +const by = (req) => (req.user ? req.user.id : null) -/** - * 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. */ +/** 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) @@ -493,16 +46,738 @@ async function holderOf(steamId) { 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, - putGroup, + server, + players, + catalogue, + setPolicy, + grant, + revoke, + removeException, + createGroup, + updateGroup, deleteGroup, + setGroupPermissions, + setGroupServers, + splitGroup, addMember, removeMember, - addGrant, - removeGrant, + clearMembers, adoptDrift, revokeDrift, + acceptDrift, + restoreDrift, + dismissDrift, syncNow, - catalogue, } diff --git a/server/router/admin/permissions.router.js b/server/router/admin/permissions.router.js index ccfea1a..0e89b31 100644 --- a/server/router/admin/permissions.router.js +++ b/server/router/admin/permissions.router.js @@ -1,39 +1,53 @@ // ── Admin · Rust · Permissions ──────────────────────────────────────────── // // Mounted under the admin tier's `/rust` prefix, so every path here is -// `/api/v1/admin/rust/permissions…`. It is a second router rather than more -// routes on `rust.router.js` because it is a second subject: that one configures -// the bridge, this one authors privilege inside somebody's game. +// `/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. The tier -// gate is not re-implemented; this is one gate on top of it, exactly as the -// server-configuration routes do it. -// -// There is no module-declared site permission to gate these more finely with — -// `MODULE_API.md` has no such member at 1.10.0 — so role is the whole of the -// available vocabulary, and `admin` is the honest choice within it. +// 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 } = core.validator +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 whole permission model' - // #swagger.description = 'Groups with their permissions, members and BetterChat style (`chat`, or null), direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server. A drift row of kind `chat-field` is a style field somebody changed in game: `subject` is the group, `object` the field and `detail` what the game holds. `chatFields` lists the twelve BetterChat fields with their types and defaults, for the style editor.' - /* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */ + // #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, ) @@ -42,118 +56,259 @@ permissionsRouter.get( '/catalogue', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Permission names the servers have registered' - // #swagger.description = 'What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.' - /* #swagger.responses[200] = { description: 'Every registered name, and which servers know it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */ + // #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.put( - '/groups/:name', +permissionsRouter.get( + '/servers/:serverId', // #swagger.tags = ['Admin · Rust'] - // #swagger.summary = 'Create or update a permission group' - // #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it. `chat` is the group’s BetterChat style: all twelve fields (`Priority`, `Title`, `TitleColor`, `TitleSize`, `TitleHidden`, `TitleHiddenIfNotPrimary`, `UsernameColor`, `UsernameSize`, `MessageColor`, `MessageSize`, `ChatFormat`, `ConsoleFormat`), each as text; `null` removes the style, which removes the group from BetterChat on the next sync; absent leaves it alone. A format must hold `{Message}` exactly once. A 400 carries one sentence per problem in `errors`.' - /* #swagger.responses[204] = { description: 'Saved' } */ - /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */ + // #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'), - param('name').matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'), - body('title').optional().isString().trim().isLength({ max: 120 }), - body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(), - body('scope').optional().isString().isLength({ min: 1, max: 64 }), - body('permissions').optional().isArray({ max: 500 }), - body('permissions.*').isString().matches(NAME), - // Shape only; the twelve fields and their rules are `chatStyle.validateStyle`'s, - // in the controller, so the form gets one sentence per problem. - body('chat').optional({ values: 'null' }).isObject().withMessage('chat is an object of BetterChat fields, or null'), + serverParam, validate, - permissions.putGroup, + 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/:name', + '/groups/:id', // #swagger.tags = ['Admin · Rust'] - // #swagger.summary = 'Delete a permission group' - // #swagger.description = 'Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.' + // #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'), - param('name').isString().isLength({ min: 1, max: 64 }), + groupParam, validate, permissions.deleteGroup, ) -permissionsRouter.post( - '/groups/:name/members', +permissionsRouter.put( + '/groups/:id/permissions', // #swagger.tags = ['Admin · Rust'] - // #swagger.summary = 'Put an account in a group' - // #swagger.description = 'Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.' - /* #swagger.responses[204] = { description: 'Added' } */ + // #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'), - param('name').isString().isLength({ min: 1, max: 64 }), - // Either identifier: the screen sends a name, the panel inside core's own user - // page already holds an id. - body('userId').optional().isInt({ min: 1 }).toInt(), - body('username').optional().isString().trim().isLength({ min: 1, max: 64 }), + 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.delete( - '/groups/:name/members/:userId', +permissionsRouter.post( + '/groups/:id/members/remove', // #swagger.tags = ['Admin · Rust'] - // #swagger.summary = 'Take an account out of a group' + // #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, or that account is not in it' } */ + /* #swagger.responses[404] = { description: 'No such group' } */ requireRole('admin'), - param('name').isString().isLength({ min: 1, max: 64 }), - param('userId').isInt({ min: 1 }).toInt(), + groupParam, + ...subject, + ...hereOnly, validate, permissions.removeMember, ) permissionsRouter.post( - '/grants', + '/groups/:id/members/clear', // #swagger.tags = ['Admin · Rust'] - // #swagger.summary = 'Grant one permission to one person' - // #swagger.description = 'A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.' - /* #swagger.responses[201] = { description: 'Granted' } */ - /* #swagger.responses[200] = { description: 'They already held it; nothing changed' } */ - /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */ - /* #swagger.responses[404] = { description: 'No account on this site has that name' } */ + // #swagger.summary = 'Remove every member of a group' + /* #swagger.responses[204] = { description: 'Emptied' } */ + /* #swagger.responses[404] = { description: 'No such group' } */ requireRole('admin'), - body('userId').optional().isInt({ min: 1 }).toInt(), - body('username').optional().isString().trim().isLength({ min: 1, max: 64 }), - body('permission').isString().matches(NAME), - body('scope').optional().isString().isLength({ min: 1, max: 64 }), - body('note').optional().isString().isLength({ max: 255 }), + groupParam, + ...hereOnly, validate, - permissions.addGrant, + permissions.clearMembers, ) permissionsRouter.delete( - '/grants/:id', + '/exceptions/:id', // #swagger.tags = ['Admin · Rust'] - // #swagger.summary = 'Remove a grant' - // #swagger.description = 'The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.' + // #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 grant' } */ + /* #swagger.responses[404] = { description: 'No such exception' } */ requireRole('admin'), param('id').isInt({ min: 1 }).toInt(), validate, - permissions.removeGrant, + permissions.removeException, ) +const driftId = param('id').isInt({ min: 1 }).toInt() + permissionsRouter.post( '/drift/:id/adopt', // #swagger.tags = ['Admin · Rust'] - // #swagger.summary = 'Adopt a hand edit' - // #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link. For a `chat-field` row it copies the game’s value into the group’s style — which every server in the group’s scope is then pushed — and answers 409 when the group has no style or the value is not one the site accepts.' + // #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 kind of drift cannot be adopted' } */ - /* #swagger.responses[409] = { description: 'That Steam account is linked to nobody on this site' } */ + /* #swagger.responses[400] = { description: 'That change was a removal' } */ + /* #swagger.responses[404] = { description: 'No such change' } */ requireRole('admin'), - param('id').isInt({ min: 1 }).toInt(), + driftId, validate, permissions.adoptDrift, ) @@ -161,21 +316,63 @@ permissionsRouter.post( permissionsRouter.post( '/drift/:id/revoke', // #swagger.tags = ['Admin · Rust'] - // #swagger.summary = 'Revoke a hand edit' - // #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own. For a `chat-field` row it puts the site’s value back over the hand edit on the next sync.' + // #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[404] = { description: 'No such drift' } */ + /* #swagger.responses[400] = { description: 'Only an addition can be revoked' } */ + /* #swagger.responses[404] = { description: 'No such change' } */ requireRole('admin'), - param('id').isInt({ min: 1 }).toInt(), + 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 = 'Push the permission set now' - // #swagger.description = 'Runs the reconciliation loop’s pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.' + // #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'), diff --git a/server/router/admin/usersRust.controller.js b/server/router/admin/usersRust.controller.js index b45cd54..414bd0d 100644 --- a/server/router/admin/usersRust.controller.js +++ b/server/router/admin/usersRust.controller.js @@ -83,32 +83,41 @@ async function listPermissions(req, res) { const userId = Number(req.params.id) try { - const [groups, groupPermissions, members, grants, allLinks] = await Promise.all([ + const [groups, groupServers, groupPermissions, members, grants, allLinks] = await Promise.all([ permissionsDb.listGroups(), + permissionsDb.listGroupServers(), permissionsDb.listGroupPermissions(), permissionsDb.listGroupMembers(), permissionsDb.listGrants({ userId }), permissionsDb.listLinks(), ]) - const theirs = new Set( - members.filter((row) => row.userId === userId).map((row) => row.groupName), - ) + const theirs = new Set(members.filter((row) => row.userId === userId).map((row) => row.groupId)) const carried = new Map() for (const row of groupPermissions) { - if (!carried.has(row.groupName)) carried.set(row.groupName, []) - carried.get(row.groupName).push(row.permission) + if (!carried.has(row.groupId)) carried.set(row.groupId, []) + carried.get(row.groupId).push(row.permission) + } + + // A group is on one server unless it is shared (D189): `scope` says `*` + // for every server, or lists the servers it is on. + const on = new Map() + for (const row of groupServers) { + if (!row.included) continue + if (!on.has(row.groupId)) on.set(row.groupId, []) + on.get(row.groupId).push(row.serverId) } res.json({ groups: groups - .filter((group) => theirs.has(group.name)) + .filter((group) => theirs.has(group.id)) .map((group) => ({ + id: group.id, name: group.name, title: group.title, - scope: group.scope, - permissions: carried.get(group.name) || [], + scope: group.allServers ? permissions.FLEET : (on.get(group.id) || []).join(','), + permissions: carried.get(group.id) || [], })), grants: permissions.collapseGrants(grants).map((grant) => ({ id: grant.id, diff --git a/server/sidecarClient.js b/server/sidecarClient.js index 48ca667..cd50e80 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -254,6 +254,70 @@ const permCatalogue = (server) => request(server, '/permissions/catalogue') */ const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set }) +/** + * One page of the game's whole permission store (protocol 13, PLAN_REDESIGNS + * §1.2): every permission with the plugin that registered it, every group, every + * holder, and what event leases hold. `{ page: 0 }` starts a fresh read; later + * pages name the `snapshotId` page 0 answered with. + * + * Like the sync, a refusal (`perm.error`: `busy`, `stale`, `too-large`) comes + * back `{ ok: true }` and is told apart by `data.kind`. + */ +const permInventory = (server, { snapshotId = null, page = 0 } = {}) => + request(server, '/permissions/inventory', { + method: 'POST', + body: snapshotId ? { snapshotId, page } : { page }, + }) + +/** + * The whole inventory, every page, or `{ ok: false, error }`. A snapshot that + * goes stale mid-read is started again once from page 0: a page from a different + * snapshot would tear the answer. + */ +async function readInventory(server) { + for (let attempt = 0; attempt < 2; attempt++) { + // eslint-disable-next-line no-await-in-loop + const first = await permInventory(server, { page: 0 }) + if (!first.ok) return { ok: false, error: first.status || 'unreachable' } + + const head = first.data || {} + if (head.kind === 'perm.error') return { ok: false, error: `the game refused the read: ${head.reason || 'unknown'}` } + if (head.kind !== 'perm.inventory') return { ok: false, error: 'the game does not know perm.inventory (protocol 13)' } + + let users = Array.isArray(head.users) ? head.users : [] + let stale = false + + for (let page = 1; page < Number(head.pages || 1); page++) { + // eslint-disable-next-line no-await-in-loop + const next = await permInventory(server, { snapshotId: head.snapshotId, page }) + const data = (next.ok && next.data) || {} + + if (data.kind !== 'perm.inventory') { + stale = true + break + } + + users = users.concat(Array.isArray(data.users) ? data.users : []) + } + + if (stale) continue + + return { + ok: true, + inventory: { + snapshotId: head.snapshotId, + permissions: Array.isArray(head.permissions) ? head.permissions : [], + groups: Array.isArray(head.groups) ? head.groups : [], + leased: Array.isArray(head.leased) ? head.leased : [], + users, + stats: head.stats || null, + }, + } + } + + return { ok: false, error: 'the inventory changed while it was being read, twice' } +} + /** * Every settings file on one game host, and every plugin loaded to reload one * (protocol 5, R18). @@ -441,6 +505,8 @@ module.exports = { confirmLink, permCatalogue, permSync, + permInventory, + readInventory, configFiles, configFile, configWrite, diff --git a/server/swagger/doc.js b/server/swagger/doc.js index ef221aa..b32b1fb 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -383,90 +383,30 @@ module.exports = { }, }, }, - RustPermissionModel: { + RustPermissionOverview: { type: 'object', description: - 'The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use.', + 'The permission manager’s front page (GET /admin/rust/permissions): every server with its policy and sync state, and every change made in a game that waits for a person (PLAN_REDESIGNS §1).', properties: { - groups: { - type: 'array', - description: 'Groups the site authors, mirrored into each in-scope game as a real group.', - items: { - type: 'object', - properties: { - name: { type: 'string', example: 'vip' }, - title: { type: 'string', example: 'VIP' }, - rank: { type: 'integer', example: 10 }, - scope: { - type: 'string', - description: 'A server id, or `*` for every server.', - example: '*', - }, - permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } }, - chat: { - type: 'object', - nullable: true, - description: 'The group’s BetterChat style — all twelve fields as text — or null for a group without one (D138).', - additionalProperties: { type: 'string' }, - example: { Title: '[VIP]', TitleColor: '#ffaa55', ChatFormat: '{Title} {Username}: {Message}' }, - }, - members: { - type: 'array', - items: { - type: 'object', - properties: { - userId: { type: 'integer', example: 42 }, - username: { type: 'string', example: 'wanderer' }, - steamId: { - type: 'string', - nullable: true, - description: 'Null when this account has linked no Steam id, in which case the membership reaches nobody yet.', - example: '76561198000000000', - }, - playerName: { type: 'string', nullable: true, example: 'Wanderer' }, - }, - }, - }, - }, - }, - }, - grants: { - type: 'array', - description: 'Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected.', - items: { - type: 'object', - properties: { - id: { type: 'integer', example: 7 }, - userId: { type: 'integer', example: 42 }, - username: { type: 'string', example: 'wanderer' }, - permission: { type: 'string', example: 'kits.gold' }, - scope: { type: 'string', example: 'main' }, - source: { - type: 'string', - description: 'What authored it — `admin`, `adopted`, or a later phase’s own writer.', - example: 'admin', - }, - note: { type: 'string', nullable: true, example: null }, - grantedAt: { type: 'string', format: 'date-time' }, - accounts: { - type: 'array', - description: 'The Steam accounts this grant reaches. Empty means it reaches nobody yet.', - items: { - type: 'object', - properties: { - steamId: { type: 'string', example: '76561198000000000' }, - name: { type: 'string', nullable: true, example: 'Wanderer' }, - }, - }, - }, - }, - }, - }, servers: { type: 'array', - description: 'The state of the mirror, per configured server.', - items: { $ref: '#/components/schemas/RustPermissionSyncState' }, + items: { + type: 'object', + properties: { + id: { type: 'string', example: 'rust-oxide' }, + name: { type: 'string', example: 'Oxide rig' }, + policy: { + type: 'string', + enum: ['auto-adopt', 'adopt', 'revoke'], + description: 'What a change made in the game becomes (D161): the site’s own, a question for a person, or undone.', + example: 'auto-adopt', + }, + sync: { $ref: '#/components/schemas/RustPermissionSyncState' }, + }, + }, }, + drift: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionDrift' } }, + policies: { type: 'array', items: { type: 'string' }, example: ['auto-adopt', 'adopt', 'revoke'] }, chatFields: { type: 'array', description: 'The twelve BetterChat group fields a style carries, with each one’s type and BetterChat’s default, for the style editor.', @@ -479,49 +419,101 @@ module.exports = { }, }, }, - drift: { + }, + }, + RustPermissionDrift: { + type: 'object', + description: 'A change made in a game that waits for a person: every change under the `adopt` policy, an event’s grant removed in the game, a hand-edited style field, or a notice that a shared group was split (D190).', + properties: { + id: { type: 'integer', example: 3 }, + serverId: { type: 'string', example: 'rust-oxide' }, + kind: { type: 'string', description: '`grant`, `member`, `group-permission`, `group`, or `chat-field`.', example: 'grant' }, + direction: { type: 'string', enum: ['added', 'removed', 'changed', 'split'], example: 'added' }, + subject: { type: 'string', description: 'A Steam id, or a group name.', example: '76561198000000000' }, + object: { type: 'string', description: 'A permission or group name, a style field, or empty.', example: 'kits.vip' }, + detail: { type: 'string', nullable: true, description: 'What the game holds now (a style value, a group’s title, rank and parent), or a notice’s sentence.', example: null }, + username: { type: 'string', nullable: true, example: 'wanderer' }, + playerName: { type: 'string', nullable: true, example: 'Wanderer' }, + firstSeen: { type: 'string', format: 'date-time' }, + }, + }, + RustPermissionServer: { + type: 'object', + description: + 'One server as the permission screen shows it (GET /admin/rust/permissions/servers/{serverId}), following uMod PermissionsManager’s flow (D162): plugins grouped by the plugin that registered each permission, the groups on the server (D189), every player holding anything there named by account and in-game name (D163), and the facts each toggle’s state is read from.', + properties: { + server: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' } } }, + servers: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' } } } }, + policy: { type: 'string', example: 'auto-adopt' }, + sync: { $ref: '#/components/schemas/RustPermissionSyncState' }, + plugins: { type: 'array', - description: 'What a game holds that the site did not author. Reported, never undone.', items: { type: 'object', properties: { - id: { type: 'integer', example: 3 }, - serverId: { type: 'string', example: 'main' }, - kind: { - type: 'string', - description: 'One of `grant`, `member`, `group-permission`, or `chat-field` for a style field changed in game.', - example: 'grant', - }, - detail: { - type: 'string', - nullable: true, - description: 'For `chat-field`, the value the game holds now. Null for every other kind.', - example: '#ff0000', - }, - subject: { - type: 'string', - description: 'A Steam id, or a group name.', - example: '76561198000000000', - }, - object: { - type: 'string', - description: 'A permission name, or a group name.', - example: 'kits.admin', - }, - username: { - type: 'string', - nullable: true, - description: 'The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked.', - example: 'wanderer', - }, - firstSeen: { type: 'string', format: 'date-time' }, + key: { type: 'string', example: 'plugin:ZoneManager' }, + label: { type: 'string', example: 'ZoneManager' }, + registered: { type: 'boolean', description: 'False for a name no plugin owns (Carbon’s built-in modules), grouped by its prefix.', example: true }, + permissions: { type: 'array', items: { type: 'string', example: 'zonemanager.ignoreflag.nokits' } }, }, }, }, - catalogue: { + groups: { type: 'array', - items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' }, + items: { + type: 'object', + properties: { + id: { type: 'integer', example: 12 }, + name: { type: 'string', example: 'vip' }, + title: { type: 'string', example: 'VIP' }, + rank: { type: 'integer', example: 10 }, + parent: { type: 'string', example: 'default' }, + source: { type: 'string', example: 'imported' }, + builtin: { type: 'boolean', example: false }, + allServers: { type: 'boolean', example: false }, + shared: { type: 'boolean', description: 'On more than one server; a change to it asks whether to change it everywhere or split this server off.', example: false }, + servers: { type: 'array', items: { type: 'string', example: 'rust-oxide' } }, + permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } }, + members: { type: 'array', items: { type: 'object', properties: { userId: { type: 'integer' }, username: { type: 'string' }, steamIds: { type: 'array', items: { type: 'string' } } } } }, + steamMembers: { type: 'array', items: { type: 'string', example: '76561198000000000' } }, + chat: { type: 'object', nullable: true, additionalProperties: { type: 'string' } }, + }, + }, }, + players: { + type: 'array', + items: { + type: 'object', + properties: { + steamId: { type: 'string', example: '76561198000000000' }, + name: { type: 'string', nullable: true, example: 'Wanderer' }, + account: { type: 'object', nullable: true, properties: { userId: { type: 'integer' }, username: { type: 'string' } } }, + grants: { + type: 'array', + items: { + type: 'object', + properties: { + permission: { type: 'string', example: 'kits.vip' }, + sources: { type: 'array', description: 'What puts it there: `userGrant`, `steamGrant` or `runGrant`, with its id and scope.', items: { type: 'object' } }, + }, + }, + }, + groups: { type: 'array', items: { type: 'string', example: 'vip' } }, + }, + }, + }, + excepted: { type: 'array', description: 'Grants that reach every server but this one (D190).', items: { type: 'object' } }, + landed: { type: 'array', description: 'What has landed on this server: `grant ` and `member `.', items: { type: 'string' } }, + report: { + type: 'object', + nullable: true, + properties: { + unresolved: { type: 'array', items: { type: 'string' } }, + pending: { type: 'array', items: { type: 'string' } }, + notLanded: { type: 'array', items: { type: 'string' } }, + }, + }, + drift: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionDrift' } }, }, }, RustPermissionSyncState: { @@ -542,6 +534,7 @@ module.exports = { dirty: { type: 'boolean', example: false }, lastAttemptAt: { type: 'string', format: 'date-time', nullable: true }, lastOkAt: { type: 'string', format: 'date-time', nullable: true }, + importedAt: { type: 'string', format: 'date-time', nullable: true, description: 'When this server’s store was first imported (D198). Null until then; until then every sync imports.' }, error: { type: 'string', nullable: true, @@ -593,6 +586,7 @@ module.exports = { properties: { permission: { type: 'string', example: 'kits.vip' }, servers: { type: 'array', items: { type: 'string', example: 'main' } }, + owner: { type: 'string', nullable: true, description: 'The plugin that registered it, from the inventory (PLAN_REDESIGNS §0.1).', example: 'Kits' }, }, }, RustPermissionSyncResult: { @@ -600,7 +594,7 @@ module.exports = { description: 'What a forced sync produced (POST /admin/rust/permissions/sync).', properties: { servers: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionSyncState' } }, - drift: { type: 'array', items: { type: 'object' } }, + drift: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionDrift' } }, }, }, RustUserPermissions: { diff --git a/server/test/optionalMods.test.js b/server/test/optionalMods.test.js index 669b9c9..58e1658 100644 --- a/server/test/optionalMods.test.js +++ b/server/test/optionalMods.test.js @@ -187,14 +187,19 @@ test('a voice has no sender: no username, and no stray colon where BetterChat’ test('the desired set carries a style per field, and its value moves the digest but not the row’s identity', () => { const model = require('../model/permissions/permissions.model') const base = { - groups: [{ name: 'staff', title: 'Staff', rank: 0, scope: '*' }], + groups: [{ id: 1, name: 'staff', title: 'Staff', rank: 0, parent: '', allServers: true }], + groupServers: [], groupPermissions: [], members: [], + steamMembers: [], grants: [], + steamGrants: [], + exceptions: [], steamIdsByUser: new Map(), + // A style belongs to a group ROW since groups became per server (D189). groupChat: [ - { groupName: 'staff', field: 'Title', value: '[Staff]' }, - { groupName: 'staff', field: 'TitleColor', value: '#ff0000' }, + { groupId: 1, field: 'Title', value: '[Staff]' }, + { groupId: 1, field: 'TitleColor', value: '#ff0000' }, ], } const a = model.buildDesired('main', base) @@ -282,8 +287,9 @@ test('a style field landed only when BetterChat took it; drift keeps the game’ assert.ok(!deletes.some((d) => d.includes('gone')), 'a group BetterChat has not removed stays, to be retired again') assert.ok(!deletes.some((d) => d.includes('chat-group')), 'a chat-group retirement is not a ledger row') + // Protocol 13: every "needs a person" row says which way it went. const drift = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_drift')) - assert.deepStrictEqual(drift.params, ['main', 'chat-field', 'staff', 'TitleColor', '#123456']) + assert.deepStrictEqual(drift.params, ['main', 'chat-field', 'staff', 'TitleColor', '#123456', 'changed']) }) test('with BetterChat absent no style field landed, and a withdrawn style is kept for later', async () => { diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js index 0ba9f61..b1c216a 100644 --- a/server/test/permissions.test.js +++ b/server/test/permissions.test.js @@ -46,23 +46,29 @@ function withCore(overrides = {}) { /** One authored set: a fleet group, a server-scoped group, and two grants. */ function authored() { return { + // A group on every server, and one on `creative` alone (D189). groups: [ - { name: 'vip', title: 'VIP', rank: 10, scope: '*' }, - { name: 'builder', title: 'Builder', rank: 0, scope: 'creative' }, + { id: 1, name: 'vip', title: 'VIP', rank: 10, parent: '', allServers: true }, + { id: 2, name: 'builder', title: 'Builder', rank: 0, parent: '', allServers: false }, ], + groupServers: [{ groupId: 2, serverId: 'creative', included: true }], groupPermissions: [ - { groupName: 'vip', permission: 'kits.vip' }, - { groupName: 'builder', permission: 'buildtools.use' }, + { groupId: 1, permission: 'kits.vip' }, + { groupId: 2, permission: 'buildtools.use' }, ], members: [ - { groupName: 'vip', userId: 1 }, - { groupName: 'builder', userId: 2 }, + { groupId: 1, userId: 1 }, + { groupId: 2, userId: 2 }, ], + steamMembers: [], grants: [ { id: 1, userId: 1, permission: 'kits.gold', scope: '*', steamId: '7656001' }, { id: 2, userId: 3, permission: 'kits.gold', scope: '*', steamId: null }, { id: 3, userId: 2, permission: 'zonemanager.admin', scope: 'creative', steamId: '7656002' }, ], + steamGrants: [], + exceptions: [], + groupChat: [], // One person with TWO Steam accounts, one with one, one with none. steamIdsByUser: new Map([ [1, ['7656001', '7656099']], @@ -83,17 +89,18 @@ test('a grant reaches every Steam account its holder has linked (D28)', () => { for (const row of payload.grants) assert.deepEqual(row.permissions, ['kits.gold']) }) -test('a holder who has linked nothing contributes to the namespace but reaches nobody', () => { +test('a holder who has linked nothing reaches nobody, and is not an error', () => { withCore() const model = require('../model/permissions/permissions.model') const { payload, rows } = model.buildDesired('main', authored()) - // User 3 holds `kits.gold` and has no account. Nothing is pushed for them… + // User 3 holds `kits.gold` and has no account. Nothing is pushed for them, and + // the grant is still the site's: it reaches them the day they link. assert.ok(!rows.some((row) => row.kind === 'grant' && row.subject === null)) - // …and the permission is still MANAGED, which is what makes a hand grant of it - // to somebody else show up as drift rather than as nothing at all. - assert.ok(payload.managed.includes('kits.gold')) + assert.deepEqual(payload.grants.map((g) => g.steamId).sort(), ['7656001', '7656099']) + // Protocol 13 sends no `managed` namespace: the inventory reads the whole store. + assert.equal(payload.managed, undefined) }) test('scope decides what a server is sent at all (D29)', () => { @@ -107,8 +114,9 @@ test('scope decides what a server is sent at all (D29)', () => { assert.deepEqual(creative.payload.groups.map((g) => g.name).sort(), ['builder', 'vip']) // The server-scoped grant is on `creative` and nowhere else. - assert.ok(!main.payload.managed.includes('zonemanager.admin')) - assert.ok(creative.payload.managed.includes('zonemanager.admin')) + const holds = (desired, permission) => desired.payload.grants.some((g) => g.permissions.includes(permission)) + assert.ok(!holds(main, 'zonemanager.admin')) + assert.ok(holds(creative, 'zonemanager.admin')) }) test('a group travels as a group: its members and its permissions are separate facts (D30)', () => { @@ -349,7 +357,7 @@ test('an event grant is unioned with the admin grants, reaches only its server, assert.deepEqual(grantsFor('7656001'), ['kits.event', 'kits.gold']) assert.deepEqual(grantsFor('7656099'), ['kits.event', 'kits.gold']) - assert.ok(!payload.managed.includes('kits.creative')) + assert.ok(!payload.grants.some((g) => g.permissions.includes('kits.creative'))) assert.strictEqual(rows.filter((r) => r.kind === 'grant' && r.object === 'kits.gold').length, 2) assert.deepEqual(payload.credits, [ diff --git a/server/test/playerPermissions.test.js b/server/test/playerPermissions.test.js index f0d0db2..0396119 100644 --- a/server/test/playerPermissions.test.js +++ b/server/test/playerPermissions.test.js @@ -45,11 +45,15 @@ const SERVERS = [ /** One person: in a fleet group, holding one server-scoped grant. */ function fixture({ pushed = [] } = {}) { return { - listGroupsForUser: [{ name: 'vip', title: 'VIP', rank: 10, scope: '*', addedAt: '2026-09-01T00:00:00Z' }], + // A group on every server (D189: `allServers`, where the old model said `scope: '*'`). + listGroupsForUser: [{ id: 1, name: 'vip', title: 'VIP', rank: 10, allServers: true, addedAt: '2026-09-01T00:00:00Z' }], + listGroupServers: [], listGroupPermissions: [ - { groupName: 'vip', permission: 'Kits.VIP' }, - { groupName: 'builder', permission: 'buildtools.use' }, + { groupId: 1, permission: 'Kits.VIP' }, + { groupId: 2, permission: 'buildtools.use' }, ], + listSteamGrants: [], + listExceptions: [], listGrants: [ { id: 7, userId: 4, permission: 'zonemanager.admin', scope: 'creative', source: 'admin', note: null, grantedAt: '2026-09-02T00:00:00Z', steamId: '7656119', playerName: 'Wanderer' }, ], @@ -110,8 +114,11 @@ test('a grant and a membership are different rows about the same person', async // direct grant named `vip` would otherwise be one entry in the map, and the // wrong one would light up. const { model, restore } = modelWith({ - listGroupsForUser: [{ name: 'vip', title: 'VIP', rank: 0, scope: '*', addedAt: null }], + listGroupsForUser: [{ id: 1, name: 'vip', title: 'VIP', rank: 0, allServers: true, addedAt: null }], + listGroupServers: [], listGroupPermissions: [], + listSteamGrants: [], + listExceptions: [], listGrants: [{ id: 1, userId: 4, permission: 'vip', scope: '*', source: 'admin', note: null, grantedAt: null, steamId: '7656119' }], listPushedForSteamIds: [{ serverId: 'main', kind: 'grant', subject: '7656119', object: 'vip' }], }) diff --git a/server/test/reconcile.test.js b/server/test/reconcile.test.js new file mode 100644 index 0000000..abf2261 --- /dev/null +++ b/server/test/reconcile.test.js @@ -0,0 +1,310 @@ +// ── Three sets, and what a change made in the game becomes (PLAN_REDESIGNS §1) ── +// +// The reconciler decides what an in-game change becomes: the site's own +// (auto-adopt), a question for a person (adopt), or undone (revoke) — and the +// first read of a server imports everything (D198). Two rules here protect the +// site's own grants, and each has a test: a permission the server has not +// REGISTERED right now is never judged (a plugin unloaded for a minute is not a +// revocation), and a pair an event lease holds is the lease's. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx } = require('./_fakes') + +function load() { + require('../core')._reset() + require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } })) + return { + reconcile: require('../model/permissions/reconcile'), + model: require('../model/permissions/permissions.model'), + } +} + +const REGISTERED = new Set(['kits.vip', 'kits.gold', 'zonemanager.zone']) + +test('the inventory becomes ledger-shaped rows, and `default` membership is not judged', () => { + const { reconcile, model } = load() + + const present = reconcile.presentRows({ + groups: [{ name: 'VIP', title: 'VIP ', rank: 2, parent: 'default', permissions: ['Kits.VIP'] }], + users: [{ steamId: '7656001', permissions: ['kits.gold'], groups: ['vip', 'default'] }], + }) + + assert.deepEqual(present.map(model.rowKey).sort(), [ + 'grant 7656001 kits.gold', + 'group vip ', + 'group-permission vip kits.vip', + 'member 7656001 default', + 'member 7656001 vip', + ]) + // The title is kept verbatim — Carbon's own end in a space. + assert.equal(present.find((r) => r.kind === 'group').value, model.groupValue('VIP ', 2, 'default')) + + const classes = reconcile.classify({ present, pushed: [], desired: [], registered: REGISTERED }) + assert.ok(!classes.added.some((r) => r.kind === 'member' && r.object === 'default')) +}) + +test('added, removed, changed and landed are told apart by the pushed ledger', () => { + const { reconcile, model } = load() + const v = (t) => model.groupValue(t, 0, '') + + const present = [ + { kind: 'grant', subject: 's1', object: 'kits.vip' }, // nobody's: added in the game + { kind: 'grant', subject: 's2', object: 'kits.gold' }, // wanted, never pushed: landed + { kind: 'group', subject: 'vip', object: '', value: v('Changed') }, + ] + const pushed = [ + { kind: 'grant', subject: 's3', object: 'kits.gold' }, // pushed, wanted, gone: removed + { kind: 'group', subject: 'vip', object: '', value: v('VIP') }, + ] + const desired = [ + { kind: 'grant', subject: 's2', object: 'kits.gold' }, + { kind: 'grant', subject: 's3', object: 'kits.gold' }, + { kind: 'group', subject: 'vip', object: '', value: v('VIP') }, + ] + + const c = reconcile.classify({ present, pushed, desired, registered: REGISTERED }) + assert.deepEqual(c.added.map(model.rowKey), ['grant s1 kits.vip']) + assert.deepEqual(c.removed.map(model.rowKey), ['grant s3 kits.gold']) + assert.deepEqual(c.landed.map(model.rowKey), ['grant s2 kits.gold']) + assert.deepEqual(c.changed.map((r) => r.value), [v('Changed')]) +}) + +test('a permission the server does not register right now is never judged — an unloaded plugin is not a revocation', () => { + const { reconcile } = load() + + // `kits.vip` was pushed and is wanted, and the inventory does not have it — + // because Kits is unloaded, so the name is not registered. + const c = reconcile.classify({ + present: [], + pushed: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }], + desired: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }], + registered: new Set(['zonemanager.zone']), + }) + + assert.equal(c.removed.length, 0) +}) + +test('a pair an event lease holds is the lease’s, not a hand edit', () => { + const { reconcile } = load() + + const c = reconcile.classify({ + present: [{ kind: 'group-permission', subject: 'default', object: 'kits.vip' }], + pushed: [], + desired: [], + registered: REGISTERED, + leased: [{ kind: 'group-permission', subject: 'default', object: 'kits.vip' }], + }) + + assert.equal(c.added.length, 0) +}) + +test('a group removed in the game takes its permissions and members with it', () => { + const { reconcile, model } = load() + const rows = [ + { kind: 'group', subject: 'vip', object: '', value: model.groupValue('VIP', 0, '') }, + { kind: 'group-permission', subject: 'vip', object: 'kits.vip' }, + { kind: 'member', subject: 's1', object: 'vip' }, + ] + + const c = reconcile.classify({ present: [], pushed: rows, desired: rows, registered: REGISTERED }) + assert.deepEqual(c.removed.map(model.rowKey), ['group vip ']) +}) + +test('the first inventory imports additions whatever the policy, and pushes removals back (D198)', () => { + const { reconcile } = load() + const classes = { + added: [ + { kind: 'grant', subject: 's1', object: 'kits.vip' }, + { kind: 'group', subject: 'vip', object: '', value: '["VIP",1,""]' }, + { kind: 'member', subject: 's1', object: 'vip' }, + ], + removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }], + changed: [], + landed: [], + } + + for (const policy of ['auto-adopt', 'adopt', 'revoke']) { + const p = reconcile.plan({ classes, policy, importing: true, sources: new Map() }) + // Groups first: a membership is written into a group that must exist. + assert.deepEqual(p.ops.map((o) => o.op), ['adoptGroup', 'adoptGrant', 'adoptMember'], policy) + assert.ok(p.ops.every((o) => o.source === undefined || o.source === 'imported')) + assert.equal(p.ops.find((o) => o.op === 'adoptGroup').title, 'VIP') + assert.equal(p.revocations.length, 0) + assert.equal(p.hold.size, 0) + } +}) + +test('auto-adopt makes an addition the site’s and a removal the site’s withdrawal (D190)', () => { + const { reconcile } = load() + const sources = new Map([['grant s3 kits.gold', [{ type: 'steamGrant', id: 9, scope: 'main' }]]]) + + const p = reconcile.plan({ + classes: { + added: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }], + removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }], + changed: [], + landed: [], + }, + policy: 'auto-adopt', + importing: false, + sources, + }) + + assert.deepEqual(p.ops.map((o) => o.op), ['adoptGrant', 'dropGrant']) + assert.equal(p.ops[0].source, 'adopted') + assert.deepEqual(p.ops[1].sources, [{ type: 'steamGrant', id: 9, scope: 'main' }]) +}) + +test('auto-adopt leaves an event’s grant to the event, pushes it back and tells a person', () => { + const { reconcile } = load() + + const p = reconcile.plan({ + classes: { added: [], removed: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }], changed: [], landed: [] }, + policy: 'auto-adopt', + importing: false, + sources: new Map([['grant s1 kits.vip', [{ type: 'runGrant', runId: '4', stepId: '1' }]]]), + }) + + assert.equal(p.ops.length, 0) + assert.deepEqual(p.drift, [{ kind: 'grant', subject: 's1', object: 'kits.vip', direction: 'removed', detail: 'event' }]) + assert.equal(p.hold.size, 0, 'pushed back, not held') +}) + +test('adopt asks a person about every change, and holds removals and changed groups until then', () => { + const { reconcile } = load() + + const p = reconcile.plan({ + classes: { + added: [{ kind: 'group', subject: 'raid', object: '', value: '["Raiders",0,""]' }], + removed: [{ kind: 'member', subject: 's1', object: 'vip' }], + changed: [{ kind: 'group', subject: 'vip', object: '', value: '["V",0,""]' }], + landed: [], + }, + policy: 'adopt', + importing: false, + sources: new Map(), + }) + + assert.equal(p.ops.length, 0) + assert.deepEqual(p.drift.map((d) => d.direction), ['added', 'removed', 'changed']) + assert.equal(p.drift[0].detail, '["Raiders",0,""]', 'an added group keeps its title for adopting later') + assert.deepEqual([...p.hold].sort(), ['group vip ', 'member s1 vip']) +}) + +test('revoke undoes an addition at this sync, and never a built-in group', () => { + const { reconcile } = load() + + const p = reconcile.plan({ + classes: { + added: [ + { kind: 'grant', subject: 's1', object: 'kits.vip' }, + { kind: 'group', subject: 'admin', object: '' }, + ], + removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }], + changed: [], + landed: [], + }, + policy: 'revoke', + importing: false, + sources: new Map(), + }) + + assert.deepEqual(p.revocations, [{ kind: 'grant', subject: 's1', object: 'kits.vip' }]) + assert.equal(p.ops.length, 0, 'a removal is pushed back by the desired set, with nothing to do') +}) + +test('a held row is neither pushed nor recorded, and a held group keeps its place without its title', () => { + const { reconcile, model } = load() + + const desired = { + hash: 'h', + rows: [ + { kind: 'group', subject: 'vip', object: '', value: model.groupValue('VIP', 1, '') }, + { kind: 'member', subject: 's1', object: 'vip' }, + { kind: 'grant', subject: 's2', object: 'kits.gold' }, + ], + payload: { + groups: [{ name: 'vip', title: 'VIP', rank: 1, parent: '', permissions: [], members: ['s1'] }], + grants: [{ steamId: 's2', permissions: ['kits.gold'] }], + credits: [], + }, + } + + const held = reconcile.withHold(desired, new Set(['group vip ', 'member s1 vip', 'grant s2 kits.gold'])) + + assert.deepEqual(held.payload.groups, [{ name: 'vip', permissions: [], members: [] }]) + assert.deepEqual(held.payload.grants, []) + assert.deepEqual(held.rows, []) + assert.equal(held.hash, 'h', 'the digest is the whole desired set’s') + + // …and a held row is not retired either: it is still wanted. + assert.deepEqual(model.retirements(desired.rows, desired.rows.slice(1), new Set(['group vip '])), []) +}) + +test('a built-in group is never retired, even when the site no longer has it', () => { + const { model } = load() + + const pushed = [ + { kind: 'group', subject: 'default', object: '' }, + { kind: 'group', subject: 'raid', object: '' }, + ] + + assert.deepEqual(model.retirements(pushed, []).map((r) => r.subject), ['raid']) +}) + +test('a group shared with other servers is marked so in the sources; a server-only one is not (D190)', () => { + const { model } = load() + + const desired = model.buildDesired('main', { + groups: [ + { id: 1, name: 'vip', title: 'VIP', rank: 0, parent: '', allServers: true }, + { id: 2, name: 'raid', title: 'Raid', rank: 0, parent: '', allServers: false }, + ], + groupServers: [{ groupId: 2, serverId: 'main', included: true }], + groupPermissions: [{ groupId: 1, permission: 'kits.vip' }, { groupId: 2, permission: 'kits.gold' }], + members: [], + steamMembers: [{ groupId: 2, steamId: 's1' }], + grants: [], + steamGrants: [], + exceptions: [], + runGrants: [], + groupChat: [], + steamIdsByUser: new Map(), + }) + + assert.equal(desired.sources.get('group-permission vip kits.vip')[0].shared, true) + assert.equal(desired.sources.get('group-permission raid kits.gold')[0].shared, false) + assert.equal(desired.sources.get('member s1 raid')[0].type, 'steamMember') +}) + +test('an exception keeps a fleet grant off one server and on every other (D190)', () => { + const { model } = load() + const set = { + groups: [], + groupServers: [], + groupPermissions: [], + members: [], + steamMembers: [], + grants: [], + steamGrants: [{ id: 5, steamId: 's1', permission: 'kits.vip', scope: '*' }], + exceptions: [{ id: 1, holder: 'steam', grantId: 5, serverId: 'main' }], + runGrants: [], + groupChat: [], + steamIdsByUser: new Map(), + } + + assert.ok(!model.buildDesired('main', set).rows.some((r) => r.kind === 'grant')) + assert.ok(model.buildDesired('creative', set).rows.some((r) => r.kind === 'grant' && r.subject === 's1')) +}) + +test('a group excluded from one server by a split is on every other server', () => { + const { model } = load() + const group = { id: 1, name: 'vip', allServers: true } + const rows = model.serversByGroup([{ groupId: 1, serverId: 'main', included: false }]) + + assert.equal(model.groupCovers(group, rows, 'main'), false) + assert.equal(model.groupCovers(group, rows, 'creative'), true) + assert.equal(model.isShared(group, rows), true) +}) diff --git a/swagger-fragment.json b/swagger-fragment.json index ab50e63..aa3e9a6 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -201,21 +201,18 @@ "tags": [ "Admin · Rust" ], - "summary": "The whole permission model", - "description": "Groups with their permissions, members and BetterChat style (`chat`, or null), direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server. A drift row of kind `chat-field` is a style field somebody changed in game: `subject` is the group, `object` the field and `detail` what the game holds. `chatFields` lists the twelve BetterChat fields with their types and defaults, for the style editor.", + "summary": "The permission manager: servers, policies and what waits for a person", + "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.", "responses": { "200": { - "description": "The authored model and what each game reported", + "description": "The overview", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RustPermissionModel" + "$ref": "#/components/schemas/RustPermissionOverview" } } } - }, - "500": { - "description": "Internal Server Error" } } } @@ -226,10 +223,10 @@ "Admin · Rust" ], "summary": "Permission names the servers have registered", - "description": "What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.", + "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’.", "responses": { "200": { - "description": "Every registered name, and which servers know it", + "description": "Every registered name, which servers know it, and who registered it", "content": { "application/json": { "schema": { @@ -237,9 +234,36 @@ } } } + } + } + } + }, + "/api/v1/admin/rust/permissions/drift/{id}/accept": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Accept a removal made in the game", + "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).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Accepted" }, - "500": { - "description": "Internal Server Error" + "400": { + "description": "Only a removal can be accepted" + }, + "404": { + "description": "No such change" } } } @@ -249,8 +273,8 @@ "tags": [ "Admin · Rust" ], - "summary": "Adopt a hand edit", - "description": "Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link. For a `chat-field` row it copies the game’s value into the group’s style — which every server in the group’s scope is then pushed — and answers 409 when the group has no style or the value is not one the site accepts.", + "summary": "Adopt a change made in the game", + "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.", "parameters": [ { "name": "id", @@ -266,27 +290,51 @@ "description": "Adopted" }, "400": { - "description": "That kind of drift cannot be adopted" + "description": "That change was a removal" }, "404": { - "description": "Not Found" + "description": "No such change" }, "409": { - "description": "That Steam account is linked to nobody on this site" - }, - "500": { - "description": "Internal Server Error" + "description": "Conflict" } } } }, - "/api/v1/admin/rust/permissions/drift/{id}/revoke": { + "/api/v1/admin/rust/permissions/drift/{id}/dismiss": { "post": { "tags": [ "Admin · Rust" ], - "summary": "Revoke a hand edit", - "description": "Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own. For a `chat-field` row it puts the site’s value back over the hand edit on the next sync.", + "summary": "Dismiss a notice", + "description": "A split notice (D190) or an event’s grant that was pushed back. Nothing in any game changes.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Dismissed" + }, + "404": { + "description": "No such notice" + } + } + } + }, + "/api/v1/admin/rust/permissions/drift/{id}/restore": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Put back what the game removed or changed", + "description": "The next sync pushes the site’s version again.", "parameters": [ { "name": "id", @@ -301,65 +349,52 @@ "202": { "description": "Queued for the next sync" }, - "404": { - "description": "No such drift" + "400": { + "description": "Only a removal or a changed group can be put back" }, - "500": { - "description": "Internal Server Error" + "404": { + "description": "No such change" } } } }, - "/api/v1/admin/rust/permissions/grants": { + "/api/v1/admin/rust/permissions/drift/{id}/revoke": { "post": { "tags": [ "Admin · Rust" ], - "summary": "Grant one permission to one person", - "description": "A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.", + "summary": "Undo an addition made in the game", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { - "200": { - "description": "They already held it; nothing changed" - }, - "201": { - "description": "Granted" + "202": { + "description": "Queued for the next sync" }, "400": { - "description": "Invalid body, or a scope naming no configured server" + "description": "Only an addition can be revoked" }, "404": { - "description": "No account on this site has that name" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "permission": { - "example": "any" - }, - "scope": { - "example": "any" - }, - "note": { - "example": "any" - } - } - } - } + "description": "No such change" } } } }, - "/api/v1/admin/rust/permissions/grants/{id}": { + "/api/v1/admin/rust/permissions/exceptions/{id}": { "delete": { "tags": [ "Admin · Rust" ], - "summary": "Remove a grant", - "description": "The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.", + "summary": "Give a grant back to the one server it was kept off", + "description": "", "parameters": [ { "name": "id", @@ -375,24 +410,21 @@ "description": "Removed" }, "404": { - "description": "No such grant" - }, - "500": { - "description": "Internal Server Error" + "description": "No such exception" } } } }, - "/api/v1/admin/rust/permissions/groups/{name}": { - "put": { + "/api/v1/admin/rust/permissions/groups/{id}": { + "patch": { "tags": [ "Admin · Rust" ], - "summary": "Create or update a permission group", - "description": "Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it. `chat` is the group’s BetterChat style: all twelve fields (`Priority`, `Title`, `TitleColor`, `TitleSize`, `TitleHidden`, `TitleHiddenIfNotPrimary`, `UsernameColor`, `UsernameSize`, `MessageColor`, `MessageSize`, `ChatFormat`, `ConsoleFormat`), each as text; `null` removes the style, which removes the group from BetterChat on the next sync; absent leaves it alone. A format must hold `{Message}` exactly once. A 400 carries one sentence per problem in `errors`.", + "summary": "Change a group’s title, rank, parent or chat style", + "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).", "parameters": [ { - "name": "name", + "name": "id", "in": "path", "required": true, "schema": { @@ -401,14 +433,17 @@ } ], "responses": { - "204": { - "description": "Saved" + "200": { + "description": "Saved; `id` is the group that changed, a new copy if it was split" }, "400": { - "description": "Invalid body, or a scope naming no configured server" + "description": "An invalid style" }, - "500": { - "description": "Internal Server Error" + "404": { + "description": "No such group" + }, + "409": { + "description": "Conflict" } }, "requestBody": { @@ -417,19 +452,13 @@ "schema": { "type": "object", "properties": { - "scope": { - "example": "any" - }, "chat": { "example": "any" }, - "title": { + "onlyHere": { "example": "any" }, - "rank": { - "example": "any" - }, - "permissions": { + "serverId": { "example": "any" } } @@ -442,11 +471,11 @@ "tags": [ "Admin · Rust" ], - "summary": "Delete a permission group", - "description": "Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.", + "summary": "Delete a group", + "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.", "parameters": [ { - "name": "name", + "name": "id", "in": "path", "required": true, "schema": { @@ -460,23 +489,20 @@ }, "404": { "description": "No such group" - }, - "500": { - "description": "Internal Server Error" } } } }, - "/api/v1/admin/rust/permissions/groups/{name}/members": { + "/api/v1/admin/rust/permissions/groups/{id}/members": { "post": { "tags": [ "Admin · Rust" ], - "summary": "Put an account in a group", - "description": "Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.", + "summary": "Put a player in a group", + "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.", "parameters": [ { - "name": "name", + "name": "id", "in": "path", "required": true, "schema": { @@ -489,32 +515,88 @@ "description": "Added" }, "400": { - "description": "Bad Request" + "description": "No subject named" }, "404": { "description": "No such group" } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "onlyHere": { + "example": "any" + }, + "serverId": { + "example": "any" + }, + "steamId": { + "example": "any" + } + } + } + } + } } } }, - "/api/v1/admin/rust/permissions/groups/{name}/members/{userId}": { - "delete": { + "/api/v1/admin/rust/permissions/groups/{id}/members/clear": { + "post": { "tags": [ "Admin · Rust" ], - "summary": "Take an account out of a group", + "summary": "Remove every member of a group", "description": "", "parameters": [ { - "name": "name", + "name": "id", "in": "path", "required": true, "schema": { "type": "string" } + } + ], + "responses": { + "204": { + "description": "Emptied" }, + "404": { + "description": "No such group" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "onlyHere": { + "example": "any" + }, + "serverId": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{id}/members/remove": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Take a player out of a group", + "description": "Both ways they can be in it: as the Steam id, and as the website account it is linked to.", + "parameters": [ { - "name": "userId", + "name": "id", "in": "path", "required": true, "schema": { @@ -527,10 +609,411 @@ "description": "Removed" }, "404": { - "description": "No such group, or that account is not in it" + "description": "No such group" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "onlyHere": { + "example": "any" + }, + "serverId": { + "example": "any" + }, + "steamId": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{id}/permissions": { + "put": { + "tags": [ + "Admin · Rust" + ], + "summary": "Replace what a group carries", + "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).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Saved; `id` is the group that changed" }, - "500": { - "description": "Internal Server Error" + "404": { + "description": "No such group" + }, + "409": { + "description": "Conflict" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "onlyHere": { + "example": "any" + }, + "serverId": { + "example": "any" + }, + "permissions": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{id}/servers": { + "put": { + "tags": [ + "Admin · Rust" + ], + "summary": "Share a group, or stop sharing it", + "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Saved" + }, + "404": { + "description": "No such group" + }, + "409": { + "description": "A chosen server has its own group of this name" + } + } + } + }, + "/api/v1/admin/rust/permissions/groups/{id}/split": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Give one server its own copy of a shared group", + "description": "The copy carries the same permissions, members and style, on that server only, and the shared group stops covering it (D190).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Split; `id` is the copy" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such group" + }, + "409": { + "description": "That group is not on that server" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "serverId": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/rust/permissions/servers/{serverId}": { + "get": { + "tags": [ + "Admin · Rust" + ], + "summary": "One server’s permissions, as the screen shows them", + "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).", + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The server view", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RustPermissionServer" + } + } + } + }, + "404": { + "description": "No such server" + } + } + } + }, + "/api/v1/admin/rust/permissions/servers/{serverId}/grant": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Grant permissions to one player (a toggle, or Grant all)", + "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.", + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "How many were granted, and how many exceptions removed" + }, + "400": { + "description": "No subject named" + }, + "404": { + "description": "No such server" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "everywhere": { + "example": "any" + }, + "permissions": { + "example": "any" + }, + "steamId": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/rust/permissions/servers/{serverId}/groups": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Create a group on one server", + "description": "A group belongs to one server unless an admin shares it (D189). A server cannot have two groups of one name.", + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Created; the body carries its id" + }, + "404": { + "description": "No such server" + }, + "409": { + "description": "This server already has a group of that name" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/rust/permissions/servers/{serverId}/players": { + "get": { + "tags": [ + "Admin · Rust" + ], + "summary": "Find a player seen on a server", + "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.", + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "q", + "in": "query", + "description": "Part of a name, Steam id or account name", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Matching players" + }, + "404": { + "description": "No such server" + } + } + } + }, + "/api/v1/admin/rust/permissions/servers/{serverId}/policy": { + "put": { + "tags": [ + "Admin · Rust" + ], + "summary": "Set what a change made in the game becomes", + "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).", + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Saved" + }, + "400": { + "description": "Not a policy" + }, + "404": { + "description": "No such server" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "policy": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/rust/permissions/servers/{serverId}/revoke": { + "post": { + "tags": [ + "Admin · Rust" + ], + "summary": "Revoke permissions from one player (a toggle, or Revoke all)", + "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.", + "parameters": [ + { + "name": "serverId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "How many grants changed, and what could not be" + }, + "400": { + "description": "No subject named" + }, + "404": { + "description": "No such server" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "everywhere": { + "example": "any" + }, + "permissions": { + "example": "any" + }, + "steamId": { + "example": "any" + } + } + } + } } } } @@ -540,8 +1023,8 @@ "tags": [ "Admin · Rust" ], - "summary": "Push the permission set now", - "description": "Runs the reconciliation loop’s pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.", + "summary": "Read, reconcile and push now", + "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.", "responses": { "200": { "description": "The state of every server after the pass", @@ -555,9 +1038,6 @@ }, "404": { "description": "No such server" - }, - "500": { - "description": "Internal Server Error" } }, "requestBody": { @@ -4174,7 +4654,7 @@ } } }, - "RustPermissionModel": { + "RustPermissionOverview": { "type": "object", "properties": { "type": { @@ -4183,257 +4663,18 @@ }, "description": { "type": "string", - "example": "The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use." + "example": "The permission manager’s front page (GET /admin/rust/permissions): every server with its policy and sync state, and every change made in a game that waits for a person (PLAN_REDESIGNS §1)." }, "properties": { "type": "object", "properties": { - "groups": { + "servers": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, - "description": { - "type": "string", - "example": "Groups the site authors, mirrored into each in-scope game as a real group." - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "vip" - } - } - }, - "title": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "VIP" - } - } - }, - "rank": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 10 - } - } - }, - "scope": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "description": { - "type": "string", - "example": "A server id, or `*` for every server." - }, - "example": { - "type": "string", - "example": "*" - } - } - }, - "permissions": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "kits.vip" - } - } - } - } - }, - "chat": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "The group’s BetterChat style — all twelve fields as text — or null for a group without one (D138)." - }, - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "example": { - "type": "object", - "properties": { - "Title": { - "type": "string", - "example": "[VIP]" - }, - "TitleColor": { - "type": "string", - "example": "#ffaa55" - }, - "ChatFormat": { - "type": "string", - "example": "{Title} {Username}: {Message}" - } - } - } - } - }, - "members": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "userId": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 42 - } - } - }, - "username": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "wanderer" - } - } - }, - "steamId": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Null when this account has linked no Steam id, in which case the membership reaches nobody yet." - }, - "example": { - "type": "string", - "example": "76561198000000000" - } - } - }, - "playerName": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Wanderer" - } - } - } - } - } - } - } - } - } - } - } - } - } - } - }, - "grants": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "description": { - "type": "string", - "example": "Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected." - }, "items": { "type": "object", "properties": { @@ -4449,28 +4690,15 @@ "properties": { "type": { "type": "string", - "example": "integer" + "example": "string" }, "example": { - "type": "number", - "example": 7 - } - } - }, - "userId": { - "type": "object", - "properties": { - "type": { "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 42 + "example": "rust-oxide" } } }, - "username": { + "name": { "type": "object", "properties": { "type": { @@ -4479,136 +4707,40 @@ }, "example": { "type": "string", - "example": "wanderer" + "example": "Oxide rig" } } }, - "permission": { + "policy": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, - "example": { - "type": "string", - "example": "kits.gold" - } - } - }, - "scope": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "main" - } - } - }, - "source": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "description": { - "type": "string", - "example": "What authored it — `admin`, `adopted`, or a later phase’s own writer." - }, - "example": { - "type": "string", - "example": "admin" - } - } - }, - "note": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": {} - } - }, - "grantedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - } - } - }, - "accounts": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "description": { - "type": "string", - "example": "The Steam accounts this grant reaches. Empty means it reaches nobody yet." - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "steamId": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "76561198000000000" - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Wanderer" - } - } - } - } - } + "enum": { + "type": "array", + "example": [ + "auto-adopt", + "adopt", + "revoke" + ], + "items": { + "type": "string" } + }, + "description": { + "type": "string", + "example": "What a change made in the game becomes (D161): the site’s own, a question for a person, or undone." + }, + "example": { + "type": "string", + "example": "auto-adopt" } } + }, + "sync": { + "$ref": "#/components/schemas/RustPermissionSyncState" } } } @@ -4616,19 +4748,44 @@ } } }, - "servers": { + "drift": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, - "description": { + "items": { + "$ref": "#/components/schemas/RustPermissionDrift" + } + } + }, + "policies": { + "type": "object", + "properties": { + "type": { "type": "string", - "example": "The state of the mirror, per configured server." + "example": "array" }, "items": { - "$ref": "#/components/schemas/RustPermissionSyncState" + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "auto-adopt", + "adopt", + "revoke" + ], + "items": { + "type": "string" + } } } }, @@ -4715,17 +4872,393 @@ } } } + } + } + } + } + }, + "RustPermissionDrift": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A change made in a game that waits for a person: every change under the `adopt` policy, an event’s grant removed in the game, a hand-edited style field, or a notice that a shared group was split (D190)." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } }, - "drift": { + "serverId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "rust-oxide" + } + } + }, + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "`grant`, `member`, `group-permission`, `group`, or `chat-field`." + }, + "example": { + "type": "string", + "example": "grant" + } + } + }, + "direction": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "added", + "removed", + "changed", + "split" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "added" + } + } + }, + "subject": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "A Steam id, or a group name." + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "object": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "A permission or group name, a style field, or empty." + }, + "example": { + "type": "string", + "example": "kits.vip" + } + } + }, + "detail": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "What the game holds now (a style value, a group’s title, rank and parent), or a notice’s sentence." + }, + "example": {} + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "wanderer" + } + } + }, + "playerName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + }, + "firstSeen": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "RustPermissionServer": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One server as the permission screen shows it (GET /admin/rust/permissions/servers/{serverId}), following uMod PermissionsManager’s flow (D162): plugins grouped by the plugin that registered each permission, the groups on the server (D189), every player holding anything there named by account and in-game name (D163), and the facts each toggle’s state is read from." + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + }, + "servers": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, - "description": { + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + }, + "policy": { + "type": "object", + "properties": { + "type": { "type": "string", - "example": "What a game holds that the site did not author. Reported, never undone." + "example": "string" + }, + "example": { + "type": "string", + "example": "auto-adopt" + } + } + }, + "sync": { + "$ref": "#/components/schemas/RustPermissionSyncState" + }, + "plugins": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "key": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "plugin:ZoneManager" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "ZoneManager" + } + } + }, + "registered": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "False for a name no plugin owns (Carbon’s built-in modules), grouped by its prefix." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "permissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "zonemanager.ignoreflag.nokits" + } + } + } + } + } + } + } + } + } + } + }, + "groups": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" }, "items": { "type": "object", @@ -4746,11 +5279,11 @@ }, "example": { "type": "number", - "example": 3 + "example": 12 } } }, - "serverId": { + "name": { "type": "object", "properties": { "type": { @@ -4759,113 +5292,249 @@ }, "example": { "type": "string", - "example": "main" + "example": "vip" } } }, - "kind": { + "title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, + "example": { + "type": "string", + "example": "VIP" + } + } + }, + "rank": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 10 + } + } + }, + "parent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "default" + } + } + }, + "source": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "imported" + } + } + }, + "builtin": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "allServers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "shared": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, "description": { "type": "string", - "example": "One of `grant`, `member`, `group-permission`, or `chat-field` for a style field changed in game." + "example": "On more than one server; a change to it asks whether to change it everywhere or split this server off." }, "example": { - "type": "string", - "example": "grant" + "type": "boolean", + "example": false } } }, - "detail": { + "servers": { "type": "object", "properties": { "type": { "type": "string", - "example": "string" + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "rust-oxide" + } + } + } + } + }, + "permissions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.vip" + } + } + } + } + }, + "members": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "steamIds": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + } + } + }, + "steamMembers": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + } + } + }, + "chat": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" }, "nullable": { "type": "boolean", "example": true }, - "description": { - "type": "string", - "example": "For `chat-field`, the value the game holds now. Null for every other kind." - }, - "example": { - "type": "string", - "example": "#ff0000" - } - } - }, - "subject": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "description": { - "type": "string", - "example": "A Steam id, or a group name." - }, - "example": { - "type": "string", - "example": "76561198000000000" - } - } - }, - "object": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "description": { - "type": "string", - "example": "A permission name, or a group name." - }, - "example": { - "type": "string", - "example": "kits.admin" - } - } - }, - "username": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked." - }, - "example": { - "type": "string", - "example": "wanderer" - } - } - }, - "firstSeen": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } } } } @@ -4875,7 +5544,7 @@ } } }, - "catalogue": { + "players": { "type": "object", "properties": { "type": { @@ -4883,7 +5552,292 @@ "example": "array" }, "items": { - "$ref": "#/components/schemas/RustPermissionCatalogueEntry" + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "steamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "76561198000000000" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Wanderer" + } + } + }, + "account": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "properties": { + "type": "object", + "properties": { + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + }, + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "permission": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "kits.vip" + } + } + }, + "sources": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "What puts it there: `userGrant`, `steamGrant` or `runGrant`, with its id and scope." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + } + } + } + } + } + } + } + } + } + } + }, + "groups": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "vip" + } + } + } + } + } + } + } + } + } + } + }, + "excepted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Grants that reach every server but this one (D190)." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + } + } + } + } + }, + "landed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "What has landed on this server: `grant ` and `member `." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + }, + "report": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "properties": { + "type": "object", + "properties": { + "unresolved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + }, + "pending": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + }, + "notLanded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + }, + "drift": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/RustPermissionDrift" } } } @@ -4999,6 +5953,27 @@ } } }, + "importedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "When this server’s store was first imported (D198). Null until then; until then every sync imports." + } + } + }, "error": { "type": "object", "properties": { @@ -5252,6 +6227,27 @@ } } } + }, + "owner": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The plugin that registered it, from the inventory (PLAN_REDESIGNS §0.1)." + }, + "example": { + "type": "string", + "example": "Kits" + } + } } } } @@ -5291,13 +6287,7 @@ "example": "array" }, "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - } - } + "$ref": "#/components/schemas/RustPermissionDrift" } } } -- 2.49.1