PLAN.md §33, D134-D143. Protocol 12. - Chat titles (D135-D137): per-server rules (stat, top N, text, colour) that rank the current wipe, and a mode (first | all | up to N). Worked out once in model/titles and read three ways: pushed whole to the game by a new titleSync loop (on change, restart or wipe), and on every leaderboard row as `titles`. Admin: PUT /servers/:id/titles. - Group styles (D138, D139): a site group may carry all twelve BetterChat fields (rust_perm_group_chat). They ride perm.sync with `expect` from the pushed ledger, which gains a value column; a field changed in game is a `chat-field` drift row with the game's value, adopted into the style or put back. A withdrawn style is one `chat-group` retirement, never for `default`, cleared from the ledger only once BetterChat removed it. - The voice (D140): one fleet setting naming a styled group; news and rust.announce chat lines carry its format and the plugin says them with no sender. Admin: GET/PUT /voice. - Popups (D141, D142): rust.announce gains `delivery` (still version 1, from rust.options.delivery); each server gains news_delivery beside the news switch; `popup-unavailable` is not retried. - GET /servers/:id/integrations reads, live, which optional mods a server has loaded. README lists BetterChat and PopupNotifications as optional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
553 lines
20 KiB
JavaScript
553 lines
20 KiB
JavaScript
// ── 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, groupChat] = await Promise.all([
|
||
db.listGroups(),
|
||
db.listGroupPermissions(),
|
||
db.listGroupMembers(),
|
||
db.listGrants(),
|
||
db.listSync(),
|
||
db.listDrift(),
|
||
db.listCatalogue(),
|
||
db.listGroupChat(),
|
||
])
|
||
|
||
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [], chat: null }]))
|
||
|
||
// Phase 17: a group's BetterChat style, or null for a group without one.
|
||
for (const [name, fields] of chatByGroup(groupChat)) {
|
||
const group = byGroup.get(name)
|
||
if (group) group.chat = fields
|
||
}
|
||
|
||
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: drift.map((row) => ({ ...row, detail: row.detail === undefined ? null : row.detail })),
|
||
catalogue: catalogueByPermission(catalogue),
|
||
}
|
||
}
|
||
|
||
/** Style rows folded into one object per group: `name → { Field: value }`. */
|
||
function chatByGroup(rows) {
|
||
const out = new Map()
|
||
|
||
for (const row of rows || []) {
|
||
if (!out.has(row.groupName)) out.set(row.groupName, {})
|
||
out.get(row.groupName)[row.field] = row.value
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* 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, groupChat] = await Promise.all([
|
||
db.listGroups(),
|
||
db.listGroupPermissions(),
|
||
db.listGroupMembers(),
|
||
db.listGrants(),
|
||
db.listLinks(),
|
||
db.listRunGrants(),
|
||
db.listGroupChat(),
|
||
])
|
||
|
||
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, groupChat }
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
}
|
||
}
|
||
|
||
// ── A group's BetterChat style (phase 17, D138) ───────────────────────
|
||
//
|
||
// One ledger row per FIELD (`chat-field`, subject the group, object the
|
||
// field), carrying its value: the diff that retires a style is the same
|
||
// `pushed − desired` as everything else, and the value is what the next sync
|
||
// sends as `expect`. The value is not in the row's identity — a changed value
|
||
// is the same field pushed again, not a retirement.
|
||
const chat = chatByGroup(authored.groupChat)
|
||
|
||
for (const group of scopedGroups) {
|
||
const fields = chat.get(group.name)
|
||
if (!fields) continue
|
||
|
||
for (const field of Object.keys(fields).sort()) {
|
||
rows.push({ kind: 'chat-field', subject: group.name, object: field, value: fields[field] })
|
||
}
|
||
}
|
||
|
||
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),
|
||
// The values only; `permSync` adds what each one expects to find, which
|
||
// is per server and comes from the ledger.
|
||
...(chat.has(group.name) ? { chat: chat.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.
|
||
//
|
||
// A style field's VALUE goes into the digest the same way, since it is not in
|
||
// the row's identity: a colour changed on the site must push.
|
||
const hashed = [
|
||
...rows.map((row) => (row.kind === 'chat-field' ? { ...row, object: `${row.object}=${row.value}` } : row)),
|
||
...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} |