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
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
// ── 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)
|
||
})
|