feat(rust): the permission manager — the site owns the whole store (D160-D163, D188-D198) #25
@@ -150,37 +150,48 @@ export const admin = {
|
|||||||
// A write is followed by a re-read rather than a local edit of the model: what
|
// A write is followed by a re-read rather than a local edit of the model: what
|
||||||
// the screen is showing is partly the game's answer, and the honest way to learn
|
// the screen is showing is partly the game's answer, and the honest way to learn
|
||||||
// the new one is to ask.
|
// the new one is to ask.
|
||||||
|
const P = '/admin/rust/permissions'
|
||||||
|
const enc = encodeURIComponent
|
||||||
|
|
||||||
export const adminPermissions = {
|
export const adminPermissions = {
|
||||||
overview: () => req('/admin/rust/permissions'),
|
overview: () => req(P),
|
||||||
catalogue: () => req('/admin/rust/permissions/catalogue'),
|
catalogue: () => req(`${P}/catalogue`),
|
||||||
|
|
||||||
saveGroup: (name, body) =>
|
// One server, as PermissionsManager shows one (D162).
|
||||||
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'PUT', body }),
|
server: (serverId) => req(`${P}/servers/${enc(serverId)}`),
|
||||||
deleteGroup: (name) =>
|
players: (serverId, q) => req(`${P}/servers/${enc(serverId)}/players?q=${enc(q || '')}`),
|
||||||
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
setPolicy: (serverId, policy) => req(`${P}/servers/${enc(serverId)}/policy`, { method: 'PUT', body: { policy } }),
|
||||||
|
|
||||||
addMember: (name, username) =>
|
// A subject is `{ steamId }` or `{ userId }`; `everywhere` reaches every server.
|
||||||
req(`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members`, {
|
grant: (serverId, subject, permissions, everywhere = false) =>
|
||||||
method: 'POST',
|
req(`${P}/servers/${enc(serverId)}/grant`, { method: 'POST', body: { ...subject, permissions, everywhere } }),
|
||||||
body: { username },
|
revoke: (serverId, subject, permissions, everywhere = false) =>
|
||||||
}),
|
req(`${P}/servers/${enc(serverId)}/revoke`, { method: 'POST', body: { ...subject, permissions, everywhere } }),
|
||||||
removeMember: (name, userId) =>
|
removeException: (id) => req(`${P}/exceptions/${enc(id)}`, { method: 'DELETE' }),
|
||||||
req(
|
|
||||||
`/admin/rust/permissions/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(userId)}`,
|
|
||||||
{ method: 'DELETE' },
|
|
||||||
),
|
|
||||||
|
|
||||||
grant: (body) => req('/admin/rust/permissions/grants', { method: 'POST', body }),
|
// Groups by id (D189). `here` is `{ onlyHere: true, serverId }` to split a
|
||||||
revoke: (id) =>
|
// shared group's copy off first (D190), or null to change it everywhere.
|
||||||
req(`/admin/rust/permissions/grants/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
createGroup: (serverId, body) => req(`${P}/servers/${enc(serverId)}/groups`, { method: 'POST', body }),
|
||||||
|
updateGroup: (id, body, here = null) => req(`${P}/groups/${enc(id)}`, { method: 'PATCH', body: { ...body, ...(here || {}) } }),
|
||||||
|
deleteGroup: (id) => req(`${P}/groups/${enc(id)}`, { method: 'DELETE' }),
|
||||||
|
setGroupPermissions: (id, permissions, here = null) =>
|
||||||
|
req(`${P}/groups/${enc(id)}/permissions`, { method: 'PUT', body: { permissions, ...(here || {}) } }),
|
||||||
|
setGroupServers: (id, body) => req(`${P}/groups/${enc(id)}/servers`, { method: 'PUT', body }),
|
||||||
|
splitGroup: (id, serverId) => req(`${P}/groups/${enc(id)}/split`, { method: 'POST', body: { serverId } }),
|
||||||
|
addMember: (id, subject, here = null) =>
|
||||||
|
req(`${P}/groups/${enc(id)}/members`, { method: 'POST', body: { ...subject, ...(here || {}) } }),
|
||||||
|
removeMember: (id, subject, here = null) =>
|
||||||
|
req(`${P}/groups/${enc(id)}/members/remove`, { method: 'POST', body: { ...subject, ...(here || {}) } }),
|
||||||
|
clearMembers: (id, here = null) => req(`${P}/groups/${enc(id)}/members/clear`, { method: 'POST', body: { ...(here || {}) } }),
|
||||||
|
|
||||||
adoptDrift: (id) =>
|
// What waits for a person (D161).
|
||||||
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/adopt`, { method: 'POST' }),
|
adoptDrift: (id) => req(`${P}/drift/${enc(id)}/adopt`, { method: 'POST' }),
|
||||||
revokeDrift: (id) =>
|
revokeDrift: (id) => req(`${P}/drift/${enc(id)}/revoke`, { method: 'POST' }),
|
||||||
req(`/admin/rust/permissions/drift/${encodeURIComponent(id)}/revoke`, { method: 'POST' }),
|
acceptDrift: (id) => req(`${P}/drift/${enc(id)}/accept`, { method: 'POST' }),
|
||||||
|
restoreDrift: (id) => req(`${P}/drift/${enc(id)}/restore`, { method: 'POST' }),
|
||||||
|
dismissDrift: (id) => req(`${P}/drift/${enc(id)}/dismiss`, { method: 'POST' }),
|
||||||
|
|
||||||
sync: (serverId = null) =>
|
sync: (serverId = null) => req(`${P}/sync`, { method: 'POST', body: serverId ? { serverId } : {} }),
|
||||||
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── admin · visibility ────────────────────────────────────────────────────
|
// ── admin · visibility ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -196,13 +196,15 @@ export function VoiceCard() {
|
|||||||
Say them as
|
Say them as
|
||||||
<select value={data.voice} onChange={(e) => choose(e.target.value)} disabled={busy} style={inputStyle}>
|
<select value={data.voice} onChange={(e) => choose(e.target.value)} disabled={busy} style={inputStyle}>
|
||||||
<option value="">Plain chat</option>
|
<option value="">Plain chat</option>
|
||||||
{data.options.map((o) => <option key={o.group} value={o.group}>{o.group} — {o.title}</option>)}
|
{data.options.map((o) => (
|
||||||
|
<option key={o.group} value={o.group}>{o.name} — {o.title} ({o.where})</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{data.voice && !current && (
|
{data.voice && !current && (
|
||||||
<p style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
<p style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||||||
The group “{data.voice}” no longer has a chat style, so lines are said in plain chat until it has one again or
|
The chosen group no longer has a chat style, so lines are said in plain chat until it has one again or another
|
||||||
another voice is chosen.
|
voice is chosen.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{current && <p className="dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>The line: <code>{current.format}</code></p>}
|
{current && <p className="dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>The line: <code>{current.format}</code></p>}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,17 +3,12 @@
|
|||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
"method": "DELETE",
|
"method": "DELETE",
|
||||||
"path": "/api/v1/admin/rust/permissions/grants/:id",
|
"path": "/api/v1/admin/rust/permissions/exceptions/:id",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "DELETE",
|
"method": "DELETE",
|
||||||
"path": "/api/v1/admin/rust/permissions/groups/:name",
|
"path": "/api/v1/admin/rust/permissions/groups/:id",
|
||||||
"tier": "public"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "DELETE",
|
|
||||||
"path": "/api/v1/admin/rust/permissions/groups/:name/members/:userId",
|
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,6 +61,16 @@
|
|||||||
"path": "/api/v1/admin/rust/permissions/catalogue",
|
"path": "/api/v1/admin/rust/permissions/catalogue",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/servers/:serverId",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/servers/:serverId/players",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/rust/servers",
|
"path": "/api/v1/admin/rust/servers",
|
||||||
@@ -166,16 +171,36 @@
|
|||||||
"path": "/api/v1/public/rust/servers/:id/wipes",
|
"path": "/api/v1/public/rust/servers/:id/wipes",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/groups/:id",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/rust/config/:serverId/file",
|
"path": "/api/v1/admin/rust/config/:serverId/file",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/drift/:id/accept",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/rust/permissions/drift/:id/adopt",
|
"path": "/api/v1/admin/rust/permissions/drift/:id/adopt",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/drift/:id/dismiss",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/drift/:id/restore",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/rust/permissions/drift/:id/revoke",
|
"path": "/api/v1/admin/rust/permissions/drift/:id/revoke",
|
||||||
@@ -183,12 +208,37 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/rust/permissions/grants",
|
"path": "/api/v1/admin/rust/permissions/groups/:id/members",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/rust/permissions/groups/:name/members",
|
"path": "/api/v1/admin/rust/permissions/groups/:id/members/clear",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/groups/:id/members/remove",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/groups/:id/split",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/servers/:serverId/grant",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/servers/:serverId/groups",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/servers/:serverId/revoke",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -223,7 +273,17 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/rust/permissions/groups/:name",
|
"path": "/api/v1/admin/rust/permissions/groups/:id/permissions",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/groups/:id/servers",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/rust/permissions/servers/:serverId/policy",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const eventWorld = require('./eventWorld')
|
|||||||
const ingest = require('./ingest')
|
const ingest = require('./ingest')
|
||||||
const mapImages = require('./mapImages')
|
const mapImages = require('./mapImages')
|
||||||
const permSync = require('./permSync')
|
const permSync = require('./permSync')
|
||||||
|
const permissionsDb = require('./model/permissions/permissions.db')
|
||||||
const titleSync = require('./titleSync')
|
const titleSync = require('./titleSync')
|
||||||
const servers = require('./model/servers/servers.model')
|
const servers = require('./model/servers/servers.model')
|
||||||
const sidecar = require('./sidecarClient')
|
const sidecar = require('./sidecarClient')
|
||||||
@@ -257,6 +258,16 @@ async function sweep() {
|
|||||||
|
|
||||||
async function onBoot() {
|
async function onBoot() {
|
||||||
await refresh()
|
await refresh()
|
||||||
|
// The permission manager's rebuild (PLAN_REDESIGNS §1) keeps groups in new
|
||||||
|
// tables. Copied once, before the loop can push anything, so no sync ever
|
||||||
|
// sees a site with its groups missing. A failure is logged, not fatal: the
|
||||||
|
// copy runs again next boot, and until then the site simply has no groups.
|
||||||
|
try {
|
||||||
|
const copied = await permissionsDb.migrateGroups()
|
||||||
|
if (copied) log.info('permission groups copied into the rebuilt tables', { groups: copied })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('could not copy the permission groups', { error: err.message })
|
||||||
|
}
|
||||||
// The permission mirror owns its own loop and its own cadence (see
|
// The permission mirror owns its own loop and its own cadence (see
|
||||||
// `permSync.js`). It is started rather than run here: a first pass would write
|
// `permSync.js`). It is started rather than run here: a first pass would write
|
||||||
// to every configured game server before the website had finished booting, and
|
// to every configured game server before the website had finished booting, and
|
||||||
|
|||||||
@@ -19,6 +19,17 @@
|
|||||||
-- it knows this module registered, because it is the side that knows which
|
-- it knows this module registered, because it is the side that knows which
|
||||||
-- registrant owned what.
|
-- registrant owned what.
|
||||||
|
|
||||||
|
-- The permission manager, rebuilt (PLAN_REDESIGNS §1). Children before
|
||||||
|
-- `rust_permgroups`, which they reference.
|
||||||
|
DROP TABLE IF EXISTS rust_perm_exceptions;
|
||||||
|
DROP TABLE IF EXISTS rust_perm_steam_grants;
|
||||||
|
DROP TABLE IF EXISTS rust_permgroup_chat;
|
||||||
|
DROP TABLE IF EXISTS rust_permgroup_steam_members;
|
||||||
|
DROP TABLE IF EXISTS rust_permgroup_members;
|
||||||
|
DROP TABLE IF EXISTS rust_permgroup_permissions;
|
||||||
|
DROP TABLE IF EXISTS rust_permgroup_servers;
|
||||||
|
DROP TABLE IF EXISTS rust_permgroups;
|
||||||
|
|
||||||
-- Phase 17. `rust_perm_group_chat` before `rust_perm_groups`, which it
|
-- Phase 17. `rust_perm_group_chat` before `rust_perm_groups`, which it
|
||||||
-- references; the rest of this phase is columns, which go with their tables.
|
-- references; the rest of this phase is columns, which go with their tables.
|
||||||
DROP TABLE IF EXISTS rust_perm_group_chat;
|
DROP TABLE IF EXISTS rust_perm_group_chat;
|
||||||
|
|||||||
@@ -1011,3 +1011,155 @@ ALTER TABLE rust_perm_pushed ADD COLUMN IF NOT EXISTS value VARCHAR(255) NULL;
|
|||||||
-- The value a changed field holds in the game, for a `chat-field` drift row.
|
-- The value a changed field holds in the game, for a `chat-field` drift row.
|
||||||
-- NULL on every other kind: a foreign grant is its own description.
|
-- NULL on every other kind: a foreign grant is its own description.
|
||||||
ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS detail VARCHAR(255) NULL;
|
ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS detail VARCHAR(255) NULL;
|
||||||
|
|
||||||
|
-- ── The permission manager, rebuilt (PLAN_REDESIGNS §1) ────────────────────
|
||||||
|
--
|
||||||
|
-- The site owns EVERY permission and group on a server now (D160), read from
|
||||||
|
-- the plugin's inventory and imported on first contact (D198). Three things the
|
||||||
|
-- tables above cannot hold made new ones necessary:
|
||||||
|
--
|
||||||
|
-- • A group belongs to ONE server unless an admin shares it (D189). The old
|
||||||
|
-- `rust_perm_groups` is keyed by name fleet-wide, so two servers' `vip`
|
||||||
|
-- groups with different contents could not both exist. A group is now a row
|
||||||
|
-- with its own id; the servers it is on are rows beside it.
|
||||||
|
-- • A holder may be a Steam account nobody has linked (D188). The authored
|
||||||
|
-- tables above are keyed by website user (D28), and most of a real store's
|
||||||
|
-- holders never link.
|
||||||
|
-- • An in-game change affects that server only (D190), even to a row that
|
||||||
|
-- reaches more servers — so a fleet-wide grant can carry exceptions.
|
||||||
|
--
|
||||||
|
-- The old group tables stay, unread: `permissions.db.migrateGroups` copies them
|
||||||
|
-- here once, and a downgrade still finds them as they were.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_permgroups (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
-- Verbatim, never trimmed: Carbon's own titles end in a space ("Default "),
|
||||||
|
-- and a trimmed copy would be "changed" by every sync.
|
||||||
|
title VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
|
`rank` INT NOT NULL DEFAULT 0,
|
||||||
|
-- A group NAME on the same server, or ''. Both frameworks store it by name.
|
||||||
|
parent VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
-- 1: on every server, including servers added later, except those
|
||||||
|
-- `rust_permgroup_servers` excludes. 0: on exactly the servers it includes.
|
||||||
|
all_servers TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
-- `admin` (made on the site), `imported` (the first inventory, D198),
|
||||||
|
-- `adopted` (a later in-game change, D190), `split` (D190's copy),
|
||||||
|
-- `migrated` (copied from the old tables).
|
||||||
|
source VARCHAR(32) NOT NULL DEFAULT 'admin',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_rust_permgroups_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Which servers a group is on. `included` 1 names a server a group is on; 0
|
||||||
|
-- takes one server out of an all-servers group — the split D190 makes when that
|
||||||
|
-- server's copy changed in the game. The model refuses two groups of one name on
|
||||||
|
-- one server; a unique key cannot say it across `all_servers`.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_permgroup_servers (
|
||||||
|
group_id INT UNSIGNED NOT NULL,
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
included TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (group_id, server_id),
|
||||||
|
KEY idx_rust_permgroup_servers_server (server_id),
|
||||||
|
CONSTRAINT fk_rust_permgroup_servers_group
|
||||||
|
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rust_permgroup_servers_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- What a group carries, the same on every server it is on.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_permgroup_permissions (
|
||||||
|
group_id INT UNSIGNED NOT NULL,
|
||||||
|
permission VARCHAR(128) NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, permission),
|
||||||
|
CONSTRAINT fk_rust_permgroup_permissions_group
|
||||||
|
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Members who are website accounts, reaching every Steam account they link (D28).
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_permgroup_members (
|
||||||
|
group_id INT UNSIGNED NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
added_by INT NULL,
|
||||||
|
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (group_id, user_id),
|
||||||
|
KEY idx_rust_permgroup_members_user (user_id),
|
||||||
|
CONSTRAINT fk_rust_permgroup_members_group
|
||||||
|
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_rust_permgroup_members_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Members who are one Steam account, linked or not (D188).
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_permgroup_steam_members (
|
||||||
|
group_id INT UNSIGNED NOT NULL,
|
||||||
|
steam_id VARCHAR(32) NOT NULL,
|
||||||
|
source VARCHAR(32) NOT NULL DEFAULT 'admin',
|
||||||
|
added_by INT NULL,
|
||||||
|
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (group_id, steam_id),
|
||||||
|
KEY idx_rust_permgroup_steam_members_steam (steam_id),
|
||||||
|
CONSTRAINT fk_rust_permgroup_steam_members_group
|
||||||
|
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- A group's BetterChat style (D138), per group row rather than per name: one
|
||||||
|
-- server's own `vip` may be styled differently from another server's.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_permgroup_chat (
|
||||||
|
group_id INT UNSIGNED NOT NULL,
|
||||||
|
field VARCHAR(32) NOT NULL,
|
||||||
|
value VARCHAR(255) NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, field),
|
||||||
|
CONSTRAINT fk_rust_permgroup_chat_group
|
||||||
|
FOREIGN KEY (group_id) REFERENCES rust_permgroups (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- A permission held by one Steam account, linked or not (D188). The import and
|
||||||
|
-- auto-adopt write these, and so does a site toggle for an UNLINKED player.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_steam_grants (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
steam_id VARCHAR(32) NOT NULL,
|
||||||
|
permission VARCHAR(128) NOT NULL,
|
||||||
|
scope VARCHAR(64) NOT NULL DEFAULT '*',
|
||||||
|
source VARCHAR(32) NOT NULL DEFAULT 'admin',
|
||||||
|
note VARCHAR(255) NULL,
|
||||||
|
granted_by INT NULL,
|
||||||
|
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_rust_perm_steam_grant (steam_id, permission, scope),
|
||||||
|
KEY idx_rust_perm_steam_grant_steam (steam_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- "Everywhere except here" (D190): a grant that reaches more than one server,
|
||||||
|
-- removed on one of them in the game, keeps reaching the others, including
|
||||||
|
-- servers added later, which a rewrite into per-server rows would lose.
|
||||||
|
-- `holder` says which grants table `grant_id` is in: `user` or `steam`. No
|
||||||
|
-- foreign key can name two tables, so deleting a grant deletes its exceptions
|
||||||
|
-- in the same model call.
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_perm_exceptions (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
holder VARCHAR(8) NOT NULL,
|
||||||
|
grant_id INT UNSIGNED NOT NULL,
|
||||||
|
server_id VARCHAR(64) NOT NULL,
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_rust_perm_exception (holder, grant_id, server_id),
|
||||||
|
CONSTRAINT fk_rust_perm_exceptions_server
|
||||||
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- The registering plugin (PLAN_REDESIGNS §0.1), from the inventory. NULL for a
|
||||||
|
-- name no plugin owns; Carbon's built-in modules register theirs that way.
|
||||||
|
ALTER TABLE rust_perm_catalogue ADD COLUMN IF NOT EXISTS owner VARCHAR(64) NULL;
|
||||||
|
|
||||||
|
-- When this server's store was first imported (D160, D198). NULL until the first
|
||||||
|
-- inventory completes, and until then every sync imports.
|
||||||
|
ALTER TABLE rust_perm_sync ADD COLUMN IF NOT EXISTS imported_at DATETIME NULL;
|
||||||
|
|
||||||
|
-- What a change made in the game becomes (D161): `auto-adopt` (the default),
|
||||||
|
-- `adopt` (a person answers each one), or `revoke` (the site's set wins).
|
||||||
|
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS perm_policy VARCHAR(16) NOT NULL DEFAULT 'auto-adopt';
|
||||||
|
|
||||||
|
-- Which way a "needs a person" row went: `added` or `removed` in the game, or
|
||||||
|
-- `split` (D190 gave a server its own copy of a shared group, and says so in
|
||||||
|
-- case the change was meant for every server).
|
||||||
|
ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS direction VARCHAR(16) NOT NULL DEFAULT 'added';
|
||||||
|
|||||||
210
server/model/permissions/permissions.apply.js
Normal file
210
server/model/permissions/permissions.apply.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
// ── 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 }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,49 @@
|
|||||||
// ── The authored set, and what it means for one server ────────────────────
|
// ── The authored set, and what it means for one server ────────────────────
|
||||||
//
|
//
|
||||||
// This file turns "what an operator wrote on the website" into "what one game
|
// This file turns "what the site holds" into "what one game server's store
|
||||||
// server's store should contain", which is where four of phase 7's decisions
|
// should contain". Since the permission manager was rebuilt (PLAN_REDESIGNS §1)
|
||||||
// actually live:
|
// the site holds EVERYTHING on every server — what was there before it, what an
|
||||||
|
// admin made, and what was changed in the game (D160) — so the decisions that
|
||||||
|
// live here are:
|
||||||
//
|
//
|
||||||
// D28 a grant is authored against a WEBSITE USER and resolved to every Steam
|
// D28 a grant or membership held by a WEBSITE USER reaches every Steam id
|
||||||
// id they have linked, here, at the moment of the push.
|
// they have linked, resolved here at the moment of the push.
|
||||||
// D29 every authored row carries a scope — one server, or `*` for the fleet —
|
// D188 one held by a STEAM ACCOUNT reaches exactly that account, linked or not.
|
||||||
// and a server sees only what names it.
|
// D29 a grant carries a scope — one server, or `*` for the fleet — and a
|
||||||
// D30 groups travel as groups. Membership is a separate wire fact from the
|
// server sees only what names it. D190 lets a fleet grant carry
|
||||||
// permissions the group carries, because the game stores them separately
|
// exceptions: "every server except this one".
|
||||||
// and one of the two can fail on its own (§12.2 rule 4).
|
// D189 a group belongs to one server unless an admin shares it. What it
|
||||||
// D31 the difference between the desired set and what this site has already
|
// carries and who is in it are the group's, and the same on every server
|
||||||
// pushed is what gets retired. Anything else in the store is drift, and
|
// it is on.
|
||||||
// drift is reported rather than undone.
|
// D31 the difference between the desired set and what this site has pushed
|
||||||
|
// is what gets retired.
|
||||||
//
|
//
|
||||||
// Nothing here talks to a sidecar — `permSync.js` does that. The split is the
|
// `buildDesired` also says, for each row, which authored rows produced it (its
|
||||||
// usual one and earns its keep twice over here: the whole of the interesting
|
// SOURCES). The reconciler needs that to answer a change made in the game: a
|
||||||
// logic is a pure function of four tables, so it is tested without a game, a
|
// grant removed in the game is deleted when it was this server's alone, and gains
|
||||||
// sidecar, or a database.
|
// an exception when it reached further (D190).
|
||||||
|
//
|
||||||
|
// Nothing here talks to a sidecar — `permSync.js` does that — and nothing here
|
||||||
|
// writes: every function below is a pure function of rows, tested without a
|
||||||
|
// game, a sidecar, or a database.
|
||||||
|
|
||||||
const crypto = require('node:crypto')
|
const crypto = require('node:crypto')
|
||||||
|
|
||||||
const db = require('./permissions.db')
|
const db = require('./permissions.db')
|
||||||
|
|
||||||
/** A scope that means every server. Stored, rather than null, so the column never needs a coalesce. */
|
/** A scope that means every server. */
|
||||||
const FLEET = '*'
|
const FLEET = '*'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission and group names, as both frameworks store them.
|
* Groups neither framework lets go of: they exist on every server by the
|
||||||
*
|
* framework's own rule. They are imported and editable, and never retired.
|
||||||
* Lowercased on the way in, because the store lowers them and a site that did
|
* `moderator` is Carbon's.
|
||||||
* not would author `Kits.VIP`, push it, read back `kits.vip`, and report its own
|
*/
|
||||||
* grant as drift for ever.
|
const BUILTIN_GROUPS = new Set(['default', 'admin', 'moderator'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission and group names, as both frameworks store them. Lowercased on the
|
||||||
|
* way in, because the store lowers them.
|
||||||
*/
|
*/
|
||||||
function normaliseName(value) {
|
function normaliseName(value) {
|
||||||
return String(value || '').trim().toLowerCase()
|
return String(value || '').trim().toLowerCase()
|
||||||
@@ -44,90 +55,72 @@ function inScope(scope, serverId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything the authoring screen renders, in one read.
|
* A group's title, rank and parent as one comparable value, stored on its
|
||||||
*
|
* `group` ledger row. The title is kept verbatim — Carbon's own end in a space —
|
||||||
* Assembled here rather than in SQL because the shape is a tree — a group with
|
* so the value the site pushed and the value the game reports are the same
|
||||||
* its permissions and its members — and the alternative is either four round
|
* string when nothing changed.
|
||||||
* trips per group or one join that repeats every group row once per member.
|
|
||||||
*/
|
*/
|
||||||
async function overview() {
|
function groupValue(title, rank, parent) {
|
||||||
const [groups, groupPermissions, members, grants, sync, drift, catalogue, groupChat] = await Promise.all([
|
return JSON.stringify([String(title == null ? '' : title), Number(rank) || 0, normaliseName(parent)])
|
||||||
db.listGroups(),
|
|
||||||
db.listGroupPermissions(),
|
|
||||||
db.listGroupMembers(),
|
|
||||||
db.listGrants(),
|
|
||||||
db.listSync(),
|
|
||||||
db.listDrift(),
|
|
||||||
db.listCatalogue(),
|
|
||||||
db.listGroupChat(),
|
|
||||||
])
|
|
||||||
|
|
||||||
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [], chat: null }]))
|
|
||||||
|
|
||||||
// Phase 17: a group's BetterChat style, or null for a group without one.
|
|
||||||
for (const [name, fields] of chatByGroup(groupChat)) {
|
|
||||||
const group = byGroup.get(name)
|
|
||||||
if (group) group.chat = fields
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of groupPermissions) {
|
|
||||||
const group = byGroup.get(row.groupName)
|
|
||||||
if (group) group.permissions.push(row.permission)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A member with two linked Steam accounts arrives as two rows from the join,
|
|
||||||
// and is one person on the screen — holding BOTH accounts, not the first one
|
|
||||||
// the join happened to return. The screen needs all of them: a membership is
|
|
||||||
// pushed per account, and it can be waiting on one while it landed on another.
|
|
||||||
const memberByKey = new Map()
|
|
||||||
|
|
||||||
for (const row of members) {
|
|
||||||
const group = byGroup.get(row.groupName)
|
|
||||||
if (!group) continue
|
|
||||||
|
|
||||||
const key = `${row.groupName}:${row.userId}`
|
|
||||||
let member = memberByKey.get(key)
|
|
||||||
|
|
||||||
if (!member) {
|
|
||||||
member = {
|
|
||||||
userId: row.userId,
|
|
||||||
username: row.username,
|
|
||||||
accounts: [],
|
|
||||||
addedAt: row.addedAt,
|
|
||||||
}
|
|
||||||
memberByKey.set(key, member)
|
|
||||||
group.members.push(member)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.steamId) member.accounts.push({ steamId: row.steamId, name: row.playerName || null })
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
groups: [...byGroup.values()],
|
|
||||||
grants: collapseGrants(grants),
|
|
||||||
servers: sync.map(shapeSync),
|
|
||||||
drift: drift.map((row) => ({ ...row, detail: row.detail === undefined ? null : row.detail })),
|
|
||||||
catalogue: catalogueByPermission(catalogue),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Style rows folded into one object per group: `name → { Field: value }`. */
|
/** `groupId → Map(serverId → included)`. */
|
||||||
|
function serversByGroup(groupServers) {
|
||||||
|
const out = new Map()
|
||||||
|
|
||||||
|
for (const row of groupServers || []) {
|
||||||
|
if (!out.has(row.groupId)) out.set(row.groupId, new Map())
|
||||||
|
out.get(row.groupId).set(row.serverId, Boolean(row.included))
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a group is on a server (D189). */
|
||||||
|
function groupCovers(group, serverRows, serverId) {
|
||||||
|
const rows = serverRows.get(group.id)
|
||||||
|
const row = rows ? rows.get(serverId) : undefined
|
||||||
|
|
||||||
|
return group.allServers ? row !== false : row === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The servers a group is on, out of a list of server ids. */
|
||||||
|
function groupReach(group, serverRows, serverIds) {
|
||||||
|
return serverIds.filter((id) => groupCovers(group, serverRows, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The groups on one server, one per name. The model refuses a second group of a
|
||||||
|
* name on a server; should the tables ever hold one anyway, the older wins and
|
||||||
|
* the newer is ignored rather than both being pushed as one.
|
||||||
|
*/
|
||||||
|
function groupsOn(serverId, authored) {
|
||||||
|
const serverRows = serversByGroup(authored.groupServers)
|
||||||
|
const byName = new Map()
|
||||||
|
|
||||||
|
for (const group of [...authored.groups].sort((a, b) => a.id - b.id)) {
|
||||||
|
if (!groupCovers(group, serverRows, serverId)) continue
|
||||||
|
if (!byName.has(group.name)) byName.set(group.name, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byName.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Style rows folded into one object per group: `groupId → { Field: value }`. */
|
||||||
function chatByGroup(rows) {
|
function chatByGroup(rows) {
|
||||||
const out = new Map()
|
const out = new Map()
|
||||||
|
|
||||||
for (const row of rows || []) {
|
for (const row of rows || []) {
|
||||||
if (!out.has(row.groupName)) out.set(row.groupName, {})
|
if (!out.has(row.groupId)) out.set(row.groupId, {})
|
||||||
out.get(row.groupName)[row.field] = row.value
|
out.get(row.groupId)[row.field] = row.value
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per grant, not one per linked account.
|
* One row per grant, not one per linked account. The join in `listGrants`
|
||||||
*
|
* multiplies a grant by the holder's accounts.
|
||||||
* The join in `listGrants` multiplies a grant by the holder's accounts, which is
|
|
||||||
* what the push wants and the opposite of what a screen wants.
|
|
||||||
*/
|
*/
|
||||||
function collapseGrants(rows) {
|
function collapseGrants(rows) {
|
||||||
const byId = new Map()
|
const byId = new Map()
|
||||||
@@ -157,13 +150,7 @@ function collapseGrants(rows) {
|
|||||||
return [...byId.values()]
|
return [...byId.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The sync row as a client reads it. */
|
||||||
* The sync row as a client reads it.
|
|
||||||
*
|
|
||||||
* `report` is stored as the JSON the game sent and parsed here rather than on the
|
|
||||||
* way in, so a report this build cannot read is a rendering problem on one
|
|
||||||
* screen instead of a write that failed.
|
|
||||||
*/
|
|
||||||
function shapeSync(row) {
|
function shapeSync(row) {
|
||||||
let report = null
|
let report = null
|
||||||
|
|
||||||
@@ -182,126 +169,37 @@ function shapeSync(row) {
|
|||||||
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
|
inSync: Boolean(row.desiredHash) && row.desiredHash === row.syncedHash && row.state === 'ok',
|
||||||
lastAttemptAt: row.lastAttemptAt,
|
lastAttemptAt: row.lastAttemptAt,
|
||||||
lastOkAt: row.lastOkAt,
|
lastOkAt: row.lastOkAt,
|
||||||
|
importedAt: row.importedAt || null,
|
||||||
error: row.error || null,
|
error: row.error || null,
|
||||||
report,
|
report,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which servers know each permission name — the form's option source, and its warning label. */
|
|
||||||
function catalogueByPermission(rows) {
|
|
||||||
const byPermission = new Map()
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!byPermission.has(row.permission)) byPermission.set(row.permission, [])
|
|
||||||
byPermission.get(row.permission).push(row.serverId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...byPermission.entries()]
|
|
||||||
.map(([permission, servers]) => ({ permission, servers }))
|
|
||||||
.sort((a, b) => a.permission.localeCompare(b.permission))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ── What one person holds, as that person reads it ────────────────────────
|
|
||||||
*
|
|
||||||
* The admin overview answers *who holds what*; this answers *what do I hold*,
|
|
||||||
* and it is a different shape rather than a filtered one. Three things make it
|
|
||||||
* different:
|
|
||||||
*
|
|
||||||
* 1. **The scope arithmetic is answered here, not sent.** A client handed
|
|
||||||
* `scope: '*'` would have to know what the fleet is and re-implement
|
|
||||||
* `inScope` to say anything useful, and then there would be two of it. Each
|
|
||||||
* entry carries the servers it actually reaches, already resolved.
|
|
||||||
* 2. **`live` is per server and it is the pushed ledger, not the authored
|
|
||||||
* row.** A grant made on the website is not a privilege in a game until a
|
|
||||||
* sync confirmed it, and phase 7 is careful never to record a push that
|
|
||||||
* silently did nothing (an unregistered permission, a store that has never
|
|
||||||
* seen the player). So "waiting" here means waiting, and saying otherwise
|
|
||||||
* would be the site claiming to have given something it has not.
|
|
||||||
* 3. **Nothing says WHY it is waiting.** Which permission names a server's
|
|
||||||
* loaded plugins registered is an operator's diagnosis and an inventory of
|
|
||||||
* what is installed; a player gets the honest state, not the reason.
|
|
||||||
*
|
|
||||||
* Every read is scoped to the caller in SQL, and the pushed rows are looked up
|
|
||||||
* by the caller's OWN Steam ids — so a person with no linked account correctly
|
|
||||||
* sees entitlements that reach nobody yet, rather than nothing at all (the
|
|
||||||
* mistake phase 7 shipped on the admin user page, §20.5).
|
|
||||||
*/
|
|
||||||
async function forPlayer(userId, steamIds, serverRows) {
|
|
||||||
const [groups, groupPermissions, grants, pushed] = await Promise.all([
|
|
||||||
db.listGroupsForUser(userId),
|
|
||||||
db.listGroupPermissions(),
|
|
||||||
db.listGrants({ userId }),
|
|
||||||
db.listPushedForSteamIds(steamIds),
|
|
||||||
])
|
|
||||||
|
|
||||||
const servers = serverRows.map((row) => ({ id: row.id, name: row.name || row.id }))
|
|
||||||
|
|
||||||
// `kind:object` -> the servers a row of ours landed on. The subject is one of
|
|
||||||
// this caller's own Steam ids by construction, so it does not enter the key:
|
|
||||||
// an entitlement is live for the person if it is live for any account they
|
|
||||||
// hold, which is the same thing the game sees.
|
|
||||||
const live = new Map()
|
|
||||||
|
|
||||||
for (const row of pushed) {
|
|
||||||
const key = `${row.kind}:${normaliseName(row.object)}`
|
|
||||||
if (!live.has(key)) live.set(key, new Set())
|
|
||||||
live.get(key).add(row.serverId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The servers a scope reaches, each marked with whether it is there yet. */
|
|
||||||
function reach(scope, key) {
|
|
||||||
const landed = live.get(key) || new Set()
|
|
||||||
|
|
||||||
return servers
|
|
||||||
.filter((server) => inScope(scope, server.id))
|
|
||||||
.map((server) => ({ ...server, live: landed.has(server.id) }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionsByGroup = new Map()
|
|
||||||
|
|
||||||
for (const row of groupPermissions) {
|
|
||||||
if (!permissionsByGroup.has(row.groupName)) permissionsByGroup.set(row.groupName, [])
|
|
||||||
permissionsByGroup.get(row.groupName).push(normaliseName(row.permission))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
groups: groups.map((group) => ({
|
|
||||||
name: group.name,
|
|
||||||
title: group.title || group.name,
|
|
||||||
scope: group.scope,
|
|
||||||
since: group.addedAt,
|
|
||||||
permissions: (permissionsByGroup.get(group.name) || []).sort(),
|
|
||||||
reach: reach(group.scope, `member:${normaliseName(group.name)}`),
|
|
||||||
})),
|
|
||||||
// `collapseGrants` first: the join multiplies a grant by the accounts its
|
|
||||||
// holder has linked, and this caller may hold two.
|
|
||||||
grants: collapseGrants(grants)
|
|
||||||
.map((grant) => ({
|
|
||||||
permission: grant.permission,
|
|
||||||
scope: grant.scope,
|
|
||||||
source: grant.source,
|
|
||||||
note: grant.note,
|
|
||||||
since: grant.grantedAt,
|
|
||||||
reach: reach(grant.scope, `grant:${normaliseName(grant.permission)}`),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.permission.localeCompare(b.permission)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The whole authored set, read once, in the shape the per-server build wants.
|
* The whole authored set, read once, in the shape the per-server build wants.
|
||||||
*
|
|
||||||
* Read once per sync tick rather than once per server: six servers is six
|
|
||||||
* different answers derived from one set of tables, and re-reading them per
|
|
||||||
* server is six times the queries for the same rows.
|
|
||||||
*/
|
*/
|
||||||
async function readAuthored() {
|
async function readAuthored() {
|
||||||
const [groups, groupPermissions, members, grants, links, runGrants, groupChat] = await Promise.all([
|
const [
|
||||||
|
groups,
|
||||||
|
groupServers,
|
||||||
|
groupPermissions,
|
||||||
|
members,
|
||||||
|
steamMembers,
|
||||||
|
grants,
|
||||||
|
steamGrants,
|
||||||
|
exceptions,
|
||||||
|
links,
|
||||||
|
runGrants,
|
||||||
|
groupChat,
|
||||||
|
] = await Promise.all([
|
||||||
db.listGroups(),
|
db.listGroups(),
|
||||||
|
db.listGroupServers(),
|
||||||
db.listGroupPermissions(),
|
db.listGroupPermissions(),
|
||||||
db.listGroupMembers(),
|
db.listGroupMembers(),
|
||||||
|
db.listGroupSteamMembers(),
|
||||||
db.listGrants(),
|
db.listGrants(),
|
||||||
|
db.listSteamGrants(),
|
||||||
|
db.listExceptions(),
|
||||||
db.listLinks(),
|
db.listLinks(),
|
||||||
db.listRunGrants(),
|
db.listRunGrants(),
|
||||||
db.listGroupChat(),
|
db.listGroupChat(),
|
||||||
@@ -314,103 +212,158 @@ async function readAuthored() {
|
|||||||
steamIdsByUser.get(link.userId).push(link.steamId)
|
steamIdsByUser.get(link.userId).push(link.steamId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser, groupChat }
|
return {
|
||||||
|
groups,
|
||||||
|
groupServers,
|
||||||
|
groupPermissions,
|
||||||
|
members,
|
||||||
|
steamMembers,
|
||||||
|
grants,
|
||||||
|
steamGrants,
|
||||||
|
exceptions,
|
||||||
|
runGrants,
|
||||||
|
groupChat,
|
||||||
|
steamIdsByUser,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A row's identity, for set arithmetic against what was pushed. */
|
||||||
|
const rowKey = (row) => `${row.kind} ${row.subject} ${row.object}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What one server's store should contain, and the rows that say so.
|
* What one server's store should contain, and the rows that say so.
|
||||||
*
|
*
|
||||||
* Returns three things the caller needs together and must not compute twice:
|
|
||||||
*
|
|
||||||
* `payload` what goes on the wire
|
* `payload` what goes on the wire
|
||||||
* `rows` the same set in `rust_perm_pushed`'s shape, for the diff
|
* `rows` the same set in `rust_perm_pushed`'s shape, for the diff
|
||||||
* `hash` a stable digest of `rows`, which is how the loop knows nothing
|
* `hash` a stable digest of `rows`
|
||||||
* has changed without asking a game server
|
* `sources` rowKey → the authored rows that produced it (see the file header)
|
||||||
*
|
*
|
||||||
* **A user with no linked Steam account contributes nothing and is not an
|
* A user with no linked Steam account contributes nothing and is not an error.
|
||||||
* error.** They are authored against perfectly well and reach nobody until they
|
|
||||||
* link — which the admin screen says out loud, because a grant that reaches
|
|
||||||
* nothing looks exactly like one that worked.
|
|
||||||
*/
|
*/
|
||||||
function buildDesired(serverId, authored) {
|
function buildDesired(serverId, authored) {
|
||||||
const { groups, groupPermissions, members, grants, steamIdsByUser } = authored
|
const { groupPermissions, members, steamIdsByUser } = authored
|
||||||
|
const steamMembers = authored.steamMembers || []
|
||||||
|
const grants = authored.grants || []
|
||||||
|
const steamGrants = authored.steamGrants || []
|
||||||
const runGrants = authored.runGrants || []
|
const runGrants = authored.runGrants || []
|
||||||
|
const exceptions = new Set(
|
||||||
|
(authored.exceptions || []).filter((e) => e.serverId === serverId).map((e) => `${e.holder}:${e.grantId}`),
|
||||||
|
)
|
||||||
|
const serverRows = serversByGroup(authored.groupServers)
|
||||||
|
|
||||||
const scopedGroups = groups.filter((group) => inScope(group.scope, serverId))
|
|
||||||
const groupNames = new Set(scopedGroups.map((group) => group.name))
|
|
||||||
|
|
||||||
const permissionsByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
|
|
||||||
const membersByGroup = new Map(scopedGroups.map((group) => [group.name, []]))
|
|
||||||
const managed = new Set()
|
|
||||||
const rows = []
|
const rows = []
|
||||||
|
const sources = new Map()
|
||||||
|
const addSource = (row, source) => {
|
||||||
|
const key = rowKey(row)
|
||||||
|
if (!sources.has(key)) sources.set(key, [])
|
||||||
|
sources.get(key).push(source)
|
||||||
|
}
|
||||||
|
|
||||||
for (const group of scopedGroups)
|
const onServer = groupsOn(serverId, authored)
|
||||||
rows.push({ kind: 'group', subject: group.name, object: '' })
|
const groupById = new Map(onServer.map((group) => [group.id, group]))
|
||||||
|
const shared = (group) => isShared(group, serverRows)
|
||||||
|
const permissionsByGroup = new Map(onServer.map((group) => [group.id, []]))
|
||||||
|
const membersByGroup = new Map(onServer.map((group) => [group.id, []]))
|
||||||
|
|
||||||
for (const row of groupPermissions) {
|
for (const group of onServer) {
|
||||||
if (!groupNames.has(row.groupName)) continue
|
const row = { kind: 'group', subject: group.name, object: '', value: groupValue(group.title, group.rank, group.parent) }
|
||||||
|
rows.push(row)
|
||||||
|
addSource(row, { type: 'group', groupId: group.id, shared: shared(group) })
|
||||||
|
}
|
||||||
|
|
||||||
const permission = normaliseName(row.permission)
|
for (const entry of groupPermissions) {
|
||||||
permissionsByGroup.get(row.groupName).push(permission)
|
const group = groupById.get(entry.groupId)
|
||||||
managed.add(permission)
|
if (!group) continue
|
||||||
rows.push({ kind: 'group-permission', subject: row.groupName, object: permission })
|
|
||||||
|
const permission = normaliseName(entry.permission)
|
||||||
|
if (!permission) continue
|
||||||
|
|
||||||
|
permissionsByGroup.get(group.id).push(permission)
|
||||||
|
const row = { kind: 'group-permission', subject: group.name, object: permission }
|
||||||
|
rows.push(row)
|
||||||
|
addSource(row, { type: 'group', groupId: group.id, shared: shared(group) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenMember = new Set()
|
const seenMember = new Set()
|
||||||
|
const addMember = (group, steamId, source) => {
|
||||||
|
const row = { kind: 'member', subject: steamId, object: group.name }
|
||||||
|
addSource(row, source)
|
||||||
|
|
||||||
for (const row of members) {
|
const key = `${group.id}:${steamId}`
|
||||||
if (!groupNames.has(row.groupName)) continue
|
if (seenMember.has(key)) return
|
||||||
|
seenMember.add(key)
|
||||||
|
|
||||||
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
membersByGroup.get(group.id).push(steamId)
|
||||||
const key = `${row.groupName}:${steamId}`
|
rows.push(row)
|
||||||
if (seenMember.has(key)) continue
|
}
|
||||||
seenMember.add(key)
|
|
||||||
|
|
||||||
membersByGroup.get(row.groupName).push(steamId)
|
for (const entry of members) {
|
||||||
rows.push({ kind: 'member', subject: steamId, object: row.groupName })
|
const group = groupById.get(entry.groupId)
|
||||||
|
if (!group) continue
|
||||||
|
|
||||||
|
// Resolved from the link map, not from the joined row, so a user with two
|
||||||
|
// accounts is a member twice and a user with none is a member nowhere.
|
||||||
|
for (const steamId of steamIdsByUser.get(entry.userId) || []) {
|
||||||
|
addMember(group, steamId, { type: 'userMember', groupId: group.id, userId: entry.userId, shared: shared(group) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const entry of steamMembers) {
|
||||||
|
const group = groupById.get(entry.groupId)
|
||||||
|
if (!group) continue
|
||||||
|
addMember(group, entry.steamId, { type: 'steamMember', groupId: group.id, steamId: entry.steamId, shared: shared(group) })
|
||||||
|
}
|
||||||
|
|
||||||
const permissionsBySteamId = new Map()
|
const permissionsBySteamId = new Map()
|
||||||
const seenGrant = new Set()
|
const seenGrant = new Set()
|
||||||
|
const addGrant = (steamId, permission, source) => {
|
||||||
|
const row = { kind: 'grant', subject: steamId, object: permission }
|
||||||
|
addSource(row, source)
|
||||||
|
|
||||||
|
const key = `${steamId}:${permission}`
|
||||||
|
if (seenGrant.has(key)) return
|
||||||
|
seenGrant.add(key)
|
||||||
|
|
||||||
|
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
|
||||||
|
permissionsBySteamId.get(steamId).push(permission)
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grant held by a website user (D28), less its exceptions (D190). The
|
||||||
|
// exception's server is left out, and every other server keeps it.
|
||||||
|
const seenUserGrant = new Set()
|
||||||
|
|
||||||
for (const row of grants) {
|
for (const row of grants) {
|
||||||
if (!inScope(row.scope, serverId)) continue
|
if (!inScope(row.scope, serverId)) continue
|
||||||
|
if (seenUserGrant.has(row.id)) continue
|
||||||
|
seenUserGrant.add(row.id)
|
||||||
|
if (exceptions.has(`user:${row.id}`)) continue
|
||||||
|
|
||||||
const permission = normaliseName(row.permission)
|
const permission = normaliseName(row.permission)
|
||||||
|
if (!permission) continue
|
||||||
|
|
||||||
// Managed whether or not it reaches anybody: the namespace is what makes a
|
|
||||||
// hand grant of this permission to somebody else show up as drift, and a
|
|
||||||
// grant whose holder has linked nothing would otherwise silently narrow it.
|
|
||||||
managed.add(permission)
|
|
||||||
|
|
||||||
// **Resolved from the link map, not from the row.** `listGrants` joins the
|
|
||||||
// links and therefore repeats a grant once per linked account, which would
|
|
||||||
// give the right answer here by accident — until somebody changes that query
|
|
||||||
// and one of a person's two accounts quietly stops being granted. The map is
|
|
||||||
// the same source the members above use, and it says what it means.
|
|
||||||
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
for (const steamId of steamIdsByUser.get(row.userId) || []) {
|
||||||
const key = `${steamId}:${permission}`
|
addGrant(steamId, permission, { type: 'userGrant', id: row.id, userId: row.userId, scope: row.scope })
|
||||||
if (seenGrant.has(key)) continue
|
|
||||||
seenGrant.add(key)
|
|
||||||
|
|
||||||
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
|
|
||||||
permissionsBySteamId.get(steamId).push(permission)
|
|
||||||
rows.push({ kind: 'grant', subject: steamId, object: permission })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A grant held by one Steam account (D188).
|
||||||
|
for (const row of steamGrants) {
|
||||||
|
if (!inScope(row.scope, serverId)) continue
|
||||||
|
if (exceptions.has(`steam:${row.id}`)) continue
|
||||||
|
|
||||||
|
const permission = normaliseName(row.permission)
|
||||||
|
if (!permission) continue
|
||||||
|
|
||||||
|
addGrant(row.steamId, permission, { type: 'steamGrant', id: row.id, steamId: row.steamId, scope: row.scope })
|
||||||
|
}
|
||||||
|
|
||||||
// ── What events granted (phase 13b, D84) ──────────────────────────────
|
// ── What events granted (phase 13b, D84) ──────────────────────────────
|
||||||
//
|
//
|
||||||
// Unioned with the admin grants above through the same `seenGrant`, so a
|
// Unioned through the same `seenGrant`, so a permission held both ways is ONE
|
||||||
// permission held both ways is ONE row in the game — and withdrawing either
|
// row in the game. An event grant reaches only the kit's server (D102), and
|
||||||
// leaves the other standing, because the next build still finds it.
|
// every account the user has linked (D28). The CREDIT is one extra use on the
|
||||||
//
|
// account that took part, while it is still linked to the winner.
|
||||||
// An event grant reaches only the kit's server (D102), and like any grant it
|
|
||||||
// reaches every account the user has linked (D28).
|
|
||||||
//
|
|
||||||
// The CREDIT is different: one win is one extra use, on the account that took
|
|
||||||
// part, and only while that account is still linked to the user who won it.
|
|
||||||
const credits = new Map()
|
const credits = new Map()
|
||||||
|
|
||||||
for (const row of runGrants) {
|
for (const row of runGrants) {
|
||||||
@@ -420,38 +373,20 @@ function buildDesired(serverId, authored) {
|
|||||||
const permission = normaliseName(row.permission)
|
const permission = normaliseName(row.permission)
|
||||||
|
|
||||||
if (permission) {
|
if (permission) {
|
||||||
managed.add(permission)
|
for (const steamId of linked) addGrant(steamId, permission, { type: 'runGrant', runId: row.runId, stepId: row.stepId })
|
||||||
|
|
||||||
for (const steamId of linked) {
|
|
||||||
const key = `${steamId}:${permission}`
|
|
||||||
if (seenGrant.has(key)) continue
|
|
||||||
seenGrant.add(key)
|
|
||||||
|
|
||||||
if (!permissionsBySteamId.has(steamId)) permissionsBySteamId.set(steamId, [])
|
|
||||||
permissionsBySteamId.get(steamId).push(permission)
|
|
||||||
rows.push({ kind: 'grant', subject: steamId, object: permission })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number(row.credit) && linked.includes(row.steamId)) {
|
if (Number(row.credit) && linked.includes(row.steamId)) {
|
||||||
// A Steam id is digits, so the first bar is always the split; a kit name
|
|
||||||
// may contain one.
|
|
||||||
const key = `${row.steamId}|${row.kit}`
|
const key = `${row.steamId}|${row.kit}`
|
||||||
credits.set(key, (credits.get(key) || 0) + 1)
|
credits.set(key, (credits.get(key) || 0) + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── A group's BetterChat style (phase 17, D138) ───────────────────────
|
// ── A group's BetterChat style (phase 17, D138) ───────────────────────
|
||||||
//
|
|
||||||
// One ledger row per FIELD (`chat-field`, subject the group, object the
|
|
||||||
// field), carrying its value: the diff that retires a style is the same
|
|
||||||
// `pushed − desired` as everything else, and the value is what the next sync
|
|
||||||
// sends as `expect`. The value is not in the row's identity — a changed value
|
|
||||||
// is the same field pushed again, not a retirement.
|
|
||||||
const chat = chatByGroup(authored.groupChat)
|
const chat = chatByGroup(authored.groupChat)
|
||||||
|
|
||||||
for (const group of scopedGroups) {
|
for (const group of onServer) {
|
||||||
const fields = chat.get(group.name)
|
const fields = chat.get(group.id)
|
||||||
if (!fields) continue
|
if (!fields) continue
|
||||||
|
|
||||||
for (const field of Object.keys(fields).sort()) {
|
for (const field of Object.keys(fields).sort()) {
|
||||||
@@ -467,79 +402,160 @@ function buildDesired(serverId, authored) {
|
|||||||
.sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit))
|
.sort((a, b) => (a.steamId + a.kit).localeCompare(b.steamId + b.kit))
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
groups: scopedGroups.map((group) => ({
|
groups: onServer.map((group) => ({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
title: group.title || group.name,
|
// Verbatim: an empty title is sent empty, not replaced by the name.
|
||||||
rank: group.rank,
|
title: group.title == null ? '' : group.title,
|
||||||
permissions: permissionsByGroup.get(group.name),
|
rank: Number(group.rank) || 0,
|
||||||
members: membersByGroup.get(group.name),
|
parent: normaliseName(group.parent),
|
||||||
// The values only; `permSync` adds what each one expects to find, which
|
permissions: permissionsByGroup.get(group.id),
|
||||||
// is per server and comes from the ledger.
|
members: membersByGroup.get(group.id),
|
||||||
...(chat.has(group.name) ? { chat: chat.get(group.name) } : {}),
|
...(chat.has(group.id) ? { chat: chat.get(group.id) } : {}),
|
||||||
})),
|
})),
|
||||||
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
|
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({ steamId, permissions })),
|
||||||
steamId,
|
// Always sent, even empty (D103).
|
||||||
permissions,
|
|
||||||
})),
|
|
||||||
managed: [...managed].sort(),
|
|
||||||
// Always sent, even empty: to the plugin an absent field means "this site
|
|
||||||
// says nothing about credits", and an empty one means "nobody has any" —
|
|
||||||
// which is what a revert of the last reward must be able to say (D103).
|
|
||||||
credits: creditRows,
|
credits: creditRows,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Credits are in the digest, so a new reward or a revert pushes, but they are
|
|
||||||
// NOT in `rows`: those are the pushed ledger's, and a use of a kit is not
|
|
||||||
// something in the permission store to retire.
|
|
||||||
//
|
|
||||||
// A style field's VALUE goes into the digest the same way, since it is not in
|
|
||||||
// the row's identity: a colour changed on the site must push.
|
|
||||||
const hashed = [
|
const hashed = [
|
||||||
...rows.map((row) => (row.kind === 'chat-field' ? { ...row, object: `${row.object}=${row.value}` } : row)),
|
...rows,
|
||||||
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
|
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
|
||||||
]
|
]
|
||||||
|
|
||||||
return { payload, rows, hash: hashRows(hashed) }
|
return { payload, rows, hash: hashRows(hashed), sources }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a group is on more than one server, or on every server. */
|
||||||
|
function isShared(group, serverRows) {
|
||||||
|
if (group.allServers) return true
|
||||||
|
|
||||||
|
const rows = serverRows.get(group.id)
|
||||||
|
if (!rows) return false
|
||||||
|
|
||||||
|
let on = 0
|
||||||
|
for (const included of rows.values()) if (included) on++
|
||||||
|
return on > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A digest of the desired set.
|
* A digest of the desired set. Sorted before hashing, and a row's VALUE is in
|
||||||
*
|
* it (a style field, a group's title, rank and parent): a change to one must push.
|
||||||
* Sorted before hashing, because the rows come out of several queries in an
|
|
||||||
* order nothing guarantees — an unsorted digest would differ between two reads
|
|
||||||
* of an unchanged set and push to every game server on every tick.
|
|
||||||
*/
|
*/
|
||||||
function hashRows(rows) {
|
function hashRows(rows) {
|
||||||
const canonical = rows
|
const canonical = rows
|
||||||
.map((row) => `${row.kind} | |||||||