mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
moving audio gain perms around
This commit is contained in:
@@ -99,10 +99,15 @@ audioForward:
|
||||
maxUploadBytes: 8388608
|
||||
|
||||
audioLevels:
|
||||
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
||||
# Base multipliers (0.0 - 4.0) applied before any approved user's signed
|
||||
# personal adjustment. The server clamps every final rover gain to this same
|
||||
# hard multiplier range.
|
||||
hornGain: 1.0
|
||||
ttsGain: 1.0
|
||||
forwardGain: 1.0
|
||||
# Approved users may move each personal slider this far below or above the
|
||||
# base multiplier. Browser cookies store percentages, never raw multipliers.
|
||||
maxPersonalAdjustmentPercent: 50
|
||||
|
||||
homeAssistant:
|
||||
enabled: false
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-C7V6I437.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-r7XMuw_k.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,10 +17,8 @@ const MODES = [
|
||||
{ key: 'lockdown', label: 'Lockdown' },
|
||||
];
|
||||
|
||||
/*
|
||||
The same three gain keys drive both the global levels and the VIP boost hard
|
||||
caps, so both editors render from one list instead of six copied sliders.
|
||||
*/
|
||||
// These are the physical rover gain channels. Personal settings remain signed
|
||||
// percentages and never replace these server-owned base multipliers.
|
||||
const GAIN_FIELDS = [
|
||||
{ key: 'hornGain', label: 'Horn gain' },
|
||||
{ key: 'ttsGain', label: 'TTS gain' },
|
||||
@@ -109,7 +107,7 @@ export default function AdminPanelContent() {
|
||||
updateAllRovers,
|
||||
rebootServer,
|
||||
setAudioLevels,
|
||||
setUserAudioGainCaps,
|
||||
setPersonalAudioAdjustmentRange,
|
||||
setPrivateSafety,
|
||||
llmControl,
|
||||
overseerControl,
|
||||
@@ -132,12 +130,11 @@ export default function AdminPanelContent() {
|
||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
||||
const currentAudioLevels = session?.audioLevels || {};
|
||||
const currentUserGainCaps = currentAudioLevels.userGainCaps || {};
|
||||
const [audioLevelDraft, setAudioLevelDraft] = useState(
|
||||
() => normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }),
|
||||
);
|
||||
const [userGainCapDraft, setUserGainCapDraft] = useState(
|
||||
() => normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }),
|
||||
const [maxAdjustmentDraft, setMaxAdjustmentDraft] = useState(
|
||||
() => Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0,
|
||||
);
|
||||
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
|
||||
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
|
||||
@@ -317,14 +314,9 @@ export default function AdminPanelContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserGainCapDraft = (key) => (event) => {
|
||||
const next = Number(event.target.value);
|
||||
setUserGainCapDraft((current) => ({ ...(current || {}), [key]: Number.isFinite(next) ? next : 0 }));
|
||||
};
|
||||
|
||||
const handleUserGainCapsSave = async () => {
|
||||
const handlePersonalAdjustmentRangeSave = async () => {
|
||||
try {
|
||||
await setUserAudioGainCaps(userGainCapDraft);
|
||||
await setPersonalAudioAdjustmentRange(maxAdjustmentDraft);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
@@ -356,9 +348,8 @@ export default function AdminPanelContent() {
|
||||
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserGainCapDraft(normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentUserGainCaps.forwardGain, currentUserGainCaps.hornGain, currentUserGainCaps.ttsGain]);
|
||||
setMaxAdjustmentDraft(Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0);
|
||||
}, [currentAudioLevels.maxPersonalAdjustmentPercent]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -462,7 +453,7 @@ export default function AdminPanelContent() {
|
||||
/>
|
||||
))}
|
||||
<p className="text-xs text-slate-500">
|
||||
These are the volume ceilings for ordinary users. Each user picks a 0-100% share of them.
|
||||
These are the base multipliers. Approved personal adjustments are calculated around these values.
|
||||
</p>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
|
||||
@@ -472,26 +463,32 @@ export default function AdminPanelContent() {
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>VIP gain boost hard caps</span>
|
||||
{session?.audioLevels?.capsUpdatedAt ? (
|
||||
<span>Updated {new Date(session.audioLevels.capsUpdatedAt).toLocaleString()}</span>
|
||||
<span>Maximum personal adjustment</span>
|
||||
{session?.audioLevels?.adjustmentRangeUpdatedAt ? (
|
||||
<span>Updated {new Date(session.audioLevels.adjustmentRangeUpdatedAt).toLocaleString()}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{GAIN_FIELDS.map(({ key, label }) => (
|
||||
<GainSlider
|
||||
key={key}
|
||||
label={label}
|
||||
value={userGainCapDraft[key]}
|
||||
onChange={handleUserGainCapDraft(key)}
|
||||
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Allowed range in both directions</span>
|
||||
<span>±{Math.round(Number(maxAdjustmentDraft) || 0)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={maxAdjustmentDraft}
|
||||
onChange={(event) => setMaxAdjustmentDraft(Number(event.target.value) || 0)}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
))}
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
Ceilings for VIPs granted the boost with <code>rs gain grant <vip></code>. A boost never lowers
|
||||
someone's limit, so a cap below the global gain above has no effect.
|
||||
Users with the personal audio adjustment permission can reduce or increase each base level by this percentage.
|
||||
</p>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button type="button" onClick={handleUserGainCapsSave} className="button-dark">
|
||||
Apply boost caps
|
||||
<button type="button" onClick={handlePersonalAdjustmentRangeSave} className="button-dark">
|
||||
Apply adjustment range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,134 +1,114 @@
|
||||
// Volume Settings Card
|
||||
// Purpose: Lets any user set their own horn, TTS, and mic-forward volume.
|
||||
// Scope: Renders the server-resolved ceilings; the server still owns every limit.
|
||||
// Personal Volume Adjustment Card
|
||||
// Purpose: Lets an approved user offset horn, text-to-speech, and microphone output around server-owned base levels.
|
||||
// Scope: Persists signed percentages in roverSettings while the server owns permission, clamping, and gain conversion.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
/*
|
||||
Sliders are a 0-1 fraction of whichever ceiling the server resolved for this
|
||||
user, so the same three keys describe the fraction, the ceiling, and the gain
|
||||
the rover will actually apply.
|
||||
*/
|
||||
const GAIN_FIELDS = [
|
||||
{ key: 'hornGain', label: 'Horn volume' },
|
||||
{ key: 'ttsGain', label: 'Text-to-speech volume' },
|
||||
{ key: 'forwardGain', label: 'Microphone volume' },
|
||||
const ADJUSTMENT_FIELDS = [
|
||||
{ key: 'hornPercent', label: 'Horn volume' },
|
||||
{ key: 'ttsPercent', label: 'Text-to-speech volume' },
|
||||
{ key: 'forwardPercent', label: 'Microphone volume' },
|
||||
];
|
||||
|
||||
// Dragging a range input fires continuously; persist once the user settles.
|
||||
const DEFAULT_ADJUSTMENTS = { hornPercent: 0, ttsPercent: 0, forwardPercent: 0 };
|
||||
const COMMIT_DEBOUNCE_MS = 300;
|
||||
|
||||
function clampFraction(value, fallback = 1) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(1, num));
|
||||
function clampPercent(value, maximum) {
|
||||
const number = Number(value);
|
||||
const limit = Math.max(0, Number(maximum) || 0);
|
||||
if (!Number.isFinite(number)) return 0;
|
||||
return Math.round(Math.max(-limit, Math.min(limit, number)));
|
||||
}
|
||||
|
||||
function normalizeValues(raw) {
|
||||
const out = {};
|
||||
GAIN_FIELDS.forEach(({ key }) => {
|
||||
out[key] = clampFraction(raw?.[key], 1);
|
||||
function normalizeAdjustments(raw, maximum) {
|
||||
const normalized = {};
|
||||
ADJUSTMENT_FIELDS.forEach(({ key }) => {
|
||||
normalized[key] = clampPercent(raw?.[key], maximum);
|
||||
});
|
||||
return out;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatGain(value) {
|
||||
const num = Number(value);
|
||||
return `${Number.isFinite(num) ? num.toFixed(2) : '0.00'}x`;
|
||||
function formatPercent(value) {
|
||||
const number = Number(value) || 0;
|
||||
return `${number > 0 ? '+' : ''}${number}%`;
|
||||
}
|
||||
|
||||
export default function VolumeSettingsCard() {
|
||||
const { session, setUserAudioGains } = useSession();
|
||||
const audioGains = session?.audioGains || null;
|
||||
const serverValues = useMemo(() => normalizeValues(audioGains?.values), [audioGains?.values]);
|
||||
const ceilings = audioGains?.ceilings || {};
|
||||
const boostGranted = Boolean(audioGains?.boostGranted);
|
||||
|
||||
const [draft, setDraft] = useState(serverValues);
|
||||
const [error, setError] = useState(null);
|
||||
const commitTimerRef = useRef(null);
|
||||
const pendingRef = useRef(null);
|
||||
|
||||
/*
|
||||
The server is authoritative, so an accepted save or an admin-side change
|
||||
resyncs the sliders. Comparing the serialized values keeps a resync from
|
||||
fighting a drag that is already in flight.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (pendingRef.current) return;
|
||||
setDraft(serverValues);
|
||||
}, [serverValues]);
|
||||
|
||||
const commit = useCallback(
|
||||
async (next) => {
|
||||
pendingRef.current = next;
|
||||
try {
|
||||
await setUserAudioGains(next);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to save volume');
|
||||
setDraft(serverValues);
|
||||
} finally {
|
||||
pendingRef.current = null;
|
||||
}
|
||||
},
|
||||
[serverValues, setUserAudioGains],
|
||||
const { session, setPersonalAudioAdjustments } = useSession();
|
||||
const { value: savedAdjustments, save: saveAdjustments } = useSettingsNamespace(
|
||||
'audioAdjustments',
|
||||
DEFAULT_ADJUSTMENTS,
|
||||
);
|
||||
const serverState = session?.audioAdjustments || null;
|
||||
const allowed = Boolean(serverState?.allowed);
|
||||
const maximum = Math.max(0, Number(serverState?.maxAdjustmentPercent) || 0);
|
||||
const normalizedSaved = useMemo(
|
||||
() => normalizeAdjustments(savedAdjustments, maximum),
|
||||
[maximum, savedAdjustments],
|
||||
);
|
||||
const [draft, setDraft] = useState(normalizedSaved);
|
||||
const [error, setError] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(normalizedSaved);
|
||||
}, [normalizedSaved]);
|
||||
|
||||
const commit = useCallback(async (next) => {
|
||||
const normalized = normalizeAdjustments(next, maximum);
|
||||
// Saving first makes the cookie the durable source used by every reconnect
|
||||
// and session:identify update. The socket call applies it immediately to a
|
||||
// rover the current browser may already control.
|
||||
saveAdjustments(normalized);
|
||||
try {
|
||||
await setPersonalAudioAdjustments(normalized);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to apply volume adjustments');
|
||||
}
|
||||
}, [maximum, saveAdjustments, setPersonalAudioAdjustments]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleChange = (key) => (event) => {
|
||||
const next = clampFraction(event.target.value, 0);
|
||||
const nextDraft = { ...draft, [key]: next };
|
||||
setDraft(nextDraft);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = setTimeout(() => commit(nextDraft), COMMIT_DEBOUNCE_MS);
|
||||
const next = { ...draft, [key]: clampPercent(event.target.value, maximum) };
|
||||
setDraft(next);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => commit(next), COMMIT_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
// Sliders would be misleading before the first session sync lands.
|
||||
if (!audioGains) return null;
|
||||
if (!serverState) return null;
|
||||
|
||||
return (
|
||||
<CardFrame title="Volume" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
|
||||
{GAIN_FIELDS.map(({ key, label }) => {
|
||||
const ceiling = Number.isFinite(Number(ceilings[key])) ? Number(ceilings[key]) : 0;
|
||||
const fraction = clampFraction(draft[key], 1);
|
||||
const muted = ceiling <= 0;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-sm text-white"
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
|
||||
<span className="min-w-0 font-semibold text-white">{label}</span>
|
||||
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{Math.round(fraction * 100)}% · {formatGain(fraction * ceiling)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={fraction}
|
||||
onChange={handleChange(key)}
|
||||
className="mt-1 w-full accent-emerald-500 disabled:opacity-50"
|
||||
disabled={muted}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{muted ? 'Muted by admin gain settings.' : `100% = ${formatGain(ceiling)} (your current limit)`}
|
||||
<CardFrame title="Personal volume adjustment" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
|
||||
{ADJUSTMENT_FIELDS.map(({ key, label }) => (
|
||||
<label key={key} className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-white">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
|
||||
<span className="min-w-0 font-semibold text-white">{label}</span>
|
||||
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{formatPercent(draft[key])}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">
|
||||
{boostGranted
|
||||
? 'You have a raised volume limit. 100% is the admin-set hard cap for boosted users rather than the normal global gain.'
|
||||
: 'Your limit is the global gain an admin has set. Admins can raise it per user.'}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={-maximum}
|
||||
max={maximum}
|
||||
step="1"
|
||||
value={clampPercent(draft[key], maximum)}
|
||||
onChange={handleChange(key)}
|
||||
className="mt-1 w-full accent-emerald-500 disabled:opacity-50"
|
||||
disabled={!allowed || maximum <= 0}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{allowed
|
||||
? `Allowed range: -${maximum}% to +${maximum}%. Center is no adjustment.`
|
||||
: 'An administrator must approve personal volume adjustments for your user.'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{error && <p className="mx-auto w-full max-w-lg text-xs text-rose-300">{error}</p>}
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -342,8 +342,8 @@ export function SessionProvider({ children }) {
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
login: (username, password) => emitWithAck('auth:login', { username, password }),
|
||||
identifySession: ({ cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface } = {}) =>
|
||||
emitWithAck('session:identify', { cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface }),
|
||||
identifySession: ({ cookieUserId, fingerprintId, nickname, audioAdjustments, overseerEnabled, identitySurface } = {}) =>
|
||||
emitWithAck('session:identify', { cookieUserId, fingerprintId, nickname, audioAdjustments, overseerEnabled, identitySurface }),
|
||||
setRole: (role) => emitWithAck('session:setRole', { role }),
|
||||
requestControl: (roverId, options = {}) =>
|
||||
emitWithAck('session:requestControl', { roverId, ...options }),
|
||||
@@ -399,13 +399,10 @@ export function SessionProvider({ children }) {
|
||||
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
|
||||
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
|
||||
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
||||
/*
|
||||
Personal volume is a 0-1 fraction of whichever ceiling the server has
|
||||
resolved for this user, so the browser never sends an absolute gain and
|
||||
cannot widen its own limits.
|
||||
*/
|
||||
setUserAudioGains: (gains = {}) => emitWithAck('audioLevels:setUserGains', gains),
|
||||
setUserAudioGainCaps: (caps = {}) => emitWithAck('audioLevels:setUserCaps', caps),
|
||||
setPersonalAudioAdjustments: (adjustments = {}) =>
|
||||
emitWithAck('audioLevels:setPersonalAdjustments', adjustments),
|
||||
setPersonalAudioAdjustmentRange: (maxAdjustmentPercent) =>
|
||||
emitWithAck('audioLevels:setPersonalAdjustmentRange', { maxAdjustmentPercent }),
|
||||
setPrivateSafety: (roverId, safety = {}) =>
|
||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
removeSignal,
|
||||
setDeterrence,
|
||||
setMuted,
|
||||
setPermission,
|
||||
setVerified,
|
||||
updateFeatureState,
|
||||
} from './identityDatabaseApi.js';
|
||||
@@ -307,9 +308,33 @@ function RawRecordCard({ user }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionsCard({ user, permissions, onPermission }) {
|
||||
const grantedKeys = new Set((user?.permissions || []).map((permission) => permission.key));
|
||||
return (
|
||||
<CardFrame title="Permissions" bodyClassName="grid gap-0.5 p-0.5 text-sm md:grid-cols-2">
|
||||
{permissions.map((permission) => (
|
||||
<label key={permission.key} className="surface space-y-0.5 px-1 py-0.75 text-slate-100">
|
||||
<span className="flex items-center gap-0.5 font-semibold">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-emerald-500"
|
||||
checked={grantedKeys.has(permission.key)}
|
||||
onChange={(event) => onPermission(permission.key, event.target.checked)}
|
||||
/>
|
||||
{permission.label}
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">{permission.description}</span>
|
||||
<code className="block text-[0.68rem] text-lime-300">{permission.key}</code>
|
||||
</label>
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IdentityDatabasePanel() {
|
||||
const socket = useSocket();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState('all');
|
||||
@@ -321,6 +346,7 @@ export default function IdentityDatabasePanel() {
|
||||
try {
|
||||
const resp = await listUsers(socket);
|
||||
setUsers(resp.users || []);
|
||||
setPermissions(resp.permissions || []);
|
||||
if (selectedUser?.id) {
|
||||
const updated = await getUser(socket, selectedUser.id);
|
||||
setSelectedUser(updated.user || null);
|
||||
@@ -380,6 +406,11 @@ export default function IdentityDatabasePanel() {
|
||||
runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.');
|
||||
const handleMuted = (enabled) =>
|
||||
runMutation((userId) => setMuted(socket, userId, enabled), 'Mute updated.');
|
||||
const handlePermission = (permissionKey, enabled) =>
|
||||
runMutation(
|
||||
(userId) => setPermission(socket, userId, permissionKey, enabled),
|
||||
'Permission updated.',
|
||||
);
|
||||
const handleSaveFeature = (namespace, value) =>
|
||||
runMutation((userId) => updateFeatureState(socket, userId, namespace, value), 'Feature state saved.');
|
||||
const handleDeleteFeature = (namespace) =>
|
||||
@@ -407,6 +438,7 @@ export default function IdentityDatabasePanel() {
|
||||
<TabList>
|
||||
<Tab id="signals">Signals</Tab>
|
||||
<Tab id="status">Status</Tab>
|
||||
<Tab id="permissions">Permissions</Tab>
|
||||
<Tab id="features">Feature state</Tab>
|
||||
<Tab id="raw">Raw JSON</Tab>
|
||||
</TabList>
|
||||
@@ -417,6 +449,9 @@ export default function IdentityDatabasePanel() {
|
||||
<TabPanel id="status">
|
||||
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} onMuted={handleMuted} />
|
||||
</TabPanel>
|
||||
<TabPanel id="permissions">
|
||||
<PermissionsCard user={selectedUser} permissions={permissions} onPermission={handlePermission} />
|
||||
</TabPanel>
|
||||
<TabPanel id="features">
|
||||
<FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
|
||||
</TabPanel>
|
||||
|
||||
@@ -41,6 +41,10 @@ export function setMuted(socket, userId, enabled) {
|
||||
return emitIdentityAdmin(socket, 'identityAdmin:setMuted', { userId, enabled });
|
||||
}
|
||||
|
||||
export function setPermission(socket, userId, permissionKey, enabled) {
|
||||
return emitIdentityAdmin(socket, 'identityAdmin:setPermission', { userId, permissionKey, enabled });
|
||||
}
|
||||
|
||||
export function updateFeatureState(socket, userId, namespace, value) {
|
||||
return emitIdentityAdmin(socket, 'identityAdmin:updateFeatureState', { userId, namespace, value });
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export function userMatchesQuery(user, query) {
|
||||
...(user?.nicknames || []),
|
||||
...(user?.knownIps || []),
|
||||
...(user?.featureNamespaces || []),
|
||||
...(user?.permissions || []).map((permission) => permission.key),
|
||||
].join(' ').toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,17 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
'overseerPreference',
|
||||
{ enabled: false },
|
||||
);
|
||||
const { value: audioAdjustments, status: audioAdjustmentsStatus } = useSettingsNamespace('audioAdjustments', {
|
||||
hornPercent: 0,
|
||||
ttsPercent: 0,
|
||||
forwardPercent: 0,
|
||||
});
|
||||
|
||||
const ready =
|
||||
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready';
|
||||
identityStatus === 'ready'
|
||||
&& profileStatus === 'ready'
|
||||
&& overseerPreferenceStatus === 'ready'
|
||||
&& audioAdjustmentsStatus === 'ready';
|
||||
const cookieUserId = (identity?.cookieUserId || '').trim();
|
||||
const nickname = (profile?.nickname || '').trim();
|
||||
const overseerEnabled = Boolean(overseerPreference?.enabled);
|
||||
@@ -40,6 +48,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
cookieUserId,
|
||||
fingerprintId: await getBrowserFingerprintId(),
|
||||
nickname,
|
||||
audioAdjustments,
|
||||
overseerEnabled,
|
||||
identitySurface: normalizedIdentitySurface,
|
||||
});
|
||||
@@ -55,6 +64,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
*/
|
||||
}
|
||||
}, [
|
||||
audioAdjustments,
|
||||
connected,
|
||||
cookieUserId,
|
||||
identifySession,
|
||||
@@ -75,7 +85,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
*/
|
||||
if (!ready || !connected || !socket?.id) return;
|
||||
sendIdentify();
|
||||
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
|
||||
}, [ready, connected, socket?.id, cookieUserId, nickname, audioAdjustments, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
|
||||
@@ -36,6 +36,9 @@ async function buildSocketIdentity() {
|
||||
cookieUserId: String(currentSettings?.identity?.cookieUserId || '').trim(),
|
||||
fingerprintId,
|
||||
nickname: String(currentSettings?.profile?.nickname || '').trim(),
|
||||
// Signed percentages are harmless browser preferences. The server resolves
|
||||
// identity first, then enforces permission and range before rover output.
|
||||
audioAdjustments: currentSettings?.audioAdjustments || {},
|
||||
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
|
||||
identitySurface: getIdentitySurface(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user