// ── 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 GROUP_CHAT = 'rust_perm_group_chat' 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 group's BetterChat style, one row per field (phase 17, D138). */ async function listGroupChat() { return core.query( `SELECT group_name AS groupName, field, value FROM ${GROUP_CHAT} ORDER BY group_name ASC, field ASC`, ) } /** * Replace a group's style whole, or remove it with `null`. A style is all twelve * fields or none, and the form edits it as one thing. */ async function setGroupChat(name, fields) { await core.query(`DELETE FROM ${GROUP_CHAT} WHERE group_name = ?`, [name]) const entries = fields ? Object.entries(fields) : [] if (!entries.length) return await core.query( `INSERT INTO ${GROUP_CHAT} (group_name, field, value) VALUES ${placeholders(entries, 3)}`, entries.flatMap(([field, value]) => [name, field, value]), ) } /** One field of a style, for adopting a hand edit. Returns whether the group has that field. */ async function setGroupChatField(name, field, value) { const result = await core.query( `UPDATE ${GROUP_CHAT} SET value = ? WHERE group_name = ? AND field = ?`, [value, name, field], ) return Number(result.affectedRows || 0) > 0 } async function getGroupChat(name) { const rows = await core.query(`SELECT field, value FROM ${GROUP_CHAT} WHERE group_name = ?`, [name]) return rows.length ? Object.fromEntries(rows.map((r) => [r.field, r.value])) : null } /** * 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, value FROM ${PUSHED} WHERE server_id = ?`, [serverId], ) } /** * Record rows as landed. A `chat-field` row carries the VALUE that landed, and a * second landing of the same field moves it: the value is what the next sync * tells a hand edit from this site's own write by (§33.2). Every other kind has * no value and is written once. */ async function addPushed(serverId, rows) { if (!rows.length) return await core.query( `INSERT INTO ${PUSHED} (server_id, kind, subject, object, value) VALUES ${placeholders(rows, 5)} ON DUPLICATE KEY UPDATE value = VALUES(value)`, rows.flatMap((row) => [serverId, row.kind, row.subject, row.object, row.value === undefined ? null : row.value]), ) } /** * Say that a style field holds `value` in one game as far as this site is * concerned. It is how a person revokes a hand edit to a style: the next sync * sends the site's value with this as what it expects to find, which is the * game's own value — so the plugin writes over it, on purpose (§33.2). */ async function setPushedValue(serverId, { kind, subject, object, value }) { await addPushed(serverId, [{ kind, subject, object, value }]) } 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, detail) VALUES ${placeholders(rows, 5)} ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP, detail = VALUES(detail)`, rows.flatMap((row) => [serverId, row.kind, row.subject, row.object, row.detail === undefined ? null : row.detail]), ) // 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.detail, 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, detail 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, listGroupChat, setGroupChat, setGroupChatField, getGroupChat, listGroupMembers, addGroupMember, removeGroupMember, listGrants, getGrant, insertGrant, deleteGrant, listRunGrants, listRunGrantsForStep, insertRunGrants, deleteRunGrantsForStep, deleteRunGrantsForKey, findUserByUsername, listLinks, listGroupsForUser, listPushedForSteamIds, listPushed, addPushed, setPushedValue, removePushed, replaceDrift, listDrift, getDrift, deleteDrift, queueRevocation, listRevocations, deleteRevocations, ensureSyncRows, listSync, markDirty, putSyncResult, putCatalogue, listCatalogue, }