// ── The authored set, and what it means for one server ──────────────────── // // 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 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. // // `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. */ const FLEET = '*' /** * 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() } /** Whether a scope reaches a server. */ function inScope(scope, serverId) { return scope === FLEET || scope === serverId } /** * 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. */ function groupValue(title, rank, parent) { return JSON.stringify([String(title == null ? '' : title), Number(rank) || 0, normaliseName(parent)]) } /** `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.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. */ 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. */ 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, importedAt: row.importedAt || null, error: row.error || null, report, } } /** * The whole authored set, read once, in the shape the per-server build wants. */ async function readAuthored() { 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(), ]) 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, 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. * * `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` * `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. */ function buildDesired(serverId, 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 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) } 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 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) }) } 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) const key = `${group.id}:${steamId}` if (seenMember.has(key)) return seenMember.add(key) membersByGroup.get(group.id).push(steamId) rows.push(row) } 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 for (const steamId of steamIdsByUser.get(row.userId) || []) { 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 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) { if (row.serverId !== serverId) continue const linked = steamIdsByUser.get(row.userId) || [] const permission = normaliseName(row.permission) if (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)) { const key = `${row.steamId}|${row.kit}` credits.set(key, (credits.get(key) || 0) + 1) } } // ── A group's BetterChat style (phase 17, D138) ─────────────────────── const chat = chatByGroup(authored.groupChat) for (const group of onServer) { const fields = chat.get(group.id) if (!fields) continue for (const field of Object.keys(fields).sort()) { rows.push({ kind: 'chat-field', subject: group.name, object: field, value: fields[field] }) } } const creditRows = [...credits.entries()] .map(([key, count]) => { const bar = key.indexOf('|') return { steamId: key.slice(0, bar), kit: key.slice(bar + 1), count } }) .sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit)) const payload = { groups: onServer.map((group) => ({ name: 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 })), // Always sent, even empty (D103). credits: creditRows, } const hashed = [ ...rows, ...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })), ] 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, 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}${row.value === undefined || row.value === null ? '' : `=${row.value}`}`) .sort() .join('\n') return crypto.createHash('sha256').update(canonical).digest('hex') } /** * What this site put in a server and has since withdrawn: `pushed − desired`. * * 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, hold = new Set()) { const desired = new Set(desiredRows.map(rowKey)) 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, groupValue, serversByGroup, groupCovers, groupReach, groupsOn, isShared, forPlayer, readAuthored, buildDesired, retirements, hashRows, rowKey, collapseGrants, shapeSync, chatByGroup, }