mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
719 lines
24 KiB
JavaScript
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,
|
|
};
|