feat(identity): add audioGainBoost user flag

Adds an audio_gain_boost_enabled status column (plus _at/_by audit
fields) to user_status, exposed as user.audioGainBoost and copied onto
sockets as socket.data.hasAudioGainBoost. The flag marks VIPs allowed to
raise their personal horn/TTS/mic gain ceiling past the global admin gain
settings.

Grant/revoke goes through verificationService so socket flags refresh and
an event is published. Resolution is restricted to verified users, so an
unverified visitor sharing a nickname can never be matched.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Saul5662
2026-07-29 03:59:47 +01:00
co-authored by Claude
parent d000b8f4f8
commit 81f52c29d8
2 changed files with 120 additions and 4 deletions
+60 -4
View File
@@ -17,7 +17,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
const DB_PATH = resolveDataPath('identity.sqlite');
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
const STORE_VERSION = 2;
const STORE_VERSION = 3;
const identityEvents = new EventEmitter();
let db = null;
@@ -169,7 +169,10 @@ function ensureSchema(conn) {
deterrence_by text,
muted_enabled integer not null default 0,
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 (
@@ -220,7 +223,8 @@ function ensureSchema(conn) {
/*
SQLite's `create table if not exists` leaves an existing table untouched.
Add the mute columns explicitly for installations created before store
version 2, while the column-name check keeps every later startup idempotent.
version 2, and the audio gain boost columns for those created before store
version 3. The column-name check keeps every later startup idempotent.
*/
const statusColumns = new Set(
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
@@ -234,6 +238,15 @@ function ensureSchema(conn) {
if (!statusColumns.has('muted_by')) {
conn.exec('alter table user_status add column muted_by text');
}
if (!statusColumns.has('audio_gain_boost_enabled')) {
conn.exec('alter table user_status add column audio_gain_boost_enabled integer not null default 0');
}
if (!statusColumns.has('audio_gain_boost_at')) {
conn.exec('alter table user_status add column audio_gain_boost_at integer');
}
if (!statusColumns.has('audio_gain_boost_by')) {
conn.exec('alter table user_status add column audio_gain_boost_by text');
}
}
function createUser(conn = getDb(), ts = nowMs()) {
@@ -418,6 +431,7 @@ function setSocketIdentityState(socket, user, identity = {}) {
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
socket.data.isMuted = Boolean(user.deterrence?.muted);
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
}
function identifySocket(socket, payload = {}) {
@@ -509,6 +523,11 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
mutedAt: status.muted_at || null,
mutedBy: status.muted_by || null,
},
audioGainBoost: {
enabled: Boolean(status.audio_gain_boost_enabled),
at: status.audio_gain_boost_at || null,
by: status.audio_gain_boost_by || null,
},
features,
};
}
@@ -726,21 +745,48 @@ function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
return getUserById(id);
}
/*
The audio gain boost flag lets a trusted VIP raise their personal horn/TTS/mic
gain ceiling past the global admin gain settings. It stays a status column
rather than feature state so it can be filtered in SQL alongside the other
moderation flags and copied onto the socket at identify time.
*/
function setAudioGainBoost(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
const id = String(userId || '').trim();
if (!id) throw new Error('userId required');
ensureUserStatus(getDb(), id);
getDb().prepare(`
update user_status
set audio_gain_boost_enabled = ?, audio_gain_boost_at = ?, audio_gain_boost_by = ?
where user_id = ?
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
identityEvents.emit('change', {
reason: enabled ? 'audio_gain_boost_granted' : 'audio_gain_boost_revoked',
userId: id,
});
return getUserById(id);
}
function isVerified(socket) {
return Boolean(socket?.data?.isVerified);
}
function hasAudioGainBoost(socket) {
return Boolean(socket?.data?.hasAudioGainBoost);
}
function isDeterred(socket) {
return Boolean(socket?.data?.isDeterred);
}
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
function listUsers({ verified = null, deterred = null, muted = null, audioGainBoost = null } = {}) {
const conn = getDb();
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
const where = [];
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
if (audioGainBoost !== null) where.push(`user_status.audio_gain_boost_enabled = ${audioGainBoost ? 1 : 0}`);
if (where.length) sql += ` where ${where.join(' and ')}`;
sql += ' order by users.updated_at desc';
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
@@ -762,6 +808,9 @@ function userToLegacyIdentityEntry(user) {
muted: Boolean(user.deterrence?.muted),
mutedAt: user.deterrence?.mutedAt || null,
mutedBy: user.deterrence?.mutedBy || null,
audioGainBoost: Boolean(user.audioGainBoost?.enabled),
audioGainBoostAt: user.audioGainBoost?.at || null,
audioGainBoostBy: user.audioGainBoost?.by || null,
};
}
@@ -777,6 +826,10 @@ function listMutedUsers() {
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
}
function listAudioGainBoostUsers() {
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
}
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
const value = String(selector || '').trim();
if (!value) return { error: 'selector_required' };
@@ -1031,11 +1084,14 @@ module.exports = {
setVerified,
setDeterrence,
setMuted,
setAudioGainBoost,
isVerified,
isDeterred,
hasAudioGainBoost,
listVerifiedUsers,
listDeterredUsers,
listMutedUsers,
listAudioGainBoostUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
createJsonStore,
@@ -27,9 +27,11 @@ const {
setVerified,
setDeterrence,
setMuted,
setAudioGainBoost,
listVerifiedUsers,
listDeterredUsers,
listMutedUsers,
listAudioGainBoostUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
} = require('../identityService');
@@ -113,10 +115,17 @@ function refreshSocketIdentityFlags(socket) {
operational chat/audio tools while preserving the flag for normal roles.
*/
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
/*
Admins already control the global gain settings, so they carry the raised
audio ceiling implicitly for the same reason they carry verification: a
stored per-user grant should never be what gates an operator's own tools.
*/
socket.data.hasAudioGainBoost = verifiedByRole || Boolean(user.audioGainBoost?.enabled);
return {
isVerified: socket.data.isVerified,
isDeterred: socket.data.isDeterred,
isMuted: socket.data.isMuted,
hasAudioGainBoost: socket.data.hasAudioGainBoost,
matchedRecordId: user.id,
reason: socket.data.isVerified ? 'matched' : 'no_match',
userId: user.id,
@@ -565,6 +574,53 @@ function setUserMute(selector, enabled, actor = null) {
return userToLegacyIdentityEntry(user);
}
/*
Audio gain boost is a VIP-only grant. resolveUserBySelector's `includeVerified`
flag reads as an inclusion toggle but actually relaxes the verified filter, so
passing `false` is what restricts the nickname lookup to verified users. That
keeps an unverified visitor from being matched by a shared nickname and handed
a raised gain ceiling they are not eligible to use.
*/
function setUserAudioGainBoost(selector, enabled, actor = null) {
const resolved = resolveUserBySelector(selector, { includeVerified: false, includeDeterred: true });
if (resolved.error || !resolved.user) {
throw new Error(resolved.error === 'ambiguous_nickname'
? 'Nickname matches multiple VIPs.'
: 'VIP not found. Only verified users can be granted an audio gain boost.');
}
if (!resolved.user.verified?.enabled) {
throw new Error('Only verified VIPs can be granted an audio gain boost.');
}
const user = setAudioGainBoost(resolved.user.id, {
enabled,
actor: actor ? String(actor) : null,
at: Date.now(),
});
refreshSocketsForUser(user.id);
publishEvent({
source: 'moderation',
type: enabled ? 'audio.gainBoostGranted' : 'audio.gainBoostRevoked',
payload: {
userId: user.id,
cookieUserId: user.cookieUserIds[0] || null,
nickname: user.nickname,
actor: actor ? String(actor) : null,
ts: Date.now(),
},
});
emitChange(enabled ? 'audio_gain_boost_grant' : 'audio_gain_boost_revoke', { userId: user.id });
return userToLegacyIdentityEntry(user);
}
function grantAudioGainBoost(selector, actor = null) {
return setUserAudioGainBoost(selector, true, actor);
}
function revokeAudioGainBoost(selector, actor = null) {
return setUserAudioGainBoost(selector, false, actor);
}
function muteUser(selector, actor = null) {
return setUserMute(selector, true, actor);
}
@@ -696,9 +752,13 @@ module.exports = {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
isVerified: (socket) => Boolean(socket?.data?.isVerified),
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
isMuted: (socket) => Boolean(socket?.data?.isMuted),
hasAudioGainBoost: (socket) => Boolean(socket?.data?.hasAudioGainBoost),
reevaluateSocketVerification,
reevaluateSocketDeterrence,
verificationEvents,