feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
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:
198
server/model/permissions/permissions.view.js
Normal file
198
server/model/permissions/permissions.view.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// ── What the permission screen reads (D162, D163, U-1) ────────────────────
|
||||
//
|
||||
// The screen follows uMod PermissionsManager's flow — a server, then players ⇄
|
||||
// groups, then a subject, then a plugin's permissions with Granted / Revoked —
|
||||
// and every toggle on it carries its own state on that server. This file
|
||||
// assembles what that needs in one read per request:
|
||||
//
|
||||
// • plugins grouped by the plugin that REGISTERED each permission (§0.1),
|
||||
// never by the name's prefix — `zonemanager.ignoreflag.nokits` is
|
||||
// ZoneManager's. A name no plugin owns (Carbon's built-in modules) is
|
||||
// grouped by its prefix, and says so.
|
||||
// • the groups on the server (D189), with where else each one is.
|
||||
// • every subject holding anything there, named by linked account and in-game
|
||||
// name, or Steam id when there is neither (D163).
|
||||
// • the raw facts the toggle states are computed from: what the site wants and
|
||||
// why (its sources), what has landed (the pushed ledger), and what the last
|
||||
// report said did not.
|
||||
|
||||
const db = require('./permissions.db')
|
||||
const model = require('./permissions.model')
|
||||
const servers = require('../servers/servers.model')
|
||||
|
||||
/** The servers, their policy and sync state, and every row waiting for a person. */
|
||||
async function overview() {
|
||||
const [serverRows, sync, policies, drift] = await Promise.all([
|
||||
servers.listForAdmin(),
|
||||
db.listSync(),
|
||||
db.listPolicies(),
|
||||
db.listDrift(),
|
||||
])
|
||||
|
||||
const syncById = new Map(sync.map((row) => [row.serverId, model.shapeSync(row)]))
|
||||
const policyById = new Map(policies.map((row) => [row.serverId, row.policy]))
|
||||
|
||||
return {
|
||||
servers: serverRows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name || row.id,
|
||||
policy: policyById.get(row.id) || 'auto-adopt',
|
||||
sync: syncById.get(row.id) || null,
|
||||
})),
|
||||
drift: drift.map((row) => ({ ...row, detail: row.detail === undefined ? null : row.detail })),
|
||||
}
|
||||
}
|
||||
|
||||
/** A permission's plugin button: its registering plugin, or its prefix. */
|
||||
function pluginOf(row) {
|
||||
if (row.owner) return { key: `plugin:${row.owner}`, label: row.owner, registered: true }
|
||||
const prefix = row.permission.includes('.') ? row.permission.slice(0, row.permission.indexOf('.')) : row.permission
|
||||
return { key: `prefix:${prefix}`, label: prefix, registered: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the screen shows for one server. Null for a server the site does
|
||||
* not have.
|
||||
*/
|
||||
async function serverView(serverId) {
|
||||
const serverRows = await servers.listForAdmin()
|
||||
const server = serverRows.find((row) => row.id === serverId)
|
||||
if (!server) return null
|
||||
|
||||
const [authored, catalogue, pushed, sync, policies, drift, links] = await Promise.all([
|
||||
model.readAuthored(),
|
||||
db.listCatalogue(),
|
||||
db.listPushed(serverId),
|
||||
db.listSync(),
|
||||
db.listPolicies(),
|
||||
db.listDrift(),
|
||||
db.listLinksNamed(),
|
||||
])
|
||||
|
||||
const serverIds = serverRows.map((row) => row.id)
|
||||
const desired = model.buildDesired(serverId, authored)
|
||||
const byGroup = model.serversByGroup(authored.groupServers)
|
||||
const syncRow = sync.find((row) => row.serverId === serverId)
|
||||
const linkBySteam = new Map(links.map((row) => [row.steamId, row]))
|
||||
|
||||
// ── Plugins, by who registered each permission ──
|
||||
const plugins = new Map()
|
||||
|
||||
for (const row of catalogue.filter((r) => r.serverId === serverId)) {
|
||||
const plugin = pluginOf(row)
|
||||
if (!plugins.has(plugin.key)) plugins.set(plugin.key, { ...plugin, permissions: [] })
|
||||
plugins.get(plugin.key).permissions.push(row.permission)
|
||||
}
|
||||
|
||||
// ── Groups on this server ──
|
||||
const chat = model.chatByGroup(authored.groupChat)
|
||||
const onServer = model.groupsOn(serverId, authored)
|
||||
const permissionsByGroup = new Map()
|
||||
for (const row of authored.groupPermissions) {
|
||||
if (!permissionsByGroup.has(row.groupId)) permissionsByGroup.set(row.groupId, [])
|
||||
permissionsByGroup.get(row.groupId).push(model.normaliseName(row.permission))
|
||||
}
|
||||
|
||||
const members = new Map()
|
||||
for (const row of authored.members) {
|
||||
if (!members.has(row.groupId)) members.set(row.groupId, new Map())
|
||||
const byUser = members.get(row.groupId)
|
||||
if (!byUser.has(row.userId)) byUser.set(row.userId, { userId: row.userId, username: row.username, steamIds: [] })
|
||||
if (row.steamId) byUser.get(row.userId).steamIds.push(row.steamId)
|
||||
}
|
||||
|
||||
const groups = onServer.map((group) => {
|
||||
const reach = model.groupReach(group, byGroup, serverIds)
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
title: group.title,
|
||||
rank: group.rank,
|
||||
parent: group.parent,
|
||||
source: group.source,
|
||||
builtin: model.BUILTIN_GROUPS.has(group.name),
|
||||
allServers: group.allServers,
|
||||
servers: reach,
|
||||
shared: model.isShared(group, byGroup),
|
||||
permissions: (permissionsByGroup.get(group.id) || []).sort(),
|
||||
members: [...((members.get(group.id) || new Map()).values())],
|
||||
steamMembers: authored.steamMembers.filter((m) => m.groupId === group.id).map((m) => m.steamId),
|
||||
chat: chat.get(group.id) || null,
|
||||
}
|
||||
})
|
||||
|
||||
// ── Subjects: every Steam id holding anything here, by the desired set ──
|
||||
const subjects = new Map()
|
||||
const subject = (steamId) => {
|
||||
if (!subjects.has(steamId)) subjects.set(steamId, { steamId, grants: [], groups: [] })
|
||||
return subjects.get(steamId)
|
||||
}
|
||||
|
||||
for (const row of desired.rows) {
|
||||
if (row.kind === 'grant') {
|
||||
subject(row.subject).grants.push({ permission: row.object, sources: desired.sources.get(model.rowKey(row)) || [] })
|
||||
} else if (row.kind === 'member') {
|
||||
subject(row.subject).groups.push(row.object)
|
||||
}
|
||||
}
|
||||
|
||||
// A grant kept off this server by an exception still belongs on the screen:
|
||||
// it is "on every server except this one", and the toggle can take it back.
|
||||
const exceptions = authored.exceptions.filter((e) => e.serverId === serverId)
|
||||
const grantById = new Map(authored.grants.map((g) => [`user:${g.id}`, g]))
|
||||
for (const g of authored.steamGrants) grantById.set(`steam:${g.id}`, g)
|
||||
|
||||
const excepted = []
|
||||
for (const e of exceptions) {
|
||||
const grant = grantById.get(`${e.holder}:${e.grantId}`)
|
||||
if (!grant) continue
|
||||
const steamIds = e.holder === 'steam' ? [grant.steamId] : (authored.steamIdsByUser.get(grant.userId) || [])
|
||||
for (const steamId of steamIds) {
|
||||
subject(steamId)
|
||||
excepted.push({ id: e.id, steamId, permission: model.normaliseName(grant.permission), holder: e.holder, grantId: e.grantId })
|
||||
}
|
||||
}
|
||||
|
||||
const steamIds = [...subjects.keys()]
|
||||
const names = new Map((await db.namesFor(steamIds)).map((row) => [row.steamId, row.name]))
|
||||
|
||||
const players = [...subjects.values()]
|
||||
.map((s) => {
|
||||
const link = linkBySteam.get(s.steamId)
|
||||
return {
|
||||
...s,
|
||||
name: names.get(s.steamId) || (link && link.playerName) || null,
|
||||
account: link ? { userId: link.userId, username: link.username } : null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (a.name || a.steamId).localeCompare(b.name || b.steamId))
|
||||
|
||||
const report = syncRow ? model.shapeSync(syncRow).report : null
|
||||
const policy = (policies.find((row) => row.serverId === serverId) || {}).policy || 'auto-adopt'
|
||||
|
||||
return {
|
||||
server: { id: server.id, name: server.name || server.id },
|
||||
servers: serverRows.map((row) => ({ id: row.id, name: row.name || row.id })),
|
||||
policy,
|
||||
sync: syncRow ? model.shapeSync(syncRow) : null,
|
||||
plugins: [...plugins.values()].sort((a, b) => Number(b.registered) - Number(a.registered) || a.label.localeCompare(b.label)),
|
||||
groups,
|
||||
players,
|
||||
excepted,
|
||||
// What has landed on this server: `grant steamId permission`, `member steamId
|
||||
// group`, `group-permission group permission`.
|
||||
landed: pushed
|
||||
.filter((row) => row.kind === 'grant' || row.kind === 'member' || row.kind === 'group-permission')
|
||||
.map(model.rowKey),
|
||||
report: report
|
||||
? {
|
||||
unresolved: report.unresolved || [],
|
||||
pending: report.pending || [],
|
||||
notLanded: report.notLanded || [],
|
||||
}
|
||||
: null,
|
||||
drift: drift.filter((row) => row.serverId === serverId),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { overview, serverView, pluginOf }
|
||||
Reference in New Issue
Block a user