// ── The authored set, and what it means for one server ──────────────────── // // This file turns "what an operator wrote on the website" into "what one game // server's store should contain", which is where four of phase 7's decisions // actually live: // // D28 a grant is authored against a WEBSITE USER and resolved to every Steam // id they have linked, here, at the moment of the push. // D29 every authored row carries a scope — one server, or `*` for the fleet — // and a server sees only what names it. // D30 groups travel as groups. Membership is a separate wire fact from the // permissions the group carries, because the game stores them separately // and one of the two can fail on its own (§12.2 rule 4). // D31 the difference between the desired set and what this site has already // pushed is what gets retired. Anything else in the store is drift, and // drift is reported rather than undone. // // Nothing here talks to a sidecar — `permSync.js` does that. The split is the // usual one and earns its keep twice over here: the whole of the interesting // logic is a pure function of four tables, so it is tested without a game, a // sidecar, or a database. const crypto = require('node:crypto') const db = require('./permissions.db') /** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */ const FLEET = '*' /** * Permission and group names, as both frameworks store them. * * Lowercased on the way in, because the store lowers them and a site that did * not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own * grant as drift for ever. */ function normaliseName(value) { return String(value || '').trim().toLowerCase() } /** Whether a scope reaches a server. */ function inScope(scope, serverId) { return scope === FLEET || scope === serverId } /** * Everything the authoring screen renders, in one read. * * Assembled here rather than in SQL because the shape is a tree — a group with * its permissions and its members — and the alternative is either four round * trips per group or one join that repeats every group row once per member. */ async function overview() { const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([ db.listGroups(), db.listGroupPermissions(), db.listGroupMembers(), db.listGrants(), db.listSync(), db.listDrift(), db.listCatalogue(), ]) const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }])) for (const row of groupPermissions) { const group = byGroup.get(row.groupName) if (group) group.permissions.push(row.permission) } // A member with two linked Steam accounts arrives as two rows from the join, // and is one person on the screen — holding BOTH accounts, not the first one // the join happened to return. The screen needs all of them: a membership is // pushed per account, and it can be waiting on one while it landed on another. const memberByKey = new Map() for (const row of members) { const group = byGroup.get(row.groupName) if (!group) continue const key = `${row.groupName}:${row.userId}` let member = memberByKey.get(key) if (!member) { member = { userId: row.userId, username: row.username, accounts: [], addedAt: row.addedAt, } memberByKey.set(key, member) group.members.push(member) } if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null }) } return { groups: [...byGroup.values()], grants: collapseGrants(grants), servers: sync.map(shapeSync), drift, catalogue: catalogueByPermission(catalogue), } } /** * One row per grant, not one per linked account. * * The join in `listGrants` multiplies a grant by the holder's accounts, which is * what the push wants and the opposite of what a screen wants. */ function collapseGrants(rows) { const byId = new Map() for (const row of rows) { const existing = byId.get(row.id) if (!existing) { byId.set(row.id, { id: row.id, userId: row.userId, username: row.username, permission: row.permission, scope: row.scope, source: row.source, note: row.note, grantedAt: row.grantedAt, accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [], }) continue } if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null }) } return [...byId.values()] } /** * The sync row as a client reads it. * * `report` is stored as the JSON the game sent and parsed here rather than on the * way in, so a report this build cannot read is a rendering problem on one * screen instead of a write that failed. */ function shapeSync(row) { let report = null if (row.report) { try { report = JSON.parse(row.report) } catch { report = null } } return { serverId: row.serverId, state: row.state, dirty: Boolean(row.dirty), inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok', lastAttemptAt: row.lastAttemptAt, lastOkAt: row.lastOkAt, error: row.error || null, report, } } /** Which servers know each permission name — the form's option source, and its warning label. */ function catalogueByPermission(rows) { const byPermission = new Map() for (const row of rows) { if (!byPermission.has(row.permission)) byPermission.set(row.permission, []) byPermission.get(row.permission).push(row.serverId) } return [...byPermission.entries()] .map(([permission, servers]) => ({ permission, servers })) .sort((a, b) => a.permission.localeCompare(b.permission)) } /** * The whole authored set, read once, in the shape the per-server build wants. * * Read once per sync tick rather than once per server: six servers is six * different answers derived from one set of tables, and re-reading them per * server is six times the queries for the same rows. */ async function readAuthored() { const [groups, groupPermissions, members, grants, links] = await Promise.all([ db.listGroups(), db.listGroupPermissions(), db.listGroupMembers(), db.listGrants(), db.listLinks(), ]) const steamIdsByUser = new Map() for (const link of links) { if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, []) steamIdsByUser.get(link.userId).push(link.steamId) } return { groups, groupPermissions, members, grants, steamIdsByUser } } /** * What one server's store should contain, and the rows that say so. * * Returns three things the caller needs together and must not compute twice: * * `payload` what goes on the wire * `rows` the same set in `rust_perm_pushed`'s shape, for the diff * `hash` a stable digest of `rows`, which is how the loop knows nothing * has changed without asking a game server * * **A user with no linked Steam account contributes nothing and is not an * error.** They are authored against perfectly well and reach nobody until they * link — which the admin screen says out loud, because a grant that reaches * nothing looks exactly like one that worked. */ function buildDesired(serverId, authored) { const { groups, groupPermissions, members, grants, steamIdsByUser } = authored const scopedGroups = groups.filter((group) => inScope(group.scope, serverId)) const groupNames = new Set(scopedGroups.map((group) => group.name)) const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []])) const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []])) const managed = new Set() const rows = [] for (const group of scopedGroups) rows.push({ kind: 'group', subject: group.name, object: '' }) for (const row of groupPermissions) { if (!groupNames.has(row.groupName)) continue const permission = normaliseName(row.permission) permissionsByGroup.get(row.groupName).push(permission) managed.add(permission) rows.push({ kind: 'group-permission', subject: row.groupName, object: permission }) } const seenMember = new Set() for (const row of members) { if (!groupNames.has(row.groupName)) continue for (const steamId of steamIdsByUser.get(row.userId) || []) { const key = `${row.groupName}:${steamId}` if (seenMember.has(key)) continue seenMember.add(key) membersByGroup.get(row.groupName).push(steamId) rows.push({ kind: 'member', subject: steamId, object: row.groupName }) } } const permissionsBySteamId = new Map() const seenGrant = new Set() for (const row of grants) { if (!inScope(row.scope, serverId)) continue const permission = normaliseName(row.permission) // Managed whether or not it reaches anybody: the namespace is what makes a // hand grant of this permission to somebody else show up as drift, and a // grant whose holder has linked nothing would otherwise silently narrow it. managed.add(permission) // **Resolved from the link map, not from the row.** `listGrants` joins the // links and therefore repeats a grant once per linked account, which would // give the right answer here by accident — until somebody changes that query // and one of a person's two accounts quietly stops being granted. The map is // the same source the members above use, and it says what it means. for (const steamId of steamIdsByUser.get(row.userId) || []) { const key = `${steamId}:${permission}` if (seenGrant.has(key)) continue seenGrant.add(key) if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, []) permissionsBySteamId.get(steamId).push(permission) rows.push({ kind: 'grant', subject: steamId, object: permission }) } } const payload = { groups: scopedGroups.map((group) => ({ name: group.name, title: group.title || group.name, rank: group.rank, permissions: permissionsByGroup.get(group.name), members: membersByGroup.get(group.name), })), grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ steamId, permissions, })), managed: [...managed].sort(), } return { payload, rows, hash: hashRows(rows) } } /** * A digest of the desired set. * * Sorted before hashing, because the rows come out of several queries in an * order nothing guarantees — an unsorted digest would differ between two reads * of an unchanged set and push to every game server on every tick. */ function hashRows(rows) { const canonical = rows .map((row) => `${row.kind}${row.subject}${row.object}`) .sort() .join('\n') return crypto.createHash('sha256').update(canonical).digest('hex') } /** A row's identity, for set arithmetic against what was pushed. */ const rowKey = (row) => `${row.kind}${row.subject}${row.object}` /** * What this site put in a server and has since withdrawn. * * `pushed − desired`, and it is the one calculation that cannot be replaced by * asking the game: a name in the store that is not in the desired set is either * something the site retired or something a human granted, and those have * opposite correct answers (D31). Only the pushed ledger tells them apart. */ function retirements(pushed, desiredRows) { const desired = new Set(desiredRows.map(rowKey)) return pushed.filter((row) => !desired.has(rowKey(row))) } module.exports = { FLEET, normaliseName, inScope, overview, readAuthored, buildDesired, retirements, hashRows, rowKey, collapseGrants, shapeSync, }