R2, and the first phase where this module WRITES to a game. Groups and grants are authored on the website and pushed into each server's own permission store, so every plugin that already calls `UserHasPermission` honours them with no adapter, and a wipe stops being a data-loss event. **Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and resolved to every Steam id they have linked at push time (D28); every authored row carries a scope — a server or `*` (D29); groups are mirrored as real groups rather than flattened (D30); a holder the site did not author is REPORTED, never undone, with adopt and revoke offered (D31); one verb, with the plugin diffing locally (D32); a permission no server has registered is reported unresolved and never self-registered (D33); authoring is people and groups by hand, with rules deferred (D34). **Three sets, and every interesting question is a difference between two.** `desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because the site put it there and has since withdrawn it; `present − desired` is drift. The middle one is why `rust_perm_pushed` exists: 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 two have opposite correct answers. **What lands is not what was sent.** A grant naming a permission the server has not registered did not land — `GrantUserPermission` no-ops silently — and a member the store has never seen could not be placed. Neither is recorded as pushed, so the site never believes it gave a privilege it did not. The loop asks a cheap question every thirty seconds — does the digest of the desired set still equal what this server last confirmed — and syncs on a change, a restart, a wipe, a drift hook, a failed attempt past its backoff, or the fifteen-minute audit that finds drift on a server nobody has touched. **This module's first admin page**, because a permission model is the first thing here that has to be composed rather than configured. What is on it is decided by what an operator can get wrong: four states are invisible from the game and from a list of grants, and each is a sentence rather than a number. Walked end to end against a real core at the pinned ref, the real sidecar, and a stand-in speaking protocol 4 — including a restart that emptied the store and was fully re-pushed. Four defects the browser found that 133 green tests did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
// ── Keeping a game's permission store equal to what the site authored ─────
|
|
//
|
|
// R2's whole mechanism, and it is chapter 4's board pointed the other way: the
|
|
// site is the single producer of a set, it re-sends the whole thing rather than
|
|
// a stream of edits, and the receiver reconciles. What is new is the direction —
|
|
// the module telling the game what the site knows, where every earlier phase
|
|
// asked the game what it knew.
|
|
//
|
|
// ── One verb (D32) ────────────────────────────────────────────────────────
|
|
//
|
|
// A sync sends the whole desired set and the plugin diffs it against the live
|
|
// store. The website never holds a copy of the game's permissions, which is the
|
|
// point: a second source of truth is stale the moment it lands, and the store is
|
|
// the bigger of the two sets.
|
|
//
|
|
// The delta the site DOES compute is the one the game cannot: what this site put
|
|
// there and has since withdrawn (`retirements`). A name in the store that is not
|
|
// in the desired set is either that, or a hand edit — and only the pushed ledger
|
|
// can tell them apart (D31).
|
|
//
|
|
// ── When it runs ──────────────────────────────────────────────────────────
|
|
//
|
|
// Every tick asks a cheap question — does the digest of the desired set still
|
|
// equal what this server last confirmed — and does nothing when the answer is
|
|
// yes. A sync therefore happens when:
|
|
//
|
|
// • an operator changed something (the dirty flag, and the digest behind it)
|
|
// • the game restarted or wiped (a new boot id or wipe id: the store may have
|
|
// been emptied, and R2's promise is that a wipe is not a data-loss event)
|
|
// • a permission hook fired in the game that we did not cause (`ingest.js`
|
|
// marks the server dirty; the authoritative answer is this sync's report)
|
|
// • the audit interval elapsed — the backstop that finds drift on a quiet
|
|
// server nobody has touched
|
|
// • the last attempt failed, after a backoff
|
|
//
|
|
// ── What it never does ────────────────────────────────────────────────────
|
|
//
|
|
// It does not remove a grant it did not make (D31), it does not invent a
|
|
// permission the server has not registered (D33), and it does not treat a
|
|
// silent sidecar as a reason to forget anything. A server that is unreachable
|
|
// keeps its retirements and its revocations until it comes back.
|
|
|
|
const core = require('./core')
|
|
|
|
const db = require('./model/permissions/permissions.db')
|
|
const model = require('./model/permissions/permissions.model')
|
|
const servers = require('./model/servers/servers.model')
|
|
const serversDb = require('./model/servers/servers.db')
|
|
const sidecar = require('./sidecarClient')
|
|
|
|
const log = core.logger('permissions')
|
|
|
|
/** How often the loop asks whether anything needs pushing. */
|
|
const TICK_MS = 30 * 1000
|
|
|
|
/**
|
|
* How long a server may go without a full reconciliation, however quiet it is.
|
|
*
|
|
* The digest comparison is what keeps the loop cheap, and on its own it would
|
|
* also mean a server whose store somebody edited by hand is never asked about
|
|
* again. This is the interval at which the question gets asked anyway.
|
|
*/
|
|
const AUDIT_MS = 15 * 60 * 1000
|
|
|
|
/** How long to leave a failing server alone before trying again. */
|
|
const FAIL_BACKOFF_MS = 2 * 60 * 1000
|
|
|
|
/**
|
|
* The most rows one sync may carry.
|
|
*
|
|
* Below the sidecar's line cap and below the plugin's operation ceiling, so the
|
|
* refusal happens here — where it can name the server and reach an operator —
|
|
* rather than as a `413` or a `too-large` from two processes away.
|
|
*/
|
|
const MAX_ROWS = 15000
|
|
|
|
let timer = null
|
|
|
|
function start() {
|
|
if (timer) return
|
|
|
|
timer = setInterval(() => {
|
|
tick().catch((err) => log.error('permission sync tick failed', { error: err.message }))
|
|
}, TICK_MS)
|
|
|
|
if (timer.unref) timer.unref()
|
|
}
|
|
|
|
function stop() {
|
|
if (!timer) return
|
|
|
|
clearInterval(timer)
|
|
timer = null
|
|
}
|
|
|
|
/**
|
|
* One pass over every enabled server.
|
|
*
|
|
* The authored set is read ONCE and handed to each server's build: six servers
|
|
* are six different answers derived from the same four tables, and re-reading
|
|
* them per server is six times the queries for identical rows.
|
|
*/
|
|
async function tick({ force = null } = {}) {
|
|
await db.ensureSyncRows()
|
|
|
|
const [rows, state, sync, authored] = await Promise.all([
|
|
servers.listForPolling(),
|
|
serversDb.listState(),
|
|
db.listSync(),
|
|
model.readAuthored(),
|
|
])
|
|
|
|
const syncById = new Map(sync.map((row) => [row.serverId, row]))
|
|
const stateById = new Map(state.map((row) => [row.serverId, row]))
|
|
|
|
// `allSettled`, for the same reason the board poll uses it: one unreachable
|
|
// host must not stop the other five being reconciled.
|
|
await Promise.allSettled(
|
|
rows
|
|
.filter((server) => force === null || force === server.id)
|
|
.map((server) =>
|
|
syncOne(server, {
|
|
authored,
|
|
sync: syncById.get(server.id) || null,
|
|
state: stateById.get(server.id) || null,
|
|
force: force !== null,
|
|
}),
|
|
),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Whether this server needs a push right now.
|
|
*
|
|
* Returns a reason rather than a boolean, because the reason is worth logging:
|
|
* "why did the website just write to my game server" is a question an operator
|
|
* asks, and `wipe` and `drift` are very different answers.
|
|
*/
|
|
function reasonToSync({ desiredHash, sync, state, force }) {
|
|
if (force) return 'requested'
|
|
if (!sync) return 'first'
|
|
if (sync.state !== 'ok' && sync.lastAttemptAt && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && !sync.dirty) {
|
|
return null
|
|
}
|
|
if (sync.state !== 'ok') return 'retry'
|
|
if (desiredHash !== sync.syncedHash) return 'changed'
|
|
if (sync.dirty) return 'dirty'
|
|
|
|
const bootId = state && state.bootId ? state.bootId : null
|
|
const wipeId = state && state.wipeId ? state.wipeId : null
|
|
|
|
// A restart or a wipe is the case R2 exists for: the game may have forgotten
|
|
// everything, and the site has not.
|
|
if (bootId && bootId !== sync.bootId) return 'restart'
|
|
if (wipeId && wipeId !== sync.wipeId) return 'wipe'
|
|
|
|
if (!sync.lastAttemptAt || age(sync.lastAttemptAt) >= AUDIT_MS) return 'audit'
|
|
|
|
return null
|
|
}
|
|
|
|
function age(value) {
|
|
const at = value instanceof Date ? value.getTime() : new Date(value).getTime()
|
|
return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER
|
|
}
|
|
|
|
async function syncOne(server, { authored, sync, state, force }) {
|
|
const desired = model.buildDesired(server.id, authored)
|
|
const reason = reasonToSync({ desiredHash: desired.hash, sync, state, force })
|
|
|
|
if (!reason) return null
|
|
|
|
const [pushed, revocations] = await Promise.all([
|
|
db.listPushed(server.id),
|
|
db.listRevocations(server.id),
|
|
])
|
|
|
|
const retirements = model.retirements(pushed, desired.rows)
|
|
const retire = [
|
|
...retirements.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
|
|
...revocations.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
|
|
]
|
|
|
|
const bootId = state && state.bootId ? state.bootId : null
|
|
const wipeId = state && state.wipeId ? state.wipeId : null
|
|
|
|
if (desired.rows.length + retire.length > MAX_ROWS) {
|
|
// Refused here rather than sent: the sidecar would answer `413` and the
|
|
// plugin would answer `too-large`, and neither of those messages reaches the
|
|
// person who has to make the set smaller.
|
|
const error = `the permission set is too large to push (${desired.rows.length + retire.length} rows, limit ${MAX_ROWS})`
|
|
log.error('permission sync refused', { server: server.id, rows: desired.rows.length })
|
|
await db.putSyncResult(server.id, {
|
|
state: 'failed',
|
|
desiredHash: desired.hash,
|
|
syncedHash: sync ? sync.syncedHash : null,
|
|
bootId,
|
|
wipeId,
|
|
report: null,
|
|
error,
|
|
})
|
|
|
|
return 'too-large'
|
|
}
|
|
|
|
log.info('syncing permissions', {
|
|
server: server.id,
|
|
reason,
|
|
rows: desired.rows.length,
|
|
retire: retire.length,
|
|
})
|
|
|
|
const result = await sidecar.permSync(server, {
|
|
setId: desired.hash,
|
|
groups: desired.payload.groups,
|
|
grants: desired.payload.grants,
|
|
managed: desired.payload.managed,
|
|
retire,
|
|
})
|
|
|
|
if (!result.ok) {
|
|
await db.putSyncResult(server.id, {
|
|
state: 'failed',
|
|
desiredHash: desired.hash,
|
|
syncedHash: sync ? sync.syncedHash : null,
|
|
bootId,
|
|
wipeId,
|
|
report: null,
|
|
error: result.status,
|
|
})
|
|
|
|
return result.status
|
|
}
|
|
|
|
const report = result.data || {}
|
|
|
|
// The plugin refuses a whole sync with `perm.error` — `busy` while an earlier
|
|
// one is still draining, `too-large` past its own ceiling. Both are answers
|
|
// rather than transport failures, exactly like a refused link code, so they
|
|
// arrive as a 200 and are told apart by `kind`.
|
|
if (report.kind === 'perm.error') {
|
|
await db.putSyncResult(server.id, {
|
|
state: 'failed',
|
|
desiredHash: desired.hash,
|
|
syncedHash: sync ? sync.syncedHash : null,
|
|
bootId,
|
|
wipeId,
|
|
report: null,
|
|
error: `the game refused the sync: ${report.reason || 'unknown'}`,
|
|
})
|
|
|
|
return report.reason || 'refused'
|
|
}
|
|
|
|
await applyReport(server, { desired, retire, report, bootId, wipeId })
|
|
|
|
return 'ok'
|
|
}
|
|
|
|
/**
|
|
* Record what the game said it did.
|
|
*
|
|
* Three writes, and the order matters only in that all three are safe to repeat:
|
|
* a sync that crashes here is re-run next tick and reaches the same place, which
|
|
* is the property that lets this loop be the only writer.
|
|
*/
|
|
async function applyReport(server, { desired, retire, report, bootId, wipeId }) {
|
|
const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
|
|
const pending = new Set(report.pending || [])
|
|
|
|
// A grant naming a permission this server has not registered did NOT land —
|
|
// `GrantUserPermission` no-ops silently for an unregistered name, which is
|
|
// why the plugin pre-checks and says so. Recording it as pushed would make the
|
|
// site believe it had given a privilege it had not.
|
|
//
|
|
// The same for a member the store could not place: the membership is waiting
|
|
// on their first connection, and it is not in the game yet.
|
|
const landed = desired.rows.filter((row) => {
|
|
if (row.kind === 'grant' || row.kind === 'group-permission') return !unresolved.has(row.object)
|
|
if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
|
|
return true
|
|
})
|
|
|
|
await db.addPushed(server.id, landed)
|
|
|
|
// Everything retired is gone from the game whether the plugin removed it or
|
|
// found it already absent, so it stops being something this site put there.
|
|
await db.removePushed(server.id, retire)
|
|
|
|
const revocations = await db.listRevocations(server.id)
|
|
await db.deleteRevocations(revocations.map((row) => row.id))
|
|
|
|
await db.replaceDrift(server.id, (report.foreign || []).map((row) => ({
|
|
kind: String(row.kind || ''),
|
|
subject: String(row.subject || ''),
|
|
object: String(row.object || ''),
|
|
})))
|
|
|
|
await db.putSyncResult(server.id, {
|
|
state: 'ok',
|
|
desiredHash: desired.hash,
|
|
syncedHash: desired.hash,
|
|
bootId,
|
|
wipeId,
|
|
report: JSON.stringify(report),
|
|
error: null,
|
|
})
|
|
|
|
// The option source, refreshed from the same server that just answered. It is
|
|
// a second round trip and it is worth it: the form must not offer a name that
|
|
// stopped being registered when somebody uninstalled a plugin, because a grant
|
|
// against one is a privilege nobody ever gets and nothing ever reports.
|
|
const catalogue = await sidecar.permCatalogue(server)
|
|
|
|
if (catalogue.ok && catalogue.data && Array.isArray(catalogue.data.permissions)) {
|
|
await db.putCatalogue(
|
|
server.id,
|
|
catalogue.data.permissions.map(model.normaliseName).filter(Boolean),
|
|
)
|
|
}
|
|
|
|
log.info('permissions synced', {
|
|
server: server.id,
|
|
applied: report.applied,
|
|
unresolved: (report.unresolved || []).length,
|
|
foreign: (report.foreign || []).length,
|
|
pending: (report.pending || []).length,
|
|
})
|
|
}
|
|
|
|
module.exports = {
|
|
TICK_MS,
|
|
AUDIT_MS,
|
|
FAIL_BACKOFF_MS,
|
|
MAX_ROWS,
|
|
start,
|
|
stop,
|
|
tick,
|
|
syncOne,
|
|
reasonToSync,
|
|
applyReport,
|
|
}
|