Files
Module-Rust/server/model/permissions/permissions.db.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

579 lines
19 KiB
JavaScript

// ── SQL for the permission mirror, and nothing else ───────────────────────
//
// The tables this file reads are described at length in `db/schema.sql`; what
// matters here is which of them is authoritative for what, because four of the
// eight look similar and answer completely different questions:
//
// AUTHORED `rust_perm_groups`, `..._group_permissions`, `..._group_members`,
// `rust_perm_grants` — what an operator (and later an event) says
// should be true. Keyed by WEBSITE USER (D28).
// PUSHED `rust_perm_pushed` — what this site has confirmed into one game's
// store. Keyed by STEAM ID, because it records what is in the game
// and the game has never heard of a website account.
// FOUND `rust_perm_drift` — what a sync found that the site did not
// author. Replaced whole by each report: it is the current
// difference, not a history of differences.
// INSTRUCTED `rust_perm_revocations` — remove this, even though we never put
// it there. The only way to act on drift, since a foreign grant
// often names a Steam id no website account holds.
//
// Raw parameterised SQL through `core.query`, no ORM, like every other `.db.js`
// here. Bulk writes are batched into one statement with a generated placeholder
// list rather than looped, because a fleet-wide sync writes hundreds of rows and
// a round trip each is how a boot tick becomes a second long.
const core = require('../../core')
const GROUPS = 'rust_perm_groups'
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
const GROUP_MEMBERS = 'rust_perm_group_members'
const GRANTS = 'rust_perm_grants'
const RUN_GRANTS = 'rust_perm_run_grants'
const PUSHED = 'rust_perm_pushed'
const DRIFT = 'rust_perm_drift'
const REVOCATIONS = 'rust_perm_revocations'
const SYNC = 'rust_perm_sync'
const CATALOGUE = 'rust_perm_catalogue'
const LINKS = 'rust_account_links'
const SERVERS = 'rust_servers'
/** `(?,?,?),(?,?,?)` for `rows.length` rows of `width` columns. */
function placeholders(rows, width) {
return rows.map(() => `(${new Array(width).fill('?').join(',')})`).join(',')
}
// ---- the authored set ----
async function listGroups() {
return core.query(
`SELECT name, title, \`rank\`, scope, created_at AS createdAt, updated_at AS updatedAt
FROM ${GROUPS}
ORDER BY \`rank\` DESC, name ASC`,
)
}
async function getGroup(name) {
const rows = await core.query(
`SELECT name, title, \`rank\`, scope FROM ${GROUPS} WHERE name = ?`,
[name],
)
return rows[0] || null
}
/**
* Create or update one group.
*
* `ON DUPLICATE KEY UPDATE` rather than a check-then-write: two admins on the
* same screen is not a race worth losing a title over, and the row's identity is
* its name either way.
*/
async function upsertGroup({ name, title, rank, scope }) {
await core.query(
`INSERT INTO ${GROUPS} (name, title, \`rank\`, scope)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE title = VALUES(title), \`rank\` = VALUES(\`rank\`),
scope = VALUES(scope), updated_at = CURRENT_TIMESTAMP`,
[name, title, rank, scope],
)
}
async function deleteGroup(name) {
const result = await core.query(`DELETE FROM ${GROUPS} WHERE name = ?`, [name])
return Number(result.affectedRows || 0) > 0
}
async function listGroupPermissions() {
return core.query(
`SELECT group_name AS groupName, permission FROM ${GROUP_PERMISSIONS} ORDER BY permission ASC`,
)
}
/** Replace a group's permission list whole. The form edits a list, so the write is a list. */
async function setGroupPermissions(name, permissions) {
await core.query(`DELETE FROM ${GROUP_PERMISSIONS} WHERE group_name = ?`, [name])
if (!permissions.length) return
await core.query(
`INSERT INTO ${GROUP_PERMISSIONS} (group_name, permission)
VALUES ${placeholders(permissions, 2)}`,
permissions.flatMap((permission) => [name, permission]),
)
}
/**
* Every membership, with the member's Steam accounts joined on.
*
* One query rather than a membership read plus a link read per member: the admin
* screen renders both together and the push needs both together, and a fleet's
* worth of members is one round trip either way.
*/
async function listGroupMembers() {
return core.query(
`SELECT m.group_name AS groupName, m.user_id AS userId, m.added_at AS addedAt,
u.username, l.steam_id AS steamId, p.name AS playerName
FROM ${GROUP_MEMBERS} m
JOIN users u ON u.id = m.user_id
LEFT JOIN ${LINKS} l ON l.user_id = m.user_id
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
ORDER BY m.group_name ASC, u.username ASC`,
)
}
async function addGroupMember(groupName, userId, addedBy) {
await core.query(
`INSERT IGNORE INTO ${GROUP_MEMBERS} (group_name, user_id, added_by) VALUES (?, ?, ?)`,
[groupName, userId, addedBy],
)
}
async function removeGroupMember(groupName, userId) {
const result = await core.query(
`DELETE FROM ${GROUP_MEMBERS} WHERE group_name = ? AND user_id = ?`,
[groupName, userId],
)
return Number(result.affectedRows || 0) > 0
}
/**
* Every direct grant, with the holder's accounts joined on.
*
* `username` is on the row because a grant with no linked Steam account still
* has to be listable and nameable — that state is the one the admin screen most
* needs to show, since it looks exactly like a working grant from every other
* angle and reaches nobody.
*/
async function listGrants({ userId = null } = {}) {
return core.query(
`SELECT g.id, g.user_id AS userId, g.permission, g.scope, g.source, g.note,
g.granted_at AS grantedAt, u.username,
l.steam_id AS steamId, p.name AS playerName
FROM ${GRANTS} g
JOIN users u ON u.id = g.user_id
LEFT JOIN ${LINKS} l ON l.user_id = g.user_id
LEFT JOIN rust_players p ON p.steam_id = l.steam_id
${userId === null ? '' : 'WHERE g.user_id = ?'}
ORDER BY u.username ASC, g.permission ASC`,
userId === null ? [] : [userId],
)
}
async function getGrant(id) {
const rows = await core.query(
`SELECT id, user_id AS userId, permission, scope, source FROM ${GRANTS} WHERE id = ?`,
[id],
)
return rows[0] || null
}
/**
* Add a grant, or leave the one that is already there alone.
*
* `INSERT IGNORE` against the unique key, and the return says which happened —
* the controller needs to tell "granted" from "they already had it" to write an
* honest activity row.
*/
async function insertGrant({ userId, permission, scope, source, note, grantedBy }) {
const result = await core.query(
`INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[userId, permission, scope, source, note, grantedBy],
)
return { inserted: Number(result.affectedRows || 0) > 0, id: result.insertId }
}
async function deleteGrant(id) {
const result = await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])
return Number(result.affectedRows || 0) > 0
}
// ---- what events granted (phase 13b) ----
//
// `rust_perm_run_grants` is authored by `rust.kit.entitle`, never by a person,
// and it is read beside `rust_perm_grants` rather than merged into it (D84): the
// push unions the two, and a revert deletes exactly one step's rows.
/** Every event grant, for the push. Small: one row per recipient per reward step still standing. */
async function listRunGrants() {
return core.query(
`SELECT run_id AS runId, step_id AS stepId, user_id AS userId, server_id AS serverId,
steam_id AS steamId, permission, kit, credit
FROM ${RUN_GRANTS}`,
)
}
/** One step's rows. A repeated key finds them here and writes nothing new. */
async function listRunGrantsForStep(runId, stepId) {
return core.query(
`SELECT user_id AS userId, server_id AS serverId, steam_id AS steamId, permission, kit, credit
FROM ${RUN_GRANTS}
WHERE run_id = ? AND step_id = ?`,
[String(runId), String(stepId)],
)
}
/**
* One step's recipients, in one statement. `INSERT IGNORE` against the unique
* key, so a retry that races the first attempt writes each row once.
*/
async function insertRunGrants(rows) {
if (!rows.length) return 0
const result = await core.query(
`INSERT IGNORE INTO ${RUN_GRANTS} (run_id, step_id, idem_key, user_id, server_id, steam_id, permission, kit, credit)
VALUES ${placeholders(rows, 9)}`,
rows.flatMap((r) => [
String(r.runId),
String(r.stepId),
String(r.idemKey || ''),
r.userId,
r.serverId,
r.steamId,
r.permission || '',
r.kit,
r.credit ? 1 : 0,
]),
)
return Number(result.affectedRows || 0)
}
/** Withdraw one step's rows. Returns the servers they were on; none is a success. */
async function deleteRunGrantsForStep(runId, stepId) {
return deleteRunGrantsWhere('run_id = ? AND step_id = ?', [String(runId), String(stepId)])
}
/** The same, found by core's idempotency key — the revert of an answer core lost. */
async function deleteRunGrantsForKey(runId, idemKey) {
if (!idemKey) return []
return deleteRunGrantsWhere('run_id = ? AND idem_key = ?', [String(runId), String(idemKey)])
}
async function deleteRunGrantsWhere(where, params) {
const found = await core.query(`SELECT DISTINCT server_id AS serverId FROM ${RUN_GRANTS} WHERE ${where}`, params)
if (!found.length) return []
await core.query(`DELETE FROM ${RUN_GRANTS} WHERE ${where}`, params)
return found.map((row) => row.serverId)
}
/**
* One website account by name, for the authoring form.
*
* A form that made an operator type a numeric user id would be a form nobody
* could use, and the alternative — calling core's own admin user search from the
* client — would bind this module to the shape of a response the contract does
* not cover. Reading the `users` table is already what every join in this file
* does.
*
* Case-insensitive because the column's collation is: core stores usernames in a
* `_ci` collation and an exact-case lookup would refuse a name the site itself
* considers the same one.
*/
async function findUserByUsername(username) {
const rows = await core.query(`SELECT id, username FROM users WHERE username = ? LIMIT 1`, [username])
return rows[0] || null
}
/** Which website user holds which Steam account. The join that turns an authored row into a push. */
async function listLinks() {
return core.query(`SELECT user_id AS userId, steam_id AS steamId FROM ${LINKS}`)
}
// ---- one person's own half of all of it (the player tier) ----
//
// Every read below is scoped inside the statement rather than filtered after it.
// The admin reads above answer "who holds what"; these answer "what do I hold",
// and the difference between the two is a `WHERE` that must not be somebody
// else's job to remember.
/** The groups one website user belongs to. Ordered the way the admin list is. */
async function listGroupsForUser(userId) {
return core.query(
`SELECT g.name, g.title, g.\`rank\`, g.scope, m.added_at AS addedAt
FROM ${GROUP_MEMBERS} m
JOIN ${GROUPS} g ON g.name = m.group_name
WHERE m.user_id = ?
ORDER BY g.\`rank\` DESC, g.name ASC`,
[userId],
)
}
/**
* Every pushed row naming one of these Steam ids, across every server.
*
* The pushed ledger is keyed by Steam id because it records what is in a GAME
* (D28's other half), so this is the one read in the file that starts from an
* account rather than from a user. `kind` is carried through: a direct grant and
* a group membership are different rows about the same person and only the
* caller can say which of them it was looking for.
*/
async function listPushedForSteamIds(steamIds) {
if (!steamIds.length) return []
return core.query(
`SELECT server_id AS serverId, kind, subject, object
FROM ${PUSHED}
WHERE subject IN (${steamIds.map(() => '?').join(',')})
AND kind IN ('grant', 'member')`,
steamIds,
)
}
// ---- what is actually out there ----
async function listPushed(serverId) {
return core.query(
`SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`,
[serverId],
)
}
async function addPushed(serverId, rows) {
if (!rows.length) return
await core.query(
`INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object)
VALUES ${placeholders(rows, 4)}`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
)
}
async function removePushed(serverId, rows) {
for (const row of rows) {
// eslint-disable-next-line no-await-in-loop
await core.query(
`DELETE FROM ${PUSHED} WHERE server_id = ? AND kind = ? AND subject = ? AND object = ?`,
[serverId, row.kind, row.subject, row.object],
)
}
}
/**
* Replace one server's drift list with what the latest report found.
*
* Whole, rather than merged, and `first_seen` survives through the
* `ON DUPLICATE KEY UPDATE` — so "this has been here since Tuesday" is still
* answerable while "somebody has since undone it" removes the row.
*/
async function replaceDrift(serverId, rows) {
if (!rows.length) {
await core.query(`DELETE FROM ${DRIFT} WHERE server_id = ?`, [serverId])
return
}
await core.query(
`INSERT INTO ${DRIFT} (server_id, kind, subject, object)
VALUES ${placeholders(rows, 4)}
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`,
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
)
// Anything this report did NOT name is gone from the game, so it goes from
// here. Named explicitly rather than swept by timestamp: two syncs a second
// apart would make a timestamp window either delete live rows or keep dead
// ones, depending on the clock.
await core.query(
`DELETE FROM ${DRIFT}
WHERE server_id = ?
AND (kind, subject, object) NOT IN (${placeholders(rows, 3)})`,
[serverId, ...rows.flatMap((row) => [row.kind, row.subject, row.object])],
)
}
async function listDrift() {
return core.query(
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object,
d.first_seen AS firstSeen, d.last_seen AS lastSeen,
l.user_id AS userId, u.username, p.name AS playerName
FROM ${DRIFT} d
LEFT JOIN ${LINKS} l ON l.steam_id = d.subject
LEFT JOIN users u ON u.id = l.user_id
LEFT JOIN rust_players p ON p.steam_id = d.subject
ORDER BY d.server_id ASC, d.kind ASC, d.subject ASC`,
)
}
async function getDrift(id) {
const rows = await core.query(
`SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`,
[id],
)
return rows[0] || null
}
async function deleteDrift(id) {
await core.query(`DELETE FROM ${DRIFT} WHERE id = ?`, [id])
}
async function queueRevocation({ serverId, kind, subject, object, requestedBy }) {
await core.query(
`INSERT IGNORE INTO ${REVOCATIONS} (server_id, kind, subject, object, requested_by)
VALUES (?, ?, ?, ?, ?)`,
[serverId, kind, subject, object, requestedBy],
)
}
async function listRevocations(serverId) {
return core.query(
`SELECT id, kind, subject, object FROM ${REVOCATIONS} WHERE server_id = ?`,
[serverId],
)
}
async function deleteRevocations(ids) {
if (!ids.length) return
await core.query(
`DELETE FROM ${REVOCATIONS} WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
// ---- the state of the mirror ----
/**
* One sync row per configured server, created on demand.
*
* A server added today has no row and must not therefore be skipped for ever, so
* the read inserts what is missing rather than the writer remembering to.
*/
async function ensureSyncRows() {
await core.query(
`INSERT IGNORE INTO ${SYNC} (server_id) SELECT id FROM ${SERVERS}`,
)
}
async function listSync() {
return core.query(
`SELECT s.server_id AS serverId, s.state, s.dirty, s.desired_hash AS desiredHash,
s.synced_hash AS syncedHash, s.boot_id AS bootId, s.wipe_id AS wipeId,
s.last_attempt_at AS lastAttemptAt, s.last_ok_at AS lastOkAt,
s.report, s.error
FROM ${SYNC} s
ORDER BY s.server_id ASC`,
)
}
/**
* Mark servers as needing a sync.
*
* `scope` is a server id or `*`; a fleet-wide change dirties every row, which is
* right: the set each server should hold has changed even if only one of them
* will notice a difference.
*/
async function markDirty(scope) {
if (!scope || scope === '*') {
await core.query(`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP`)
return
}
await core.query(
`UPDATE ${SYNC} SET dirty = 1, updated_at = CURRENT_TIMESTAMP WHERE server_id = ?`,
[scope],
)
}
/**
* Record the outcome of one attempt.
*
* **`dirty` is cleared unconditionally, and that is safe because it is an
* optimisation rather than the truth.** Something may well have changed the
* authored set while this sync was in flight, and clearing the flag would then
* lose that change — except that the loop's real condition is
* `desired_hash != synced_hash`, recomputed from the tables on every tick. The
* flag only saves a hash comparison; the hash is what cannot be wrong.
*
* `last_ok_at` moves only on success, and it is passed rather than composed into
* the SQL so the statement is the same string every time.
*/
async function putSyncResult(serverId, { state, syncedHash, desiredHash, bootId, wipeId, report, error }) {
const okAt = state === 'ok' ? new Date() : null
await core.query(
`INSERT INTO ${SYNC} (server_id, state, dirty, desired_hash, synced_hash, boot_id, wipe_id,
last_attempt_at, last_ok_at, report, error, updated_at)
VALUES (?, ?, 0, ?, ?, ?, ?, NOW(), ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE state = VALUES(state), dirty = 0,
desired_hash = VALUES(desired_hash),
synced_hash = VALUES(synced_hash),
boot_id = VALUES(boot_id), wipe_id = VALUES(wipe_id),
last_attempt_at = NOW(),
last_ok_at = COALESCE(VALUES(last_ok_at), last_ok_at),
report = VALUES(report), error = VALUES(error),
updated_at = NOW()`,
[serverId, state, desiredHash, syncedHash, bootId, wipeId, okAt, report, error],
)
}
// ---- the option source ----
async function putCatalogue(serverId, permissions) {
await core.query(`DELETE FROM ${CATALOGUE} WHERE server_id = ?`, [serverId])
if (!permissions.length) return
await core.query(
`INSERT IGNORE INTO ${CATALOGUE} (server_id, permission)
VALUES ${placeholders(permissions, 2)}`,
permissions.flatMap((permission) => [serverId, permission]),
)
}
async function listCatalogue() {
return core.query(
`SELECT server_id AS serverId, permission FROM ${CATALOGUE} ORDER BY permission ASC`,
)
}
module.exports = {
GROUPS,
GRANTS,
RUN_GRANTS,
PUSHED,
DRIFT,
listGroups,
getGroup,
upsertGroup,
deleteGroup,
listGroupPermissions,
setGroupPermissions,
listGroupMembers,
addGroupMember,
removeGroupMember,
listGrants,
getGrant,
insertGrant,
deleteGrant,
listRunGrants,
listRunGrantsForStep,
insertRunGrants,
deleteRunGrantsForStep,
deleteRunGrantsForKey,
findUserByUsername,
listLinks,
listGroupsForUser,
listPushedForSteamIds,
listPushed,
addPushed,
removePushed,
replaceDrift,
listDrift,
getDrift,
deleteDrift,
queueRevocation,
listRevocations,
deleteRevocations,
ensureSyncRows,
listSync,
markDirty,
putSyncResult,
putCatalogue,
listCatalogue,
}