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

@@ -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,