moving audio gain perms around

This commit is contained in:
legop3
2026-08-07 22:34:04 -04:00
parent 9702cf0f82
commit 6c69c583c5
34 changed files with 936 additions and 1132 deletions
+83 -50
View File
@@ -10,6 +10,7 @@ const Database = require('better-sqlite3');
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
const { resolveDataPath } = require('../../helpers/dataPaths');
const logger = require('../../globals/logger').child('identityService');
const { listRegisteredPermissions, requireRegisteredPermission } = require('./permissions');
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
const FINGERPRINT_ID_RE = /^tm_[a-z0-9_-]{8,256}$/;
@@ -17,7 +18,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
const DB_PATH = resolveDataPath('identity.sqlite');
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
const STORE_VERSION = 3;
const STORE_VERSION = 4;
const identityEvents = new EventEmitter();
let db = null;
@@ -169,10 +170,7 @@ function ensureSchema(conn) {
deterrence_by text,
muted_enabled integer not null default 0,
muted_at integer,
muted_by text,
audio_gain_boost_enabled integer not null default 0,
audio_gain_boost_at integer,
audio_gain_boost_by text
muted_by text
);
create table if not exists verification_requests (
@@ -208,6 +206,15 @@ function ensureSchema(conn) {
primary key (user_id, namespace)
);
create table if not exists user_permissions (
user_id text not null references users(id) on delete cascade,
permission_key text not null,
granted_at integer not null,
granted_by text,
primary key (user_id, permission_key)
);
create index if not exists idx_user_permissions_key on user_permissions(permission_key);
create table if not exists legacy_imports (
source text not null,
legacy_id text not null,
@@ -223,8 +230,9 @@ function ensureSchema(conn) {
/*
SQLite's `create table if not exists` leaves an existing table untouched.
Add the mute columns explicitly for installations created before store
version 2, and the audio gain boost columns for those created before store
version 3. The column-name check keeps every later startup idempotent.
version 2. Permission grants now live in their own normalized table, so the
obsolete audio-specific status columns are deliberately removed instead of
carrying old grants into the new capability system.
*/
const statusColumns = new Set(
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
@@ -238,15 +246,13 @@ function ensureSchema(conn) {
if (!statusColumns.has('muted_by')) {
conn.exec('alter table user_status add column muted_by text');
}
if (!statusColumns.has('audio_gain_boost_enabled')) {
conn.exec('alter table user_status add column audio_gain_boost_enabled integer not null default 0');
}
if (!statusColumns.has('audio_gain_boost_at')) {
conn.exec('alter table user_status add column audio_gain_boost_at integer');
}
if (!statusColumns.has('audio_gain_boost_by')) {
conn.exec('alter table user_status add column audio_gain_boost_by text');
}
['audio_gain_boost_enabled', 'audio_gain_boost_at', 'audio_gain_boost_by'].forEach((column) => {
if (statusColumns.has(column)) conn.exec(`alter table user_status drop column ${column}`);
});
// Old personal fractions were identity-backed feature state. The replacement
// is intentionally browser-local, so retaining these unreachable rows would
// make the database page imply that they still control runtime behavior.
conn.prepare('delete from user_feature_state where namespace = ?').run('audioGains');
}
function createUser(conn = getDb(), ts = nowMs()) {
@@ -291,6 +297,13 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
conn.prepare('delete from user_known_ips where user_id = ?').run(sourceUserId);
conn.prepare('update verification_requests set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
conn.prepare('update legacy_imports set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
/*
Permissions describe the person, not one browser signal. Merging identities
therefore unions their grants before the source user is deleted; a conflict
keeps the target row and its original audit metadata.
*/
conn.prepare('update or ignore user_permissions set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
conn.prepare('delete from user_permissions where user_id = ?').run(sourceUserId);
const sourceStatus = conn.prepare('select * from user_status where user_id = ?').get(sourceUserId);
ensureUserStatus(conn, targetUserId);
@@ -431,7 +444,6 @@ function setSocketIdentityState(socket, user, identity = {}) {
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
socket.data.isMuted = Boolean(user.deterrence?.muted);
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
}
function identifySocket(socket, payload = {}) {
@@ -523,11 +535,7 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
mutedAt: status.muted_at || null,
mutedBy: status.muted_by || null,
},
audioGainBoost: {
enabled: Boolean(status.audio_gain_boost_enabled),
at: status.audio_gain_boost_at || null,
by: status.audio_gain_boost_by || null,
},
permissions: getUserPermissions(id, { conn }),
features,
};
}
@@ -746,47 +754,76 @@ function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
}
/*
The audio gain boost flag lets a trusted VIP raise their personal horn/TTS/mic
gain ceiling past the global admin gain settings. It stays a status column
rather than feature state so it can be filtered in SQL alongside the other
moderation flags and copied onto the socket at identify time.
Positive capabilities use normalized rows rather than feature-specific status
columns. This keeps moderation state focused and gives future permissions the
same audited grant/revoke path without another schema alteration.
*/
function setAudioGainBoost(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
function setUserPermission(userId, permissionKey, { enabled = true, actor = null, at = nowMs() } = {}) {
const id = String(userId || '').trim();
if (!id) throw new Error('userId required');
ensureUserStatus(getDb(), id);
getDb().prepare(`
update user_status
set audio_gain_boost_enabled = ?, audio_gain_boost_at = ?, audio_gain_boost_by = ?
where user_id = ?
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
const permission = requireRegisteredPermission(permissionKey);
const conn = getDb();
if (!conn.prepare('select 1 from users where id = ?').get(id)) throw new Error('User not found.');
if (enabled) {
conn.prepare(`
insert into user_permissions (user_id, permission_key, granted_at, granted_by)
values (?, ?, ?, ?)
on conflict(user_id, permission_key) do update set granted_at = excluded.granted_at, granted_by = excluded.granted_by
`).run(id, permission.key, at, actor ? String(actor) : null);
} else {
conn.prepare('delete from user_permissions where user_id = ? and permission_key = ?').run(id, permission.key);
}
identityEvents.emit('change', {
reason: enabled ? 'audio_gain_boost_granted' : 'audio_gain_boost_revoked',
reason: enabled ? 'permission_granted' : 'permission_revoked',
userId: id,
permissionKey: permission.key,
});
conn.prepare('update users set updated_at = ? where id = ?').run(at, id);
return getUserById(id);
}
function getUserPermissions(userId, { conn = getDb() } = {}) {
const id = String(userId || '').trim();
if (!id) return [];
return conn.prepare(`
select permission_key as key, granted_at as grantedAt, granted_by as grantedBy
from user_permissions
where user_id = ?
order by permission_key
`).all(id);
}
function hasUserPermission(userId, permissionKey, { conn = getDb() } = {}) {
const id = String(userId || '').trim();
const permission = requireRegisteredPermission(permissionKey);
if (!id) return false;
return Boolean(conn.prepare('select 1 from user_permissions where user_id = ? and permission_key = ?').get(id, permission.key));
}
function listUsersWithPermission(permissionKey) {
const permission = requireRegisteredPermission(permissionKey);
const conn = getDb();
return conn.prepare('select user_id from user_permissions where permission_key = ? order by granted_at desc')
.all(permission.key)
.map((row) => getUserById(row.user_id, { conn, includeFeatures: false }))
.filter(Boolean);
}
function isVerified(socket) {
return Boolean(socket?.data?.isVerified);
}
function hasAudioGainBoost(socket) {
return Boolean(socket?.data?.hasAudioGainBoost);
}
function isDeterred(socket) {
return Boolean(socket?.data?.isDeterred);
}
function listUsers({ verified = null, deterred = null, muted = null, audioGainBoost = null } = {}) {
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
const conn = getDb();
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
const where = [];
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
if (audioGainBoost !== null) where.push(`user_status.audio_gain_boost_enabled = ${audioGainBoost ? 1 : 0}`);
if (where.length) sql += ` where ${where.join(' and ')}`;
sql += ' order by users.updated_at desc';
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
@@ -808,9 +845,7 @@ function userToLegacyIdentityEntry(user) {
muted: Boolean(user.deterrence?.muted),
mutedAt: user.deterrence?.mutedAt || null,
mutedBy: user.deterrence?.mutedBy || null,
audioGainBoost: Boolean(user.audioGainBoost?.enabled),
audioGainBoostAt: user.audioGainBoost?.at || null,
audioGainBoostBy: user.audioGainBoost?.by || null,
permissions: (user.permissions || []).map((permission) => permission.key),
};
}
@@ -826,10 +861,6 @@ function listMutedUsers() {
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
}
function listAudioGainBoostUsers() {
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
}
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
const value = String(selector || '').trim();
if (!value) return { error: 'selector_required' };
@@ -1084,14 +1115,16 @@ module.exports = {
setVerified,
setDeterrence,
setMuted,
setAudioGainBoost,
setUserPermission,
getUserPermissions,
hasUserPermission,
listUsersWithPermission,
listRegisteredPermissions,
isVerified,
isDeterred,
hasAudioGainBoost,
listVerifiedUsers,
listDeterredUsers,
listMutedUsers,
listAudioGainBoostUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
createJsonStore,
@@ -0,0 +1,30 @@
// Identity Permission Registry
// Purpose: Defines every positive capability that can be granted to a canonical user.
// Scope: Keeps stable database keys and operator-facing descriptions centralized so services and admin tools cannot invent mismatched permission names.
const USER_PERMISSIONS = Object.freeze({
'audio.personalAdjustment': Object.freeze({
key: 'audio.personalAdjustment',
commandName: 'audio-adjustment',
label: 'Personal audio adjustment',
description: 'Allows personal horn, text-to-speech, and microphone volume adjustments.',
}),
});
function listRegisteredPermissions() {
return Object.values(USER_PERMISSIONS).map((permission) => ({ ...permission }));
}
function requireRegisteredPermission(permissionKey) {
const key = String(permissionKey || '').trim().toLowerCase();
const permission = Object.values(USER_PERMISSIONS).find((entry) => (
entry.key.toLowerCase() === key || entry.commandName.toLowerCase() === key
));
if (!permission) throw new Error('Unknown user permission.');
return permission;
}
module.exports = {
USER_PERMISSIONS,
listRegisteredPermissions,
requireRegisteredPermission,
};
@@ -0,0 +1,82 @@
// Identity Permission Storage Tests
// Purpose: Verifies normalized grants, registry validation, and the intentionally empty replacement for legacy audio boost flags.
// Scope: Uses an isolated temporary data directory and never opens the development identity database.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rover-identity-permissions-'));
process.env.SERVER_DATA_DIR = testDataDir;
/*
Seed the exact legacy concern this redesign removes. Opening identityService
must preserve the real moderation fields while dropping all old boost grants
instead of translating them into the new permission table.
*/
const legacyDb = new Database(path.join(testDataDir, 'identity.sqlite'));
legacyDb.exec(`
create table users (
id text primary key,
created_at integer not null,
updated_at integer not null,
last_seen_at integer
);
create table user_status (
user_id text primary key references users(id) on delete cascade,
verified_enabled integer not null default 0,
verified_at integer,
verified_by text,
deterrence_enabled integer not null default 0,
deterrence_reason text,
deterrence_at integer,
deterrence_by text,
muted_enabled integer not null default 0,
muted_at integer,
muted_by text,
audio_gain_boost_enabled integer not null default 0,
audio_gain_boost_at integer,
audio_gain_boost_by text
);
`);
legacyDb.close();
const identityService = require('./index');
test.after(() => {
identityService.getDb().close();
fs.rmSync(testDataDir, { recursive: true, force: true });
});
test('legacy audio boost columns are removed and normalized permissions are created empty', () => {
const db = identityService.getDb();
const statusColumns = db.prepare('pragma table_info(user_status)').all().map((column) => column.name);
assert.doesNotMatch(statusColumns.join(','), /audio_gain_boost/);
assert.ok(db.prepare("select 1 from sqlite_master where type = 'table' and name = 'user_permissions'").get());
});
test('registered permissions can be granted, listed, queried, and revoked', () => {
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_11111111111111111111111111111111' });
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
identityService.setUserPermission(userId, 'audio-adjustment', { enabled: true, actor: 'test-admin', at: 1234 });
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), true);
assert.deepEqual(identityService.getUserPermissions(userId), [{
key: 'audio.personalAdjustment',
grantedAt: 1234,
grantedBy: 'test-admin',
}]);
assert.equal(identityService.listUsersWithPermission('audio-adjustment')[0].id, userId);
identityService.setUserPermission(userId, 'audio.personalAdjustment', { enabled: false });
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
});
test('unknown permission keys cannot be persisted', () => {
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_22222222222222222222222222222222' });
assert.throws(
() => identityService.setUserPermission(userId, 'made.up.permission', { enabled: true }),
/Unknown user permission/,
);
});