Files
MultiRoombaRover/server/src/services/verificationService/index.js
T

719 lines
24 KiB
JavaScript

// Verification Service Module
// Purpose: Binds socket.IO verification/moderation events to the central identity service.
// Scope: Keeps verification workflow behavior stable while moving user equality to identityService.
const EventEmitter = require('events');
const crypto = require('crypto');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('verificationService');
const { publishEvent } = require('../eventBus');
const { getNickname, setNickname } = require('../nicknameService');
const { getRole, roleEvents } = require('../roleService');
const {
identityEvents,
getDb,
identifySocket: identifyCanonicalSocket,
getUserById,
getUserForSocket,
getUserIdForSocket,
getIdentitySummary,
getKnownIp,
normalizeCookieUserId,
isValidCookieUserId,
normalizeFingerprintId,
isValidFingerprintId,
resolveUserIdForIdentity,
attachIdentitySignals,
sanitizeNickname,
setVerified,
setDeterrence,
setMuted,
listVerifiedUsers,
listDeterredUsers,
listMutedUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
} = require('../identityService');
const { shouldEnforceSingleDriverTab } = require('../../helpers/bandwidthSavings');
const verificationEvents = new EventEmitter();
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 = {}) {
verificationEvents.emit('change', { reason, ...payload });
}
function isAdminRole(role) {
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) {
const user = getUserForSocket(socket);
if (!socket?.data || !user) {
return { isVerified: false, isDeterred: false, matchedRecordId: null, reason: 'no_user' };
}
const role = getRole(socket);
const verifiedByRole = isAdminRole(role);
const deterredByUser = Boolean(user.deterrence?.enabled);
const mutedByUser = Boolean(user.deterrence?.muted);
socket.data.isVerified = verifiedByRole || Boolean(user.verified?.enabled);
socket.data.verifiedRecordId = socket.data.isVerified ? user.id : null;
socket.data.isDeterred = isAdminRole(role) ? false : deterredByUser;
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 {
isVerified: socket.data.isVerified,
isDeterred: socket.data.isDeterred,
isMuted: socket.data.isMuted,
matchedRecordId: user.id,
reason: socket.data.isVerified ? 'matched' : 'no_match',
userId: user.id,
};
}
function identifySocket(socket, payload = {}) {
if (!socket) throw new Error('Socket required');
/*
Personal audio percentages travel with portable browser identity but are not
identity database state. Keeping the raw object on this connection lets the
audio service apply its current permission and range policy after canonical
identity resolution, including during handshake authentication.
*/
socket.data = socket.data || {};
socket.data.audioAdjustments = payload?.audioAdjustments && typeof payload.audioAdjustments === 'object'
? { ...payload.audioAdjustments }
: {};
const incomingNickname = sanitizeNickname(payload.nickname);
if (incomingNickname) {
try {
if (incomingNickname !== getNickname(socket)) {
setNickname(socket, incomingNickname);
}
socket.data = socket.data || {};
socket.data.nickname = incomingNickname;
} catch (err) {
logger.warn('Failed to set nickname from identify', err.message);
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'overseerEnabled')) {
socket.data = socket.data || {};
socket.data.overseerEnabled = Boolean(payload.overseerEnabled);
} else if (typeof socket?.data?.overseerEnabled !== 'boolean') {
socket.data = socket.data || {};
socket.data.overseerEnabled = true;
}
/*
Spectator-style pages carry identity too, but only the driver and PTZ surfaces
participate in duplicate-driver enforcement. This flag remains on the
socket because it is connection-specific, not person-specific.
*/
socket.data.identitySurface = ['driver', 'ptz'].includes(payload.identitySurface) ? payload.identitySurface : 'passive';
const result = identifyCanonicalSocket(socket, {
...payload,
nickname: incomingNickname || getNickname(socket) || '',
});
refreshSocketIdentityFlags(socket);
enforceSingleDriverSocketPerIdentity(socket);
emitChange('identify', { socketId: socket.id, userId: result.userId });
return {
cookieUserId: result.cookieUserId,
fingerprintId: result.fingerprintId,
userId: result.userId,
overseerEnabled: Boolean(socket.data.overseerEnabled),
isVerified: Boolean(socket.data.isVerified),
isDeterred: Boolean(socket.data.isDeterred),
reason: socket.data.isVerified ? 'matched' : 'no_match',
identifiedAt: Date.now(),
};
}
function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
if (!socket?.id || socket.disconnected) return;
socket.emit('session:duplicateIdentity', {
reason: 'duplicate_identity',
message: 'This driver session is already active in another tab.',
...payload,
});
setTimeout(() => {
if (!socket.disconnected) {
socket.disconnect(true);
}
}, DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS);
}
function enforceSingleDriverSocketPerIdentity(currentSocket) {
const currentUserId = getUserIdForSocket(currentSocket);
const currentRole = getRole(currentSocket);
const enforceForCurrentSocket = shouldEnforceSingleDriverTab({
isVerified: Boolean(currentSocket?.data?.isVerified),
isAdmin: isAdminRole(currentRole),
});
if (
!currentSocket?.id ||
!currentUserId ||
!['driver', 'ptz'].includes(currentSocket.data?.identitySurface) ||
!enforceForCurrentSocket
) {
return;
}
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
if (!['driver', 'ptz'].includes(candidate?.data?.identitySurface)) return false;
return getUserIdForSocket(candidate) === currentUserId;
});
if (!duplicates.length) return;
const verifiedDuplicate = duplicates.find((candidate) => {
/*
verifiedOnly keeps the previous "verified tab wins" rule. In notAllowed
mode, verified users are subject to the same single-driver-tab rule, so a
verified duplicate should not protect the newer socket from enforcement.
*/
const candidateRole = getRole(candidate);
const enforceForCandidate = shouldEnforceSingleDriverTab({
isVerified: Boolean(candidate?.data?.isVerified),
isAdmin: isAdminRole(candidateRole),
});
return candidate?.data?.isVerified && !enforceForCandidate;
});
if (verifiedDuplicate) {
logger.info('Disconnecting driver socket because its user is already active on an exempt verified socket', {
socketId: currentSocket.id,
retainedSocketId: verifiedDuplicate.id,
userId: currentUserId,
});
emitDuplicateIdentityAndDisconnect(currentSocket, { retainedSocketId: verifiedDuplicate.id });
return;
}
duplicates.forEach((duplicate) => {
logger.info('Disconnecting older duplicate driver socket', {
socketId: duplicate.id,
retainedSocketId: currentSocket.id,
userId: currentUserId,
});
emitDuplicateIdentityAndDisconnect(duplicate, { retainedSocketId: currentSocket.id });
});
}
function getPendingRequestForUser(userId) {
if (!userId) return null;
return getDb().prepare(`
select * from verification_requests
where user_id = ? and status = 'pending'
order by created_at desc
limit 1
`).get(userId) || null;
}
function getVerificationStateForSocket(socket) {
const userId = getUserIdForSocket(socket);
const pending = getPendingRequestForUser(userId);
return {
isVerified: Boolean(socket?.data?.isVerified),
pendingRequestId: pending?.id || null,
pendingRequestedAt: pending?.created_at || null,
};
}
function getModerationStateForSocket(socket) {
return {
isDeterred: Boolean(socket?.data?.isDeterred),
isMuted: Boolean(socket?.data?.isMuted),
recordId: socket?.data?.deterredRecordId || null,
};
}
function createVerificationRequest(socket) {
if (!socket) throw new Error('Socket required');
refreshSocketIdentityFlags(socket);
const user = getUserForSocket(socket);
if (!user) throw new Error('Identity missing. Reconnect and try again.');
if (socket.data?.isVerified) throw new Error('You are already verified.');
const cookieUserId = normalizeCookieUserId(socket.data?.cookieUserId);
if (!cookieUserId || !isValidCookieUserId(cookieUserId)) {
throw new Error('Identity key format invalid.');
}
const existingPending = getPendingRequestForUser(user.id);
if (existingPending) {
return {
id: existingPending.id,
status: existingPending.status,
cookieUserId: existingPending.cookie_user_id,
userId: existingPending.user_id,
};
}
const request = {
id: `vr_${crypto.randomBytes(8).toString('hex')}`,
status: 'pending',
userId: user.id,
cookieUserId,
fingerprintId: normalizeFingerprintId(socket.data?.fingerprintId) || null,
nickname: getNickname(socket) || user.nickname || null,
ip: getKnownIp(socket) || null,
socketId: socket.id,
createdAt: Date.now(),
};
getDb().prepare(`
insert into verification_requests
(id, user_id, cookie_user_id, fingerprint_id, nickname, ip, socket_id, status, decision, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
request.id,
request.userId,
request.cookieUserId,
request.fingerprintId,
request.nickname,
request.ip,
request.socketId,
request.status,
null,
request.createdAt,
);
publishEvent({ source: 'verification', type: 'verification.requested', payload: request });
emitChange('request', { requestId: request.id, socketId: socket.id, userId: user.id });
return request;
}
function attachDmMessage(requestId, messageId, adminDiscordId) {
if (!requestId || !messageId) return;
getDb().prepare(`
insert or ignore into verification_dm_messages (message_id, request_id, admin_discord_id, created_at)
values (?, ?, ?, ?)
`).run(String(messageId), String(requestId), adminDiscordId ? String(adminDiscordId) : null, Date.now());
}
function rowToRequest(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
cookieUserId: row.cookie_user_id,
fingerprintId: row.fingerprint_id,
nickname: row.nickname,
ip: row.ip,
socketId: row.socket_id,
status: row.status,
decision: row.decision,
createdAt: row.created_at,
resolvedAt: row.resolved_at,
resolvedBy: row.resolved_by,
};
}
function getPendingRequestById(requestId) {
if (!requestId) return null;
return rowToRequest(getDb().prepare(`
select * from verification_requests
where id = ? and status = 'pending'
`).get(String(requestId)));
}
function getRequestByMessageId(messageId) {
if (!messageId) return null;
const map = getDb().prepare('select * from verification_dm_messages where message_id = ?').get(String(messageId));
if (!map) return null;
const request = rowToRequest(getDb().prepare('select * from verification_requests where id = ?').get(map.request_id));
return request ? {
request,
map: {
requestId: map.request_id,
messageId: map.message_id,
adminDiscordId: map.admin_discord_id,
createdAt: map.created_at,
},
} : null;
}
function refreshSocketsForUser(userId) {
io.sockets.sockets.forEach((socket) => {
if (getUserIdForSocket(socket) !== userId) return;
refreshSocketIdentityFlags(socket);
emitChange('user_refresh', { socketId: socket.id, userId });
});
}
function approveRequest(requestId, actorDiscordId) {
const request = getPendingRequestById(requestId);
if (!request) throw new Error('Request not found or already resolved.');
const resolvedAt = Date.now();
const actor = actorDiscordId ? String(actorDiscordId) : null;
getDb().prepare(`
update verification_requests
set status = 'approved', decision = 'approved', resolved_at = ?, resolved_by = ?
where id = ? and status = 'pending'
`).run(resolvedAt, actor, requestId);
setVerified(request.userId, { enabled: true, actor, at: resolvedAt });
refreshSocketsForUser(request.userId);
publishEvent({
source: 'verification',
type: 'verification.resolved',
payload: {
requestId,
decision: 'approved',
userId: request.userId,
cookieUserId: request.cookieUserId,
nickname: request.nickname,
resolvedBy: actor,
resolvedAt,
},
});
emitChange('approve', { requestId, userId: request.userId });
}
function denyRequest(requestId, actorDiscordId) {
const request = getPendingRequestById(requestId);
if (!request) throw new Error('Request not found or already resolved.');
const resolvedAt = Date.now();
const actor = actorDiscordId ? String(actorDiscordId) : null;
getDb().prepare(`
update verification_requests
set status = 'denied', decision = 'denied', resolved_at = ?, resolved_by = ?
where id = ? and status = 'pending'
`).run(resolvedAt, actor, requestId);
publishEvent({
source: 'verification',
type: 'verification.resolved',
payload: {
requestId,
decision: 'denied',
userId: request.userId,
cookieUserId: request.cookieUserId,
nickname: request.nickname,
resolvedBy: actor,
resolvedAt,
},
});
emitChange('deny', { requestId, userId: request.userId });
}
function removeVerifiedUser(selector, removedBy = null) {
const resolved = resolveUserBySelector(selector, { includeVerified: true, includeDeterred: false });
if (resolved.error || !resolved.user) {
throw new Error(resolved.error === 'ambiguous_nickname' ? 'Nickname matches multiple users.' : 'Verified user not found.');
}
const removed = setVerified(resolved.user.id, { enabled: false, actor: removedBy || null });
refreshSocketsForUser(resolved.user.id);
emitChange('remove', { userId: resolved.user.id });
publishEvent({
source: 'verification',
type: 'verification.userRemoved',
payload: {
userId: removed.id,
cookieUserId: removed.cookieUserIds[0] || null,
nickname: removed.nickname,
removedBy,
removedAt: Date.now(),
},
});
return userToLegacyIdentityEntry(removed);
}
function deterUser(selector, options = {}) {
const rawSelector = String(selector || '').trim();
if (!rawSelector) throw new Error('Selector required.');
let resolved = resolveUserBySelector(rawSelector, { includeVerified: true, includeDeterred: true });
if (resolved.error === 'not_found') {
const cookie = normalizeCookieUserId(rawSelector);
const fingerprint = normalizeFingerprintId(rawSelector);
if (cookie && isValidCookieUserId(cookie)) {
const userId = resolveUserIdForIdentity({ cookieUserId: cookie }, { create: true });
resolved = { user: attachIdentitySignals(userId, { cookieUserId: cookie }) };
} else if (fingerprint && isValidFingerprintId(fingerprint)) {
const userId = resolveUserIdForIdentity({ fingerprintId: fingerprint }, { create: true });
resolved = { user: attachIdentitySignals(userId, { fingerprintId: fingerprint }) };
}
}
if (resolved.error || !resolved.user) {
throw new Error(resolved.error === 'ambiguous_nickname' ? 'Nickname matches multiple users.' : 'User not found.');
}
const wasDeterred = Boolean(resolved.user.deterrence?.enabled);
const user = setDeterrence(resolved.user.id, {
enabled: true,
reason: String(options?.reason || '').trim() || null,
actor: options?.actor ? String(options.actor) : null,
at: Date.now(),
});
refreshSocketsForUser(user.id);
publishEvent({
source: 'moderation',
type: wasDeterred ? 'moderation.deterrenceUpdated' : 'moderation.deterred',
payload: {
id: user.id,
userId: user.id,
cookieUserId: user.cookieUserIds[0] || null,
fingerprintId: user.fingerprintIds[0] || null,
nickname: user.nickname,
knownIps: user.knownIps,
reason: user.deterrence.reason,
actor: options?.actor ? String(options.actor) : null,
ts: Date.now(),
},
});
emitChange('deter_update', { userId: user.id });
return { ...userToLegacyIdentityEntry(user), created: !wasDeterred };
}
function undeterUser(selector, removedBy = null) {
const resolved = resolveUserBySelector(selector, { includeVerified: true, includeDeterred: true });
if (resolved.error || !resolved.user || !resolved.user.deterrence?.enabled) {
throw new Error(resolved.error === 'ambiguous_nickname' ? 'Nickname matches multiple users.' : 'Deterred user not found.');
}
const user = setDeterrence(resolved.user.id, { enabled: false, actor: removedBy || null });
refreshSocketsForUser(user.id);
const removedAt = Date.now();
emitChange('deter_remove', { userId: user.id });
publishEvent({
source: 'moderation',
type: 'moderation.undeterred',
payload: {
id: user.id,
userId: user.id,
cookieUserId: user.cookieUserIds[0] || null,
nickname: user.nickname,
removedBy: removedBy ? String(removedBy) : null,
removedAt,
},
});
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) {
return refreshSocketIdentityFlags(socket);
}
function reevaluateSocketDeterrence(socket) {
return refreshSocketIdentityFlags(socket);
}
function getVerificationStatus(socket) {
return {
isVerified: Boolean(socket?.data?.isVerified),
recordId: socket?.data?.verifiedRecordId || null,
};
}
/*
Namespace middleware completes before Socket.IO emits `connection` to any
service. Attaching canonical identity here guarantees that authorization,
session construction, queues, chat, and media handlers never observe the old
intermediate state where a transport existed but session:identify had not
arrived yet. Missing keys remain valid: the canonical service creates the
first portable key and exposes it through the initial session sync.
*/
io.use((socket, next) => {
try {
socket.data = socket.data || {};
socket.data.connectedAt = Date.now();
identifySocket(socket, socket.handshake?.auth || {});
next();
} catch (err) {
logger.warn('Rejected socket with invalid handshake identity', {
socketId: socket.id,
error: err.message,
});
next(new Error(err.message));
}
});
io.on('connection', (socket) => {
installDeterredSocketGuard(socket);
/*
Duplicate-driver enforcement emits a normal socket event before disconnecting
the losing tab, so it runs after middleware admits the namespace connection.
Identity itself is already present before this point.
*/
enforceSingleDriverSocketPerIdentity(socket);
socket.on('session:identify', (payload = {}, cb = () => {}) => {
try {
const result = identifySocket(socket, payload || {});
cb({ success: true, ...result });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('verification:request', (_, cb = () => {}) => {
try {
const request = createVerificationRequest(socket);
cb({ success: true, requestId: request.id, status: request.status });
} catch (err) {
cb({ error: err.message });
}
});
});
roleEvents.on('change', ({ socket }) => {
if (!socket) return;
try {
refreshSocketIdentityFlags(socket);
emitChange('role_change', { socketId: socket.id, userId: getUserIdForSocket(socket) });
} catch (err) {
logger.warn('Failed to reevaluate verification on role change', err.message);
}
});
identityEvents.on('change', ({ userId, reason } = {}) => {
if (!userId) return;
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 });
});
module.exports = {
identifySocket,
getVerificationStatus,
getIdentitySummary,
getVerificationStateForSocket,
getModerationStateForSocket,
createVerificationRequest,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
isVerified: (socket) => Boolean(socket?.data?.isVerified),
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
isMuted: (socket) => Boolean(socket?.data?.isMuted),
reevaluateSocketVerification,
reevaluateSocketDeterrence,
verificationEvents,
};