moving audio gain perms around

This commit is contained in:
legop3
2026-08-07 22:34:04 -04:00
parent 9702cf0f82
commit 6c69c583c5
34 changed files with 936 additions and 1132 deletions
@@ -1,180 +0,0 @@
// Operator Gain Command
// Purpose: Handles the audio gain boost permission for VIPs.
// Scope: Supports list, grant, and revoke subcommands; resolution stays VIP-only.
const { mask, normalizeSearchText, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
/*
Every connected socket's canonical user id. One person can hold several sockets
across tabs, so this is a set of identities rather than a count of connections.
*/
function collectOnlineUserIds(io) {
const online = new Set();
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return online;
sockets.forEach((socket) => {
const userId = String(socket?.data?.userId || '').trim();
if (userId) online.add(userId);
});
return online;
}
/*
Candidates whose identity fields equal the selector outright. Nicknames are not
unique — the same person re-verifying from a new browser produces a second
verified record with the same name — so an exact nickname match can legitimately
return several records.
*/
function findExactMatches(selector, candidates) {
const needle = normalizeSearchText(selector);
if (!needle) return [];
return (Array.isArray(candidates) ? candidates : []).filter((record) => (
normalizeSearchText(record?.nickname) === needle
|| normalizeSearchText(record?.userId) === needle
|| normalizeSearchText(record?.id) === needle
|| normalizeSearchText(record?.cookieUserId) === needle
|| normalizeSearchText(record?.fingerprintId) === needle
));
}
function createGainCommand({
io,
listVerifiedUsers,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
sanitizeMentions,
config,
}) {
// Usage text comes from the same core prefix that both transports parse.
const { prefix: commandPrefix } = getCommandConfig(config);
const plain = { parse: [], repliedUser: false };
function usage(subcommand) {
return `Usage: \`${commandPrefix} gain ${subcommand} <nickname|userId|cookieUserId>\``;
}
/*
Duplicate nicknames used to make `gain grant <name>` unusable: the shared
resolver refuses on ambiguity, which is right for destructive commands like
deter and kick but wrong here. Granting a volume ceiling to the wrong one of
two accounts belonging to the same person is recoverable, so this command
picks one and says which.
The online account wins, because that is who the admin is reacting to. With
nobody online the first stored record is used. The shared fuzzy resolver still
handles the no-exact-match case so typo tolerance and its error text are
unchanged.
*/
function resolveBoostTarget(selector, candidates) {
const exact = findExactMatches(selector, candidates);
if (exact.length === 1) return { record: exact[0] };
if (exact.length > 1) {
const onlineUserIds = collectOnlineUserIds(io);
const onlineMatches = exact.filter((record) => {
const userId = String(record?.userId || '').trim();
return userId && onlineUserIds.has(userId);
});
if (onlineMatches.length) {
return { record: onlineMatches[0], duplicates: exact.length, picked: 'online' };
}
return { record: exact[0], duplicates: exact.length, picked: 'first' };
}
return resolveIdentitySelector(selector, candidates, { includeId: false });
}
function describePick(resolved) {
if (!resolved.duplicates) return '';
if (resolved.picked === 'online') {
return ` ${resolved.duplicates} accounts share that name; picked the one that is online.`;
}
return ` ${resolved.duplicates} accounts share that name and none are online; picked the first.`;
}
function helpText() {
return [
'**Audio gain boost**',
'Raises a user\'s volume ceiling past the global gains, still bounded by the hard caps.',
'',
`- \`${commandPrefix} gain list\`: show everyone who holds the boost.`,
`- \`${commandPrefix} gain grant <vip>\`: give the boost to a verified user.`,
`- \`${commandPrefix} gain revoke <vip>\`: take the boost away.`,
`- \`${commandPrefix} gain help\`: show this.`,
'',
'A user can be named by nickname, userId, or cookieUserId. Only verified (VIP)',
'users can be granted the boost. If several accounts share a nickname, the one',
'that is currently online is used.',
].join('\n');
}
/*
The boost is a VIP-only permission, so candidate matching runs against the
verified list rather than every known identity. A nickname that only belongs
to an unverified visitor therefore reports "not found" instead of resolving
to someone who cannot hold the flag anyway.
*/
async function applyBoost(message, tokens, enabled) {
const selector = tokens.join(' ').trim();
if (!selector) {
return message.reply({ content: usage(enabled ? 'grant' : 'revoke'), allowedMentions: plain });
}
const candidates = enabled ? listVerifiedUsers() : listAudioGainBoostUsers();
const resolved = resolveBoostTarget(selector, candidates);
if (resolved.error) {
return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: plain });
}
const target = resolved.record.userId || resolved.record.id || resolved.record.cookieUserId;
try {
const actor = message.actor?.id || null;
const user = enabled ? grantAudioGainBoost(target, actor) : revokeAudioGainBoost(target, actor);
const verb = enabled ? 'Granted' : 'Revoked';
return message.reply({
content: sanitizeMentions(`${verb} audio gain boost for ${user.nickname || 'unknown'} (${mask(user.cookieUserId)}).${describePick(resolved)}`),
allowedMentions: plain,
});
} catch (err) {
return message.reply({
content: sanitizeMentions(`Failed to update audio gain boost: ${err.message}`),
allowedMentions: plain,
});
}
}
return async function handleGainCommand(message, tokens) {
if (!message.actor?.isAdmin) {
await message.reply({ content: 'Only admins can manage audio gain boosts.', allowedMentions: plain });
return;
}
const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'help') {
return message.reply({ content: helpText(), allowedMentions: plain });
}
if (action === 'list') {
const users = listAudioGainBoostUsers();
if (!users.length) {
return message.reply({ content: 'No users hold an audio gain boost.', allowedMentions: plain });
}
const lines = users.map((entry, idx) => (
`${idx + 1}. ${entry.nickname || 'unknown'} | ${entry.userId || entry.id} | ${mask(entry.cookieUserId)}`
));
return message.reply({
content: sanitizeMentions(['Audio gain boost holders:', ...lines].join('\n').slice(0, 1900)),
allowedMentions: plain,
});
}
if (action === 'grant') return applyBoost(message, tokens, true);
if (action === 'revoke') return applyBoost(message, tokens, false);
return message.reply({
content: `Unknown gain command.\n${helpText()}`,
allowedMentions: plain,
});
};
}
module.exports = { createGainCommand };
@@ -1,261 +0,0 @@
// Operator Gain Command Tests
// Purpose: Verifies the audio gain boost command stays admin-only and VIP-only.
// Scope: Exercises command target resolution with in-memory identity doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createGainCommand } = require('./gain');
const VIPS = [
{ userId: 'usr-vip', nickname: 'Croissant', cookieUserId: 'cookie-croissant' },
{ userId: 'usr-other', nickname: 'Baguette', cookieUserId: 'cookie-baguette' },
];
// Two verified records sharing one nickname. This is the real shape behind the
// "matched multiple records" failure: one person re-verifying from a new browser
// produces a second record with the same name and a different cookie id.
const DUPLICATE_SAULS = [
{ userId: 'usr-saul-a', nickname: 'Saul', cookieUserId: 'cu_a28ffffffff33ab5c' },
{ userId: 'usr-saul-b', nickname: 'Saul', cookieUserId: 'cu_5a5ffffffffb6add3' },
];
function createSocketRegistry(onlineUserIds = []) {
const sockets = new Map();
onlineUserIds.forEach((userId, index) => {
// Two sockets per identity, so the resolver must dedupe rather than count
// connections.
sockets.set(`s${index}a`, { id: `s${index}a`, data: { userId } });
sockets.set(`s${index}b`, { id: `s${index}b`, data: { userId } });
});
return { sockets: { sockets } };
}
function createHarness({ verified = VIPS, boosted = [], isAdmin = true, online = [] } = {}) {
const calls = [];
const replies = [];
const handler = createGainCommand({
io: createSocketRegistry(online),
listVerifiedUsers: () => verified,
listAudioGainBoostUsers: () => boosted,
grantAudioGainBoost: (selector, actor) => {
calls.push({ action: 'grant', selector, actor });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
revokeAudioGainBoost: (selector, actor) => {
calls.push({ action: 'revoke', selector, actor });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
sanitizeMentions: (value) => value,
config: { commands: { prefix: 'rs' } },
});
const message = {
actor: { id: 'admin', isAdmin },
reply: async (payload) => {
replies.push(payload);
return payload;
},
};
return { handler, message, calls, replies };
}
test('non-admins cannot manage the boost', async () => {
const { handler, message, calls, replies } = createHarness({ isAdmin: false });
await handler(message, ['grant', 'Croissant']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /Only admins/);
});
test('grant resolves a VIP nickname to its stable user id', async () => {
const { handler, message, calls } = createHarness();
await handler(message, ['grant', 'croissant']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-vip', actor: 'admin' }]);
});
test('grant refuses a nickname that belongs to no VIP', async () => {
const { handler, message, calls, replies } = createHarness({ verified: [] });
await handler(message, ['grant', 'Stranger']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /not found/i);
});
test('revoke only matches users who currently hold the boost', async () => {
const { handler, message, calls, replies } = createHarness({ boosted: [] });
await handler(message, ['revoke', 'Croissant']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /not found/i);
});
test('revoke resolves against the boosted list', async () => {
const { handler, message, calls } = createHarness({ boosted: [VIPS[0]] });
await handler(message, ['revoke', 'Croissant']);
assert.deepEqual(calls, [{ action: 'revoke', selector: 'usr-vip', actor: 'admin' }]);
});
test('grant without a target prints usage instead of acting', async () => {
const { handler, message, calls, replies } = createHarness();
await handler(message, ['grant']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /rs gain grant/);
});
test('list defaults when no subcommand is given', async () => {
const { handler, message, replies } = createHarness({ boosted: [VIPS[0]] });
await handler(message, []);
assert.match(replies[0].content, /Croissant/);
assert.match(replies[0].content, /usr-vip/);
});
test('list reports an empty holder set', async () => {
const { handler, message, replies } = createHarness({ boosted: [] });
await handler(message, ['list']);
assert.match(replies[0].content, /No users hold/);
});
test('duplicate nicknames resolve to the account that is online', async () => {
const { handler, message, calls, replies } = createHarness({
verified: DUPLICATE_SAULS,
online: ['usr-saul-b'],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-b', actor: 'admin' }]);
assert.doesNotMatch(replies[0].content, /matched multiple records/i);
assert.match(replies[0].content, /2 accounts share that name; picked the one that is online/);
});
test('duplicate nicknames fall back to the first record when nobody is online', async () => {
const { handler, message, calls, replies } = createHarness({
verified: DUPLICATE_SAULS,
online: [],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-a', actor: 'admin' }]);
assert.match(replies[0].content, /none are online; picked the first/);
});
test('an unrelated online user does not influence the pick', async () => {
const { handler, message, calls } = createHarness({
verified: DUPLICATE_SAULS,
online: ['usr-somebody-else'],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-a', actor: 'admin' }]);
});
test('several duplicates online pick one deterministically rather than refusing', async () => {
const { handler, message, calls, replies } = createHarness({
verified: DUPLICATE_SAULS,
online: ['usr-saul-a', 'usr-saul-b'],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-a', actor: 'admin' }]);
assert.match(replies[0].content, /picked the one that is online/);
});
test('revoke disambiguates the same way against the boosted list', async () => {
const { handler, message, calls } = createHarness({
boosted: DUPLICATE_SAULS,
online: ['usr-saul-b'],
});
await handler(message, ['revoke', 'Saul']);
assert.deepEqual(calls, [{ action: 'revoke', selector: 'usr-saul-b', actor: 'admin' }]);
});
test('a unique nickname reports no disambiguation note', async () => {
const { handler, message, replies } = createHarness();
await handler(message, ['grant', 'Croissant']);
assert.doesNotMatch(replies[0].content, /accounts share that name/);
});
test('an exact cookieUserId still selects one record out of a duplicate pair', async () => {
const { handler, message, calls } = createHarness({ verified: DUPLICATE_SAULS });
await handler(message, ['grant', 'cu_5a5ffffffffb6add3']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-b', actor: 'admin' }]);
});
test('a typo still resolves through the fuzzy matcher', async () => {
const { handler, message, calls } = createHarness();
await handler(message, ['grant', 'Croissnat']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-vip', actor: 'admin' }]);
});
test('help lists every subcommand', async () => {
const { handler, message, calls, replies } = createHarness();
await handler(message, ['help']);
assert.deepEqual(calls, [], 'help must not change anything');
for (const fragment of ['rs gain list', 'rs gain grant <vip>', 'rs gain revoke <vip>', 'rs gain help']) {
assert.ok(replies[0].content.includes(fragment), `help should mention ${fragment}`);
}
// Subcommand help follows the same copy-friendly punctuation rule as the
// shared `rs help` renderer instead of quietly reintroducing em dashes.
assert.doesNotMatch(replies[0].content, /—/);
});
test('an unknown subcommand falls back to the same help text', async () => {
const { handler, message, calls, replies } = createHarness();
await handler(message, ['sideways']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /Unknown gain command/);
assert.ok(replies[0].content.includes('rs gain grant <vip>'));
});
test('help stays admin-only like the rest of the command', async () => {
const { handler, message, replies } = createHarness({ isAdmin: false });
await handler(message, ['help']);
assert.match(replies[0].content, /Only admins/);
});
test('a service rejection is surfaced instead of thrown', async () => {
const { handler, message, replies } = createHarness();
const failing = createGainCommand({
io: createSocketRegistry([]),
listVerifiedUsers: () => VIPS,
listAudioGainBoostUsers: () => [],
grantAudioGainBoost: () => {
throw new Error('Only verified VIPs can be granted an audio gain boost.');
},
revokeAudioGainBoost: () => null,
sanitizeMentions: (value) => value,
config: { commands: { prefix: 'rs' } },
});
await failing(message, ['grant', 'Croissant']);
assert.match(replies[0].content, /Only verified VIPs/);
});
@@ -0,0 +1,108 @@
// Operator Permissions Command
// Purpose: Lets administrators inspect and change registered positive user capabilities.
// Scope: Resolves canonical users and delegates persistence to identityService without embedding feature-specific permission logic.
const { mask, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../config');
function commandCandidates(users = []) {
return users.map((user) => ({
...user,
userId: user.id,
cookieUserId: user.cookieUserIds?.[0] || null,
fingerprintId: user.fingerprintIds?.[0] || null,
}));
}
function createPermissionsCommand({
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
sanitizeMentions,
config,
}) {
const { prefix } = getCommandConfig(config);
const plain = { parse: [], repliedUser: false };
function helpText() {
return [
'**User permissions**',
`- \`${prefix} permissions\`: list available permissions.`,
`- \`${prefix} permissions list <permission>\`: list users with a permission.`,
`- \`${prefix} permissions grant <permission> <user>\`: grant a permission.`,
`- \`${prefix} permissions revoke <permission> <user>\`: revoke a permission.`,
].join('\n');
}
function resolvePermission(selector) {
const needle = String(selector || '').trim().toLowerCase();
return listRegisteredPermissions().find((permission) => (
permission.key.toLowerCase() === needle || permission.commandName.toLowerCase() === needle
)) || null;
}
return async function handlePermissionsCommand(message, tokens = []) {
if (!message.actor?.isAdmin) {
return message.reply({ content: 'Only admins can manage user permissions.', allowedMentions: plain });
}
const action = (tokens.shift() || 'help').toLowerCase();
if (action === 'help') return message.reply({ content: helpText(), allowedMentions: plain });
if (action !== 'list' && action !== 'grant' && action !== 'revoke') {
return message.reply({ content: `Unknown permissions command.\n${helpText()}`, allowedMentions: plain });
}
const permissionSelector = tokens.shift();
if (!permissionSelector && action === 'list') {
const lines = listRegisteredPermissions().map((permission) => (
`- \`${permission.commandName}\`: ${permission.description}`
));
return message.reply({ content: ['Registered user permissions:', ...lines].join('\n'), allowedMentions: plain });
}
const permission = resolvePermission(permissionSelector);
if (!permission) {
return message.reply({ content: `Unknown permission. Use \`${prefix} permissions list\` to see valid names.`, allowedMentions: plain });
}
if (action === 'list') {
const users = listUsersWithPermission(permission.key);
if (!users.length) return message.reply({ content: `No users have ${permission.label}.`, allowedMentions: plain });
const lines = users.map((user, index) => (
`${index + 1}. ${user.nickname || 'unknown'} | ${user.id} | ${mask(user.cookieUserIds?.[0])}`
));
return message.reply({
content: sanitizeMentions([`${permission.label}:`, ...lines].join('\n').slice(0, 1900)),
allowedMentions: plain,
});
}
const selector = tokens.join(' ').trim();
if (!selector) {
return message.reply({
content: `Usage: \`${prefix} permissions ${action} ${permission.commandName} <user>\``,
allowedMentions: plain,
});
}
const resolved = resolveIdentitySelector(selector, commandCandidates(listUsersForAdmin()));
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: plain });
try {
const user = setUserPermission(resolved.record.id, permission.key, {
enabled: action === 'grant',
actor: message.actor?.id || null,
at: Date.now(),
});
return message.reply({
content: sanitizeMentions(`${action === 'grant' ? 'Granted' : 'Revoked'} ${permission.label} for ${user.nickname || 'unknown'} (${user.id}).`),
allowedMentions: plain,
});
} catch (err) {
return message.reply({ content: sanitizeMentions(`Permission update failed: ${err.message}`), allowedMentions: plain });
}
};
}
module.exports = { createPermissionsCommand };
@@ -0,0 +1,64 @@
// Operator Permissions Command Tests
// Purpose: Pins admin authorization and the universal grant, revoke, and list command contract.
// Scope: Uses in-memory identity doubles; database persistence is tested by identityService.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createPermissionsCommand } = require('./permissions');
const USER = {
id: 'usr_11111111111111111111111111111111',
nickname: 'alice',
cookieUserIds: ['cu_11111111111111111111111111111111'],
fingerprintIds: [],
knownIps: [],
};
const PERMISSION = {
key: 'audio.personalAdjustment',
commandName: 'audio-adjustment',
label: 'Personal audio adjustment',
description: 'Allows personal volume adjustments.',
};
function harness({ isAdmin = true, granted = [] } = {}) {
const replies = [];
const changes = [];
const handler = createPermissionsCommand({
listUsersForAdmin: () => [USER],
listUsersWithPermission: () => granted,
listRegisteredPermissions: () => [PERMISSION],
setUserPermission: (userId, permissionKey, options) => {
changes.push({ userId, permissionKey, options });
return USER;
},
sanitizeMentions: String,
config: { commands: { prefix: 'rs' } },
});
const message = {
actor: { id: 'admin-1', isAdmin },
reply: async (payload) => replies.push(payload.content),
};
return { handler, message, replies, changes };
}
test('non-admin users cannot inspect or change grants', async () => {
const { handler, message, replies } = harness({ isAdmin: false });
await handler(message, ['list']);
assert.match(replies[0], /Only admins/);
});
test('grant resolves a user and writes the registered permission key', async () => {
const { handler, message, changes, replies } = harness();
await handler(message, ['grant', 'audio-adjustment', 'alice']);
assert.equal(changes[0].userId, USER.id);
assert.equal(changes[0].permissionKey, PERMISSION.key);
assert.equal(changes[0].options.enabled, true);
assert.match(replies[0], /Granted Personal audio adjustment/);
});
test('list shows users holding a permission', async () => {
const { handler, message, replies } = harness({ granted: [USER] });
await handler(message, ['list', 'audio-adjustment']);
assert.match(replies[0], /alice/);
assert.match(replies[0], new RegExp(USER.id));
});
@@ -8,7 +8,7 @@ const { createReasonCommand } = require('./commands/reason');
const { createGoalCommand } = require('./commands/goal');
const { createVerifyCommand } = require('./commands/verify');
const { createDeterCommand } = require('./commands/deter');
const { createGainCommand } = require('./commands/gain');
const { createPermissionsCommand } = require('./commands/permissions');
const { createLightsCommand } = require('./commands/lights');
const { createKickCommand } = require('./commands/kick');
const { createLiftCommand } = require('./commands/lift');
@@ -57,7 +57,7 @@ function createCommandHandlers(deps) {
const handleGoalCommand = createGoalCommand(deps);
const handleVerifyCommand = createVerifyCommand(deps);
const handleDeterCommand = createDeterCommand(deps);
const handleGainCommand = createGainCommand(deps);
const handlePermissionsCommand = createPermissionsCommand(deps);
const handleBridgeCommand = transportHandlers.bridge;
const handleTimeStatusCommand = transportHandlers.timeStatus;
const handleLightsCommand = createLightsCommand(deps);
@@ -108,7 +108,7 @@ function createCommandHandlers(deps) {
// is included because its lock/unlock subcommands change room policy. Its
// ordinary on/off/color actions are also intentionally restricted to a
// lockdown admin while the entire server is in lockdown.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'gain', 'lights', 'kick', 'lift', 'neato']);
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'permissions', 'lights', 'kick', 'lift', 'neato']);
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
// Feature commands are public activities while access is open or managed
@@ -164,8 +164,8 @@ function createCommandHandlers(deps) {
return handleVerifyCommand(request, tokens);
case 'deter':
return handleDeterCommand(request, tokens);
case 'gain':
return handleGainCommand(request, tokens);
case 'permissions':
return handlePermissionsCommand(request, tokens);
default:
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
}
@@ -35,6 +35,15 @@ function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
listVerifiedUsers: () => [],
listDeterredUsers: () => [],
listMutedUsers: () => [],
listUsersForAdmin: () => [],
listUsersWithPermission: () => [],
listRegisteredPermissions: () => [{
key: 'audio.personalAdjustment',
commandName: 'audio-adjustment',
label: 'Personal audio adjustment',
description: 'Allows personal volume adjustments.',
}],
setUserPermission: () => null,
getGlobalObjective: () => null,
getAdminReason: () => null,
config: { commands: { prefix: 'rs' } },
@@ -63,7 +72,7 @@ const admin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLo
test('admin-only commands stay admin-only for a non-admin', async () => {
const run = createRouter();
for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs kick alice']) {
for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs kick alice', 'rs permissions list']) {
assert.match(await run(command, nonAdmin), ADMIN_DENIAL, `${command} must stay admin-only`);
}
});
@@ -3,7 +3,7 @@
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter', 'gain'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter', 'permissions'] },
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
discord: { title: 'Discord', names: ['bridge'] },
};
@@ -46,14 +46,13 @@ function buildCommandRegistry(prefix, timeCommand) {
access: 'Lockdown admin',
permission: 'lockdown-admin',
},
gain: {
permissions: {
category: 'admin',
summary: 'Manage the VIP audio gain boost that raises a user\'s volume ceiling past the global gains.',
summary: 'Manage registered user permissions.',
usage: [
`${prefix} gain list`,
`${prefix} gain grant <vip>`,
`${prefix} gain revoke <vip>`,
`${prefix} gain help`,
`${prefix} permissions list [permission]`,
`${prefix} permissions grant <permission> <user>`,
`${prefix} permissions revoke <permission> <user>`,
],
access: 'Admin',
permission: 'admin',