Files
Module-Rust/server/permSync.js
wtclaude e0d13e73db
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 7m58s
feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198)
PLAN_REDESIGNS section 1.

- Every sync reads the store (perm.inventory), reconciles it against the
  site's record and its ledger, and pushes. A change made in the game is
  settled by the server's policy (D161): auto-adopt (default), adopt, or
  revoke. The first read of a server imports everything (D198).
- Groups belong to one server unless an admin shares them (D189), in new
  id-keyed tables; the old ones are copied once at boot and left unread.
  Holders may be a Steam account nobody linked (D188).
- An in-game change affects that server only (D190): a grant that reaches
  further gains an exception, a shared group is split.
- Never judged: a permission the server does not register right now (an
  unloaded plugin is not a revocation), and a pair an event lease holds.
- A new admin API (server view, grant/revoke with everywhere-or-here,
  groups by id, share/split, members, drift answers) and a screen on
  PermissionsManager's flow with a state on every toggle (D162, D163, U-1).
- The announcement voice names a group by id; old name settings still read.

Walked on both rigs against the walk core: import on an existing install,
auto-adopt of a grant and a revoke, a fleet grant's exception, Kits
unloaded without loss, a shared group split, adopt and revoke policies.
Server 420/420, client 58/58, swagger, imports and route manifest current.

Refs #21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-28 06:58:59 -05:00

527 lines
20 KiB
JavaScript

