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 (
-
-
- )}
+/** 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 (
+
+ 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.
+
- {/* 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. */}
-
+ {error &&
- 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.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}
- )}
-
-
-
- ))}
-
-
)
}
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"
}
}
}