feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198) #25

Merged
whitlocktech merged 1 commits from feat/perm-manager into edge 2026-09-28 16:39:13 +00:00
24 changed files with 5873 additions and 2589 deletions

View File

@@ -150,37 +150,48 @@ export const admin = {
// A write is followed by a re-read rather than a local edit of the model: what
// the screen is showing is partly the game's answer, and the honest way to learn
// the new one is to ask.
const P = '/admin/rust/permissions'
const enc = encodeURIComponent
export const adminPermissions = {
overview: () => req('/admin/rust/permissions'),
catalogue: () => req('/admin/rust/permissions/catalogue'),
overview: () => req(P),
catalogue: () => req(`${P}/catalogue`),
saveGroup: (name, body) =>
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }),
deleteGroup: (name) =>
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }),
// One server, as PermissionsManager shows one (D162).
server: (serverId) => req(`${P}/servers/${enc(serverId)}`),
players: (serverId, q) => req(`${P}/servers/${enc(serverId)}/players?q=${enc(q || '')}`),
setPolicy: (serverId, policy) => req(`${P}/servers/${enc(serverId)}/policy`, { method: 'PUT', body: { policy } }),
addMember: (name, username) =>
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, {
method: 'POST',
body: { username },
}),
removeMember: (name, userId) =>
req(
`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`,
{ method: 'DELETE' },
),
// A subject is `{ steamId }` or `{ userId }`; `everywhere` reaches every server.
grant: (serverId, subject, permissions, everywhere = false) =>
req(`${P}/servers/${enc(serverId)}/grant`, { method: 'POST', body: { ...subject, permissions, everywhere } }),
revoke: (serverId, subject, permissions, everywhere = false) =>
req(`${P}/servers/${enc(serverId)}/revoke`, { method: 'POST', body: { ...subject, permissions, everywhere } }),
removeException: (id) => req(`${P}/exceptions/${enc(id)}`, { method: 'DELETE' }),
grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }),
revoke: (id) =>
req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// Groups by id (D189). `here` is `{ onlyHere: true, serverId }` to split a
// shared group's copy off first (D190), or null to change it everywhere.
createGroup: (serverId, body) => req(`${P}/servers/${enc(serverId)}/groups`, { method: 'POST', body }),
updateGroup: (id, body, here = null) => req(`${P}/groups/${enc(id)}`, { method: 'PATCH', body: { ...body, ...(here || {}) } }),
deleteGroup: (id) => req(`${P}/groups/${enc(id)}`, { method: 'DELETE' }),
setGroupPermissions: (id, permissions, here = null) =>
req(`${P}/groups/${enc(id)}/permissions`, { method: 'PUT', body: { permissions, ...(here || {}) } }),
setGroupServers: (id, body) => req(`${P}/groups/${enc(id)}/servers`, { method: 'PUT', body }),
splitGroup: (id, serverId) => req(`${P}/groups/${enc(id)}/split`, { method: 'POST', body: { serverId } }),
addMember: (id, subject, here = null) =>
req(`${P}/groups/${enc(id)}/members`, { method: 'POST', body: { ...subject, ...(here || {}) } }),
removeMember: (id, subject, here = null) =>
req(`${P}/groups/${enc(id)}/members/remove`, { method: 'POST', body: { ...subject, ...(here || {}) } }),
clearMembers: (id, here = null) => req(`${P}/groups/${enc(id)}/members/clear`, { method: 'POST', body: { ...(here || {}) } }),
adoptDrift: (id) =>
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }),
revokeDrift: (id) =>
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
// What waits for a person (D161).
adoptDrift: (id) => req(`${P}/drift/${enc(id)}/adopt`, { method: 'POST' }),
revokeDrift: (id) => req(`${P}/drift/${enc(id)}/revoke`, { method: 'POST' }),
acceptDrift: (id) => req(`${P}/drift/${enc(id)}/accept`, { method: 'POST' }),
restoreDrift: (id) => req(`${P}/drift/${enc(id)}/restore`, { method: 'POST' }),
dismissDrift: (id) => req(`${P}/drift/${enc(id)}/dismiss`, { method: 'POST' }),
sync: (serverId = null) =>
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
sync: (serverId = null) => req(`${P}/sync`, { method: 'POST', body: serverId ? { serverId } : {} }),
}
// ── admin · visibility ────────────────────────────────────────────────────

View File

