feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 7m58s

PLAN_REDESIGNS section 1.

- Every sync reads the store (perm.inventory), reconciles it against the
  site's record and its ledger, and pushes. A change made in the game is
  settled by the server's policy (D161): auto-adopt (default), adopt, or
  revoke. The first read of a server imports everything (D198).
- Groups belong to one server unless an admin shares them (D189), in new
  id-keyed tables; the old ones are copied once at boot and left unread.
  Holders may be a Steam account nobody linked (D188).
- An in-game change affects that server only (D190): a grant that reaches
  further gains an exception, a shared group is split.
- Never judged: a permission the server does not register right now (an
  unloaded plugin is not a revocation), and a pair an event lease holds.
- A new admin API (server view, grant/revoke with everywhere-or-here,
  groups by id, share/split, members, drift answers) and a screen on
  PermissionsManager's flow with a state on every toggle (D162, D163, U-1).
- The announcement voice names a group by id; old name settings still read.

Walked on both rigs against the walk core: import on an existing install,
auto-adopt of a grant and a revoke, a fleet grant's exception, Kits
unloaded without loss, a shared group split, adopt and revoke policies.
Server 420/420, client 58/58, swagger, imports and route manifest current.

Refs #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-28 06:58:59 -05:00
parent 77c90db338
commit e0d13e73db
24 changed files with 5873 additions and 2589 deletions

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 }