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,310 @@
// ── Three sets, and what a change made in the game becomes (PLAN_REDESIGNS §1) ──
//
// The reconciler decides what an in-game change becomes: the site's own
// (auto-adopt), a question for a person (adopt), or undone (revoke) — and the
// first read of a server imports everything (D198). Two rules here protect the
// site's own grants, and each has a test: a permission the server has not
// REGISTERED right now is never judged (a plugin unloaded for a minute is not a
// revocation), and a pair an event lease holds is the lease's.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function load() {
require('../core')._reset()
require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
return {
reconcile: require('../model/permissions/reconcile'),
model: require('../model/permissions/permissions.model'),
}
}
const REGISTERED = new Set(['kits.vip', 'kits.gold', 'zonemanager.zone'])
test('the inventory becomes ledger-shaped rows, and `default` membership is not judged', () => {
const { reconcile, model } = load()
const present = reconcile.presentRows({
groups: [{ name: 'VIP', title: 'VIP ', rank: 2, parent: 'default', permissions: ['Kits.VIP'] }],
users: [{ steamId: '7656001', permissions: ['kits.gold'], groups: ['vip', 'default'] }],
})
assert.deepEqual(present.map(model.rowKey).sort(), [
'grant 7656001 kits.gold',
'group vip ',
'group-permission vip kits.vip',
'member 7656001 default',
'member 7656001 vip',
])
// The title is kept verbatim — Carbon's own end in a space.
assert.equal(present.find((r) => r.kind === 'group').value, model.groupValue('VIP ', 2, 'default'))
const classes = reconcile.classify({ present, pushed: [], desired: [], registered: REGISTERED })
assert.ok(!classes.added.some((r) => r.kind === 'member' && r.object === 'default'))
})
test('added, removed, changed and landed are told apart by the pushed ledger', () => {
const { reconcile, model } = load()
const v = (t) => model.groupValue(t, 0, '')
const present = [
{ kind: 'grant', subject: 's1', object: 'kits.vip' }, // nobody's: added in the game
{ kind: 'grant', subject: 's2', object: 'kits.gold' }, // wanted, never pushed: landed
{ kind: 'group', subject: 'vip', object: '', value: v('Changed') },
]
const pushed = [
{ kind: 'grant', subject: 's3', object: 'kits.gold' }, // pushed, wanted, gone: removed
{ kind: 'group', subject: 'vip', object: '', value: v('VIP') },
]
const desired = [
{ kind: 'grant', subject: 's2', object: 'kits.gold' },
{ kind: 'grant', subject: 's3', object: 'kits.gold' },
{ kind: 'group', subject: 'vip', object: '', value: v('VIP') },
]
const c = reconcile.classify({ present, pushed, desired, registered: REGISTERED })
assert.deepEqual(c.added.map(model.rowKey), ['grant s1 kits.vip'])
assert.deepEqual(c.removed.map(model.rowKey), ['grant s3 kits.gold'])
assert.deepEqual(c.landed.map(model.rowKey), ['grant s2 kits.gold'])
assert.deepEqual(c.changed.map((r) => r.value), [v('Changed')])
})
test('a permission the server does not register right now is never judged — an unloaded plugin is not a revocation', () => {
const { reconcile } = load()
// `kits.vip` was pushed and is wanted, and the inventory does not have it —
// because Kits is unloaded, so the name is not registered.
const c = reconcile.classify({
present: [],
pushed: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }],
desired: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }],
registered: new Set(['zonemanager.zone']),
})
assert.equal(c.removed.length, 0)
})
test('a pair an event lease holds is the lease’s, not a hand edit', () => {
const { reconcile } = load()
const c = reconcile.classify({
present: [{ kind: 'group-permission', subject: 'default', object: 'kits.vip' }],
pushed: [],
desired: [],
registered: REGISTERED,
leased: [{ kind: 'group-permission', subject: 'default', object: 'kits.vip' }],
})
assert.equal(c.added.length, 0)
})
test('a group removed in the game takes its permissions and members with it', () => {
const { reconcile, model } = load()
const rows = [
{ kind: 'group', subject: 'vip', object: '', value: model.groupValue('VIP', 0, '') },
{ kind: 'group-permission', subject: 'vip', object: 'kits.vip' },
{ kind: 'member', subject: 's1', object: 'vip' },
]
const c = reconcile.classify({ present: [], pushed: rows, desired: rows, registered: REGISTERED })
assert.deepEqual(c.removed.map(model.rowKey), ['group vip '])
})
test('the first inventory imports additions whatever the policy, and pushes removals back (D198)', () => {
const { reconcile } = load()
const classes = {
added: [
{ kind: 'grant', subject: 's1', object: 'kits.vip' },
{ kind: 'group', subject: 'vip', object: '', value: '["VIP",1,""]' },
{ kind: 'member', subject: 's1', object: 'vip' },
],
removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }],
changed: [],
landed: [],
}
for (const policy of ['auto-adopt', 'adopt', 'revoke']) {
const p = reconcile.plan({ classes, policy, importing: true, sources: new Map() })
// Groups first: a membership is written into a group that must exist.
assert.deepEqual(p.ops.map((o) => o.op), ['adoptGroup', 'adoptGrant', 'adoptMember'], policy)
assert.ok(p.ops.every((o) => o.source === undefined || o.source === 'imported'))
assert.equal(p.ops.find((o) => o.op === 'adoptGroup').title, 'VIP')
assert.equal(p.revocations.length, 0)
assert.equal(p.hold.size, 0)
}
})
test('auto-adopt makes an addition the site’s and a removal the site’s withdrawal (D190)', () => {
const { reconcile } = load()
const sources = new Map([['grant s3 kits.gold', [{ type: 'steamGrant', id: 9, scope: 'main' }]]])
const p = reconcile.plan({
classes: {
added: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }],
removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }],
changed: [],
landed: [],
},
policy: 'auto-adopt',
importing: false,
sources,
})
assert.deepEqual(p.ops.map((o) => o.op), ['adoptGrant', 'dropGrant'])
assert.equal(p.ops[0].source, 'adopted')
assert.deepEqual(p.ops[1].sources, [{ type: 'steamGrant', id: 9, scope: 'main' }])
})
test('auto-adopt leaves an event’s grant to the event, pushes it back and tells a person', () => {
const { reconcile } = load()
const p = reconcile.plan({
classes: { added: [], removed: [{ kind: 'grant', subject: 's1', object: 'kits.vip' }], changed: [], landed: [] },
policy: 'auto-adopt',
importing: false,
sources: new Map([['grant s1 kits.vip', [{ type: 'runGrant', runId: '4', stepId: '1' }]]]),
})
assert.equal(p.ops.length, 0)
assert.deepEqual(p.drift, [{ kind: 'grant', subject: 's1', object: 'kits.vip', direction: 'removed', detail: 'event' }])
assert.equal(p.hold.size, 0, 'pushed back, not held')
})
test('adopt asks a person about every change, and holds removals and changed groups until then', () => {
const { reconcile } = load()
const p = reconcile.plan({
classes: {
added: [{ kind: 'group', subject: 'raid', object: '', value: '["Raiders",0,""]' }],
removed: [{ kind: 'member', subject: 's1', object: 'vip' }],
changed: [{ kind: 'group', subject: 'vip', object: '', value: '["V",0,""]' }],
landed: [],
},
policy: 'adopt',
importing: false,
sources: new Map(),
})
assert.equal(p.ops.length, 0)
assert.deepEqual(p.drift.map((d) => d.direction), ['added', 'removed', 'changed'])
assert.equal(p.drift[0].detail, '["Raiders",0,""]', 'an added group keeps its title for adopting later')
assert.deepEqual([...p.hold].sort(), ['group vip ', 'member s1 vip'])
})
test('revoke undoes an addition at this sync, and never a built-in group', () => {
const { reconcile } = load()
const p = reconcile.plan({
classes: {
added: [
{ kind: 'grant', subject: 's1', object: 'kits.vip' },
{ kind: 'group', subject: 'admin', object: '' },
],
removed: [{ kind: 'grant', subject: 's3', object: 'kits.gold' }],
changed: [],
landed: [],
},
policy: 'revoke',
importing: false,
sources: new Map(),
})
assert.deepEqual(p.revocations, [{ kind: 'grant', subject: 's1', object: 'kits.vip' }])
assert.equal(p.ops.length, 0, 'a removal is pushed back by the desired set, with nothing to do')
})
test('a held row is neither pushed nor recorded, and a held group keeps its place without its title', () => {
const { reconcile, model } = load()
const desired = {
hash: 'h',
rows: [
{ kind: 'group', subject: 'vip', object: '', value: model.groupValue('VIP', 1, '') },
{ kind: 'member', subject: 's1', object: 'vip' },
{ kind: 'grant', subject: 's2', object: 'kits.gold' },
],
payload: {
groups: [{ name: 'vip', title: 'VIP', rank: 1, parent: '', permissions: [], members: ['s1'] }],
grants: [{ steamId: 's2', permissions: ['kits.gold'] }],
credits: [],
},
}
const held = reconcile.withHold(desired, new Set(['group vip ', 'member s1 vip', 'grant s2 kits.gold']))
assert.deepEqual(held.payload.groups, [{ name: 'vip', permissions: [], members: [] }])
assert.deepEqual(held.payload.grants, [])
assert.deepEqual(held.rows, [])
assert.equal(held.hash, 'h', 'the digest is the whole desired set’s')
// …and a held row is not retired either: it is still wanted.
assert.deepEqual(model.retirements(desired.rows, desired.rows.slice(1), new Set(['group vip '])), [])
})
test('a built-in group is never retired, even when the site no longer has it', () => {
const { model } = load()
const pushed = [
{ kind: 'group', subject: 'default', object: '' },
{ kind: 'group', subject: 'raid', object: '' },
]
assert.deepEqual(model.retirements(pushed, []).map((r) => r.subject), ['raid'])
})
test('a group shared with other servers is marked so in the sources; a server-only one is not (D190)', () => {
const { model } = load()
const desired = model.buildDesired('main', {
groups: [
{ id: 1, name: 'vip', title: 'VIP', rank: 0, parent: '', allServers: true },
{ id: 2, name: 'raid', title: 'Raid', rank: 0, parent: '', allServers: false },
],
groupServers: [{ groupId: 2, serverId: 'main', included: true }],
groupPermissions: [{ groupId: 1, permission: 'kits.vip' }, { groupId: 2, permission: 'kits.gold' }],
members: [],
steamMembers: [{ groupId: 2, steamId: 's1' }],
grants: [],
steamGrants: [],
exceptions: [],
runGrants: [],
groupChat: [],
steamIdsByUser: new Map(),
})
assert.equal(desired.sources.get('group-permission vip kits.vip')[0].shared, true)
assert.equal(desired.sources.get('group-permission raid kits.gold')[0].shared, false)
assert.equal(desired.sources.get('member s1 raid')[0].type, 'steamMember')
})
test('an exception keeps a fleet grant off one server and on every other (D190)', () => {
const { model } = load()
const set = {
groups: [],
groupServers: [],
groupPermissions: [],
members: [],
steamMembers: [],
grants: [],
steamGrants: [{ id: 5, steamId: 's1', permission: 'kits.vip', scope: '*' }],
exceptions: [{ id: 1, holder: 'steam', grantId: 5, serverId: 'main' }],
runGrants: [],
groupChat: [],
steamIdsByUser: new Map(),
}
assert.ok(!model.buildDesired('main', set).rows.some((r) => r.kind === 'grant'))
assert.ok(model.buildDesired('creative', set).rows.some((r) => r.kind === 'grant' && r.subject === 's1'))
})
test('a group excluded from one server by a split is on every other server', () => {
const { model } = load()
const group = { id: 1, name: 'vip', allServers: true }
const rows = model.serversByGroup([{ groupId: 1, serverId: 'main', included: false }])
assert.equal(model.groupCovers(group, rows, 'main'), false)
assert.equal(model.groupCovers(group, rows, 'creative'), true)
assert.equal(model.isShared(group, rows), true)
})