@@ -196,13 +196,15 @@ export function VoiceCard() {
Say them as
<select value={data.voice} onChange={(e) => choose(e.target.value)} disabled={busy} style={inputStyle}>
<option value="">Plain chat</option>
{data.options.map((o) => <option key={o.group} value={o.group}>{o.group} — {o.title}</option>)}
{data.options.map((o) => (
<option key={o.group} value={o.group}>{o.name} — {o.title} ({o.where})</option>
))}
</select>
</label>
{data.voice && !current && (
<p style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
The group “{data.voice}” no longer has a chat style, so lines are said in plain chat until it has one again or
another voice is chosen.
The chosen group no longer has a chat style, so lines are said in plain chat until it has one again or another
voice is chosen.
</p>
)}
{current && <p className="dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>The line: <code>{current.format}</code></p>}

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,12 @@
"routes": [
{
"method": "DELETE",
"path": "/api/v1/admin/rust/permissions/grants/:id",
"path": "/api/v1/admin/rust/permissions/exceptions/:id",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/rust/permissions/groups/:name",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/rust/permissions/groups/:name/members/:userId",
"path": "/api/v1/admin/rust/permissions/groups/:id",
"tier": "public"
},
{
@@ -66,6 +61,16 @@
"path": "/api/v1/admin/rust/permissions/catalogue",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/permissions/servers/:serverId",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/permissions/servers/:serverId/players",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/servers",
@@ -166,16 +171,36 @@
"path": "/api/v1/public/rust/servers/:id/wipes",
"tier": "public"
},
{
"method": "PATCH",
"path": "/api/v1/admin/rust/permissions/groups/:id",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/config/:serverId/file",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/drift/:id/accept",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/drift/:id/adopt",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/drift/:id/dismiss",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/drift/:id/restore",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/drift/:id/revoke",
@@ -183,12 +208,37 @@
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/grants",
"path": "/api/v1/admin/rust/permissions/groups/:id/members",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/groups/:name/members",
"path": "/api/v1/admin/rust/permissions/groups/:id/members/clear",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/groups/:id/members/remove",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/groups/:id/split",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/servers/:serverId/grant",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/servers/:serverId/groups",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/servers/:serverId/revoke",
"tier": "public"
},
{
@@ -223,7 +273,17 @@
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/permissions/groups/:name",
"path": "/api/v1/admin/rust/permissions/groups/:id/permissions",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/permissions/groups/:id/servers",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/permissions/servers/:serverId/policy",
"tier": "public"
},
{

View File

@@ -49,6 +49,7 @@ const eventWorld = require('./eventWorld')
const ingest = require('./ingest')
const mapImages = require('./mapImages')
const permSync = require('./permSync')
const permissionsDb = require('./model/permissions/permissions.db')
const titleSync = require('./titleSync')
const servers = require('./model/servers/servers.model')
const sidecar = require('./sidecarClient')
@@ -257,6 +258,16 @@ async function sweep() {
async function onBoot() {
await refresh()
// The permission manager's rebuild (PLAN_REDESIGNS §1) keeps groups in new
// tables. Copied once, before the loop can push anything, so no sync ever
// sees a site with its groups missing. A failure is logged, not fatal: the
// copy runs again next boot, and until then the site simply has no groups.
try {
const copied = await permissionsDb.migrateGroups()
if (copied) log.info('permission groups copied into the rebuilt tables', { groups: copied })
} catch (err) {
log.error('could not copy the permission groups', { error: err.message })
}
// The permission mirror owns its own loop and its own cadence (see
// `permSync.js`). It is started rather than run here: a first pass would write
// to every configured game server before the website had finished booting, and

View File

@@ -19,6 +19,17 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
-- The permission manager, rebuilt (PLAN_REDESIGNS §1). Children before
-- `rust_permgroups`, which they reference.
DROP TABLE IF EXISTS rust_perm_exceptions;
DROP TABLE IF EXISTS rust_perm_steam_grants;
DROP TABLE IF EXISTS rust_permgroup_chat;
DROP TABLE IF EXISTS rust_permgroup_steam_members;
DROP TABLE IF EXISTS rust_permgroup_members;
DROP TABLE IF EXISTS rust_permgroup_permissions;
DROP TABLE IF EXISTS rust_permgroup_servers;
DROP TABLE IF EXISTS rust_permgroups;
-- Phase 17. `rust_perm_group_chat` before `rust_perm_groups`, which it
-- references; the rest of this phase is columns, which go with their tables.
DROP TABLE IF EXISTS rust_perm_group_chat;

View File

@@ -1011,3 +1011,155 @@ ALTER TABLE rust_perm_pushed ADD COLUMN IF NOT EXISTS value VARCHAR(255) NULL;
-- The value a changed field holds in the game, for a `chat-field` drift row.
-- NULL on every other kind: a foreign grant is its own description.
ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS detail VARCHAR(255) NULL;
-- ── The permission manager, rebuilt (PLAN_REDESIGNS §1) ────────────────────
--
-- The site owns EVERY permission and group on a server now (D160), read from
-- the plugin's inventory and imported on first contact (D198). Three things the
-- tables above cannot hold made new ones necessary:
--
-- • A group belongs to ONE server unless an admin shares it (D189). The old
-- `rust_perm_groups` is keyed by name fleet-wide, so two servers' `vip`
-- groups with different contents could not both exist. A group is now a row
-- with its own id; the servers it is on are rows beside it.
-- • A holder may be a Steam account nobody has linked (D188). The authored
-- tables above are keyed by website user (D28), and most of a real store's
-- holders never link.
-- • An in-game change affects that server only (D190), even to a row that
-- reaches more servers — so a fleet-wide grant can carry exceptions.
--
-- The old group tables stay, unread: `permissions.db.migrateGroups` copies them
-- here once, and a downgrade still finds them as they were.
CREATE TABLE IF NOT EXISTS rust_permgroups (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
-- Verbatim, never trimmed: Carbon's own titles end in a space ("Default "),
-- and a trimmed copy would be "changed" by every sync.
title VARCHAR(120) NOT NULL DEFAULT '',
`rank` INT NOT NULL DEFAULT 0,
-- A group NAME on the same server, or ''. Both frameworks store it by name.
parent VARCHAR(64) NOT NULL DEFAULT '',
-- 1: on every server, including servers added later, except those
-- `rust_permgroup_servers` excludes. 0: on exactly the servers it includes.
all_servers TINYINT(1) NOT NULL DEFAULT 0,
-- `admin` (made on the site), `imported` (the first inventory, D198),
-- `adopted` (a later in-game change, D190), `split` (D190's copy),
-- `migrated` (copied from the old tables).
source VARCHAR(32) NOT NULL DEFAULT 'admin',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rust_permgroups_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Which servers a group is on. `included` 1 names a server a group is on; 0
-- takes one server out of an all-servers group — the split D190 makes when that
-- server's copy changed in the game. The model refuses two groups of one name on
-- one server; a unique key cannot say it across `all_servers`.
CREATE TABLE IF NOT EXISTS rust_permgroup_servers (
group_id INT UNSIGNED NOT NULL,
server_id VARCHAR(64) NOT NULL,
included TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (group_id, server_id),
KEY idx_rust_permgroup_servers_server (server_id),
CONSTRAINT fk_rust_permgroup_servers_group
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE,
CONSTRAINT fk_rust_permgroup_servers_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- What a group carries, the same on every server it is on.
CREATE TABLE IF NOT EXISTS rust_permgroup_permissions (
group_id INT UNSIGNED NOT NULL,
permission VARCHAR(128) NOT NULL,
PRIMARY KEY (group_id, permission),
CONSTRAINT fk_rust_permgroup_permissions_group
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Members who are website accounts, reaching every Steam account they link (D28).
CREATE TABLE IF NOT EXISTS rust_permgroup_members (
group_id INT UNSIGNED NOT NULL,
user_id INT NOT NULL,
added_by INT NULL,
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id, user_id),
KEY idx_rust_permgroup_members_user (user_id),
CONSTRAINT fk_rust_permgroup_members_group
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE,
CONSTRAINT fk_rust_permgroup_members_user
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Members who are one Steam account, linked or not (D188).
CREATE TABLE IF NOT EXISTS rust_permgroup_steam_members (
group_id INT UNSIGNED NOT NULL,
steam_id VARCHAR(32) NOT NULL,
source VARCHAR(32) NOT NULL DEFAULT 'admin',
added_by INT NULL,
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_id, steam_id),
KEY idx_rust_permgroup_steam_members_steam (steam_id),
CONSTRAINT fk_rust_permgroup_steam_members_group
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- A group's BetterChat style (D138), per group row rather than per name: one
-- server's own `vip` may be styled differently from another server's.
CREATE TABLE IF NOT EXISTS rust_permgroup_chat (
group_id INT UNSIGNED NOT NULL,
field VARCHAR(32) NOT NULL,
value VARCHAR(255) NOT NULL,
PRIMARY KEY (group_id, field),
CONSTRAINT fk_rust_permgroup_chat_group
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- A permission held by one Steam account, linked or not (D188). The import and
-- auto-adopt write these, and so does a site toggle for an UNLINKED player.
CREATE TABLE IF NOT EXISTS rust_perm_steam_grants (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
steam_id VARCHAR(32) NOT NULL,
permission VARCHAR(128) NOT NULL,
scope VARCHAR(64) NOT NULL DEFAULT '*',
source VARCHAR(32) NOT NULL DEFAULT 'admin',
note VARCHAR(255) NULL,
granted_by INT NULL,
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_rust_perm_steam_grant (steam_id, permission, scope),
KEY idx_rust_perm_steam_grant_steam (steam_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- "Everywhere except here" (D190): a grant that reaches more than one server,
-- removed on one of them in the game, keeps reaching the others, including
-- servers added later, which a rewrite into per-server rows would lose.
-- `holder` says which grants table `grant_id` is in: `user` or `steam`. No
-- foreign key can name two tables, so deleting a grant deletes its exceptions
-- in the same model call.
CREATE TABLE IF NOT EXISTS rust_perm_exceptions (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
holder VARCHAR(8) NOT NULL,
grant_id INT UNSIGNED NOT NULL,
server_id VARCHAR(64) NOT NULL,
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_rust_perm_exception (holder, grant_id, server_id),
CONSTRAINT fk_rust_perm_exceptions_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The registering plugin (PLAN_REDESIGNS §0.1), from the inventory. NULL for a
-- name no plugin owns; Carbon's built-in modules register theirs that way.
ALTER TABLE rust_perm_catalogue ADD COLUMN IF NOT EXISTS owner VARCHAR(64) NULL;
-- When this server's store was first imported (D160, D198). NULL until the first
-- inventory completes, and until then every sync imports.
ALTER TABLE rust_perm_sync ADD COLUMN IF NOT EXISTS imported_at DATETIME NULL;
-- What a change made in the game becomes (D161): `auto-adopt` (the default),
-- `adopt` (a person answers each one), or `revoke` (the site's set wins).
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS perm_policy VARCHAR(16) NOT NULL DEFAULT 'auto-adopt';
-- Which way a "needs a person" row went: `added` or `removed` in the game, or
-- `split` (D190 gave a server its own copy of a shared group, and says so in
-- case the change was meant for every server).
ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS direction VARCHAR(16) NOT NULL DEFAULT 'added';

View File

@@ -0,0 +1,210 @@
// ── Carrying out what the reconciler decided ──────────────────────────────
//
// `reconcile.plan` says WHAT a change made in the game becomes; this file writes
// it into the site's own record. Every write here is about ONE server (D190): a
// change in one game affects that server and nothing else, even when the site's
// row reaches further.
//
// • A grant that reaches only this server is deleted or written outright.
// • A grant that reaches more (a fleet grant, or a user's grant scoped `*`)
// gains an EXCEPTION for this server, and keeps reaching every other one.
// • A group shared with other servers is SPLIT: this server gets its own copy,
// the change is made to the copy, and the shared group stops covering it. A
// notice says so, in case the change was meant for every server.
//
// Ops are applied one at a time and each re-reads what it needs, because an
// earlier op in the same plan may have split the group a later one writes to.
// `permSync` runs a plan under one lock for the whole fleet, so two servers'
// plans never split the same shared group at once.
const db = require('./permissions.db')
const model = require('./permissions.model')
/** The site's group of this name on this server, or null (D189). */
async function groupOn(name, serverId) {
const [groups, groupServers] = await Promise.all([db.listGroups(), db.listGroupServers()])
return model.groupsOn(serverId, { groups, groupServers }).find((group) => group.name === name) || null
}
/**
* The group of this name that belongs to THIS server alone, splitting a shared
* one if that is what covers it (D190). Null when the site has no such group.
*/
async function ownGroup(name, serverId) {
const group = await groupOn(name, serverId)
if (!group) return null
const groupServers = await db.listGroupServers()
if (!model.isShared(group, model.serversByGroup(groupServers))) return group
const copy = await db.copyGroup(group.id, 'split')
await db.setGroupServers(copy, { allServers: false, servers: [serverId] })
await db.removeGroupFromServer(group.id, serverId)
await db.noteSplit(serverId, {
group: name,
detail: `changed in the game on ${serverId}; that server now has its own copy of "${name}"`,
})
return db.getGroup(copy)
}
/** The Steam ids and user linked to one Steam id, for finding a user's grant. */
async function userOf(steamId) {
const links = await db.listLinks()
const link = links.find((row) => row.steamId === steamId)
return link ? link.userId : null
}
/**
* A grant the game holds and the site does not. If a grant that reaches this
* server was only kept off it by an EXCEPTION, the exception is what the game
* just undid, so the exception goes. Otherwise a Steam-account grant for this
* server alone is written (D188, D190).
*/
async function adoptGrant(serverId, { steamId, permission, source }) {
const exceptions = (await db.listExceptions()).filter((e) => e.serverId === serverId)
if (exceptions.length) {
const userId = await userOf(steamId)
const [userGrants, steamGrants] = await Promise.all([
userId === null ? [] : db.listGrants({ userId }),
db.listSteamGrants({ steamId }),
])
const candidates = [
...userGrants.map((g) => ({ holder: 'user', id: g.id, permission: g.permission, scope: g.scope })),
...steamGrants.map((g) => ({ holder: 'steam', id: g.id, permission: g.permission, scope: g.scope })),
].filter((g) => model.normaliseName(g.permission) === permission && model.inScope(g.scope, serverId))
for (const grant of candidates) {
const exception = exceptions.find((e) => e.holder === grant.holder && Number(e.grantId) === Number(grant.id))
if (exception) {
await db.deleteException(exception.id)
return
}
}
}
await db.insertSteamGrant({ steamId, permission, scope: serverId, source })
}
/**
* A grant the site holds and the game no longer does. Each source that put it
* on this server stops doing so: one scoped to this server alone is deleted; one
* that reaches further gains an exception here. An event's grant is left to the
* event (the reconciler never sends one here).
*/
async function dropGrant(serverId, { sources = [] }) {
for (const source of sources) {
if (source.type !== 'userGrant' && source.type !== 'steamGrant') continue
const holder = source.type === 'userGrant' ? 'user' : 'steam'
if (source.scope === serverId) {
if (holder === 'user') await db.deleteGrant(source.id)
else await db.deleteSteamGrant(source.id)
} else {
await db.addException({ holder, grantId: source.id, serverId })
}
}
}
/** Run one op. Returns a short line for the log. */
async function applyOp(serverId, op) {
switch (op.op) {
case 'adoptGroup': {
// A group of this name may already exist on another server, or be shared
// with every server but this one: either way this server gets its own.
const existing = await groupOn(op.name, serverId)
if (existing) return `group ${op.name}: already the site's`
const id = await db.insertGroup({ name: op.name, title: op.title, rank: op.rank, parent: op.parent, source: op.source })
await db.setGroupServers(id, { allServers: false, servers: [serverId] })
return `group ${op.name}: adopted`
}
case 'setGroupAttrs': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.updateGroup(group.id, { title: op.title, rank: op.rank, parent: op.parent })
return `group ${op.group}: title, rank and parent from the game`
}
case 'adoptGroupPermission': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.addGroupPermission(group.id, op.permission)
return `group ${op.group} + ${op.permission}`
}
case 'dropGroupPermission': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.removeGroupPermission(group.id, op.permission)
return `group ${op.group} − ${op.permission}`
}
case 'adoptMember': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.addGroupSteamMember(group.id, op.steamId, { source: op.source })
return `${op.steamId} in ${op.group}`
}
case 'dropMember': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
// Whichever way the site had them in it: as a Steam account, and as the
// website account that account is linked to.
await db.removeGroupSteamMember(group.id, op.steamId)
const userId = await userOf(op.steamId)
if (userId !== null) await db.removeGroupMember(group.id, userId)
return `${op.steamId} out of ${op.group}`
}
case 'adoptGrant':
await adoptGrant(serverId, op)
return `${op.steamId} + ${op.permission}`
case 'dropGrant':
await dropGrant(serverId, op)
return `${op.steamId} − ${op.permission}`
case 'dropGroup': {
const group = await groupOn(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
const groupServers = await db.listGroupServers()
if (model.isShared(group, model.serversByGroup(groupServers))) {
await db.removeGroupFromServer(group.id, serverId)
return `group ${op.group}: no longer on ${serverId}`
}
await db.deleteGroup(group.id)
return `group ${op.group}: deleted`
}
default:
return `unknown op ${op.op}`
}
}
/** Run a plan's ops in order. One op failing does not stop the rest. */
async function applyOps(serverId, ops, log = null) {
const done = []
for (const op of ops) {
try {
// eslint-disable-next-line no-await-in-loop
done.push(await applyOp(serverId, op))
} catch (err) {
done.push(`${op.op} failed: ${err.message}`)
if (log) log.warn('permission op failed', { server: serverId, op: op.op, error: err.message })
}
}
return done
}
module.exports = { groupOn, ownGroup, applyOp, applyOps }

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,49 @@
// ── The authored set, and what it means for one server ────────────────────
//
// This file turns "what an operator wrote on the website" into "what one game
// server's store should contain", which is where four of phase 7's decisions
// actually live:
// This file turns "what the site holds" into "what one game server's store
// should contain". Since the permission manager was rebuilt (PLAN_REDESIGNS §1)
// the site holds EVERYTHING on every server — what was there before it, what an
// admin made, and what was changed in the game (D160) — so the decisions that
// live here are:
//
// D28 a grant is authored against a WEBSITE USER and resolved to every Steam
// id they have linked, here, at the moment of the push.
// D29 every authored row carries a scope — one server, or `*` for the fleet —
// and a server sees only what names it.
// D30 groups travel as groups. Membership is a separate wire fact from the
// permissions the group carries, because the game stores them separately
// and one of the two can fail on its own (§12.2 rule 4).
// D31 the difference between the desired set and what this site has already
// pushed is what gets retired. Anything else in the store is drift, and
// drift is reported rather than undone.
// D28 a grant or membership held by a WEBSITE USER reaches every Steam id
// they have linked, resolved here at the moment of the push.
// D188 one held by a STEAM ACCOUNT reaches exactly that account, linked or not.
// D29 a grant carries a scope — one server, or `*` for the fleet — and a
// server sees only what names it. D190 lets a fleet grant carry
// exceptions: "every server except this one".
// D189 a group belongs to one server unless an admin shares it. What it
// carries and who is in it are the group's, and the same on every server
// it is on.
// D31 the difference between the desired set and what this site has pushed
// is what gets retired.
//
// Nothing here talks to a sidecar — `permSync.js` does that. The split is the
// usual one and earns its keep twice over here: the whole of the interesting
// logic is a pure function of four tables, so it is tested without a game, a
// sidecar, or a database.
// `buildDesired` also says, for each row, which authored rows produced it (its
// SOURCES). The reconciler needs that to answer a change made in the game: a
// grant removed in the game is deleted when it was this server's alone, and gains
// an exception when it reached further (D190).
//
// Nothing here talks to a sidecar — `permSync.js` does that — and nothing here
// writes: every function below is a pure function of rows, tested without a
// game, a sidecar, or a database.
const crypto = require('node:crypto')
const db = require('./permissions.db')
/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */
/** A scope that means every server. */
const FLEET = '*'
/**
* Permission and group names, as both frameworks store them.
*
* Lowercased on the way in, because the store lowers them and a site that did
* not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own
* grant as drift for ever.
* Groups neither framework lets go of: they exist on every server by the
* framework's own rule. They are imported and editable, and never retired.
* `moderator` is Carbon's.
*/
const BUILTIN_GROUPS = new Set(['default', 'admin', 'moderator'])
/**
* Permission and group names, as both frameworks store them. Lowercased on the
* way in, because the store lowers them.
*/
function normaliseName(value) {
return String(value || '').trim().toLowerCase()
@@ -44,90 +55,72 @@ function inScope(scope, serverId) {
}
/**
* Everything the authoring screen renders, in one read.
*
* Assembled here rather than in SQL because the shape is a tree — a group with
* its permissions and its members — and the alternative is either four round
* trips per group or one join that repeats every group row once per member.
* A group's title, rank and parent as one comparable value, stored on its
* `group` ledger row. The title is kept verbatim — Carbon's own end in a space —
* so the value the site pushed and the value the game reports are the same
* string when nothing changed.
*/
async function overview() {
const [groups, groupPermissions, members, grants, sync, drift, catalogue, groupChat] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listSync(),
db.listDrift(),
db.listCatalogue(),
db.listGroupChat(),
])
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [], chat: null }]))
// Phase 17: a group's BetterChat style, or null for a group without one.
for (const [name, fields] of chatByGroup(groupChat)) {
const group = byGroup.get(name)
if (group) group.chat = fields
}
for (const row of groupPermissions) {
const group = byGroup.get(row.groupName)
if (group) group.permissions.push(row.permission)
}
// A member with two linked Steam accounts arrives as two rows from the join,
// and is one person on the screen — holding BOTH accounts, not the first one
// the join happened to return. The screen needs all of them: a membership is
// pushed per account, and it can be waiting on one while it landed on another.
const memberByKey = new Map()
for (const row of members) {
const group = byGroup.get(row.groupName)
if (!group) continue
const key = `${row.groupName}:${row.userId}`
let member = memberByKey.get(key)
if (!member) {
member = {
userId: row.userId,
username: row.username,
accounts: [],
addedAt: row.addedAt,
}
memberByKey.set(key, member)
group.members.push(member)
}
if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null })
}
return {
groups: [...byGroup.values()],
grants: collapseGrants(grants),
servers: sync.map(shapeSync),
drift: drift.map((row) => ({ ...row, detail: row.detail === undefined ? null : row.detail })),
catalogue: catalogueByPermission(catalogue),
}
function groupValue(title, rank, parent) {
return JSON.stringify([String(title == null ? '' : title), Number(rank) || 0, normaliseName(parent)])
}
/** Style rows folded into one object per group: `name → { Field: value }`. */
/** `groupId → Map(serverId → included)`. */
function serversByGroup(groupServers) {
const out = new Map()
for (const row of groupServers || []) {
if (!out.has(row.groupId)) out.set(row.groupId, new Map())
out.get(row.groupId).set(row.serverId, Boolean(row.included))
}
return out
}
/** Whether a group is on a server (D189). */
function groupCovers(group, serverRows, serverId) {
const rows = serverRows.get(group.id)
const row = rows ? rows.get(serverId) : undefined
return group.allServers ? row !== false : row === true
}
/** The servers a group is on, out of a list of server ids. */
function groupReach(group, serverRows, serverIds) {
return serverIds.filter((id) => groupCovers(group, serverRows, id))
}
/**
* The groups on one server, one per name. The model refuses a second group of a
* name on a server; should the tables ever hold one anyway, the older wins and
* the newer is ignored rather than both being pushed as one.
*/
function groupsOn(serverId, authored) {
const serverRows = serversByGroup(authored.groupServers)
const byName = new Map()
for (const group of [...authored.groups].sort((a, b) => a.id - b.id)) {
if (!groupCovers(group, serverRows, serverId)) continue
if (!byName.has(group.name)) byName.set(group.name, group)
}
return [...byName.values()]
}
/** Style rows folded into one object per group: `groupId → { Field: value }`. */
function chatByGroup(rows) {
const out = new Map()
for (const row of rows || []) {
if (!out.has(row.groupName)) out.set(row.groupName, {})
out.get(row.groupName)[row.field] = row.value
if (!out.has(row.groupId)) out.set(row.groupId, {})
out.get(row.groupId)[row.field] = row.value
}
return out
}
/**
* One row per grant, not one per linked account.
*
* The join in `listGrants` multiplies a grant by the holder's accounts, which is
* what the push wants and the opposite of what a screen wants.
* One row per grant, not one per linked account. The join in `listGrants`
* multiplies a grant by the holder's accounts.
*/
function collapseGrants(rows) {
const byId = new Map()
@@ -157,13 +150,7 @@ function collapseGrants(rows) {
return [...byId.values()]
}
/**
* The sync row as a client reads it.
*
* `report` is stored as the JSON the game sent and parsed here rather than on the
* way in, so a report this build cannot read is a rendering problem on one
* screen instead of a write that failed.
*/
/** The sync row as a client reads it. */
function shapeSync(row) {
let report = null
@@ -182,126 +169,37 @@ function shapeSync(row) {
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
lastAttemptAt: row.lastAttemptAt,
lastOkAt: row.lastOkAt,
importedAt: row.importedAt || null,
error: row.error || null,
report,
}
}
/** Which servers know each permission name — the form's option source, and its warning label. */
function catalogueByPermission(rows) {
const byPermission = new Map()
for (const row of rows) {
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
byPermission.get(row.permission).push(row.serverId)
}
return [...byPermission.entries()]
.map(([permission, servers]) => ({ permission, servers }))
.sort((a, b) => a.permission.localeCompare(b.permission))
}
/**
* ── What one person holds, as that person reads it ────────────────────────
*
* The admin overview answers *who holds what*; this answers *what do I hold*,
* and it is a different shape rather than a filtered one. Three things make it
* different:
*
* 1. **The scope arithmetic is answered here, not sent.** A client handed
* `scope: '*'` would have to know what the fleet is and re-implement
* `inScope` to say anything useful, and then there would be two of it. Each
* entry carries the servers it actually reaches, already resolved.
* 2. **`live` is per server and it is the pushed ledger, not the authored
* row.** A grant made on the website is not a privilege in a game until a
* sync confirmed it, and phase 7 is careful never to record a push that
* silently did nothing (an unregistered permission, a store that has never
* seen the player). So "waiting" here means waiting, and saying otherwise
* would be the site claiming to have given something it has not.
* 3. **Nothing says WHY it is waiting.** Which permission names a server's
* loaded plugins registered is an operator's diagnosis and an inventory of
* what is installed; a player gets the honest state, not the reason.
*
* Every read is scoped to the caller in SQL, and the pushed rows are looked up
* by the caller's OWN Steam ids — so a person with no linked account correctly
* sees entitlements that reach nobody yet, rather than nothing at all (the
* mistake phase 7 shipped on the admin user page, §20.5).
*/
async function forPlayer(userId, steamIds, serverRows) {
const [groups, groupPermissions, grants, pushed] = await Promise.all([
db.listGroupsForUser(userId),
db.listGroupPermissions(),
db.listGrants({ userId }),
db.listPushedForSteamIds(steamIds),
])
const servers = serverRows.map((row) => ({ id: row.id, name: row.name || row.id }))
// `kind:object` -> the servers a row of ours landed on. The subject is one of
// this caller's own Steam ids by construction, so it does not enter the key:
// an entitlement is live for the person if it is live for any account they
// hold, which is the same thing the game sees.
const live = new Map()
for (const row of pushed) {
const key = `${row.kind}:${normaliseName(row.object)}`
if (!live.has(key)) live.set(key, new Set())
live.get(key).add(row.serverId)
}
/** The servers a scope reaches, each marked with whether it is there yet. */
function reach(scope, key) {
const landed = live.get(key) || new Set()
return servers
.filter((server) => inScope(scope, server.id))
.map((server) => ({ ...server, live: landed.has(server.id) }))
}
const permissionsByGroup = new Map()
for (const row of groupPermissions) {
if (!permissionsByGroup.has(row.groupName)) permissionsByGroup.set(row.groupName, [])
permissionsByGroup.get(row.groupName).push(normaliseName(row.permission))
}
return {
groups: groups.map((group) => ({
name: group.name,
title: group.title || group.name,
scope: group.scope,
since: group.addedAt,
permissions: (permissionsByGroup.get(group.name) || []).sort(),
reach: reach(group.scope, `member:${normaliseName(group.name)}`),
})),
// `collapseGrants` first: the join multiplies a grant by the accounts its
// holder has linked, and this caller may hold two.
grants: collapseGrants(grants)
.map((grant) => ({
permission: grant.permission,
scope: grant.scope,
source: grant.source,
note: grant.note,
since: grant.grantedAt,
reach: reach(grant.scope, `grant:${normaliseName(grant.permission)}`),
}))
.sort((a, b) => a.permission.localeCompare(b.permission)),
}
}
/**
* The whole authored set, read once, in the shape the per-server build wants.
*
* Read once per sync tick rather than once per server: six servers is six
* different answers derived from one set of tables, and re-reading them per
* server is six times the queries for the same rows.
*/
async function readAuthored() {
const [groups, groupPermissions, members, grants, links, runGrants, groupChat] = await Promise.all([
const [
groups,
groupServers,
groupPermissions,
members,
steamMembers,
grants,
steamGrants,
exceptions,
links,
runGrants,
groupChat,
] = await Promise.all([
db.listGroups(),
db.listGroupServers(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGroupSteamMembers(),
db.listGrants(),
db.listSteamGrants(),
db.listExceptions(),
db.listLinks(),
db.listRunGrants(),
db.listGroupChat(),
@@ -314,103 +212,158 @@ async function readAuthored() {
steamIdsByUser.get(link.userId).push(link.steamId)
}
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser, groupChat }
return {
groups,
groupServers,
groupPermissions,
members,
steamMembers,
grants,
steamGrants,
exceptions,
runGrants,
groupChat,
steamIdsByUser,
}
}
/** A row's identity, for set arithmetic against what was pushed. */
const rowKey = (row) => `${row.kind} ${row.subject} ${row.object}`
/**
* What one server's store should contain, and the rows that say so.
*
* Returns three things the caller needs together and must not compute twice:
*
* `payload` what goes on the wire
* `rows` the same set in `rust_perm_pushed`'s shape, for the diff
* `hash` a stable digest of `rows`, which is how the loop knows nothing
* has changed without asking a game server
* `hash` a stable digest of `rows`
* `sources` rowKey → the authored rows that produced it (see the file header)
*
* **A user with no linked Steam account contributes nothing and is not an
* error.** They are authored against perfectly well and reach nobody until they
* link — which the admin screen says out loud, because a grant that reaches
* nothing looks exactly like one that worked.
* A user with no linked Steam account contributes nothing and is not an error.
*/
function buildDesired(serverId, authored) {
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
const { groupPermissions, members, steamIdsByUser } = authored
const steamMembers = authored.steamMembers || []
const grants = authored.grants || []
const steamGrants = authored.steamGrants || []
const runGrants = authored.runGrants || []
const exceptions = new Set(
(authored.exceptions || []).filter((e) => e.serverId === serverId).map((e) => `${e.holder}:${e.grantId}`),
)
const serverRows = serversByGroup(authored.groupServers)
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
const groupNames = new Set(scopedGroups.map((group) => group.name))
const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
const managed = new Set()
const rows = []
const sources = new Map()
const addSource = (row, source) => {
const key = rowKey(row)
if (!sources.has(key)) sources.set(key, [])
sources.get(key).push(source)
}
for (const group of scopedGroups)
rows.push({ kind: 'group', subject: group.name, object: '' })
const onServer = groupsOn(serverId, authored)
const groupById = new Map(onServer.map((group) => [group.id, group]))
const shared = (group) => isShared(group, serverRows)
const permissionsByGroup = new Map(onServer.map((group) => [group.id, []]))
const membersByGroup = new Map(onServer.map((group) => [group.id, []]))
for (const row of groupPermissions) {
if (!groupNames.has(row.groupName)) continue
for (const group of onServer) {
const row = { kind: 'group', subject: group.name, object: '', value: groupValue(group.title, group.rank, group.parent) }
rows.push(row)
addSource(row, { type: 'group', groupId: group.id, shared: shared(group) })
}
const permission = normaliseName(row.permission)
permissionsByGroup.get(row.groupName).push(permission)
managed.add(permission)
rows.push({ kind: 'group-permission', subject: row.groupName, object: permission })
for (const entry of groupPermissions) {
const group = groupById.get(entry.groupId)
if (!group) continue
const permission = normaliseName(entry.permission)
if (!permission) continue
permissionsByGroup.get(group.id).push(permission)
const row = { kind: 'group-permission', subject: group.name, object: permission }
rows.push(row)
addSource(row, { type: 'group', groupId: group.id, shared: shared(group) })
}
const seenMember = new Set()
const addMember = (group, steamId, source) => {
const row = { kind: 'member', subject: steamId, object: group.name }
addSource(row, source)
for (const row of members) {
if (!groupNames.has(row.groupName)) continue
const key = `${group.id}:${steamId}`
if (seenMember.has(key)) return
seenMember.add(key)
for (const steamId of steamIdsByUser.get(row.userId) || []) {
const key = `${row.groupName}:${steamId}`
if (seenMember.has(key)) continue
seenMember.add(key)
membersByGroup.get(group.id).push(steamId)
rows.push(row)
}
membersByGroup.get(row.groupName).push(steamId)
rows.push({ kind: 'member', subject: steamId, object: row.groupName })
for (const entry of members) {
const group = groupById.get(entry.groupId)
if (!group) continue
// Resolved from the link map, not from the joined row, so a user with two
// accounts is a member twice and a user with none is a member nowhere.
for (const steamId of steamIdsByUser.get(entry.userId) || []) {
addMember(group, steamId, { type: 'userMember', groupId: group.id, userId: entry.userId, shared: shared(group) })
}
}
for (const entry of steamMembers) {
const group = groupById.get(entry.groupId)
if (!group) continue
addMember(group, entry.steamId, { type: 'steamMember', groupId: group.id, steamId: entry.steamId, shared: shared(group) })
}
const permissionsBySteamId = new Map()
const seenGrant = new Set()
const addGrant = (steamId, permission, source) => {
const row = { kind: 'grant', subject: steamId, object: permission }
addSource(row, source)
const key = `${steamId}:${permission}`
if (seenGrant.has(key)) return
seenGrant.add(key)
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
permissionsBySteamId.get(steamId).push(permission)
rows.push(row)
}
// A grant held by a website user (D28), less its exceptions (D190). The
// exception's server is left out, and every other server keeps it.
const seenUserGrant = new Set()
for (const row of grants) {
if (!inScope(row.scope, serverId)) continue
if (seenUserGrant.has(row.id)) continue
seenUserGrant.add(row.id)
if (exceptions.has(`user:${row.id}`)) continue
const permission = normaliseName(row.permission)
if (!permission) continue
// Managed whether or not it reaches anybody: the namespace is what makes a
// hand grant of this permission to somebody else show up as drift, and a
// grant whose holder has linked nothing would otherwise silently narrow it.
managed.add(permission)
// **Resolved from the link map, not from the row.** `listGrants` joins the
// links and therefore repeats a grant once per linked account, which would
// give the right answer here by accident — until somebody changes that query
// and one of a person's two accounts quietly stops being granted. The map is
// the same source the members above use, and it says what it means.
for (const steamId of steamIdsByUser.get(row.userId) || []) {
const key = `${steamId}:${permission}`
if (seenGrant.has(key)) continue
seenGrant.add(key)
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
permissionsBySteamId.get(steamId).push(permission)
rows.push({ kind: 'grant', subject: steamId, object: permission })
addGrant(steamId, permission, { type: 'userGrant', id: row.id, userId: row.userId, scope: row.scope })
}
}
// A grant held by one Steam account (D188).
for (const row of steamGrants) {
if (!inScope(row.scope, serverId)) continue
if (exceptions.has(`steam:${row.id}`)) continue
const permission = normaliseName(row.permission)
if (!permission) continue
addGrant(row.steamId, permission, { type: 'steamGrant', id: row.id, steamId: row.steamId, scope: row.scope })
}
// ── What events granted (phase 13b, D84) ──────────────────────────────
//
// Unioned with the admin grants above through the same `seenGrant`, so a
// permission held both ways is ONE row in the game — and withdrawing either
// leaves the other standing, because the next build still finds it.
//
// An event grant reaches only the kit's server (D102), and like any grant it
// reaches every account the user has linked (D28).
//
// The CREDIT is different: one win is one extra use, on the account that took
// part, and only while that account is still linked to the user who won it.
// Unioned through the same `seenGrant`, so a permission held both ways is ONE
// row in the game. An event grant reaches only the kit's server (D102), and
// every account the user has linked (D28). The CREDIT is one extra use on the
// account that took part, while it is still linked to the winner.
const credits = new Map()
for (const row of runGrants) {
@@ -420,38 +373,20 @@ function buildDesired(serverId, authored) {
const permission = normaliseName(row.permission)
if (permission) {
managed.add(permission)
for (const steamId of linked) {
const key = `${steamId}:${permission}`
if (seenGrant.has(key)) continue
seenGrant.add(key)
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
permissionsBySteamId.get(steamId).push(permission)
rows.push({ kind: 'grant', subject: steamId, object: permission })
}
for (const steamId of linked) addGrant(steamId, permission, { type: 'runGrant', runId: row.runId, stepId: row.stepId })
}
if (Number(row.credit) && linked.includes(row.steamId)) {
// A Steam id is digits, so the first bar is always the split; a kit name
// may contain one.
const key = `${row.steamId}|${row.kit}`
credits.set(key, (credits.get(key) || 0) + 1)
}
}
// ── A group's BetterChat style (phase 17, D138) ───────────────────────
//
// One ledger row per FIELD (`chat-field`, subject the group, object the
// field), carrying its value: the diff that retires a style is the same
// `pushed − desired` as everything else, and the value is what the next sync
// sends as `expect`. The value is not in the row's identity — a changed value
// is the same field pushed again, not a retirement.
const chat = chatByGroup(authored.groupChat)
for (const group of scopedGroups) {
const fields = chat.get(group.name)
for (const group of onServer) {
const fields = chat.get(group.id)
if (!fields) continue
for (const field of Object.keys(fields).sort()) {
@@ -467,79 +402,160 @@ function buildDesired(serverId, authored) {
.sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit))
const payload = {
groups: scopedGroups.map((group) => ({
groups: onServer.map((group) => ({
name: group.name,
title: group.title || group.name,
rank: group.rank,
permissions: permissionsByGroup.get(group.name),
members: membersByGroup.get(group.name),
// The values only; `permSync` adds what each one expects to find, which
// is per server and comes from the ledger.
...(chat.has(group.name) ? { chat: chat.get(group.name) } : {}),
// Verbatim: an empty title is sent empty, not replaced by the name.
title: group.title == null ? '' : group.title,
rank: Number(group.rank) || 0,
parent: normaliseName(group.parent),
permissions: permissionsByGroup.get(group.id),
members: membersByGroup.get(group.id),
...(chat.has(group.id) ? { chat: chat.get(group.id) } : {}),
})),
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
steamId,
permissions,
})),
managed: [...managed].sort(),
// Always sent, even empty: to the plugin an absent field means "this site
// says nothing about credits", and an empty one means "nobody has any" —
// which is what a revert of the last reward must be able to say (D103).
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ steamId, permissions })),
// Always sent, even empty (D103).
credits: creditRows,
}
// Credits are in the digest, so a new reward or a revert pushes, but they are
// NOT in `rows`: those are the pushed ledger's, and a use of a kit is not
// something in the permission store to retire.
//
// A style field's VALUE goes into the digest the same way, since it is not in
// the row's identity: a colour changed on the site must push.
const hashed = [
...rows.map((row) => (row.kind === 'chat-field' ? { ...row, object: `${row.object}=${row.value}` } : row)),
...rows,
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
]
return { payload, rows, hash: hashRows(hashed) }
return { payload, rows, hash: hashRows(hashed), sources }
}
/** Whether a group is on more than one server, or on every server. */
function isShared(group, serverRows) {
if (group.allServers) return true
const rows = serverRows.get(group.id)
if (!rows) return false
let on = 0
for (const included of rows.values()) if (included) on++
return on > 1
}
/**
* A digest of the desired set.
*
* Sorted before hashing, because the rows come out of several queries in an
* order nothing guarantees — an unsorted digest would differ between two reads
* of an unchanged set and push to every game server on every tick.
* A digest of the desired set. Sorted before hashing, and a row's VALUE is in
* it (a style field, a group's title, rank and parent): a change to one must push.
*/
function hashRows(rows) {
const canonical = rows
.map((row) => `${row.kind}${row.subject}${row.object}`)
.map((row) => `${row.kind} ${row.subject} ${row.object}${row.value === undefined || row.value === null ? '' : `=${row.value}`}`)
.sort()
.join('\n')
return crypto.createHash('sha256').update(canonical).digest('hex')
}
/** A row's identity, for set arithmetic against what was pushed. */
const rowKey = (row) => `${row.kind}${row.subject}${row.object}`
/**
* What this site put in a server and has since withdrawn.
* What this site put in a server and has since withdrawn: `pushed − desired`.
*
* `pushed − desired`, and it is the one calculation that cannot be replaced by
* asking the game: a name in the store that is not in the desired set is either
* something the site retired or something a human granted, and those have
* opposite correct answers (D31). Only the pushed ledger tells them apart.
* Two things are never retired: a built-in group, which the framework keeps
* anyway; and a row in `hold` — a change made in the game that is waiting for a
* person's answer (the `adopt` policy, D161), which is neither the site's to
* push back nor its to remove yet.
*/
function retirements(pushed, desiredRows) {
function retirements(pushed, desiredRows, hold = new Set()) {
const desired = new Set(desiredRows.map(rowKey))
return pushed.filter((row) => !desired.has(rowKey(row)))
return pushed.filter((row) => {
const key = rowKey(row)
if (desired.has(key) || hold.has(key)) return false
if (row.kind === 'group' && BUILTIN_GROUPS.has(row.subject)) return false
return true
})
}
/**
* ── What one person holds, as that person reads it ────────────────────────
*
* Unchanged in intent by the rebuild: scope arithmetic answered here, `live`
* per server from the pushed ledger, and no reason given for "waiting". A group
* now reaches the servers it is on (D189) rather than a scope.
*/
async function forPlayer(userId, steamIds, serverRows) {
const [groups, groupServers, groupPermissions, grants, steamGrants, exceptions, pushed] = await Promise.all([
db.listGroupsForUser(userId),
db.listGroupServers(),
db.listGroupPermissions(),
db.listGrants({ userId }),
Promise.all(steamIds.map((steamId) => db.listSteamGrants({ steamId }))).then((lists) => lists.flat()),
db.listExceptions(),
db.listPushedForSteamIds(steamIds),
])
const servers = serverRows.map((row) => ({ id: row.id, name: row.name || row.id }))
const serverIds = servers.map((s) => s.id)
const byGroup = serversByGroup(groupServers)
const excepted = new Set(exceptions.map((e) => `${e.holder}:${e.grantId}:${e.serverId}`))
const live = new Map()
for (const row of pushed) {
const key = `${row.kind}:${normaliseName(row.object)}`
if (!live.has(key)) live.set(key, new Set())
live.get(key).add(row.serverId)
}
const reachOf = (ids, key) => {
const landed = live.get(key) || new Set()
return servers.filter((s) => ids.includes(s.id)).map((s) => ({ ...s, live: landed.has(s.id) }))
}
const grantReach = (grant, holder) =>
serverIds.filter((id) => inScope(grant.scope, id) && !excepted.has(`${holder}:${grant.id}:${id}`))
const permissionsByGroup = new Map()
for (const row of groupPermissions) {
if (!permissionsByGroup.has(row.groupId)) permissionsByGroup.set(row.groupId, [])
permissionsByGroup.get(row.groupId).push(normaliseName(row.permission))
}
const shapeGrant = (grant, holder) => ({
permission: grant.permission,
scope: grant.scope,
source: grant.source,
note: grant.note || null,
since: grant.grantedAt,
reach: reachOf(grantReach(grant, holder), `grant:${normaliseName(grant.permission)}`),
})
return {
groups: groups.map((group) => {
const reach = groupReach(group, byGroup, serverIds)
return {
name: group.name,
title: group.title || group.name,
// Kept for older clients: `*` for a group on every server, else the
// servers it is on.
scope: group.allServers ? FLEET : reach.join(','),
since: group.addedAt,
permissions: (permissionsByGroup.get(group.id) || []).sort(),
reach: reachOf(reach, `member:${normaliseName(group.name)}`),
}
}),
grants: [
...collapseGrants(grants).map((grant) => shapeGrant(grant, 'user')),
...steamGrants.map((grant) => shapeGrant(grant, 'steam')),
].sort((a, b) => a.permission.localeCompare(b.permission)),
}
}
module.exports = {
FLEET,
BUILTIN_GROUPS,
normaliseName,
inScope,
overview,
groupValue,
serversByGroup,
groupCovers,
groupReach,
groupsOn,
isShared,
forPlayer,
readAuthored,
buildDesired,

View File

@@ -0,0 +1,198 @@
// ── What the permission screen reads (D162, D163, U-1) ────────────────────
//
// The screen follows uMod PermissionsManager's flow — a server, then players ⇄
// groups, then a subject, then a plugin's permissions with Granted / Revoked —
// and every toggle on it carries its own state on that server. This file
// assembles what that needs in one read per request:
//
// • plugins grouped by the plugin that REGISTERED each permission (§0.1),
// never by the name's prefix — `zonemanager.ignoreflag.nokits` is
// ZoneManager's. A name no plugin owns (Carbon's built-in modules) is
// grouped by its prefix, and says so.
// • the groups on the server (D189), with where else each one is.
// • every subject holding anything there, named by linked account and in-game
// name, or Steam id when there is neither (D163).
// • the raw facts the toggle states are computed from: what the site wants and
// why (its sources), what has landed (the pushed ledger), and what the last
// report said did not.
const db = require('./permissions.db')
const model = require('./permissions.model')
const servers = require('../servers/servers.model')
/** The servers, their policy and sync state, and every row waiting for a person. */
async function overview() {
const [serverRows, sync, policies, drift] = await Promise.all([
servers.listForAdmin(),
db.listSync(),
db.listPolicies(),
db.listDrift(),
])
const syncById = new Map(sync.map((row) => [row.serverId, model.shapeSync(row)]))
const policyById = new Map(policies.map((row) => [row.serverId, row.policy]))
return {
servers: serverRows.map((row) => ({
id: row.id,
name: row.name || row.id,
policy: policyById.get(row.id) || 'auto-adopt',
sync: syncById.get(row.id) || null,
})),
drift: drift.map((row) => ({ ...row, detail: row.detail === undefined ? null : row.detail })),
}
}
/** A permission's plugin button: its registering plugin, or its prefix. */
function pluginOf(row) {
if (row.owner) return { key: `plugin:${row.owner}`, label: row.owner, registered: true }
const prefix = row.permission.includes('.') ? row.permission.slice(0, row.permission.indexOf('.')) : row.permission
return { key: `prefix:${prefix}`, label: prefix, registered: false }
}
/**
* Everything the screen shows for one server. Null for a server the site does
* not have.
*/
async function serverView(serverId) {
const serverRows = await servers.listForAdmin()
const server = serverRows.find((row) => row.id === serverId)
if (!server) return null
const [authored, catalogue, pushed, sync, policies, drift, links] = await Promise.all([
model.readAuthored(),
db.listCatalogue(),
db.listPushed(serverId),
db.listSync(),
db.listPolicies(),
db.listDrift(),
db.listLinksNamed(),
])
const serverIds = serverRows.map((row) => row.id)
const desired = model.buildDesired(serverId, authored)
const byGroup = model.serversByGroup(authored.groupServers)
const syncRow = sync.find((row) => row.serverId === serverId)
const linkBySteam = new Map(links.map((row) => [row.steamId, row]))
// ── Plugins, by who registered each permission ──
const plugins = new Map()
for (const row of catalogue.filter((r) => r.serverId === serverId)) {
const plugin = pluginOf(row)
if (!plugins.has(plugin.key)) plugins.set(plugin.key, { ...plugin, permissions: [] })
plugins.get(plugin.key).permissions.push(row.permission)
}
// ── Groups on this server ──
const chat = model.chatByGroup(authored.groupChat)
const onServer = model.groupsOn(serverId, authored)
const permissionsByGroup = new Map()
for (const row of authored.groupPermissions) {
if (!permissionsByGroup.has(row.groupId)) permissionsByGroup.set(row.groupId, [])
permissionsByGroup.get(row.groupId).push(model.normaliseName(row.permission))
}
const members = new Map()
for (const row of authored.members) {
if (!members.has(row.groupId)) members.set(row.groupId, new Map())
const byUser = members.get(row.groupId)
if (!byUser.has(row.userId)) byUser.set(row.userId, { userId: row.userId, username: row.username, steamIds: [] })
if (row.steamId) byUser.get(row.userId).steamIds.push(row.steamId)
}
const groups = onServer.map((group) => {
const reach = model.groupReach(group, byGroup, serverIds)
return {
id: group.id,
name: group.name,
title: group.title,
rank: group.rank,
parent: group.parent,
source: group.source,
builtin: model.BUILTIN_GROUPS.has(group.name),
allServers: group.allServers,
servers: reach,
shared: model.isShared(group, byGroup),
permissions: (permissionsByGroup.get(group.id) || []).sort(),
members: [...((members.get(group.id) || new Map()).values())],
steamMembers: authored.steamMembers.filter((m) => m.groupId === group.id).map((m) => m.steamId),
chat: chat.get(group.id) || null,
}
})
// ── Subjects: every Steam id holding anything here, by the desired set ──
const subjects = new Map()
const subject = (steamId) => {
if (!subjects.has(steamId)) subjects.set(steamId, { steamId, grants: [], groups: [] })
return subjects.get(steamId)
}
for (const row of desired.rows) {
if (row.kind === 'grant') {
subject(row.subject).grants.push({ permission: row.object, sources: desired.sources.get(model.rowKey(row)) || [] })
} else if (row.kind === 'member') {
subject(row.subject).groups.push(row.object)
}
}
// A grant kept off this server by an exception still belongs on the screen:
// it is "on every server except this one", and the toggle can take it back.
const exceptions = authored.exceptions.filter((e) => e.serverId === serverId)
const grantById = new Map(authored.grants.map((g) => [`user:${g.id}`, g]))
for (const g of authored.steamGrants) grantById.set(`steam:${g.id}`, g)
const excepted = []
for (const e of exceptions) {
const grant = grantById.get(`${e.holder}:${e.grantId}`)
if (!grant) continue
const steamIds = e.holder === 'steam' ? [grant.steamId] : (authored.steamIdsByUser.get(grant.userId) || [])
for (const steamId of steamIds) {
subject(steamId)
excepted.push({ id: e.id, steamId, permission: model.normaliseName(grant.permission), holder: e.holder, grantId: e.grantId })
}
}
const steamIds = [...subjects.keys()]
const names = new Map((await db.namesFor(steamIds)).map((row) => [row.steamId, row.name]))
const players = [...subjects.values()]
.map((s) => {
const link = linkBySteam.get(s.steamId)
return {
...s,
name: names.get(s.steamId) || (link && link.playerName) || null,
account: link ? { userId: link.userId, username: link.username } : null,
}
})
.sort((a, b) => (a.name || a.steamId).localeCompare(b.name || b.steamId))
const report = syncRow ? model.shapeSync(syncRow).report : null
const policy = (policies.find((row) => row.serverId === serverId) || {}).policy || 'auto-adopt'
return {
server: { id: server.id, name: server.name || server.id },
servers: serverRows.map((row) => ({ id: row.id, name: row.name || row.id })),
policy,
sync: syncRow ? model.shapeSync(syncRow) : null,
plugins: [...plugins.values()].sort((a, b) => Number(b.registered) - Number(a.registered) || a.label.localeCompare(b.label)),
groups,
players,
excepted,
// What has landed on this server: `grant steamId permission`, `member steamId
// group`, `group-permission group permission`.
landed: pushed
.filter((row) => row.kind === 'grant' || row.kind === 'member' || row.kind === 'group-permission')
.map(model.rowKey),
report: report
? {
unresolved: report.unresolved || [],
pending: report.pending || [],
notLanded: report.notLanded || [],
}
: null,
drift: drift.filter((row) => row.serverId === serverId),
}
}
module.exports = { overview, serverView, pluginOf }

View File

@@ -0,0 +1,277 @@
// ── Three sets, and what a change made in the game becomes ────────────────
//
// `rust_perm_pushed`'s own comment has always named three sets — what is in the
// game, what this site put there, and what the site wants there. Until protocol
// 13 the plugin could only compute the first for the names the site claimed.
// The inventory (PLAN_REDESIGNS §1.2) gives the site all three, so the whole of
// "what happened, and what do we do about it" is decided here:
//
// in the game pushed desired means
// yes no no ADDED in the game
// no yes yes REMOVED in the game
// yes yes yes a group whose title, rank or parent the
// game holds differently from what was
// pushed: CHANGED in the game
// yes no yes landed by some other hand: recorded
//
// (desired − pushed is the ordinary push, and pushed − desired the ordinary
// retirement; neither is this file's business.)
//
// Then the server's policy (D161) says what each change becomes: the site's own
// (`auto-adopt`, the default), a question for a person (`adopt`), or undone
// (`revoke`). The first inventory of a server imports what it finds whatever the
// policy (D198).
//
// **Two things are never judged, and both are how a site would otherwise throw
// away its own grants.** A permission the server has not REGISTERED right now —
// a plugin unloaded for a minute — is missing from the inventory because the
// plugin that owns it is, not because anybody revoked it. And a group permission
// an event lease holds is the lease's until it ends.
//
// Pure: rows in, a plan out. `permissions.apply.js` carries the plan out.
const { rowKey, groupValue, normaliseName, BUILTIN_GROUPS } = require('./permissions.model')
const JUDGED = new Set(['group', 'group-permission', 'member', 'grant'])
/** The inventory in the pushed ledger's shape. */
function presentRows(inventory) {
const rows = []
for (const group of (inventory && inventory.groups) || []) {
const name = normaliseName(group.name)
if (!name) continue
rows.push({ kind: 'group', subject: name, object: '', value: groupValue(group.title, group.rank, group.parent) })
for (const permission of group.permissions || []) {
rows.push({ kind: 'group-permission', subject: name, object: normaliseName(permission) })
}
}
for (const user of (inventory && inventory.users) || []) {
for (const permission of user.permissions || []) {
rows.push({ kind: 'grant', subject: String(user.steamId), object: normaliseName(permission) })
}
for (const group of user.groups || []) {
rows.push({ kind: 'member', subject: String(user.steamId), object: normaliseName(group) })
}
}
return rows
}
/** The group attributes a `group` row's value carries. */
function parseGroupValue(value) {
try {
const [title, rank, parent] = JSON.parse(value)
return { title: String(title == null ? '' : title), rank: Number(rank) || 0, parent: normaliseName(parent) }
} catch {
return null
}
}
/**
* Sort every row into added, removed, changed or landed.
*
* `registered` is the set of names the server registers right now; `leased` the
* lease-held pairs, as `group-permission` rows.
*/
function classify({ present, pushed, desired, registered, leased = [] }) {
const leasedKeys = new Set(leased.map((row) => rowKey({ kind: 'group-permission', subject: normaliseName(row.subject), object: normaliseName(row.object) })))
const judged = (row) => {
if (!JUDGED.has(row.kind)) return false
if ((row.kind === 'grant' || row.kind === 'group-permission') && !registered.has(row.object)) return false
// `default` holds every connected player by the framework's rule (§1.2).
if (row.kind === 'member' && row.object === 'default') return false
if (leasedKeys.has(rowKey(row))) return false
return true
}
const index = (rows) => new Map(rows.filter(judged).map((row) => [rowKey(row), row]))
const P = index(present)
const U = index(pushed)
const D = index(desired)
const added = []
const removed = []
const changed = []
const landed = []
for (const [key, row] of P) {
if (!U.has(key) && !D.has(key)) added.push(row)
else if (!U.has(key) && D.has(key)) landed.push({ ...D.get(key) })
else if (row.kind === 'group' && U.has(key) && D.has(key)) {
const pushedValue = U.get(key).value
// A value the site pushed, that the site still wants, and that the game no
// longer holds: somebody changed the group in the game. A ledger row with
// no value (before protocol 13) cannot say, and the site's value is pushed.
if (pushedValue && row.value !== pushedValue && D.get(key).value === pushedValue) changed.push(row)
}
}
for (const [key, row] of U) {
if (!P.has(key) && D.has(key)) removed.push(row)
}
// A group removed in the game takes its permissions and members with it; they
// are the group's removal, not changes of their own.
const goneGroups = new Set(removed.filter((row) => row.kind === 'group').map((row) => row.subject))
const keep = (row) =>
row.kind === 'group' || !goneGroups.has(row.kind === 'member' ? row.object : row.subject)
return { added, removed: removed.filter(keep), changed, landed }
}
/**
* What the changes become under one server's policy.
*
* Returns:
* `ops` for `permissions.apply.js`, in the order they must run —
* groups before what goes in them
* `drift` "needs a person" rows (D161's `adopt`, and what no policy can
* settle alone)
* `revocations` what the `revoke` policy undoes at this sync
* `hold` row keys this sync must neither push back nor retire nor
* record, because a person has not answered yet
*/
function plan({ classes, policy, importing, sources }) {
const ops = []
const drift = []
const revocations = []
const hold = new Set()
const source = importing ? 'imported' : 'adopted'
const adoptOp = (row) => {
if (row.kind === 'group') {
const attrs = parseGroupValue(row.value) || { title: '', rank: 0, parent: '' }
return { op: 'adoptGroup', name: row.subject, ...attrs, source }
}
if (row.kind === 'group-permission') return { op: 'adoptGroupPermission', group: row.subject, permission: row.object, source }
if (row.kind === 'member') return { op: 'adoptMember', group: row.object, steamId: row.subject, source }
return { op: 'adoptGrant', steamId: row.subject, permission: row.object, source }
}
const dropOp = (row) => {
if (row.kind === 'group') return { op: 'dropGroup', group: row.subject }
if (row.kind === 'group-permission') return { op: 'dropGroupPermission', group: row.subject, permission: row.object }
if (row.kind === 'member') {
return { op: 'dropMember', group: row.object, steamId: row.subject, sources: sources.get(rowKey(row)) || [] }
}
return { op: 'dropGrant', steamId: row.subject, permission: row.object, sources: sources.get(rowKey(row)) || [] }
}
const order = { adoptGroup: 0, setGroupAttrs: 1, adoptGroupPermission: 2, adoptMember: 2, adoptGrant: 2, dropGroupPermission: 3, dropMember: 3, dropGrant: 3, dropGroup: 4 }
// ── The first inventory: everything present becomes the site's (D160, D198) ──
//
// Additions and changed attributes are imported whatever the policy. A removal
// at import is something this site pushed that the game has since lost — it is
// pushed back, as it always was, rather than deleted on the strength of a
// snapshot taken the moment the site first looked.
if (importing) {
for (const row of classes.added) ops.push(adoptOp(row))
for (const row of classes.changed) ops.push({ op: 'setGroupAttrs', group: row.subject, ...parseGroupValue(row.value) })
return { ops: ops.sort((a, b) => order[a.op] - order[b.op]), drift, revocations, hold }
}
if (policy === 'revoke') {
// The site's set wins. An addition is removed at this sync; a removal or a
// changed group is simply pushed back by the desired set.
for (const row of classes.added) {
// A built-in group cannot be removed; one made in the game under `revoke`
// is — but `default` and `admin` are never "added", the import took them.
if (row.kind === 'group' && BUILTIN_GROUPS.has(row.subject)) continue
revocations.push({ kind: row.kind, subject: row.subject, object: row.object })
}
return { ops, drift, revocations, hold }
}
if (policy === 'adopt') {
// Every change waits for a person, and until then the game is left as it is.
// A group made in the game carries its title, rank and parent, so adopting it
// later keeps them.
for (const row of classes.added) {
drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'added', ...(row.kind === 'group' ? { detail: row.value } : {}) })
}
for (const row of classes.removed) {
drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'removed' })
hold.add(rowKey(row))
}
for (const row of classes.changed) {
drift.push({ kind: 'group', subject: row.subject, object: '', direction: 'changed', detail: row.value })
hold.add(rowKey(row))
}
return { ops, drift, revocations, hold }
}
// ── auto-adopt, the default (D161, D190) ──
for (const row of classes.added) ops.push(adoptOp(row))
for (const row of classes.removed) {
const op = dropOp(row)
// A grant only an event gave cannot be adopted away: the event owns it, and
// its revert will withdraw it. It is pushed back, and a person is told.
if (op.op === 'dropGrant' && op.sources.length && op.sources.every((s) => s.type === 'runGrant')) {
drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'removed', detail: 'event' })
continue
}
ops.push(op)
}
for (const row of classes.changed) ops.push({ op: 'setGroupAttrs', group: row.subject, ...parseGroupValue(row.value) })
return { ops: ops.sort((a, b) => order[a.op] - order[b.op]), drift, revocations, hold }
}
/**
* The desired set with the held rows taken out: not in the payload, so the
* plugin does not put them back, and not in `rows`, so the report does not
* record them. A held group keeps its place — only its title, rank and parent
* are left off, which the plugin reads as "leave them".
*/
function withHold(desired, hold) {
if (!hold || !hold.size) return desired
const heldGroups = new Set()
const payload = { ...desired.payload }
payload.groups = desired.payload.groups.map((group) => {
const key = rowKey({ kind: 'group', subject: group.name, object: '' })
const out = { ...group }
if (hold.has(key)) {
heldGroups.add(group.name)
delete out.title
delete out.rank
delete out.parent
}
out.permissions = (group.permissions || []).filter((p) => !hold.has(rowKey({ kind: 'group-permission', subject: group.name, object: p })))
out.members = (group.members || []).filter((s) => !hold.has(rowKey({ kind: 'member', subject: s, object: group.name })))
return out
})
payload.grants = desired.payload.grants
.map((grant) => ({
...grant,
permissions: grant.permissions.filter((p) => !hold.has(rowKey({ kind: 'grant', subject: grant.steamId, object: p }))),
}))
.filter((grant) => grant.permissions.length)
return {
...desired,
payload,
rows: desired.rows.filter((row) => !hold.has(rowKey(row))),
heldGroups,
}
}
module.exports = { presentRows, parseGroupValue, classify, plan, withHold }

View File

@@ -18,53 +18,89 @@ const model = require('./permissions.model')
const VOICE_KEY = 'announce.voice'
/** The chosen group's name, or '' for plain chat. */
/**
* The chosen group's id as a string, or '' for plain chat.
*
* Since groups became per server (D189) a name can belong to several groups, so
* the setting holds a group's id. A setting written before that holds a NAME,
* and is read as the first styled group of that name until somebody chooses again.
*/
async function chosen() {
return (await settingsDb.getSetting(VOICE_KEY)) || ''
}
/** The chosen group's style fields, or null. */
async function chosenFields() {
const value = await chosen()
if (!value) return { value, fields: null }
if (/^\d+$/.test(value)) return { value, fields: await db.getGroupChat(Number(value)) }
const groups = await db.listGroups()
for (const group of groups.filter((g) => g.name === model.normaliseName(value))) {
// eslint-disable-next-line no-await-in-loop
const fields = await db.getGroupChat(group.id)
if (fields) return { value: String(group.id), fields }
}
return { value, fields: null }
}
/**
* The format a line is said in right now, or null for plain chat — which is
* also the answer when the chosen group has since lost its style or gone.
*/
async function currentFormat() {
const group = await chosen()
if (!group) return null
const fields = await db.getGroupChat(group)
const { fields } = await chosenFields()
return fields ? chatStyle.voiceFormat(fields) : null
}
/** The setting, and every group that could be a voice, for the admin page. */
async function describe() {
const [voice, rows] = await Promise.all([chosen(), db.listGroupChat()])
const [{ value }, rows, groups, groupServers] = await Promise.all([
chosenFields(),
db.listGroupChat(),
db.listGroups(),
db.listGroupServers(),
])
const byId = new Map(groups.map((g) => [g.id, g]))
const options = []
for (const [group, fields] of model.chatByGroup(rows)) {
const format = chatStyle.voiceFormat(fields)
if (format) options.push({ group, title: fields.Title || group, format })
// Which servers each group is on, so two groups of one name can be told apart.
const where = (group) => {
if (group.allServers) return 'all servers'
const ids = groupServers.filter((r) => r.groupId === group.id && r.included).map((r) => r.serverId)
return ids.length ? ids.join(', ') : 'no server'
}
return { voice, options: options.sort((a, b) => a.group.localeCompare(b.group)) }
}
/**
* Choose the voice. A group is accepted only when it has a style a voice can be
* made from; '' goes back to plain chat. Resolves `{ ok }` or
* `{ ok: false, message }`.
*/
async function choose(group, userId = null) {
const name = model.normaliseName(group)
if (name) {
const fields = await db.getGroupChat(name)
if (!fields || !chatStyle.voiceFormat(fields)) {
return { ok: false, message: `The group "${name}" has no chat style, so it cannot be a voice. Give it one under Permissions first.` }
for (const [groupId, fields] of model.chatByGroup(rows)) {
const format = chatStyle.voiceFormat(fields)
const group = byId.get(groupId)
if (format && group) {
options.push({ group: String(groupId), name: group.name, where: where(group), title: fields.Title || group.name, format })
}
}
await settingsDb.setSetting(VOICE_KEY, name, userId)
return { ok: true, voice: name }
return { voice: value, options: options.sort((a, b) => a.name.localeCompare(b.name) || a.group.localeCompare(b.group)) }
}
/**
* Choose the voice by group id. A group is accepted only when it has a style a
* voice can be made from; '' goes back to plain chat. Resolves `{ ok }` or
* `{ ok: false, message }`.
*/
async function choose(group, userId = null) {
const value = String(group || '').trim()
if (value) {
const fields = /^\d+$/.test(value) ? await db.getGroupChat(Number(value)) : null
if (!fields || !chatStyle.voiceFormat(fields)) {
return { ok: false, message: 'That group has no chat style, so it cannot be a voice. Give it one under Permissions first.' }
}
}
await settingsDb.setSetting(VOICE_KEY, value, userId)
return { ok: true, voice: value }
}
module.exports = { VOICE_KEY, chosen, currentFormat, describe, choose }

View File

@@ -1,22 +1,23 @@
// ── Keeping a game's permission store equal to what the site authored ─────
// ── Keeping a game's permission store and the site's record of it equal ───
//
// R2's whole mechanism, and it is chapter 4's board pointed the other way: the
// site is the single producer of a set, it re-sends the whole thing rather than
// a stream of edits, and the receiver reconciles. What is new is the direction —
// the module telling the game what the site knows, where every earlier phase
// asked the game what it knew.
// R2's whole mechanism, rebuilt in protocol 13 (PLAN_REDESIGNS §1) around one
// fact: **the site owns every permission and group on the server** — those that
// were there before it, those an admin makes, and those changed in the game
// (D160). So a sync is three steps, not one:
//
// ── One verb (D32) ────────────────────────────────────────────────────────
// 1. READ the whole store (`perm.inventory`), in pages, with the plugin that
// registered each permission.
// 2. RECONCILE it against the site's record and what the site last pushed
// (`reconcile.js`): a change made in the game becomes the site's own, a
// question for a person, or undone — the server's policy says which (D161).
// The first read of a server imports everything it finds (D198).
// 3. PUSH the whole desired set (`perm.sync`, D32) with what the site has
// withdrawn, and record what the plugin says landed.
//
// A sync sends the whole desired set and the plugin diffs it against the live
// store. The website never holds a copy of the game's permissions, which is the
// point: a second source of truth is stale the moment it lands, and the store is
// the bigger of the two sets.
//
// The delta the site DOES compute is the one the game cannot: what this site put
// there and has since withdrawn (`retirements`). A name in the store that is not
// in the desired set is either that, or a hand edit — and only the pushed ledger
// can tell them apart (D31).
// Step 2 writes to the site's own tables, and a change in one game affects that
// server only (D190) — which can split a group shared with other servers. So it
// runs under ONE lock for the whole fleet: two servers' reconciles never split
// the same shared group at once. Steps 1 and 3 run in parallel across servers.
//
// ── When it runs ──────────────────────────────────────────────────────────
//
@@ -25,25 +26,26 @@
// yes. A sync therefore happens when:
//
// • an operator changed something (the dirty flag, and the digest behind it)
// • the game restarted or wiped (a new boot id or wipe id: the store may have
// been emptied, and R2's promise is that a wipe is not a data-loss event)
// • the game restarted or wiped (a new boot id or wipe id)
// • a permission hook fired in the game that we did not cause (`ingest.js`
// marks the server dirty; the authoritative answer is this sync's report)
// • the audit interval elapsed — the backstop that finds drift on a quiet
// server nobody has touched
// marks the server dirty; the inventory then says what changed)
// • the audit interval elapsed — the backstop that finds a hand edit on a quiet
// server
// • the last attempt failed, after a backoff
//
// ── What it never does ────────────────────────────────────────────────────
//
// It does not remove a grant it did not make (D31), it does not invent a
// permission the server has not registered (D33), and it does not treat a
// silent sidecar as a reason to forget anything. A server that is unreachable
// keeps its retirements and its revocations until it comes back.
// It never judges a permission the server has not registered right now (a
// plugin unloaded for a minute is not a revocation), never touches what an event
// lease holds, never invents a permission the server has not registered (D33),
// and never treats a silent sidecar as a reason to forget anything.
const core = require('./core')
const apply = require('./model/permissions/permissions.apply')
const db = require('./model/permissions/permissions.db')
const model = require('./model/permissions/permissions.model')
const reconcile = require('./model/permissions/reconcile')
const servers = require('./model/servers/servers.model')
const serversDb = require('./model/servers/servers.db')
const sidecar = require('./sidecarClient')
@@ -54,11 +56,8 @@ const log = core.logger('permissions')
const TICK_MS = 30 * 1000
/**
* How long a server may go without a full reconciliation, however quiet it is.
*
* The digest comparison is what keeps the loop cheap, and on its own it would
* also mean a server whose store somebody edited by hand is never asked about
* again. This is the interval at which the question gets asked anyway.
* How long a server may go without a full reconciliation, however quiet it is:
* the interval at which a hand edit on a quiet server is found anyway.
*/
const AUDIT_MS = 15 * 60 * 1000
@@ -66,16 +65,26 @@ const AUDIT_MS = 15 * 60 * 1000
const FAIL_BACKOFF_MS = 2 * 60 * 1000
/**
* The most rows one sync may carry.
*
* Below the sidecar's line cap and below the plugin's operation ceiling, so the
* refusal happens here — where it can name the server and reach an operator —
* rather than as a `413` or a `too-large` from two processes away.
* The most rows one sync may carry. Below the sidecar's line cap and the
* plugin's operation ceiling, so the refusal happens here, where it can name the
* server and reach an operator.
*/
const MAX_ROWS = 15000
/** The policies a server may have (D161). */
const POLICIES = ['auto-adopt', 'adopt', 'revoke']
let timer = null
/** The fleet-wide lock the reconcile step runs under (see the header). */
let lock = Promise.resolve()
function withLock(fn) {
const run = lock.then(fn, fn)
lock = run.catch(() => {})
return run
}
function start() {
if (timer) return
@@ -96,25 +105,26 @@ function stop() {
/**
* One pass over every enabled server.
*
* The authored set is read ONCE and handed to each server's build: six servers
* are six different answers derived from the same four tables, and re-reading
* them per server is six times the queries for identical rows.
* The authored set is read once here, for the cheap "does anything need doing"
* digest. The reconcile re-reads it under the lock, because another server's
* reconcile may have changed it in between.
*/
async function tick({ force = null } = {}) {
await db.ensureSyncRows()
const [rows, state, sync, authored] = await Promise.all([
const [rows, state, sync, authored, policyRows] = await Promise.all([
servers.listForPolling(),
serversDb.listState(),
db.listSync(),
model.readAuthored(),
db.listPolicies(),
])
const syncById = new Map(sync.map((row) => [row.serverId, row]))
const stateById = new Map(state.map((row) => [row.serverId, row]))
const policies = new Map(policyRows.map((row) => [row.serverId, row.policy]))
// `allSettled`, for the same reason the board poll uses it: one unreachable
// host must not stop the other five being reconciled.
// `allSettled`: one unreachable host must not stop the others.
await Promise.allSettled(
rows
.filter((server) => force === null || force === server.id)
@@ -124,6 +134,7 @@ async function tick({ force = null } = {}) {
sync: syncById.get(server.id) || null,
state: stateById.get(server.id) || null,
force: force !== null,
policy: policies.get(server.id) || 'auto-adopt',
}),
),
)
@@ -179,18 +190,99 @@ function age(value) {
return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER
}
async function syncOne(server, { authored, sync, state, force }) {
const desired = model.buildDesired(server.id, authored)
const reason = reasonToSync({ desiredHash: desired.hash, sync, state, force })
/** Record a failed attempt, with the reason on the row and in the log (F7). */
async function failed(server, { reason, error, desiredHash, sync, bootId, wipeId }) {
log.warn('permission sync failed', { server: server.id, reason, error })
await db.putSyncResult(server.id, {
state: 'failed',
desiredHash,
syncedHash: sync ? sync.syncedHash : null,
bootId,
wipeId,
report: null,
error: String(error).slice(0, 191),
})
}
/**
* Read, reconcile and push one server. Returns what happened, for the log and
* the tests: null (nothing to do), 'ok', or why it stopped.
*/
async function syncOne(server, { authored, sync, state, force, policy = 'auto-adopt' }) {
const first = model.buildDesired(server.id, authored)
const reason = reasonToSync({ desiredHash: first.hash, sync, state, force })
if (!reason) return null
const [pushed, revocations] = await Promise.all([
db.listPushed(server.id),
db.listRevocations(server.id),
])
const bootId = state && state.bootId ? state.bootId : null
const wipeId = state && state.wipeId ? state.wipeId : null
const retirements = model.retirements(pushed, desired.rows)
// ── 1. Read the whole store ────────────────────────────────────────────
const read = await sidecar.readInventory(server)
if (!read.ok) {
await failed(server, { reason, error: read.error, desiredHash: first.hash, sync, bootId, wipeId })
return 'inventory'
}
const { inventory } = read
const registered = new Set(inventory.permissions.map((row) => model.normaliseName(row.name)).filter(Boolean))
// The option source, from the same read: what this server registers, and which
// plugin registered each name (PLAN_REDESIGNS §0.1).
await db.putCatalogue(
server.id,
inventory.permissions
.map((row) => ({ permission: model.normaliseName(row.name), owner: row.owner || null }))
.filter((row) => row.permission),
)
const importing = !(sync && sync.importedAt)
// ── 2. Reconcile, under the fleet lock ─────────────────────────────────
const settled = await withLock(async () => {
let current = await model.readAuthored()
let desired = model.buildDesired(server.id, current)
const pushed = await db.listPushed(server.id)
const classes = reconcile.classify({
present: reconcile.presentRows(inventory),
pushed,
desired: desired.rows,
registered,
leased: inventory.leased,
})
const planned = reconcile.plan({ classes, policy, importing, sources: desired.sources })
if (planned.ops.length) {
const done = await apply.applyOps(server.id, planned.ops, log)
log.info(importing ? 'permissions imported from the game' : 'changes made in the game adopted', {
server: server.id,
policy,
ops: done.length,
})
current = await model.readAuthored()
desired = model.buildDesired(server.id, current)
}
for (const row of planned.revocations) {
await db.queueRevocation({ serverId: server.id, ...row, requestedBy: null })
}
return { desired, planned, classes }
})
const { desired, planned } = settled
const held = reconcile.withHold(desired, planned.hold)
// ── 3. Push ────────────────────────────────────────────────────────────
const [pushed, revocations] = await Promise.all([db.listPushed(server.id), db.listRevocations(server.id)])
// Retired against the WHOLE desired set: a held row is still wanted, it is
// only not being pushed back while a person decides.
const retirements = model.retirements(pushed, desired.rows, planned.hold)
const { styleRetired, sent: styleRetire } = styleRetirements(retirements)
const retire = [
...retirements
@@ -200,84 +292,68 @@ async function syncOne(server, { authored, sync, state, force }) {
...revocations.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
]
const bootId = state && state.bootId ? state.bootId : null
const wipeId = state && state.wipeId ? state.wipeId : null
if (desired.rows.length + retire.length > MAX_ROWS) {
// Refused here rather than sent: the sidecar would answer `413` and the
// plugin would answer `too-large`, and neither of those messages reaches the
// person who has to make the set smaller.
const error = `the permission set is too large to push (${desired.rows.length + retire.length} rows, limit ${MAX_ROWS})`
log.error('permission sync refused', { server: server.id, rows: desired.rows.length })
await db.putSyncResult(server.id, {
state: 'failed',
if (held.rows.length + retire.length > MAX_ROWS) {
await failed(server, {
reason,
error: `the permission set is too large to push (${held.rows.length + retire.length} rows, limit ${MAX_ROWS})`,
desiredHash: desired.hash,
syncedHash: sync ? sync.syncedHash : null,
sync,
bootId,
wipeId,
report: null,
error,
})
return 'too-large'
}
log.info('syncing permissions', {
server: server.id,
reason,
rows: desired.rows.length,
policy,
importing,
rows: held.rows.length,
retire: retire.length,
held: planned.hold.size,
})
const result = await sidecar.permSync(server, {
setId: desired.hash,
groups: withExpect(desired.payload.groups, pushed),
grants: desired.payload.grants,
managed: desired.payload.managed,
credits: desired.payload.credits,
groups: withExpect(held.payload.groups, pushed),
grants: held.payload.grants,
credits: held.payload.credits,
retire,
})
if (!result.ok) {
// Said in the log as well as on the row (F7): the first walk's restart sync
// timed out with nothing in the log at all, while the titles push that failed
// beside it did log.
log.warn('permission sync failed', { server: server.id, reason, status: result.status })
await db.putSyncResult(server.id, {
state: 'failed',
desiredHash: desired.hash,
syncedHash: sync ? sync.syncedHash : null,
bootId,
wipeId,
report: null,
error: result.status,
})
await failed(server, { reason, error: result.status, desiredHash: desired.hash, sync, bootId, wipeId })
return result.status
}
const report = result.data || {}
// The plugin refuses a whole sync with `perm.error` — `busy` while an earlier
// one is still draining, `too-large` past its own ceiling. Both are answers
// rather than transport failures, exactly like a refused link code, so they
// arrive as a 200 and are told apart by `kind`.
// `perm.error` (`busy`, `too-large`) is an answer, not a transport failure.
if (report.kind === 'perm.error') {
log.warn('permission sync refused by the game', { server: server.id, reason, refused: report.reason || 'unknown' })
await db.putSyncResult(server.id, {
state: 'failed',
await failed(server, {
reason,
error: `the game refused the sync: ${report.reason || 'unknown'}`,
desiredHash: desired.hash,
syncedHash: sync ? sync.syncedHash : null,
sync,
bootId,
wipeId,
report: null,
error: `the game refused the sync: ${report.reason || 'unknown'}`,
})
return report.reason || 'refused'
}
await applyReport(server, { desired, retire, styleRetired, report, bootId, wipeId })
await applyReport(server, {
desired: held,
hash: desired.hash,
retire,
styleRetired,
report,
bootId,
wipeId,
drift: planned.drift,
})
if (importing) await db.markImported(server.id)
return 'ok'
}
@@ -324,31 +400,20 @@ function styleRetirements(retirements) {
}
/**
* Record what the game said it did.
* Record what the game said it did, and what waits for a person.
*
* Three writes, and the order matters only in that all three are safe to repeat:
* a sync that crashes here is re-run next tick and reaches the same place, which
* is the property that lets this loop be the only writer.
* Every write is safe to repeat: a sync that crashes here is re-run next tick and
* reaches the same place.
*/
async function applyReport(server, { desired, retire, styleRetired = [], report, bootId, wipeId }) {
async function applyReport(server, { desired, hash, retire, styleRetired = [], report, bootId, wipeId, drift = [] }) {
const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
const pending = new Set(report.pending || [])
// Grants the plugin made and then did not find in the store when it read it
// back (D85). Before protocol 9 there was no such read-back, and on Oxide every
// grant of another plugin's permission landed nowhere while this site recorded
// it as pushed (PLAN.md §27.6).
// Grants the plugin made and then did not find when it read them back (D85);
// since protocol 13 also `group:parent:name` for a parent that would not set.
const notLanded = new Set((report.notLanded || []).map((entry) => String(entry).toLowerCase()))
// A grant naming a permission this server has not registered did NOT land —
// `GrantUserPermission` no-ops silently for an unregistered name, which is
// why the plugin pre-checks and says so. Recording it as pushed would make the
// site believe it had given a privilege it had not.
//
// The same for a member the store could not place: the membership is waiting
// on their first connection, and it is not in the game yet.
// Protocol 12. A style field landed when BetterChat was there to take it and
// the report names it neither drift nor failed. With BetterChat absent none
// did, and each is sent again with the same `expect` next time (§33.2).
// the report names it neither drift nor failed (§33.2).
const chat = report.chat && typeof report.chat === 'object' ? report.chat : null
const chatLoaded = Boolean(chat && chat.loaded === true)
const fieldKey = (group, field) => `${group} ${String(field || '').toLowerCase()}`
@@ -366,19 +431,24 @@ async function applyReport(server, { desired, retire, styleRetired = [], report,
return !unresolved.has(row.object) && !notLanded.has(`${row.subject}:${row.object}`.toLowerCase())
}
if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
if (row.kind === 'group') {
// The value landed only if the parent did; a group whose parent would not
// set is recorded without a value, so the next sync pushes it again.
if ([...notLanded].some((entry) => entry.startsWith(`${row.subject}:parent:`))) {
row.value = null
}
}
return true
})
await db.addPushed(server.id, landed)
// Everything retired is gone from the game whether the plugin removed it or
// found it already absent, so it stops being something this site put there.
// A `chat-group` is not a ledger row; its fields are, below.
// found it already absent. A `chat-group` is not a ledger row; its fields are.
await db.removePushed(server.id, retire.filter((row) => row.kind !== 'chat-group'))
// A style's fields leave the ledger only once BetterChat has removed the group
// — or for `default`, which is never removed — so a style withdrawn while
// BetterChat was absent is retired by the first sync that can (D139).
// A style's fields leave the ledger only once BetterChat has removed the group,
// or for `default`, which is never removed (D139).
const removed = new Set((chatLoaded && chat.removed) || [])
await db.removePushed(
server.id,
@@ -388,51 +458,42 @@ async function applyReport(server, { desired, retire, styleRetired = [], report,
const revocations = await db.listRevocations(server.id)
await db.deleteRevocations(revocations.map((row) => row.id))
// What waits for a person: the reconcile's (D161's `adopt`, and an event's
// grant removed in the game), and a style field somebody changed by hand (D138).
await db.replaceDrift(server.id, [
...(report.foreign || []).map((row) => ({
...drift.map((row) => ({
kind: String(row.kind || ''),
subject: String(row.subject || ''),
object: String(row.object || ''),
detail: row.detail === undefined || row.detail === null ? null : String(row.detail).slice(0, 255),
direction: row.direction || 'added',
})),
// A style field somebody changed by hand, with what it holds now (D138).
...((chatLoaded && chat.drift) || []).map((row) => ({
kind: 'chat-field',
subject: String(row.group || ''),
object: String(row.field || ''),
detail: row.game === undefined || row.game === null ? null : String(row.game).slice(0, 255),
direction: 'changed',
})),
])
await db.putSyncResult(server.id, {
state: 'ok',
desiredHash: desired.hash,
syncedHash: desired.hash,
desiredHash: hash,
syncedHash: hash,
bootId,
wipeId,
report: JSON.stringify(report),
error: null,
})
// The option source, refreshed from the same server that just answered. It is
// a second round trip and it is worth it: the form must not offer a name that
// stopped being registered when somebody uninstalled a plugin, because a grant
// against one is a privilege nobody ever gets and nothing ever reports.
const catalogue = await sidecar.permCatalogue(server)
if (catalogue.ok && catalogue.data && Array.isArray(catalogue.data.permissions)) {
await db.putCatalogue(
server.id,
catalogue.data.permissions.map(model.normaliseName).filter(Boolean),
)
}
log.info('permissions synced', {
server: server.id,
applied: report.applied,
unresolved: (report.unresolved || []).length,
foreign: (report.foreign || []).length,
pending: (report.pending || []).length,
notLanded: (report.notLanded || []).length,
waiting: drift.length,
...(chat
? {
betterChat: chatLoaded,
@@ -452,6 +513,7 @@ module.exports = {
AUDIT_MS,
FAIL_BACKOFF_MS,
MAX_ROWS,
POLICIES,
start,
stop,
tick,
@@ -460,4 +522,5 @@ module.exports = {
applyReport,
withExpect,
styleRetirements,
withLock,
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,53 @@
// ── Admin · Rust · Permissions ────────────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/permissions…`. It is a second router rather than more
// routes on `rust.router.js` because it is a second subject: that one configures
// the bridge, this one authors privilege inside somebody's game.
// `/api/v1/admin/rust/permissions…`. The permission manager (PLAN_REDESIGNS §1):
// the site owns every permission and group on every server (D160), a server at a
// time on the screen (D162), with each server's policy for a change made in the
// game (D161).
//
// **Every route is `requireRole('admin')`.** The admin tier's own gate admits
// editors and moderators, and a moderator being able to grant themselves
// `kits.admin` on six servers is the whole of R1's "a weak link is now a
// privilege-escalation path" arriving through the front door instead. The tier
// gate is not re-implemented; this is one gate on top of it, exactly as the
// server-configuration routes do it.
//
// There is no module-declared site permission to gate these more finely with —
// `MODULE_API.md` has no such member at 1.10.0 — so role is the whole of the
// available vocabulary, and `admin` is the honest choice within it.
// privilege-escalation path" arriving through the front door instead.
const core = require('../../core')
const express = core.express
const permissions = require('./permissions.controller')
const { requireRole, validate } = core.middleware
const { body, param } = core.validator
const { body, param, query } = core.validator
const permissionsRouter = express.Router()
/** A permission or group name, as both mod frameworks store them. */
const NAME = /^[a-z0-9][a-z0-9._-]{0,127}$/i
/** A Steam id: seventeen digits in practice, digits always. */
const STEAM = /^\d{5,20}$/
const serverParam = param('serverId').isString().isLength({ min: 1, max: 64 })
const groupParam = param('id').isInt({ min: 1 }).toInt()
/** A subject: a Steam id, a website account id, or a username. */
const subject = [
body('steamId').optional().isString().matches(STEAM),
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
]
/** "Here only" on a shared group: split this server's copy off first (D190). */
const hereOnly = [
body('onlyHere').optional().isBoolean().toBoolean(),
body('serverId').optional().isString().isLength({ min: 1, max: 64 }),
]
permissionsRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'The whole permission model'
// #swagger.description = 'Groups with their permissions, members and BetterChat style (`chat`, or null), direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server. A drift row of kind `chat-field` is a style field somebody changed in game: `subject` is the group, `object` the field and `detail` what the game holds. `chatFields` lists the twelve BetterChat fields with their types and defaults, for the style editor.'
/* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */
// #swagger.summary = 'The permission manager: servers, policies and what waits for a person'
// #swagger.description = 'Every configured server with its policy for a change made in the game (`auto-adopt`, `adopt`, `revoke`; D161) and its sync state, and every row waiting for a person: each change under `adopt`, an event’s grant removed in the game, a hand-edited style field, and notices of a shared group split off for one server (D190). `chatFields` lists the twelve BetterChat fields for the style editor.'
/* #swagger.responses[200] = { description: 'The overview', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionOverview" } } } } */
requireRole('admin'),
permissions.overview,
)
@@ -42,118 +56,259 @@ permissionsRouter.get(
'/catalogue',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Permission names the servers have registered'
// #swagger.description = 'What the loaded plugins on each configured server have registered, cached from the last sync. It is the option source for the authoring form: a permission no server knows cannot be granted, because `GrantUserPermission` silently does nothing for an unregistered name.'
/* #swagger.responses[200] = { description: 'Every registered name, and which servers know it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */
// #swagger.description = 'Every permission name the loaded plugins on each server registered, from the last inventory, with the plugin that registered it (`owner`) — null for a name no plugin owns, which on Carbon is its built-in modules’.'
/* #swagger.responses[200] = { description: 'Every registered name, which servers know it, and who registered it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionCatalogue" } } } } */
requireRole('admin'),
permissions.catalogue,
)
permissionsRouter.put(
'/groups/:name',
permissionsRouter.get(
'/servers/:serverId',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a permission group'
// #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it. `chat` is the group’s BetterChat style: all twelve fields (`Priority`, `Title`, `TitleColor`, `TitleSize`, `TitleHidden`, `TitleHiddenIfNotPrimary`, `UsernameColor`, `UsernameSize`, `MessageColor`, `MessageSize`, `ChatFormat`, `ConsoleFormat`), each as text; `null` removes the style, which removes the group from BetterChat on the next sync; absent leaves it alone. A format must hold `{Message}` exactly once. A 400 carries one sentence per problem in `errors`.'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
// #swagger.summary = 'One server’s permissions, as the screen shows them'
// #swagger.description = 'Plugins grouped by the plugin that registered each permission, never by prefix; the groups on the server and where else each one is (D189); every player holding anything there, named by linked account and in-game name, or Steam id (D163); what the site wants and why, what has landed, and what the last report said did not — the facts every toggle’s state is read from (U-1).'
/* #swagger.responses[200] = { description: 'The server view', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionServer" } } } } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
param('name').matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'),
body('title').optional().isString().trim().isLength({ max: 120 }),
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
body('permissions').optional().isArray({ max: 500 }),
body('permissions.*').isString().matches(NAME),
// Shape only; the twelve fields and their rules are `chatStyle.validateStyle`'s,
// in the controller, so the form gets one sentence per problem.
body('chat').optional({ values: 'null' }).isObject().withMessage('chat is an object of BetterChat fields, or null'),
serverParam,
validate,
permissions.putGroup,
permissions.server,
)
permissionsRouter.get(
'/servers/:serverId/players',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Find a player seen on a server'
// #swagger.description = 'By in-game name, Steam id or linked account name, among the players this server has seen — to grant to somebody who holds nothing yet. At most 25, newest first.'
/* #swagger.parameters['q'] = { in: 'query', description: 'Part of a name, Steam id or account name', type: 'string' } */
/* #swagger.responses[200] = { description: 'Matching players' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
query('q').optional().isString().isLength({ max: 64 }),
validate,
permissions.players,
)
permissionsRouter.put(
'/servers/:serverId/policy',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Set what a change made in the game becomes'
// #swagger.description = '`auto-adopt` (the default) makes it the site’s own for that server; `adopt` puts each one to a person; `revoke` undoes it (D161).'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Not a policy' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
body('policy').isString().isIn(['auto-adopt', 'adopt', 'revoke']),
validate,
permissions.setPolicy,
)
permissionsRouter.post(
'/servers/:serverId/grant',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Grant permissions to one player (a toggle, or Grant all)'
// #swagger.description = 'On this server, or with `everywhere` on every server. A linked Steam id is granted as its website account and reaches every account the person links (D28); an unlinked one as itself (D188). A grant kept off this server by an exception has the exception removed instead.'
/* #swagger.responses[200] = { description: 'How many were granted, and how many exceptions removed' } */
/* #swagger.responses[400] = { description: 'No subject named' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
...subject,
body('permissions').isArray({ min: 1, max: 500 }),
body('permissions.*').isString().matches(NAME),
body('everywhere').optional().isBoolean().toBoolean(),
validate,
permissions.grant,
)
permissionsRouter.post(
'/servers/:serverId/revoke',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Revoke permissions from one player (a toggle, or Revoke all)'
// #swagger.description = 'Every direct grant that puts the permission on this server stops doing so: one scoped to this server is deleted; one that reaches further is deleted with `everywhere`, and otherwise gains an exception for this server (D190). What the player holds through a group or from an event is reported back in `untouched`, not changed.'
/* #swagger.responses[200] = { description: 'How many grants changed, and what could not be' } */
/* #swagger.responses[400] = { description: 'No subject named' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
...subject,
body('permissions').isArray({ min: 1, max: 500 }),
body('permissions.*').isString().matches(NAME),
body('everywhere').optional().isBoolean().toBoolean(),
validate,
permissions.revoke,
)
permissionsRouter.post(
'/servers/:serverId/groups',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create a group on one server'
// #swagger.description = 'A group belongs to one server unless an admin shares it (D189). A server cannot have two groups of one name.'
/* #swagger.responses[201] = { description: 'Created; the body carries its id' } */
/* #swagger.responses[404] = { description: 'No such server' } */
/* #swagger.responses[409] = { description: 'This server already has a group of that name' } */
requireRole('admin'),
serverParam,
body('name').isString().matches(NAME).withMessage('a group name is letters, digits, dots, dashes and underscores'),
body('title').optional().isString().isLength({ max: 120 }),
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
body('parent').optional().isString().isLength({ max: 64 }),
validate,
permissions.createGroup,
)
permissionsRouter.patch(
'/groups/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change a group’s title, rank, parent or chat style'
// #swagger.description = 'The title is kept verbatim. `chat` is the group’s BetterChat style — all twelve fields as text; `null` removes it; absent leaves it. With `onlyHere` and `serverId` on a shared group, that server’s copy is split off first and only it changes (D190).'
/* #swagger.responses[200] = { description: 'Saved; `id` is the group that changed, a new copy if it was split' } */
/* #swagger.responses[400] = { description: 'An invalid style' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
groupParam,
body('title').optional().isString().isLength({ max: 120 }),
body('rank').optional().isInt({ min: -1000, max: 1000 }).toInt(),
body('parent').optional().isString().isLength({ max: 64 }),
body('chat').optional({ values: 'null' }).isObject().withMessage('chat is an object of BetterChat fields, or null'),
...hereOnly,
validate,
permissions.updateGroup,
)
permissionsRouter.delete(
'/groups/:name',
'/groups/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Delete a permission group'
// #swagger.description = 'Removes the group, its permission list and its membership from the site. The next sync retires the group from every server it had been pushed to — a group the site authored and has withdrawn is removed from the game, unlike one somebody created by hand.'
// #swagger.summary = 'Delete a group'
// #swagger.description = 'From the site and, at the next sync, from every server it is on. A built-in group (`default`, `admin`, Carbon’s `moderator`) is never removed from a game.'
/* #swagger.responses[204] = { description: 'Deleted' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
groupParam,
validate,
permissions.deleteGroup,
)
permissionsRouter.post(
'/groups/:name/members',
permissionsRouter.put(
'/groups/:id/permissions',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Put an account in a group'
// #swagger.description = 'Membership is authored against a website user and reaches every Steam account they have linked. A member who has never connected to a server cannot be placed in its store yet — the sync reports them as pending and the membership lands on their first connection.'
/* #swagger.responses[204] = { description: 'Added' } */
// #swagger.summary = 'Replace what a group carries'
// #swagger.description = 'The whole list: the screen’s toggles, Grant all and Revoke all each send it. With `onlyHere` and `serverId` on a shared group, that server’s copy is split off first (D190).'
/* #swagger.responses[200] = { description: 'Saved; `id` is the group that changed' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
// Either identifier: the screen sends a name, the panel inside core's own user
// page already holds an id.
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
groupParam,
body('permissions').isArray({ max: 2000 }),
body('permissions.*').isString().matches(NAME),
...hereOnly,
validate,
permissions.setGroupPermissions,
)
permissionsRouter.put(
'/groups/:id/servers',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Share a group, or stop sharing it'
// #swagger.description = '`allServers` puts it on every server, including servers added later; otherwise `servers` lists them (D189). A chosen server that already has its own group of this name answers 409 with each one’s permissions; repeat with `replace` listing the ids to replace.'
/* #swagger.responses[200] = { description: 'Saved' } */
/* #swagger.responses[404] = { description: 'No such group' } */
/* #swagger.responses[409] = { description: 'A chosen server has its own group of this name' } */
requireRole('admin'),
groupParam,
body('allServers').optional().isBoolean().toBoolean(),
body('servers').optional().isArray({ max: 200 }),
body('servers.*').isString().isLength({ min: 1, max: 64 }),
body('replace').optional().isArray({ max: 200 }),
body('replace.*').isInt({ min: 1 }).toInt(),
validate,
permissions.setGroupServers,
)
permissionsRouter.post(
'/groups/:id/split',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Give one server its own copy of a shared group'
// #swagger.description = 'The copy carries the same permissions, members and style, on that server only, and the shared group stops covering it (D190).'
/* #swagger.responses[200] = { description: 'Split; `id` is the copy' } */
/* #swagger.responses[404] = { description: 'No such group' } */
/* #swagger.responses[409] = { description: 'That group is not on that server' } */
requireRole('admin'),
groupParam,
body('serverId').isString().isLength({ min: 1, max: 64 }),
validate,
permissions.splitGroup,
)
permissionsRouter.post(
'/groups/:id/members',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Put a player in a group'
// #swagger.description = 'A Steam id is a member as itself (D188); a website account reaches every Steam id it links (D28). A member who has never connected to a server is pending there until their first connection.'
/* #swagger.responses[204] = { description: 'Added' } */
/* #swagger.responses[400] = { description: 'No subject named' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
groupParam,
...subject,
...hereOnly,
validate,
permissions.addMember,
)
permissionsRouter.delete(
'/groups/:name/members/:userId',
permissionsRouter.post(
'/groups/:id/members/remove',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Take an account out of a group'
// #swagger.summary = 'Take a player out of a group'
// #swagger.description = 'Both ways they can be in it: as the Steam id, and as the website account it is linked to.'
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such group, or that account is not in it' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
param('name').isString().isLength({ min: 1, max: 64 }),
param('userId').isInt({ min: 1 }).toInt(),
groupParam,
...subject,
...hereOnly,
validate,
permissions.removeMember,
)
permissionsRouter.post(
'/grants',
'/groups/:id/members/clear',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Grant one permission to one person'
// #swagger.description = 'A direct grant, authored against a website user and pushed to every Steam account they have linked. Unlike group membership it reaches a player who has never connected to the server, which is what an entitlement earned on the website has to do.'
/* #swagger.responses[201] = { description: 'Granted' } */
/* #swagger.responses[200] = { description: 'They already held it; nothing changed' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
/* #swagger.responses[404] = { description: 'No account on this site has that name' } */
// #swagger.summary = 'Remove every member of a group'
/* #swagger.responses[204] = { description: 'Emptied' } */
/* #swagger.responses[404] = { description: 'No such group' } */
requireRole('admin'),
body('userId').optional().isInt({ min: 1 }).toInt(),
body('username').optional().isString().trim().isLength({ min: 1, max: 64 }),
body('permission').isString().matches(NAME),
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
body('note').optional().isString().isLength({ max: 255 }),
groupParam,
...hereOnly,
validate,
permissions.addGrant,
permissions.clearMembers,
)
permissionsRouter.delete(
'/grants/:id',
'/exceptions/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Remove a grant'
// #swagger.description = 'The next sync revokes it in every in-scope game. A player who has already used what it allowed keeps what they did with it — the grant is the entitlement, not the consumption.'
// #swagger.summary = 'Give a grant back to the one server it was kept off'
/* #swagger.responses[204] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such grant' } */
/* #swagger.responses[404] = { description: 'No such exception' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
validate,
permissions.removeGrant,
permissions.removeException,
)
const driftId = param('id').isInt({ min: 1 }).toInt()
permissionsRouter.post(
'/drift/:id/adopt',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Adopt a hand edit'
// #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link. For a `chat-field` row it copies the game’s value into the group’s style — which every server in the group’s scope is then pushed — and answers 409 when the group has no style or the value is not one the site accepts.'
// #swagger.summary = 'Adopt a change made in the game'
// #swagger.description = 'An addition (or a changed group) becomes the site’s own for that server — the same write auto-adopt makes. For a `chat-field` row, the game’s value becomes the group’s style; 409 when the group has no style or the value is not one the site accepts.'
/* #swagger.responses[204] = { description: 'Adopted' } */
/* #swagger.responses[400] = { description: 'That kind of drift cannot be adopted' } */
/* #swagger.responses[409] = { description: 'That Steam account is linked to nobody on this site' } */
/* #swagger.responses[400] = { description: 'That change was a removal' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
driftId,
validate,
permissions.adoptDrift,
)
@@ -161,21 +316,63 @@ permissionsRouter.post(
permissionsRouter.post(
'/drift/:id/revoke',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Revoke a hand edit'
// #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own. For a `chat-field` row it puts the site’s value back over the hand edit on the next sync.'
// #swagger.summary = 'Undo an addition made in the game'
// #swagger.description = 'Queued: a server that is down keeps the instruction until it comes back. For a `chat-field` row it puts the site’s value back.'
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
/* #swagger.responses[404] = { description: 'No such drift' } */
/* #swagger.responses[400] = { description: 'Only an addition can be revoked' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
param('id').isInt({ min: 1 }).toInt(),
driftId,
validate,
permissions.revokeDrift,
)
permissionsRouter.post(
'/drift/:id/accept',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Accept a removal made in the game'
// #swagger.description = 'The site stops giving it on that server: deleted when it was that server’s alone, an exception when it reached further, a split when it was a shared group’s (D190).'
/* #swagger.responses[204] = { description: 'Accepted' } */
/* #swagger.responses[400] = { description: 'Only a removal can be accepted' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
driftId,
validate,
permissions.acceptDrift,
)
permissionsRouter.post(
'/drift/:id/restore',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Put back what the game removed or changed'
// #swagger.description = 'The next sync pushes the site’s version again.'
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
/* #swagger.responses[400] = { description: 'Only a removal or a changed group can be put back' } */
/* #swagger.responses[404] = { description: 'No such change' } */
requireRole('admin'),
driftId,
validate,
permissions.restoreDrift,
)
permissionsRouter.post(
'/drift/:id/dismiss',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Dismiss a notice'
// #swagger.description = 'A split notice (D190) or an event’s grant that was pushed back. Nothing in any game changes.'
/* #swagger.responses[204] = { description: 'Dismissed' } */
/* #swagger.responses[404] = { description: 'No such notice' } */
requireRole('admin'),
driftId,
validate,
permissions.dismissDrift,
)
permissionsRouter.post(
'/sync',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Push the permission set now'
// #swagger.description = 'Runs the reconciliation loop’s pass immediately, for one server or for all of them, and answers with what each one reported. The loop does this on its own; the button exists so an operator who has just changed something can see it land, and finds out at once when a server is unreachable.'
// #swagger.summary = 'Read, reconcile and push now'
// #swagger.description = 'Runs the loop’s pass immediately for one server or all of them — read the store, settle what changed in the game by the server’s policy, push — and answers with each server’s state.'
/* #swagger.responses[200] = { description: 'The state of every server after the pass', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionSyncResult" } } } } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),

View File

@@ -83,32 +83,41 @@ async function listPermissions(req, res) {
const userId = Number(req.params.id)
try {
const [groups, groupPermissions, members, grants, allLinks] = await Promise.all([
const [groups, groupServers, groupPermissions, members, grants, allLinks] = await Promise.all([
permissionsDb.listGroups(),
permissionsDb.listGroupServers(),
permissionsDb.listGroupPermissions(),
permissionsDb.listGroupMembers(),
permissionsDb.listGrants({ userId }),
permissionsDb.listLinks(),
])
const theirs = new Set(
members.filter((row) => row.userId === userId).map((row) => row.groupName),
)
const theirs = new Set(members.filter((row) => row.userId === userId).map((row) => row.groupId))
const carried = new Map()
for (const row of groupPermissions) {
if (!carried.has(row.groupName)) carried.set(row.groupName, [])
carried.get(row.groupName).push(row.permission)
if (!carried.has(row.groupId)) carried.set(row.groupId, [])
carried.get(row.groupId).push(row.permission)
}
// A group is on one server unless it is shared (D189): `scope` says `*`
// for every server, or lists the servers it is on.
const on = new Map()
for (const row of groupServers) {
if (!row.included) continue
if (!on.has(row.groupId)) on.set(row.groupId, [])
on.get(row.groupId).push(row.serverId)
}
res.json({
groups: groups
.filter((group) => theirs.has(group.name))
.filter((group) => theirs.has(group.id))
.map((group) => ({
id: group.id,
name: group.name,
title: group.title,
scope: group.scope,
permissions: carried.get(group.name) || [],
scope: group.allServers ? permissions.FLEET : (on.get(group.id) || []).join(','),
permissions: carried.get(group.id) || [],
})),
grants: permissions.collapseGrants(grants).map((grant) => ({
id: grant.id,

View File

@@ -254,6 +254,70 @@ const permCatalogue = (server) => request(server, '/permissions/catalogue')
*/
const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set })
/**
* One page of the game's whole permission store (protocol 13, PLAN_REDESIGNS
* §1.2): every permission with the plugin that registered it, every group, every
* holder, and what event leases hold. `{ page: 0 }` starts a fresh read; later
* pages name the `snapshotId` page 0 answered with.
*
* Like the sync, a refusal (`perm.error`: `busy`, `stale`, `too-large`) comes
* back `{ ok: true }` and is told apart by `data.kind`.
*/
const permInventory = (server, { snapshotId = null, page = 0 } = {}) =>
request(server, '/permissions/inventory', {
method: 'POST',
body: snapshotId ? { snapshotId, page } : { page },
})
/**
* The whole inventory, every page, or `{ ok: false, error }`. A snapshot that
* goes stale mid-read is started again once from page 0: a page from a different
* snapshot would tear the answer.
*/
async function readInventory(server) {
for (let attempt = 0; attempt < 2; attempt++) {
// eslint-disable-next-line no-await-in-loop
const first = await permInventory(server, { page: 0 })
if (!first.ok) return { ok: false, error: first.status || 'unreachable' }
const head = first.data || {}
if (head.kind === 'perm.error') return { ok: false, error: `the game refused the read: ${head.reason || 'unknown'}` }
if (head.kind !== 'perm.inventory') return { ok: false, error: 'the game does not know perm.inventory (protocol 13)' }
let users = Array.isArray(head.users) ? head.users : []
let stale = false
for (let page = 1; page < Number(head.pages || 1); page++) {
// eslint-disable-next-line no-await-in-loop
const next = await permInventory(server, { snapshotId: head.snapshotId, page })
const data = (next.ok && next.data) || {}
if (data.kind !== 'perm.inventory') {
stale = true
break
}
users = users.concat(Array.isArray(data.users) ? data.users : [])
}
if (stale) continue
return {
ok: true,
inventory: {
snapshotId: head.snapshotId,
permissions: Array.isArray(head.permissions) ? head.permissions : [],
groups: Array.isArray(head.groups) ? head.groups : [],
leased: Array.isArray(head.leased) ? head.leased : [],
users,
stats: head.stats || null,
},
}
}
return { ok: false, error: 'the inventory changed while it was being read, twice' }
}
/**
* Every settings file on one game host, and every plugin loaded to reload one
* (protocol 5, R18).
@@ -441,6 +505,8 @@ module.exports = {
confirmLink,
permCatalogue,
permSync,
permInventory,
readInventory,
configFiles,
configFile,
configWrite,

View File

@@ -383,90 +383,30 @@ module.exports = {
},
},
},
RustPermissionModel: {
RustPermissionOverview: {
type: 'object',
description:
'The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use.',
'The permission manager’s front page (GET /admin/rust/permissions): every server with its policy and sync state, and every change made in a game that waits for a person (PLAN_REDESIGNS §1).',
properties: {
groups: {
type: 'array',
description: 'Groups the site authors, mirrored into each in-scope game as a real group.',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
rank: { type: 'integer', example: 10 },
scope: {
type: 'string',
description: 'A server id, or `*` for every server.',
example: '*',
},
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
chat: {
type: 'object',
nullable: true,
description: 'The group’s BetterChat style — all twelve fields as text — or null for a group without one (D138).',
additionalProperties: { type: 'string' },
example: { Title: '[VIP]', TitleColor: '#ffaa55', ChatFormat: '{Title} {Username}: {Message}' },
},
members: {
type: 'array',
items: {
type: 'object',
properties: {
userId: { type: 'integer', example: 42 },
username: { type: 'string', example: 'wanderer' },
steamId: {
type: 'string',
nullable: true,
description: 'Null when this account has linked no Steam id, in which case the membership reaches nobody yet.',
example: '76561198000000000',
},
playerName: { type: 'string', nullable: true, example: 'Wanderer' },
},
},
},
},
},
},
grants: {
type: 'array',
description: 'Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected.',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 7 },
userId: { type: 'integer', example: 42 },
username: { type: 'string', example: 'wanderer' },
permission: { type: 'string', example: 'kits.gold' },
scope: { type: 'string', example: 'main' },
source: {
type: 'string',
description: 'What authored it — `admin`, `adopted`, or a later phase’s own writer.',
example: 'admin',
},
note: { type: 'string', nullable: true, example: null },
grantedAt: { type: 'string', format: 'date-time' },
accounts: {
type: 'array',
description: 'The Steam accounts this grant reaches. Empty means it reaches nobody yet.',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: { type: 'string', nullable: true, example: 'Wanderer' },
},
},
},
},
},
},
servers: {
type: 'array',
description: 'The state of the mirror, per configured server.',
items: { $ref: '#/components/schemas/RustPermissionSyncState' },
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'rust-oxide' },
name: { type: 'string', example: 'Oxide rig' },
policy: {
type: 'string',
enum: ['auto-adopt', 'adopt', 'revoke'],
description: 'What a change made in the game becomes (D161): the site’s own, a question for a person, or undone.',
example: 'auto-adopt',
},
sync: { $ref: '#/components/schemas/RustPermissionSyncState' },
},
},
},
drift: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionDrift' } },
policies: { type: 'array', items: { type: 'string' }, example: ['auto-adopt', 'adopt', 'revoke'] },
chatFields: {
type: 'array',
description: 'The twelve BetterChat group fields a style carries, with each one’s type and BetterChat’s default, for the style editor.',
@@ -479,49 +419,101 @@ module.exports = {
},
},
},
drift: {
},
},
RustPermissionDrift: {
type: 'object',
description: 'A change made in a game that waits for a person: every change under the `adopt` policy, an event’s grant removed in the game, a hand-edited style field, or a notice that a shared group was split (D190).',
properties: {
id: { type: 'integer', example: 3 },
serverId: { type: 'string', example: 'rust-oxide' },
kind: { type: 'string', description: '`grant`, `member`, `group-permission`, `group`, or `chat-field`.', example: 'grant' },
direction: { type: 'string', enum: ['added', 'removed', 'changed', 'split'], example: 'added' },
subject: { type: 'string', description: 'A Steam id, or a group name.', example: '76561198000000000' },
object: { type: 'string', description: 'A permission or group name, a style field, or empty.', example: 'kits.vip' },
detail: { type: 'string', nullable: true, description: 'What the game holds now (a style value, a group’s title, rank and parent), or a notice’s sentence.', example: null },
username: { type: 'string', nullable: true, example: 'wanderer' },
playerName: { type: 'string', nullable: true, example: 'Wanderer' },
firstSeen: { type: 'string', format: 'date-time' },
},
},
RustPermissionServer: {
type: 'object',
description:
'One server as the permission screen shows it (GET /admin/rust/permissions/servers/{serverId}), following uMod PermissionsManager’s flow (D162): plugins grouped by the plugin that registered each permission, the groups on the server (D189), every player holding anything there named by account and in-game name (D163), and the facts each toggle’s state is read from.',
properties: {
server: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' } } },
servers: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' } } } },
policy: { type: 'string', example: 'auto-adopt' },
sync: { $ref: '#/components/schemas/RustPermissionSyncState' },
plugins: {
type: 'array',
description: 'What a game holds that the site did not author. Reported, never undone.',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 3 },
serverId: { type: 'string', example: 'main' },
kind: {
type: 'string',
description: 'One of `grant`, `member`, `group-permission`, or `chat-field` for a style field changed in game.',
example: 'grant',
},
detail: {
type: 'string',
nullable: true,
description: 'For `chat-field`, the value the game holds now. Null for every other kind.',
example: '#ff0000',
},
subject: {
type: 'string',
description: 'A Steam id, or a group name.',
example: '76561198000000000',
},
object: {
type: 'string',
description: 'A permission name, or a group name.',
example: 'kits.admin',
},
username: {
type: 'string',
nullable: true,
description: 'The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked.',
example: 'wanderer',
},
firstSeen: { type: 'string', format: 'date-time' },
key: { type: 'string', example: 'plugin:ZoneManager' },
label: { type: 'string', example: 'ZoneManager' },
registered: { type: 'boolean', description: 'False for a name no plugin owns (Carbon’s built-in modules), grouped by its prefix.', example: true },
permissions: { type: 'array', items: { type: 'string', example: 'zonemanager.ignoreflag.nokits' } },
},
},
},
catalogue: {
groups: {
type: 'array',
items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' },
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 12 },
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
rank: { type: 'integer', example: 10 },
parent: { type: 'string', example: 'default' },
source: { type: 'string', example: 'imported' },
builtin: { type: 'boolean', example: false },
allServers: { type: 'boolean', example: false },
shared: { type: 'boolean', description: 'On more than one server; a change to it asks whether to change it everywhere or split this server off.', example: false },
servers: { type: 'array', items: { type: 'string', example: 'rust-oxide' } },
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
members: { type: 'array', items: { type: 'object', properties: { userId: { type: 'integer' }, username: { type: 'string' }, steamIds: { type: 'array', items: { type: 'string' } } } } },
steamMembers: { type: 'array', items: { type: 'string', example: '76561198000000000' } },
chat: { type: 'object', nullable: true, additionalProperties: { type: 'string' } },
},
},
},
players: {
type: 'array',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: { type: 'string', nullable: true, example: 'Wanderer' },
account: { type: 'object', nullable: true, properties: { userId: { type: 'integer' }, username: { type: 'string' } } },
grants: {
type: 'array',
items: {
type: 'object',
properties: {
permission: { type: 'string', example: 'kits.vip' },
sources: { type: 'array', description: 'What puts it there: `userGrant`, `steamGrant` or `runGrant`, with its id and scope.', items: { type: 'object' } },
},
},
},
groups: { type: 'array', items: { type: 'string', example: 'vip' } },
},
},
},
excepted: { type: 'array', description: 'Grants that reach every server but this one (D190).', items: { type: 'object' } },
landed: { type: 'array', description: 'What has landed on this server: `grant <steamId> <permission>` and `member <steamId> <group>`.', items: { type: 'string' } },
report: {
type: 'object',
nullable: true,
properties: {
unresolved: { type: 'array', items: { type: 'string' } },
pending: { type: 'array', items: { type: 'string' } },
notLanded: { type: 'array', items: { type: 'string' } },
},
},
drift: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionDrift' } },
},
},
RustPermissionSyncState: {
@@ -542,6 +534,7 @@ module.exports = {
dirty: { type: 'boolean', example: false },
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
lastOkAt: { type: 'string', format: 'date-time', nullable: true },
importedAt: { type: 'string', format: 'date-time', nullable: true, description: 'When this server’s store was first imported (D198). Null until then; until then every sync imports.' },
error: {
type: 'string',
nullable: true,
@@ -593,6 +586,7 @@ module.exports = {
properties: {
permission: { type: 'string', example: 'kits.vip' },
servers: { type: 'array', items: { type: 'string', example: 'main' } },
owner: { type: 'string', nullable: true, description: 'The plugin that registered it, from the inventory (PLAN_REDESIGNS §0.1).', example: 'Kits' },
},
},
RustPermissionSyncResult: {
@@ -600,7 +594,7 @@ module.exports = {
description: 'What a forced sync produced (POST /admin/rust/permissions/sync).',
properties: {
servers: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionSyncState' } },
drift: { type: 'array', items: { type: 'object' } },
drift: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionDrift' } },
},
},
RustUserPermissions: {

View File

@@ -187,14 +187,19 @@ test('a voice has no sender: no username, and no stray colon where BetterChat’
test('the desired set carries a style per field, and its value moves the digest but not the row’s identity', () => {
const model = require('../model/permissions/permissions.model')
const base = {
groups: [{ name: 'staff', title: 'Staff', rank: 0, scope: '*' }],
groups: [{ id: 1, name: 'staff', title: 'Staff', rank: 0, parent: '', allServers: true }],
groupServers: [],
groupPermissions: [],
members: [],
steamMembers: [],
grants: [],
steamGrants: [],
exceptions: [],
steamIdsByUser: new Map(),
// A style belongs to a group ROW since groups became per server (D189).
groupChat: [
{ groupName: 'staff', field: 'Title', value: '[Staff]' },
{ groupName: 'staff', field: 'TitleColor', value: '#ff0000' },
{ groupId: 1, field: 'Title', value: '[Staff]' },
{ groupId: 1, field: 'TitleColor', value: '#ff0000' },
],
}
const a = model.buildDesired('main', base)
@@ -282,8 +287,9 @@ test('a style field landed only when BetterChat took it; drift keeps the game’
assert.ok(!deletes.some((d) => d.includes('gone')), 'a group BetterChat has not removed stays, to be retired again')
assert.ok(!deletes.some((d) => d.includes('chat-group')), 'a chat-group retirement is not a ledger row')
// Protocol 13: every "needs a person" row says which way it went.
const drift = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_drift'))
assert.deepStrictEqual(drift.params, ['main', 'chat-field', 'staff', 'TitleColor', '#123456'])
assert.deepStrictEqual(drift.params, ['main', 'chat-field', 'staff', 'TitleColor', '#123456', 'changed'])
})
test('with BetterChat absent no style field landed, and a withdrawn style is kept for later', async () => {

View File

@@ -46,23 +46,29 @@ function withCore(overrides = {}) {
/** One authored set: a fleet group, a server-scoped group, and two grants. */
function authored() {
return {
// A group on every server, and one on `creative` alone (D189).
groups: [
{ name: 'vip', title: 'VIP', rank: 10, scope: '*' },
{ name: 'builder', title: 'Builder', rank: 0, scope: 'creative' },
{ id: 1, name: 'vip', title: 'VIP', rank: 10, parent: '', allServers: true },
{ id: 2, name: 'builder', title: 'Builder', rank: 0, parent: '', allServers: false },
],
groupServers: [{ groupId: 2, serverId: 'creative', included: true }],
groupPermissions: [
{ groupName: 'vip', permission: 'kits.vip' },
{ groupName: 'builder', permission: 'buildtools.use' },
{ groupId: 1, permission: 'kits.vip' },
{ groupId: 2, permission: 'buildtools.use' },
],
members: [
{ groupName: 'vip', userId: 1 },
{ groupName: 'builder', userId: 2 },
{ groupId: 1, userId: 1 },
{ groupId: 2, userId: 2 },
],
steamMembers: [],
grants: [
{ id: 1, userId: 1, permission: 'kits.gold', scope: '*', steamId: '7656001' },
{ id: 2, userId: 3, permission: 'kits.gold', scope: '*', steamId: null },
{ id: 3, userId: 2, permission: 'zonemanager.admin', scope: 'creative', steamId: '7656002' },
],
steamGrants: [],
exceptions: [],
groupChat: [],
// One person with TWO Steam accounts, one with one, one with none.
steamIdsByUser: new Map([
[1, ['7656001', '7656099']],
@@ -83,17 +89,18 @@ test('a grant reaches every Steam account its holder has linked (D28)', () => {
for (const row of payload.grants) assert.deepEqual(row.permissions, ['kits.gold'])
})
test('a holder who has linked nothing contributes to the namespace but reaches nobody', () => {
test('a holder who has linked nothing reaches nobody, and is not an error', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const { payload, rows } = model.buildDesired('main', authored())
// User 3 holds `kits.gold` and has no account. Nothing is pushed for them…
// User 3 holds `kits.gold` and has no account. Nothing is pushed for them, and
// the grant is still the site's: it reaches them the day they link.
assert.ok(!rows.some((row) => row.kind === 'grant' && row.subject === null))
// …and the permission is still MANAGED, which is what makes a hand grant of it
// to somebody else show up as drift rather than as nothing at all.
assert.ok(payload.managed.includes('kits.gold'))
assert.deepEqual(payload.grants.map((g) => g.steamId).sort(), ['7656001', '7656099'])
// Protocol 13 sends no `managed` namespace: the inventory reads the whole store.
assert.equal(payload.managed, undefined)
})
test('scope decides what a server is sent at all (D29)', () => {
@@ -107,8 +114,9 @@ test('scope decides what a server is sent at all (D29)', () => {
assert.deepEqual(creative.payload.groups.map((g) => g.name).sort(), ['builder', 'vip'])
// The server-scoped grant is on `creative` and nowhere else.
assert.ok(!main.payload.managed.includes('zonemanager.admin'))
assert.ok(creative.payload.managed.includes('zonemanager.admin'))
const holds = (desired, permission) => desired.payload.grants.some((g) => g.permissions.includes(permission))
assert.ok(!holds(main, 'zonemanager.admin'))
assert.ok(holds(creative, 'zonemanager.admin'))
})
test('a group travels as a group: its members and its permissions are separate facts (D30)', () => {
@@ -349,7 +357,7 @@ test('an event grant is unioned with the admin grants, reaches only its server,
assert.deepEqual(grantsFor('7656001'), ['kits.event', 'kits.gold'])
assert.deepEqual(grantsFor('7656099'), ['kits.event', 'kits.gold'])
assert.ok(!payload.managed.includes('kits.creative'))
assert.ok(!payload.grants.some((g) => g.permissions.includes('kits.creative')))
assert.strictEqual(rows.filter((r) => r.kind === 'grant' && r.object === 'kits.gold').length, 2)
assert.deepEqual(payload.credits, [

View File

@@ -45,11 +45,15 @@ const SERVERS = [
/** One person: in a fleet group, holding one server-scoped grant. */
function fixture({ pushed = [] } = {}) {
return {
listGroupsForUser: [{ name: 'vip', title: 'VIP', rank: 10, scope: '*', addedAt: '2026-09-01T00:00:00Z' }],
// A group on every server (D189: `allServers`, where the old model said `scope: '*'`).
listGroupsForUser: [{ id: 1, name: 'vip', title: 'VIP', rank: 10, allServers: true, addedAt: '2026-09-01T00:00:00Z' }],
listGroupServers: [],
listGroupPermissions: [
{ groupName: 'vip', permission: 'Kits.VIP' },
{ groupName: 'builder', permission: 'buildtools.use' },
{ groupId: 1, permission: 'Kits.VIP' },
{ groupId: 2, permission: 'buildtools.use' },
],
listSteamGrants: [],
listExceptions: [],
listGrants: [
{ id: 7, userId: 4, permission: 'zonemanager.admin', scope: 'creative', source: 'admin', note: null, grantedAt: '2026-09-02T00:00:00Z', steamId: '7656119', playerName: 'Wanderer' },
],
@@ -110,8 +114,11 @@ test('a grant and a membership are different rows about the same person', async
// direct grant named `vip` would otherwise be one entry in the map, and the
// wrong one would light up.
const { model, restore } = modelWith({
listGroupsForUser: [{ name: 'vip', title: 'VIP', rank: 0, scope: '*', addedAt: null }],
listGroupsForUser: [{ id: 1, name: 'vip', title: 'VIP', rank: 0, allServers: true, addedAt: null }],
listGroupServers: [],
listGroupPermissions: [],
listSteamGrants: [],
listExceptions: [],
listGrants: [{ id: 1, userId: 4, permission: 'vip', scope: '*', source: 'admin', note: null, grantedAt: null, steamId: '7656119' }],
listPushedForSteamIds: [{ serverId: 'main', kind: 'grant', subject: '7656119', object: 'vip' }],
})

View File

@@ -0,0 +1,310 @@
// ── Three sets, and what a change made in the game becomes (PLAN_REDESIGNS §1) ──
//
// The reconciler decides what an in-game change becomes: the site's own
// (auto-adopt), a question for a person (adopt), or undone (revoke) — and the
// first read of a server imports everything (D198). Two rules here protect the
// site's own grants, and each has a test: a permission the server has not
// REGISTERED right now is never judged (a plugin unloaded for a minute is not a
// revocation), and a pair an event lease holds is the lease's.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function load() {
require('../core')._reset()
require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
return {
reconcile: require('../model/permissions/reconcile'),
model: require('../model/permissions/permissions.model'),
}
}
const REGISTERED = new Set(['kits.vip', 'kits.gold', 'zonemanager.zone'])
test('the inventory becomes ledger-shaped rows, and `default` membership is not judged', () => {
const { reconcile, model } = load()
const present = reconcile.presentRows({
groups: [{ name: 'VIP', title: 'VIP ', rank: 2, parent: 'default', permissions: ['Kits.VIP'] }],
users: [{ steamId: '7656001', permissions: ['kits.gold'], groups: ['vip', 'default'] }],
})
assert.deepEqual(present.map(model.rowKey).sort(), [
'grant 7656001 kits.gold',
'group vip ',
'group-permission vip kits.vip',
'member 7656001 default',
'member 7656001 vip',
])
// The title is kept verbatim — Carbon's own end in a space.
assert.equal(present.find((r) => r.kind === 'group').value, model.groupValue('VIP ', 2, 'default'))
const classes = reconcile.classify({ present, pushed: [], desired: [], registered: REGISTERED })
assert.ok(!classes.added.some((r) => r.kind === 'member' && r.object === 'default'))
})
test('added, removed, changed and landed are told apart by the pushed ledger', () => {
const { reconcile, model } = load()
const v = (t) => model.groupValue(t, 0, '')
const present = [
{ kind: 'grant', subject: 's1', object: 'kits.vip' }, // nobody's: added in the game
{ kind: 'grant', subject: 's2', object: 'kits.gold' }, // wanted, never pushed: landed
{ kind: 'group', subject: 'vip', object: '', value: v('Changed') },
]
const pushed = [
{ kind: 'grant', subject: 's3', object: 'kits.gold' }, // pushed, wanted, gone: removed
{ kind: 'group', subject: 'vip', object: '', value: v('VIP') },
]
const desired = [
{ kind: 'grant', subject: 's2', object: 'kits.gold' },
{ kind: 'grant', subject: 's3', object: 'kits.gold' },
{ kind: 'group', subject: 'vip', object: '', value: v('VIP') },
]
const c = reconcile.classify({ present, pushed, desired, registered: REGISTERED })
assert.deepEqual(c.added.map(model.rowKey), ['grant s1 kits.vip'])
assert.deepEqual(c.removed.map(model.rowKey), ['grant s3 kits.gold'])
assert.deepEqual(c.landed.map(model.rowKey), ['grant s2 kits.gold'])
assert.deepEqual(c.changed.map((r) => r.value), [v('Changed')])
})
test('a permission the server does not register right now is never judged — an unloaded plugin is not a revocation', () => {
const { reconcile } = load()
// `kits.vip` was pushed and is wanted, and the inventory does not have it —
// because Kits is unloaded, so the name is not registered.
const c = reconcile.classify({
present: [],
pushed: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }],
desired: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }],
registered: new Set(['zonemanager.zone']),
})
assert.equal(c.removed.length, 0)
})
test('a pair an event lease holds is the lease’s, not a hand edit', () => {
const { reconcile } = load()
const c = reconcile.classify({
present: [{ kind: 'group-permission', subject: 'default', object: 'kits.vip' }],
pushed: [],
desired: [],
registered: REGISTERED,
leased: [{ kind: 'group-permission', subject: 'default', object: 'kits.vip' }],
})
assert.equal(c.added.length, 0)
})
test('a group removed in the game takes its permissions and members with it', () => {
const { reconcile, model } = load()
const rows = [
{ kind: 'group', subject: 'vip', object: '', value: model.groupValue('VIP', 0, '') },
{ kind: 'group-permission', subject: 'vip', object: 'kits.vip' },
{ kind: 'member', subject: 's1', object: 'vip' },
]
const c = reconcile.classify({ present: [], pushed: rows, desired: rows, registered: REGISTERED })
assert.deepEqual(c.removed.map(model.rowKey), ['group vip '])
})
test('the first inventory imports additions whatever the policy, and pushes removals back (D198)', () => {
const { reconcile } = load()
const classes = {
added: [
{ kind: 'grant', subject: 's1', object: 'kits.vip' },
{ kind: 'group', subject: 'vip', object: '', value: '["VIP",1,""]' },
{ kind: 'member', subject: 's1', object: 'vip' },
],
removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }],
changed: [],
landed: [],
}
for (const policy of ['auto-adopt', 'adopt', 'revoke']) {
const p = reconcile.plan({ classes, policy, importing: true, sources: new Map() })
// Groups first: a membership is written into a group that must exist.
assert.deepEqual(p.ops.map((o) => o.op), ['adoptGroup', 'adoptGrant', 'adoptMember'], policy)
assert.ok(p.ops.every((o) => o.source === undefined || o.source === 'imported'))
assert.equal(p.ops.find((o) => o.op === 'adoptGroup').title, 'VIP')
assert.equal(p.revocations.length, 0)
assert.equal(p.hold.size, 0)
}
})
test('auto-adopt makes an addition the site’s and a removal the site’s withdrawal (D190)', () => {
const { reconcile } = load()
const sources = new Map([['grant s3 kits.gold', [{ type: 'steamGrant', id: 9, scope: 'main' }]]])
const p = reconcile.plan({
classes: {
added: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }],
removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }],
changed: [],
landed: [],
},
policy: 'auto-adopt',
importing: false,
sources,
})
assert.deepEqual(p.ops.map((o) => o.op), ['adoptGrant', 'dropGrant'])
assert.equal(p.ops[0].source, 'adopted')
assert.deepEqual(p.ops[1].sources, [{ type: 'steamGrant', id: 9, scope: 'main' }])
})
test('auto-adopt leaves an event’s grant to the event, pushes it back and tells a person', () => {
const { reconcile } = load()
const p = reconcile.plan({
classes: { added: [], removed: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }], changed: [], landed: [] },
policy: 'auto-adopt',
importing: false,
sources: new Map([['grant s1 kits.vip', [{ type: 'runGrant', runId: '4', stepId: '1' }]]]),
})
assert.equal(p.ops.length, 0)
assert.deepEqual(p.drift, [{ kind: 'grant', subject: 's1', object: 'kits.vip', direction: 'removed', detail: 'event' }])
assert.equal(p.hold.size, 0, 'pushed back, not held')
})
test('adopt asks a person about every change, and holds removals and changed groups until then', () => {
const { reconcile } = load()
const p = reconcile.plan({
classes: {
added: [{ kind: 'group', subject: 'raid', object: '', value: '["Raiders",0,""]' }],
removed: [{ kind: 'member', subject: 's1', object: 'vip' }],
changed: [{ kind: 'group', subject: 'vip', object: '', value: '["V",0,""]' }],
landed: [],
},
policy: 'adopt',
importing: false,
sources: new Map(),
})
assert.equal(p.ops.length, 0)
assert.deepEqual(p.drift.map((d) => d.direction), ['added', 'removed', 'changed'])
assert.equal(p.drift[0].detail, '["Raiders",0,""]', 'an added group keeps its title for adopting later')
assert.deepEqual([...p.hold].sort(), ['group vip ', 'member s1 vip'])
})
test('revoke undoes an addition at this sync, and never a built-in group', () => {
const { reconcile } = load()
const p = reconcile.plan({
classes: {
added: [
{ kind: 'grant', subject: 's1', object: 'kits.vip' },
{ kind: 'group', subject: 'admin', object: '' },
],
removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }],
changed: [],
landed: [],
},
policy: 'revoke',
importing: false,
sources: new Map(),
})
assert.deepEqual(p.revocations, [{ kind: 'grant', subject: 's1', object: 'kits.vip' }])
assert.equal(p.ops.length, 0, 'a removal is pushed back by the desired set, with nothing to do')
})
test('a held row is neither pushed nor recorded, and a held group keeps its place without its title', () => {
const { reconcile, model } = load()
const desired = {
hash: 'h',
rows: [
{ kind: 'group', subject: 'vip', object: '', value: model.groupValue('VIP', 1, '') },
{ kind: 'member', subject: 's1', object: 'vip' },
{ kind: 'grant', subject: 's2', object: 'kits.gold' },
],
payload: {
groups: [{ name: 'vip', title: 'VIP', rank: 1, parent: '', permissions: [], members: ['s1'] }],
grants: [{ steamId: 's2', permissions: ['kits.gold'] }],
credits: [],
},
}
const held = reconcile.withHold(desired, new Set(['group vip ', 'member s1 vip', 'grant s2 kits.gold']))
assert.deepEqual(held.payload.groups, [{ name: 'vip', permissions: [], members: [] }])
assert.deepEqual(held.payload.grants, [])
assert.deepEqual(held.rows, [])
assert.equal(held.hash, 'h', 'the digest is the whole desired set’s')
// …and a held row is not retired either: it is still wanted.
assert.deepEqual(model.retirements(desired.rows, desired.rows.slice(1), new Set(['group vip '])), [])
})
test('a built-in group is never retired, even when the site no longer has it', () => {
const { model } = load()
const pushed = [
{ kind: 'group', subject: 'default', object: '' },
{ kind: 'group', subject: 'raid', object: '' },
]
assert.deepEqual(model.retirements(pushed, []).map((r) => r.subject), ['raid'])
})
test('a group shared with other servers is marked so in the sources; a server-only one is not (D190)', () => {
const { model } = load()
const desired = model.buildDesired('main', {
groups: [
{ id: 1, name: 'vip', title: 'VIP', rank: 0, parent: '', allServers: true },
{ id: 2, name: 'raid', title: 'Raid', rank: 0, parent: '', allServers: false },
],
groupServers: [{ groupId: 2, serverId: 'main', included: true }],
groupPermissions: [{ groupId: 1, permission: 'kits.vip' }, { groupId: 2, permission: 'kits.gold' }],
members: [],
steamMembers: [{ groupId: 2, steamId: 's1' }],
grants: [],
steamGrants: [],
exceptions: [],
runGrants: [],
groupChat: [],
steamIdsByUser: new Map(),
})
assert.equal(desired.sources.get('group-permission vip kits.vip')[0].shared, true)
assert.equal(desired.sources.get('group-permission raid kits.gold')[0].shared, false)
assert.equal(desired.sources.get('member s1 raid')[0].type, 'steamMember')
})
test('an exception keeps a fleet grant off one server and on every other (D190)', () => {
const { model } = load()
const set = {
groups: [],
groupServers: [],
groupPermissions: [],
members: [],
steamMembers: [],
grants: [],
steamGrants: [{ id: 5, steamId: 's1', permission: 'kits.vip', scope: '*' }],
exceptions: [{ id: 1, holder: 'steam', grantId: 5, serverId: 'main' }],
runGrants: [],
groupChat: [],
steamIdsByUser: new Map(),
}
assert.ok(!model.buildDesired('main', set).rows.some((r) => r.kind === 'grant'))
assert.ok(model.buildDesired('creative', set).rows.some((r) => r.kind === 'grant' && r.subject === 's1'))
})
test('a group excluded from one server by a split is on every other server', () => {
const { model } = load()
const group = { id: 1, name: 'vip', allServers: true }
const rows = model.serversByGroup([{ groupId: 1, serverId: 'main', included: false }])
assert.equal(model.groupCovers(group, rows, 'main'), false)
assert.equal(model.groupCovers(group, rows, 'creative'), true)
assert.equal(model.isShared(group, rows), true)
})

File diff suppressed because it is too large Load Diff