here goes nothin'

This commit is contained in:
legop3
2026-06-27 04:23:58 -04:00
parent 0172ea5150
commit 3c1b1dab6e
21 changed files with 1554 additions and 332 deletions
+433 -163
View File
@@ -1,31 +1,35 @@
// Verification Service Module
// Purpose: Composes verification identity, request, and moderation deterrence flows into one public service API.
// Scope: Exposes stable verification operations while delegating behavior to focused submodules.
// 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 { loadStore, withStore } = require('./store');
const {
ensureSocketData,
identityFromSocket,
normalizeNicknameKey,
normalizeKnownIps,
isAdminRole,
parseDeterrenceSelector,
isRawIp,
getDb,
identifySocket: identifyCanonicalSocket,
getUserById,
getUserForSocket,
getUserIdForSocket,
getIdentitySummary,
getKnownIp,
normalizeCookieUserId,
isValidCookieUserId,
generateCookieUserId,
normalizeFingerprintId,
isValidFingerprintId,
resolveUserIdForIdentity,
attachIdentitySignals,
sanitizeNickname,
} = require('./identity');
const { createVerificationFlow } = require('./verificationFlow');
const { createDeterrenceFlow } = require('./deterrenceFlow');
const { createRequestFlow } = require('./requestFlow');
const { registerVerificationHooks } = require('./hooks');
setVerified,
setDeterrence,
listVerifiedUsers,
listDeterredUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
} = require('../identityService');
const verificationEvents = new EventEmitter();
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
@@ -36,72 +40,34 @@ function emitChange(reason, payload = {}) {
verificationEvents.emit('change', { reason, ...payload });
}
let reevaluateSocketVerification = () => ({ isVerified: false, matchedRecordId: null, reason: 'not_initialized' });
let reevaluateSocketDeterrence = () => ({ isDeterred: false, matchedRecordId: null, reason: 'not_initialized' });
function isAdminRole(role) {
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
}
const verificationFlow = createVerificationFlow({
loadStore,
withStore,
io,
publishEvent,
emitChange,
getRole,
getNickname,
ensureSocketData,
identityFromSocket,
normalizeCookieUserId,
sanitizeNickname,
reevaluateSocketDeterrence: (...args) => reevaluateSocketDeterrence(...args),
});
reevaluateSocketVerification = verificationFlow.reevaluateSocketVerification;
function refreshSocketIdentityFlags(socket) {
const user = getUserForSocket(socket);
if (!socket?.data || !user) {
return { isVerified: false, isDeterred: false, matchedRecordId: null, reason: 'no_user' };
}
const deterrenceFlow = createDeterrenceFlow({
loadStore,
withStore,
io,
publishEvent,
emitChange,
getRole,
ensureSocketData,
identityFromSocket,
normalizeNicknameKey,
normalizeKnownIps,
isAdminRole,
parseDeterrenceSelector,
isRawIp,
normalizeCookieUserId,
isValidCookieUserId,
sanitizeNickname,
findVerifiedMatch: verificationFlow.findVerifiedMatch,
});
reevaluateSocketDeterrence = deterrenceFlow.reevaluateSocketDeterrence;
const requestFlow = createRequestFlow({
loadStore,
withStore,
io,
publishEvent,
emitChange,
ensureSocketData,
identityFromSocket,
isValidCookieUserId,
normalizeCookieUserId,
reevaluateSocketVerification,
reevaluateSocketDeterrence,
});
const role = getRole(socket);
const verifiedByRole = isAdminRole(role);
const deterredByUser = Boolean(user.deterrence?.enabled);
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;
return {
isVerified: socket.data.isVerified,
isDeterred: socket.data.isDeterred,
matchedRecordId: user.id,
reason: socket.data.isVerified ? 'matched' : 'no_match',
userId: user.id,
};
}
function identifySocket(socket, payload = {}) {
if (!socket) {
throw new Error('Socket required');
}
const data = ensureSocketData(socket);
const incomingKey = normalizeCookieUserId(payload.cookieUserId);
if (incomingKey && !isValidCookieUserId(incomingKey)) {
throw new Error('Invalid identity key format.');
}
const currentKey = normalizeCookieUserId(data.cookieUserId);
const safeCurrentKey = isValidCookieUserId(currentKey) ? currentKey : '';
data.cookieUserId = incomingKey || safeCurrentKey || generateCookieUserId();
if (!socket) throw new Error('Socket required');
const incomingNickname = sanitizeNickname(payload.nickname);
if (incomingNickname) {
@@ -109,49 +75,50 @@ function identifySocket(socket, payload = {}) {
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')) {
data.overseerEnabled = Boolean(payload.overseerEnabled);
} else if (typeof data.overseerEnabled !== 'boolean') {
data.overseerEnabled = true;
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 send the same identity heartbeat as the driver page,
but they should not participate in multitabbing prevention. The page surface
flag makes that distinction explicit before role changes finish, which avoids
a race where a spectator route briefly looks like a normal user connection.
Spectator-style pages send identity heartbeats too, but only the driver
surface participates in duplicate-driver enforcement. This flag remains on
the socket because it is connection-specific, not person-specific.
*/
data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
const verification = reevaluateSocketVerification(socket);
const deterrence = reevaluateSocketDeterrence(socket);
const result = identifyCanonicalSocket(socket, {
...payload,
nickname: incomingNickname || getNickname(socket) || '',
});
refreshSocketIdentityFlags(socket);
enforceSingleUnverifiedSocketPerIdentity(socket);
emitChange('identify', { socketId: socket.id });
emitChange('identify', { socketId: socket.id, userId: result.userId });
return {
cookieUserId: data.cookieUserId,
overseerEnabled: Boolean(data.overseerEnabled),
isVerified: verification.isVerified,
isDeterred: deterrence.isDeterred,
reason: verification.reason,
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;
/*
The browser needs a small amount of time to render the blocking overlay
before the transport closes. Socket.IO does not guarantee that an immediate
disconnect after emit will be visible to the client, so the short timer is
intentionally used as an event-delivery grace period rather than a retry or
background worker.
*/
socket.emit('session:duplicateIdentity', {
reason: 'duplicate_identity',
message: 'This driver session is already active in another tab.',
@@ -165,82 +132,385 @@ function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
}
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
const currentKey = normalizeCookieUserId(currentSocket?.data?.cookieUserId);
const currentUserId = getUserIdForSocket(currentSocket);
if (
!currentSocket?.id ||
!currentKey ||
!currentUserId ||
currentSocket.data?.isVerified ||
currentSocket.data?.identitySurface !== 'driver'
) {
return;
}
/*
Verification status is evaluated before this function runs. That ordering is
important because verified users are immune to duplicate-tab enforcement:
a verified socket is never disconnected here, and a verified current socket
never causes older tabs to be removed.
*/
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
if (candidate?.data?.identitySurface !== 'driver') return false;
const candidateKey = normalizeCookieUserId(candidate?.data?.cookieUserId);
return Boolean(candidateKey && candidateKey === currentKey);
return getUserIdForSocket(candidate) === currentUserId;
});
if (duplicates.length === 0) {
return;
}
if (!duplicates.length) return;
const verifiedDuplicate = duplicates.find((candidate) => candidate?.data?.isVerified);
if (verifiedDuplicate) {
/*
A verified tab is allowed to keep running, but the non-verified tab that
collided with it should still be blocked. This keeps the immunity attached
to verified users instead of turning a verified identity key into a bypass
for unverified browser sessions.
*/
logger.info('Disconnecting non-verified socket because its identity is already active on a verified socket', {
logger.info('Disconnecting non-verified socket because its user is already active on a verified socket', {
socketId: currentSocket.id,
retainedSocketId: verifiedDuplicate.id,
cookieUserId: currentKey,
});
emitDuplicateIdentityAndDisconnect(currentSocket, {
retainedSocketId: verifiedDuplicate.id,
userId: currentUserId,
});
emitDuplicateIdentityAndDisconnect(currentSocket, { retainedSocketId: verifiedDuplicate.id });
return;
}
/*
When all duplicates are non-verified, the newest socket wins. Opening a new
tab should move the user to that tab instead of leaving an older background
tab with rover control, chat identity, or game participation.
*/
duplicates.forEach((duplicate) => {
logger.info('Disconnecting older non-verified duplicate identity socket', {
logger.info('Disconnecting older non-verified duplicate user socket', {
socketId: duplicate.id,
retainedSocketId: currentSocket.id,
cookieUserId: currentKey,
});
emitDuplicateIdentityAndDisconnect(duplicate, {
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) {
return verificationFlow.getVerificationStateForSocket(socket, requestFlow.getPendingRequestForIdentity);
const userId = getUserIdForSocket(socket);
const pending = getPendingRequestForUser(userId);
return {
isVerified: Boolean(socket?.data?.isVerified),
pendingRequestId: pending?.id || null,
pendingRequestedAt: pending?.created_at || null,
};
}
registerVerificationHooks({
io,
roleEvents,
logger,
identifySocket,
createVerificationRequest: requestFlow.createVerificationRequest,
reevaluateSocketVerification,
reevaluateSocketDeterrence,
emitChange,
function getModerationStateForSocket(socket) {
return {
isDeterred: Boolean(socket?.data?.isDeterred),
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 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,
};
}
io.on('connection', (socket) => {
socket.data = socket.data || {};
socket.data.connectedAt = Date.now();
identifySocket(socket, {});
socket.on('session:identify', (payload = {}, cb = () => {}) => {
try {
const result = identifySocket(socket, payload || {});
socket.data.lastClientIdentifyAt = Date.now();
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);
}
});
setInterval(() => {
@@ -266,22 +536,22 @@ setInterval(() => {
module.exports = {
identifySocket,
getVerificationStatus: verificationFlow.getVerificationStatus,
getIdentitySummary: verificationFlow.getIdentitySummary,
getVerificationStatus,
getIdentitySummary,
getVerificationStateForSocket,
getModerationStateForSocket: deterrenceFlow.getModerationStateForSocket,
createVerificationRequest: requestFlow.createVerificationRequest,
attachDmMessage: requestFlow.attachDmMessage,
getRequestByMessageId: requestFlow.getRequestByMessageId,
approveRequest: requestFlow.approveRequest,
denyRequest: requestFlow.denyRequest,
listVerifiedUsers: verificationFlow.listVerifiedUsers,
removeVerifiedUser: verificationFlow.removeVerifiedUser,
listDeterredUsers: deterrenceFlow.listDeterredUsers,
deterUser: deterrenceFlow.deterUser,
undeterUser: deterrenceFlow.undeterUser,
isVerified: verificationFlow.isVerified,
isDeterred: deterrenceFlow.isDeterred,
getModerationStateForSocket,
createVerificationRequest,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
deterUser,
undeterUser,
isVerified: (socket) => Boolean(socket?.data?.isVerified),
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
reevaluateSocketVerification,
reevaluateSocketDeterrence,
verificationEvents,