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

@@ -254,6 +254,70 @@ const permCatalogue = (server) => request(server, '/permissions/catalogue')
*/
const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set })
/**
* One page of the game's whole permission store (protocol 13, PLAN_REDESIGNS
* §1.2): every permission with the plugin that registered it, every group, every
* holder, and what event leases hold. `{ page: 0 }` starts a fresh read; later
* pages name the `snapshotId` page 0 answered with.
*
* Like the sync, a refusal (`perm.error`: `busy`, `stale`, `too-large`) comes
* back `{ ok: true }` and is told apart by `data.kind`.
*/
const permInventory = (server, { snapshotId = null, page = 0 } = {}) =>
request(server, '/permissions/inventory', {
method: 'POST',
body: snapshotId ? { snapshotId, page } : { page },
})
/**
* The whole inventory, every page, or `{ ok: false, error }`. A snapshot that
* goes stale mid-read is started again once from page 0: a page from a different
* snapshot would tear the answer.
*/
async function readInventory(server) {
for (let attempt = 0; attempt < 2; attempt++) {
// eslint-disable-next-line no-await-in-loop
const first = await permInventory(server, { page: 0 })
if (!first.ok) return { ok: false, error: first.status || 'unreachable' }
const head = first.data || {}
if (head.kind === 'perm.error') return { ok: false, error: `the game refused the read: ${head.reason || 'unknown'}` }
if (head.kind !== 'perm.inventory') return { ok: false, error: 'the game does not know perm.inventory (protocol 13)' }
let users = Array.isArray(head.users) ? head.users : []
let stale = false
for (let page = 1; page < Number(head.pages || 1); page++) {
// eslint-disable-next-line no-await-in-loop
const next = await permInventory(server, { snapshotId: head.snapshotId, page })
const data = (next.ok && next.data) || {}
if (data.kind !== 'perm.inventory') {
stale = true
break
}
users = users.concat(Array.isArray(data.users) ? data.users : [])
}
if (stale) continue
return {
ok: true,
inventory: {
snapshotId: head.snapshotId,
permissions: Array.isArray(head.permissions) ? head.permissions : [],
groups: Array.isArray(head.groups) ? head.groups : [],
leased: Array.isArray(head.leased) ? head.leased : [],
users,
stats: head.stats || null,
},
}
}
return { ok: false, error: 'the inventory changed while it was being read, twice' }
}
/**
* Every settings file on one game host, and every plugin loaded to reload one
* (protocol 5, R18).
@@ -441,6 +505,8 @@ module.exports = {
confirmLink,
permCatalogue,
permSync,
permInventory,
readInventory,
configFiles,
configFile,
configWrite,