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
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
// ── Three sets, and what a change made in the game becomes ────────────────
|
||
//
|
||
// `rust_perm_pushed`'s own comment has always named three sets — what is in the
|
||
// game, what this site put there, and what the site wants there. Until protocol
|
||
// 13 the plugin could only compute the first for the names the site claimed.
|
||
// The inventory (PLAN_REDESIGNS §1.2) gives the site all three, so the whole of
|
||
// "what happened, and what do we do about it" is decided here:
|
||
//
|
||
// in the game pushed desired means
|
||
// yes no no ADDED in the game
|
||
// no yes yes REMOVED in the game
|
||
// yes yes yes a group whose title, rank or parent the
|
||
// game holds differently from what was
|
||
// pushed: CHANGED in the game
|
||
// yes no yes landed by some other hand: recorded
|
||
//
|
||
// (desired − pushed is the ordinary push, and pushed − desired the ordinary
|
||
// retirement; neither is this file's business.)
|
||
//
|
||
// Then the server's policy (D161) says what each change becomes: the site's own
|
||
// (`auto-adopt`, the default), a question for a person (`adopt`), or undone
|
||
// (`revoke`). The first inventory of a server imports what it finds whatever the
|
||
// policy (D198).
|
||
//
|
||
// **Two things are never judged, and both are how a site would otherwise throw
|
||
// away its own grants.** A permission the server has not REGISTERED right now —
|
||
// a plugin unloaded for a minute — is missing from the inventory because the
|
||
// plugin that owns it is, not because anybody revoked it. And a group permission
|
||
// an event lease holds is the lease's until it ends.
|
||
//
|
||
// Pure: rows in, a plan out. `permissions.apply.js` carries the plan out.
|
||
|
||
const { rowKey, groupValue, normaliseName, BUILTIN_GROUPS } = require('./permissions.model')
|
||
|
||
const JUDGED = new Set(['group', 'group-permission', 'member', 'grant'])
|
||
|
||
/** The inventory in the pushed ledger's shape. */
|
||
function presentRows(inventory) {
|
||
const rows = []
|
||
|
||
for (const group of (inventory && inventory.groups) || []) {
|
||
const name = normaliseName(group.name)
|
||
if (!name) continue
|
||
|
||
rows.push({ kind: 'group', subject: name, object: '', value: groupValue(group.title, group.rank, group.parent) })
|
||
|
||
for (const permission of group.permissions || []) {
|
||
rows.push({ kind: 'group-permission', subject: name, object: normaliseName(permission) })
|
||
}
|
||
}
|
||
|
||
for (const user of (inventory && inventory.users) || []) {
|
||
for (const permission of user.permissions || []) {
|
||
rows.push({ kind: 'grant', subject: String(user.steamId), object: normaliseName(permission) })
|
||
}
|
||
|
||
for (const group of user.groups || []) {
|
||
rows.push({ kind: 'member', subject: String(user.steamId), object: normaliseName(group) })
|
||
}
|
||
}
|
||
|
||
return rows
|
||
}
|
||
|
||
/** The group attributes a `group` row's value carries. */
|
||
function parseGroupValue(value) {
|
||
try {
|
||
const [title, rank, parent] = JSON.parse(value)
|
||
return { title: String(title == null ? '' : title), rank: Number(rank) || 0, parent: normaliseName(parent) }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sort every row into added, removed, changed or landed.
|
||
*
|
||
* `registered` is the set of names the server registers right now; `leased` the
|
||
* lease-held pairs, as `group-permission` rows.
|
||
*/
|
||
function classify({ present, pushed, desired, registered, leased = [] }) {
|
||
const leasedKeys = new Set(leased.map((row) => rowKey({ kind: 'group-permission', subject: normaliseName(row.subject), object: normaliseName(row.object) })))
|
||
|
||
const judged = (row) => {
|
||
if (!JUDGED.has(row.kind)) return false
|
||
if ((row.kind === 'grant' || row.kind === 'group-permission') && !registered.has(row.object)) return false
|
||
// `default` holds every connected player by the framework's rule (§1.2).
|
||
if (row.kind === 'member' && row.object === 'default') return false
|
||
if (leasedKeys.has(rowKey(row))) return false
|
||
return true
|
||
}
|
||
|
||
const index = (rows) => new Map(rows.filter(judged).map((row) => [rowKey(row), row]))
|
||
const P = index(present)
|
||
const U = index(pushed)
|
||
const D = index(desired)
|
||
|
||
const added = []
|
||
const removed = []
|
||
const changed = []
|
||
const landed = []
|
||
|
||
for (const [key, row] of P) {
|
||
if (!U.has(key) && !D.has(key)) added.push(row)
|
||
else if (!U.has(key) && D.has(key)) landed.push({ ...D.get(key) })
|
||
else if (row.kind === 'group' && U.has(key) && D.has(key)) {
|
||
const pushedValue = U.get(key).value
|
||
// A value the site pushed, that the site still wants, and that the game no
|
||
// longer holds: somebody changed the group in the game. A ledger row with
|
||
// no value (before protocol 13) cannot say, and the site's value is pushed.
|
||
if (pushedValue && row.value !== pushedValue && D.get(key).value === pushedValue) changed.push(row)
|
||
}
|
||
}
|
||
|
||
for (const [key, row] of U) {
|
||
if (!P.has(key) && D.has(key)) removed.push(row)
|
||
}
|
||
|
||
// A group removed in the game takes its permissions and members with it; they
|
||
// are the group's removal, not changes of their own.
|
||
const goneGroups = new Set(removed.filter((row) => row.kind === 'group').map((row) => row.subject))
|
||
const keep = (row) =>
|
||
row.kind === 'group' || !goneGroups.has(row.kind === 'member' ? row.object : row.subject)
|
||
|
||
return { added, removed: removed.filter(keep), changed, landed }
|
||
}
|
||
|
||
/**
|
||
* What the changes become under one server's policy.
|
||
*
|
||
* Returns:
|
||
* `ops` for `permissions.apply.js`, in the order they must run —
|
||
* groups before what goes in them
|
||
* `drift` "needs a person" rows (D161's `adopt`, and what no policy can
|
||
* settle alone)
|
||
* `revocations` what the `revoke` policy undoes at this sync
|
||
* `hold` row keys this sync must neither push back nor retire nor
|
||
* record, because a person has not answered yet
|
||
*/
|
||
function plan({ classes, policy, importing, sources }) {
|
||
const ops = []
|
||
const drift = []
|
||
const revocations = []
|
||
const hold = new Set()
|
||
const source = importing ? 'imported' : 'adopted'
|
||
|
||
const adoptOp = (row) => {
|
||
if (row.kind === 'group') {
|
||
const attrs = parseGroupValue(row.value) || { title: '', rank: 0, parent: '' }
|
||
return { op: 'adoptGroup', name: row.subject, ...attrs, source }
|
||
}
|
||
if (row.kind === 'group-permission') return { op: 'adoptGroupPermission', group: row.subject, permission: row.object, source }
|
||
if (row.kind === 'member') return { op: 'adoptMember', group: row.object, steamId: row.subject, source }
|
||
return { op: 'adoptGrant', steamId: row.subject, permission: row.object, source }
|
||
}
|
||
|
||
const dropOp = (row) => {
|
||
if (row.kind === 'group') return { op: 'dropGroup', group: row.subject }
|
||
if (row.kind === 'group-permission') return { op: 'dropGroupPermission', group: row.subject, permission: row.object }
|
||
if (row.kind === 'member') {
|
||
return { op: 'dropMember', group: row.object, steamId: row.subject, sources: sources.get(rowKey(row)) || [] }
|
||
}
|
||
return { op: 'dropGrant', steamId: row.subject, permission: row.object, sources: sources.get(rowKey(row)) || [] }
|
||
}
|
||
|
||
const order = { adoptGroup: 0, setGroupAttrs: 1, adoptGroupPermission: 2, adoptMember: 2, adoptGrant: 2, dropGroupPermission: 3, dropMember: 3, dropGrant: 3, dropGroup: 4 }
|
||
|
||
// ── The first inventory: everything present becomes the site's (D160, D198) ──
|
||
//
|
||
// Additions and changed attributes are imported whatever the policy. A removal
|
||
// at import is something this site pushed that the game has since lost — it is
|
||
// pushed back, as it always was, rather than deleted on the strength of a
|
||
// snapshot taken the moment the site first looked.
|
||
if (importing) {
|
||
for (const row of classes.added) ops.push(adoptOp(row))
|
||
for (const row of classes.changed) ops.push({ op: 'setGroupAttrs', group: row.subject, ...parseGroupValue(row.value) })
|
||
return { ops: ops.sort((a, b) => order[a.op] - order[b.op]), drift, revocations, hold }
|
||
}
|
||
|
||
if (policy === 'revoke') {
|
||
// The site's set wins. An addition is removed at this sync; a removal or a
|
||
// changed group is simply pushed back by the desired set.
|
||
for (const row of classes.added) {
|
||
// A built-in group cannot be removed; one made in the game under `revoke`
|
||
// is — but `default` and `admin` are never "added", the import took them.
|
||
if (row.kind === 'group' && BUILTIN_GROUPS.has(row.subject)) continue
|
||
revocations.push({ kind: row.kind, subject: row.subject, object: row.object })
|
||
}
|
||
return { ops, drift, revocations, hold }
|
||
}
|
||
|
||
if (policy === 'adopt') {
|
||
// Every change waits for a person, and until then the game is left as it is.
|
||
// A group made in the game carries its title, rank and parent, so adopting it
|
||
// later keeps them.
|
||
for (const row of classes.added) {
|
||
drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'added', ...(row.kind === 'group' ? { detail: row.value } : {}) })
|
||
}
|
||
|
||
for (const row of classes.removed) {
|
||
drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'removed' })
|
||
hold.add(rowKey(row))
|
||
}
|
||
|
||
for (const row of classes.changed) {
|
||
drift.push({ kind: 'group', subject: row.subject, object: '', direction: 'changed', detail: row.value })
|
||
hold.add(rowKey(row))
|
||
}
|
||
|
||
return { ops, drift, revocations, hold }
|
||
}
|
||
|
||
// ── auto-adopt, the default (D161, D190) ──
|
||
for (const row of classes.added) ops.push(adoptOp(row))
|
||
|
||
for (const row of classes.removed) {
|
||
const op = dropOp(row)
|
||
|
||
// A grant only an event gave cannot be adopted away: the event owns it, and
|
||
// its revert will withdraw it. It is pushed back, and a person is told.
|
||
if (op.op === 'dropGrant' && op.sources.length && op.sources.every((s) => s.type === 'runGrant')) {
|
||
drift.push({ kind: row.kind, subject: row.subject, object: row.object, direction: 'removed', detail: 'event' })
|
||
continue
|
||
}
|
||
|
||
ops.push(op)
|
||
}
|
||
|
||
for (const row of classes.changed) ops.push({ op: 'setGroupAttrs', group: row.subject, ...parseGroupValue(row.value) })
|
||
|
||
return { ops: ops.sort((a, b) => order[a.op] - order[b.op]), drift, revocations, hold }
|
||
}
|
||
|
||
/**
|
||
* The desired set with the held rows taken out: not in the payload, so the
|
||
* plugin does not put them back, and not in `rows`, so the report does not
|
||
* record them. A held group keeps its place — only its title, rank and parent
|
||
* are left off, which the plugin reads as "leave them".
|
||
*/
|
||
function withHold(desired, hold) {
|
||
if (!hold || !hold.size) return desired
|
||
|
||
const heldGroups = new Set()
|
||
const payload = { ...desired.payload }
|
||
|
||
payload.groups = desired.payload.groups.map((group) => {
|
||
const key = rowKey({ kind: 'group', subject: group.name, object: '' })
|
||
const out = { ...group }
|
||
|
||
if (hold.has(key)) {
|
||
heldGroups.add(group.name)
|
||
delete out.title
|
||
delete out.rank
|
||
delete out.parent
|
||
}
|
||
|
||
out.permissions = (group.permissions || []).filter((p) => !hold.has(rowKey({ kind: 'group-permission', subject: group.name, object: p })))
|
||
out.members = (group.members || []).filter((s) => !hold.has(rowKey({ kind: 'member', subject: s, object: group.name })))
|
||
return out
|
||
})
|
||
|
||
payload.grants = desired.payload.grants
|
||
.map((grant) => ({
|
||
...grant,
|
||
permissions: grant.permissions.filter((p) => !hold.has(rowKey({ kind: 'grant', subject: grant.steamId, object: p }))),
|
||
}))
|
||
.filter((grant) => grant.permissions.length)
|
||
|
||
return {
|
||
...desired,
|
||
payload,
|
||
rows: desired.rows.filter((row) => !hold.has(rowKey(row))),
|
||
heldGroups,
|
||
}
|
||
}
|
||
|
||
module.exports = { presentRows, parseGroupValue, classify, plan, withHold }
|