mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
moving audio gain perms around
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user