From 81f52c29d88e2f1dbe2e1e4f3a000df8ec7aeda7 Mon Sep 17 00:00:00 2001 From: Saul5662 Date: Wed, 29 Jul 2026 03:59:47 +0100 Subject: [PATCH] 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 --- server/src/services/identityService/index.js | 64 +++++++++++++++++-- .../src/services/verificationService/index.js | 60 +++++++++++++++++ 2 files changed, 120 insertions(+), 4 deletions(-) diff --git a/server/src/services/identityService/index.js b/server/src/services/identityService/index.js index 09d7e55a..47a86110 100644 --- a/server/src/services/identityService/index.js +++ b/server/src/services/identityService/index.js @@ -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, diff --git a/server/src/services/verificationService/index.js b/server/src/services/verificationService/index.js index c40de827..7c8d4008 100644 --- a/server/src/services/verificationService/index.js +++ b/server/src/services/verificationService/index.js @@ -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,