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
569 lines
18 KiB
JavaScript
569 lines
18 KiB
JavaScript
// ── 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,
|
||
}
|