better banning, new deter mute, replay discord fixes.

This commit is contained in:
legop3
2026-07-21 22:50:12 -04:00
parent fb9565faf9
commit b26daed93f
25 changed files with 628 additions and 46 deletions
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
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CEQiOJg5.js"></script> <script type="module" crossorigin src="/assets/index-BWsjlMbl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DCUrAu3o.css"> <link rel="stylesheet" crossorigin href="/assets/index-L6xBNcuq.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -23,8 +23,34 @@ function registerAudioForwardHooks(deps) {
buildWhipUrl, buildWhipUrl,
videoSessions, videoSessions,
startSilenceWriter, startSilenceWriter,
isMuted,
verificationEvents,
} = deps; } = deps;
verificationEvents.on('change', ({ socketId } = {}) => {
if (!socketId) return;
const socket = io.sockets.sockets.get(socketId);
if (!socket || !isMuted(socket)) return;
/*
Permission checks stop new muted audio, but an upload or microphone can
already be live when moderation changes. Stop only streams owned by this
socket so muting does not disturb another driver's audio or unrelated
server-generated sounds.
*/
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
if (ownerSocketId === socketId) {
stopWhipForRover(roverId, 'owner_muted');
}
}
workers.forEach((worker, roverId) => {
if (worker?.contentKind === 'upload' && worker.activeOwnerSocketId === socketId) {
logger.info('Stopping uploaded audio because its owner was muted', { roverId, socketId });
startSilenceWriter(roverId);
}
});
});
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => { roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return; if (!roverId) return;
if (action === 'removed') { if (action === 'removed') {
@@ -8,7 +8,7 @@ const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const turnService = require('../turnService'); const turnService = require('../turnService');
const { isVerified } = require('../verificationService'); const { isMuted, isVerified, verificationEvents } = require('../verificationService');
const videoSessions = require('../videoSessions'); const videoSessions = require('../videoSessions');
const { createAudioForwardPolicy } = require('./policy'); const { createAudioForwardPolicy } = require('./policy');
const { createAudioForwardWorkerEngine } = require('./workerEngine'); const { createAudioForwardWorkerEngine } = require('./workerEngine');
@@ -62,6 +62,7 @@ function getAudioForwardState() {
const audioForwardPolicy = createAudioForwardPolicy({ const audioForwardPolicy = createAudioForwardPolicy({
isVerified, isVerified,
isMuted,
roverManager, roverManager,
turnService, turnService,
streamSuffix, streamSuffix,
@@ -141,6 +142,8 @@ registerAudioForwardHooks({
buildWhipUrl, buildWhipUrl,
videoSessions, videoSessions,
startSilenceWriter, startSilenceWriter,
isMuted,
verificationEvents,
}); });
registerChargeCompleteSound({ registerChargeCompleteSound({
@@ -4,6 +4,7 @@
function createAudioForwardPolicy(deps) { function createAudioForwardPolicy(deps) {
const { const {
isVerified, isVerified,
isMuted,
roverManager, roverManager,
turnService, turnService,
streamSuffix, streamSuffix,
@@ -18,6 +19,9 @@ function createAudioForwardPolicy(deps) {
function ensureAudioForwardPermission(socket, roverId) { function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket); ensureVipVerified(socket);
if (isMuted(socket)) {
throw new Error('Muted');
}
if (!roverManager.isDriver(roverId, socket)) { if (!roverManager.isDriver(roverId, socket)) {
throw new Error('Audio forwarding is only allowed on your own rover'); throw new Error('Audio forwarding is only allowed on your own rover');
} }
@@ -0,0 +1,27 @@
// Audio Forward Policy Tests
// Purpose: Verifies that mute blocks user-owned forwarding without changing ordinary driver authorization.
// Scope: Exercises the pure permission policy with small injected role, rover, and turn doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createAudioForwardPolicy } = require('./policy');
function createPolicy({ verified = true, muted = false, driver = true, canDrive = true } = {}) {
return createAudioForwardPolicy({
isVerified: () => verified,
isMuted: () => muted,
roverManager: { isDriver: () => driver },
turnService: { canDrive: () => canDrive },
streamSuffix: '-fwd',
mediaConfig: {},
});
}
test('rejects audio forwarding for a muted verified driver', () => {
const policy = createPolicy({ muted: true });
assert.throws(() => policy.ensureAudioForwardPermission({}, 'rover'), /Muted/);
});
test('preserves normal audio forwarding for an unmuted verified driver', () => {
const policy = createPolicy();
assert.doesNotThrow(() => policy.ensureAudioForwardPermission({}, 'rover'));
});
+25 -1
View File
@@ -3,6 +3,7 @@
// Scope: Owns message validation pipeline and typed outbound message construction. // Scope: Owns message validation pipeline and typed outbound message construction.
const logger = require('../../globals/logger').child('chatService'); const logger = require('../../globals/logger').child('chatService');
const { getRole } = require('../roleService'); const { getRole } = require('../roleService');
const { isDeterred, isMuted } = require('../verificationService');
const { withinRateLimit } = require('./state'); const { withinRateLimit } = require('./state');
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters'); const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders'); const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
@@ -17,6 +18,12 @@ function createHandlers({ sendSystemMessage }) {
const normalized = normalizeUserText(text); const normalized = normalizeUserText(text);
const clean = normalized.trim(); const clean = normalized.trim();
if (!clean) return cb({ error: 'Message required' }); if (!clean) return cb({ error: 'Message required' });
/*
Mute is narrower than deterrence: the socket may continue driving and
using ordinary features, but its message must stop before broadcast,
command parsing, TTS, or any other chat-derived side effect occurs.
*/
if (isMuted(socket)) return cb({ error: 'Muted' });
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' }); if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
// This service no longer enforces a character-count ceiling for chat text. // This service no longer enforces a character-count ceiling for chat text.
// The chat layer only rejects empty, rate-limited, or moderated content so // The chat layer only rejects empty, rate-limited, or moderated content so
@@ -37,23 +44,40 @@ function createHandlers({ sendSystemMessage }) {
}); });
logger.info('Chat message', { socket: socket.id, roverId: message.roverId }); logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
const deterred = isDeterred(socket);
/*
Deterred users retain text chat, but chat must not become an indirect
hardware-control path. Suppress the rover typing note and TTS while still
constructing and broadcasting the same visible message as everyone else.
*/
if (!deterred) {
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id); playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
}
if (isPrivateClosedRoverId(message.roverId)) { if (isPrivateClosedRoverId(message.roverId)) {
// Private-closed chat does not broadcast the text, so TTS is the only // Private-closed chat does not broadcast the text, so TTS is the only
// delivery path. Use the same Google speech default as normal chat when // delivery path. Use the same Google speech default as normal chat when
// the sender did not provide explicit TTS settings. // the sender did not provide explicit TTS settings.
const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' }; const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' };
if (!deterred) {
maybeSpeak(socket, message, forcedTts); maybeSpeak(socket, message, forcedTts);
}
cb({ success: true, privateOnly: true }); cb({ success: true, privateOnly: true });
return; return;
} }
broadcastMessage(message); broadcastMessage(message);
maybeSendAccessNotice(message, sendSystemMessage); maybeSendAccessNotice(message, sendSystemMessage);
if (!deterred) {
maybeSpeak(socket, message, ttsOptions); maybeSpeak(socket, message, ttsOptions);
}
const command = isTextCommand(clean); /*
Command-shaped text from a deterred user remains ordinary visible chat.
Reporting command=false prevents the client from implying that the server
accepted an action, and the command router is never invoked.
*/
const command = !deterred && isTextCommand(clean);
// Chat delivery is complete once validation, broadcast, and local side // Chat delivery is complete once validation, broadcast, and local side
// effects above have succeeded. A command may wait on Home Assistant, // effects above have succeeded. A command may wait on Home Assistant,
// hardware, replay preparation, or an external transport, so tying the // hardware, replay preparation, or an external transport, so tying the
+20 -1
View File
@@ -3,6 +3,7 @@
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients. // Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
const io = require('../../globals/io'); const io = require('../../globals/io');
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const { isDeterred, isMuted } = require('../verificationService');
const { typingBySocket } = require('./state'); const { typingBySocket } = require('./state');
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders'); const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
const { broadcastTyping } = require('./broadcast'); const { broadcastTyping } = require('./broadcast');
@@ -13,11 +14,29 @@ function registerChatSocketHooks({ history, handleIncoming }) {
socket.emit('chat:init', history); socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb)); socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => { socket.on('chat:typing', (payload = {}) => {
/*
A muted typing packet must not leak presence or produce rover notes.
Clearing any prior state also removes a typing indicator that began
immediately before an administrator applied the mute.
*/
if (isMuted(socket)) {
const wasTyping = typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id);
if (wasTyping && !isPrivateClosedRoverId(roverId)) {
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }));
}
return;
}
const isTyping = Boolean(payload?.isTyping); const isTyping = Boolean(payload?.isTyping);
const wasTyping = typingBySocket.get(socket.id); const wasTyping = typingBySocket.get(socket.id);
if (isTyping) { if (isTyping) {
typingBySocket.set(socket.id, true); typingBySocket.set(socket.id, true);
if (!wasTyping) { /*
The typing indicator is part of chat and remains available to a
deterred user. The rover note is a physical side effect, however, so
text-only deterrence suppresses that note without changing presence.
*/
if (!wasTyping && !isDeterred(socket)) {
const roverId = resolveRoverId(socket?.id); const roverId = resolveRoverId(socket?.id);
playTypingNote(roverId, TYPING_START_NOTE, socket?.id); playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
} }
@@ -17,8 +17,11 @@ const {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser,
unmuteUser,
} = require('../verificationService'); } = require('../verificationService');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService'); const assignmentService = require('../assignmentService');
@@ -176,8 +179,11 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser,
unmuteUser,
sanitizeMentions, sanitizeMentions,
sendToChannel: null, sendToChannel: null,
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket), isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
+9 -1
View File
@@ -6,7 +6,7 @@ const EventEmitter = require('events');
const io = require('../../globals/io'); const io = require('../../globals/io');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { isAdmin, isLockdownAdmin } = require('../roleService'); const { isAdmin, isLockdownAdmin } = require('../roleService');
const { isDeterred } = require('../verificationService'); const { isDeterred, isMuted } = require('../verificationService');
const logger = require('../../globals/logger').child('commandService'); const logger = require('../../globals/logger').child('commandService');
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness'); const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
@@ -292,6 +292,14 @@ io.on('connection', (socket) => {
if (!isAdminSocket && isDeterred(socket)) { if (!isAdminSocket && isDeterred(socket)) {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
/*
Both structured song commands and raw Open Interface song payloads
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
and any future browser beeper without affecting unrelated driving.
*/
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
throw new Error('Muted');
}
// Rover updates run a privileged, root-owned helper on the Pi. Keep this // Rover updates run a privileged, root-owned helper on the Pi. Keep this
// in the same explicit admin-only branch as reboot instead of relying on // in the same explicit admin-only branch as reboot instead of relying on
// drive ownership checks, because having a turn should not grant system // drive ownership checks, because having a turn should not grant system
@@ -41,8 +41,11 @@ const {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser,
unmuteUser,
} = require('../verificationService'); } = require('../verificationService');
const { const {
attachDmMessage: attachPrivateAccessDmMessage, attachDmMessage: attachPrivateAccessDmMessage,
@@ -242,8 +245,11 @@ const commandDependencies = {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser,
unmuteUser,
sanitizeMentions, sanitizeMentions,
sendToChannel: channelIO.sendToChannel, sendToChannel: channelIO.sendToChannel,
isAdminUser, isAdminUser,
@@ -11,6 +11,7 @@ const {
removeUserSignal, removeUserSignal,
setVerified, setVerified,
setDeterrence, setDeterrence,
setMuted,
setFeatureState, setFeatureState,
deleteFeatureState, deleteFeatureState,
} = require('../identityService'); } = require('../identityService');
@@ -103,6 +104,14 @@ io.on('connection', (socket) => {
}).id), }).id),
})); }));
ackHandler(socket, 'identityAdmin:setMuted', ({ userId, enabled }) => ({
user: getUserForAdmin(setMuted(userId, {
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);
+66 -3
View File
@@ -17,7 +17,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 = 1; const STORE_VERSION = 2;
const identityEvents = new EventEmitter(); const identityEvents = new EventEmitter();
let db = null; let db = null;
@@ -166,7 +166,10 @@ function ensureSchema(conn) {
deterrence_enabled integer not null default 0, deterrence_enabled integer not null default 0,
deterrence_reason text, deterrence_reason text,
deterrence_at integer, deterrence_at integer,
deterrence_by text deterrence_by text,
muted_enabled integer not null default 0,
muted_at integer,
muted_by text
); );
create table if not exists verification_requests ( create table if not exists verification_requests (
@@ -213,6 +216,24 @@ function ensureSchema(conn) {
pragma user_version = ${STORE_VERSION}; pragma user_version = ${STORE_VERSION};
`); `);
/*
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.
*/
const statusColumns = new Set(
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
);
if (!statusColumns.has('muted_enabled')) {
conn.exec('alter table user_status add column muted_enabled integer not null default 0');
}
if (!statusColumns.has('muted_at')) {
conn.exec('alter table user_status add column muted_at integer');
}
if (!statusColumns.has('muted_by')) {
conn.exec('alter table user_status add column muted_by text');
}
} }
function createUser(conn = getDb(), ts = nowMs()) { function createUser(conn = getDb(), ts = nowMs()) {
@@ -279,6 +300,20 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
where user_id = ? where user_id = ?
`).run(sourceStatus.deterrence_reason || null, sourceStatus.deterrence_at || ts, sourceStatus.deterrence_by || null, targetUserId); `).run(sourceStatus.deterrence_reason || null, sourceStatus.deterrence_at || ts, sourceStatus.deterrence_by || null, targetUserId);
} }
if (sourceStatus?.muted_enabled) {
/*
Identity merging must preserve the stricter moderation state. Otherwise
joining two signals could silently clear a mute merely because the
unmuted record happened to become the merge target.
*/
conn.prepare(`
update user_status
set muted_enabled = 1,
muted_at = coalesce(muted_at, ?),
muted_by = coalesce(muted_by, ?)
where user_id = ?
`).run(sourceStatus.muted_at || ts, sourceStatus.muted_by || null, targetUserId);
}
const sourceFeatures = conn.prepare('select namespace, data_json, created_at, updated_at from user_feature_state where user_id = ?').all(sourceUserId); const sourceFeatures = conn.prepare('select namespace, data_json, created_at, updated_at from user_feature_state where user_id = ?').all(sourceUserId);
sourceFeatures.forEach((feature) => { sourceFeatures.forEach((feature) => {
@@ -382,6 +417,7 @@ function setSocketIdentityState(socket, user, identity = {}) {
socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null; socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null;
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);
} }
function identifySocket(socket, payload = {}) { function identifySocket(socket, payload = {}) {
@@ -422,6 +458,7 @@ function identifySocket(socket, payload = {}) {
fingerprintId: fingerprintId || null, fingerprintId: fingerprintId || null,
isVerified: Boolean(user.verified?.enabled), isVerified: Boolean(user.verified?.enabled),
isDeterred: Boolean(user.deterrence?.enabled), isDeterred: Boolean(user.deterrence?.enabled),
isMuted: Boolean(user.deterrence?.muted),
}; };
} }
@@ -468,6 +505,9 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
reason: status.deterrence_reason || null, reason: status.deterrence_reason || null,
at: status.deterrence_at || null, at: status.deterrence_at || null,
by: status.deterrence_by || null, by: status.deterrence_by || null,
muted: Boolean(status.muted_enabled),
mutedAt: status.muted_at || null,
mutedBy: status.muted_by || null,
}, },
features, features,
}; };
@@ -673,6 +713,19 @@ function setDeterrence(userId, { enabled = true, reason = null, actor = null, at
return getUserById(id); return getUserById(id);
} }
function setMuted(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 muted_enabled = ?, muted_at = ?, muted_by = ?
where user_id = ?
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
identityEvents.emit('change', { reason: enabled ? 'muted' : 'unmuted', userId: id });
return getUserById(id);
}
function isVerified(socket) { function isVerified(socket) {
return Boolean(socket?.data?.isVerified); return Boolean(socket?.data?.isVerified);
} }
@@ -681,12 +734,13 @@ function isDeterred(socket) {
return Boolean(socket?.data?.isDeterred); return Boolean(socket?.data?.isDeterred);
} }
function listUsers({ verified = null, deterred = 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 (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 }));
@@ -705,6 +759,9 @@ function userToLegacyIdentityEntry(user) {
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
approvedBy: user.verified?.by || null, approvedBy: user.verified?.by || null,
reason: user.deterrence?.reason || null, reason: user.deterrence?.reason || null,
muted: Boolean(user.deterrence?.muted),
mutedAt: user.deterrence?.mutedAt || null,
mutedBy: user.deterrence?.mutedBy || null,
}; };
} }
@@ -716,6 +773,10 @@ function listDeterredUsers() {
return listUsers({ deterred: true }).map(userToLegacyIdentityEntry); return listUsers({ deterred: true }).map(userToLegacyIdentityEntry);
} }
function listMutedUsers() {
return listUsers({ muted: 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' };
@@ -969,10 +1030,12 @@ module.exports = {
listFeatureStates, listFeatureStates,
setVerified, setVerified,
setDeterrence, setDeterrence,
setMuted,
isVerified, isVerified,
isDeterred, isDeterred,
listVerifiedUsers, listVerifiedUsers,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
resolveUserBySelector, resolveUserBySelector,
userToLegacyIdentityEntry, userToLegacyIdentityEntry,
createJsonStore, createJsonStore,
@@ -1,10 +1,45 @@
// Operator Deter Command // Operator Deter Command
// Purpose: Handles deterrence moderation commands for lockdown admins. // Purpose: Handles deterrence moderation commands for lockdown admins.
// Scope: Supports list, ban, and unban subcommands. // Scope: Supports list, ban, and unban subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers'); const { mask, normalizeSearchText, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config'); const { getCommandConfig } = require('../../operatorCommandService/config');
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, sanitizeMentions, config }) { function findOnlineNicknameMatches(io, getNickname, selector) {
const normalizedSelector = normalizeSearchText(selector);
if (!normalizedSelector) return [];
const matchesByUserId = new Map();
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return [];
sockets.forEach((socket) => {
const nickname = getNickname(socket);
const userId = String(socket?.data?.userId || '').trim();
if (!userId || normalizeSearchText(nickname) !== normalizedSelector) return;
/*
One person may have multiple connected tabs or surfaces. Collapse those
sockets to the canonical user id so duplicate tabs do not manufacture an
ambiguous moderation target when they all represent the same identity.
*/
if (!matchesByUserId.has(userId)) {
matchesByUserId.set(userId, { userId, nickname });
}
});
return Array.from(matchesByUserId.values());
}
function uniqueIdentityRecords(records = []) {
const byIdentity = new Map();
records.forEach((record) => {
const key = record?.userId || record?.id || record?.cookieUserId || record?.fingerprintId;
if (key && !byIdentity.has(key)) byIdentity.set(key, record);
});
return Array.from(byIdentity.values());
}
function createDeterCommand({ io, getNickname, listDeterredUsers, listMutedUsers, listVerifiedUsers, deterUser, undeterUser, muteUser, unmuteUser, sanitizeMentions, config }) {
// Moderation usage errors use the same core prefix shown by organized help. // Moderation usage errors use the same core prefix shown by organized help.
const { prefix: commandPrefix } = getCommandConfig(config); const { prefix: commandPrefix } = getCommandConfig(config);
@@ -15,23 +50,58 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
} }
const action = (tokens.shift() || 'list').toLowerCase(); const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'list') { if (action === 'list') {
const users = listDeterredUsers(); const deterredUsers = listDeterredUsers().map((entry) => ({ ...entry, deterred: true }));
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } }); const mutedUsers = listMutedUsers().map((entry) => ({ ...entry, muted: true }));
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`); const usersById = new Map();
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } }); [...deterredUsers, ...mutedUsers].forEach((entry) => {
const userId = entry.userId || entry.id;
if (!userId) return;
const existing = usersById.get(userId) || {};
usersById.set(userId, {
...existing,
...entry,
deterred: Boolean(existing.deterred || entry.deterred),
muted: Boolean(existing.muted || entry.muted),
});
});
const users = Array.from(usersById.values());
if (!users.length) return message.reply({ content: 'No deterred or muted users.', allowedMentions: { parse: [], repliedUser: false } });
const lines = users.map((entry, idx) => {
const flags = [entry.deterred ? 'deterred' : '', entry.muted ? 'muted' : ''].filter(Boolean).join(', ');
return `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${flags} | ${mask(entry.cookieUserId)}`;
});
return message.reply({ content: ['Moderated users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
} }
if (action === 'ban') { if (action === 'ban') {
const selector = tokens.join(' ').trim(); const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } }); if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
try { try {
const onlineMatches = findOnlineNicknameMatches(io, getNickname, selector);
if (onlineMatches.length > 1) {
/*
Identical live nicknames are genuinely ambiguous, so do not guess
for a destructive command. Unlike the old generic error, this
response exposes stable selectors that the administrator can copy
directly into a follow-up command.
*/
const choices = onlineMatches.map((match) => `${match.nickname} (${match.userId})`).join(', ');
return message.reply({
content: sanitizeMentions(`More than one online user is named ${selector}: ${choices}. Retry with \`${commandPrefix} deter ban <userId>\`.`),
allowedMentions: { parse: [], repliedUser: false },
});
}
let stableSelector = onlineMatches[0]?.userId || null;
if (!stableSelector) {
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false }); const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) { if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
} }
stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
}
// Ban reasons were deliberately removed from the command grammar. The // Ban reasons were deliberately removed from the command grammar. The
// full remaining text is now always the selector, which lets lockdown // full remaining text is now always the selector, which lets lockdown
// admins deter multi-word nicknames without quoting or delimiter rules. // admins deter multi-word nicknames without quoting or delimiter rules.
const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
const deterred = deterUser(stableSelector, { actor: message.actor?.id || null }); const deterred = deterUser(stableSelector, { actor: message.actor?.id || null });
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) { } catch (err) {
@@ -50,8 +120,47 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
} }
} }
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, or \`${commandPrefix} deter unban <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } }); if (action === 'mute' || action === 'unmute') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ${action} <userId|cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
try {
const onlineMatches = findOnlineNicknameMatches(io, getNickname, selector);
if (onlineMatches.length > 1) {
const choices = onlineMatches.map((match) => `${match.nickname} (${match.userId})`).join(', ');
return message.reply({
content: sanitizeMentions(`More than one online user is named ${selector}: ${choices}. Retry with \`${commandPrefix} deter ${action} <userId>\`.`),
allowedMentions: { parse: [], repliedUser: false },
});
}
let stableSelector = onlineMatches[0]?.userId || null;
if (!stableSelector) {
const storedCandidates = uniqueIdentityRecords([...listVerifiedUsers(), ...listMutedUsers()]);
const storedMatch = resolveIdentitySelector(selector, storedCandidates, { includeId: true });
if (storedMatch.error && !/not found/i.test(storedMatch.error)) {
return message.reply({ content: sanitizeMentions(storedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
}
/*
Unverified users may not appear in the convenience candidate list.
Passing the original exact selector through lets verificationService
resolve any canonical identity without making fuzzy guesses here.
*/
stableSelector = storedMatch.record?.userId || storedMatch.record?.id || storedMatch.record?.cookieUserId || selector;
}
const updated = action === 'mute'
? muteUser(stableSelector, message.actor?.id || null)
: unmuteUser(stableSelector, message.actor?.id || null);
return message.reply({
content: sanitizeMentions(`${action === 'mute' ? 'Muted' : 'Unmuted'} ${updated.nickname || 'unknown'} (${mask(updated.cookieUserId)}).`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to ${action} user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
}
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, \`${commandPrefix} deter unban <selector>\`, \`${commandPrefix} deter mute <selector>\`, or \`${commandPrefix} deter unmute <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } });
}; };
} }
module.exports = { createDeterCommand }; module.exports = { createDeterCommand, findOnlineNicknameMatches, uniqueIdentityRecords };
@@ -0,0 +1,113 @@
// Operator Deter Command Tests
// Purpose: Verifies that live nickname identity takes precedence over ambiguous stored aliases.
// Scope: Exercises only command target resolution with in-memory socket and identity doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createDeterCommand } = require('./deter');
function createSocket(id, userId, nickname) {
return { id, data: { userId, nickname } };
}
function createHarness(sockets = [], verifiedUsers = []) {
const deterCalls = [];
const muteCalls = [];
const replies = [];
const handler = createDeterCommand({
io: { sockets: { sockets: new Map(sockets.map((socket) => [socket.id, socket])) } },
getNickname: (socket) => socket?.data?.nickname || '',
listDeterredUsers: () => [],
listMutedUsers: () => [],
listVerifiedUsers: () => verifiedUsers,
deterUser: (selector) => {
deterCalls.push(selector);
return { created: true, nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
undeterUser: () => null,
muteUser: (selector) => {
muteCalls.push({ action: 'mute', selector });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
unmuteUser: (selector) => {
muteCalls.push({ action: 'unmute', selector });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
sanitizeMentions: (value) => value,
config: { commands: { prefix: 'rs' } },
});
const message = {
actor: { id: 'admin', isLockdownAdmin: true },
reply: async (payload) => {
replies.push(payload);
return payload;
},
};
return { handler, message, deterCalls, muteCalls, replies };
}
test('prefers the one online exact nickname over ambiguous stored records', async () => {
const verifiedUsers = [
{ userId: 'old-user', nickname: 'Croissant', cookieUserId: 'old-cookie' },
{ userId: 'live-user', nickname: 'Croissant', cookieUserId: 'live-cookie' },
];
const { handler, message, deterCalls } = createHarness([
createSocket('socket-1', 'live-user', 'Croissant'),
], verifiedUsers);
await handler(message, ['ban', 'croissant']);
assert.deepEqual(deterCalls, ['live-user']);
});
test('collapses multiple sockets belonging to the same online identity', async () => {
const { handler, message, deterCalls } = createHarness([
createSocket('socket-1', 'live-user', 'Croissant'),
createSocket('socket-2', 'live-user', 'croissant'),
]);
await handler(message, ['ban', 'Croissant']);
assert.deepEqual(deterCalls, ['live-user']);
});
test('returns usable user ids when different online identities share a nickname', async () => {
const { handler, message, deterCalls, replies } = createHarness([
createSocket('socket-1', 'user-one', 'Croissant'),
createSocket('socket-2', 'user-two', 'croissant'),
]);
await handler(message, ['ban', 'croissant']);
assert.deepEqual(deterCalls, []);
assert.match(replies[0].content, /user-one/);
assert.match(replies[0].content, /user-two/);
assert.match(replies[0].content, /rs deter ban <userId>/);
});
test('mute uses the same exact online nickname preference as ban', async () => {
const verifiedUsers = [
{ userId: 'old-user', nickname: 'Croissant', cookieUserId: 'old-cookie' },
{ userId: 'live-user', nickname: 'Croissant', cookieUserId: 'live-cookie' },
];
const { handler, message, muteCalls } = createHarness([
createSocket('socket-1', 'live-user', 'Croissant'),
], verifiedUsers);
await handler(message, ['mute', 'croissant']);
assert.deepEqual(muteCalls, [{ action: 'mute', selector: 'live-user' }]);
});
test('unmute returns usable ids for genuinely duplicated online nicknames', async () => {
const { handler, message, muteCalls, replies } = createHarness([
createSocket('socket-1', 'user-one', 'Croissant'),
createSocket('socket-2', 'user-two', 'croissant'),
]);
await handler(message, ['unmute', 'Croissant']);
assert.deepEqual(muteCalls, []);
assert.match(replies[0].content, /user-one/);
assert.match(replies[0].content, /user-two/);
assert.match(replies[0].content, /rs deter unmute <userId>/);
});
@@ -33,7 +33,19 @@ function buildCommandRegistry(prefix, timeCommand) {
}, },
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' }, kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' }, verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' }, deter: {
category: 'admin',
summary: 'Manage identity deterrence and mute status.',
usage: [
`${prefix} deter list`,
`${prefix} deter ban <identity>`,
`${prefix} deter unban <identity>`,
`${prefix} deter mute <identity>`,
`${prefix} deter unmute <identity>`,
],
access: 'Lockdown admin',
permission: 'lockdown-admin',
},
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' }, lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' }, neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' }, bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
@@ -95,7 +95,11 @@ async function hostReplay({ buffer, job }) {
title: job.title, title: job.title,
requester: job.requester, requester: job.requester,
requestedBy: job.requestedBy || null, requestedBy: job.requestedBy || null,
url: `/media/replays/${filename}`, // The readable filename intentionally contains spaces, but the media URL is
// also pasted into Discord messages where an unescaped space terminates the
// detected link. Encode only the path segment; Express decodes the route
// parameter back to the exact on-disk filename before the file is served.
url: `/media/replays/${encodeURIComponent(filename)}`,
proxyUrl: null, proxyUrl: null,
messageUrl: null, messageUrl: null,
filename, filename,
@@ -26,8 +26,10 @@ const {
sanitizeNickname, sanitizeNickname,
setVerified, setVerified,
setDeterrence, setDeterrence,
setMuted,
listVerifiedUsers, listVerifiedUsers,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
resolveUserBySelector, resolveUserBySelector,
userToLegacyIdentityEntry, userToLegacyIdentityEntry,
} = require('../identityService'); } = require('../identityService');
@@ -37,6 +39,26 @@ const verificationEvents = new EventEmitter();
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000; const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
const IDENTITY_SWEEP_INTERVAL_MS = 15 * 1000; const IDENTITY_SWEEP_INTERVAL_MS = 15 * 1000;
const DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS = 250; const DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS = 250;
const DETERRED_DISCONNECT_DELAY_MS = 250;
/*
Deterrence is intentionally enforced at the socket boundary instead of being
repeated in every feature service. A deterred browser still needs the small
identity/authentication surface that lets it reconnect, retain a chat name,
and let a real administrator log in. Everything else is limited to ordinary
text chat and its non-mutating typing indicator.
Keeping this list exact is important: newly added socket capabilities are
denied by default, so a future feature cannot accidentally become an escape
hatch merely because its service forgot a deterrence check.
*/
const DETERRED_ALLOWED_SOCKET_EVENTS = new Set([
'auth:login',
'session:identify',
'nickname:set',
'chat:send',
'chat:typing',
]);
function emitChange(reason, payload = {}) { function emitChange(reason, payload = {}) {
verificationEvents.emit('change', { reason, ...payload }); verificationEvents.emit('change', { reason, ...payload });
@@ -46,6 +68,31 @@ function isAdminRole(role) {
return role === 'admin' || role === 'lockdown'; return role === 'admin' || role === 'lockdown';
} }
function installDeterredSocketGuard(socket) {
socket.use(([eventName, ...eventArgs], next) => {
if (!socket?.data?.isDeterred || DETERRED_ALLOWED_SOCKET_EVENTS.has(eventName)) {
next();
return;
}
/*
Socket.IO does not automatically acknowledge a packet that middleware
declines. Reply through the packet's acknowledgement callback when one
exists so browser promises settle normally instead of hanging forever.
We deliberately do not call next() after the reply because doing so would
deliver the denied packet to its feature handler.
*/
const acknowledgement = eventArgs[eventArgs.length - 1];
if (typeof acknowledgement === 'function') {
acknowledgement({ error: 'Not authorized' });
}
logger.info('Blocked socket event from deterred user', {
socketId: socket.id,
eventName,
});
});
}
function refreshSocketIdentityFlags(socket) { function refreshSocketIdentityFlags(socket) {
const user = getUserForSocket(socket); const user = getUserForSocket(socket);
if (!socket?.data || !user) { if (!socket?.data || !user) {
@@ -55,13 +102,21 @@ function refreshSocketIdentityFlags(socket) {
const role = getRole(socket); const role = getRole(socket);
const verifiedByRole = isAdminRole(role); const verifiedByRole = isAdminRole(role);
const deterredByUser = Boolean(user.deterrence?.enabled); const deterredByUser = Boolean(user.deterrence?.enabled);
const mutedByUser = Boolean(user.deterrence?.muted);
socket.data.isVerified = verifiedByRole || Boolean(user.verified?.enabled); socket.data.isVerified = verifiedByRole || Boolean(user.verified?.enabled);
socket.data.verifiedRecordId = socket.data.isVerified ? user.id : null; socket.data.verifiedRecordId = socket.data.isVerified ? user.id : null;
socket.data.isDeterred = isAdminRole(role) ? false : deterredByUser; socket.data.isDeterred = isAdminRole(role) ? false : deterredByUser;
socket.data.deterredRecordId = socket.data.isDeterred ? user.id : null; socket.data.deterredRecordId = socket.data.isDeterred ? user.id : null;
/*
Mute follows the same administrator exemption as full deterrence. This
prevents a stored moderation flag from disabling an authenticated admin's
operational chat/audio tools while preserving the flag for normal roles.
*/
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
return { return {
isVerified: socket.data.isVerified, isVerified: socket.data.isVerified,
isDeterred: socket.data.isDeterred, isDeterred: socket.data.isDeterred,
isMuted: socket.data.isMuted,
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,
@@ -212,6 +267,7 @@ function getVerificationStateForSocket(socket) {
function getModerationStateForSocket(socket) { function getModerationStateForSocket(socket) {
return { return {
isDeterred: Boolean(socket?.data?.isDeterred), isDeterred: Boolean(socket?.data?.isDeterred),
isMuted: Boolean(socket?.data?.isMuted),
recordId: socket?.data?.deterredRecordId || null, recordId: socket?.data?.deterredRecordId || null,
}; };
} }
@@ -482,6 +538,41 @@ function undeterUser(selector, removedBy = null) {
return userToLegacyIdentityEntry(user); return userToLegacyIdentityEntry(user);
} }
function setUserMute(selector, enabled, actor = null) {
const resolved = resolveUserBySelector(selector, { includeVerified: true, includeDeterred: true });
if (resolved.error || !resolved.user) {
throw new Error(resolved.error === 'ambiguous_nickname' ? 'Nickname matches multiple users.' : 'User not found.');
}
const user = setMuted(resolved.user.id, {
enabled,
actor: actor ? String(actor) : null,
at: Date.now(),
});
refreshSocketsForUser(user.id);
publishEvent({
source: 'moderation',
type: enabled ? 'moderation.muted' : 'moderation.unmuted',
payload: {
userId: user.id,
cookieUserId: user.cookieUserIds[0] || null,
nickname: user.nickname,
actor: actor ? String(actor) : null,
ts: Date.now(),
},
});
emitChange(enabled ? 'mute_update' : 'mute_remove', { userId: user.id });
return userToLegacyIdentityEntry(user);
}
function muteUser(selector, actor = null) {
return setUserMute(selector, true, actor);
}
function unmuteUser(selector, actor = null) {
return setUserMute(selector, false, actor);
}
function reevaluateSocketVerification(socket) { function reevaluateSocketVerification(socket) {
return refreshSocketIdentityFlags(socket); return refreshSocketIdentityFlags(socket);
} }
@@ -500,6 +591,7 @@ function getVerificationStatus(socket) {
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.data = socket.data || {}; socket.data = socket.data || {};
socket.data.connectedAt = Date.now(); socket.data.connectedAt = Date.now();
installDeterredSocketGuard(socket);
identifySocket(socket, {}); identifySocket(socket, {});
socket.on('session:identify', (payload = {}, cb = () => {}) => { socket.on('session:identify', (payload = {}, cb = () => {}) => {
@@ -535,6 +627,32 @@ roleEvents.on('change', ({ socket }) => {
identityEvents.on('change', ({ userId, reason } = {}) => { identityEvents.on('change', ({ userId, reason } = {}) => {
if (!userId) return; if (!userId) return;
refreshSocketsForUser(userId); refreshSocketsForUser(userId);
if (reason === 'deterred') {
io.sockets.sockets.forEach((socket) => {
if (getUserIdForSocket(socket) !== userId || !socket.data?.isDeterred) return;
/*
A user can be deterred while already driving or consuming media. One
normal disconnect lets the established rover, PTZ, video, and snapshot
services perform their own cleanup without coupling moderation to each
subsystem. The browser may reconnect immediately; identification then
restores the deterred flag and the guard above leaves chat available.
*/
setTimeout(() => {
if (!socket.disconnected && socket.data?.isDeterred) {
/*
Close the underlying transport rather than issuing Socket.IO's
explicit server-disconnect packet. A server-disconnect disables
automatic reconnection in the browser, while a transport close
runs the same disconnect cleanup and then lets the normal client
reconnect path restore its chat-only session.
*/
socket.conn.close();
}
}, DETERRED_DISCONNECT_DELAY_MS);
});
}
emitChange('identity_change', { userId, reason }); emitChange('identity_change', { userId, reason });
}); });
@@ -573,10 +691,14 @@ module.exports = {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
listDeterredUsers, listDeterredUsers,
listMutedUsers,
deterUser, deterUser,
undeterUser, undeterUser,
muteUser,
unmuteUser,
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),
reevaluateSocketVerification, reevaluateSocketVerification,
reevaluateSocketDeterrence, reevaluateSocketDeterrence,
verificationEvents, verificationEvents,
+24 -2
View File
@@ -12,6 +12,7 @@ import {
listUsers, listUsers,
removeSignal, removeSignal,
setDeterrence, setDeterrence,
setMuted,
setVerified, setVerified,
updateFeatureState, updateFeatureState,
} from './identityDatabaseApi.js'; } from './identityDatabaseApi.js';
@@ -30,6 +31,7 @@ const FILTERS = [
{ key: 'all', label: 'All' }, { key: 'all', label: 'All' },
{ key: 'verified', label: 'Verified' }, { key: 'verified', label: 'Verified' },
{ key: 'deterred', label: 'Deterred' }, { key: 'deterred', label: 'Deterred' },
{ key: 'muted', label: 'Muted' },
{ key: 'unverified', label: 'Unverified' }, { key: 'unverified', label: 'Unverified' },
]; ];
@@ -85,6 +87,7 @@ function UserListCard({ users, selectedUserId, query, filter, loading, onQuery,
<span className="flex flex-col items-end gap-0.25"> <span className="flex flex-col items-end gap-0.25">
<StatusPill active={user.verified?.enabled}>verified</StatusPill> <StatusPill active={user.verified?.enabled}>verified</StatusPill>
<StatusPill active={user.deterrence?.enabled}>deterred</StatusPill> <StatusPill active={user.deterrence?.enabled}>deterred</StatusPill>
<StatusPill active={user.deterrence?.muted}>muted</StatusPill>
</span> </span>
</button> </button>
)) : ( )) : (
@@ -164,7 +167,7 @@ function SignalsCard({ user, onAddSignal, onRemoveSignal }) {
); );
} }
function StatusCard({ user, onVerified, onDeterrence }) { function StatusCard({ user, onVerified, onDeterrence, onMuted }) {
const [reason, setReason] = useState(user?.deterrence?.reason || ''); const [reason, setReason] = useState(user?.deterrence?.reason || '');
useEffect(() => { useEffect(() => {
@@ -206,6 +209,23 @@ function StatusCard({ user, onVerified, onDeterrence }) {
<button type="button" className="button-dark text-xs" onClick={() => onDeterrence(Boolean(user?.deterrence?.enabled), reason)}> <button type="button" className="button-dark text-xs" onClick={() => onDeterrence(Boolean(user?.deterrence?.enabled), reason)}>
Save Reason Save Reason
</button> </button>
<div className="border-t border-slate-700/70 pt-0.5">
<label className="flex items-center gap-0.5 text-slate-100">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-amber-500"
checked={Boolean(user?.deterrence?.muted)}
onChange={(event) => onMuted(event.target.checked)}
/>
<span>Muted</span>
</label>
{/*
Mute is intentionally presented inside deterrence status because
it is a narrower moderation action, while its own timestamp makes
it clear that toggling mute does not toggle full deterrence.
*/}
<p className="text-xs text-slate-500">Updated {formatDateTime(user?.deterrence?.mutedAt)}</p>
</div>
</div> </div>
</CardFrame> </CardFrame>
); );
@@ -358,6 +378,8 @@ export default function IdentityDatabasePanel() {
runMutation((userId) => setVerified(socket, userId, enabled), 'Verification updated.'); runMutation((userId) => setVerified(socket, userId, enabled), 'Verification updated.');
const handleDeterrence = (enabled, reason) => const handleDeterrence = (enabled, reason) =>
runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.'); runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.');
const handleMuted = (enabled) =>
runMutation((userId) => setMuted(socket, userId, enabled), 'Mute 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) =>
@@ -393,7 +415,7 @@ export default function IdentityDatabasePanel() {
<SignalsCard user={selectedUser} onAddSignal={handleAddSignal} onRemoveSignal={handleRemoveSignal} /> <SignalsCard user={selectedUser} onAddSignal={handleAddSignal} onRemoveSignal={handleRemoveSignal} />
</TabPanel> </TabPanel>
<TabPanel id="status"> <TabPanel id="status">
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} /> <StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} onMuted={handleMuted} />
</TabPanel> </TabPanel>
<TabPanel id="features"> <TabPanel id="features">
<FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} /> <FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
@@ -37,6 +37,10 @@ export function setDeterrence(socket, userId, enabled, reason) {
return emitIdentityAdmin(socket, 'identityAdmin:setDeterrence', { userId, enabled, reason }); return emitIdentityAdmin(socket, 'identityAdmin:setDeterrence', { userId, enabled, reason });
} }
export function setMuted(socket, userId, enabled) {
return emitIdentityAdmin(socket, 'identityAdmin:setMuted', { userId, 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 });
} }
@@ -55,6 +55,7 @@ export function userMatchesQuery(user, query) {
export function userMatchesFilter(user, filter) { export function userMatchesFilter(user, filter) {
if (filter === 'verified') return Boolean(user?.verified?.enabled); if (filter === 'verified') return Boolean(user?.verified?.enabled);
if (filter === 'deterred') return Boolean(user?.deterrence?.enabled); if (filter === 'deterred') return Boolean(user?.deterrence?.enabled);
if (filter === 'muted') return Boolean(user?.deterrence?.muted);
if (filter === 'unverified') return !user?.verified?.enabled; if (filter === 'unverified') return !user?.verified?.enabled;
return true; return true;
} }