// ── Keeping a game's permission store and the site's record of it equal ───
//
// R2's whole mechanism, rebuilt in protocol 13 (PLAN_REDESIGNS §1) around one
// fact: **the site owns every permission and group on the server** — those that
// were there before it, those an admin makes, and those changed in the game
// (D160). So a sync is three steps, not one:
//
// 1. READ the whole store (`perm.inventory`), in pages, with the plugin that
// registered each permission.
// 2. RECONCILE it against the site's record and what the site last pushed
// (`reconcile.js`): a change made in the game becomes the site's own, a
// question for a person, or undone — the server's policy says which (D161).
// The first read of a server imports everything it finds (D198).
// 3. PUSH the whole desired set (`perm.sync`, D32) with what the site has
// withdrawn, and record what the plugin says landed.
//
// Step 2 writes to the site's own tables, and a change in one game affects that
// server only (D190) — which can split a group shared with other servers. So it
// runs under ONE lock for the whole fleet: two servers' reconciles never split
// the same shared group at once. Steps 1 and 3 run in parallel across servers.
//
// ── 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)
// • a permission hook fired in the game that we did not cause (`ingest.js`
// marks the server dirty; the inventory then says what changed)
// • the audit interval elapsed — the backstop that finds a hand edit on a quiet
// server
// • the last attempt failed, after a backoff
//
// ── What it never does ────────────────────────────────────────────────────
//
// It never judges a permission the server has not registered right now (a
// plugin unloaded for a minute is not a revocation), never touches what an event
// lease holds, never invents a permission the server has not registered (D33),
// and never treats a silent sidecar as a reason to forget anything.
const core = require('./core')
const apply = require('./model/permissions/permissions.apply')
const db = require('./model/permissions/permissions.db')
const model = require('./model/permissions/permissions.model')
const reconcile = require('./model/permissions/reconcile')
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 interval at which a hand edit on a quiet server is found 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 the
* plugin's operation ceiling, so the refusal happens here, where it can name the
* server and reach an operator.
*/
const MAX_ROWS = 15000
/** The policies a server may have (D161). */
const POLICIES = ['auto-adopt', 'adopt', 'revoke']
let timer = null
/** The fleet-wide lock the reconcile step runs under (see the header). */
let lock = Promise.resolve()
function withLock(fn) {
const run = lock.then(fn, fn)
lock = run.catch(() => {})
return run
}
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 here, for the cheap "does anything need doing"
* digest. The reconcile re-reads it under the lock, because another server's
* reconcile may have changed it in between.
*/
async function tick({ force = null } = {}) {
await db.ensureSyncRows()
const [rows, state, sync, authored, policyRows] = await Promise.all([
servers.listForPolling(),
serversDb.listState(),
db.listSync(),
model.readAuthored(),
db.listPolicies(),
])
const syncById = new Map(sync.map((row) => [row.serverId, row]))
const stateById = new Map(state.map((row) => [row.serverId, row]))
const policies = new Map(policyRows.map((row) => [row.serverId, row.policy]))
// `allSettled`: one unreachable host must not stop the others.
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,
policy: policies.get(server.id) || 'auto-adopt',
}),
),
)
}
/**
* 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'
// Not while the world is loading (PLAN_FIXES F7). The plugin connects before
// the save loads, and the first walk's restart sync went 35 s before "Server
// startup complete", timed out behind the busy main thread, and was retried
// 2.5 minutes later as "0 applied" — so nothing said what the restart had
// restored. The plugin says `worldReady: true` in the hello it sends the moment
// the world is up, and the sync goes on the next tick. A human's "sync now" is
// not held: they asked, and a failure then is theirs to read.
if (state && state.worldReady === false) return null
// Nor while the game is not connected (F7, the step-2 walk). The stored hello is
// the LAST one: between a restart and the poll that reads the new boot's hello,
// it still says the old world is ready. On Carbon a due audit went out in that
// gap, 80 s before "Server startup complete". The poll marks the server offline
// the moment it goes away, so offline holds until a fresh hello says otherwise.
// Unknown (`online` absent) is not held — a state row always carries it.
if (state && state.online !== undefined && state.online !== null && !Number(state.online)) return null
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
}
/** Record a failed attempt, with the reason on the row and in the log (F7). */
async function failed(server, { reason, error, desiredHash, sync, bootId, wipeId }) {
log.warn('permission sync failed', { server: server.id, reason, error })
await db.putSyncResult(server.id, {
state: 'failed',
desiredHash,
syncedHash: sync ? sync.syncedHash : null,
bootId,
wipeId,
report: null,
error: String(error).slice(0, 191),
})
}
/**
* Read, reconcile and push one server. Returns what happened, for the log and
* the tests: null (nothing to do), 'ok', or why it stopped.
*/
async function syncOne(server, { authored, sync, state, force, policy = 'auto-adopt' }) {
const first = model.buildDesired(server.id, authored)
const reason = reasonToSync({ desiredHash: first.hash, sync, state, force })
if (!reason) return null
const bootId = state && state.bootId ? state.bootId : null
const wipeId = state && state.wipeId ? state.wipeId : null
// ── 1. Read the whole store ────────────────────────────────────────────
const read = await sidecar.readInventory(server)
if (!read.ok) {
await failed(server, { reason, error: read.error, desiredHash: first.hash, sync, bootId, wipeId })
return 'inventory'
}
const { inventory } = read
const registered = new Set(inventory.permissions.map((row) => model.normaliseName(row.name)).filter(Boolean))
// The option source, from the same read: what this server registers, and which
// plugin registered each name (PLAN_REDESIGNS §0.1).
await db.putCatalogue(
server.id,
inventory.permissions
.map((row) => ({ permission: model.normaliseName(row.name), owner: row.owner || null }))
.filter((row) => row.permission),
)
const importing = !(sync && sync.importedAt)
// ── 2. Reconcile, under the fleet lock ─────────────────────────────────
const settled = await withLock(async () => {
let current = await model.readAuthored()
let desired = model.buildDesired(server.id, current)
const pushed = await db.listPushed(server.id)
const classes = reconcile.classify({
present: reconcile.presentRows(inventory),
pushed,
desired: desired.rows,
registered,
leased: inventory.leased,
})
const planned = reconcile.plan({ classes, policy, importing, sources: desired.sources })
if (planned.ops.length) {
const done = await apply.applyOps(server.id, planned.ops, log)
log.info(importing ? 'permissions imported from the game' : 'changes made in the game adopted', {
server: server.id,
policy,
ops: done.length,
})
current = await model.readAuthored()
desired = model.buildDesired(server.id, current)
}
for (const row of planned.revocations) {
await db.queueRevocation({ serverId: server.id, ...row, requestedBy: null })
}
return { desired, planned, classes }
})
const { desired, planned } = settled
const held = reconcile.withHold(desired, planned.hold)
// ── 3. Push ────────────────────────────────────────────────────────────
const [pushed, revocations] = await Promise.all([db.listPushed(server.id), db.listRevocations(server.id)])
// Retired against the WHOLE desired set: a held row is still wanted, it is
// only not being pushed back while a person decides.
const retirements = model.retirements(pushed, desired.rows, planned.hold)
const { styleRetired, sent: styleRetire } = styleRetirements(retirements)
const retire = [
...retirements
.filter((row) => row.kind !== 'chat-field')
.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
...styleRetire,
...revocations.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
]
if (held.rows.length + retire.length > MAX_ROWS) {
await failed(server, {
reason,
error: `the permission set is too large to push (${held.rows.length + retire.length} rows, limit ${MAX_ROWS})`,
desiredHash: desired.hash,
sync,
bootId,
wipeId,
})
return 'too-large'
}
log.info('syncing permissions', {
server: server.id,
reason,
policy,
importing,
rows: held.rows.length,
retire: retire.length,
held: planned.hold.size,
})
const result = await sidecar.permSync(server, {
setId: desired.hash,
groups: withExpect(held.payload.groups, pushed),
grants: held.payload.grants,
credits: held.payload.credits,
retire,
})
if (!result.ok) {
await failed(server, { reason, error: result.status, desiredHash: desired.hash, sync, bootId, wipeId })
return result.status
}
const report = result.data || {}
// `perm.error` (`busy`, `too-large`) is an answer, not a transport failure.
if (report.kind === 'perm.error') {
await failed(server, {
reason,
error: `the game refused the sync: ${report.reason || 'unknown'}`,
desiredHash: desired.hash,
sync,
bootId,
wipeId,
})
return report.reason || 'refused'
}
await applyReport(server, {
desired: held,
hash: desired.hash,
retire,
styleRetired,
report,
bootId,
wipeId,
drift: planned.drift,
})
if (importing) await db.markImported(server.id)
return 'ok'
}
/**
* The groups as the wire carries them: each style field with the value this
* site last pushed to THIS server (`expect`), or null for one it never has.
* The plugin writes a field only when the game still holds that, so a field
* somebody changed by hand is reported instead of overwritten (D138).
*/
function withExpect(groups, pushed) {
const expect = new Map(
pushed.filter((row) => row.kind === 'chat-field').map((row) => [`${row.subject} ${row.object}`, row.value]),
)
return groups.map((group) => {
if (!group.chat) return group
const chat = {}
for (const [field, value] of Object.entries(group.chat)) {
const last = expect.get(`${group.name} ${field}`)
chat[field] = { value, expect: last === undefined ? null : last }
}
return { ...group, chat }
})
}
/**
* Style fields the site pushed and no longer wants, as the wire says it: ONE
* `chat-group` retirement per group, because a style is all twelve fields or
* none, and the only way to take one out of BetterChat is to remove its group
* (D139).
*
* **`default` is never removed.** It is BetterChat's fallback, which it warns
* about on every line when it is missing; a style withdrawn from the site's
* `default` group stops being pushed and is left as it stands (§33.5).
*/
function styleRetirements(retirements) {
const styleRetired = retirements.filter((row) => row.kind === 'chat-field')
const groups = [...new Set(styleRetired.map((row) => row.subject))].filter((name) => name !== 'default')
return { styleRetired, sent: groups.map((name) => ({ kind: 'chat-group', subject: name, object: '' })) }
}
/**
* Record what the game said it did, and what waits for a person.
*
* Every write is safe to repeat: a sync that crashes here is re-run next tick and
* reaches the same place.
*/
async function applyReport(server, { desired, hash, retire, styleRetired = [], report, bootId, wipeId, drift = [] }) {
const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
const pending = new Set(report.pending || [])
// Grants the plugin made and then did not find when it read them back (D85);
// since protocol 13 also `group:parent:name` for a parent that would not set.
const notLanded = new Set((report.notLanded || []).map((entry) => String(entry).toLowerCase()))
// Protocol 12. A style field landed when BetterChat was there to take it and
// the report names it neither drift nor failed (§33.2).
const chat = report.chat && typeof report.chat === 'object' ? report.chat : null
const chatLoaded = Boolean(chat && chat.loaded === true)
const fieldKey = (group, field) => `${group} ${String(field || '').toLowerCase()}`
const chatHeld = new Set([
...((chatLoaded && chat.drift) || []).map((row) => fieldKey(row.group, row.field)),
...((chatLoaded && chat.failed) || []).filter((row) => row.field).map((row) => fieldKey(row.group, row.field)),
])
const chatGroupFailed = new Set(((chatLoaded && chat.failed) || []).filter((row) => !row.field).map((row) => row.group))
const landed = desired.rows.filter((row) => {
if (row.kind === 'chat-field') {
return chatLoaded && !chatGroupFailed.has(row.subject) && !chatHeld.has(fieldKey(row.subject, row.object))
}
if (row.kind === 'grant' || row.kind === 'group-permission') {
return !unresolved.has(row.object) && !notLanded.has(`${row.subject}:${row.object}`.toLowerCase())
}
if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
if (row.kind === 'group') {
// The value landed only if the parent did; a group whose parent would not
// set is recorded without a value, so the next sync pushes it again.
if ([...notLanded].some((entry) => entry.startsWith(`${row.subject}:parent:`))) {
row.value = null
}
}
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. A `chat-group` is not a ledger row; its fields are.
await db.removePushed(server.id, retire.filter((row) => row.kind !== 'chat-group'))
// A style's fields leave the ledger only once BetterChat has removed the group,
// or for `default`, which is never removed (D139).
const removed = new Set((chatLoaded && chat.removed) || [])
await db.removePushed(
server.id,
styleRetired.filter((row) => row.subject === 'default' || removed.has(row.subject)),
)
const revocations = await db.listRevocations(server.id)
await db.deleteRevocations(revocations.map((row) => row.id))
// What waits for a person: the reconcile's (D161's `adopt`, and an event's
// grant removed in the game), and a style field somebody changed by hand (D138).
await db.replaceDrift(server.id, [
...drift.map((row) => ({
kind: String(row.kind || ''),
subject: String(row.subject || ''),
object: String(row.object || ''),
detail: row.detail === undefined || row.detail === null ? null : String(row.detail).slice(0, 255),
direction: row.direction || 'added',
})),
...((chatLoaded && chat.drift) || []).map((row) => ({
kind: 'chat-field',
subject: String(row.group || ''),
object: String(row.field || ''),
detail: row.game === undefined || row.game === null ? null : String(row.game).slice(0, 255),
direction: 'changed',
})),
])
await db.putSyncResult(server.id, {
state: 'ok',
desiredHash: hash,
syncedHash: hash,
bootId,
wipeId,
report: JSON.stringify(report),
error: null,
})
log.info('permissions synced', {
server: server.id,
applied: report.applied,
unresolved: (report.unresolved || []).length,
pending: (report.pending || []).length,
notLanded: (report.notLanded || []).length,
waiting: drift.length,
...(chat
? {
betterChat: chatLoaded,
...(chatLoaded
? { styleApplied: chat.applied, styleSaved: chat.saved, styleDrift: (chat.drift || []).length, styleFailed: (chat.failed || []).length }
: {}),
}
: {}),
...(report.creditsApplied !== undefined
? { creditsApplied: report.creditsApplied, creditsWithdrawn: report.creditsWithdrawn }
: {}),
})
}
module.exports = {
TICK_MS,
AUDIT_MS,
FAIL_BACKOFF_MS,
MAX_ROWS,
POLICIES,
start,
stop,
tick,
syncOne,
reasonToSync,
applyReport,
withExpect,
styleRetirements,
withLock,
}