diff --git a/ci/bundle.json b/ci/bundle.json
index 5638de9..10047de 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -35,6 +35,7 @@
"db",
"engagement",
"eventLeases.js",
+ "eventPermRewards.js",
"eventProgress.js",
"eventRewards.js",
"eventTemplates.js",
diff --git a/ci/core-ref.json b/ci/core-ref.json
index 58fe4cf..8e4e270 100644
--- a/ci/core-ref.json
+++ b/ci/core-ref.json
@@ -1,6 +1,6 @@
{
- "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree).",
+ "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. Pinned to website#213's branch head while that PR is open; re-pin to the sha #213 merges as.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
- "ref": "ca7ec53b145a0a518c2e6e932e23de7583ce3893",
- "refName": "main @ MODULE_API 1.13.0 (website#212 merged)"
+ "ref": "743f1bc8b2188600368ece603685d48f1ada76f2",
+ "refName": "feat/event-param-multiple @ MODULE_API 1.14.0 (website#213, open)"
}
diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx
index 5fc73b5..7aed7a1 100644
--- a/client/src/routes/admin/Permissions.jsx
+++ b/client/src/routes/admin/Permissions.jsx
@@ -191,12 +191,15 @@ function playerState(facts, view, player, permission) {
if (direct) {
const wider = direct.sources.some((s) => (s.type === 'userGrant' || s.type === 'steamGrant') && s.scope !== view.server.id)
const fromEvent = direct.sources.every((s) => s.type === 'runGrant')
+ // Given for good by an event (PLAN_REDESIGNS.md §12.5): a standing grant like
+ // a staff member's, toggled off like one, and marked with its note.
+ const given = direct.sources.find((s) => s.type === 'userGrant' && s.origin === 'event')
let chip
if (!facts.registered.has(permission)) chip = not registered here
else if (facts.notLanded.has(`${player.steamId}:${permission}`)) chip = did not land
else if (facts.landed.has(`grant ${player.steamId} ${permission}`)) chip = granted
else chip = waiting for a sync
- return { on: true, chip, wider, fromEvent, excepted: null, viaGroups }
+ return { on: true, chip, wider, fromEvent, given: given ? given.note || 'an event' : null, excepted: null, viaGroups }
}
if (viaGroups.length) {
@@ -294,6 +297,7 @@ function PlayerPanel({ view, player, act, busy }) {
{perm}
{s.fromEvent && from an event}
+ {s.given && given by {s.given}}
{s.chip}
toggle(perm, on)} />
diff --git a/engagement-triggers.json b/engagement-triggers.json
index 54a119b..fd173df 100644
--- a/engagement-triggers.json
+++ b/engagement-triggers.json
@@ -1,6 +1,6 @@
{
"_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.",
- "coreApi": "^1.13.0",
+ "coreApi": "^1.14.0",
"triggers": [
{
"id": "rust.base.destroyed",
diff --git a/module.json b/module.json
index 47f7e8e..aa91c28 100644
--- a/module.json
+++ b/module.json
@@ -2,7 +2,7 @@
"id": "rust",
"name": "Rust",
"version": "0.1.0",
- "coreApi": "^1.13.0",
+ "coreApi": "^1.14.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
diff --git a/server/eventPermRewards.js b/server/eventPermRewards.js
new file mode 100644
index 0000000..e309aca
--- /dev/null
+++ b/server/eventPermRewards.js
@@ -0,0 +1,524 @@
+// ── Permissions as event rewards (PLAN_REDESIGNS.md §12, D342–D346) ───────
+//
+// Any permission a server's plugins registered can be a reward — night vision,
+// a chat colour, a `/remove` command — with no code written for the perk. Two
+// verbs, because core gives an action one risk class (D346):
+//
+// rust.perm.lend until the run ends: a kit reward's lease, one
+// `rust_perm_run_grants` row per recipient per permission,
+// withdrawn by the step's revert (`change` / `ledger`)
+// rust.perm.give for good: a standing `rust_perm_grants` row per website
+// user per permission, scoped to the step's server, source
+// `event` (`irreversible` / `none`); staff take it back in
+// the permission manager
+//
+// …and the picker both use, `rust.options.permissions`: the enabled servers'
+// catalogues, grouped by the plugin that registered each permission.
+//
+// Nothing here is new machinery. Both write rows the permission mirror already
+// pushes, so there is no plugin code, no sidecar code and no protocol change
+// (§12.1). The message is D333's notice and rides `perm.sync` like a kit
+// reward's.
+//
+// ── The safety is the author's own "I am sure" (D343) ───────────────────────
+//
+// No blocklist and no allowlist: the bridge's own `runicgateway.*` permissions
+// are in the picker like any other. A required toggle in the step says the
+// author meant it, and unticked the dry run and the run refuse, naming what the
+// step would have given.
+
+const permDb = require('./model/permissions/permissions.db')
+const linksDb = require('./model/links/links.db')
+const servers = require('./model/servers/servers.model')
+const core = require('./core')
+const { serverFor, bounded } = require('./eventLeases')
+const { BUDGET_MS } = require('./eventWorld')
+const rewards = require('./eventRewards')
+
+const log = core.logger('perm-rewards')
+
+const { MAX_RECIPIENTS, MAX_CHAT, DELIVERIES, MODES, oneOf, checkCount, pickRecipients, peopleOf, usersFor, readTally, entitlementRef } = rewards
+
+/** At most this many permissions in one step (§12.6): a perk bundle, with a price that stays readable. */
+const MAX_PERMISSIONS = 10
+
+/** A permission name as Oxide and Carbon store it: lower case, and at most the column's width. */
+const PERMISSION_MAX = 128
+
+/** What a for-good grant says it came from, in the permission manager (§12.5). */
+const SOURCE = 'event'
+
+const BUDGETS = [
+ {
+ id: 'rust.permissions',
+ label: 'Permissions lent',
+ unit: 'permissions',
+ description:
+ 'Permissions an event lends until it ends: one per recipient per permission. A mode that is not a count is priced at the most it could lend.',
+ },
+ {
+ id: 'rust.permissions.kept',
+ label: 'Permissions given for good',
+ unit: 'permissions',
+ description:
+ 'Permissions an event gives for good: one per recipient per permission. Ending the event does not take them back; staff remove them in the permission manager. A mode that is not a count is priced at the most it could give.',
+ },
+]
+
+// ── The params both steps check ──────────────────────────────────────────────
+
+/**
+ * The picked permissions as a list, checked: lower case, trimmed, each once, at
+ * least one and at most MAX_PERMISSIONS. Core hands a `multiple` param over as
+ * an array (MODULE_API 1.14.0); a bare value is a list of one.
+ */
+function checkPermissions(raw) {
+ const items = raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw]
+ const out = []
+ for (const item of items) {
+ const name = String(item === undefined || item === null ? '' : item).trim().toLowerCase()
+ if (!name) continue
+ if (name.length > PERMISSION_MAX || /\s/.test(name)) return { ok: false, error: `"${item}" is not a permission name` }
+ if (!out.includes(name)) out.push(name)
+ }
+ if (!out.length) return { ok: false, error: 'pick at least one permission' }
+ if (out.length > MAX_PERMISSIONS) return { ok: false, error: `a step gives at most ${MAX_PERMISSIONS} permissions, not ${out.length}` }
+ return { ok: true, value: out }
+}
+
+/** The "I am sure" toggle (D343). Only a real `true` passes; a string is not a tick. */
+function checkConfirm(params, permissions) {
+ if (params.confirm === true) return { ok: true }
+ return { ok: false, error: `Tick "I am sure" to make ${permissions.join(', ')} a reward` }
+}
+
+/**
+ * The step's message, checked like a kit reward's (D333): `{ ok, text,
+ * delivery }`, text null for a step that tells nobody — the default (§12.6),
+ * because a permission's name means nothing to a player and only the author can
+ * say what it does. `{permissions}` is the names; measured with them in place.
+ */
+function permNotice(params, permissions) {
+ const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
+ if (!text) return { ok: true, text: null, delivery: null }
+
+ const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
+ const delivery = oneOf(rawDelivery, DELIVERIES)
+ if (!delivery) return { ok: false, error: `a reward's message goes to chat or to a popup, not to "${rawDelivery}"` }
+
+ const said = sayPermissions(text, permissions)
+ if (said.length > MAX_CHAT) {
+ return { ok: false, error: `a reward's message is at most ${MAX_CHAT} characters with the permissions in it, not ${said.length}` }
+ }
+ return { ok: true, text, delivery }
+}
+
+const sayPermissions = (text, permissions) => text.replace(/\{permissions\}/g, permissions.join(', '))
+
+/**
+ * Every picked permission must be in the server's catalogue (§12.2), and one
+ * that is not is refused by name rather than skipped (§12.6): the plugin may be
+ * unloaded for the wipe, and a reward that silently gives less than it says is
+ * the worse failure. Runs in the dry run too.
+ */
+async function checkCatalogue(server, permissions) {
+ const name = server.name || server.id
+ const registered = new Set(
+ (await permDb.listCatalogue())
+ .filter((row) => row.serverId === server.id)
+ .map((row) => String(row.permission).toLowerCase()),
+ )
+ if (!registered.size) {
+ return {
+ ok: false,
+ error: `${name} has reported no permissions yet — its plugins' permissions arrive with its next permission sync`,
+ }
+ }
+ const missing = permissions.filter((p) => !registered.has(p))
+ if (missing.length) {
+ return {
+ ok: false,
+ retry: false,
+ error: `${name} has no permission ${missing.join(', ')} — its plugins did not register ${missing.length === 1 ? 'it' : 'them'}`,
+ }
+ }
+ return { ok: true }
+}
+
+/**
+ * Everything both steps check before touching anything, in the order an author
+ * fixes them: the permissions, the toggle, the mode and its count, the message,
+ * the server, its catalogue, the bound. `{ ok, permissions, mode, count, notice,
+ * server }` or a refusal core reads as permanent.
+ */
+async function checkStep(params, verb) {
+ const permissions = checkPermissions(params.permissions)
+ if (!permissions.ok) return { ok: false, retry: false, error: permissions.error }
+
+ const confirm = checkConfirm(params, permissions.value)
+ if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
+
+ const mode = oneOf(params.recipients, MODES)
+ if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
+
+ const count = checkCount(mode, params.count)
+ if (!count.ok) return { ok: false, retry: false, error: count.error }
+
+ const notice = permNotice(params, permissions.value)
+ if (!notice.ok) return { ok: false, retry: false, error: notice.error }
+
+ const found = await serverFor(String(params.server || '').trim())
+ if (!found.ok) return found
+ const server = found.server
+
+ const listed = await checkCatalogue(server, permissions.value)
+ if (!listed.ok) return listed
+
+ if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
+ return { ok: false, retry: false, error: `${server.name || server.id} ${verb} permissions to at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
+ }
+
+ return { ok: true, permissions: permissions.value, mode, count, notice, server }
+}
+
+/**
+ * The people a mode picks, as website users (D101, D103): `{ ok, people,
+ * missed }`, each person `{ steamId, name, userId }` and each website user once —
+ * of two linked accounts that both took part, the earlier in the list, the
+ * higher score, is the one. `linked` reads no tally (§2.5).
+ */
+async function recipientsOf({ server, mode, count, runId, idempotencyKey, verb }) {
+ const name = server.name || server.id
+ let picked
+ let bound = MAX_RECIPIENTS
+ let users
+
+ if (mode === 'linked') {
+ // One past the bound, so "too many" is a sentence and not a silent cut.
+ const linked = await linksDb.linkedOnServer(server.id, bound + 1)
+ picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
+ users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
+ if (picked.length > bound) {
+ return { ok: false, retry: false, error: `${name} has more than ${bound} linked players, and ${verb} permissions to at most ${bound} in one step — reward from a tally instead` }
+ }
+ } else {
+ const tally = await readTally(server, runId)
+ if (!tally.ok) return tally
+
+ picked = pickRecipients(peopleOf(tally.data), mode, count, idempotencyKey)
+ bound = Number(tally.data.maxRecipients) || bound
+ if (picked.length > bound) {
+ return { ok: false, retry: false, error: `${picked.length} people qualify, and ${name} ${verb} permissions to at most ${bound} in one step — pick a count-based mode or a higher bar` }
+ }
+ users = await usersFor(picked.map((p) => p.steamId))
+ }
+
+ const people = []
+ const byUser = new Set()
+ const missed = []
+ for (const p of picked) {
+ const userId = users.get(p.steamId)
+ if (!userId) {
+ missed.push(p.name)
+ continue
+ }
+ if (byUser.has(userId)) continue
+ byUser.add(userId)
+ people.push({ ...p, userId })
+ }
+ return { ok: true, people, missed }
+}
+
+const missedDetail = (missed) =>
+ missed.length ? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' } : {}
+
+/** People priced before the tally is read: the count for a count, the bound for every other mode. */
+function pricedPeople(p) {
+ const mode = oneOf(p.recipients, MODES)
+ const n = Math.round(Number(p.count) || 0)
+ return mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
+}
+
+/** How many permissions a step names, as its price counts them: at least one, at most the bound. */
+function pricedPermissions(p) {
+ const checked = checkPermissions(p.permissions)
+ return checked.ok ? checked.value.length : 1
+}
+
+// ── The params, declared once for both steps (§12.5: "the same params") ─────
+
+const paramsFor = (messageExample) => [
+ { name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
+ description: 'The server the permissions are given on. Each one must be registered there by one of its plugins.' },
+ { name: 'permissions', type: 'string', required: true, example: 'nightvision.allowed', source: 'rust.options.permissions',
+ multiple: true, max: MAX_PERMISSIONS,
+ description: `The permissions, by the plugin that registered them — at most ${MAX_PERMISSIONS}.` },
+ { name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
+ description: 'Who gets them: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
+ { name: 'count', type: 'float', required: false, example: 3,
+ description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
+ { name: 'message', type: 'string', required: false, example: messageExample,
+ description: 'Said once to each player, in game: at once if they are up, otherwise when they next wake on the server. {permissions} is the names. Leave it empty to say nothing — a permission\'s name means little to a player, so say what it does.' },
+ { name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
+ description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
+ { name: 'confirm', type: 'boolean', required: true, example: false,
+ description: 'I am sure I want to give these permissions as a reward. Unticked, the step refuses in the dry run and when it runs.' },
+]
+
+// ── Until the run ends (§12.4) ───────────────────────────────────────────────
+
+const kitEntitle = rewards.ACTIONS.find((a) => a.id === 'rust.kit.entitle')
+
+const permLend = {
+ id: 'rust.perm.lend',
+ label: 'Permissions until the event ends',
+ description:
+ 'Lends the permissions you pick to the people a mode picks from this run\'s tally — or every linked player of the server — until the run ends. Waits for them if they are offline. Someone who already held one keeps it when the event ends.',
+ // A kit reward's lease with other permissions (D346): the same rows, the same
+ // revert, so the same risk. Core reverts it at the end of every run.
+ risk: 'change',
+ reversible: 'ledger',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: paramsFor('You can use /nv until the event ends'),
+
+ cost: (p) => ({ 'rust.permissions': pricedPeople(p) * pricedPermissions(p) }),
+
+ async perform({ runId, stepId, idempotencyKey, params, verify }) {
+ const step = await checkStep(params, 'lends')
+ if (!step.ok) return step
+ if (verify) return { ok: true }
+
+ const { permissions, mode, count, notice, server } = step
+ const name = server.name || server.id
+ const ref = entitlementRef(server.id, runId, stepId)
+ const resource = { kind: 'permission-lease', ref, payload: { serverId: server.id, permissions } }
+
+ // As a kit reward's: the rows ARE the lease, and a repeated key finds them.
+ const existing = await permDb.listRunGrantsForStep(runId, stepId)
+ if (existing.length) {
+ return {
+ ok: true,
+ resources: [resource],
+ detail: { repeat: true, lent: new Set(existing.map((r) => r.userId)).size, note: 'answered from the first attempt; nothing new was lent' },
+ }
+ }
+
+ const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'lends' })
+ if (!picked.ok) return picked
+
+ // One row per recipient per permission; the message on the first only, so
+ // each person is told once (the notice key is per run, step and user).
+ const said = notice.text ? sayPermissions(notice.text, permissions) : null
+ const rows = []
+ for (const p of picked.people) {
+ permissions.forEach((permission, i) => {
+ rows.push({
+ runId,
+ stepId,
+ idemKey: idempotencyKey,
+ userId: p.userId,
+ serverId: server.id,
+ steamId: p.steamId,
+ permission,
+ kit: '',
+ credit: false,
+ ...(said && i === 0 ? { notice: said, noticeDelivery: notice.delivery } : {}),
+ })
+ })
+ }
+
+ await permDb.insertRunGrants(rows)
+ await permDb.markDirty(server.id)
+
+ log.info('permissions lent', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, missed: picked.missed.length })
+
+ return {
+ ok: true,
+ resources: [resource],
+ detail: {
+ server: name,
+ permissions,
+ mode,
+ ...(count.value === null ? {} : { count: count.value }),
+ lent: picked.people.length,
+ ...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
+ ...missedDetail(picked.missed),
+ },
+ }
+ },
+
+ // The lease ends as a kit reward's does: the step's rows go and the next sync
+ // revokes what nobody else grants. A player who already held a permission
+ // keeps it, because the push reads the union (§12.1).
+ revert: (ctx) => kitEntitle.revert(ctx),
+ reconcile: (ctx) => kitEntitle.reconcile(ctx),
+}
+
+// ── For good (§12.5) ─────────────────────────────────────────────────────────
+
+/** What a for-good grant's note says, in the permission manager. */
+const grantNote = (runId, stepId) => `event run ${runId}, step ${stepId}`
+
+const permGive = {
+ id: 'rust.perm.give',
+ label: 'Permissions for good',
+ description:
+ 'Gives the permissions you pick to the people a mode picks, for good. Ending or cancelling the event does not take them back; staff remove them in the permission manager.',
+ // Irreversible, as `rust.coins.pay` is (D335, D346): core reverts a ledgered
+ // step at the end of EVERY run, so a ledger here would take the gift back.
+ risk: 'irreversible',
+ reversible: 'none',
+ version: 1,
+ budgetMs: BUDGET_MS,
+ params: paramsFor('You can use /nv from now on'),
+
+ cost: (p) => ({ 'rust.permissions.kept': pricedPeople(p) * pricedPermissions(p) }),
+
+ async perform({ runId, stepId, idempotencyKey, params, verify }) {
+ const step = await checkStep(params, 'gives')
+ if (!step.ok) return step
+ if (verify) return { ok: true }
+
+ const { permissions, mode, count, notice, server } = step
+ const name = server.name || server.id
+
+ const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey, verb: 'gives' })
+ if (!picked.ok) return picked
+
+ // A grant already there is left alone (§12.5). One this step's own first
+ // attempt wrote carries its note, and is counted as given, not as held: a
+ // retry after a lost answer reads the same as the attempt that landed.
+ const note = grantNote(runId, stepId)
+ const held = await permDb.listGrantsForUsers(picked.people.map((p) => p.userId), server.id)
+ const heldBy = new Map(held.map((g) => [`${g.userId}:${String(g.permission).toLowerCase()}`, g]))
+
+ const rows = []
+ let alreadyHeld = 0
+ let repeated = 0
+ for (const p of picked.people) {
+ for (const permission of permissions) {
+ const there = heldBy.get(`${p.userId}:${permission}`)
+ if (there) {
+ if (there.source === SOURCE && there.note === note) repeated++
+ else alreadyHeld++
+ continue
+ }
+ rows.push({ userId: p.userId, permission, scope: server.id, source: SOURCE, note })
+ }
+ }
+
+ const inserted = await permDb.insertGrants(rows)
+
+ // The message rides a notice-only row (§12.5): no permission, no kit, no
+ // credit, so the push grants nothing for it and tells it once. Nothing
+ // reverts this step, so the row stays — a sentence that has been said.
+ const said = notice.text ? sayPermissions(notice.text, permissions) : null
+ if (said && picked.people.length) {
+ await permDb.insertRunGrants(picked.people.map((p) => ({
+ runId,
+ stepId,
+ idemKey: idempotencyKey,
+ userId: p.userId,
+ serverId: server.id,
+ steamId: p.steamId,
+ permission: '',
+ kit: '',
+ credit: false,
+ notice: said,
+ noticeDelivery: notice.delivery,
+ })))
+ }
+
+ if (inserted || said) await permDb.markDirty(server.id)
+
+ log.info('permissions given', { server: server.id, run: runId, step: stepId, permissions, mode, people: picked.people.length, given: inserted, alreadyHeld, repeated })
+
+ return {
+ ok: true,
+ detail: {
+ server: name,
+ permissions,
+ mode,
+ ...(count.value === null ? {} : { count: count.value }),
+ people: picked.people.length,
+ given: inserted + repeated,
+ ...(alreadyHeld ? { alreadyHeld, alreadyHeldNote: 'already held: the grant was there before, and is left as it was' } : {}),
+ ...(repeated ? { repeat: true, repeatNote: 'answered from the first attempt; nothing was given twice' } : {}),
+ ...(said && picked.people.length ? { told: `${picked.people.length} will be told in game, by ${notice.delivery}` } : {}),
+ ...missedDetail(picked.missed),
+ },
+ }
+ },
+}
+
+// ── The picker (§12.2) ───────────────────────────────────────────────────────
+
+/** A permission's group: the plugin that registered it, or its prefix, saying so (the permission manager's rule). */
+function groupOf(permission, owner) {
+ if (owner) return owner
+ const prefix = permission.includes('.') ? permission.slice(0, permission.indexOf('.')) : permission
+ return `${prefix} (not registered by a plugin)`
+}
+
+/**
+ * The picker's rows from the catalogue and the enabled servers. Pure, so the
+ * rule is testable without a database: each permission once, grouped by its
+ * plugin, sorted by group and then permission without case, and a permission not
+ * on every enabled server says where it is (`note`, MODULE_API 1.13.0).
+ */
+function permissionRows(catalogue, enabled) {
+ const names = new Map(enabled.map((s) => [s.id, s.name || s.id]))
+ const byPermission = new Map()
+ for (const row of catalogue) {
+ if (!names.has(row.serverId)) continue
+ const permission = String(row.permission || '').trim()
+ if (!permission) continue
+ const key = permission.toLowerCase()
+ if (!byPermission.has(key)) byPermission.set(key, { permission, owner: null, on: new Set() })
+ const entry = byPermission.get(key)
+ if (!entry.owner && row.owner) entry.owner = String(row.owner)
+ entry.on.add(row.serverId)
+ }
+
+ const rows = [...byPermission.values()].map((e) => {
+ const group = groupOf(e.permission, e.owner)
+ const row = { value: e.permission, label: e.permission, group }
+ if (e.on.size < names.size) {
+ const where = enabled.filter((s) => e.on.has(s.id)).map((s) => names.get(s.id))
+ row.note = `Only on ${where.join(', ')}`
+ }
+ return row
+ })
+
+ const fold = (s) => s.toLowerCase()
+ rows.sort((a, b) => fold(a.group).localeCompare(fold(b.group)) || fold(a.value).localeCompare(fold(b.value)))
+ return rows
+}
+
+const OPTION_SOURCES = [
+ {
+ id: 'rust.options.permissions',
+ label: 'Permissions',
+ description: "Every permission the enabled servers' plugins registered, grouped by plugin; one missing from a server says where it is.",
+ async resolve() {
+ const [catalogue, enabled] = await Promise.all([permDb.listCatalogue(), servers.listForPolling()])
+ return bounded(permissionRows(catalogue, enabled), 'rust.options.permissions')
+ },
+ },
+]
+
+const ACTIONS = [permLend, permGive]
+
+module.exports = {
+ MAX_PERMISSIONS,
+ SOURCE,
+ BUDGETS,
+ ACTIONS,
+ OPTION_SOURCES,
+ checkPermissions,
+ checkConfirm,
+ permNotice,
+ permissionRows,
+ grantNote,
+}
diff --git a/server/eventRewards.js b/server/eventRewards.js
index 559d25f..9e979da 100644
--- a/server/eventRewards.js
+++ b/server/eventRewards.js
@@ -1391,6 +1391,13 @@ module.exports = {
ACTIONS,
LEG,
OPTION_SOURCES,
+ MODES,
+ PERMANENT,
+ oneOf,
+ peopleOf,
+ usersFor,
+ readTally,
+ entitlementRef,
pickRecipients,
checkCount,
splitKit,
diff --git a/server/index.js b/server/index.js
index 5653570..553ad1f 100644
--- a/server/index.js
+++ b/server/index.js
@@ -59,6 +59,7 @@ module.exports = function register(ctx, api) {
const eventLeases = require('./eventLeases')
const eventWorld = require('./eventWorld')
const eventRewards = require('./eventRewards')
+ const eventPermRewards = require('./eventPermRewards')
const eventTemplates = require('./eventTemplates')
const boot = require('./boot')
const commands = require('./commands')
@@ -169,8 +170,11 @@ module.exports = function register(ctx, api) {
// and the chat line, with the two budgets they spend. Registered in the same
// calls' neighbours, not merged into eventWorld's arrays, so each file keeps
// its own statement of what it declares.
- api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS])
- api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS])
+ //
+ // Permissions as rewards (PLAN_REDESIGNS.md §12) beside them: any registered
+ // permission, lent until the run ends or given for good, each on its own budget.
+ api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS])
+ api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS])
// The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend
// as a starting point on core's New event screen. Core keeps nothing about a
@@ -188,6 +192,7 @@ module.exports = function register(ctx, api) {
...eventLeases.OPTION_SOURCES,
...eventWorld.OPTION_SOURCES,
...eventRewards.OPTION_SOURCES,
+ ...eventPermRewards.OPTION_SOURCES,
])
// The slash commands (phase 16, R11). The handlers run HERE, in the website
@@ -206,10 +211,10 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
- actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length,
- budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length,
+ actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length,
+ budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length,
announceLeg: eventRewards.LEG.leg,
commands: commands.map((cmd) => cmd.name).join(' '),
- optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length,
+ optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length,
})
}
diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js
index 39941d9..8119324 100644
--- a/server/model/permissions/permissions.db.js
+++ b/server/model/permissions/permissions.db.js
@@ -322,6 +322,33 @@ async function insertGrant({ userId, permission, scope, source, note, grantedBy
return { inserted: affected(result) > 0, id: result.insertId }
}
+/**
+ * The grants some users already hold at one scope, with their source and note
+ * (PLAN_REDESIGNS §12.5): `rust.perm.give` leaves a grant that is there alone,
+ * and tells its own first attempt's grants from a staff member's by the note.
+ */
+async function listGrantsForUsers(userIds, scope) {
+ const ids = [...new Set((userIds || []).map(Number).filter(Boolean))]
+ if (!ids.length) return []
+ return core.query(
+ `SELECT user_id AS userId, permission, source, note
+ FROM ${GRANTS}
+ WHERE scope = ? AND user_id IN (${ids.map(() => '?').join(', ')})`,
+ [scope, ...ids],
+ )
+}
+
+/** Add many grants at once, leaving any already there (§12.5). Returns how many were added. */
+async function insertGrants(rows) {
+ if (!rows.length) return 0
+ const result = await core.query(
+ `INSERT IGNORE INTO ${GRANTS} (user_id, permission, scope, source, note, granted_by)
+ VALUES ${placeholders(rows, 6)}`,
+ rows.flatMap((r) => [r.userId, r.permission, r.scope, r.source, r.note || null, r.grantedBy || null]),
+ )
+ return affected(result)
+}
+
/** Delete a grant and the exceptions it carried, which no foreign key can reach. */
async function deleteGrant(id) {
const removed = affected(await core.query(`DELETE FROM ${GRANTS} WHERE id = ?`, [id])) > 0
@@ -873,6 +900,8 @@ module.exports = {
listGrants,
getGrant,
insertGrant,
+ insertGrants,
+ listGrantsForUsers,
deleteGrant,
listSteamGrants,
getSteamGrant,
diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js
index 96d8f35..c207ba9 100644
--- a/server/model/permissions/permissions.model.js
+++ b/server/model/permissions/permissions.model.js
@@ -342,8 +342,11 @@ function buildDesired(serverId, authored) {
const permission = normaliseName(row.permission)
if (!permission) continue
+ // A grant an event gave for good (PLAN_REDESIGNS.md §12.5) says so, with its
+ // note, so the screen can show where it came from; staff delete it like any.
+ const given = row.source === 'event' ? { origin: 'event', note: row.note || '' } : {}
for (const steamId of steamIdsByUser.get(row.userId) || []) {
- addGrant(steamId, permission, { type: 'userGrant', id: row.id, userId: row.userId, scope: row.scope })
+ addGrant(steamId, permission, { type: 'userGrant', id: row.id, userId: row.userId, scope: row.scope, ...given })
}
}
diff --git a/server/test/entry.test.js b/server/test/entry.test.js
index d4be141..63c65a4 100644
--- a/server/test/entry.test.js
+++ b/server/test/entry.test.js
@@ -163,6 +163,8 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.npc.place',
'rust.participation.collect',
'rust.participation.open',
+ 'rust.perm.give',
+ 'rust.perm.lend',
'rust.zone.open',
])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
@@ -171,6 +173,8 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.coins',
'rust.grants',
'rust.npcs',
+ 'rust.permissions',
+ 'rust.permissions.kept',
'rust.prefabs',
'rust.zone.minutes',
])
@@ -182,7 +186,8 @@ test('the event verbs are registered, and every budget has a verb that spends it
// operator. The two tally verbs spend nothing: counting people costs no loot.
const spent = new Set()
for (const a of actions) {
- const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
+ // A list param (MODULE_API 1.14.0) is priced as core prices it: its one example as a list of one.
+ const example = Object.fromEntries(a.params.map((p) => [p.name, p.multiple ? [p.example] : p.example]))
const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
if (a.id.startsWith('rust.participation.')) {
assert.strictEqual(dims.length, 0, `${a.id} prices ${dims.join(', ')}`)
@@ -194,16 +199,17 @@ test('the event verbs are registered, and every budget has a verb that spends it
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
// What a teardown can give back is declared honestly: a line said, a
- // collect filed and coins paid (D335) are not undone.
- const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay'])
+ // collect filed, coins paid (D335) and permissions given for good (D346) are
+ // not undone.
+ const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give'])
for (const a of actions) {
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
- if (once.has(a.id)) continue
- assert.strictEqual(typeof a.revert, 'function')
- assert.strictEqual(typeof a.reconcile, 'function')
// Every param source is one this module registers.
const sources = new Set(api.record.eventOptionSources.map((s) => s.id))
for (const p of a.params) if (p.source) assert.ok(sources.has(p.source), `${a.id}.${p.name} names ${p.source}`)
+ if (once.has(a.id)) continue
+ assert.strictEqual(typeof a.revert, 'function')
+ assert.strictEqual(typeof a.reconcile, 'function')
}
})
diff --git a/server/test/permRewards.test.js b/server/test/permRewards.test.js
new file mode 100644
index 0000000..ece1669
--- /dev/null
+++ b/server/test/permRewards.test.js
@@ -0,0 +1,345 @@
+// ── Permissions as event rewards (PLAN_REDESIGNS §12, D342–D346) ──────────
+//
+// Each test is one way the feature could look right and be wrong:
+//
+// the picker groups by the plugin that registered a permission, by prefix
+// when none did, sorts both levels without case, and says where one is missing
+// several permissions arrive as core's list (MODULE_API 1.14.0); a bare value
+// is a list of one; more than ten is refused
+// unticked, both steps refuse in the dry run, naming what they would give (D343)
+// a permission the server's catalogue lacks is refused by name, never skipped
+// lend writes one row per recipient per permission, the message on one row
+// give writes standing grants, source `event`, scoped to the server, leaves one
+// already there alone, and tells a retry from a staff member's grant by note
+// give's message rides a notice-only row the push grants nothing for
+// a for-good grant carries its note to the permission manager
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx } = require('./_fakes')
+
+function withRecorder(rows = []) {
+ const statements = []
+ const ctx = fakeCtx({
+ db: {
+ query: (sql, params = []) => {
+ statements.push({ sql, params })
+ return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
+ },
+ pool: {},
+ },
+ })
+ require('../core')._reset()
+ require('../core').init(ctx)
+ return { statements }
+}
+
+withRecorder()
+
+const client = require('../sidecarClient')
+const serversDb = require('../model/servers/servers.db')
+const linksDb = require('../model/links/links.db')
+const permDb = require('../model/permissions/permissions.db')
+const model = require('../model/permissions/permissions.model')
+const rewards = require('../eventRewards')
+const perms = require('../eventPermRewards')
+
+const ok = (data) => ({ ok: true, status: 'ok', data })
+
+const A = '76561198000000001'
+const B = '76561198000000002'
+const C = '76561198000000003'
+
+const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
+const lend = perms.ACTIONS.find((a) => a.id === 'rust.perm.lend')
+const give = perms.ACTIONS.find((a) => a.id === 'rust.perm.give')
+
+const CATALOGUE = [
+ { serverId: 'main', permission: 'nightvision.allowed', owner: 'NightVision' },
+ { serverId: 'main', permission: 'betterchat.color', owner: 'BetterChat' },
+ { serverId: 'main', permission: 'kits.vip', owner: 'Kits' },
+]
+
+function stub(t, { catalogue = CATALOGUE, people = [], links = {}, existing = [], held = [] } = {}) {
+ const calls = { inserted: [], grants: [], dirty: [], deleted: [], heldAsked: [] }
+ const saved = {
+ getServer: serversDb.getServer,
+ tallySnapshot: client.tallySnapshot,
+ userIdsForSteamIds: linksDb.userIdsForSteamIds,
+ listCatalogue: permDb.listCatalogue,
+ listRunGrantsForStep: permDb.listRunGrantsForStep,
+ insertRunGrants: permDb.insertRunGrants,
+ insertGrants: permDb.insertGrants,
+ listGrantsForUsers: permDb.listGrantsForUsers,
+ markDirty: permDb.markDirty,
+ deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
+ }
+
+ serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
+ client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
+ linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
+ permDb.listCatalogue = async () => catalogue
+ permDb.listRunGrantsForStep = async () => existing
+ permDb.insertRunGrants = async (rows) => {
+ calls.inserted.push(...rows)
+ return rows.length
+ }
+ permDb.insertGrants = async (rows) => {
+ calls.grants.push(...rows)
+ return rows.length
+ }
+ permDb.listGrantsForUsers = async (userIds, scope) => {
+ calls.heldAsked.push({ userIds, scope })
+ return held
+ }
+ permDb.markDirty = async (serverId) => calls.dirty.push(serverId)
+ permDb.deleteRunGrantsForStep = async (runId, stepId) => {
+ calls.deleted.push(`${runId}:${stepId}`)
+ return ['main']
+ }
+
+ t.after(() => {
+ Object.assign(serversDb, { getServer: saved.getServer })
+ Object.assign(client, { tallySnapshot: saved.tallySnapshot })
+ Object.assign(linksDb, { userIdsForSteamIds: saved.userIdsForSteamIds })
+ Object.assign(permDb, {
+ listCatalogue: saved.listCatalogue,
+ listRunGrantsForStep: saved.listRunGrantsForStep,
+ insertRunGrants: saved.insertRunGrants,
+ insertGrants: saved.insertGrants,
+ listGrantsForUsers: saved.listGrantsForUsers,
+ markDirty: saved.markDirty,
+ deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
+ })
+ })
+ return calls
+}
+
+const PARAMS = { server: 'main', permissions: ['nightvision.allowed', 'betterchat.color'], recipients: 'everyone', confirm: true }
+const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
+
+// ── The declarations ───────────────────────────────────────────────────────
+
+test('lend is a ledgered change and give is irreversible, each on its own budget (D346)', () => {
+ assert.strictEqual(lend.risk, 'change')
+ assert.strictEqual(lend.reversible, 'ledger')
+ assert.strictEqual(typeof lend.revert, 'function')
+ assert.strictEqual(give.risk, 'irreversible')
+ assert.strictEqual(give.reversible, 'none')
+ assert.strictEqual(give.revert, undefined)
+
+ // People times permissions: the count for a count, the bound for every other mode.
+ assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3, permissions: ['a.x', 'b.y'] }), { 'rust.permissions': 6 })
+ assert.deepStrictEqual(give.cost({ recipients: 'linked', permissions: ['a.x'] }), { 'rust.permissions.kept': rewards.MAX_RECIPIENTS })
+ // The switchboard prices the example as a list of one.
+ assert.deepStrictEqual(lend.cost({ recipients: 'top', count: 3, permissions: ['nightvision.allowed'] }), { 'rust.permissions': 3 })
+ assert.deepStrictEqual(perms.BUDGETS.map((b) => b.id), ['rust.permissions', 'rust.permissions.kept'])
+})
+
+test('the permissions param is core\'s list, at most ten, and confirm is a required toggle', () => {
+ for (const action of [lend, give]) {
+ const list = action.params.find((p) => p.name === 'permissions')
+ assert.strictEqual(list.multiple, true)
+ assert.strictEqual(list.max, perms.MAX_PERMISSIONS)
+ assert.strictEqual(list.source, 'rust.options.permissions')
+ assert.strictEqual(Array.isArray(list.example), false)
+ const confirm = action.params.find((p) => p.name === 'confirm')
+ assert.deepStrictEqual([confirm.type, confirm.required, confirm.example], ['boolean', true, false])
+ }
+ // Each step's own message example says how long the perk lasts.
+ assert.match(lend.params.find((p) => p.name === 'message').example, /until the event ends/)
+ assert.doesNotMatch(give.params.find((p) => p.name === 'message').example, /until the event ends/)
+})
+
+test('a list is lower case, once each, a bare value is a list of one, and eleven is refused', () => {
+ assert.deepStrictEqual(perms.checkPermissions(['NightVision.Allowed', ' kits.vip ', 'nightvision.allowed']).value, ['nightvision.allowed', 'kits.vip'])
+ assert.deepStrictEqual(perms.checkPermissions('kits.vip').value, ['kits.vip'])
+ assert.match(perms.checkPermissions([]).error, /at least one/)
+ assert.match(perms.checkPermissions(['two words']).error, /not a permission name/)
+ const eleven = Array.from({ length: 11 }, (_, i) => `p.n${i}`)
+ assert.match(perms.checkPermissions(eleven).error, /at most 10 permissions, not 11/)
+})
+
+// ── The picker (§12.2) ─────────────────────────────────────────────────────
+
+test('the picker groups by plugin, by prefix when none registered it, sorted both levels without case', () => {
+ const enabled = [{ id: 'main', name: 'Main' }, { id: 'pvp', name: 'PvP' }]
+ const rows = perms.permissionRows(
+ [
+ { serverId: 'main', permission: 'zonemanager.ignoreflag.nokits', owner: 'ZoneManager' },
+ { serverId: 'pvp', permission: 'zonemanager.ignoreflag.nokits', owner: 'ZoneManager' },
+ { serverId: 'main', permission: 'Betterchat.Color', owner: 'BetterChat' },
+ { serverId: 'pvp', permission: 'betterchat.color', owner: 'BetterChat' },
+ { serverId: 'main', permission: 'nightvision.allowed', owner: 'NightVision' },
+ { serverId: 'main', permission: 'admin.teleport', owner: null },
+ { serverId: 'pvp', permission: 'admin.teleport', owner: null },
+ { serverId: 'main', permission: 'betterchat.admin', owner: 'BetterChat' },
+ { serverId: 'pvp', permission: 'betterchat.admin', owner: 'BetterChat' },
+ // A disabled server's catalogue is not offered.
+ { serverId: 'off', permission: 'secret.thing', owner: 'Secret' },
+ ],
+ enabled,
+ )
+ assert.deepStrictEqual(rows.map((r) => [r.group, r.value]), [
+ ['admin (not registered by a plugin)', 'admin.teleport'],
+ ['BetterChat', 'betterchat.admin'],
+ ['BetterChat', 'Betterchat.Color'],
+ ['NightVision', 'nightvision.allowed'],
+ ['ZoneManager', 'zonemanager.ignoreflag.nokits'],
+ ])
+ // One server only: the row says which.
+ assert.strictEqual(rows.find((r) => r.value === 'nightvision.allowed').note, 'Only on Main')
+ assert.strictEqual('note' in rows.find((r) => r.value === 'admin.teleport'), false)
+})
+
+// ── What both steps refuse ─────────────────────────────────────────────────
+
+test('unticked, both steps refuse in the dry run, naming what they would give (D343)', async (t) => {
+ stub(t)
+ for (const action of [lend, give]) {
+ for (const confirm of [false, undefined, 'true']) {
+ const result = await action.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, confirm }, verify: true })
+ assert.strictEqual(result.ok, false)
+ assert.strictEqual(result.retry, false)
+ assert.strictEqual(result.error, 'Tick "I am sure" to make nightvision.allowed, betterchat.color a reward')
+ }
+ }
+})
+
+test('a permission the server did not register is refused by name, in the dry run too', async (t) => {
+ stub(t)
+ const result = await give.perform({ runId: 7, stepId: 4, params: { ...PARAMS, permissions: ['nightvision.allowed', 'removertool.remove'] }, verify: true })
+ assert.strictEqual(result.ok, false)
+ assert.strictEqual(result.retry, false)
+ assert.strictEqual(result.error, 'Main has no permission removertool.remove — its plugins did not register it')
+})
+
+test('a server that has reported no catalogue yet is a wait, not a refusal', async (t) => {
+ stub(t, { catalogue: [] })
+ const result = await lend.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true })
+ assert.strictEqual(result.ok, false)
+ assert.notStrictEqual(result.retry, false)
+ assert.match(result.error, /Main has reported no permissions yet/)
+})
+
+test('a dry run that passes writes nothing', async (t) => {
+ const calls = stub(t)
+ assert.deepStrictEqual(await lend.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true }), { ok: true })
+ assert.deepStrictEqual(await give.perform({ runId: 7, stepId: 4, params: PARAMS, verify: true }), { ok: true })
+ assert.deepStrictEqual([calls.inserted, calls.grants, calls.dirty], [[], [], []])
+})
+
+// ── Until the run ends (§12.4) ─────────────────────────────────────────────
+
+test('lend writes a row per recipient per permission, the message on one row each', async (t) => {
+ const calls = stub(t, { people: [person(A, 10), person(B, 5), person(C, 0)], links: { [A]: 1, [B]: 2, [C]: 3 } })
+ const result = await lend.perform({
+ runId: 7, stepId: 4, idempotencyKey: 'k-1',
+ params: { ...PARAMS, message: 'You have {permissions} until the end', delivery: 'popup' },
+ })
+ assert.strictEqual(result.ok, true)
+ assert.strictEqual(result.detail.lent, 2)
+ assert.deepStrictEqual(result.resources, [{ kind: 'permission-lease', ref: 'main:7:4', payload: { serverId: 'main', permissions: ['nightvision.allowed', 'betterchat.color'] } }])
+
+ assert.deepStrictEqual(calls.inserted.map((r) => [r.userId, r.permission, r.kit, r.credit, r.notice || null]), [
+ [1, 'nightvision.allowed', '', false, 'You have nightvision.allowed, betterchat.color until the end'],
+ [1, 'betterchat.color', '', false, null],
+ [2, 'nightvision.allowed', '', false, 'You have nightvision.allowed, betterchat.color until the end'],
+ [2, 'betterchat.color', '', false, null],
+ ])
+ assert.strictEqual(calls.inserted[0].noticeDelivery, 'popup')
+ assert.deepStrictEqual(calls.dirty, ['main'])
+})
+
+test('a blank message tells nobody (§12.6)', async (t) => {
+ const calls = stub(t, { people: [person(A, 10)], links: { [A]: 1 } })
+ const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: ' ' } })
+ assert.strictEqual(result.ok, true)
+ assert.strictEqual(calls.inserted.some((r) => r.notice), false)
+ assert.strictEqual('told' in result.detail, false)
+})
+
+test('a repeated key finds the rows and lends nothing new; the revert is a kit reward\'s', async (t) => {
+ const calls = stub(t, { existing: [{ userId: 1 }, { userId: 1 }, { userId: 2 }] })
+ const result = await lend.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS })
+ assert.strictEqual(result.detail.repeat, true)
+ assert.strictEqual(result.detail.lent, 2)
+ assert.deepStrictEqual(calls.inserted, [])
+
+ assert.deepStrictEqual(await lend.revert({ runId: 7, resources: result.resources, idempotencyKey: 'k-1' }), { ok: true })
+ assert.deepStrictEqual(calls.deleted, ['7:4'])
+ assert.deepStrictEqual(calls.dirty, ['main'])
+})
+
+// ── For good (§12.5) ───────────────────────────────────────────────────────
+
+test('give writes standing grants scoped to the server, source event, with the step in the note', async (t) => {
+ const calls = stub(t, { people: [person(A, 10), person(B, 5)], links: { [A]: 1, [B]: 2 } })
+ const result = await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS })
+ assert.strictEqual(result.ok, true)
+ assert.strictEqual(result.resources, undefined)
+ assert.deepStrictEqual(calls.heldAsked, [{ userIds: [1, 2], scope: 'main' }])
+ assert.deepStrictEqual(calls.grants, [
+ { userId: 1, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' },
+ { userId: 1, permission: 'betterchat.color', scope: 'main', source: 'event', note: 'event run 7, step 4' },
+ { userId: 2, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' },
+ { userId: 2, permission: 'betterchat.color', scope: 'main', source: 'event', note: 'event run 7, step 4' },
+ ])
+ assert.strictEqual(result.detail.given, 4)
+ // No message: no notice row at all.
+ assert.deepStrictEqual(calls.inserted, [])
+ assert.deepStrictEqual(calls.dirty, ['main'])
+})
+
+test('a grant already there is left alone; this step\'s own is counted as given on a retry', async (t) => {
+ const calls = stub(t, {
+ people: [person(A, 10), person(B, 5)],
+ links: { [A]: 1, [B]: 2 },
+ held: [
+ { userId: 1, permission: 'nightvision.allowed', source: 'admin', note: null },
+ { userId: 2, permission: 'nightvision.allowed', source: 'event', note: 'event run 7, step 4' },
+ { userId: 2, permission: 'betterchat.color', source: 'event', note: 'event run 7, step 4' },
+ ],
+ })
+ const result = await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS })
+ assert.deepStrictEqual(calls.grants.map((g) => [g.userId, g.permission]), [[1, 'betterchat.color']])
+ assert.strictEqual(result.detail.given, 3)
+ assert.strictEqual(result.detail.alreadyHeld, 1)
+ assert.strictEqual(result.detail.repeat, true)
+})
+
+test('give\'s message rides a notice-only row the push grants nothing for', async (t) => {
+ const calls = stub(t, { people: [person(A, 10)], links: { [A]: 1 } })
+ await give.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: { ...PARAMS, message: 'You keep {permissions}' } })
+ assert.deepStrictEqual(calls.inserted.map((r) => [r.userId, r.permission, r.kit, r.credit, r.notice, r.noticeDelivery]), [
+ [1, '', '', false, 'You keep nightvision.allowed, betterchat.color', 'chat'],
+ ])
+
+ const { payload } = model.buildDesired('main', {
+ groups: [], groupServers: [], groupPermissions: [], members: [], steamMembers: [],
+ grants: [], steamGrants: [], exceptions: [], groupChat: [],
+ steamIdsByUser: new Map([[1, [A]]]),
+ runGrants: calls.inserted.map((r) => ({ ...r, noticeToldAt: null })),
+ })
+ assert.deepStrictEqual(payload.grants, [])
+ assert.deepStrictEqual(payload.notices.map((n) => [n.key, n.steamId, n.message]), [['7:4:1', A, 'You keep nightvision.allowed, betterchat.color']])
+})
+
+test('a for-good grant carries its note to the permission manager; a staff grant does not grow one', () => {
+ const { sources } = model.buildDesired('main', {
+ groups: [], groupServers: [], groupPermissions: [], members: [], steamMembers: [],
+ grants: [
+ { id: 9, userId: 1, permission: 'nightvision.allowed', scope: 'main', source: 'event', note: 'event run 7, step 4' },
+ { id: 10, userId: 1, permission: 'kits.vip', scope: 'main', source: 'admin', note: null },
+ ],
+ steamGrants: [], exceptions: [], groupChat: [], runGrants: [],
+ steamIdsByUser: new Map([[1, [A]]]),
+ })
+ assert.deepStrictEqual(sources.get(model.rowKey({ kind: 'grant', subject: A, object: 'nightvision.allowed' })), [
+ { type: 'userGrant', id: 9, userId: 1, scope: 'main', origin: 'event', note: 'event run 7, step 4' },
+ ])
+ assert.deepStrictEqual(sources.get(model.rowKey({ kind: 'grant', subject: A, object: 'kits.vip' })), [
+ { type: 'userGrant', id: 10, userId: 1, scope: 'main' },
+ ])
+})