feat(rust): site-owned permissions — the site is the author, the game is the cache

R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: 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 two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-21 18:28:32 -05:00
parent a1b6d155a1
commit 43147b796a
27 changed files with 5515 additions and 21 deletions

View File

@@ -0,0 +1,356 @@
// ── 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,
}