// ── Carrying out what the reconciler decided ────────────────────────────── // // `reconcile.plan` says WHAT a change made in the game becomes; this file writes // it into the site's own record. Every write here is about ONE server (D190): a // change in one game affects that server and nothing else, even when the site's // row reaches further. // // • A grant that reaches only this server is deleted or written outright. // • A grant that reaches more (a fleet grant, or a user's grant scoped `*`) // gains an EXCEPTION for this server, and keeps reaching every other one. // • A group shared with other servers is SPLIT: this server gets its own copy, // the change is made to the copy, and the shared group stops covering it. A // notice says so, in case the change was meant for every server. // // Ops are applied one at a time and each re-reads what it needs, because an // earlier op in the same plan may have split the group a later one writes to. // `permSync` runs a plan under one lock for the whole fleet, so two servers' // plans never split the same shared group at once. const db = require('./permissions.db') const model = require('./permissions.model') /** The site's group of this name on this server, or null (D189). */ async function groupOn(name, serverId) { const [groups, groupServers] = await Promise.all([db.listGroups(), db.listGroupServers()]) return model.groupsOn(serverId, { groups, groupServers }).find((group) => group.name === name) || null } /** * The group of this name that belongs to THIS server alone, splitting a shared * one if that is what covers it (D190). Null when the site has no such group. */ async function ownGroup(name, serverId) { const group = await groupOn(name, serverId) if (!group) return null const groupServers = await db.listGroupServers() if (!model.isShared(group, model.serversByGroup(groupServers))) return group const copy = await db.copyGroup(group.id, 'split') await db.setGroupServers(copy, { allServers: false, servers: [serverId] }) await db.removeGroupFromServer(group.id, serverId) await db.noteSplit(serverId, { group: name, detail: `changed in the game on ${serverId}; that server now has its own copy of "${name}"`, }) return db.getGroup(copy) } /** The Steam ids and user linked to one Steam id, for finding a user's grant. */ async function userOf(steamId) { const links = await db.listLinks() const link = links.find((row) => row.steamId === steamId) return link ? link.userId : null } /** * A grant the game holds and the site does not. If a grant that reaches this * server was only kept off it by an EXCEPTION, the exception is what the game * just undid, so the exception goes. Otherwise a Steam-account grant for this * server alone is written (D188, D190). */ async function adoptGrant(serverId, { steamId, permission, source }) { const exceptions = (await db.listExceptions()).filter((e) => e.serverId === serverId) if (exceptions.length) { const userId = await userOf(steamId) const [userGrants, steamGrants] = await Promise.all([ userId === null ? [] : db.listGrants({ userId }), db.listSteamGrants({ steamId }), ]) const candidates = [ ...userGrants.map((g) => ({ holder: 'user', id: g.id, permission: g.permission, scope: g.scope })), ...steamGrants.map((g) => ({ holder: 'steam', id: g.id, permission: g.permission, scope: g.scope })), ].filter((g) => model.normaliseName(g.permission) === permission && model.inScope(g.scope, serverId)) for (const grant of candidates) { const exception = exceptions.find((e) => e.holder === grant.holder && Number(e.grantId) === Number(grant.id)) if (exception) { await db.deleteException(exception.id) return } } } await db.insertSteamGrant({ steamId, permission, scope: serverId, source }) } /** * A grant the site holds and the game no longer does. Each source that put it * on this server stops doing so: one scoped to this server alone is deleted; one * that reaches further gains an exception here. An event's grant is left to the * event (the reconciler never sends one here). */ async function dropGrant(serverId, { sources = [] }) { for (const source of sources) { if (source.type !== 'userGrant' && source.type !== 'steamGrant') continue const holder = source.type === 'userGrant' ? 'user' : 'steam' if (source.scope === serverId) { if (holder === 'user') await db.deleteGrant(source.id) else await db.deleteSteamGrant(source.id) } else { await db.addException({ holder, grantId: source.id, serverId }) } } } /** Run one op. Returns a short line for the log. */ async function applyOp(serverId, op) { switch (op.op) { case 'adoptGroup': { // A group of this name may already exist on another server, or be shared // with every server but this one: either way this server gets its own. const existing = await groupOn(op.name, serverId) if (existing) return `group ${op.name}: already the site's` const id = await db.insertGroup({ name: op.name, title: op.title, rank: op.rank, parent: op.parent, source: op.source }) await db.setGroupServers(id, { allServers: false, servers: [serverId] }) return `group ${op.name}: adopted` } case 'setGroupAttrs': { const group = await ownGroup(op.group, serverId) if (!group) return `group ${op.group}: not the site's` await db.updateGroup(group.id, { title: op.title, rank: op.rank, parent: op.parent }) return `group ${op.group}: title, rank and parent from the game` } case 'adoptGroupPermission': { const group = await ownGroup(op.group, serverId) if (!group) return `group ${op.group}: not the site's` await db.addGroupPermission(group.id, op.permission) return `group ${op.group} + ${op.permission}` } case 'dropGroupPermission': { const group = await ownGroup(op.group, serverId) if (!group) return `group ${op.group}: not the site's` await db.removeGroupPermission(group.id, op.permission) return `group ${op.group} − ${op.permission}` } case 'adoptMember': { const group = await ownGroup(op.group, serverId) if (!group) return `group ${op.group}: not the site's` await db.addGroupSteamMember(group.id, op.steamId, { source: op.source }) return `${op.steamId} in ${op.group}` } case 'dropMember': { const group = await ownGroup(op.group, serverId) if (!group) return `group ${op.group}: not the site's` // Whichever way the site had them in it: as a Steam account, and as the // website account that account is linked to. await db.removeGroupSteamMember(group.id, op.steamId) const userId = await userOf(op.steamId) if (userId !== null) await db.removeGroupMember(group.id, userId) return `${op.steamId} out of ${op.group}` } case 'adoptGrant': await adoptGrant(serverId, op) return `${op.steamId} + ${op.permission}` case 'dropGrant': await dropGrant(serverId, op) return `${op.steamId} − ${op.permission}` case 'dropGroup': { const group = await groupOn(op.group, serverId) if (!group) return `group ${op.group}: not the site's` const groupServers = await db.listGroupServers() if (model.isShared(group, model.serversByGroup(groupServers))) { await db.removeGroupFromServer(group.id, serverId) return `group ${op.group}: no longer on ${serverId}` } await db.deleteGroup(group.id) return `group ${op.group}: deleted` } default: return `unknown op ${op.op}` } } /** Run a plan's ops in order. One op failing does not stop the rest. */ async function applyOps(serverId, ops, log = null) { const done = [] for (const op of ops) { try { // eslint-disable-next-line no-await-in-loop done.push(await applyOp(serverId, op)) } catch (err) { done.push(`${op.op} failed: ${err.message}`) if (log) log.warn('permission op failed', { server: serverId, op: op.op, error: err.message }) } } return done } module.exports = { groupOn, ownGroup, applyOp, applyOps }