Files
Module-Rust/server/model/permissions/permissions.model.js
wtclaude cc185db26b feat(rust): the rewards — tally, kit reward, chat and the news leg (phase 13b, protocol 10)
Four event verbs and the announce leg, per PLAN.md §29:

- rust.participation.open / .collect: the plugin counts who takes part
  (seconds, kills or both, in a zone this run opened or the whole server)
  and collect files them as the run's participants, keyed by Steam id.
- rust.kit.entitle: the five recipient modes (D101), rows in the new
  rust_perm_run_grants (D84) unioned into the permission push, one extra
  use of the kit per reward as site-held credits on perm.sync (D103),
  and the rust.kit.entitled notice deferred from phase 10 (D64).
- rust.announce: one server or every server (D105).
- rust.chat announce leg, speaking only on servers whose new news switch
  is on (D104) - a card on Admin -> Rust visibility (D106).

Budgets rust.grants and rust.announcements; the kit source and four
fixed-choice sources (core has no enum param type). rust_perm_run_grants
carries core's idempotency key so a revert of a lost answer can find its
rows. Protocol 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 07:00:44 -05:00

508 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The authored set, and what it means for one server ────────────────────
//
// This file turns "what an operator wrote on the website" into "what one game
// server's store should contain", which is where four of phase 7's decisions
// actually live:
//
// D28 a grant is authored against a WEBSITE USER and resolved to every Steam
// id they have linked, here, at the moment of the push.
// D29 every authored row carries a scope — one server, or `*` for the fleet —
// and a server sees only what names it.
// D30 groups travel as groups. Membership is a separate wire fact from the
// permissions the group carries, because the game stores them separately
// and one of the two can fail on its own (§12.2 rule 4).
// D31 the difference between the desired set and what this site has already
// pushed is what gets retired. Anything else in the store is drift, and
// drift is reported rather than undone.
//
// Nothing here talks to a sidecar — `permSync.js` does that. The split is the
// usual one and earns its keep twice over here: the whole of the interesting
// logic is a pure function of four tables, so it is tested without a game, a
// sidecar, or a database.
const crypto = require('node:crypto')
const db = require('./permissions.db')
/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */
const FLEET = '*'
/**
* Permission and group names, as both frameworks store them.
*
* Lowercased on the way in, because the store lowers them and a site that did
* not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own
* grant as drift for ever.
*/
function normaliseName(value) {
return String(value || '').trim().toLowerCase()
}
/** Whether a scope reaches a server. */
function inScope(scope, serverId) {
return scope === FLEET || scope === serverId
}
/**
* Everything the authoring screen renders, in one read.
*
* Assembled here rather than in SQL because the shape is a tree — a group with
* its permissions and its members — and the alternative is either four round
* trips per group or one join that repeats every group row once per member.
*/
async function overview() {
const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listSync(),
db.listDrift(),
db.listCatalogue(),
])
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }]))
for (const row of groupPermissions) {
const group = byGroup.get(row.groupName)
if (group) group.permissions.push(row.permission)
}
// A member with two linked Steam accounts arrives as two rows from the join,
// and is one person on the screen — holding BOTH accounts, not the first one
// the join happened to return. The screen needs all of them: a membership is
// pushed per account, and it can be waiting on one while it landed on another.
const memberByKey = new Map()
for (const row of members) {
const group = byGroup.get(row.groupName)
if (!group) continue
const key = `${row.groupName}:${row.userId}`
let member = memberByKey.get(key)
if (!member) {
member = {
userId: row.userId,
username: row.username,
accounts: [],
addedAt: row.addedAt,
}
memberByKey.set(key, member)
group.members.push(member)
}
if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null })
}
return {
groups: [...byGroup.values()],
grants: collapseGrants(grants),
servers: sync.map(shapeSync),
drift,
catalogue: catalogueByPermission(catalogue),
}
}
/**
* One row per grant, not one per linked account.
*
* The join in `listGrants` multiplies a grant by the holder's accounts, which is
* what the push wants and the opposite of what a screen wants.
*/
function collapseGrants(rows) {
const byId = new Map()
for (const row of rows) {
const existing = byId.get(row.id)
if (!existing) {
byId.set(row.id, {
id: row.id,
userId: row.userId,
username: row.username,
permission: row.permission,
scope: row.scope,
source: row.source,
note: row.note,
grantedAt: row.grantedAt,
accounts: row.steamId ? [{ steamId: row.steamId, name: row.playerName || null }] : [],
})
continue
}
if (row.steamId) existing.accounts.push({ steamId: row.steamId, name: row.playerName || null })
}
return [...byId.values()]
}
/**
* The sync row as a client reads it.
*
* `report` is stored as the JSON the game sent and parsed here rather than on the
* way in, so a report this build cannot read is a rendering problem on one
* screen instead of a write that failed.
*/
function shapeSync(row) {
let report = null
if (row.report) {
try {
report = JSON.parse(row.report)
} catch {
report = null
}
}
return {
serverId: row.serverId,
state: row.state,
dirty: Boolean(row.dirty),
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
lastAttemptAt: row.lastAttemptAt,
lastOkAt: row.lastOkAt,
error: row.error || null,
report,
}
}
/** Which servers know each permission name — the form's option source, and its warning label. */
function catalogueByPermission(rows) {
const byPermission = new Map()
for (const row of rows) {
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
byPermission.get(row.permission).push(row.serverId)
}
return [...byPermission.entries()]
.map(([permission, servers]) => ({ permission, servers }))
.sort((a, b) => a.permission.localeCompare(b.permission))
}
/**
* ── What one person holds, as that person reads it ────────────────────────
*
* The admin overview answers *who holds what*; this answers *what do I hold*,
* and it is a different shape rather than a filtered one. Three things make it
* different:
*
* 1. **The scope arithmetic is answered here, not sent.** A client handed
* `scope: '*'` would have to know what the fleet is and re-implement
* `inScope` to say anything useful, and then there would be two of it. Each
* entry carries the servers it actually reaches, already resolved.
* 2. **`live` is per server and it is the pushed ledger, not the authored
* row.** A grant made on the website is not a privilege in a game until a
* sync confirmed it, and phase 7 is careful never to record a push that
* silently did nothing (an unregistered permission, a store that has never
* seen the player). So "waiting" here means waiting, and saying otherwise
* would be the site claiming to have given something it has not.
* 3. **Nothing says WHY it is waiting.** Which permission names a server's
* loaded plugins registered is an operator's diagnosis and an inventory of
* what is installed; a player gets the honest state, not the reason.
*
* Every read is scoped to the caller in SQL, and the pushed rows are looked up
* by the caller's OWN Steam ids — so a person with no linked account correctly
* sees entitlements that reach nobody yet, rather than nothing at all (the
* mistake phase 7 shipped on the admin user page, §20.5).
*/
async function forPlayer(userId, steamIds, serverRows) {
const [groups, groupPermissions, grants, pushed] = await Promise.all([
db.listGroupsForUser(userId),
db.listGroupPermissions(),
db.listGrants({ userId }),
db.listPushedForSteamIds(steamIds),
])
const servers = serverRows.map((row) => ({ id: row.id, name: row.name || row.id }))
// `kind:object` -> the servers a row of ours landed on. The subject is one of
// this caller's own Steam ids by construction, so it does not enter the key:
// an entitlement is live for the person if it is live for any account they
// hold, which is the same thing the game sees.
const live = new Map()
for (const row of pushed) {
const key = `${row.kind}:${normaliseName(row.object)}`
if (!live.has(key)) live.set(key, new Set())
live.get(key).add(row.serverId)
}
/** The servers a scope reaches, each marked with whether it is there yet. */
function reach(scope, key) {
const landed = live.get(key) || new Set()
return servers
.filter((server) => inScope(scope, server.id))
.map((server) => ({ ...server, live: landed.has(server.id) }))
}
const permissionsByGroup = new Map()
for (const row of groupPermissions) {
if (!permissionsByGroup.has(row.groupName)) permissionsByGroup.set(row.groupName, [])
permissionsByGroup.get(row.groupName).push(normaliseName(row.permission))
}
return {
groups: groups.map((group) => ({
name: group.name,
title: group.title || group.name,
scope: group.scope,
since: group.addedAt,
permissions: (permissionsByGroup.get(group.name) || []).sort(),
reach: reach(group.scope, `member:${normaliseName(group.name)}`),
})),
// `collapseGrants` first: the join multiplies a grant by the accounts its
// holder has linked, and this caller may hold two.
grants: collapseGrants(grants)
.map((grant) => ({
permission: grant.permission,
scope: grant.scope,
source: grant.source,
note: grant.note,
since: grant.grantedAt,
reach: reach(grant.scope, `grant:${normaliseName(grant.permission)}`),
}))
.sort((a, b) => a.permission.localeCompare(b.permission)),
}
}
/**
* The whole authored set, read once, in the shape the per-server build wants.
*
* Read once per sync tick rather than once per server: six servers is six
* different answers derived from one set of tables, and re-reading them per
* server is six times the queries for the same rows.
*/
async function readAuthored() {
const [groups, groupPermissions, members, grants, links, runGrants] = await Promise.all([
db.listGroups(),
db.listGroupPermissions(),
db.listGroupMembers(),
db.listGrants(),
db.listLinks(),
db.listRunGrants(),
])
const steamIdsByUser = new Map()
for (const link of links) {
if (!steamIdsByUser.has(link.userId)) steamIdsByUser.set(link.userId, [])
steamIdsByUser.get(link.userId).push(link.steamId)
}
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser }
}
/**
* What one server's store should contain, and the rows that say so.
*
* Returns three things the caller needs together and must not compute twice:
*
* `payload` what goes on the wire
* `rows` the same set in `rust_perm_pushed`'s shape, for the diff
* `hash` a stable digest of `rows`, which is how the loop knows nothing
* has changed without asking a game server
*
* **A user with no linked Steam account contributes nothing and is not an
* error.** They are authored against perfectly well and reach nobody until they
* link — which the admin screen says out loud, because a grant that reaches
* nothing looks exactly like one that worked.
*/
function buildDesired(serverId, authored) {
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
const runGrants = authored.runGrants || []
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
const groupNames = new Set(scopedGroups.map((group) => group.name))
const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
const managed = new Set()
const rows = []
for (const group of scopedGroups)
rows.push({ kind: 'group', subject: group.name, object: '' })
for (const row of groupPermissions) {
if (!groupNames.has(row.groupName)) continue
const permission = normaliseName(row.permission)
permissionsByGroup.get(row.groupName).push(permission)
managed.add(permission)
rows.push({ kind: 'group-permission', subject: row.groupName, object: permission })
}
const seenMember = new Set()
for (const row of members) {
if (!groupNames.has(row.groupName)) continue
for (const steamId of steamIdsByUser.get(row.userId) || []) {
const key = `${row.groupName}:${steamId}`
if (seenMember.has(key)) continue
seenMember.add(key)
membersByGroup.get(row.groupName).push(steamId)
rows.push({ kind: 'member', subject: steamId, object: row.groupName })
}
}
const permissionsBySteamId = new Map()
const seenGrant = new Set()
for (const row of grants) {
if (!inScope(row.scope, serverId)) continue
const permission = normaliseName(row.permission)
// Managed whether or not it reaches anybody: the namespace is what makes a
// hand grant of this permission to somebody else show up as drift, and a
// grant whose holder has linked nothing would otherwise silently narrow it.
managed.add(permission)
// **Resolved from the link map, not from the row.** `listGrants` joins the
// links and therefore repeats a grant once per linked account, which would
// give the right answer here by accident — until somebody changes that query
// and one of a person's two accounts quietly stops being granted. The map is
// the same source the members above use, and it says what it means.
for (const steamId of steamIdsByUser.get(row.userId) || []) {
const key = `${steamId}:${permission}`
if (seenGrant.has(key)) continue
seenGrant.add(key)
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
permissionsBySteamId.get(steamId).push(permission)
rows.push({ kind: 'grant', subject: steamId, object: permission })
}
}
// ── What events granted (phase 13b, D84) ──────────────────────────────
//
// Unioned with the admin grants above through the same `seenGrant`, so a
// permission held both ways is ONE row in the game — and withdrawing either
// leaves the other standing, because the next build still finds it.
//
// An event grant reaches only the kit's server (D102), and like any grant it
// reaches every account the user has linked (D28).
//
// The CREDIT is different: one win is one extra use, on the account that took
// part, and only while that account is still linked to the user who won it.
const credits = new Map()
for (const row of runGrants) {
if (row.serverId !== serverId) continue
const linked = steamIdsByUser.get(row.userId) || []
const permission = normaliseName(row.permission)
if (permission) {
managed.add(permission)
for (const steamId of linked) {
const key = `${steamId}:${permission}`
if (seenGrant.has(key)) continue
seenGrant.add(key)
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
permissionsBySteamId.get(steamId).push(permission)
rows.push({ kind: 'grant', subject: steamId, object: permission })
}
}
if (Number(row.credit) && linked.includes(row.steamId)) {
// A Steam id is digits, so the first bar is always the split; a kit name
// may contain one.
const key = `${row.steamId}|${row.kit}`
credits.set(key, (credits.get(key) || 0) + 1)
}
}
const creditRows = [...credits.entries()]
.map(([key, count]) => {
const bar = key.indexOf('|')
return { steamId: key.slice(0, bar), kit: key.slice(bar + 1), count }
})
.sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit))
const payload = {
groups: scopedGroups.map((group) => ({
name: group.name,
title: group.title || group.name,
rank: group.rank,
permissions: permissionsByGroup.get(group.name),
members: membersByGroup.get(group.name),
})),
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
steamId,
permissions,
})),
managed: [...managed].sort(),
// Always sent, even empty: to the plugin an absent field means "this site
// says nothing about credits", and an empty one means "nobody has any" —
// which is what a revert of the last reward must be able to say (D103).
credits: creditRows,
}
// Credits are in the digest, so a new reward or a revert pushes, but they are
// NOT in `rows`: those are the pushed ledger's, and a use of a kit is not
// something in the permission store to retire.
const hashed = [
...rows,
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
]
return { payload, rows, hash: hashRows(hashed) }
}
/**
* A digest of the desired set.
*
* Sorted before hashing, because the rows come out of several queries in an
* order nothing guarantees — an unsorted digest would differ between two reads
* of an unchanged set and push to every game server on every tick.
*/
function hashRows(rows) {
const canonical = rows
.map((row) => `${row.kind}${row.subject}${row.object}`)
.sort()
.join('\n')
return crypto.createHash('sha256').update(canonical).digest('hex')
}
/** A row's identity, for set arithmetic against what was pushed. */
const rowKey = (row) => `${row.kind}${row.subject}${row.object}`
/**
* What this site put in a server and has since withdrawn.
*
* `pushed − desired`, and it is the one calculation that cannot be replaced by
* asking the game: a name in the store that is not in the desired set is either
* something the site retired or something a human granted, and those have
* opposite correct answers (D31). Only the pushed ledger tells them apart.
*/
function retirements(pushed, desiredRows) {
const desired = new Set(desiredRows.map(rowKey))
return pushed.filter((row) => !desired.has(rowKey(row)))
}
module.exports = {
FLEET,
normaliseName,
inScope,
overview,
forPlayer,
readAuthored,
buildDesired,
retirements,
hashRows,
rowKey,
collapseGrants,
shapeSync,
}