// ── 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)) } /** * ── 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] = 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, forPlayer, readAuthored, buildDesired, retirements, hashRows, rowKey, collapseGrants, shapeSync, }