mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
moving audio gain perms around
This commit is contained in:
@@ -1,79 +1,62 @@
|
||||
// audio Levels Gain Math
|
||||
// Purpose: Holds the pure clamping and ceiling rules shared by every gain layer.
|
||||
// Scope: No IO, no state; keeps the volume policy independently reviewable and testable.
|
||||
|
||||
/*
|
||||
The three gain keys are the same on every layer of this feature: the global
|
||||
admin gains, the admin-editable VIP boost caps, and each user's personal
|
||||
preference. Iterating one list keeps those layers from drifting apart.
|
||||
*/
|
||||
const GAIN_KEYS = ['hornGain', 'ttsGain', 'forwardGain'];
|
||||
|
||||
// Absolute gain limits accepted anywhere a multiplier is stored.
|
||||
// Audio Adjustment Math
|
||||
// Purpose: Converts signed browser percentages into server-enforced rover gain multipliers.
|
||||
// Scope: Contains no IO or identity logic so the adjustment policy can be tested independently.
|
||||
const ADJUSTMENT_FIELDS = [
|
||||
{ gainKey: 'hornGain', percentKey: 'hornPercent' },
|
||||
{ gainKey: 'ttsGain', percentKey: 'ttsPercent' },
|
||||
{ gainKey: 'forwardGain', percentKey: 'forwardPercent' },
|
||||
];
|
||||
const MIN_GAIN = 0;
|
||||
const MAX_GAIN = 4;
|
||||
const MIN_ADJUSTMENT_PERCENT = -100;
|
||||
const MAX_ADJUSTMENT_PERCENT = 100;
|
||||
|
||||
function clampGain(value, fallback = 1) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, num));
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, number));
|
||||
}
|
||||
|
||||
function clampFraction(value, fallback = 1) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(1, num));
|
||||
function clampMaximumAdjustmentPercent(value, fallback = 50) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.round(Math.max(0, Math.min(MAX_ADJUSTMENT_PERCENT, number)));
|
||||
}
|
||||
|
||||
function normalizeUserGains(raw = {}) {
|
||||
const out = {};
|
||||
GAIN_KEYS.forEach((key) => {
|
||||
out[key] = clampFraction(raw?.[key], 1);
|
||||
function clampAdjustmentPercent(value, maximum = 0) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return 0;
|
||||
const limit = clampMaximumAdjustmentPercent(maximum, 0);
|
||||
return Math.round(Math.max(-limit, Math.min(limit, number)));
|
||||
}
|
||||
|
||||
function normalizeAdjustments(raw = {}, maximum = 0) {
|
||||
const normalized = {};
|
||||
ADJUSTMENT_FIELDS.forEach(({ percentKey }) => {
|
||||
normalized[percentKey] = clampAdjustmentPercent(raw?.[percentKey], maximum);
|
||||
});
|
||||
return out;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeGainSet(raw = {}, fallback = {}) {
|
||||
const out = {};
|
||||
GAIN_KEYS.forEach((key) => {
|
||||
out[key] = clampGain(raw?.[key], clampGain(fallback?.[key], 1));
|
||||
function applyAdjustments(baseLevels = {}, adjustments = {}) {
|
||||
const effective = {};
|
||||
ADJUSTMENT_FIELDS.forEach(({ gainKey, percentKey }) => {
|
||||
const base = clampGain(baseLevels?.[gainKey], 0);
|
||||
const percentage = Math.max(MIN_ADJUSTMENT_PERCENT, Math.min(MAX_ADJUSTMENT_PERCENT, Number(adjustments?.[percentKey]) || 0));
|
||||
effective[gainKey] = clampGain(base * (1 + percentage / 100), 0);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/*
|
||||
A user without the boost flag can never exceed the global admin gain. The flag
|
||||
raises the ceiling to the admin-managed hard cap, and Math.max keeps the flag
|
||||
from ever being a downgrade: if an admin runs the global gain higher than the
|
||||
boost cap, a boosted user keeps the global ceiling instead of losing volume
|
||||
for holding a permission.
|
||||
*/
|
||||
function resolveCeilings({ adminLimits = {}, boostCaps = {}, hasBoost = false } = {}) {
|
||||
const out = {};
|
||||
GAIN_KEYS.forEach((key) => {
|
||||
const adminCeiling = clampGain(adminLimits?.[key], 0);
|
||||
out[key] = hasBoost ? Math.max(adminCeiling, clampGain(boostCaps?.[key], 0)) : adminCeiling;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Personal preferences are fractions of whichever ceiling applies to the user.
|
||||
function applyCeilings(fractions = {}, ceilings = {}) {
|
||||
const out = {};
|
||||
GAIN_KEYS.forEach((key) => {
|
||||
out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0);
|
||||
});
|
||||
return out;
|
||||
return effective;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GAIN_KEYS,
|
||||
ADJUSTMENT_FIELDS,
|
||||
MIN_GAIN,
|
||||
MAX_GAIN,
|
||||
MIN_ADJUSTMENT_PERCENT,
|
||||
MAX_ADJUSTMENT_PERCENT,
|
||||
clampGain,
|
||||
clampFraction,
|
||||
normalizeUserGains,
|
||||
normalizeGainSet,
|
||||
resolveCeilings,
|
||||
applyCeilings,
|
||||
clampMaximumAdjustmentPercent,
|
||||
clampAdjustmentPercent,
|
||||
normalizeAdjustments,
|
||||
applyAdjustments,
|
||||
};
|
||||
|
||||
@@ -1,85 +1,37 @@
|
||||
// audio Levels Gain Math Tests
|
||||
// Purpose: Pins the ceiling rules that keep user volume inside admin limits.
|
||||
// Scope: Pure math only; no store, socket, or rover involvement.
|
||||
// Audio Adjustment Math Tests
|
||||
// Purpose: Pins percentage clamping and conversion independently of sockets, identity, and rover IO.
|
||||
// Scope: Covers only the pure rules used by audioLevelsService.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
clampFraction,
|
||||
clampGain,
|
||||
normalizeUserGains,
|
||||
normalizeGainSet,
|
||||
resolveCeilings,
|
||||
applyCeilings,
|
||||
} = require('./gainMath');
|
||||
const { clampMaximumAdjustmentPercent, normalizeAdjustments, applyAdjustments } = require('./gainMath');
|
||||
|
||||
const ADMIN_LIMITS = { hornGain: 0.3, ttsGain: 0.2, forwardGain: 0.1 };
|
||||
const BOOST_CAPS = { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 };
|
||||
|
||||
test('an unboosted user is capped by the global admin gains', () => {
|
||||
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: false });
|
||||
assert.deepEqual(ceilings, ADMIN_LIMITS);
|
||||
test('the configured range is a whole percentage from zero through one hundred', () => {
|
||||
assert.equal(clampMaximumAdjustmentPercent(-5), 0);
|
||||
assert.equal(clampMaximumAdjustmentPercent(32.6), 33);
|
||||
assert.equal(clampMaximumAdjustmentPercent(500), 100);
|
||||
});
|
||||
|
||||
test('the boost flag raises the ceiling to the hard caps', () => {
|
||||
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: true });
|
||||
assert.deepEqual(ceilings, BOOST_CAPS);
|
||||
});
|
||||
|
||||
test('the boost flag never lowers a ceiling when admin gains exceed the caps', () => {
|
||||
const loud = { hornGain: 2, ttsGain: 1.5, forwardGain: 3 };
|
||||
const ceilings = resolveCeilings({ adminLimits: loud, boostCaps: BOOST_CAPS, hasBoost: true });
|
||||
assert.deepEqual(ceilings, loud);
|
||||
});
|
||||
|
||||
test('a full personal slider resolves to exactly the ceiling', () => {
|
||||
const effective = applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ADMIN_LIMITS);
|
||||
assert.deepEqual(effective, ADMIN_LIMITS);
|
||||
});
|
||||
|
||||
test('a personal slider scales the ceiling rather than replacing it', () => {
|
||||
const effective = applyCeilings({ hornGain: 0.5, ttsGain: 0.5, forwardGain: 0.5 }, BOOST_CAPS);
|
||||
assert.deepEqual(effective, { hornGain: 0.25, ttsGain: 0.4, forwardGain: 0.2 });
|
||||
});
|
||||
|
||||
test('an out-of-range personal value cannot escape the ceiling', () => {
|
||||
const effective = applyCeilings({ hornGain: 12, ttsGain: -4, forwardGain: 'loud' }, ADMIN_LIMITS);
|
||||
assert.equal(effective.hornGain, ADMIN_LIMITS.hornGain);
|
||||
assert.equal(effective.ttsGain, 0);
|
||||
// A non-numeric value falls back to the full slider, still bounded by the ceiling.
|
||||
assert.equal(effective.forwardGain, ADMIN_LIMITS.forwardGain);
|
||||
});
|
||||
|
||||
test('a zero admin gain silences even a boosted user at full slider', () => {
|
||||
const ceilings = resolveCeilings({
|
||||
adminLimits: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
|
||||
boostCaps: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
|
||||
hasBoost: true,
|
||||
});
|
||||
assert.deepEqual(applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ceilings), {
|
||||
hornGain: 0,
|
||||
ttsGain: 0,
|
||||
forwardGain: 0,
|
||||
test('each browser percentage is clamped equally in both directions', () => {
|
||||
assert.deepEqual(normalizeAdjustments({ hornPercent: -80, ttsPercent: 10, forwardPercent: 90 }, 40), {
|
||||
hornPercent: -40,
|
||||
ttsPercent: 10,
|
||||
forwardPercent: 40,
|
||||
});
|
||||
});
|
||||
|
||||
test('personal values normalize into the 0..1 range with a full-volume default', () => {
|
||||
assert.deepEqual(normalizeUserGains({ hornGain: 0.25, ttsGain: 9 }), {
|
||||
hornGain: 0.25,
|
||||
ttsGain: 1,
|
||||
forwardGain: 1,
|
||||
});
|
||||
test('signed percentages adjust each server base gain', () => {
|
||||
assert.deepEqual(
|
||||
applyAdjustments(
|
||||
{ hornGain: 1, ttsGain: 2, forwardGain: 0.5 },
|
||||
{ hornPercent: -25, ttsPercent: 25, forwardPercent: 40 },
|
||||
),
|
||||
{ hornGain: 0.75, ttsGain: 2.5, forwardGain: 0.7 },
|
||||
);
|
||||
});
|
||||
|
||||
test('gain sets normalize into the 0..4 range and fall back per key', () => {
|
||||
assert.deepEqual(normalizeGainSet({ hornGain: 9, ttsGain: 'x' }, BOOST_CAPS), {
|
||||
hornGain: 4,
|
||||
ttsGain: BOOST_CAPS.ttsGain,
|
||||
forwardGain: BOOST_CAPS.forwardGain,
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps reject non-finite input by returning the supplied fallback', () => {
|
||||
assert.equal(clampGain(Number.NaN, 0.7), 0.7);
|
||||
assert.equal(clampGain(Infinity, 0.7), 0.7);
|
||||
assert.equal(clampFraction(undefined, 0.4), 0.4);
|
||||
test('effective gains remain inside the rover hard bounds', () => {
|
||||
assert.deepEqual(
|
||||
applyAdjustments({ hornGain: 4, ttsGain: 0, forwardGain: 3 }, { hornPercent: 100, ttsPercent: -100, forwardPercent: 100 }),
|
||||
{ hornGain: 4, ttsGain: 0, forwardGain: 4 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,18 +7,16 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('audioLevelsService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const { isAdmin, roleEvents } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
|
||||
const { identityEvents, getUserIdForSocket, hasUserPermission } = require('../identityService');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const {
|
||||
GAIN_KEYS,
|
||||
ADJUSTMENT_FIELDS,
|
||||
clampGain,
|
||||
clampFraction,
|
||||
normalizeUserGains,
|
||||
normalizeGainSet,
|
||||
resolveCeilings,
|
||||
applyCeilings,
|
||||
clampMaximumAdjustmentPercent,
|
||||
normalizeAdjustments,
|
||||
applyAdjustments,
|
||||
} = require('./gainMath');
|
||||
|
||||
const audioLevelsEvents = new EventEmitter();
|
||||
@@ -26,48 +24,32 @@ const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('audio-levels.json');
|
||||
const config = loadConfig();
|
||||
const configuredDefaults = config.audioLevels || {};
|
||||
const configuredUserCaps = configuredDefaults.userGainCaps || {};
|
||||
|
||||
/*
|
||||
Per-user preferences live in identity feature state so they follow the user
|
||||
across browsers and cannot be raised by editing a client-side cookie. They are
|
||||
stored as a 0..1 fraction of whatever ceiling currently applies rather than an
|
||||
absolute gain, so lowering the global admin gain immediately quiets everyone
|
||||
without having to rewrite every stored preference.
|
||||
*/
|
||||
const USER_GAINS_NAMESPACE = 'audioGains';
|
||||
|
||||
/*
|
||||
Absolute ceilings for users holding the audioGainBoost flag. These are the
|
||||
hard caps the flag cannot exceed; admins can retune them from the driver page.
|
||||
*/
|
||||
const USER_GAIN_CAP_DEFAULTS = {
|
||||
hornGain: 0.5,
|
||||
ttsGain: 0.8,
|
||||
forwardGain: 0.4,
|
||||
};
|
||||
const PERSONAL_ADJUSTMENT_PERMISSION = 'audio.personalAdjustment';
|
||||
const DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT = 50;
|
||||
|
||||
const DEFAULTS = {
|
||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||
userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS),
|
||||
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
|
||||
configuredDefaults.maxPersonalAdjustmentPercent,
|
||||
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
|
||||
),
|
||||
};
|
||||
|
||||
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
|
||||
return normalizeGainSet(raw, fallback);
|
||||
}
|
||||
|
||||
function normalizeStore(raw = {}) {
|
||||
return {
|
||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||
userGainCaps: normalizeUserGainCaps(raw.userGainCaps),
|
||||
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
|
||||
raw.maxPersonalAdjustmentPercent,
|
||||
DEFAULTS.maxPersonalAdjustmentPercent,
|
||||
),
|
||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
||||
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
||||
capsUpdatedAt: Number.isFinite(raw.capsUpdatedAt) ? raw.capsUpdatedAt : null,
|
||||
capsUpdatedBy: typeof raw.capsUpdatedBy === 'string' ? raw.capsUpdatedBy : null,
|
||||
adjustmentRangeUpdatedAt: Number.isFinite(raw.adjustmentRangeUpdatedAt) ? raw.adjustmentRangeUpdatedAt : null,
|
||||
adjustmentRangeUpdatedBy: typeof raw.adjustmentRangeUpdatedBy === 'string' ? raw.adjustmentRangeUpdatedBy : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +60,11 @@ function loadState() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
state = normalizeStore(raw);
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'userGainCaps')) {
|
||||
// Rewrite once so the retired VIP-cap object does not linger beside the
|
||||
// new percentage range and confuse future operator inspection.
|
||||
persistState(state);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load audio levels store', err.message);
|
||||
@@ -103,18 +90,14 @@ function getAudioLevels() {
|
||||
hornGain: current.hornGain,
|
||||
ttsGain: current.ttsGain,
|
||||
forwardGain: current.forwardGain,
|
||||
userGainCaps: { ...current.userGainCaps },
|
||||
maxPersonalAdjustmentPercent: current.maxPersonalAdjustmentPercent,
|
||||
updatedAt: current.updatedAt,
|
||||
updatedBy: current.updatedBy,
|
||||
capsUpdatedAt: current.capsUpdatedAt,
|
||||
capsUpdatedBy: current.capsUpdatedBy,
|
||||
adjustmentRangeUpdatedAt: current.adjustmentRangeUpdatedAt,
|
||||
adjustmentRangeUpdatedBy: current.adjustmentRangeUpdatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function getUserGainCaps() {
|
||||
return { ...loadState().userGainCaps };
|
||||
}
|
||||
|
||||
function emitChange(reason = 'update', extra = {}) {
|
||||
audioLevelsEvents.emit('change', {
|
||||
reason,
|
||||
@@ -132,30 +115,19 @@ function getAdminLimits() {
|
||||
};
|
||||
}
|
||||
|
||||
function getGainCeilings(hasBoost) {
|
||||
const current = loadState();
|
||||
return resolveCeilings({
|
||||
adminLimits: getAdminLimits(),
|
||||
boostCaps: current.userGainCaps,
|
||||
hasBoost,
|
||||
});
|
||||
function canUsePersonalAdjustments(socket) {
|
||||
if (isAdmin(socket)) return true;
|
||||
const userId = getUserIdForSocket(socket);
|
||||
return Boolean(userId && hasUserPermission(userId, PERSONAL_ADJUSTMENT_PERMISSION));
|
||||
}
|
||||
|
||||
function getGainCeilingsForSocket(socket) {
|
||||
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
|
||||
}
|
||||
|
||||
function getUserGains(userId) {
|
||||
if (!userId) return normalizeUserGains({});
|
||||
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
|
||||
}
|
||||
|
||||
function getUserGainsForSocket(socket) {
|
||||
return getUserGains(getUserIdForSocket(socket));
|
||||
function getAdjustmentsForSocket(socket) {
|
||||
if (!canUsePersonalAdjustments(socket)) return normalizeAdjustments({}, 0);
|
||||
return normalizeAdjustments(socket?.data?.audioAdjustments, loadState().maxPersonalAdjustmentPercent);
|
||||
}
|
||||
|
||||
function getEffectiveLevelsForSocket(socket) {
|
||||
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
|
||||
return applyAdjustments(getAdminLimits(), getAdjustmentsForSocket(socket));
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -234,55 +206,49 @@ function setAudioLevels(input = {}, actor = null) {
|
||||
return getAudioLevels();
|
||||
}
|
||||
|
||||
function setUserGainCaps(input = {}, actor = null) {
|
||||
function setMaxPersonalAdjustmentPercent(value, actor = null) {
|
||||
const current = loadState();
|
||||
const next = {
|
||||
...current,
|
||||
userGainCaps: normalizeUserGainCaps(input, current.userGainCaps),
|
||||
capsUpdatedAt: Date.now(),
|
||||
capsUpdatedBy: actor,
|
||||
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(value, current.maxPersonalAdjustmentPercent),
|
||||
adjustmentRangeUpdatedAt: Date.now(),
|
||||
adjustmentRangeUpdatedBy: actor,
|
||||
};
|
||||
persistState(next);
|
||||
/*
|
||||
Lowering a cap has to take effect immediately for anyone already driving,
|
||||
otherwise a boosted user keeps the louder gain until their next turn.
|
||||
A narrower range must take effect immediately for current drivers rather
|
||||
than leaving an out-of-range multiplier active until their next turn.
|
||||
*/
|
||||
pushLevelsToAllRovers();
|
||||
emitChange('user_caps_set');
|
||||
return getUserGainCaps();
|
||||
emitChange('personal_adjustment_range_set');
|
||||
return loadState().maxPersonalAdjustmentPercent;
|
||||
}
|
||||
|
||||
function setUserGains(socket, input = {}) {
|
||||
const userId = getUserIdForSocket(socket);
|
||||
if (!userId) throw new Error('Identity required');
|
||||
const current = getUserGains(userId);
|
||||
const next = { ...current };
|
||||
GAIN_KEYS.forEach((key) => {
|
||||
if (input?.[key] === undefined) return;
|
||||
next[key] = clampFraction(input[key], current[key]);
|
||||
});
|
||||
setFeatureState(userId, USER_GAINS_NAMESPACE, next);
|
||||
function setSocketAdjustments(socket, input = {}) {
|
||||
socket.data = socket.data || {};
|
||||
// Store only server-normalized percentages on the transport. The cookie is a
|
||||
// browser preference, while permission and range enforcement remain here.
|
||||
socket.data.audioAdjustments = normalizeAdjustments(input, 100);
|
||||
pushLevelsForSocket(socket);
|
||||
emitChange('user_gains_set', { scope: 'user', userId });
|
||||
return getAudioGainStateForSocket(socket);
|
||||
emitChange('personal_adjustments_set', { scope: 'socket', socketId: socket.id });
|
||||
return getAudioAdjustmentStateForSocket(socket);
|
||||
}
|
||||
|
||||
/*
|
||||
The client needs all three layers to render an honest slider: its own stored
|
||||
fraction, the ceiling that fraction is measured against, and the resolved gain
|
||||
so the UI can show what the rover will actually play.
|
||||
The client receives the percentages the server accepted, the permitted range,
|
||||
and the resulting multipliers. This keeps the UI honest even when a cookie was
|
||||
edited or an administrator changed permission while the browser was online.
|
||||
*/
|
||||
function getAudioGainStateForSocket(socket) {
|
||||
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
|
||||
const values = getUserGainsForSocket(socket);
|
||||
const ceilings = getGainCeilings(hasBoost);
|
||||
function getAudioAdjustmentStateForSocket(socket) {
|
||||
const allowed = canUsePersonalAdjustments(socket);
|
||||
const maximum = loadState().maxPersonalAdjustmentPercent;
|
||||
const values = allowed ? getAdjustmentsForSocket(socket) : normalizeAdjustments({}, 0);
|
||||
return {
|
||||
values,
|
||||
ceilings,
|
||||
effective: applyCeilings(values, ceilings),
|
||||
boostGranted: hasBoost,
|
||||
adminLimits: getAdminLimits(),
|
||||
boostCaps: getUserGainCaps(),
|
||||
allowed,
|
||||
maxAdjustmentPercent: maximum,
|
||||
effective: applyAdjustments(getAdminLimits(), values),
|
||||
baseLevels: getAdminLimits(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,6 +277,23 @@ setImmediate(() => {
|
||||
}
|
||||
});
|
||||
|
||||
identityEvents.on('change', ({ reason, userId } = {}) => {
|
||||
if (!userId || !['permission_granted', 'permission_revoked', 'identify'].includes(reason)) return;
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
if (getUserIdForSocket(socket) !== userId) return;
|
||||
pushLevelsForSocket(socket);
|
||||
// Permission changes alter both effective rover output and the controls the
|
||||
// browser may use, so each affected connection receives a fresh session.
|
||||
emitChange('personal_adjustment_permission_changed', { scope: 'socket', socketId: socket.id });
|
||||
});
|
||||
});
|
||||
|
||||
roleEvents.on('change', ({ socket } = {}) => {
|
||||
// Administrators implicitly have this capability, so login/logout can change
|
||||
// the effective adjustment even though no database permission row changed.
|
||||
if (socket) pushLevelsForSocket(socket);
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||
cb({ success: true, levels: getAudioLevels() });
|
||||
@@ -329,30 +312,30 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audioLevels:setUserCaps', (payload = {}, cb = () => {}) => {
|
||||
socket.on('audioLevels:setPersonalAdjustmentRange', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
if (!isAdmin(socket)) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
const actor = socket?.data?.user?.username || null;
|
||||
const userGainCaps = setUserGainCaps(payload || {}, actor);
|
||||
cb({ success: true, userGainCaps });
|
||||
const maxPersonalAdjustmentPercent = setMaxPersonalAdjustmentPercent(payload?.maxAdjustmentPercent, actor);
|
||||
cb({ success: true, maxPersonalAdjustmentPercent });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audioLevels:getUserGains', (_, cb = () => {}) => {
|
||||
socket.on('audioLevels:getPersonalAdjustments', (_, cb = () => {}) => {
|
||||
try {
|
||||
cb({ success: true, audioGains: getAudioGainStateForSocket(socket) });
|
||||
cb({ success: true, audioAdjustments: getAudioAdjustmentStateForSocket(socket) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audioLevels:setUserGains', (payload = {}, cb = () => {}) => {
|
||||
socket.on('audioLevels:setPersonalAdjustments', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
cb({ success: true, audioGains: setUserGains(socket, payload || {}) });
|
||||
cb({ success: true, audioAdjustments: setSocketAdjustments(socket, payload || {}) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
@@ -362,17 +345,15 @@ io.on('connection', (socket) => {
|
||||
loadState();
|
||||
|
||||
module.exports = {
|
||||
GAIN_KEYS,
|
||||
USER_GAIN_CAP_DEFAULTS,
|
||||
ADJUSTMENT_FIELDS,
|
||||
PERSONAL_ADJUSTMENT_PERMISSION,
|
||||
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
|
||||
getAudioLevels,
|
||||
setAudioLevels,
|
||||
getUserGainCaps,
|
||||
setUserGainCaps,
|
||||
getUserGains,
|
||||
setUserGains,
|
||||
getGainCeilingsForSocket,
|
||||
setMaxPersonalAdjustmentPercent,
|
||||
setSocketAdjustments,
|
||||
getEffectiveLevelsForSocket,
|
||||
getAudioGainStateForSocket,
|
||||
getAudioAdjustmentStateForSocket,
|
||||
pushLevelsToRover,
|
||||
audioLevelsEvents,
|
||||
};
|
||||
|
||||
@@ -22,10 +22,13 @@ const {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
} = require('../verificationService');
|
||||
const {
|
||||
listUsersForAdmin,
|
||||
listUsersWithPermission,
|
||||
listRegisteredPermissions,
|
||||
setUserPermission,
|
||||
} = require('../identityService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
@@ -187,9 +190,10 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
listUsersForAdmin,
|
||||
listUsersWithPermission,
|
||||
listRegisteredPermissions,
|
||||
setUserPermission,
|
||||
sanitizeMentions,
|
||||
sendToChannel: null,
|
||||
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||
|
||||
@@ -46,10 +46,13 @@ const {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
} = require('../verificationService');
|
||||
const {
|
||||
listUsersForAdmin,
|
||||
listUsersWithPermission,
|
||||
listRegisteredPermissions,
|
||||
setUserPermission,
|
||||
} = require('../identityService');
|
||||
const {
|
||||
attachDmMessage: attachPrivateAccessDmMessage,
|
||||
getRequestByMessageId: getPrivateAccessRequestByMessageId,
|
||||
@@ -253,9 +256,10 @@ const commandDependencies = {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
listUsersForAdmin,
|
||||
listUsersWithPermission,
|
||||
listRegisteredPermissions,
|
||||
setUserPermission,
|
||||
sanitizeMentions,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
isAdminUser,
|
||||
|
||||
@@ -12,6 +12,8 @@ const {
|
||||
setVerified,
|
||||
setDeterrence,
|
||||
setMuted,
|
||||
setUserPermission,
|
||||
listRegisteredPermissions,
|
||||
setFeatureState,
|
||||
deleteFeatureState,
|
||||
} = require('../identityService');
|
||||
@@ -71,6 +73,11 @@ function ackHandler(socket, eventName, handler) {
|
||||
io.on('connection', (socket) => {
|
||||
ackHandler(socket, 'identityAdmin:listUsers', () => ({
|
||||
users: listUsersForAdmin(),
|
||||
permissions: listRegisteredPermissions(),
|
||||
}));
|
||||
|
||||
ackHandler(socket, 'identityAdmin:listPermissions', () => ({
|
||||
permissions: listRegisteredPermissions(),
|
||||
}));
|
||||
|
||||
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
|
||||
@@ -112,6 +119,14 @@ io.on('connection', (socket) => {
|
||||
}).id),
|
||||
}));
|
||||
|
||||
ackHandler(socket, 'identityAdmin:setPermission', ({ userId, permissionKey, enabled }) => ({
|
||||
user: getUserForAdmin(setUserPermission(userId, permissionKey, {
|
||||
enabled: Boolean(enabled),
|
||||
actor: socket?.data?.user?.username || socket.id,
|
||||
at: Date.now(),
|
||||
}).id),
|
||||
}));
|
||||
|
||||
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
|
||||
const normalized = normalizeFeaturePayload(namespace, value);
|
||||
setFeatureState(userId, normalized.namespace, normalized.value);
|
||||
|
||||
@@ -10,6 +10,7 @@ const Database = require('better-sqlite3');
|
||||
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const logger = require('../../globals/logger').child('identityService');
|
||||
const { listRegisteredPermissions, requireRegisteredPermission } = require('./permissions');
|
||||
|
||||
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
|
||||
const FINGERPRINT_ID_RE = /^tm_[a-z0-9_-]{8,256}$/;
|
||||
@@ -17,7 +18,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
|
||||
const DB_PATH = resolveDataPath('identity.sqlite');
|
||||
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
||||
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
||||
const STORE_VERSION = 3;
|
||||
const STORE_VERSION = 4;
|
||||
const identityEvents = new EventEmitter();
|
||||
|
||||
let db = null;
|
||||
@@ -169,10 +170,7 @@ function ensureSchema(conn) {
|
||||
deterrence_by text,
|
||||
muted_enabled integer not null default 0,
|
||||
muted_at integer,
|
||||
muted_by text,
|
||||
audio_gain_boost_enabled integer not null default 0,
|
||||
audio_gain_boost_at integer,
|
||||
audio_gain_boost_by text
|
||||
muted_by text
|
||||
);
|
||||
|
||||
create table if not exists verification_requests (
|
||||
@@ -208,6 +206,15 @@ function ensureSchema(conn) {
|
||||
primary key (user_id, namespace)
|
||||
);
|
||||
|
||||
create table if not exists user_permissions (
|
||||
user_id text not null references users(id) on delete cascade,
|
||||
permission_key text not null,
|
||||
granted_at integer not null,
|
||||
granted_by text,
|
||||
primary key (user_id, permission_key)
|
||||
);
|
||||
create index if not exists idx_user_permissions_key on user_permissions(permission_key);
|
||||
|
||||
create table if not exists legacy_imports (
|
||||
source text not null,
|
||||
legacy_id text not null,
|
||||
@@ -223,8 +230,9 @@ function ensureSchema(conn) {
|
||||
/*
|
||||
SQLite's `create table if not exists` leaves an existing table untouched.
|
||||
Add the mute columns explicitly for installations created before store
|
||||
version 2, and the audio gain boost columns for those created before store
|
||||
version 3. The column-name check keeps every later startup idempotent.
|
||||
version 2. Permission grants now live in their own normalized table, so the
|
||||
obsolete audio-specific status columns are deliberately removed instead of
|
||||
carrying old grants into the new capability system.
|
||||
*/
|
||||
const statusColumns = new Set(
|
||||
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
||||
@@ -238,15 +246,13 @@ function ensureSchema(conn) {
|
||||
if (!statusColumns.has('muted_by')) {
|
||||
conn.exec('alter table user_status add column muted_by text');
|
||||
}
|
||||
if (!statusColumns.has('audio_gain_boost_enabled')) {
|
||||
conn.exec('alter table user_status add column audio_gain_boost_enabled integer not null default 0');
|
||||
}
|
||||
if (!statusColumns.has('audio_gain_boost_at')) {
|
||||
conn.exec('alter table user_status add column audio_gain_boost_at integer');
|
||||
}
|
||||
if (!statusColumns.has('audio_gain_boost_by')) {
|
||||
conn.exec('alter table user_status add column audio_gain_boost_by text');
|
||||
}
|
||||
['audio_gain_boost_enabled', 'audio_gain_boost_at', 'audio_gain_boost_by'].forEach((column) => {
|
||||
if (statusColumns.has(column)) conn.exec(`alter table user_status drop column ${column}`);
|
||||
});
|
||||
// Old personal fractions were identity-backed feature state. The replacement
|
||||
// is intentionally browser-local, so retaining these unreachable rows would
|
||||
// make the database page imply that they still control runtime behavior.
|
||||
conn.prepare('delete from user_feature_state where namespace = ?').run('audioGains');
|
||||
}
|
||||
|
||||
function createUser(conn = getDb(), ts = nowMs()) {
|
||||
@@ -291,6 +297,13 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
|
||||
conn.prepare('delete from user_known_ips where user_id = ?').run(sourceUserId);
|
||||
conn.prepare('update verification_requests set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
||||
conn.prepare('update legacy_imports set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
||||
/*
|
||||
Permissions describe the person, not one browser signal. Merging identities
|
||||
therefore unions their grants before the source user is deleted; a conflict
|
||||
keeps the target row and its original audit metadata.
|
||||
*/
|
||||
conn.prepare('update or ignore user_permissions set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
||||
conn.prepare('delete from user_permissions where user_id = ?').run(sourceUserId);
|
||||
|
||||
const sourceStatus = conn.prepare('select * from user_status where user_id = ?').get(sourceUserId);
|
||||
ensureUserStatus(conn, targetUserId);
|
||||
@@ -431,7 +444,6 @@ function setSocketIdentityState(socket, user, identity = {}) {
|
||||
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
||||
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
||||
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
||||
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
|
||||
}
|
||||
|
||||
function identifySocket(socket, payload = {}) {
|
||||
@@ -523,11 +535,7 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
|
||||
mutedAt: status.muted_at || null,
|
||||
mutedBy: status.muted_by || null,
|
||||
},
|
||||
audioGainBoost: {
|
||||
enabled: Boolean(status.audio_gain_boost_enabled),
|
||||
at: status.audio_gain_boost_at || null,
|
||||
by: status.audio_gain_boost_by || null,
|
||||
},
|
||||
permissions: getUserPermissions(id, { conn }),
|
||||
features,
|
||||
};
|
||||
}
|
||||
@@ -746,47 +754,76 @@ function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||
}
|
||||
|
||||
/*
|
||||
The audio gain boost flag lets a trusted VIP raise their personal horn/TTS/mic
|
||||
gain ceiling past the global admin gain settings. It stays a status column
|
||||
rather than feature state so it can be filtered in SQL alongside the other
|
||||
moderation flags and copied onto the socket at identify time.
|
||||
Positive capabilities use normalized rows rather than feature-specific status
|
||||
columns. This keeps moderation state focused and gives future permissions the
|
||||
same audited grant/revoke path without another schema alteration.
|
||||
*/
|
||||
function setAudioGainBoost(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||
function setUserPermission(userId, permissionKey, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||
const id = String(userId || '').trim();
|
||||
if (!id) throw new Error('userId required');
|
||||
ensureUserStatus(getDb(), id);
|
||||
getDb().prepare(`
|
||||
update user_status
|
||||
set audio_gain_boost_enabled = ?, audio_gain_boost_at = ?, audio_gain_boost_by = ?
|
||||
where user_id = ?
|
||||
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
|
||||
const permission = requireRegisteredPermission(permissionKey);
|
||||
const conn = getDb();
|
||||
if (!conn.prepare('select 1 from users where id = ?').get(id)) throw new Error('User not found.');
|
||||
if (enabled) {
|
||||
conn.prepare(`
|
||||
insert into user_permissions (user_id, permission_key, granted_at, granted_by)
|
||||
values (?, ?, ?, ?)
|
||||
on conflict(user_id, permission_key) do update set granted_at = excluded.granted_at, granted_by = excluded.granted_by
|
||||
`).run(id, permission.key, at, actor ? String(actor) : null);
|
||||
} else {
|
||||
conn.prepare('delete from user_permissions where user_id = ? and permission_key = ?').run(id, permission.key);
|
||||
}
|
||||
identityEvents.emit('change', {
|
||||
reason: enabled ? 'audio_gain_boost_granted' : 'audio_gain_boost_revoked',
|
||||
reason: enabled ? 'permission_granted' : 'permission_revoked',
|
||||
userId: id,
|
||||
permissionKey: permission.key,
|
||||
});
|
||||
conn.prepare('update users set updated_at = ? where id = ?').run(at, id);
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
function getUserPermissions(userId, { conn = getDb() } = {}) {
|
||||
const id = String(userId || '').trim();
|
||||
if (!id) return [];
|
||||
return conn.prepare(`
|
||||
select permission_key as key, granted_at as grantedAt, granted_by as grantedBy
|
||||
from user_permissions
|
||||
where user_id = ?
|
||||
order by permission_key
|
||||
`).all(id);
|
||||
}
|
||||
|
||||
function hasUserPermission(userId, permissionKey, { conn = getDb() } = {}) {
|
||||
const id = String(userId || '').trim();
|
||||
const permission = requireRegisteredPermission(permissionKey);
|
||||
if (!id) return false;
|
||||
return Boolean(conn.prepare('select 1 from user_permissions where user_id = ? and permission_key = ?').get(id, permission.key));
|
||||
}
|
||||
|
||||
function listUsersWithPermission(permissionKey) {
|
||||
const permission = requireRegisteredPermission(permissionKey);
|
||||
const conn = getDb();
|
||||
return conn.prepare('select user_id from user_permissions where permission_key = ? order by granted_at desc')
|
||||
.all(permission.key)
|
||||
.map((row) => getUserById(row.user_id, { conn, includeFeatures: false }))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isVerified(socket) {
|
||||
return Boolean(socket?.data?.isVerified);
|
||||
}
|
||||
|
||||
function hasAudioGainBoost(socket) {
|
||||
return Boolean(socket?.data?.hasAudioGainBoost);
|
||||
}
|
||||
|
||||
function isDeterred(socket) {
|
||||
return Boolean(socket?.data?.isDeterred);
|
||||
}
|
||||
|
||||
function listUsers({ verified = null, deterred = null, muted = null, audioGainBoost = null } = {}) {
|
||||
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
|
||||
const conn = getDb();
|
||||
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
||||
const where = [];
|
||||
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
||||
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
|
||||
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
|
||||
if (audioGainBoost !== null) where.push(`user_status.audio_gain_boost_enabled = ${audioGainBoost ? 1 : 0}`);
|
||||
if (where.length) sql += ` where ${where.join(' and ')}`;
|
||||
sql += ' order by users.updated_at desc';
|
||||
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
||||
@@ -808,9 +845,7 @@ function userToLegacyIdentityEntry(user) {
|
||||
muted: Boolean(user.deterrence?.muted),
|
||||
mutedAt: user.deterrence?.mutedAt || null,
|
||||
mutedBy: user.deterrence?.mutedBy || null,
|
||||
audioGainBoost: Boolean(user.audioGainBoost?.enabled),
|
||||
audioGainBoostAt: user.audioGainBoost?.at || null,
|
||||
audioGainBoostBy: user.audioGainBoost?.by || null,
|
||||
permissions: (user.permissions || []).map((permission) => permission.key),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -826,10 +861,6 @@ function listMutedUsers() {
|
||||
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
||||
}
|
||||
|
||||
function listAudioGainBoostUsers() {
|
||||
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
|
||||
}
|
||||
|
||||
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
||||
const value = String(selector || '').trim();
|
||||
if (!value) return { error: 'selector_required' };
|
||||
@@ -1084,14 +1115,16 @@ module.exports = {
|
||||
setVerified,
|
||||
setDeterrence,
|
||||
setMuted,
|
||||
setAudioGainBoost,
|
||||
setUserPermission,
|
||||
getUserPermissions,
|
||||
hasUserPermission,
|
||||
listUsersWithPermission,
|
||||
listRegisteredPermissions,
|
||||
isVerified,
|
||||
isDeterred,
|
||||
hasAudioGainBoost,
|
||||
listVerifiedUsers,
|
||||
listDeterredUsers,
|
||||
listMutedUsers,
|
||||
listAudioGainBoostUsers,
|
||||
resolveUserBySelector,
|
||||
userToLegacyIdentityEntry,
|
||||
createJsonStore,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Identity Permission Registry
|
||||
// Purpose: Defines every positive capability that can be granted to a canonical user.
|
||||
// Scope: Keeps stable database keys and operator-facing descriptions centralized so services and admin tools cannot invent mismatched permission names.
|
||||
const USER_PERMISSIONS = Object.freeze({
|
||||
'audio.personalAdjustment': Object.freeze({
|
||||
key: 'audio.personalAdjustment',
|
||||
commandName: 'audio-adjustment',
|
||||
label: 'Personal audio adjustment',
|
||||
description: 'Allows personal horn, text-to-speech, and microphone volume adjustments.',
|
||||
}),
|
||||
});
|
||||
|
||||
function listRegisteredPermissions() {
|
||||
return Object.values(USER_PERMISSIONS).map((permission) => ({ ...permission }));
|
||||
}
|
||||
|
||||
function requireRegisteredPermission(permissionKey) {
|
||||
const key = String(permissionKey || '').trim().toLowerCase();
|
||||
const permission = Object.values(USER_PERMISSIONS).find((entry) => (
|
||||
entry.key.toLowerCase() === key || entry.commandName.toLowerCase() === key
|
||||
));
|
||||
if (!permission) throw new Error('Unknown user permission.');
|
||||
return permission;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
USER_PERMISSIONS,
|
||||
listRegisteredPermissions,
|
||||
requireRegisteredPermission,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// Identity Permission Storage Tests
|
||||
// Purpose: Verifies normalized grants, registry validation, and the intentionally empty replacement for legacy audio boost flags.
|
||||
// Scope: Uses an isolated temporary data directory and never opens the development identity database.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rover-identity-permissions-'));
|
||||
process.env.SERVER_DATA_DIR = testDataDir;
|
||||
/*
|
||||
Seed the exact legacy concern this redesign removes. Opening identityService
|
||||
must preserve the real moderation fields while dropping all old boost grants
|
||||
instead of translating them into the new permission table.
|
||||
*/
|
||||
const legacyDb = new Database(path.join(testDataDir, 'identity.sqlite'));
|
||||
legacyDb.exec(`
|
||||
create table users (
|
||||
id text primary key,
|
||||
created_at integer not null,
|
||||
updated_at integer not null,
|
||||
last_seen_at integer
|
||||
);
|
||||
create table user_status (
|
||||
user_id text primary key references users(id) on delete cascade,
|
||||
verified_enabled integer not null default 0,
|
||||
verified_at integer,
|
||||
verified_by text,
|
||||
deterrence_enabled integer not null default 0,
|
||||
deterrence_reason text,
|
||||
deterrence_at integer,
|
||||
deterrence_by text,
|
||||
muted_enabled integer not null default 0,
|
||||
muted_at integer,
|
||||
muted_by text,
|
||||
audio_gain_boost_enabled integer not null default 0,
|
||||
audio_gain_boost_at integer,
|
||||
audio_gain_boost_by text
|
||||
);
|
||||
`);
|
||||
legacyDb.close();
|
||||
const identityService = require('./index');
|
||||
|
||||
test.after(() => {
|
||||
identityService.getDb().close();
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('legacy audio boost columns are removed and normalized permissions are created empty', () => {
|
||||
const db = identityService.getDb();
|
||||
const statusColumns = db.prepare('pragma table_info(user_status)').all().map((column) => column.name);
|
||||
|
||||
assert.doesNotMatch(statusColumns.join(','), /audio_gain_boost/);
|
||||
assert.ok(db.prepare("select 1 from sqlite_master where type = 'table' and name = 'user_permissions'").get());
|
||||
});
|
||||
|
||||
test('registered permissions can be granted, listed, queried, and revoked', () => {
|
||||
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_11111111111111111111111111111111' });
|
||||
|
||||
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
|
||||
identityService.setUserPermission(userId, 'audio-adjustment', { enabled: true, actor: 'test-admin', at: 1234 });
|
||||
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), true);
|
||||
assert.deepEqual(identityService.getUserPermissions(userId), [{
|
||||
key: 'audio.personalAdjustment',
|
||||
grantedAt: 1234,
|
||||
grantedBy: 'test-admin',
|
||||
}]);
|
||||
assert.equal(identityService.listUsersWithPermission('audio-adjustment')[0].id, userId);
|
||||
|
||||
identityService.setUserPermission(userId, 'audio.personalAdjustment', { enabled: false });
|
||||
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
|
||||
});
|
||||
|
||||
test('unknown permission keys cannot be persisted', () => {
|
||||
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_22222222222222222222222222222222' });
|
||||
assert.throws(
|
||||
() => identityService.setUserPermission(userId, 'made.up.permission', { enabled: true }),
|
||||
/Unknown user permission/,
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -53,7 +53,7 @@ const {
|
||||
getUserIdForSocket,
|
||||
} = require('../identityService');
|
||||
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
||||
const { getAudioLevels, getAudioGainStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
|
||||
const { getAudioLevels, getAudioAdjustmentStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
|
||||
const { getButtonBoxState } = require('../buttonBoxService');
|
||||
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
||||
const {
|
||||
@@ -231,7 +231,7 @@ function buildSession(socket) {
|
||||
isVerified: Boolean(socket?.data?.isVerified),
|
||||
audioForward: getAudioForwardState(),
|
||||
audioLevels: getAudioLevels(),
|
||||
audioGains: getAudioGainStateForSocket(socket),
|
||||
audioAdjustments: getAudioAdjustmentStateForSocket(socket),
|
||||
buttonBox: getButtonBoxState(),
|
||||
/*
|
||||
Inter-instance state is a read-only directory snapshot. It is included in
|
||||
@@ -463,16 +463,14 @@ audioForwardEvents.on('change', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
audioLevelsEvents.on('change', ({ scope, userId } = {}) => {
|
||||
audioLevelsEvents.on('change', ({ scope, socketId } = {}) => {
|
||||
/*
|
||||
A user dragging their own volume slider only changes their own payload, so
|
||||
it resyncs just that user's tabs. Admin gain and cap edits still change
|
||||
everyone's ceiling and keep the full broadcast.
|
||||
A browser dragging its own volume slider changes only that connection's
|
||||
payload. Base-level and allowed-range changes still affect everyone and use
|
||||
the full broadcast path.
|
||||
*/
|
||||
if (scope === 'user' && userId) {
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
if (getUserIdForSocket(socket) === userId) syncSocket(socket);
|
||||
});
|
||||
if (scope === 'socket' && socketId) {
|
||||
syncSocket(io.sockets.sockets.get(socketId));
|
||||
return;
|
||||
}
|
||||
syncAll();
|
||||
|
||||
@@ -27,11 +27,9 @@ const {
|
||||
setVerified,
|
||||
setDeterrence,
|
||||
setMuted,
|
||||
setAudioGainBoost,
|
||||
listVerifiedUsers,
|
||||
listDeterredUsers,
|
||||
listMutedUsers,
|
||||
listAudioGainBoostUsers,
|
||||
resolveUserBySelector,
|
||||
userToLegacyIdentityEntry,
|
||||
} = require('../identityService');
|
||||
@@ -113,17 +111,10 @@ function refreshSocketIdentityFlags(socket) {
|
||||
operational chat/audio tools while preserving the flag for normal roles.
|
||||
*/
|
||||
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
|
||||
/*
|
||||
Admins already control the global gain settings, so they carry the raised
|
||||
audio ceiling implicitly for the same reason they carry verification: a
|
||||
stored per-user grant should never be what gates an operator's own tools.
|
||||
*/
|
||||
socket.data.hasAudioGainBoost = verifiedByRole || Boolean(user.audioGainBoost?.enabled);
|
||||
return {
|
||||
isVerified: socket.data.isVerified,
|
||||
isDeterred: socket.data.isDeterred,
|
||||
isMuted: socket.data.isMuted,
|
||||
hasAudioGainBoost: socket.data.hasAudioGainBoost,
|
||||
matchedRecordId: user.id,
|
||||
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
||||
userId: user.id,
|
||||
@@ -133,6 +124,17 @@ function refreshSocketIdentityFlags(socket) {
|
||||
function identifySocket(socket, payload = {}) {
|
||||
if (!socket) throw new Error('Socket required');
|
||||
|
||||
/*
|
||||
Personal audio percentages travel with portable browser identity but are not
|
||||
identity database state. Keeping the raw object on this connection lets the
|
||||
audio service apply its current permission and range policy after canonical
|
||||
identity resolution, including during handshake authentication.
|
||||
*/
|
||||
socket.data = socket.data || {};
|
||||
socket.data.audioAdjustments = payload?.audioAdjustments && typeof payload.audioAdjustments === 'object'
|
||||
? { ...payload.audioAdjustments }
|
||||
: {};
|
||||
|
||||
const incomingNickname = sanitizeNickname(payload.nickname);
|
||||
if (incomingNickname) {
|
||||
try {
|
||||
@@ -572,53 +574,6 @@ function setUserMute(selector, enabled, actor = null) {
|
||||
return userToLegacyIdentityEntry(user);
|
||||
}
|
||||
|
||||
/*
|
||||
Audio gain boost is a VIP-only grant. resolveUserBySelector's `includeVerified`
|
||||
flag reads as an inclusion toggle but actually relaxes the verified filter, so
|
||||
passing `false` is what restricts the nickname lookup to verified users. That
|
||||
keeps an unverified visitor from being matched by a shared nickname and handed
|
||||
a raised gain ceiling they are not eligible to use.
|
||||
*/
|
||||
function setUserAudioGainBoost(selector, enabled, actor = null) {
|
||||
const resolved = resolveUserBySelector(selector, { includeVerified: false, includeDeterred: true });
|
||||
if (resolved.error || !resolved.user) {
|
||||
throw new Error(resolved.error === 'ambiguous_nickname'
|
||||
? 'Nickname matches multiple VIPs.'
|
||||
: 'VIP not found. Only verified users can be granted an audio gain boost.');
|
||||
}
|
||||
if (!resolved.user.verified?.enabled) {
|
||||
throw new Error('Only verified VIPs can be granted an audio gain boost.');
|
||||
}
|
||||
|
||||
const user = setAudioGainBoost(resolved.user.id, {
|
||||
enabled,
|
||||
actor: actor ? String(actor) : null,
|
||||
at: Date.now(),
|
||||
});
|
||||
refreshSocketsForUser(user.id);
|
||||
publishEvent({
|
||||
source: 'moderation',
|
||||
type: enabled ? 'audio.gainBoostGranted' : 'audio.gainBoostRevoked',
|
||||
payload: {
|
||||
userId: user.id,
|
||||
cookieUserId: user.cookieUserIds[0] || null,
|
||||
nickname: user.nickname,
|
||||
actor: actor ? String(actor) : null,
|
||||
ts: Date.now(),
|
||||
},
|
||||
});
|
||||
emitChange(enabled ? 'audio_gain_boost_grant' : 'audio_gain_boost_revoke', { userId: user.id });
|
||||
return userToLegacyIdentityEntry(user);
|
||||
}
|
||||
|
||||
function grantAudioGainBoost(selector, actor = null) {
|
||||
return setUserAudioGainBoost(selector, true, actor);
|
||||
}
|
||||
|
||||
function revokeAudioGainBoost(selector, actor = null) {
|
||||
return setUserAudioGainBoost(selector, false, actor);
|
||||
}
|
||||
|
||||
function muteUser(selector, actor = null) {
|
||||
return setUserMute(selector, true, actor);
|
||||
}
|
||||
@@ -754,13 +709,9 @@ module.exports = {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
||||
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
||||
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
||||
hasAudioGainBoost: (socket) => Boolean(socket?.data?.hasAudioGainBoost),
|
||||
reevaluateSocketVerification,
|
||||
reevaluateSocketDeterrence,
|
||||
verificationEvents,
|
||||
|
||||
Reference in New Issue
Block a user