feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
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

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
This commit is contained in:
2026-09-28 06:58:59 -05:00
parent 77c90db338
commit e0d13e73db
24 changed files with 5873 additions and 2589 deletions

View File

@@ -0,0 +1,210 @@
// ── Carrying out what the reconciler decided ──────────────────────────────
//
// `reconcile.plan` says WHAT a change made in the game becomes; this file writes
// it into the site's own record. Every write here is about ONE server (D190): a
// change in one game affects that server and nothing else, even when the site's
// row reaches further.
//
// • A grant that reaches only this server is deleted or written outright.
// • A grant that reaches more (a fleet grant, or a user's grant scoped `*`)
// gains an EXCEPTION for this server, and keeps reaching every other one.
// • A group shared with other servers is SPLIT: this server gets its own copy,
// the change is made to the copy, and the shared group stops covering it. A
// notice says so, in case the change was meant for every server.
//
// Ops are applied one at a time and each re-reads what it needs, because an
// earlier op in the same plan may have split the group a later one writes to.
// `permSync` runs a plan under one lock for the whole fleet, so two servers'
// plans never split the same shared group at once.
const db = require('./permissions.db')
const model = require('./permissions.model')
/** The site's group of this name on this server, or null (D189). */
async function groupOn(name, serverId) {
const [groups, groupServers] = await Promise.all([db.listGroups(), db.listGroupServers()])
return model.groupsOn(serverId, { groups, groupServers }).find((group) => group.name === name) || null
}
/**
* The group of this name that belongs to THIS server alone, splitting a shared
* one if that is what covers it (D190). Null when the site has no such group.
*/
async function ownGroup(name, serverId) {
const group = await groupOn(name, serverId)
if (!group) return null
const groupServers = await db.listGroupServers()
if (!model.isShared(group, model.serversByGroup(groupServers))) return group
const copy = await db.copyGroup(group.id, 'split')
await db.setGroupServers(copy, { allServers: false, servers: [serverId] })
await db.removeGroupFromServer(group.id, serverId)
await db.noteSplit(serverId, {
group: name,
detail: `changed in the game on ${serverId}; that server now has its own copy of "${name}"`,
})
return db.getGroup(copy)
}
/** The Steam ids and user linked to one Steam id, for finding a user's grant. */
async function userOf(steamId) {
const links = await db.listLinks()
const link = links.find((row) => row.steamId === steamId)
return link ? link.userId : null
}
/**
* A grant the game holds and the site does not. If a grant that reaches this
* server was only kept off it by an EXCEPTION, the exception is what the game
* just undid, so the exception goes. Otherwise a Steam-account grant for this
* server alone is written (D188, D190).
*/
async function adoptGrant(serverId, { steamId, permission, source }) {
const exceptions = (await db.listExceptions()).filter((e) => e.serverId === serverId)
if (exceptions.length) {
const userId = await userOf(steamId)
const [userGrants, steamGrants] = await Promise.all([
userId === null ? [] : db.listGrants({ userId }),
db.listSteamGrants({ steamId }),
])
const candidates = [
...userGrants.map((g) => ({ holder: 'user', id: g.id, permission: g.permission, scope: g.scope })),
...steamGrants.map((g) => ({ holder: 'steam', id: g.id, permission: g.permission, scope: g.scope })),
].filter((g) => model.normaliseName(g.permission) === permission && model.inScope(g.scope, serverId))
for (const grant of candidates) {
const exception = exceptions.find((e) => e.holder === grant.holder && Number(e.grantId) === Number(grant.id))
if (exception) {
await db.deleteException(exception.id)
return
}
}
}
await db.insertSteamGrant({ steamId, permission, scope: serverId, source })
}
/**
* A grant the site holds and the game no longer does. Each source that put it
* on this server stops doing so: one scoped to this server alone is deleted; one
* that reaches further gains an exception here. An event's grant is left to the
* event (the reconciler never sends one here).
*/
async function dropGrant(serverId, { sources = [] }) {
for (const source of sources) {
if (source.type !== 'userGrant' && source.type !== 'steamGrant') continue
const holder = source.type === 'userGrant' ? 'user' : 'steam'
if (source.scope === serverId) {
if (holder === 'user') await db.deleteGrant(source.id)
else await db.deleteSteamGrant(source.id)
} else {
await db.addException({ holder, grantId: source.id, serverId })
}
}
}
/** Run one op. Returns a short line for the log. */
async function applyOp(serverId, op) {
switch (op.op) {
case 'adoptGroup': {
// A group of this name may already exist on another server, or be shared
// with every server but this one: either way this server gets its own.
const existing = await groupOn(op.name, serverId)
if (existing) return `group ${op.name}: already the site's`
const id = await db.insertGroup({ name: op.name, title: op.title, rank: op.rank, parent: op.parent, source: op.source })
await db.setGroupServers(id, { allServers: false, servers: [serverId] })
return `group ${op.name}: adopted`
}
case 'setGroupAttrs': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.updateGroup(group.id, { title: op.title, rank: op.rank, parent: op.parent })
return `group ${op.group}: title, rank and parent from the game`
}
case 'adoptGroupPermission': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.addGroupPermission(group.id, op.permission)
return `group ${op.group} + ${op.permission}`
}
case 'dropGroupPermission': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.removeGroupPermission(group.id, op.permission)
return `group ${op.group} − ${op.permission}`
}
case 'adoptMember': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
await db.addGroupSteamMember(group.id, op.steamId, { source: op.source })
return `${op.steamId} in ${op.group}`
}
case 'dropMember': {
const group = await ownGroup(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
// Whichever way the site had them in it: as a Steam account, and as the
// website account that account is linked to.
await db.removeGroupSteamMember(group.id, op.steamId)
const userId = await userOf(op.steamId)
if (userId !== null) await db.removeGroupMember(group.id, userId)
return `${op.steamId} out of ${op.group}`
}
case 'adoptGrant':
await adoptGrant(serverId, op)
return `${op.steamId} + ${op.permission}`
case 'dropGrant':
await dropGrant(serverId, op)
return `${op.steamId} − ${op.permission}`
case 'dropGroup': {
const group = await groupOn(op.group, serverId)
if (!group) return `group ${op.group}: not the site's`
const groupServers = await db.listGroupServers()
if (model.isShared(group, model.serversByGroup(groupServers))) {
await db.removeGroupFromServer(group.id, serverId)
return `group ${op.group}: no longer on ${serverId}`
}
await db.deleteGroup(group.id)
return `group ${op.group}: deleted`
}
default:
return `unknown op ${op.op}`
}
}
/** Run a plan's ops in order. One op failing does not stop the rest. */
async function applyOps(serverId, ops, log = null) {
const done = []
for (const op of ops) {
try {
// eslint-disable-next-line no-await-in-loop
done.push(await applyOp(serverId, op))
} catch (err) {
done.push(`${op.op} failed: ${err.message}`)
if (log) log.warn('permission op failed', { server: serverId, op: op.op, error: err.message })
}
}
return done
}
module.exports = { groupOn, ownGroup, applyOp, applyOps }