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
199 lines
8.0 KiB
JavaScript
199 lines
8.0 KiB
JavaScript
// ── 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 }
|