Files
Module-Rust/server/model/permissions/permissions.view.js
wtclaude e0d13e73db
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
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
2026-09-28 06:58:59 -05:00

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 }