diff --git a/ci/bundle.json b/ci/bundle.json
index a38c7af..6ecfb44 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -35,6 +35,7 @@
"ingest.js",
"model",
"package.json",
+ "permSync.js",
"router",
"sidecarClient.js"
],
diff --git a/client/src/api.js b/client/src/api.js
index 429c8b1..5947fe7 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -103,6 +103,50 @@ export const admin = {
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
}
+// ── admin · permissions (R2) ──────────────────────────────────────────────
+//
+// The authoring surface. Every call here writes to the SITE, and none of them
+// reaches a game server — the mirror's own loop does that on its own cadence.
+// `sync` is the exception and says so in its name: it runs the pass now and
+// answers with what each server reported, which is the only call on this screen
+// that can be slow or fail because a game host is down.
+//
+// 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.
+export const adminPermissions = {
+ overview: () => req('/admin/rust/permissions'),
+ catalogue: () => req('/admin/rust/permissions/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' }),
+
+ 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' },
+ ),
+
+ grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }),
+ revoke: (id) =>
+ req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+
+ adoptDrift: (id) =>
+ req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }),
+ revokeDrift: (id) =>
+ req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
+
+ sync: (serverId = null) =>
+ req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
+}
+
// ── the admin.users.detail extension slot ─────────────────────────────────
//
// The client half of R13's first slot. Core hands the component a `userId` and
@@ -118,8 +162,34 @@ export const adminUserLinks = {
}),
}
+// The same panel's phase 7 half: what this person may do in game. The id in the
+// path is the one the slot handed the component, so these send `userId` rather
+// than a name — the screen already knows who it is looking at.
+export const adminUserPermissions = {
+ list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions`),
+ grant: (userId, body) =>
+ req(`/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants`, {
+ method: 'POST',
+ body,
+ }),
+ revoke: (userId, grantId) =>
+ req(
+ `/admin/users/${encodeURIComponent(userId)}/rust/permissions/grants/${encodeURIComponent(grantId)}`,
+ { method: 'DELETE' },
+ ),
+}
+
// Exported for the rare caller that needs the base itself — an ``, a
// download link, an EventSource. Reach for `request` first.
export { BASE, query }
-export default { servers, playerServers, playerLinks, admin, adminUserLinks, BASE }
+export default {
+ servers,
+ playerServers,
+ playerLinks,
+ admin,
+ adminPermissions,
+ adminUserLinks,
+ adminUserPermissions,
+ BASE,
+}
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index 7fb8b18..2a1c57e 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -21,9 +21,10 @@ import { registry, coreApiVersion } from './core.js'
import Servers from './routes/public/Servers.jsx'
import ServerDetail from './routes/public/ServerDetail.jsx'
import Account from './routes/player/Account.jsx'
+import Permissions from './routes/admin/Permissions.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
-import { IconLink } from './icons.jsx'
+import { IconKey, IconLink } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -63,12 +64,25 @@ const ID = 'rust'
// to do, and a landing page above one page is a page nobody wants. Core applies
// its own portal chrome and its own auth gate to the tier, so the component
// renders no layout and re-implements no check.
+//
+// **The admin route arrives in phase 7 and is this module's first.** Everything
+// before it was configured through the API — the server rows still are — because
+// nothing until now had to be AUTHORED. A permission model is different in kind:
+// it is a thing an operator composes and keeps looking at, and there is no
+// version of "grant somebody VIP" that belongs in a terminal.
+//
+// It is registered with an empty path, so it lands at `/admin/rust`, and core
+// applies the admin tier's own gate. The routes underneath it are stricter than
+// that gate (`requireRole('admin')` on every one), which is a server-side answer
+// rather than a client one: a moderator who reached this page would see it fail
+// honestly rather than be quietly shown a page that cannot save.
registry.registerRoutes(ID, {
public: [
{ path: '', element: },
{ path: 'servers/:id', element: },
],
player: [{ path: '', element: }],
+ admin: [{ path: '', element: }],
})
// ── Nav ───────────────────────────────────────────────────────────────────
@@ -105,6 +119,17 @@ registry.registerNav(ID, {
items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }],
})
+// The admin sidebar's row. `group` names an existing core group — an unknown name
+// appends a new group at the end rather than dropping the row, which is the
+// failure mode to avoid here: a row nobody can find is a feature nobody has.
+//
+// It carries an icon for the same reason the player row does: core draws one on
+// every sidebar row, and the one without is the only text in a column of glyphs.
+registry.registerNav(ID, {
+ area: 'admin',
+ items: [{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey }],
+})
+
// ── Extension slots ───────────────────────────────────────────────────────
//
// Core declares a slot, only core may declare one, and at most one module may
diff --git a/client/src/icons.jsx b/client/src/icons.jsx
index 941eb3e..4019cd2 100644
--- a/client/src/icons.jsx
+++ b/client/src/icons.jsx
@@ -46,4 +46,20 @@ export const IconLink = () => (
)
-export default { IconLink }
+/**
+ * A key — the admin sidebar's row for the permission mirror.
+ *
+ * Core's admin groups are labelled by subject and drawn with glyphs of the same
+ * weight, so this is the same 16px frame as the portal's. A key rather than a
+ * shield: a shield is protection from something, and this row is about handing
+ * somebody the right to do something.
+ */
+export const IconKey = () => (
+
+
+
+
+
+)
+
+export default { IconLink, IconKey }
diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx
new file mode 100644
index 0000000..c19b09b
--- /dev/null
+++ b/client/src/routes/admin/Permissions.jsx
@@ -0,0 +1,617 @@
+// ── Admin · Rust · Permissions ────────────────────────────────────────────
+//
+// R2's authoring surface, and this module's first admin page.
+//
+// **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:
+//
+// • 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.
+//
+// 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.
+
+import { useCallback, useState } from 'react'
+
+import { ErrorState, Loading, useAsync } from '../../core.js'
+import { ago } from '../../lib/format.js'
+import api from '../../api.js'
+
+const FLEET = '*'
+
+/** Shared furniture. The kit is nine exports and none of them is a table. */
+function Card({ title, subtitle, children, actions }) {
+ return (
+
+
+
+ )}
+
+ {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.
+
+ )}
+
+ )
+}
+
+/** A hand edit, with the two answers to it. */
+function DriftRow({ row, onAdopt, onRevoke, busy }) {
+ const subject = row.username ? `${row.username} (${row.subject})` : row.subject
+
+ return (
+
+
+ {row.object}{' '}
+
+ {row.kind === 'group-permission' ? `on group ${row.subject}` : `held by ${subject}`} ·{' '}
+ {row.serverId} · seen {ago(row.firstSeen)}
+
+
+
+
+
+ )
+}
+
+/**
+ * The memberships the game could not place yet, as `steamId:group`.
+ *
+ * Read out of each server's own report, because it is the only thing that knows:
+ * a member who has never connected to a server has no user record there to put
+ * in a group (§12.2 rule 4), and from every other angle they look like a member.
+ * The server strip says how many; this is what puts it next to the person.
+ */
+function pendingSet(servers) {
+ const pending = new Map()
+
+ for (const server of servers) {
+ for (const entry of (server.report && server.report.pending) || []) {
+ if (!pending.has(entry)) pending.set(entry, [])
+ pending.get(entry).push(server.serverId)
+ }
+ }
+
+ return pending
+}
+
+function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
+ const [busy, setBusy] = useState(false)
+ const [member, setMember] = useState('')
+ const [permission, setPermission] = useState('')
+
+ const act = async (fn) => {
+ setBusy(true)
+ setError('')
+ try {
+ await fn()
+ await onChanged()
+ } catch (err) {
+ setError(err.message || 'That did not work.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const save = (permissions) =>
+ act(() =>
+ api.adminPermissions.saveGroup(group.name, {
+ title: group.title,
+ rank: group.rank,
+ scope: group.scope,
+ permissions,
+ }),
+ )
+
+ return (
+ {group.name} · >}
+ actions={
+
+ }
+ >
+
Permissions
+ {group.permissions.length === 0 && (
+
+ This group carries nothing, so being in it does nothing.
+
+ )}
+ {group.permissions.map((perm) => (
+
+ {perm}
+ {!catalogue.some((entry) => entry.permission === perm) && (
+
+ no server has registered this
+
+ )}
+
+
+ ))}
+
+
+
+
+ {/* 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.
+
+
+ {/* 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.
+
+ )}
+ {(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}
+ )}
+
+
+
+ ))}
+
+
+
+
+ {(data.groups || []).map((group) => (
+
+ ))}
+
+
+
+
+ A group is created in each in-scope game as a real group, so plugins that read group
+ membership see it. A member who has never connected to a server joins it there on their
+ first connection — a direct grant reaches them straight away, which is the difference
+ worth knowing when somebody is waiting.
+
+
+
+ )
+}
diff --git a/client/src/routes/admin/UserRustSections.jsx b/client/src/routes/admin/UserRustSections.jsx
index 3313965..1d167d6 100644
--- a/client/src/routes/admin/UserRustSections.jsx
+++ b/client/src/routes/admin/UserRustSections.jsx
@@ -113,11 +113,131 @@ function LinkPanel({ userId, link, onRemoved }) {
)
}
+/**
+ * Phase 7's half of the panel: what this person may do in game.
+ *
+ * It renders whenever they hold anything, INCLUDING when they have linked no
+ * Steam account — which is the one case worth going out of the way for. A grant
+ * against an unlinked person is authored, stored, pushed nowhere, and identical
+ * to a working one everywhere except here.
+ */
+function PermissionsPanel({ userId, data, onChanged }) {
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [permission, setPermission] = useState('')
+
+ const act = async (fn) => {
+ setBusy(true)
+ setError('')
+ try {
+ await fn()
+ await onChanged()
+ } catch (err) {
+ setError(err.message || 'That did not work.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (!data) return null
+
+ const nothing = data.groups.length === 0 && data.grants.length === 0
+
+ return (
+
+ This account has linked no Steam id, so none of it reaches a game yet. It will apply by
+ itself when they link.
+
+ )}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
+
export default function UserRustSections({ userId }) {
// Core's `useAsync` has no refresh, so a counter in the deps is how this
// re-reads after its own write (the same shape the player page uses).
const [reloads, setReloads] = useState(0)
const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads])
+ const { data: permissions } = useAsync(
+ () => api.adminUserPermissions.list(userId),
+ [userId, reloads],
+ )
const reload = useCallback(() => setReloads((n) => n + 1), [])
// No `Loading` and no `ErrorState`, deliberately. This is a section inside
@@ -125,7 +245,15 @@ export default function UserRustSections({ userId }) {
// have nothing to do with is worse than a section that appears when it has
// something, and a failure here must not replace core's own user detail with an
// error card.
- if (!data || data.links.length === 0) return null
+ // **Both reads decide whether this section exists**, and the second one is the
+ // reason. A browser walk found it: a person can hold permissions and have
+ // linked no Steam account — which is exactly the state an operator most needs
+ // to see, because it is the one that reaches nobody — and a section gated on
+ // links alone hides it completely.
+ const holdsSomething =
+ permissions && (permissions.groups.length > 0 || permissions.grants.length > 0)
+
+ if (!data || (data.links.length === 0 && !holdsSomething)) return null
return (
@@ -135,13 +263,22 @@ export default function UserRustSections({ userId }) {
{data.links.map((link) => (
))}
-
-
- A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
- recorded in the activity log — it is the way back for a player who linked the wrong account
- and cannot reach it in game.
-
+ {data.links.length > 0 && (
+
+ A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
+ recorded in the activity log — it is the way back for a player who linked the wrong
+ account and cannot reach it in game.
+
+ )}
+
+ {/* Inside the same section rather than beside it: "who is this in game"
+ and "what may they do there" are one question asked twice, and an
+ operator reading a support ticket has both in front of them. The note
+ above belongs to the links, so it sits with them rather than under
+ the panel it would otherwise appear to describe. */}
+
+
)
}
diff --git a/routes.manifest.json b/routes.manifest.json
index 18657bd..e1814e3 100644
--- a/routes.manifest.json
+++ b/routes.manifest.json
@@ -1,6 +1,21 @@
{
"$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.",
"routes": [
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/rust/permissions/grants/: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",
+ "tier": "public"
+ },
{
"method": "DELETE",
"path": "/api/v1/admin/rust/servers/:id",
@@ -11,11 +26,26 @@
"path": "/api/v1/admin/users/:id/rust/links/:steamId",
"tier": "public"
},
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/users/:id/rust/permissions/grants/:grantId",
+ "tier": "public"
+ },
{
"method": "DELETE",
"path": "/api/v1/player/rust/links/:steamId",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/rust/permissions",
+ "tier": "public"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/rust/permissions/catalogue",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/admin/rust/servers",
@@ -26,6 +56,11 @@
"path": "/api/v1/admin/users/:id/rust/links",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/users/:id/rust/permissions",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/player/rust/links",
@@ -66,16 +101,51 @@
"path": "/api/v1/public/rust/servers/:id/wipes",
"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/revoke",
+ "tier": "public"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/rust/permissions/grants",
+ "tier": "public"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/rust/permissions/groups/:name/members",
+ "tier": "public"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/rust/permissions/sync",
+ "tier": "public"
+ },
{
"method": "POST",
"path": "/api/v1/admin/rust/servers/:id/test",
"tier": "public"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/users/:id/rust/permissions/grants",
+ "tier": "public"
+ },
{
"method": "POST",
"path": "/api/v1/player/rust/link",
"tier": "public"
},
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/rust/permissions/groups/:name",
+ "tier": "public"
+ },
{
"method": "PUT",
"path": "/api/v1/admin/rust/servers/:id",
diff --git a/server/boot.js b/server/boot.js
index 945cf5f..b4eb58d 100644
--- a/server/boot.js
+++ b/server/boot.js
@@ -44,6 +44,7 @@ const core = require('./core')
const db = require('./model/servers/servers.db')
const eventsDb = require('./model/events/events.db')
const ingest = require('./ingest')
+const permSync = require('./permSync')
const servers = require('./model/servers/servers.model')
const sidecar = require('./sidecarClient')
@@ -190,6 +191,11 @@ async function prune() {
async function onBoot() {
await refresh()
+ // 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
+ // nothing about R2 is urgent enough to delay a listener for.
+ permSync.start()
refreshTimer = setInterval(refresh, REFRESH_MS)
ingestTimer = setInterval(ingestAll, INGEST_MS)
pruneTimer = setInterval(prune, PRUNE_MS)
@@ -200,7 +206,7 @@ async function onBoot() {
if (timer && typeof timer.unref === 'function') timer.unref()
}
- log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS })
+ log.info('booted', { refreshMs: REFRESH_MS, ingestMs: INGEST_MS, permSyncMs: permSync.TICK_MS })
}
/**
@@ -212,6 +218,8 @@ async function onBoot() {
* rather than cancelled, since nothing can stop a promise that is still running.
*/
async function onShutdown() {
+ permSync.stop()
+
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
if (timer) clearInterval(timer)
}
diff --git a/server/catalogue.js b/server/catalogue.js
index cf255bf..de3c6ba 100644
--- a/server/catalogue.js
+++ b/server/catalogue.js
@@ -76,6 +76,10 @@ const STAFF_KINDS = Object.freeze([
// about somebody's identity, not about what happened on the server.
'account.link.requested',
'account.unlinked',
+ // Protocol 4. Who holds which privilege in game, and the fact that somebody
+ // changed it by hand — a question about a person's standing and about an
+ // operator's own console, neither of which is a public page's business.
+ 'perm.drift',
])
/** Every kind protocol 3 defines. */
diff --git a/server/db/purge.sql b/server/db/purge.sql
index bb0e96d..f48674b 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.
+-- Phase 7. Children before parents: every one of these carries a foreign key
+-- into `rust_servers`, `users` or `rust_perm_groups`.
+DROP TABLE IF EXISTS rust_perm_catalogue;
+DROP TABLE IF EXISTS rust_perm_sync;
+DROP TABLE IF EXISTS rust_perm_revocations;
+DROP TABLE IF EXISTS rust_perm_drift;
+DROP TABLE IF EXISTS rust_perm_pushed;
+DROP TABLE IF EXISTS rust_perm_grants;
+DROP TABLE IF EXISTS rust_perm_group_members;
+DROP TABLE IF EXISTS rust_perm_group_permissions;
+DROP TABLE IF EXISTS rust_perm_groups;
DROP TABLE IF EXISTS rust_account_links;
DROP TABLE IF EXISTS rust_ingest_cursor;
DROP TABLE IF EXISTS rust_presence;
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 3248d5f..da2b04b 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -335,6 +335,271 @@ CREATE TABLE IF NOT EXISTS rust_account_links (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- ── Site-owned permissions (phase 7, R2) ──────────────────────────────────
+--
+-- The website is the author of record for who may do what in game, and the
+-- framework's own permission store is an ENFORCEMENT CACHE. That is one
+-- sentence with three consequences, and the tables below are shaped by them:
+--
+-- • Every third-party plugin honours a site grant with no adapter, because
+-- they all already call `UserHasPermission`. Nothing here is read by the
+-- game directly; it is pushed into the store the game already consults.
+-- • A wipe stops being a data-loss event. The game forgets and the site does
+-- not, so the next sync puts it all back.
+-- • A hand edit is REPORTED, never silently overwritten (D31). Which means
+-- the site has to be able to tell a grant it made from one somebody typed
+-- at a console — and that is a fact only the site can hold, because the
+-- store records who granted a permission nowhere.
+--
+-- ── A grant is against a WEBSITE USER (D28) ───────────────────────────────
+--
+-- Not against a Steam id, though a Steam id is what reaches the game. The site
+-- authors privilege for a PERSON: phase 13's earned entitlements follow whoever
+-- earned them, and an account unlinked from a person takes their privileges
+-- with it. The Steam ids are resolved from `rust_account_links` at push time,
+-- so a player who links a second account gets what they hold on both — which is
+-- the honest reading of "this person may do this".
+--
+-- A user with no linked account is authored against perfectly well and simply
+-- reaches nobody until they link. That is visible on the admin screen rather
+-- than silent, because a grant that reaches nothing looks identical to a grant
+-- that worked from every other angle.
+--
+-- ── Scope (D29) ───────────────────────────────────────────────────────────
+--
+-- Every authored row carries one: a server id, or `*` for the whole fleet. The
+-- game stores permissions per server (each has its own store), an operator
+-- running a modded server and a vanilla one will not want one set on both, and
+-- a single-server community never has to think about it.
+
+
+-- ── Groups ────────────────────────────────────────────────────────────────
+--
+-- Mirrored into the game as REAL groups (D30) rather than flattened into
+-- per-player grants. Third-party plugins read group membership, BetterChat's
+-- group API (R15, phase 17) has something to hang on, and an operator reading
+-- `oxide.show groups` sees what the website shows.
+--
+-- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does
+-- not go away: **a player the store has never seen cannot be put in a group**,
+-- while a direct grant to the same id works immediately. The sync reports those
+-- members as pending and the membership lands on their first connection.
+--
+-- The name is the primary key, fleet-wide, even though the row carries a scope:
+-- one `vip` on the site is one `vip` in the game, pushed to the servers its
+-- scope names. Two groups of the same name with different scopes would be two
+-- definitions of one name in every store that received both.
+CREATE TABLE IF NOT EXISTS rust_perm_groups (
+ name VARCHAR(64) NOT NULL PRIMARY KEY,
+ title VARCHAR(120) NOT NULL DEFAULT '',
+ rank INT NOT NULL DEFAULT 0,
+ scope VARCHAR(64) NOT NULL DEFAULT '*',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+
+-- What each group carries. A row per permission rather than a list on the group
+-- for the ordinary reason: "which groups grant kits.vip" is the question an
+-- operator asks when they are about to remove a plugin, and that is a WHERE
+-- clause here and a scan of every row in the other shape.
+CREATE TABLE IF NOT EXISTS rust_perm_group_permissions (
+ group_name VARCHAR(64) NOT NULL,
+ permission VARCHAR(128) NOT NULL,
+ PRIMARY KEY (group_name, permission),
+ CONSTRAINT fk_rust_perm_group_permissions_group
+ FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE
+);
+
+
+-- Who is in each group — by website user, like every other authored row.
+--
+-- `added_by` is an admin's user id and deliberately carries NO foreign key: a
+-- staff member's account being deleted must not delete the record of what they
+-- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody".
+-- The activity log is the audit trail; this column is a convenience beside it.
+CREATE TABLE IF NOT EXISTS rust_perm_group_members (
+ group_name VARCHAR(64) NOT NULL,
+ user_id INT NOT NULL,
+ added_by INT NULL,
+ added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (group_name, user_id),
+ KEY idx_rust_perm_members_user (user_id),
+ CONSTRAINT fk_rust_perm_members_group
+ FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE,
+ CONSTRAINT fk_rust_perm_members_user
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+
+-- ── Direct grants ─────────────────────────────────────────────────────────
+--
+-- A permission held by one person, without a group. It is not a lesser version
+-- of membership: it is the shape that reaches a player who has never connected
+-- to that server, which is exactly what an entitlement earned on the website at
+-- three in the morning has to do (R16).
+--
+-- `source` is why this table does not need changing in phase 13. Every later
+-- author — an event action granting the right to redeem a kit, a lease handing
+-- out a weekend group — writes a row here with its own source rather than a
+-- store of its own, so there is one answer to "why does this player have this"
+-- and one place the push reads.
+CREATE TABLE IF NOT EXISTS rust_perm_grants (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ user_id INT 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_grant (user_id, permission, scope),
+ KEY idx_rust_perm_grant_user (user_id),
+ CONSTRAINT fk_rust_perm_grants_user
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+
+-- ── What this site has actually put in each game ──────────────────────────
+--
+-- The site's memory of its own authorship, one row per thing it has confirmed
+-- into one server's store. It is the table that makes D31 possible at all.
+--
+-- Three sets, and every interesting question is the difference between two of
+-- them:
+--
+-- desired − pushed what to apply
+-- pushed − desired what to RETIRE, because the site put it there and has
+-- since withdrawn it
+-- present − desired drift: somebody else put it there
+--
+-- Without the middle row a withdrawn grant is indistinguishable from a hand
+-- edit, and those two have opposite correct answers. Inferring it from absence
+-- is the mistake this table exists to prevent.
+--
+-- It is keyed by Steam id rather than by user, because it records what is in the
+-- GAME, and the game has never heard of a website account. Unlinking an account
+-- therefore leaves its row here until the next sync retires it — which is the
+-- correct behaviour and would be impossible to express keyed the other way.
+CREATE TABLE IF NOT EXISTS rust_perm_pushed (
+ server_id VARCHAR(64) NOT NULL,
+ -- `grant` | `member` | `group-permission` | `group`
+ kind VARCHAR(24) NOT NULL,
+ -- a Steam id, or a group name
+ subject VARCHAR(64) NOT NULL,
+ -- a permission, a group name, or '' for the existence of a group
+ object VARCHAR(128) NOT NULL,
+ pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (server_id, kind, subject, object),
+ CONSTRAINT fk_rust_perm_pushed_server
+ FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
+);
+
+
+-- ── Drift ─────────────────────────────────────────────────────────────────
+--
+-- What a sync found in a server's store that the site did not author, within
+-- the namespace the site claims. Rows appear and disappear with the report:
+-- this is the CURRENT difference, not a history of differences, and a hand edit
+-- that somebody has since removed should stop being on the screen.
+--
+-- Nothing here is ever removed from the game by the sync itself. An operator
+-- typing `oxide.grant` during an incident is drift, not an error, and the two
+-- answers offered to them — adopt it, or revoke it — are both a person's
+-- decision.
+CREATE TABLE IF NOT EXISTS rust_perm_drift (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ server_id VARCHAR(64) NOT NULL,
+ kind VARCHAR(24) NOT NULL,
+ subject VARCHAR(64) NOT NULL,
+ object VARCHAR(128) NOT NULL,
+ first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object),
+ CONSTRAINT fk_rust_perm_drift_server
+ FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
+);
+
+
+-- ── Removing something the site never put there ───────────────────────────
+--
+-- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole
+-- point of a drift row is that it was never pushed. It cannot go through the
+-- authored tables either: a foreign grant often names a Steam id that belongs
+-- to no website account at all, and there is no user to author it against.
+--
+-- So a revoke is its own instruction with its own lifetime: queued by a person,
+-- carried in the next sync's retire list, and deleted once a report says the
+-- game no longer has it. A server that is offline keeps the instruction until
+-- it comes back, which is the behaviour an operator expects from a website that
+-- claims to be the author of record.
+CREATE TABLE IF NOT EXISTS rust_perm_revocations (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ server_id VARCHAR(64) NOT NULL,
+ kind VARCHAR(24) NOT NULL,
+ subject VARCHAR(64) NOT NULL,
+ object VARCHAR(128) NOT NULL,
+ requested_by INT NULL,
+ requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object),
+ CONSTRAINT fk_rust_perm_revocations_server
+ FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
+);
+
+
+-- ── The state of the mirror, per server ───────────────────────────────────
+--
+-- One row per configured server: whether its store currently matches what the
+-- site authors, when that was last true, and what the last report said.
+--
+-- `dirty` is how everything that should provoke a sync says so without knowing
+-- anything about syncing: an admin writing a grant, a drift hook firing in the
+-- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and
+-- every other part of the module owns WHETHER.
+--
+-- `desired_hash` and `synced_hash` are the cheap half of that question. A loop
+-- that pushed the whole set every tick would work and would also write to six
+-- game servers every thirty seconds for ever; comparing a hash costs one query
+-- and skips the round trip when nothing has changed. The periodic audit below
+-- is what keeps that from being a way to never notice drift.
+CREATE TABLE IF NOT EXISTS rust_perm_sync (
+ server_id VARCHAR(64) NOT NULL PRIMARY KEY,
+ -- `pending` | `ok` | `failed`
+ state VARCHAR(24) NOT NULL DEFAULT 'pending',
+ dirty TINYINT(1) NOT NULL DEFAULT 1,
+ desired_hash VARCHAR(64) NULL,
+ synced_hash VARCHAR(64) NULL,
+ boot_id VARCHAR(64) NULL,
+ wipe_id VARCHAR(48) NULL,
+ last_attempt_at DATETIME NULL,
+ last_ok_at DATETIME NULL,
+ report LONGTEXT NULL,
+ error VARCHAR(191) NULL,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_rust_perm_sync_server
+ FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
+);
+
+
+-- ── What each server's plugins have registered ────────────────────────────
+--
+-- The option source the authoring form offers (D33), cached from the live read
+-- so that opening the form is not six round trips to six game hosts.
+--
+-- It is a cache of a fact that changes when an operator loads a plugin, and it
+-- is refreshed on every sync — which is also why a name that has stopped being
+-- registered disappears from the form rather than lingering as a choice that
+-- silently does nothing.
+CREATE TABLE IF NOT EXISTS rust_perm_catalogue (
+ server_id VARCHAR(64) NOT NULL,
+ permission VARCHAR(128) NOT NULL,
+ seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (server_id, permission),
+ CONSTRAINT fk_rust_perm_catalogue_server
+ FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
+);
+
+
-- ── Changes to tables that already shipped ────────────────────────────────
--
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
diff --git a/server/ingest.js b/server/ingest.js
index 7e878d5..8256293 100644
--- a/server/ingest.js
+++ b/server/ingest.js
@@ -35,6 +35,7 @@ const core = require('./core')
const db = require('./model/events/events.db')
const links = require('./model/links/links.model')
+const permissionsDb = require('./model/permissions/permissions.db')
const sidecar = require('./sidecarClient')
const log = core.logger('ingest')
@@ -173,6 +174,23 @@ async function apply(serverId, item) {
await db.touchPlayer(frame.steamId, frame.name || null)
break
+ // ── Protocol 4: somebody changed the permission store, and it was not us ──
+ //
+ // The plugin raises this only for writes it did not make itself — its own
+ // sync suppresses the hooks while it applies (PROTOCOL.md §10.4). What
+ // arrives here is therefore a hand edit, a console command, or another
+ // plugin granting something.
+ //
+ // **It is a reason to reconcile, not the reconciliation.** This frame cannot
+ // say whether the change is foreign: only the desired set can, and that
+ // comparison happens in the sync. So the server is marked dirty and the next
+ // tick produces the authoritative answer — which means a hook that stops
+ // firing on a framework upgrade costs latency and nothing else. The audit
+ // interval finds the same drift within fifteen minutes either way.
+ case 'perm.drift':
+ await permissionsDb.markDirty(serverId)
+ break
+
default:
// Stored, not counted. Moderation frames, the server lifecycle, and
// anything a newer protocol sends that this build does not understand.
diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js
new file mode 100644
index 0000000..de97b05
--- /dev/null
+++ b/server/model/permissions/permissions.db.js
@@ -0,0 +1,459 @@
+// ── 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:
+//
+// 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).
+// 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.
+// 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.
+//
+// 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
+// list rather than looped, because a fleet-wide sync writes hundreds of rows and
+// a round trip each is how a boot tick becomes a second long.
+
+const core = require('../../core')
+
+const GROUPS = 'rust_perm_groups'
+const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
+const GROUP_MEMBERS = 'rust_perm_group_members'
+const GRANTS = 'rust_perm_grants'
+const PUSHED = 'rust_perm_pushed'
+const DRIFT = 'rust_perm_drift'
+const REVOCATIONS = 'rust_perm_revocations'
+const SYNC = 'rust_perm_sync'
+const CATALOGUE = 'rust_perm_catalogue'
+const LINKS = 'rust_account_links'
+const SERVERS = 'rust_servers'
+
+/** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */
+function placeholders(rows, width) {
+ return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',')
+}
+
+// ---- the authored set ----
+
+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`,
+ )
+}
+
+async function getGroup(name) {
+ const rows = await core.query(
+ `SELECT name, title, \`rank\`, scope FROM ${GROUPS} WHERE name = ?`,
+ [name],
+ )
+
+ return rows[0] || null
+}
+
+/**
+ * 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.
+ */
+async function upsertGroup({ name, title, rank, scope }) {
+ 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],
+ )
+}
+
+async function deleteGroup(name) {
+ const result = await core.query(`DELETE FROM ${GROUPS} WHERE name = ?`, [name])
+ return Number(result.affectedRows || 0) > 0
+}
+
+async function listGroupPermissions() {
+ return core.query(
+ `SELECT group_name AS groupName, 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])
+
+ if (!permissions.length) return
+
+ await core.query(
+ `INSERT INTO ${GROUP_PERMISSIONS} (group_name, permission)
+ VALUES ${placeholders(permissions, 2)}`,
+ permissions.flatMap((permission) => [name, permission]),
+ )
+}
+
+/**
+ * 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.
+ */
+async function listGroupMembers() {
+ return core.query(
+ `SELECT m.group_name AS groupName, 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`,
+ )
+}
+
+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 removeGroupMember(groupName, userId) {
+ const result = await core.query(
+ `DELETE FROM ${GROUP_MEMBERS} WHERE group_name = ? AND user_id = ?`,
+ [groupName, userId],
+ )
+
+ return Number(result.affectedRows || 0) > 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.
+ */
+async function listGrants({ userId = null } = {}) {
+ return core.query(
+ `SELECT g.id, g.user_id AS userId, g.permission, g.scope, g.source, g.note,
+ g.granted_at AS grantedAt, u.username,
+ l.steam_id AS steamId, p.name AS playerName
+ FROM ${GRANTS} g
+ JOIN users u ON u.id = g.user_id
+ LEFT JOIN ${LINKS} l ON l.user_id = g.user_id
+ LEFT JOIN rust_players p ON p.steam_id = l.steam_id
+ ${userId === null ? '' : 'WHERE g.user_id = ?'}
+ ORDER BY u.username ASC, g.permission ASC`,
+ userId === null ? [] : [userId],
+ )
+}
+
+async function getGrant(id) {
+ const rows = await core.query(
+ `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.
+ */
+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 }
+}
+
+async function deleteGrant(id) {
+ const result = await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])
+ return Number(result.affectedRows || 0) > 0
+}
+
+/**
+ * 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.
+ */
+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. */
+async function listLinks() {
+ return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
+}
+
+// ---- what is actually out there ----
+
+async function listPushed(serverId) {
+ return core.query(
+ `SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`,
+ [serverId],
+ )
+}
+
+async function addPushed(serverId, rows) {
+ if (!rows.length) return
+
+ await core.query(
+ `INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object)
+ VALUES ${placeholders(rows, 4)}`,
+ rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
+ )
+}
+
+async function removePushed(serverId, rows) {
+ for (const row of rows) {
+ // eslint-disable-next-line no-await-in-loop
+ await core.query(
+ `DELETE FROM ${PUSHED} WHERE server_id = ? AND kind = ? AND subject = ? AND object = ?`,
+ [serverId, row.kind, row.subject, row.object],
+ )
+ }
+}
+
+/**
+ * Replace one server's drift list with what the latest report 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.
+ */
+async function replaceDrift(serverId, rows) {
+ if (!rows.length) {
+ await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ?`, [serverId])
+ return
+ }
+
+ await core.query(
+ `INSERT INTO ${DRIFT} (server_id, kind, subject, object)
+ VALUES ${placeholders(rows, 4)}
+ ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`,
+ rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
+ )
+
+ // 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 (kind, subject, object) NOT IN (${placeholders(rows, 3)})`,
+ [serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])],
+ )
+}
+
+async function listDrift() {
+ return core.query(
+ `SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object,
+ d.first_seen AS firstSeen, d.last_seen AS lastSeen,
+ l.user_id AS userId, u.username, p.name AS playerName
+ FROM ${DRIFT} d
+ LEFT JOIN ${LINKS} l ON l.steam_id = d.subject
+ LEFT JOIN users u ON u.id = l.user_id
+ LEFT JOIN rust_players p ON p.steam_id = d.subject
+ ORDER BY d.server_id ASC, d.kind ASC, d.subject ASC`,
+ )
+}
+
+async function getDrift(id) {
+ const rows = await core.query(
+ `SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`,
+ [id],
+ )
+
+ return rows[0] || null
+}
+
+async function deleteDrift(id) {
+ await core.query(`DELETE FROM ${DRIFT} WHERE id = ?`, [id])
+}
+
+async function queueRevocation({ serverId, kind, subject, object, requestedBy }) {
+ await core.query(
+ `INSERT IGNORE INTO ${REVOCATIONS} (server_id, kind, subject, object, requested_by)
+ VALUES (?, ?, ?, ?, ?)`,
+ [serverId, kind, subject, object, requestedBy],
+ )
+}
+
+async function listRevocations(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,
+ )
+}
+
+// ---- 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}`,
+ )
+}
+
+async function listSync() {
+ return core.query(
+ `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
+ 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.
+ */
+async function markDirty(scope) {
+ if (!scope || scope === '*') {
+ await core.query(`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP`)
+ return
+ }
+
+ await core.query(
+ `UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id = ?`,
+ [scope],
+ )
+}
+
+/**
+ * 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.
+ */
+async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) {
+ const okAt = state === 'ok' ? new Date() : null
+
+ await core.query(
+ `INSERT INTO ${SYNC} (server_id, state, dirty, desired_hash, synced_hash, boot_id, wipe_id,
+ last_attempt_at, last_ok_at, report, error, updated_at)
+ VALUES (?, ?, 0, ?, ?, ?, ?, NOW(), ?, ?, ?, NOW())
+ ON DUPLICATE KEY UPDATE state = VALUES(state), dirty = 0,
+ desired_hash = VALUES(desired_hash),
+ synced_hash = VALUES(synced_hash),
+ boot_id = VALUES(boot_id), wipe_id = VALUES(wipe_id),
+ last_attempt_at = NOW(),
+ last_ok_at = COALESCE(VALUES(last_ok_at), last_ok_at),
+ report = VALUES(report), error = VALUES(error),
+ updated_at = NOW()`,
+ [serverId, state, desiredHash, syncedHash, bootId, wipeId, okAt, report, error],
+ )
+}
+
+// ---- the option source ----
+
+async function putCatalogue(serverId, permissions) {
+ await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId])
+
+ if (!permissions.length) return
+
+ await core.query(
+ `INSERT IGNORE INTO ${CATALOGUE} (server_id, permission)
+ VALUES ${placeholders(permissions, 2)}`,
+ permissions.flatMap((permission) => [serverId, permission]),
+ )
+}
+
+async function listCatalogue() {
+ return core.query(
+ `SELECT server_id AS serverId, permission FROM ${CATALOGUE} ORDER BY permission ASC`,
+ )
+}
+
+module.exports = {
+ GROUPS,
+ GRANTS,
+ PUSHED,
+ DRIFT,
+ listGroups,
+ getGroup,
+ upsertGroup,
+ deleteGroup,
+ listGroupPermissions,
+ setGroupPermissions,
+ listGroupMembers,
+ addGroupMember,
+ removeGroupMember,
+ listGrants,
+ getGrant,
+ insertGrant,
+ deleteGrant,
+ findUserByUsername,
+ listLinks,
+ listPushed,
+ addPushed,
+ removePushed,
+ replaceDrift,
+ listDrift,
+ getDrift,
+ deleteDrift,
+ queueRevocation,
+ listRevocations,
+ deleteRevocations,
+ ensureSyncRows,
+ listSync,
+ markDirty,
+ putSyncResult,
+ putCatalogue,
+ listCatalogue,
+}
diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js
new file mode 100644
index 0000000..5412fd1
--- /dev/null
+++ b/server/model/permissions/permissions.model.js
@@ -0,0 +1,356 @@
+// ── 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:
+//
+// 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.
+//
+// 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.
+
+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. */
+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.
+ */
+function normaliseName(value) {
+ return String(value || '').trim().toLowerCase()
+}
+
+/** Whether a scope reaches a server. */
+function inScope(scope, serverId) {
+ return scope === FLEET || 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.
+ */
+async function overview() {
+ const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([
+ db.listGroups(),
+ db.listGroupPermissions(),
+ db.listGroupMembers(),
+ db.listGrants(),
+ db.listSync(),
+ db.listDrift(),
+ db.listCatalogue(),
+ ])
+
+ const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }]))
+
+ 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,
+ catalogue: catalogueByPermission(catalogue),
+ }
+}
+
+/**
+ * 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.
+ */
+function collapseGrants(rows) {
+ const byId = new Map()
+
+ for (const row of rows) {
+ const existing = byId.get(row.id)
+
+ if (!existing) {
+ byId.set(row.id, {
+ id: row.id,
+ userId: row.userId,
+ username: row.username,
+ permission: row.permission,
+ scope: row.scope,
+ source: row.source,
+ note: row.note,
+ grantedAt: row.grantedAt,
+ accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [],
+ })
+
+ continue
+ }
+
+ if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null })
+ }
+
+ 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.
+ */
+function shapeSync(row) {
+ let report = null
+
+ if (row.report) {
+ try {
+ report = JSON.parse(row.report)
+ } catch {
+ report = null
+ }
+ }
+
+ return {
+ serverId: row.serverId,
+ state: row.state,
+ dirty: Boolean(row.dirty),
+ inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
+ lastAttemptAt: row.lastAttemptAt,
+ lastOkAt: row.lastOkAt,
+ 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))
+}
+
+/**
+ * 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] = await Promise.all([
+ db.listGroups(),
+ db.listGroupPermissions(),
+ db.listGroupMembers(),
+ db.listGrants(),
+ db.listLinks(),
+ ])
+
+ const steamIdsByUser = new Map()
+
+ for (const link of links) {
+ if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, [])
+ steamIdsByUser.get(link.userId).push(link.steamId)
+ }
+
+ return { groups, groupPermissions, members, grants, steamIdsByUser }
+}
+
+/**
+ * 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
+ *
+ * **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.
+ */
+function buildDesired(serverId, authored) {
+ const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
+
+ 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 = []
+
+ for (const group of scopedGroups)
+ rows.push({ kind: 'group', subject: group.name, object: '' })
+
+ for (const row of groupPermissions) {
+ if (!groupNames.has(row.groupName)) continue
+
+ const permission = normaliseName(row.permission)
+ permissionsByGroup.get(row.groupName).push(permission)
+ managed.add(permission)
+ rows.push({ kind: 'group-permission', subject: row.groupName, object: permission })
+ }
+
+ const seenMember = new Set()
+
+ for (const row of members) {
+ if (!groupNames.has(row.groupName)) continue
+
+ for (const steamId of steamIdsByUser.get(row.userId) || []) {
+ const key = `${row.groupName}:${steamId}`
+ if (seenMember.has(key)) continue
+ seenMember.add(key)
+
+ membersByGroup.get(row.groupName).push(steamId)
+ rows.push({ kind: 'member', subject: steamId, object: row.groupName })
+ }
+ }
+
+ const permissionsBySteamId = new Map()
+ const seenGrant = new Set()
+
+ for (const row of grants) {
+ if (!inScope(row.scope, serverId)) continue
+
+ const permission = normaliseName(row.permission)
+
+ // 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 })
+ }
+ }
+
+ const payload = {
+ groups: scopedGroups.map((group) => ({
+ name: group.name,
+ title: group.title || group.name,
+ rank: group.rank,
+ permissions: permissionsByGroup.get(group.name),
+ members: membersByGroup.get(group.name),
+ })),
+ grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
+ steamId,
+ permissions,
+ })),
+ managed: [...managed].sort(),
+ }
+
+ return { payload, rows, hash: hashRows(rows) }
+}
+
+/**
+ * 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.
+ */
+function hashRows(rows) {
+ const canonical = rows
+ .map((row) => `${row.kind} ${row.subject} ${row.object}`)
+ .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.
+ *
+ * `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.
+ */
+function retirements(pushed, desiredRows) {
+ const desired = new Set(desiredRows.map(rowKey))
+
+ return pushed.filter((row) => !desired.has(rowKey(row)))
+}
+
+module.exports = {
+ FLEET,
+ normaliseName,
+ inScope,
+ overview,
+ readAuthored,
+ buildDesired,
+ retirements,
+ hashRows,
+ rowKey,
+ collapseGrants,
+ shapeSync,
+}
diff --git a/server/permSync.js b/server/permSync.js
new file mode 100644
index 0000000..00881e1
--- /dev/null
+++ b/server/permSync.js
@@ -0,0 +1,342 @@
+// ── Keeping a game's permission store equal to what the site authored ─────
+//
+// 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.
+//
+// ── One verb (D32) ────────────────────────────────────────────────────────
+//
+// 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).
+//
+// ── When it runs ──────────────────────────────────────────────────────────
+//
+// Every tick asks a cheap question — does the digest of the desired set still
+// equal what this server last confirmed — and does nothing when the answer is
+// 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)
+// • 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
+// • 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.
+
+const core = require('./core')
+
+const db = require('./model/permissions/permissions.db')
+const model = require('./model/permissions/permissions.model')
+const servers = require('./model/servers/servers.model')
+const serversDb = require('./model/servers/servers.db')
+const sidecar = require('./sidecarClient')
+
+const log = core.logger('permissions')
+
+/** How often the loop asks whether anything needs pushing. */
+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.
+ */
+const AUDIT_MS = 15 * 60 * 1000
+
+/** How long to leave a failing server alone before trying again. */
+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.
+ */
+const MAX_ROWS = 15000
+
+let timer = null
+
+function start() {
+ if (timer) return
+
+ timer = setInterval(() => {
+ tick().catch((err) => log.error('permission sync tick failed', { error: err.message }))
+ }, TICK_MS)
+
+ if (timer.unref) timer.unref()
+}
+
+function stop() {
+ if (!timer) return
+
+ clearInterval(timer)
+ timer = null
+}
+
+/**
+ * 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.
+ */
+async function tick({ force = null } = {}) {
+ await db.ensureSyncRows()
+
+ const [rows, state, sync, authored] = await Promise.all([
+ servers.listForPolling(),
+ serversDb.listState(),
+ db.listSync(),
+ model.readAuthored(),
+ ])
+
+ const syncById = new Map(sync.map((row) => [row.serverId, row]))
+ const stateById = new Map(state.map((row) => [row.serverId, row]))
+
+ // `allSettled`, for the same reason the board poll uses it: one unreachable
+ // host must not stop the other five being reconciled.
+ await Promise.allSettled(
+ rows
+ .filter((server) => force === null || force === server.id)
+ .map((server) =>
+ syncOne(server, {
+ authored,
+ sync: syncById.get(server.id) || null,
+ state: stateById.get(server.id) || null,
+ force: force !== null,
+ }),
+ ),
+ )
+}
+
+/**
+ * Whether this server needs a push right now.
+ *
+ * Returns a reason rather than a boolean, because the reason is worth logging:
+ * "why did the website just write to my game server" is a question an operator
+ * asks, and `wipe` and `drift` are very different answers.
+ */
+function reasonToSync({ desiredHash, sync, state, force }) {
+ if (force) return 'requested'
+ if (!sync) return 'first'
+ if (sync.state !== 'ok' && sync.lastAttemptAt && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && !sync.dirty) {
+ return null
+ }
+ if (sync.state !== 'ok') return 'retry'
+ if (desiredHash !== sync.syncedHash) return 'changed'
+ if (sync.dirty) return 'dirty'
+
+ const bootId = state && state.bootId ? state.bootId : null
+ const wipeId = state && state.wipeId ? state.wipeId : null
+
+ // A restart or a wipe is the case R2 exists for: the game may have forgotten
+ // everything, and the site has not.
+ if (bootId && bootId !== sync.bootId) return 'restart'
+ if (wipeId && wipeId !== sync.wipeId) return 'wipe'
+
+ if (!sync.lastAttemptAt || age(sync.lastAttemptAt) >= AUDIT_MS) return 'audit'
+
+ return null
+}
+
+function age(value) {
+ const at = value instanceof Date ? value.getTime() : new Date(value).getTime()
+ 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 })
+
+ if (!reason) return null
+
+ const [pushed, revocations] = await Promise.all([
+ db.listPushed(server.id),
+ db.listRevocations(server.id),
+ ])
+
+ const retirements = model.retirements(pushed, desired.rows)
+ const retire = [
+ ...retirements.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
+ ...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',
+ desiredHash: desired.hash,
+ syncedHash: sync ? sync.syncedHash : null,
+ bootId,
+ wipeId,
+ report: null,
+ error,
+ })
+
+ return 'too-large'
+ }
+
+ log.info('syncing permissions', {
+ server: server.id,
+ reason,
+ rows: desired.rows.length,
+ retire: retire.length,
+ })
+
+ const result = await sidecar.permSync(server, {
+ setId: desired.hash,
+ groups: desired.payload.groups,
+ grants: desired.payload.grants,
+ managed: desired.payload.managed,
+ retire,
+ })
+
+ if (!result.ok) {
+ await db.putSyncResult(server.id, {
+ state: 'failed',
+ desiredHash: desired.hash,
+ syncedHash: sync ? sync.syncedHash : null,
+ bootId,
+ wipeId,
+ report: null,
+ error: result.status,
+ })
+
+ 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`.
+ if (report.kind === 'perm.error') {
+ await db.putSyncResult(server.id, {
+ state: 'failed',
+ desiredHash: desired.hash,
+ syncedHash: sync ? sync.syncedHash : null,
+ bootId,
+ wipeId,
+ report: null,
+ error: `the game refused the sync: ${report.reason || 'unknown'}`,
+ })
+
+ return report.reason || 'refused'
+ }
+
+ await applyReport(server, { desired, retire, report, bootId, wipeId })
+
+ return 'ok'
+}
+
+/**
+ * Record what the game said it did.
+ *
+ * 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.
+ */
+async function applyReport(server, { desired, retire, report, bootId, wipeId }) {
+ const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
+ const pending = new Set(report.pending || [])
+
+ // 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.
+ const landed = desired.rows.filter((row) => {
+ if (row.kind === 'grant' || row.kind === 'group-permission') return !unresolved.has(row.object)
+ if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
+ 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.
+ await db.removePushed(server.id, retire)
+
+ const revocations = await db.listRevocations(server.id)
+ await db.deleteRevocations(revocations.map((row) => row.id))
+
+ await db.replaceDrift(server.id, (report.foreign || []).map((row) => ({
+ kind: String(row.kind || ''),
+ subject: String(row.subject || ''),
+ object: String(row.object || ''),
+ })))
+
+ await db.putSyncResult(server.id, {
+ state: 'ok',
+ desiredHash: desired.hash,
+ syncedHash: desired.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,
+ })
+}
+
+module.exports = {
+ TICK_MS,
+ AUDIT_MS,
+ FAIL_BACKOFF_MS,
+ MAX_ROWS,
+ start,
+ stop,
+ tick,
+ syncOne,
+ reasonToSync,
+ applyReport,
+}
diff --git a/server/router/admin/permissions.controller.js b/server/router/admin/permissions.controller.js
new file mode 100644
index 0000000..e2649c7
--- /dev/null
+++ b/server/router/admin/permissions.controller.js
@@ -0,0 +1,424 @@
+// ── Admin · Rust · Permissions ────────────────────────────────────────────
+//
+// The authoring surface for R2. Everything here writes to the site's own tables
+// and marks the affected servers dirty; nothing here talks to a game. The push
+// is `permSync.js`'s loop, which is deliberate — a form that wrote to six game
+// hosts inside the request would fail differently for each of them and have no
+// honest status code to answer with.
+//
+// **The one exception is "sync now"**, which runs the loop's pass for one server
+// and waits for it. It exists because an operator who has just changed something
+// wants to see it land, and because waiting thirty seconds to find out that a
+// server is unreachable is a bad way to learn it.
+//
+// Every write logs an activity row. These rows decide who may do what inside
+// somebody's game server, which is the one thing on this module's admin tier
+// more consequential than the sidecar credential.
+
+const core = require('../../core')
+
+const db = require('../../model/permissions/permissions.db')
+const model = require('../../model/permissions/permissions.model')
+const permSync = require('../../permSync')
+const servers = require('../../model/servers/servers.model')
+
+const log = core.logger('admin:permissions')
+
+/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */
+async function overview(req, res) {
+ try {
+ res.json(await model.overview())
+ } catch (err) {
+ log.error('failed to read the permission model', { error: err.message })
+ res.status(500).json({ message: 'Failed to read the permission model' })
+ }
+}
+
+/**
+ * Create or update a group.
+ *
+ * The permission list is part of the same write, because that is how the form
+ * edits it: a group and what it carries are one idea on the screen, and two
+ * requests would leave a group briefly carrying the wrong set.
+ */
+async function putGroup(req, res) {
+ const name = model.normaliseName(req.params.name)
+ const scope = String(req.body.scope || model.FLEET)
+
+ try {
+ if (scope !== model.FLEET && !(await knownServer(scope))) {
+ return res.status(400).json({ message: 'That scope names no configured server' })
+ }
+
+ 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)
+
+ // 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 },
+ })
+
+ 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 !== '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' })
+
+ 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' })
+ }
+}
+
+/** Run the loop's pass now, for one server or for all of them, and report what happened. */
+async function syncNow(req, res) {
+ const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null
+
+ try {
+ if (serverId && !(await knownServer(serverId))) {
+ return res.status(404).json({ message: 'No such server' })
+ }
+
+ await db.markDirty(serverId || model.FLEET)
+ await permSync.tick({ force: serverId })
+
+ await core.activity.log({
+ req,
+ action: 'rust.perm.sync',
+ detail: { server: serverId || 'all' },
+ })
+
+ const state = await model.overview()
+ return res.json({ servers: state.servers, drift: state.drift })
+ } catch (err) {
+ log.error('a forced sync failed', { server: serverId, error: err.message })
+ return res.status(500).json({ message: 'Failed to run the sync' })
+ }
+}
+
+/** Every permission name any configured server has registered, with which ones know it. */
+async function catalogue(req, res) {
+ try {
+ const rows = await db.listCatalogue()
+ res.json({ permissions: groupCatalogue(rows) })
+ } catch (err) {
+ log.error('failed to read the catalogue', { error: err.message })
+ res.status(500).json({ message: 'Failed to read the permission catalogue' })
+ }
+}
+
+function groupCatalogue(rows) {
+ const byPermission = new Map()
+
+ for (const row of rows) {
+ if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
+ byPermission.get(row.permission).push(row.serverId)
+ }
+
+ return [...byPermission.entries()]
+ .map(([permission, serverIds]) => ({ permission, servers: serverIds }))
+ .sort((a, b) => a.permission.localeCompare(b.permission))
+}
+
+/**
+ * The user id a write is about, from either an id or a username.
+ *
+ * The form sends a name, because a form that made an operator type a numeric id
+ * would be a form nobody could use. The id form stays accepted because the
+ * client already holds one on the panel inside core's user page, and looking a
+ * name back up from it would be a round trip to answer a question it has
+ * already answered.
+ */
+async function resolveUser(body) {
+ if (body.userId) return Number(body.userId)
+ if (!body.username) return null
+
+ const user = await db.findUserByUsername(String(body.username).trim())
+ return user ? user.id : null
+}
+
+/** Whether a scope names a server row. A disabled server still counts — it exists. */
+async function knownServer(id) {
+ const rows = await servers.listForAdmin()
+ return rows.some((row) => row.id === id)
+}
+
+/** The website account that holds a Steam id, or null. */
+async function holderOf(steamId) {
+ const links = await db.listLinks()
+ return links.find((link) => link.steamId === steamId) || null
+}
+
+module.exports = {
+ overview,
+ putGroup,
+ deleteGroup,
+ addMember,
+ removeMember,
+ addGrant,
+ removeGrant,
+ adoptDrift,
+ revokeDrift,
+ syncNow,
+ catalogue,
+}
diff --git a/server/router/admin/permissions.router.js b/server/router/admin/permissions.router.js
new file mode 100644
index 0000000..69a3f3f
--- /dev/null
+++ b/server/router/admin/permissions.router.js
@@ -0,0 +1,184 @@
+// ── 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.
+//
+// **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.
+
+const core = require('../../core')
+
+const express = core.express
+const permissions = require('./permissions.controller')
+const { requireRole, validate } = core.middleware
+const { body, param } = 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
+
+permissionsRouter.get(
+ '/',
+ // #swagger.tags = ['Admin · Rust']
+ // #swagger.summary = 'The whole permission model'
+ // #swagger.description = 'Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.'
+ /* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */
+ requireRole('admin'),
+ permissions.overview,
+)
+
+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" } } } } */
+ requireRole('admin'),
+ permissions.catalogue,
+)
+
+permissionsRouter.put(
+ '/groups/:name',
+ // #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.'
+ /* #swagger.responses[204] = { description: 'Saved' } */
+ /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured 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),
+ validate,
+ permissions.putGroup,
+)
+
+permissionsRouter.delete(
+ '/groups/:name',
+ // #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.responses[204] = { description: 'Deleted' } */
+ /* #swagger.responses[404] = { description: 'No such group' } */
+ requireRole('admin'),
+ param('name').isString().isLength({ min: 1, max: 64 }),
+ validate,
+ permissions.deleteGroup,
+)
+
+permissionsRouter.post(
+ '/groups/:name/members',
+ // #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.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 }),
+ validate,
+ permissions.addMember,
+)
+
+permissionsRouter.delete(
+ '/groups/:name/members/:userId',
+ // #swagger.tags = ['Admin · Rust']
+ // #swagger.summary = 'Take an account out of a group'
+ /* #swagger.responses[204] = { description: 'Removed' } */
+ /* #swagger.responses[404] = { description: 'No such group, or that account is not in it' } */
+ requireRole('admin'),
+ param('name').isString().isLength({ min: 1, max: 64 }),
+ param('userId').isInt({ min: 1 }).toInt(),
+ validate,
+ permissions.removeMember,
+)
+
+permissionsRouter.post(
+ '/grants',
+ // #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' } */
+ 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 }),
+ validate,
+ permissions.addGrant,
+)
+
+permissionsRouter.delete(
+ '/grants/: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.responses[204] = { description: 'Removed' } */
+ /* #swagger.responses[404] = { description: 'No such grant' } */
+ requireRole('admin'),
+ param('id').isInt({ min: 1 }).toInt(),
+ validate,
+ permissions.removeGrant,
+)
+
+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.'
+ /* #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' } */
+ requireRole('admin'),
+ param('id').isInt({ min: 1 }).toInt(),
+ validate,
+ permissions.adoptDrift,
+)
+
+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.'
+ /* #swagger.responses[202] = { description: 'Queued for the next sync' } */
+ /* #swagger.responses[404] = { description: 'No such drift' } */
+ requireRole('admin'),
+ param('id').isInt({ min: 1 }).toInt(),
+ validate,
+ permissions.revokeDrift,
+)
+
+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.responses[200] = { description: 'The state of every server after the pass', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionSyncResult" } } } } */
+ /* #swagger.responses[404] = { description: 'No such server' } */
+ requireRole('admin'),
+ body('serverId').optional().isString().isLength({ min: 1, max: 64 }),
+ validate,
+ permissions.syncNow,
+)
+
+module.exports = permissionsRouter
diff --git a/server/router/admin/rust.router.js b/server/router/admin/rust.router.js
index c91d993..c0f7019 100644
--- a/server/router/admin/rust.router.js
+++ b/server/router/admin/rust.router.js
@@ -27,6 +27,11 @@ const { body, param } = core.validator
const adminRustRouter = express.Router()
+// R2's authoring surface, under `/rust/permissions`. Its own file because it is
+// its own subject — this router configures the bridge, that one decides who may
+// do what inside the game the bridge reaches.
+adminRustRouter.use('/permissions', require('./permissions.router'))
+
adminRustRouter.get(
'/servers',
// #swagger.tags = ['Admin · Rust']
diff --git a/server/router/admin/usersRust.controller.js b/server/router/admin/usersRust.controller.js
index 6fb2592..b45cd54 100644
--- a/server/router/admin/usersRust.controller.js
+++ b/server/router/admin/usersRust.controller.js
@@ -8,6 +8,9 @@
const core = require('../../core')
const links = require('../../model/links/links.model')
+const permissionsDb = require('../../model/permissions/permissions.db')
+const permissions = require('../../model/permissions/permissions.model')
+const servers = require('../../model/servers/servers.model')
const log = core.logger('admin')
@@ -68,4 +71,132 @@ async function removeLink(req, res) {
}
}
-module.exports = { listLinks, removeLink }
+/**
+ * GET /admin/users/:id/rust/permissions
+ *
+ * What this person may do in game, and — the part that is easy to leave out —
+ * whether any of it reaches anybody. A grant against an account with no linked
+ * Steam id is authored, stored, pushed nowhere and looks identical to a working
+ * one on every screen that does not say so.
+ */
+async function listPermissions(req, res) {
+ const userId = Number(req.params.id)
+
+ try {
+ const [groups, groupPermissions, members, grants, allLinks] = await Promise.all([
+ permissionsDb.listGroups(),
+ permissionsDb.listGroupPermissions(),
+ permissionsDb.listGroupMembers(),
+ permissionsDb.listGrants({ userId }),
+ permissionsDb.listLinks(),
+ ])
+
+ const theirs = new Set(
+ members.filter((row) => row.userId === userId).map((row) => row.groupName),
+ )
+
+ 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)
+ }
+
+ res.json({
+ groups: groups
+ .filter((group) => theirs.has(group.name))
+ .map((group) => ({
+ name: group.name,
+ title: group.title,
+ scope: group.scope,
+ permissions: carried.get(group.name) || [],
+ })),
+ grants: permissions.collapseGrants(grants).map((grant) => ({
+ id: grant.id,
+ permission: grant.permission,
+ scope: grant.scope,
+ source: grant.source,
+ note: grant.note,
+ grantedAt: grant.grantedAt,
+ })),
+ reaches: allLinks.filter((link) => link.userId === userId).map((link) => link.steamId),
+ })
+ } catch (err) {
+ log.error('failed to read a user’s Rust permissions', { error: err.message })
+ res.status(500).json({ message: 'Failed to read this user’s Rust permissions' })
+ }
+}
+
+/** POST /admin/users/:id/rust/permissions/grants */
+async function addGrant(req, res) {
+ const userId = Number(req.params.id)
+ const permission = permissions.normaliseName(req.body.permission)
+ const scope = String(req.body.scope || permissions.FLEET)
+
+ try {
+ if (scope !== permissions.FLEET) {
+ const known = await servers.listForAdmin()
+ if (!known.some((row) => row.id === scope)) {
+ return res.status(400).json({ message: 'That scope names no configured server' })
+ }
+ }
+
+ const { inserted } = await permissionsDb.insertGrant({
+ userId,
+ permission,
+ scope,
+ source: 'admin',
+ note: null,
+ grantedBy: req.user ? req.user.id : null,
+ })
+
+ if (inserted) {
+ await permissionsDb.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 a permission', { userId, permission, error: err.message })
+ return res.status(400).json({ message: 'That permission could not be granted' })
+ }
+}
+
+/**
+ * DELETE /admin/users/:id/rust/permissions/grants/:grantId
+ *
+ * **Scoped by the user as well as by the grant**, like every other write in this
+ * panel: a grant id belonging to somebody else answers `404` rather than
+ * removing a privilege from a person whose page nobody was looking at.
+ */
+async function removeGrant(req, res) {
+ const userId = Number(req.params.id)
+ const grantId = Number(req.params.grantId)
+
+ try {
+ const grant = await permissionsDb.getGrant(grantId)
+
+ if (!grant || grant.userId !== userId) {
+ return res.status(404).json({ message: 'That grant does not belong to this user' })
+ }
+
+ await permissionsDb.deleteGrant(grantId)
+ await permissionsDb.markDirty(grant.scope)
+
+ await core.activity.log({
+ req,
+ action: 'rust.perm.revoke',
+ detail: { userId, permission: grant.permission, scope: grant.scope },
+ })
+
+ return res.status(204).end()
+ } catch (err) {
+ log.error('failed to remove a grant', { userId, grant: grantId, error: err.message })
+ return res.status(500).json({ message: 'Failed to remove that permission' })
+ }
+}
+
+module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant }
diff --git a/server/router/admin/usersRust.router.js b/server/router/admin/usersRust.router.js
index feed707..24c830c 100644
--- a/server/router/admin/usersRust.router.js
+++ b/server/router/admin/usersRust.router.js
@@ -30,7 +30,7 @@
const core = require('../../core')
const express = core.express
-const { param } = core.validator
+const { body, param } = core.validator
const usersRust = require('./usersRust.controller')
const { validate } = core.middleware
@@ -70,4 +70,62 @@ usersRustRouter.delete(
usersRust.removeLink,
)
+// ── Phase 7: what this person may do in game ─────────────────────────────
+//
+// The same panel, one section lower. It is here rather than only on the
+// permissions screen because the question an operator actually has is about a
+// PERSON — "why can this player spawn a kit" is asked on their page, not on a
+// list of groups — and because the slot is already the place this module says
+// everything else it knows about one user.
+//
+// Both writes go through the ordinary authored tables and the ordinary loop. A
+// grant made here reaches the game when the mirror next reconciles, which is
+// seconds, and never inside this request.
+
+usersRustRouter.get(
+ '/rust/permissions',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'A user’s Rust privileges (admin only)'
+ // #swagger.description = 'The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[200] = { description: 'Their groups and grants', content: { "application/json": { schema: { $ref: "#/components/schemas/RustUserPermissions" } } } } */
+ param('id').isInt(),
+ validate,
+ usersRust.listPermissions,
+)
+
+usersRustRouter.post(
+ '/rust/permissions/grants',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'Grant a Rust permission to this user (admin only)'
+ // #swagger.description = 'Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirror’s next pass.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ /* #swagger.responses[201] = { description: 'Granted' } */
+ /* #swagger.responses[200] = { description: 'They already held it' } */
+ /* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ body('permission').isString().matches(/^[a-z0-9][a-z0-9._-]{0,127}$/i),
+ body('scope').optional().isString().isLength({ min: 1, max: 64 }),
+ validate,
+ usersRust.addGrant,
+)
+
+usersRustRouter.delete(
+ '/rust/permissions/grants/:grantId',
+ // #swagger.tags = ['Admin · Users']
+ // #swagger.summary = 'Remove a Rust permission from this user (admin only)'
+ // #swagger.description = 'Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody else’s privilege. The revoke reaches the game on the mirror’s next pass.'
+ // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
+ // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
+ // #swagger.parameters['grantId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grant to remove.' }
+ /* #swagger.responses[204] = { description: 'Removed' } */
+ /* #swagger.responses[404] = { description: 'No such grant for this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
+ param('id').isInt(),
+ param('grantId').isInt({ min: 1 }).toInt(),
+ validate,
+ usersRust.removeGrant,
+)
+
module.exports = usersRustRouter
diff --git a/server/scripts/swaggerFragment.js b/server/scripts/swaggerFragment.js
index 09a6c97..2b2e0c0 100644
--- a/server/scripts/swaggerFragment.js
+++ b/server/scripts/swaggerFragment.js
@@ -76,6 +76,16 @@ const SLOT_MOUNT = {
'admin.users.detail': '/api/v1/admin/users/:id',
}
+// A router mounted INSIDE a registered one with `use()` needs nothing here, and
+// that was worth finding out: swagger-autogen reads a FILE and follows its
+// `require`s, so `/rust/permissions` is generated with the right prefix from
+// `rust.router.js` alone. It is the opposite of the hole phase 6 found with the
+// slot — the registration walk cannot see a nested router, and the generator can.
+//
+// A nested router exists at all because a mount prefix is ONE path segment
+// (core's `PREFIX` is `/^\/[a-z0-9][a-z0-9-]*$/`), so `/rust/permissions` cannot
+// be declared in `module.json` and has to be a `use()` under `/rust`.
+
/**
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
*
diff --git a/server/sidecarClient.js b/server/sidecarClient.js
index 8683704..a13b1fe 100644
--- a/server/sidecarClient.js
+++ b/server/sidecarClient.js
@@ -52,9 +52,11 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
- * **3 — identity.** Protocol 2 was the read path; 3 adds the first message the
- * WEBSITE originates (`link.confirm`) and the two account frames the plugin
- * emits beside it. The bump lands here in the same change as the emitters,
+ * **4 — the permission mirror.** Protocol 2 was the read path, 3 the first
+ * message the WEBSITE originates (`link.confirm`); 4 is the first that WRITES
+ * to the game — the whole permission set the site authors for one server, and
+ * the report the plugin sends back. The bump lands here in the same change as
+ * the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -64,7 +66,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
-const PROTOCOL_VERSION = 3
+const PROTOCOL_VERSION = 4
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -210,6 +212,33 @@ const feedTail = (server) => request(server, '/feed')
const confirmLink = (server, code) =>
request(server, '/link/confirm', { method: 'POST', body: { code } })
+/**
+ * What one server's loaded plugins have registered, and the groups its store
+ * holds (protocol 4).
+ *
+ * The option source behind the authoring form (D33). It is a live read through
+ * to the game rather than anything cached at the sidecar, because the answer
+ * changes when an operator loads a plugin — and the whole reason to ask is to
+ * offer names that will actually resolve. It therefore fails when the game is
+ * down, like `/status` and unlike every store-backed read.
+ */
+const permCatalogue = (server) => request(server, '/permissions/catalogue')
+
+/**
+ * Push the whole permission set this site authors for one server (protocol 4).
+ *
+ * **The second call in this file that is not a GET, and the first that changes
+ * the game.** The body is the desired set plus what the site has withdrawn; the
+ * plugin diffs it against the live store, applies the difference and answers
+ * with a report — counts, the names it could not resolve, the memberships that
+ * are waiting on a first connection, and every holder the site did not author.
+ *
+ * **A refusal comes back `{ ok: true }`**, like a refused link code: `perm.error`
+ * and `perm.report` are both answers, and the sidecar keeps its own status codes
+ * for the transport. The caller discriminates on `data.kind`.
+ */
+const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set })
+
module.exports = {
TIMEOUT_MS,
PROTOCOL_VERSION,
@@ -221,5 +250,7 @@ module.exports = {
feed,
feedTail,
confirmLink,
+ permCatalogue,
+ permSync,
joinUrl,
}
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index ca16863..27fa245 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -195,6 +195,237 @@ module.exports = {
},
},
},
+ RustPermissionModel: {
+ 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.',
+ 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' } },
+ 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' },
+ },
+ drift: {
+ 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`.',
+ example: 'grant',
+ },
+ 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' },
+ },
+ },
+ },
+ catalogue: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' },
+ },
+ },
+ },
+ RustPermissionSyncState: {
+ type: 'object',
+ description: 'Whether one server’s store matches what the site authors, and what its last report said.',
+ properties: {
+ serverId: { type: 'string', example: 'main' },
+ state: {
+ type: 'string',
+ description: 'One of `pending`, `ok`, `failed`.',
+ example: 'ok',
+ },
+ inSync: {
+ type: 'boolean',
+ description: 'True when the last successful push carried the set the site currently authors.',
+ example: true,
+ },
+ dirty: { type: 'boolean', example: false },
+ lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
+ lastOkAt: { type: 'string', format: 'date-time', nullable: true },
+ error: {
+ type: 'string',
+ nullable: true,
+ description: 'Why the last attempt failed — a transport word (`timeout`, `no-token`, `protocol-mismatch`) or the game’s own refusal.',
+ example: null,
+ },
+ report: {
+ type: 'object',
+ nullable: true,
+ description: 'The plugin’s report from the last successful sync.',
+ properties: {
+ applied: {
+ type: 'object',
+ properties: {
+ grants: { type: 'integer', example: 2 },
+ revokes: { type: 'integer', example: 0 },
+ groupsCreated: { type: 'integer', example: 1 },
+ members: { type: 'integer', example: 3 },
+ },
+ },
+ alreadyCorrect: { type: 'integer', example: 14 },
+ unresolved: {
+ type: 'array',
+ description: 'Permission names no loaded plugin on that server has registered. A grant naming one lands nowhere and is not recorded as pushed.',
+ items: { type: 'string', example: 'kits.gold' },
+ },
+ pending: {
+ type: 'array',
+ description: 'Memberships waiting on a first connection: the store has no user record to put in a group yet.',
+ items: { type: 'string', example: '76561198000000000:vip' },
+ },
+ },
+ },
+ },
+ },
+ RustPermissionCatalogue: {
+ type: 'object',
+ description: 'Every permission name the configured servers have registered (GET /admin/rust/permissions/catalogue).',
+ properties: {
+ permissions: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' },
+ },
+ },
+ },
+ RustPermissionCatalogueEntry: {
+ type: 'object',
+ description: 'One registered permission name, and which servers know it.',
+ properties: {
+ permission: { type: 'string', example: 'kits.vip' },
+ servers: { type: 'array', items: { type: 'string', example: 'main' } },
+ },
+ },
+ RustPermissionSyncResult: {
+ type: 'object',
+ 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' } },
+ },
+ },
+ RustUserPermissions: {
+ type: 'object',
+ description: 'One person’s Rust privileges, for the admin.users.detail panel (GET /admin/users/{id}/rust/permissions).',
+ properties: {
+ groups: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', example: 'vip' },
+ title: { type: 'string', example: 'VIP' },
+ scope: { type: 'string', example: '*' },
+ permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
+ },
+ },
+ },
+ grants: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'integer', example: 7 },
+ permission: { type: 'string', example: 'kits.gold' },
+ scope: { type: 'string', example: 'main' },
+ source: { type: 'string', example: 'admin' },
+ grantedAt: { type: 'string', format: 'date-time' },
+ },
+ },
+ },
+ reaches: {
+ type: 'array',
+ description: 'The Steam accounts these privileges reach. Empty means this person has linked nothing and holds them on paper only.',
+ items: { type: 'string', example: '76561198000000000' },
+ },
+ },
+ },
RustSidecarProbe: {
type: 'object',
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js
index b718ec7..d97836d 100644
--- a/server/test/catalogue.test.js
+++ b/server/test/catalogue.test.js
@@ -87,13 +87,13 @@ test('every kind is classified exactly once', () => {
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
})
-test('the classification covers exactly the kinds protocol 3 defines', () => {
+test('the classification covers exactly the kinds protocol 4 defines', () => {
// The spec lives in another repository, so the list is restated here rather
// than parsed — and restating it is the point: adding a kind to the protocol
// without deciding who may see it has to fail somewhere, and this is where.
//
// Sourced from docs/rust-link/PROTOCOL.md §8.4.
- const PROTOCOL_3 = [
+ const PROTOCOL_4 = [
'player.connected',
'player.disconnected',
'player.respawned',
@@ -111,7 +111,8 @@ test('the classification covers exactly the kinds protocol 3 defines', () => {
'server.shutdown',
'account.link.requested',
'account.unlinked',
+ 'perm.drift',
]
- assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_3].sort())
+ assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
})
diff --git a/server/test/identityRoutes.test.js b/server/test/identityRoutes.test.js
index ee0a18b..12a2851 100644
--- a/server/test/identityRoutes.test.js
+++ b/server/test/identityRoutes.test.js
@@ -74,7 +74,14 @@ test('the admin.users.detail router merges the parent’s params and keeps its o
assert.equal(slot.router.mergeParams, true)
const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort()
- assert.deepEqual(paths, ['DELETE /rust/links/:steamId', 'GET /rust/links'])
+ assert.deepEqual(paths, [
+ 'DELETE /rust/links/:steamId',
+ // Phase 7 filled the same panel with what this person may do in game.
+ 'DELETE /rust/permissions/grants/:grantId',
+ 'GET /rust/links',
+ 'GET /rust/permissions',
+ 'POST /rust/permissions/grants',
+ ])
for (const route of routesOf(slot.router)) {
assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`)
diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js
new file mode 100644
index 0000000..bf24bb8
--- /dev/null
+++ b/server/test/permissions.test.js
@@ -0,0 +1,285 @@
+// ── The permission mirror ─────────────────────────────────────────────────
+//
+// The whole of R2's correctness is three set operations and one rule about what
+// counts as landed, and every test here is one of those:
+//
+// desired − pushed apply
+// pushed − desired RETIRE, because the site put it there and withdrew it
+// present − desired drift, which is reported and never undone
+//
+// and: a grant naming a permission the server has not registered did NOT land,
+// however much the push looked like it worked.
+//
+// The last one is the one with teeth. `GrantUserPermission` returns void, throws
+// nothing and logs nothing for an unregistered name (PLAN.md §12.2 rule 1), so a
+// module that recorded it as pushed would believe it had given a privilege it had
+// not — and would then RETIRE it from a server that never had it, which is a
+// no-op that reads as a success in every log.
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx } = require('./_fakes')
+
+function withCore(overrides = {}) {
+ const queries = []
+
+ require('../core')._reset()
+ require('../core').init(
+ fakeCtx({
+ db: {
+ query: (sql, params) => {
+ queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params })
+ const verb = sql.trim().split(/\s+/)[0].toUpperCase()
+ if (verb === 'SELECT') return Promise.resolve([])
+ return Promise.resolve({ affectedRows: 1 })
+ },
+ pool: {},
+ },
+ ...overrides,
+ }),
+ )
+
+ return queries
+}
+
+/** One authored set: a fleet group, a server-scoped group, and two grants. */
+function authored() {
+ return {
+ groups: [
+ { name: 'vip', title: 'VIP', rank: 10, scope: '*' },
+ { name: 'builder', title: 'Builder', rank: 0, scope: 'creative' },
+ ],
+ groupPermissions: [
+ { groupName: 'vip', permission: 'kits.vip' },
+ { groupName: 'builder', permission: 'buildtools.use' },
+ ],
+ members: [
+ { groupName: 'vip', userId: 1 },
+ { groupName: 'builder', userId: 2 },
+ ],
+ 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' },
+ ],
+ // One person with TWO Steam accounts, one with one, one with none.
+ steamIdsByUser: new Map([
+ [1, ['7656001', '7656099']],
+ [2, ['7656002']],
+ ]),
+ }
+}
+
+test('a grant reaches every Steam account its holder has linked (D28)', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const { payload } = model.buildDesired('main', authored())
+ const holders = payload.grants.map((row) => row.steamId).sort()
+
+ // `kits.gold` is authored once, against user 1, who holds two accounts.
+ assert.deepEqual(holders, ['7656001', '7656099'])
+ 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', () => {
+ 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…
+ 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'))
+})
+
+test('scope decides what a server is sent at all (D29)', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const main = model.buildDesired('main', authored())
+ const creative = model.buildDesired('creative', authored())
+
+ assert.deepEqual(main.payload.groups.map((g) => g.name), ['vip'])
+ 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'))
+})
+
+test('a group travels as a group: its members and its permissions are separate facts (D30)', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const { payload, rows } = model.buildDesired('main', authored())
+ const vip = payload.groups.find((group) => group.name === 'vip')
+
+ assert.deepEqual(vip.permissions, ['kits.vip'])
+ assert.deepEqual(vip.members.sort(), ['7656001', '7656099'])
+
+ // Three distinct row kinds, because the game can fail at each independently: a
+ // group can exist while a membership does not, which is exactly what happens
+ // for a player the store has never seen.
+ assert.ok(rows.some((r) => r.kind === 'group' && r.subject === 'vip'))
+ assert.ok(rows.some((r) => r.kind === 'group-permission' && r.object === 'kits.vip'))
+ assert.ok(rows.some((r) => r.kind === 'member' && r.object === 'vip'))
+})
+
+test('the digest does not depend on the order rows came out of the database', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const rows = model.buildDesired('main', authored()).rows
+ const shuffled = [...rows].reverse()
+
+ // An unsorted digest would differ between two reads of an unchanged set, and
+ // the loop would push to every game server on every tick for ever.
+ assert.equal(model.hashRows(rows), model.hashRows(shuffled))
+ assert.notEqual(model.hashRows(rows), model.hashRows(rows.slice(1)))
+})
+
+test('what this site put there and has withdrawn is the only thing retired (D31)', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const desired = [
+ { kind: 'grant', subject: '7656001', object: 'kits.gold' },
+ { kind: 'member', subject: '7656001', object: 'vip' },
+ ]
+
+ const pushed = [
+ { kind: 'grant', subject: '7656001', object: 'kits.gold' }, // still wanted
+ { kind: 'grant', subject: '7656001', object: 'kits.silver' }, // withdrawn
+ ]
+
+ assert.deepEqual(model.retirements(pushed, desired), [
+ { kind: 'grant', subject: '7656001', object: 'kits.silver' },
+ ])
+
+ // A hand grant is in NEITHER set, so it is never retired by this calculation —
+ // it reaches the operator as drift instead. That difference is the reason the
+ // pushed ledger exists at all.
+ assert.deepEqual(model.retirements([], desired), [])
+})
+
+test('a permission the server could not resolve is not recorded as pushed', async () => {
+ const queries = withCore()
+ const permSync = require('../permSync')
+
+ const desired = {
+ hash: 'h1',
+ rows: [
+ { kind: 'grant', subject: '7656001', object: 'kits.gold' },
+ { kind: 'grant', subject: '7656001', object: 'kits.vip' },
+ { kind: 'member', subject: '7656002', object: 'vip' },
+ { kind: 'member', subject: '7656003', object: 'vip' },
+ ],
+ }
+
+ const report = {
+ kind: 'perm.report',
+ applied: { grants: 1 },
+ unresolved: ['kits.vip'],
+ pending: ['7656003:vip'],
+ foreign: [],
+ }
+
+ // The catalogue refresh is a second call to the game; stubbed so the report
+ // path is what this test is about.
+ const sidecar = require('../sidecarClient')
+ sidecar.permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
+
+ await permSync.applyReport({ id: 'main' }, { desired, retire: [], report, bootId: null, wipeId: null })
+
+ const insert = queries.find((q) => q.sql.startsWith('INSERT IGNORE INTO rust_perm_pushed'))
+ assert.ok(insert, 'the rows that landed must be recorded')
+
+ const recorded = insert.params.join(' ')
+ assert.ok(recorded.includes('kits.gold'), 'a grant that landed is pushed')
+ assert.ok(!recorded.includes('kits.vip'), 'an unresolved permission never reached the store')
+ assert.ok(recorded.includes('7656002'), 'a membership that took is pushed')
+ assert.ok(!recorded.includes('7656003'), 'a pending membership is not in the game yet')
+})
+
+test('a restart, a wipe and a hand edit each provoke a sync; a quiet server does not', () => {
+ withCore()
+ const permSync = require('../permSync')
+
+ const base = {
+ state: 'ok',
+ dirty: false,
+ syncedHash: 'h1',
+ bootId: 'boot-1',
+ wipeId: 'w-1',
+ lastAttemptAt: new Date(),
+ }
+
+ const at = (sync, state = {}) =>
+ permSync.reasonToSync({
+ desiredHash: 'h1',
+ sync,
+ state: { bootId: 'boot-1', wipeId: 'w-1', ...state },
+ force: false,
+ })
+
+ assert.equal(at(base), null, 'nothing changed: no push')
+ assert.equal(at({ ...base, dirty: true }), 'dirty')
+ assert.equal(permSync.reasonToSync({ desiredHash: 'h2', sync: base, state: {}, force: false }), 'changed')
+ assert.equal(at(base, { bootId: 'boot-2' }), 'restart')
+ assert.equal(at(base, { wipeId: 'w-2' }), 'wipe')
+ assert.equal(at(null), 'first')
+
+ // The audit is the backstop that finds drift on a server nobody has touched.
+ const old = new Date(Date.now() - permSync.AUDIT_MS - 1000)
+ assert.equal(at({ ...base, lastAttemptAt: old }), 'audit')
+})
+
+test('a failing server is left alone for a backoff, unless something changed', () => {
+ withCore()
+ const permSync = require('../permSync')
+
+ const failing = {
+ state: 'failed',
+ dirty: false,
+ syncedHash: 'h1',
+ lastAttemptAt: new Date(),
+ }
+
+ assert.equal(
+ permSync.reasonToSync({ desiredHash: 'h1', sync: failing, state: {}, force: false }),
+ null,
+ 'a server that just failed is not hammered every thirty seconds',
+ )
+
+ assert.equal(
+ permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, dirty: true }, state: {}, force: false }),
+ 'retry',
+ 'an operator changing something is a reason to try again at once',
+ )
+
+ const older = new Date(Date.now() - permSync.FAIL_BACKOFF_MS - 1000)
+ assert.equal(
+ permSync.reasonToSync({ desiredHash: 'h1', sync: { ...failing, lastAttemptAt: older }, state: {}, force: false }),
+ 'retry',
+ )
+})
+
+test('names are lowered, because the store lowers them', () => {
+ withCore()
+ const model = require('../model/permissions/permissions.model')
+
+ const set = {
+ ...authored(),
+ grants: [{ id: 9, userId: 1, permission: 'Kits.GOLD', scope: '*', steamId: '7656001' }],
+ }
+
+ const { payload } = model.buildDesired('main', set)
+
+ // Pushed as `kits.gold`, read back as `kits.gold`. Unlowered, the site would
+ // push one name, find another, and report its own grant as drift for ever.
+ assert.deepEqual(payload.grants[0].permissions, ['kits.gold'])
+})
diff --git a/swagger-fragment.json b/swagger-fragment.json
index 8b797bb..38e51ad 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -1,5 +1,382 @@
{
"paths": {
+ "/api/v1/admin/rust/permissions": {
+ "get": {
+ "tags": [
+ "Admin · Rust"
+ ],
+ "summary": "The whole permission model",
+ "description": "Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.",
+ "responses": {
+ "200": {
+ "description": "The authored model and what each game reported",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustPermissionModel"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/api/v1/admin/rust/permissions/catalogue": {
+ "get": {
+ "tags": [
+ "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.",
+ "responses": {
+ "200": {
+ "description": "Every registered name, and which servers know it",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustPermissionCatalogue"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/api/v1/admin/rust/permissions/drift/{id}/adopt": {
+ "post": {
+ "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.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Adopted"
+ },
+ "400": {
+ "description": "That kind of drift cannot be adopted"
+ },
+ "404": {
+ "description": "Not Found"
+ },
+ "409": {
+ "description": "That Steam account is linked to nobody on this site"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/api/v1/admin/rust/permissions/drift/{id}/revoke": {
+ "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.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "description": "Queued for the next sync"
+ },
+ "404": {
+ "description": "No such drift"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/api/v1/admin/rust/permissions/grants": {
+ "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.",
+ "responses": {
+ "200": {
+ "description": "They already held it; nothing changed"
+ },
+ "201": {
+ "description": "Granted"
+ },
+ "400": {
+ "description": "Invalid body, or a scope naming no configured server"
+ },
+ "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"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/rust/permissions/grants/{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.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Removed"
+ },
+ "404": {
+ "description": "No such grant"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/api/v1/admin/rust/permissions/groups/{name}": {
+ "put": {
+ "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.",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Saved"
+ },
+ "400": {
+ "description": "Invalid body, or a scope naming no configured server"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "scope": {
+ "example": "any"
+ },
+ "title": {
+ "example": "any"
+ },
+ "rank": {
+ "example": "any"
+ },
+ "permissions": {
+ "example": "any"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "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.",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Deleted"
+ },
+ "404": {
+ "description": "No such group"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/api/v1/admin/rust/permissions/groups/{name}/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.",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Added"
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "404": {
+ "description": "No such group"
+ }
+ }
+ }
+ },
+ "/api/v1/admin/rust/permissions/groups/{name}/members/{userId}": {
+ "delete": {
+ "tags": [
+ "Admin · Rust"
+ ],
+ "summary": "Take an account out of a group",
+ "description": "",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "userId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Removed"
+ },
+ "404": {
+ "description": "No such group, or that account is not in it"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
+ "/api/v1/admin/rust/permissions/sync": {
+ "post": {
+ "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.",
+ "responses": {
+ "200": {
+ "description": "The state of every server after the pass",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustPermissionSyncResult"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such server"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "req": {
+ "example": "any"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/admin/rust/servers": {
"get": {
"tags": [
@@ -259,6 +636,167 @@
]
}
},
+ "/api/v1/admin/users/{id}/rust/permissions": {
+ "get": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "A user’s Rust privileges (admin only)",
+ "description": "The groups this person is in, the permissions granted to them directly, and the Steam accounts those privileges actually reach. An empty `reaches` means they have linked nothing and hold them on paper only.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Their groups and grants",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustUserPermissions"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "/api/v1/admin/users/{id}/rust/permissions/grants": {
+ "post": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "Grant a Rust permission to this user (admin only)",
+ "description": "Authored against the website account, so it reaches every Steam id they have linked — now and later. `scope` is a server id or `*` for the fleet. The push happens on the mirror’s next pass.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "They already held it"
+ },
+ "201": {
+ "description": "Granted"
+ },
+ "400": {
+ "description": "Invalid body, or a scope naming no configured server",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "permission": {
+ "example": "any"
+ },
+ "scope": {
+ "example": "any"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/admin/users/{id}/rust/permissions/grants/{grantId}": {
+ "delete": {
+ "tags": [
+ "Admin · Users"
+ ],
+ "summary": "Remove a Rust permission from this user (admin only)",
+ "description": "Scoped to this user as well as to the grant, so a wrong id on the URL removes nothing rather than somebody else’s privilege. The revoke reaches the game on the mirror’s next pass.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "User id."
+ },
+ {
+ "name": "grantId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "The grant to remove."
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Removed"
+ },
+ "404": {
+ "description": "No such grant for this user",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/player/rust/link": {
"post": {
"tags": [
@@ -1649,6 +2187,1205 @@
}
}
},
+ "RustPermissionModel": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "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."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "groups": {
+ "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"
+ }
+ }
+ }
+ }
+ },
+ "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": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 7
+ }
+ }
+ },
+ "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"
+ }
+ }
+ },
+ "permission": {
+ "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"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "servers": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "The state of the mirror, per configured server."
+ },
+ "items": {
+ "$ref": "#/components/schemas/RustPermissionSyncState"
+ }
+ }
+ },
+ "drift": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "What a game holds that the site did not author. Reported, never undone."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 3
+ }
+ }
+ },
+ "serverId": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main"
+ }
+ }
+ },
+ "kind": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "One of `grant`, `member`, `group-permission`."
+ },
+ "example": {
+ "type": "string",
+ "example": "grant"
+ }
+ }
+ },
+ "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"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "catalogue": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/RustPermissionCatalogueEntry"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustPermissionSyncState": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Whether one server’s store matches what the site authors, and what its last report said."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "serverId": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main"
+ }
+ }
+ },
+ "state": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "One of `pending`, `ok`, `failed`."
+ },
+ "example": {
+ "type": "string",
+ "example": "ok"
+ }
+ }
+ },
+ "inSync": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "True when the last successful push carried the set the site currently authors."
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "dirty": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ },
+ "lastAttemptAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "lastOkAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "error": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Why the last attempt failed — a transport word (`timeout`, `no-token`, `protocol-mismatch`) or the game’s own refusal."
+ },
+ "example": {}
+ }
+ },
+ "report": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "The plugin’s report from the last successful sync."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "applied": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "grants": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 2
+ }
+ }
+ },
+ "revokes": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 0
+ }
+ }
+ },
+ "groupsCreated": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 1
+ }
+ }
+ },
+ "members": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 3
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "alreadyCorrect": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 14
+ }
+ }
+ },
+ "unresolved": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "Permission names no loaded plugin on that server has registered. A grant naming one lands nowhere and is not recorded as pushed."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "kits.gold"
+ }
+ }
+ }
+ }
+ },
+ "pending": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "Memberships waiting on a first connection: the store has no user record to put in a group yet."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "76561198000000000:vip"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustPermissionCatalogue": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Every permission name the configured servers have registered (GET /admin/rust/permissions/catalogue)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "permissions": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/RustPermissionCatalogueEntry"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustPermissionCatalogueEntry": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "One registered permission name, and which servers know it."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "permission": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "kits.vip"
+ }
+ }
+ },
+ "servers": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "main"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustPermissionSyncResult": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "What a forced sync produced (POST /admin/rust/permissions/sync)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "servers": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/RustPermissionSyncState"
+ }
+ }
+ },
+ "drift": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustUserPermissions": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "One person’s Rust privileges, for the admin.users.detail panel (GET /admin/users/{id}/rust/permissions)."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "groups": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "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"
+ }
+ }
+ },
+ "scope": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "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"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "grants": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 7
+ }
+ }
+ },
+ "permission": {
+ "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"
+ },
+ "example": {
+ "type": "string",
+ "example": "admin"
+ }
+ }
+ },
+ "grantedAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "reaches": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "The Steam accounts these privileges reach. Empty means this person has linked nothing and holds them on paper only."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "76561198000000000"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"RustSidecarProbe": {
"type": "object",
"properties": {