Files
Module-Rust/server/model/permissions/permissions.model.js
wtclaude e0d13e73db
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
feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
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
2026-09-28 06:58:59 -05:00

569 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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,
}