mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
moving audio gain perms around
This commit is contained in:
@@ -99,10 +99,15 @@ audioForward:
|
|||||||
maxUploadBytes: 8388608
|
maxUploadBytes: 8388608
|
||||||
|
|
||||||
audioLevels:
|
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
|
hornGain: 1.0
|
||||||
ttsGain: 1.0
|
ttsGain: 1.0
|
||||||
forwardGain: 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:
|
homeAssistant:
|
||||||
enabled: false
|
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" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- site-metadata:inject -->
|
<!-- site-metadata:inject -->
|
||||||
<!-- analytics: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">
|
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,79 +1,62 @@
|
|||||||
// audio Levels Gain Math
|
// Audio Adjustment Math
|
||||||
// Purpose: Holds the pure clamping and ceiling rules shared by every gain layer.
|
// Purpose: Converts signed browser percentages into server-enforced rover gain multipliers.
|
||||||
// Scope: No IO, no state; keeps the volume policy independently reviewable and testable.
|
// Scope: Contains no IO or identity logic so the adjustment policy can be tested independently.
|
||||||
|
const ADJUSTMENT_FIELDS = [
|
||||||
/*
|
{ gainKey: 'hornGain', percentKey: 'hornPercent' },
|
||||||
The three gain keys are the same on every layer of this feature: the global
|
{ gainKey: 'ttsGain', percentKey: 'ttsPercent' },
|
||||||
admin gains, the admin-editable VIP boost caps, and each user's personal
|
{ gainKey: 'forwardGain', percentKey: 'forwardPercent' },
|
||||||
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.
|
|
||||||
const MIN_GAIN = 0;
|
const MIN_GAIN = 0;
|
||||||
const MAX_GAIN = 4;
|
const MAX_GAIN = 4;
|
||||||
|
const MIN_ADJUSTMENT_PERCENT = -100;
|
||||||
|
const MAX_ADJUSTMENT_PERCENT = 100;
|
||||||
|
|
||||||
function clampGain(value, fallback = 1) {
|
function clampGain(value, fallback = 1) {
|
||||||
const num = Number(value);
|
const number = Number(value);
|
||||||
if (!Number.isFinite(num)) return fallback;
|
if (!Number.isFinite(number)) return fallback;
|
||||||
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, num));
|
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, number));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampFraction(value, fallback = 1) {
|
function clampMaximumAdjustmentPercent(value, fallback = 50) {
|
||||||
const num = Number(value);
|
const number = Number(value);
|
||||||
if (!Number.isFinite(num)) return fallback;
|
if (!Number.isFinite(number)) return fallback;
|
||||||
return Math.max(0, Math.min(1, num));
|
return Math.round(Math.max(0, Math.min(MAX_ADJUSTMENT_PERCENT, number)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUserGains(raw = {}) {
|
function clampAdjustmentPercent(value, maximum = 0) {
|
||||||
const out = {};
|
const number = Number(value);
|
||||||
GAIN_KEYS.forEach((key) => {
|
if (!Number.isFinite(number)) return 0;
|
||||||
out[key] = clampFraction(raw?.[key], 1);
|
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 = {}) {
|
function applyAdjustments(baseLevels = {}, adjustments = {}) {
|
||||||
const out = {};
|
const effective = {};
|
||||||
GAIN_KEYS.forEach((key) => {
|
ADJUSTMENT_FIELDS.forEach(({ gainKey, percentKey }) => {
|
||||||
out[key] = clampGain(raw?.[key], clampGain(fallback?.[key], 1));
|
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;
|
return effective;
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
GAIN_KEYS,
|
ADJUSTMENT_FIELDS,
|
||||||
MIN_GAIN,
|
MIN_GAIN,
|
||||||
MAX_GAIN,
|
MAX_GAIN,
|
||||||
|
MIN_ADJUSTMENT_PERCENT,
|
||||||
|
MAX_ADJUSTMENT_PERCENT,
|
||||||
clampGain,
|
clampGain,
|
||||||
clampFraction,
|
clampMaximumAdjustmentPercent,
|
||||||
normalizeUserGains,
|
clampAdjustmentPercent,
|
||||||
normalizeGainSet,
|
normalizeAdjustments,
|
||||||
resolveCeilings,
|
applyAdjustments,
|
||||||
applyCeilings,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,85 +1,37 @@
|
|||||||
// audio Levels Gain Math Tests
|
// Audio Adjustment Math Tests
|
||||||
// Purpose: Pins the ceiling rules that keep user volume inside admin limits.
|
// Purpose: Pins percentage clamping and conversion independently of sockets, identity, and rover IO.
|
||||||
// Scope: Pure math only; no store, socket, or rover involvement.
|
// Scope: Covers only the pure rules used by audioLevelsService.
|
||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const {
|
const { clampMaximumAdjustmentPercent, normalizeAdjustments, applyAdjustments } = require('./gainMath');
|
||||||
clampFraction,
|
|
||||||
clampGain,
|
|
||||||
normalizeUserGains,
|
|
||||||
normalizeGainSet,
|
|
||||||
resolveCeilings,
|
|
||||||
applyCeilings,
|
|
||||||
} = require('./gainMath');
|
|
||||||
|
|
||||||
const ADMIN_LIMITS = { hornGain: 0.3, ttsGain: 0.2, forwardGain: 0.1 };
|
test('the configured range is a whole percentage from zero through one hundred', () => {
|
||||||
const BOOST_CAPS = { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 };
|
assert.equal(clampMaximumAdjustmentPercent(-5), 0);
|
||||||
|
assert.equal(clampMaximumAdjustmentPercent(32.6), 33);
|
||||||
test('an unboosted user is capped by the global admin gains', () => {
|
assert.equal(clampMaximumAdjustmentPercent(500), 100);
|
||||||
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: false });
|
|
||||||
assert.deepEqual(ceilings, ADMIN_LIMITS);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the boost flag raises the ceiling to the hard caps', () => {
|
test('each browser percentage is clamped equally in both directions', () => {
|
||||||
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: true });
|
assert.deepEqual(normalizeAdjustments({ hornPercent: -80, ttsPercent: 10, forwardPercent: 90 }, 40), {
|
||||||
assert.deepEqual(ceilings, BOOST_CAPS);
|
hornPercent: -40,
|
||||||
});
|
ttsPercent: 10,
|
||||||
|
forwardPercent: 40,
|
||||||
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('personal values normalize into the 0..1 range with a full-volume default', () => {
|
test('signed percentages adjust each server base gain', () => {
|
||||||
assert.deepEqual(normalizeUserGains({ hornGain: 0.25, ttsGain: 9 }), {
|
assert.deepEqual(
|
||||||
hornGain: 0.25,
|
applyAdjustments(
|
||||||
ttsGain: 1,
|
{ hornGain: 1, ttsGain: 2, forwardGain: 0.5 },
|
||||||
forwardGain: 1,
|
{ 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', () => {
|
test('effective gains remain inside the rover hard bounds', () => {
|
||||||
assert.deepEqual(normalizeGainSet({ hornGain: 9, ttsGain: 'x' }, BOOST_CAPS), {
|
assert.deepEqual(
|
||||||
hornGain: 4,
|
applyAdjustments({ hornGain: 4, ttsGain: 0, forwardGain: 3 }, { hornPercent: 100, ttsPercent: -100, forwardPercent: 100 }),
|
||||||
ttsGain: BOOST_CAPS.ttsGain,
|
{ hornGain: 4, ttsGain: 0, forwardGain: 4 },
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ const io = require('../../globals/io');
|
|||||||
const logger = require('../../globals/logger').child('audioLevelsService');
|
const logger = require('../../globals/logger').child('audioLevelsService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
const { isAdmin } = require('../roleService');
|
const { isAdmin, roleEvents } = require('../roleService');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
|
const { identityEvents, getUserIdForSocket, hasUserPermission } = require('../identityService');
|
||||||
const { issueCommand } = require('../commandService');
|
const { issueCommand } = require('../commandService');
|
||||||
const {
|
const {
|
||||||
GAIN_KEYS,
|
ADJUSTMENT_FIELDS,
|
||||||
clampGain,
|
clampGain,
|
||||||
clampFraction,
|
clampMaximumAdjustmentPercent,
|
||||||
normalizeUserGains,
|
normalizeAdjustments,
|
||||||
normalizeGainSet,
|
applyAdjustments,
|
||||||
resolveCeilings,
|
|
||||||
applyCeilings,
|
|
||||||
} = require('./gainMath');
|
} = require('./gainMath');
|
||||||
|
|
||||||
const audioLevelsEvents = new EventEmitter();
|
const audioLevelsEvents = new EventEmitter();
|
||||||
@@ -26,48 +24,32 @@ const DATA_DIR = resolveDataDir();
|
|||||||
const STORE_PATH = resolveDataPath('audio-levels.json');
|
const STORE_PATH = resolveDataPath('audio-levels.json');
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const configuredDefaults = config.audioLevels || {};
|
const configuredDefaults = config.audioLevels || {};
|
||||||
const configuredUserCaps = configuredDefaults.userGainCaps || {};
|
const PERSONAL_ADJUSTMENT_PERMISSION = 'audio.personalAdjustment';
|
||||||
|
const DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT = 50;
|
||||||
/*
|
|
||||||
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 DEFAULTS = {
|
const DEFAULTS = {
|
||||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||||
forwardGain: clampGain(configuredDefaults.forwardGain, 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 = {}) {
|
function normalizeStore(raw = {}) {
|
||||||
return {
|
return {
|
||||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||||
userGainCaps: normalizeUserGainCaps(raw.userGainCaps),
|
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
|
||||||
|
raw.maxPersonalAdjustmentPercent,
|
||||||
|
DEFAULTS.maxPersonalAdjustmentPercent,
|
||||||
|
),
|
||||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
||||||
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
||||||
capsUpdatedAt: Number.isFinite(raw.capsUpdatedAt) ? raw.capsUpdatedAt : null,
|
adjustmentRangeUpdatedAt: Number.isFinite(raw.adjustmentRangeUpdatedAt) ? raw.adjustmentRangeUpdatedAt : null,
|
||||||
capsUpdatedBy: typeof raw.capsUpdatedBy === 'string' ? raw.capsUpdatedBy : null,
|
adjustmentRangeUpdatedBy: typeof raw.adjustmentRangeUpdatedBy === 'string' ? raw.adjustmentRangeUpdatedBy : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +60,11 @@ function loadState() {
|
|||||||
try {
|
try {
|
||||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||||
state = normalizeStore(raw);
|
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) {
|
} catch (err) {
|
||||||
if (err.code !== 'ENOENT') {
|
if (err.code !== 'ENOENT') {
|
||||||
logger.warn('Failed to load audio levels store', err.message);
|
logger.warn('Failed to load audio levels store', err.message);
|
||||||
@@ -103,18 +90,14 @@ function getAudioLevels() {
|
|||||||
hornGain: current.hornGain,
|
hornGain: current.hornGain,
|
||||||
ttsGain: current.ttsGain,
|
ttsGain: current.ttsGain,
|
||||||
forwardGain: current.forwardGain,
|
forwardGain: current.forwardGain,
|
||||||
userGainCaps: { ...current.userGainCaps },
|
maxPersonalAdjustmentPercent: current.maxPersonalAdjustmentPercent,
|
||||||
updatedAt: current.updatedAt,
|
updatedAt: current.updatedAt,
|
||||||
updatedBy: current.updatedBy,
|
updatedBy: current.updatedBy,
|
||||||
capsUpdatedAt: current.capsUpdatedAt,
|
adjustmentRangeUpdatedAt: current.adjustmentRangeUpdatedAt,
|
||||||
capsUpdatedBy: current.capsUpdatedBy,
|
adjustmentRangeUpdatedBy: current.adjustmentRangeUpdatedBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserGainCaps() {
|
|
||||||
return { ...loadState().userGainCaps };
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitChange(reason = 'update', extra = {}) {
|
function emitChange(reason = 'update', extra = {}) {
|
||||||
audioLevelsEvents.emit('change', {
|
audioLevelsEvents.emit('change', {
|
||||||
reason,
|
reason,
|
||||||
@@ -132,30 +115,19 @@ function getAdminLimits() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGainCeilings(hasBoost) {
|
function canUsePersonalAdjustments(socket) {
|
||||||
const current = loadState();
|
if (isAdmin(socket)) return true;
|
||||||
return resolveCeilings({
|
const userId = getUserIdForSocket(socket);
|
||||||
adminLimits: getAdminLimits(),
|
return Boolean(userId && hasUserPermission(userId, PERSONAL_ADJUSTMENT_PERMISSION));
|
||||||
boostCaps: current.userGainCaps,
|
|
||||||
hasBoost,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGainCeilingsForSocket(socket) {
|
function getAdjustmentsForSocket(socket) {
|
||||||
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
|
if (!canUsePersonalAdjustments(socket)) return normalizeAdjustments({}, 0);
|
||||||
}
|
return normalizeAdjustments(socket?.data?.audioAdjustments, loadState().maxPersonalAdjustmentPercent);
|
||||||
|
|
||||||
function getUserGains(userId) {
|
|
||||||
if (!userId) return normalizeUserGains({});
|
|
||||||
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserGainsForSocket(socket) {
|
|
||||||
return getUserGains(getUserIdForSocket(socket));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEffectiveLevelsForSocket(socket) {
|
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();
|
return getAudioLevels();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUserGainCaps(input = {}, actor = null) {
|
function setMaxPersonalAdjustmentPercent(value, actor = null) {
|
||||||
const current = loadState();
|
const current = loadState();
|
||||||
const next = {
|
const next = {
|
||||||
...current,
|
...current,
|
||||||
userGainCaps: normalizeUserGainCaps(input, current.userGainCaps),
|
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(value, current.maxPersonalAdjustmentPercent),
|
||||||
capsUpdatedAt: Date.now(),
|
adjustmentRangeUpdatedAt: Date.now(),
|
||||||
capsUpdatedBy: actor,
|
adjustmentRangeUpdatedBy: actor,
|
||||||
};
|
};
|
||||||
persistState(next);
|
persistState(next);
|
||||||
/*
|
/*
|
||||||
Lowering a cap has to take effect immediately for anyone already driving,
|
A narrower range must take effect immediately for current drivers rather
|
||||||
otherwise a boosted user keeps the louder gain until their next turn.
|
than leaving an out-of-range multiplier active until their next turn.
|
||||||
*/
|
*/
|
||||||
pushLevelsToAllRovers();
|
pushLevelsToAllRovers();
|
||||||
emitChange('user_caps_set');
|
emitChange('personal_adjustment_range_set');
|
||||||
return getUserGainCaps();
|
return loadState().maxPersonalAdjustmentPercent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUserGains(socket, input = {}) {
|
function setSocketAdjustments(socket, input = {}) {
|
||||||
const userId = getUserIdForSocket(socket);
|
socket.data = socket.data || {};
|
||||||
if (!userId) throw new Error('Identity required');
|
// Store only server-normalized percentages on the transport. The cookie is a
|
||||||
const current = getUserGains(userId);
|
// browser preference, while permission and range enforcement remain here.
|
||||||
const next = { ...current };
|
socket.data.audioAdjustments = normalizeAdjustments(input, 100);
|
||||||
GAIN_KEYS.forEach((key) => {
|
|
||||||
if (input?.[key] === undefined) return;
|
|
||||||
next[key] = clampFraction(input[key], current[key]);
|
|
||||||
});
|
|
||||||
setFeatureState(userId, USER_GAINS_NAMESPACE, next);
|
|
||||||
pushLevelsForSocket(socket);
|
pushLevelsForSocket(socket);
|
||||||
emitChange('user_gains_set', { scope: 'user', userId });
|
emitChange('personal_adjustments_set', { scope: 'socket', socketId: socket.id });
|
||||||
return getAudioGainStateForSocket(socket);
|
return getAudioAdjustmentStateForSocket(socket);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The client needs all three layers to render an honest slider: its own stored
|
The client receives the percentages the server accepted, the permitted range,
|
||||||
fraction, the ceiling that fraction is measured against, and the resolved gain
|
and the resulting multipliers. This keeps the UI honest even when a cookie was
|
||||||
so the UI can show what the rover will actually play.
|
edited or an administrator changed permission while the browser was online.
|
||||||
*/
|
*/
|
||||||
function getAudioGainStateForSocket(socket) {
|
function getAudioAdjustmentStateForSocket(socket) {
|
||||||
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
|
const allowed = canUsePersonalAdjustments(socket);
|
||||||
const values = getUserGainsForSocket(socket);
|
const maximum = loadState().maxPersonalAdjustmentPercent;
|
||||||
const ceilings = getGainCeilings(hasBoost);
|
const values = allowed ? getAdjustmentsForSocket(socket) : normalizeAdjustments({}, 0);
|
||||||
return {
|
return {
|
||||||
values,
|
values,
|
||||||
ceilings,
|
allowed,
|
||||||
effective: applyCeilings(values, ceilings),
|
maxAdjustmentPercent: maximum,
|
||||||
boostGranted: hasBoost,
|
effective: applyAdjustments(getAdminLimits(), values),
|
||||||
adminLimits: getAdminLimits(),
|
baseLevels: getAdminLimits(),
|
||||||
boostCaps: getUserGainCaps(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||||
cb({ success: true, levels: getAudioLevels() });
|
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 {
|
try {
|
||||||
if (!isAdmin(socket)) {
|
if (!isAdmin(socket)) {
|
||||||
throw new Error('Not authorized');
|
throw new Error('Not authorized');
|
||||||
}
|
}
|
||||||
const actor = socket?.data?.user?.username || null;
|
const actor = socket?.data?.user?.username || null;
|
||||||
const userGainCaps = setUserGainCaps(payload || {}, actor);
|
const maxPersonalAdjustmentPercent = setMaxPersonalAdjustmentPercent(payload?.maxAdjustmentPercent, actor);
|
||||||
cb({ success: true, userGainCaps });
|
cb({ success: true, maxPersonalAdjustmentPercent });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('audioLevels:getUserGains', (_, cb = () => {}) => {
|
socket.on('audioLevels:getPersonalAdjustments', (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
cb({ success: true, audioGains: getAudioGainStateForSocket(socket) });
|
cb({ success: true, audioAdjustments: getAudioAdjustmentStateForSocket(socket) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('audioLevels:setUserGains', (payload = {}, cb = () => {}) => {
|
socket.on('audioLevels:setPersonalAdjustments', (payload = {}, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
cb({ success: true, audioGains: setUserGains(socket, payload || {}) });
|
cb({ success: true, audioAdjustments: setSocketAdjustments(socket, payload || {}) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
@@ -362,17 +345,15 @@ io.on('connection', (socket) => {
|
|||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
GAIN_KEYS,
|
ADJUSTMENT_FIELDS,
|
||||||
USER_GAIN_CAP_DEFAULTS,
|
PERSONAL_ADJUSTMENT_PERMISSION,
|
||||||
|
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
|
||||||
getAudioLevels,
|
getAudioLevels,
|
||||||
setAudioLevels,
|
setAudioLevels,
|
||||||
getUserGainCaps,
|
setMaxPersonalAdjustmentPercent,
|
||||||
setUserGainCaps,
|
setSocketAdjustments,
|
||||||
getUserGains,
|
|
||||||
setUserGains,
|
|
||||||
getGainCeilingsForSocket,
|
|
||||||
getEffectiveLevelsForSocket,
|
getEffectiveLevelsForSocket,
|
||||||
getAudioGainStateForSocket,
|
getAudioAdjustmentStateForSocket,
|
||||||
pushLevelsToRover,
|
pushLevelsToRover,
|
||||||
audioLevelsEvents,
|
audioLevelsEvents,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ const {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
listAudioGainBoostUsers,
|
|
||||||
grantAudioGainBoost,
|
|
||||||
revokeAudioGainBoost,
|
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
|
const {
|
||||||
|
listUsersForAdmin,
|
||||||
|
listUsersWithPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
|
setUserPermission,
|
||||||
|
} = require('../identityService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
@@ -187,9 +190,10 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
listAudioGainBoostUsers,
|
listUsersForAdmin,
|
||||||
grantAudioGainBoost,
|
listUsersWithPermission,
|
||||||
revokeAudioGainBoost,
|
listRegisteredPermissions,
|
||||||
|
setUserPermission,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: null,
|
sendToChannel: null,
|
||||||
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||||
|
|||||||
@@ -46,10 +46,13 @@ const {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
listAudioGainBoostUsers,
|
|
||||||
grantAudioGainBoost,
|
|
||||||
revokeAudioGainBoost,
|
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
|
const {
|
||||||
|
listUsersForAdmin,
|
||||||
|
listUsersWithPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
|
setUserPermission,
|
||||||
|
} = require('../identityService');
|
||||||
const {
|
const {
|
||||||
attachDmMessage: attachPrivateAccessDmMessage,
|
attachDmMessage: attachPrivateAccessDmMessage,
|
||||||
getRequestByMessageId: getPrivateAccessRequestByMessageId,
|
getRequestByMessageId: getPrivateAccessRequestByMessageId,
|
||||||
@@ -253,9 +256,10 @@ const commandDependencies = {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
listAudioGainBoostUsers,
|
listUsersForAdmin,
|
||||||
grantAudioGainBoost,
|
listUsersWithPermission,
|
||||||
revokeAudioGainBoost,
|
listRegisteredPermissions,
|
||||||
|
setUserPermission,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: channelIO.sendToChannel,
|
sendToChannel: channelIO.sendToChannel,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const {
|
|||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
setMuted,
|
setMuted,
|
||||||
|
setUserPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
setFeatureState,
|
setFeatureState,
|
||||||
deleteFeatureState,
|
deleteFeatureState,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
@@ -71,6 +73,11 @@ function ackHandler(socket, eventName, handler) {
|
|||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
ackHandler(socket, 'identityAdmin:listUsers', () => ({
|
ackHandler(socket, 'identityAdmin:listUsers', () => ({
|
||||||
users: listUsersForAdmin(),
|
users: listUsersForAdmin(),
|
||||||
|
permissions: listRegisteredPermissions(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ackHandler(socket, 'identityAdmin:listPermissions', () => ({
|
||||||
|
permissions: listRegisteredPermissions(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
|
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
|
||||||
@@ -112,6 +119,14 @@ io.on('connection', (socket) => {
|
|||||||
}).id),
|
}).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 }) => {
|
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
|
||||||
const normalized = normalizeFeaturePayload(namespace, value);
|
const normalized = normalizeFeaturePayload(namespace, value);
|
||||||
setFeatureState(userId, normalized.namespace, normalized.value);
|
setFeatureState(userId, normalized.namespace, normalized.value);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const Database = require('better-sqlite3');
|
|||||||
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
||||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
const logger = require('../../globals/logger').child('identityService');
|
const logger = require('../../globals/logger').child('identityService');
|
||||||
|
const { listRegisteredPermissions, requireRegisteredPermission } = require('./permissions');
|
||||||
|
|
||||||
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
|
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
|
||||||
const FINGERPRINT_ID_RE = /^tm_[a-z0-9_-]{8,256}$/;
|
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 DB_PATH = resolveDataPath('identity.sqlite');
|
||||||
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
||||||
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
||||||
const STORE_VERSION = 3;
|
const STORE_VERSION = 4;
|
||||||
const identityEvents = new EventEmitter();
|
const identityEvents = new EventEmitter();
|
||||||
|
|
||||||
let db = null;
|
let db = null;
|
||||||
@@ -169,10 +170,7 @@ function ensureSchema(conn) {
|
|||||||
deterrence_by text,
|
deterrence_by text,
|
||||||
muted_enabled integer not null default 0,
|
muted_enabled integer not null default 0,
|
||||||
muted_at integer,
|
muted_at integer,
|
||||||
muted_by text,
|
muted_by text
|
||||||
audio_gain_boost_enabled integer not null default 0,
|
|
||||||
audio_gain_boost_at integer,
|
|
||||||
audio_gain_boost_by text
|
|
||||||
);
|
);
|
||||||
|
|
||||||
create table if not exists verification_requests (
|
create table if not exists verification_requests (
|
||||||
@@ -208,6 +206,15 @@ function ensureSchema(conn) {
|
|||||||
primary key (user_id, namespace)
|
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 (
|
create table if not exists legacy_imports (
|
||||||
source text not null,
|
source text not null,
|
||||||
legacy_id 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.
|
SQLite's `create table if not exists` leaves an existing table untouched.
|
||||||
Add the mute columns explicitly for installations created before store
|
Add the mute columns explicitly for installations created before store
|
||||||
version 2, and the audio gain boost columns for those created before store
|
version 2. Permission grants now live in their own normalized table, so the
|
||||||
version 3. The column-name check keeps every later startup idempotent.
|
obsolete audio-specific status columns are deliberately removed instead of
|
||||||
|
carrying old grants into the new capability system.
|
||||||
*/
|
*/
|
||||||
const statusColumns = new Set(
|
const statusColumns = new Set(
|
||||||
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
||||||
@@ -238,15 +246,13 @@ function ensureSchema(conn) {
|
|||||||
if (!statusColumns.has('muted_by')) {
|
if (!statusColumns.has('muted_by')) {
|
||||||
conn.exec('alter table user_status add column muted_by text');
|
conn.exec('alter table user_status add column muted_by text');
|
||||||
}
|
}
|
||||||
if (!statusColumns.has('audio_gain_boost_enabled')) {
|
['audio_gain_boost_enabled', 'audio_gain_boost_at', 'audio_gain_boost_by'].forEach((column) => {
|
||||||
conn.exec('alter table user_status add column audio_gain_boost_enabled integer not null default 0');
|
if (statusColumns.has(column)) conn.exec(`alter table user_status drop column ${column}`);
|
||||||
}
|
});
|
||||||
if (!statusColumns.has('audio_gain_boost_at')) {
|
// Old personal fractions were identity-backed feature state. The replacement
|
||||||
conn.exec('alter table user_status add column audio_gain_boost_at integer');
|
// is intentionally browser-local, so retaining these unreachable rows would
|
||||||
}
|
// make the database page imply that they still control runtime behavior.
|
||||||
if (!statusColumns.has('audio_gain_boost_by')) {
|
conn.prepare('delete from user_feature_state where namespace = ?').run('audioGains');
|
||||||
conn.exec('alter table user_status add column audio_gain_boost_by text');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUser(conn = getDb(), ts = nowMs()) {
|
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('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 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);
|
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);
|
const sourceStatus = conn.prepare('select * from user_status where user_id = ?').get(sourceUserId);
|
||||||
ensureUserStatus(conn, targetUserId);
|
ensureUserStatus(conn, targetUserId);
|
||||||
@@ -431,7 +444,6 @@ function setSocketIdentityState(socket, user, identity = {}) {
|
|||||||
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
||||||
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
||||||
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
||||||
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function identifySocket(socket, payload = {}) {
|
function identifySocket(socket, payload = {}) {
|
||||||
@@ -523,11 +535,7 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
|
|||||||
mutedAt: status.muted_at || null,
|
mutedAt: status.muted_at || null,
|
||||||
mutedBy: status.muted_by || null,
|
mutedBy: status.muted_by || null,
|
||||||
},
|
},
|
||||||
audioGainBoost: {
|
permissions: getUserPermissions(id, { conn }),
|
||||||
enabled: Boolean(status.audio_gain_boost_enabled),
|
|
||||||
at: status.audio_gain_boost_at || null,
|
|
||||||
by: status.audio_gain_boost_by || null,
|
|
||||||
},
|
|
||||||
features,
|
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
|
Positive capabilities use normalized rows rather than feature-specific status
|
||||||
gain ceiling past the global admin gain settings. It stays a status column
|
columns. This keeps moderation state focused and gives future permissions the
|
||||||
rather than feature state so it can be filtered in SQL alongside the other
|
same audited grant/revoke path without another schema alteration.
|
||||||
moderation flags and copied onto the socket at identify time.
|
|
||||||
*/
|
*/
|
||||||
function setAudioGainBoost(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
function setUserPermission(userId, permissionKey, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||||
const id = String(userId || '').trim();
|
const id = String(userId || '').trim();
|
||||||
if (!id) throw new Error('userId required');
|
if (!id) throw new Error('userId required');
|
||||||
ensureUserStatus(getDb(), id);
|
const permission = requireRegisteredPermission(permissionKey);
|
||||||
getDb().prepare(`
|
const conn = getDb();
|
||||||
update user_status
|
if (!conn.prepare('select 1 from users where id = ?').get(id)) throw new Error('User not found.');
|
||||||
set audio_gain_boost_enabled = ?, audio_gain_boost_at = ?, audio_gain_boost_by = ?
|
if (enabled) {
|
||||||
where user_id = ?
|
conn.prepare(`
|
||||||
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
|
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', {
|
identityEvents.emit('change', {
|
||||||
reason: enabled ? 'audio_gain_boost_granted' : 'audio_gain_boost_revoked',
|
reason: enabled ? 'permission_granted' : 'permission_revoked',
|
||||||
userId: id,
|
userId: id,
|
||||||
|
permissionKey: permission.key,
|
||||||
});
|
});
|
||||||
|
conn.prepare('update users set updated_at = ? where id = ?').run(at, id);
|
||||||
return getUserById(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) {
|
function isVerified(socket) {
|
||||||
return Boolean(socket?.data?.isVerified);
|
return Boolean(socket?.data?.isVerified);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAudioGainBoost(socket) {
|
|
||||||
return Boolean(socket?.data?.hasAudioGainBoost);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDeterred(socket) {
|
function isDeterred(socket) {
|
||||||
return Boolean(socket?.data?.isDeterred);
|
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();
|
const conn = getDb();
|
||||||
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
||||||
const where = [];
|
const where = [];
|
||||||
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
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 (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
|
||||||
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 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 ')}`;
|
if (where.length) sql += ` where ${where.join(' and ')}`;
|
||||||
sql += ' order by users.updated_at desc';
|
sql += ' order by users.updated_at desc';
|
||||||
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
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),
|
muted: Boolean(user.deterrence?.muted),
|
||||||
mutedAt: user.deterrence?.mutedAt || null,
|
mutedAt: user.deterrence?.mutedAt || null,
|
||||||
mutedBy: user.deterrence?.mutedBy || null,
|
mutedBy: user.deterrence?.mutedBy || null,
|
||||||
audioGainBoost: Boolean(user.audioGainBoost?.enabled),
|
permissions: (user.permissions || []).map((permission) => permission.key),
|
||||||
audioGainBoostAt: user.audioGainBoost?.at || null,
|
|
||||||
audioGainBoostBy: user.audioGainBoost?.by || null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,10 +861,6 @@ function listMutedUsers() {
|
|||||||
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listAudioGainBoostUsers() {
|
|
||||||
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
||||||
const value = String(selector || '').trim();
|
const value = String(selector || '').trim();
|
||||||
if (!value) return { error: 'selector_required' };
|
if (!value) return { error: 'selector_required' };
|
||||||
@@ -1084,14 +1115,16 @@ module.exports = {
|
|||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
setMuted,
|
setMuted,
|
||||||
setAudioGainBoost,
|
setUserPermission,
|
||||||
|
getUserPermissions,
|
||||||
|
hasUserPermission,
|
||||||
|
listUsersWithPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
isVerified,
|
isVerified,
|
||||||
isDeterred,
|
isDeterred,
|
||||||
hasAudioGainBoost,
|
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
listMutedUsers,
|
listMutedUsers,
|
||||||
listAudioGainBoostUsers,
|
|
||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
createJsonStore,
|
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 { createGoalCommand } = require('./commands/goal');
|
||||||
const { createVerifyCommand } = require('./commands/verify');
|
const { createVerifyCommand } = require('./commands/verify');
|
||||||
const { createDeterCommand } = require('./commands/deter');
|
const { createDeterCommand } = require('./commands/deter');
|
||||||
const { createGainCommand } = require('./commands/gain');
|
const { createPermissionsCommand } = require('./commands/permissions');
|
||||||
const { createLightsCommand } = require('./commands/lights');
|
const { createLightsCommand } = require('./commands/lights');
|
||||||
const { createKickCommand } = require('./commands/kick');
|
const { createKickCommand } = require('./commands/kick');
|
||||||
const { createLiftCommand } = require('./commands/lift');
|
const { createLiftCommand } = require('./commands/lift');
|
||||||
@@ -57,7 +57,7 @@ function createCommandHandlers(deps) {
|
|||||||
const handleGoalCommand = createGoalCommand(deps);
|
const handleGoalCommand = createGoalCommand(deps);
|
||||||
const handleVerifyCommand = createVerifyCommand(deps);
|
const handleVerifyCommand = createVerifyCommand(deps);
|
||||||
const handleDeterCommand = createDeterCommand(deps);
|
const handleDeterCommand = createDeterCommand(deps);
|
||||||
const handleGainCommand = createGainCommand(deps);
|
const handlePermissionsCommand = createPermissionsCommand(deps);
|
||||||
const handleBridgeCommand = transportHandlers.bridge;
|
const handleBridgeCommand = transportHandlers.bridge;
|
||||||
const handleTimeStatusCommand = transportHandlers.timeStatus;
|
const handleTimeStatusCommand = transportHandlers.timeStatus;
|
||||||
const handleLightsCommand = createLightsCommand(deps);
|
const handleLightsCommand = createLightsCommand(deps);
|
||||||
@@ -108,7 +108,7 @@ function createCommandHandlers(deps) {
|
|||||||
// is included because its lock/unlock subcommands change room policy. Its
|
// is included because its lock/unlock subcommands change room policy. Its
|
||||||
// ordinary on/off/color actions are also intentionally restricted to a
|
// ordinary on/off/color actions are also intentionally restricted to a
|
||||||
// lockdown admin while the entire server is in lockdown.
|
// 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';
|
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
|
||||||
|
|
||||||
// Feature commands are public activities while access is open or managed
|
// Feature commands are public activities while access is open or managed
|
||||||
@@ -164,8 +164,8 @@ function createCommandHandlers(deps) {
|
|||||||
return handleVerifyCommand(request, tokens);
|
return handleVerifyCommand(request, tokens);
|
||||||
case 'deter':
|
case 'deter':
|
||||||
return handleDeterCommand(request, tokens);
|
return handleDeterCommand(request, tokens);
|
||||||
case 'gain':
|
case 'permissions':
|
||||||
return handleGainCommand(request, tokens);
|
return handlePermissionsCommand(request, tokens);
|
||||||
default:
|
default:
|
||||||
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
|
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: () => [],
|
listVerifiedUsers: () => [],
|
||||||
listDeterredUsers: () => [],
|
listDeterredUsers: () => [],
|
||||||
listMutedUsers: () => [],
|
listMutedUsers: () => [],
|
||||||
|
listUsersForAdmin: () => [],
|
||||||
|
listUsersWithPermission: () => [],
|
||||||
|
listRegisteredPermissions: () => [{
|
||||||
|
key: 'audio.personalAdjustment',
|
||||||
|
commandName: 'audio-adjustment',
|
||||||
|
label: 'Personal audio adjustment',
|
||||||
|
description: 'Allows personal volume adjustments.',
|
||||||
|
}],
|
||||||
|
setUserPermission: () => null,
|
||||||
getGlobalObjective: () => null,
|
getGlobalObjective: () => null,
|
||||||
getAdminReason: () => null,
|
getAdminReason: () => null,
|
||||||
config: { commands: { prefix: 'rs' } },
|
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 () => {
|
test('admin-only commands stay admin-only for a non-admin', async () => {
|
||||||
const run = createRouter();
|
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`);
|
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.
|
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
||||||
const CATEGORIES = {
|
const CATEGORIES = {
|
||||||
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
|
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'] },
|
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
|
||||||
discord: { title: 'Discord', names: ['bridge'] },
|
discord: { title: 'Discord', names: ['bridge'] },
|
||||||
};
|
};
|
||||||
@@ -46,14 +46,13 @@ function buildCommandRegistry(prefix, timeCommand) {
|
|||||||
access: 'Lockdown admin',
|
access: 'Lockdown admin',
|
||||||
permission: 'lockdown-admin',
|
permission: 'lockdown-admin',
|
||||||
},
|
},
|
||||||
gain: {
|
permissions: {
|
||||||
category: 'admin',
|
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: [
|
usage: [
|
||||||
`${prefix} gain list`,
|
`${prefix} permissions list [permission]`,
|
||||||
`${prefix} gain grant <vip>`,
|
`${prefix} permissions grant <permission> <user>`,
|
||||||
`${prefix} gain revoke <vip>`,
|
`${prefix} permissions revoke <permission> <user>`,
|
||||||
`${prefix} gain help`,
|
|
||||||
],
|
],
|
||||||
access: 'Admin',
|
access: 'Admin',
|
||||||
permission: 'admin',
|
permission: 'admin',
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const {
|
|||||||
getUserIdForSocket,
|
getUserIdForSocket,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
||||||
const { getAudioLevels, getAudioGainStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
|
const { getAudioLevels, getAudioAdjustmentStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
|
||||||
const { getButtonBoxState } = require('../buttonBoxService');
|
const { getButtonBoxState } = require('../buttonBoxService');
|
||||||
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
||||||
const {
|
const {
|
||||||
@@ -231,7 +231,7 @@ function buildSession(socket) {
|
|||||||
isVerified: Boolean(socket?.data?.isVerified),
|
isVerified: Boolean(socket?.data?.isVerified),
|
||||||
audioForward: getAudioForwardState(),
|
audioForward: getAudioForwardState(),
|
||||||
audioLevels: getAudioLevels(),
|
audioLevels: getAudioLevels(),
|
||||||
audioGains: getAudioGainStateForSocket(socket),
|
audioAdjustments: getAudioAdjustmentStateForSocket(socket),
|
||||||
buttonBox: getButtonBoxState(),
|
buttonBox: getButtonBoxState(),
|
||||||
/*
|
/*
|
||||||
Inter-instance state is a read-only directory snapshot. It is included in
|
Inter-instance state is a read-only directory snapshot. It is included in
|
||||||
@@ -463,16 +463,14 @@ audioForwardEvents.on('change', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
audioLevelsEvents.on('change', ({ scope, userId } = {}) => {
|
audioLevelsEvents.on('change', ({ scope, socketId } = {}) => {
|
||||||
/*
|
/*
|
||||||
A user dragging their own volume slider only changes their own payload, so
|
A browser dragging its own volume slider changes only that connection's
|
||||||
it resyncs just that user's tabs. Admin gain and cap edits still change
|
payload. Base-level and allowed-range changes still affect everyone and use
|
||||||
everyone's ceiling and keep the full broadcast.
|
the full broadcast path.
|
||||||
*/
|
*/
|
||||||
if (scope === 'user' && userId) {
|
if (scope === 'socket' && socketId) {
|
||||||
io.sockets.sockets.forEach((socket) => {
|
syncSocket(io.sockets.sockets.get(socketId));
|
||||||
if (getUserIdForSocket(socket) === userId) syncSocket(socket);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
syncAll();
|
syncAll();
|
||||||
|
|||||||
@@ -27,11 +27,9 @@ const {
|
|||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
setMuted,
|
setMuted,
|
||||||
setAudioGainBoost,
|
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
listMutedUsers,
|
listMutedUsers,
|
||||||
listAudioGainBoostUsers,
|
|
||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
@@ -113,17 +111,10 @@ function refreshSocketIdentityFlags(socket) {
|
|||||||
operational chat/audio tools while preserving the flag for normal roles.
|
operational chat/audio tools while preserving the flag for normal roles.
|
||||||
*/
|
*/
|
||||||
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
|
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 {
|
return {
|
||||||
isVerified: socket.data.isVerified,
|
isVerified: socket.data.isVerified,
|
||||||
isDeterred: socket.data.isDeterred,
|
isDeterred: socket.data.isDeterred,
|
||||||
isMuted: socket.data.isMuted,
|
isMuted: socket.data.isMuted,
|
||||||
hasAudioGainBoost: socket.data.hasAudioGainBoost,
|
|
||||||
matchedRecordId: user.id,
|
matchedRecordId: user.id,
|
||||||
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -133,6 +124,17 @@ function refreshSocketIdentityFlags(socket) {
|
|||||||
function identifySocket(socket, payload = {}) {
|
function identifySocket(socket, payload = {}) {
|
||||||
if (!socket) throw new Error('Socket required');
|
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);
|
const incomingNickname = sanitizeNickname(payload.nickname);
|
||||||
if (incomingNickname) {
|
if (incomingNickname) {
|
||||||
try {
|
try {
|
||||||
@@ -572,53 +574,6 @@ function setUserMute(selector, enabled, actor = null) {
|
|||||||
return userToLegacyIdentityEntry(user);
|
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) {
|
function muteUser(selector, actor = null) {
|
||||||
return setUserMute(selector, true, actor);
|
return setUserMute(selector, true, actor);
|
||||||
}
|
}
|
||||||
@@ -754,13 +709,9 @@ module.exports = {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
listAudioGainBoostUsers,
|
|
||||||
grantAudioGainBoost,
|
|
||||||
revokeAudioGainBoost,
|
|
||||||
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
||||||
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
||||||
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
||||||
hasAudioGainBoost: (socket) => Boolean(socket?.data?.hasAudioGainBoost),
|
|
||||||
reevaluateSocketVerification,
|
reevaluateSocketVerification,
|
||||||
reevaluateSocketDeterrence,
|
reevaluateSocketDeterrence,
|
||||||
verificationEvents,
|
verificationEvents,
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ const MODES = [
|
|||||||
{ key: 'lockdown', label: 'Lockdown' },
|
{ key: 'lockdown', label: 'Lockdown' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/*
|
// These are the physical rover gain channels. Personal settings remain signed
|
||||||
The same three gain keys drive both the global levels and the VIP boost hard
|
// percentages and never replace these server-owned base multipliers.
|
||||||
caps, so both editors render from one list instead of six copied sliders.
|
|
||||||
*/
|
|
||||||
const GAIN_FIELDS = [
|
const GAIN_FIELDS = [
|
||||||
{ key: 'hornGain', label: 'Horn gain' },
|
{ key: 'hornGain', label: 'Horn gain' },
|
||||||
{ key: 'ttsGain', label: 'TTS gain' },
|
{ key: 'ttsGain', label: 'TTS gain' },
|
||||||
@@ -109,7 +107,7 @@ export default function AdminPanelContent() {
|
|||||||
updateAllRovers,
|
updateAllRovers,
|
||||||
rebootServer,
|
rebootServer,
|
||||||
setAudioLevels,
|
setAudioLevels,
|
||||||
setUserAudioGainCaps,
|
setPersonalAudioAdjustmentRange,
|
||||||
setPrivateSafety,
|
setPrivateSafety,
|
||||||
llmControl,
|
llmControl,
|
||||||
overseerControl,
|
overseerControl,
|
||||||
@@ -132,12 +130,11 @@ export default function AdminPanelContent() {
|
|||||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||||
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
||||||
const currentAudioLevels = session?.audioLevels || {};
|
const currentAudioLevels = session?.audioLevels || {};
|
||||||
const currentUserGainCaps = currentAudioLevels.userGainCaps || {};
|
|
||||||
const [audioLevelDraft, setAudioLevelDraft] = useState(
|
const [audioLevelDraft, setAudioLevelDraft] = useState(
|
||||||
() => normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }),
|
() => normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }),
|
||||||
);
|
);
|
||||||
const [userGainCapDraft, setUserGainCapDraft] = useState(
|
const [maxAdjustmentDraft, setMaxAdjustmentDraft] = useState(
|
||||||
() => normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }),
|
() => Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0,
|
||||||
);
|
);
|
||||||
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
|
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
|
||||||
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
|
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
|
||||||
@@ -317,14 +314,9 @@ export default function AdminPanelContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUserGainCapDraft = (key) => (event) => {
|
const handlePersonalAdjustmentRangeSave = async () => {
|
||||||
const next = Number(event.target.value);
|
|
||||||
setUserGainCapDraft((current) => ({ ...(current || {}), [key]: Number.isFinite(next) ? next : 0 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUserGainCapsSave = async () => {
|
|
||||||
try {
|
try {
|
||||||
await setUserAudioGainCaps(userGainCapDraft);
|
await setPersonalAudioAdjustmentRange(maxAdjustmentDraft);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
}
|
}
|
||||||
@@ -356,9 +348,8 @@ export default function AdminPanelContent() {
|
|||||||
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
|
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserGainCapDraft(normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }));
|
setMaxAdjustmentDraft(Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [currentAudioLevels.maxPersonalAdjustmentPercent]);
|
||||||
}, [currentUserGainCaps.forwardGain, currentUserGainCaps.hornGain, currentUserGainCaps.ttsGain]);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -462,7 +453,7 @@ export default function AdminPanelContent() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<p className="text-xs text-slate-500">
|
<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>
|
</p>
|
||||||
<div className="flex gap-0.5 text-xs">
|
<div className="flex gap-0.5 text-xs">
|
||||||
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
|
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
|
||||||
@@ -472,26 +463,32 @@ export default function AdminPanelContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<span>VIP gain boost hard caps</span>
|
<span>Maximum personal adjustment</span>
|
||||||
{session?.audioLevels?.capsUpdatedAt ? (
|
{session?.audioLevels?.adjustmentRangeUpdatedAt ? (
|
||||||
<span>Updated {new Date(session.audioLevels.capsUpdatedAt).toLocaleString()}</span>
|
<span>Updated {new Date(session.audioLevels.adjustmentRangeUpdatedAt).toLocaleString()}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{GAIN_FIELDS.map(({ key, label }) => (
|
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||||
<GainSlider
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
key={key}
|
<span>Allowed range in both directions</span>
|
||||||
label={label}
|
<span>±{Math.round(Number(maxAdjustmentDraft) || 0)}%</span>
|
||||||
value={userGainCapDraft[key]}
|
</div>
|
||||||
onChange={handleUserGainCapDraft(key)}
|
<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">
|
<p className="text-xs text-slate-500">
|
||||||
Ceilings for VIPs granted the boost with <code>rs gain grant <vip></code>. A boost never lowers
|
Users with the personal audio adjustment permission can reduce or increase each base level by this percentage.
|
||||||
someone's limit, so a cap below the global gain above has no effect.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-0.5 text-xs">
|
<div className="flex gap-0.5 text-xs">
|
||||||
<button type="button" onClick={handleUserGainCapsSave} className="button-dark">
|
<button type="button" onClick={handlePersonalAdjustmentRangeSave} className="button-dark">
|
||||||
Apply boost caps
|
Apply adjustment range
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,134 +1,114 @@
|
|||||||
// Volume Settings Card
|
// Personal Volume Adjustment Card
|
||||||
// Purpose: Lets any user set their own horn, TTS, and mic-forward volume.
|
// Purpose: Lets an approved user offset horn, text-to-speech, and microphone output around server-owned base levels.
|
||||||
// Scope: Renders the server-resolved ceilings; the server still owns every limit.
|
// Scope: Persists signed percentages in roverSettings while the server owns permission, clamping, and gain conversion.
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { useSession } from '../../context/SessionContext.jsx';
|
import { useSession } from '../../context/SessionContext.jsx';
|
||||||
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
|
||||||
/*
|
const ADJUSTMENT_FIELDS = [
|
||||||
Sliders are a 0-1 fraction of whichever ceiling the server resolved for this
|
{ key: 'hornPercent', label: 'Horn volume' },
|
||||||
user, so the same three keys describe the fraction, the ceiling, and the gain
|
{ key: 'ttsPercent', label: 'Text-to-speech volume' },
|
||||||
the rover will actually apply.
|
{ key: 'forwardPercent', label: 'Microphone volume' },
|
||||||
*/
|
|
||||||
const GAIN_FIELDS = [
|
|
||||||
{ key: 'hornGain', label: 'Horn volume' },
|
|
||||||
{ key: 'ttsGain', label: 'Text-to-speech volume' },
|
|
||||||
{ key: 'forwardGain', label: 'Microphone volume' },
|
|
||||||
];
|
];
|
||||||
|
const DEFAULT_ADJUSTMENTS = { hornPercent: 0, ttsPercent: 0, forwardPercent: 0 };
|
||||||
// Dragging a range input fires continuously; persist once the user settles.
|
|
||||||
const COMMIT_DEBOUNCE_MS = 300;
|
const COMMIT_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
function clampFraction(value, fallback = 1) {
|
function clampPercent(value, maximum) {
|
||||||
const num = Number(value);
|
const number = Number(value);
|
||||||
if (!Number.isFinite(num)) return fallback;
|
const limit = Math.max(0, Number(maximum) || 0);
|
||||||
return Math.max(0, Math.min(1, num));
|
if (!Number.isFinite(number)) return 0;
|
||||||
|
return Math.round(Math.max(-limit, Math.min(limit, number)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeValues(raw) {
|
function normalizeAdjustments(raw, maximum) {
|
||||||
const out = {};
|
const normalized = {};
|
||||||
GAIN_FIELDS.forEach(({ key }) => {
|
ADJUSTMENT_FIELDS.forEach(({ key }) => {
|
||||||
out[key] = clampFraction(raw?.[key], 1);
|
normalized[key] = clampPercent(raw?.[key], maximum);
|
||||||
});
|
});
|
||||||
return out;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatGain(value) {
|
function formatPercent(value) {
|
||||||
const num = Number(value);
|
const number = Number(value) || 0;
|
||||||
return `${Number.isFinite(num) ? num.toFixed(2) : '0.00'}x`;
|
return `${number > 0 ? '+' : ''}${number}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VolumeSettingsCard() {
|
export default function VolumeSettingsCard() {
|
||||||
const { session, setUserAudioGains } = useSession();
|
const { session, setPersonalAudioAdjustments } = useSession();
|
||||||
const audioGains = session?.audioGains || null;
|
const { value: savedAdjustments, save: saveAdjustments } = useSettingsNamespace(
|
||||||
const serverValues = useMemo(() => normalizeValues(audioGains?.values), [audioGains?.values]);
|
'audioAdjustments',
|
||||||
const ceilings = audioGains?.ceilings || {};
|
DEFAULT_ADJUSTMENTS,
|
||||||
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 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(() => () => {
|
useEffect(() => () => {
|
||||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleChange = (key) => (event) => {
|
const handleChange = (key) => (event) => {
|
||||||
const next = clampFraction(event.target.value, 0);
|
const next = { ...draft, [key]: clampPercent(event.target.value, maximum) };
|
||||||
const nextDraft = { ...draft, [key]: next };
|
setDraft(next);
|
||||||
setDraft(nextDraft);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
timerRef.current = setTimeout(() => commit(next), COMMIT_DEBOUNCE_MS);
|
||||||
commitTimerRef.current = setTimeout(() => commit(nextDraft), COMMIT_DEBOUNCE_MS);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sliders would be misleading before the first session sync lands.
|
if (!serverState) return null;
|
||||||
if (!audioGains) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Volume" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
|
<CardFrame title="Personal volume adjustment" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
|
||||||
{GAIN_FIELDS.map(({ key, label }) => {
|
{ADJUSTMENT_FIELDS.map(({ key, label }) => (
|
||||||
const ceiling = Number.isFinite(Number(ceilings[key])) ? Number(ceilings[key]) : 0;
|
<label key={key} className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-white">
|
||||||
const fraction = clampFraction(draft[key], 1);
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
|
||||||
const muted = ceiling <= 0;
|
<span className="min-w-0 font-semibold text-white">{label}</span>
|
||||||
return (
|
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||||
<label
|
{formatPercent(draft[key])}
|
||||||
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)`}
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
);
|
<input
|
||||||
})}
|
type="range"
|
||||||
<p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">
|
min={-maximum}
|
||||||
{boostGranted
|
max={maximum}
|
||||||
? 'You have a raised volume limit. 100% is the admin-set hard cap for boosted users rather than the normal global gain.'
|
step="1"
|
||||||
: 'Your limit is the global gain an admin has set. Admins can raise it per user.'}
|
value={clampPercent(draft[key], maximum)}
|
||||||
</p>
|
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>}
|
{error && <p className="mx-auto w-full max-w-lg text-xs text-rose-300">{error}</p>}
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -342,8 +342,8 @@ export function SessionProvider({ children }) {
|
|||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
login: (username, password) => emitWithAck('auth:login', { username, password }),
|
login: (username, password) => emitWithAck('auth:login', { username, password }),
|
||||||
identifySession: ({ cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface } = {}) =>
|
identifySession: ({ cookieUserId, fingerprintId, nickname, audioAdjustments, overseerEnabled, identitySurface } = {}) =>
|
||||||
emitWithAck('session:identify', { cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface }),
|
emitWithAck('session:identify', { cookieUserId, fingerprintId, nickname, audioAdjustments, overseerEnabled, identitySurface }),
|
||||||
setRole: (role) => emitWithAck('session:setRole', { role }),
|
setRole: (role) => emitWithAck('session:setRole', { role }),
|
||||||
requestControl: (roverId, options = {}) =>
|
requestControl: (roverId, options = {}) =>
|
||||||
emitWithAck('session:requestControl', { roverId, ...options }),
|
emitWithAck('session:requestControl', { roverId, ...options }),
|
||||||
@@ -399,13 +399,10 @@ export function SessionProvider({ children }) {
|
|||||||
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
|
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
|
||||||
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
|
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
|
||||||
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
||||||
/*
|
setPersonalAudioAdjustments: (adjustments = {}) =>
|
||||||
Personal volume is a 0-1 fraction of whichever ceiling the server has
|
emitWithAck('audioLevels:setPersonalAdjustments', adjustments),
|
||||||
resolved for this user, so the browser never sends an absolute gain and
|
setPersonalAudioAdjustmentRange: (maxAdjustmentPercent) =>
|
||||||
cannot widen its own limits.
|
emitWithAck('audioLevels:setPersonalAdjustmentRange', { maxAdjustmentPercent }),
|
||||||
*/
|
|
||||||
setUserAudioGains: (gains = {}) => emitWithAck('audioLevels:setUserGains', gains),
|
|
||||||
setUserAudioGainCaps: (caps = {}) => emitWithAck('audioLevels:setUserCaps', caps),
|
|
||||||
setPrivateSafety: (roverId, safety = {}) =>
|
setPrivateSafety: (roverId, safety = {}) =>
|
||||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||||
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
removeSignal,
|
removeSignal,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
setMuted,
|
setMuted,
|
||||||
|
setPermission,
|
||||||
setVerified,
|
setVerified,
|
||||||
updateFeatureState,
|
updateFeatureState,
|
||||||
} from './identityDatabaseApi.js';
|
} 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() {
|
export default function IdentityDatabasePanel() {
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
const [permissions, setPermissions] = useState([]);
|
||||||
const [selectedUser, setSelectedUser] = useState(null);
|
const [selectedUser, setSelectedUser] = useState(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [filter, setFilter] = useState('all');
|
const [filter, setFilter] = useState('all');
|
||||||
@@ -321,6 +346,7 @@ export default function IdentityDatabasePanel() {
|
|||||||
try {
|
try {
|
||||||
const resp = await listUsers(socket);
|
const resp = await listUsers(socket);
|
||||||
setUsers(resp.users || []);
|
setUsers(resp.users || []);
|
||||||
|
setPermissions(resp.permissions || []);
|
||||||
if (selectedUser?.id) {
|
if (selectedUser?.id) {
|
||||||
const updated = await getUser(socket, selectedUser.id);
|
const updated = await getUser(socket, selectedUser.id);
|
||||||
setSelectedUser(updated.user || null);
|
setSelectedUser(updated.user || null);
|
||||||
@@ -380,6 +406,11 @@ export default function IdentityDatabasePanel() {
|
|||||||
runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.');
|
runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.');
|
||||||
const handleMuted = (enabled) =>
|
const handleMuted = (enabled) =>
|
||||||
runMutation((userId) => setMuted(socket, userId, enabled), 'Mute updated.');
|
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) =>
|
const handleSaveFeature = (namespace, value) =>
|
||||||
runMutation((userId) => updateFeatureState(socket, userId, namespace, value), 'Feature state saved.');
|
runMutation((userId) => updateFeatureState(socket, userId, namespace, value), 'Feature state saved.');
|
||||||
const handleDeleteFeature = (namespace) =>
|
const handleDeleteFeature = (namespace) =>
|
||||||
@@ -407,6 +438,7 @@ export default function IdentityDatabasePanel() {
|
|||||||
<TabList>
|
<TabList>
|
||||||
<Tab id="signals">Signals</Tab>
|
<Tab id="signals">Signals</Tab>
|
||||||
<Tab id="status">Status</Tab>
|
<Tab id="status">Status</Tab>
|
||||||
|
<Tab id="permissions">Permissions</Tab>
|
||||||
<Tab id="features">Feature state</Tab>
|
<Tab id="features">Feature state</Tab>
|
||||||
<Tab id="raw">Raw JSON</Tab>
|
<Tab id="raw">Raw JSON</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
@@ -417,6 +449,9 @@ export default function IdentityDatabasePanel() {
|
|||||||
<TabPanel id="status">
|
<TabPanel id="status">
|
||||||
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} onMuted={handleMuted} />
|
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} onMuted={handleMuted} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel id="permissions">
|
||||||
|
<PermissionsCard user={selectedUser} permissions={permissions} onPermission={handlePermission} />
|
||||||
|
</TabPanel>
|
||||||
<TabPanel id="features">
|
<TabPanel id="features">
|
||||||
<FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
|
<FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export function setMuted(socket, userId, enabled) {
|
|||||||
return emitIdentityAdmin(socket, 'identityAdmin:setMuted', { 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) {
|
export function updateFeatureState(socket, userId, namespace, value) {
|
||||||
return emitIdentityAdmin(socket, 'identityAdmin:updateFeatureState', { userId, namespace, value });
|
return emitIdentityAdmin(socket, 'identityAdmin:updateFeatureState', { userId, namespace, value });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function userMatchesQuery(user, query) {
|
|||||||
...(user?.nicknames || []),
|
...(user?.nicknames || []),
|
||||||
...(user?.knownIps || []),
|
...(user?.knownIps || []),
|
||||||
...(user?.featureNamespaces || []),
|
...(user?.featureNamespaces || []),
|
||||||
|
...(user?.permissions || []).map((permission) => permission.key),
|
||||||
].join(' ').toLowerCase();
|
].join(' ').toLowerCase();
|
||||||
return haystack.includes(needle);
|
return haystack.includes(needle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
'overseerPreference',
|
'overseerPreference',
|
||||||
{ enabled: false },
|
{ enabled: false },
|
||||||
);
|
);
|
||||||
|
const { value: audioAdjustments, status: audioAdjustmentsStatus } = useSettingsNamespace('audioAdjustments', {
|
||||||
|
hornPercent: 0,
|
||||||
|
ttsPercent: 0,
|
||||||
|
forwardPercent: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const ready =
|
const ready =
|
||||||
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready';
|
identityStatus === 'ready'
|
||||||
|
&& profileStatus === 'ready'
|
||||||
|
&& overseerPreferenceStatus === 'ready'
|
||||||
|
&& audioAdjustmentsStatus === 'ready';
|
||||||
const cookieUserId = (identity?.cookieUserId || '').trim();
|
const cookieUserId = (identity?.cookieUserId || '').trim();
|
||||||
const nickname = (profile?.nickname || '').trim();
|
const nickname = (profile?.nickname || '').trim();
|
||||||
const overseerEnabled = Boolean(overseerPreference?.enabled);
|
const overseerEnabled = Boolean(overseerPreference?.enabled);
|
||||||
@@ -40,6 +48,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
cookieUserId,
|
cookieUserId,
|
||||||
fingerprintId: await getBrowserFingerprintId(),
|
fingerprintId: await getBrowserFingerprintId(),
|
||||||
nickname,
|
nickname,
|
||||||
|
audioAdjustments,
|
||||||
overseerEnabled,
|
overseerEnabled,
|
||||||
identitySurface: normalizedIdentitySurface,
|
identitySurface: normalizedIdentitySurface,
|
||||||
});
|
});
|
||||||
@@ -55,6 +64,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
audioAdjustments,
|
||||||
connected,
|
connected,
|
||||||
cookieUserId,
|
cookieUserId,
|
||||||
identifySession,
|
identifySession,
|
||||||
@@ -75,7 +85,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
|||||||
*/
|
*/
|
||||||
if (!ready || !connected || !socket?.id) return;
|
if (!ready || !connected || !socket?.id) return;
|
||||||
sendIdentify();
|
sendIdentify();
|
||||||
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
|
}, [ready, connected, socket?.id, cookieUserId, nickname, audioAdjustments, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ async function buildSocketIdentity() {
|
|||||||
cookieUserId: String(currentSettings?.identity?.cookieUserId || '').trim(),
|
cookieUserId: String(currentSettings?.identity?.cookieUserId || '').trim(),
|
||||||
fingerprintId,
|
fingerprintId,
|
||||||
nickname: String(currentSettings?.profile?.nickname || '').trim(),
|
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),
|
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
|
||||||
identitySurface: getIdentitySurface(),
|
identitySurface: getIdentitySurface(),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user