Files
MultiRoombaRover/server/src/services/privateRoverAccessRequestService/core.js
T
2026-08-07 23:25:56 -04:00

395 lines
13 KiB
JavaScript

// Private Rover Access Core
// Purpose: Implements request creation, grant/approval lifecycle, and requester state projection.
// Scope: Owns service business rules while keeping transport/event wiring outside this module.
const crypto = require('crypto');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('privateRoverAccessRequest');
const { publishEvent } = require('../eventBus');
const roverManager = require('../roverManager');
const { getNickname } = require('../nicknameService');
const { getRole, isLockdownAdmin } = require('../roleService');
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
const { getIdentitySummary, normalizeCookieUserId } = require('../identityService');
const {
REQUEST_COOLDOWN_MS,
GRANT_TTL_MS,
requestEvents,
pendingRequests,
pendingByRequesterRover,
lastRequestAtByRequester,
dmMessages,
grants,
} = require('./state');
const {
normalizeRoverId,
buildRequesterKey,
normalizeRequesterKey,
buildGrantKey,
listClosedPrivateRovers,
getSocketByRequesterKey,
} = require('./helpers');
function isGrantExpired(grant, now = Date.now()) {
const expiresAt = Number(grant?.expiresAt || 0);
return expiresAt > 0 && expiresAt <= now;
}
function pruneExpiredGrants(now = Date.now()) {
let removed = 0;
for (const [key, grant] of grants.entries()) {
if (!isGrantExpired(grant, now)) continue;
grants.delete(key);
removed += 1;
}
return removed;
}
function pruneExpiredGrantsAndRefresh(reason = 'grant_expired') {
const removed = pruneExpiredGrants();
if (!removed) return 0;
refreshAllSocketGrantCaches();
roverManager.broadcastRoster();
requestEvents.emit('change', { reason, expiredGrants: removed });
return removed;
}
function listPendingForRequester(socket) {
const requesterKey = buildRequesterKey(socket);
const pending = [];
for (const request of pendingRequests.values()) {
if (request.requesterKey !== requesterKey) continue;
if (request.status !== 'pending') continue;
pending.push({
id: request.id,
roverId: request.roverId,
roverName: request.roverName,
createdAt: request.createdAt,
status: request.status,
});
}
pending.sort((a, b) => b.createdAt - a.createdAt);
return pending;
}
function listGrantedRoversForRequester(requesterKey) {
pruneExpiredGrants();
const key = normalizeRequesterKey(requesterKey);
if (!key) return [];
const roverIds = [];
for (const grant of grants.values()) {
if (grant.requesterKey !== key) continue;
roverIds.push(grant.roverId);
}
return Array.from(new Set(roverIds));
}
function applySocketGrantCache(socket) {
if (!socket) return false;
socket.data = socket.data || {};
const requesterKey = buildRequesterKey(socket);
const nextRoverIds = listGrantedRoversForRequester(requesterKey).sort();
const previousRoverIds = Array.isArray(socket.data.privateClosedAccessRovers)
? socket.data.privateClosedAccessRovers
: [];
const requesterChanged = socket.data.privateAccessRequesterKey !== requesterKey;
const grantsChanged = previousRoverIds.length !== nextRoverIds.length
|| previousRoverIds.some((roverId, index) => String(roverId) !== String(nextRoverIds[index]));
/*
Remember both inputs that determine the socket's private-access projection.
The requester key matters even when both identities currently have no grants,
because pending requests in the session payload are also keyed by requester.
Returning whether either input changed lets transport hooks avoid announcing
a false state change for ordinary live identity-setting updates.
*/
socket.data.privateAccessRequesterKey = requesterKey;
if (grantsChanged) {
socket.data.privateClosedAccessRovers = nextRoverIds;
}
return requesterChanged || grantsChanged;
}
function refreshAllSocketGrantCaches() {
io.sockets.sockets.forEach((socket) => applySocketGrantCache(socket));
}
function getGrantForRequester(requesterKey, roverId) {
if (!requesterKey || !roverId) return null;
const grantKey = buildGrantKey(requesterKey, roverId);
const grant = grants.get(grantKey) || null;
if (!grant) return null;
if (isGrantExpired(grant)) {
grants.delete(grantKey);
return null;
}
return grant;
}
function hasClosedPrivateAccessForSocket(socket, roverId) {
if (!socket || !roverId) return false;
return Boolean(getGrantForRequester(buildRequesterKey(socket), roverId));
}
function getStateForSocket(socket) {
pruneExpiredGrants();
const requesterKey = buildRequesterKey(socket);
const grantedRovers = [];
for (const grant of grants.values()) {
if (grant.requesterKey !== requesterKey) continue;
grantedRovers.push({
roverId: grant.roverId,
grantedAt: grant.grantedAt,
expiresAt: grant.expiresAt || null,
requestId: grant.requestId || null,
});
}
grantedRovers.sort((a, b) => b.grantedAt - a.grantedAt);
return {
requestableRovers: listClosedPrivateRovers(),
pendingRequests: listPendingForRequester(socket),
grantedRovers,
};
}
function clearPendingRequest(request, reason = 'resolved') {
if (!request || request.status !== 'pending') return;
request.status = reason;
request.resolvedAt = Date.now();
pendingByRequesterRover.delete(`${request.requesterKey}:${request.roverId}`);
requestEvents.emit('change', { reason, requestId: request.id, roverId: request.roverId });
}
function clearPendingForRover(roverId, reason = 'resolved') {
const target = normalizeRoverId(roverId);
for (const request of pendingRequests.values()) {
if (request.status !== 'pending') continue;
if (String(request.roverId) !== target) continue;
clearPendingRequest(request, reason);
}
}
function findPendingRequestById(requestId) {
const id = String(requestId || '').trim();
if (!id) return null;
const request = pendingRequests.get(id);
if (!request || request.status !== 'pending') return null;
return request;
}
function attachDmMessage(requestId, messageId, adminDiscordId = null) {
const rid = String(requestId || '').trim();
const mid = String(messageId || '').trim();
if (!rid || !mid) return;
dmMessages.set(mid, {
requestId: rid,
adminDiscordId: adminDiscordId ? String(adminDiscordId) : null,
createdAt: Date.now(),
});
}
function getRequestByMessageId(messageId) {
const mid = String(messageId || '').trim();
if (!mid) return null;
const map = dmMessages.get(mid);
if (!map) return null;
const request = pendingRequests.get(map.requestId) || null;
if (!request) return null;
return { request, map };
}
function tryAssignClosedPrivateRover(socket, roverId) {
if (!socket || !roverId) return false;
const record = roverManager.rovers.get(String(roverId));
if (!record) return false;
const access = roverManager.canRequestControl(roverId, socket, {
allowUser: true,
allowClosedPrivateGrantInLockdown: true,
});
if (!access.ok) {
throw new Error(access.reason || 'Control denied');
}
const previousJoined = roverManager.getRoversForSocket(socket.id);
roverManager.requestControl(roverId, socket, {
allowUser: true,
allowClosedPrivateGrantInLockdown: true,
forceTurn: true,
});
previousJoined.forEach((rid) => {
if (rid !== roverId) roverManager.releaseControl(rid, socket);
});
roverManager.managerEvents.emit('switch', { socketId: socket.id, roverId: String(roverId) });
try {
const assignmentService = require('../assignmentService');
assignmentService.moveAssignment(socket, String(roverId), { releasePrevious: false });
} catch (err) {
logger.warn('Failed to move assignment after private access grant', { socketId: socket.id, error: err.message });
}
socket.emit('controlGranted', { roverId: String(roverId) });
roverManager.broadcastRoster();
return true;
}
function approveRequest(requestId, actorDiscordId = null) {
const request = findPendingRequestById(requestId);
if (!request) throw new Error('Request not found or already resolved.');
request.status = 'approved';
request.resolvedAt = Date.now();
request.resolvedBy = actorDiscordId ? String(actorDiscordId) : null;
pendingByRequesterRover.delete(`${request.requesterKey}:${request.roverId}`);
const grantKey = buildGrantKey(request.requesterKey, request.roverId);
const grantedAt = Date.now();
grants.set(grantKey, {
requesterKey: request.requesterKey,
roverId: request.roverId,
requestId: request.id,
grantedAt,
expiresAt: grantedAt + GRANT_TTL_MS,
grantedBy: request.resolvedBy,
});
let assignedSocketId = null;
const socket = getSocketByRequesterKey(request.requesterKey);
if (socket) {
applySocketGrantCache(socket);
try {
if (tryAssignClosedPrivateRover(socket, request.roverId)) {
assignedSocketId = socket.id;
}
} catch (err) {
logger.warn('Private rover access approved but assignment failed', {
requestId: request.id,
roverId: request.roverId,
socketId: socket.id,
error: err.message,
});
}
}
publishEvent({
source: 'privateRoverAccessRequest',
type: 'privateRoverAccess.resolved',
payload: {
requestId: request.id,
decision: 'approved',
roverId: request.roverId,
requesterKey: request.requesterKey,
resolvedBy: request.resolvedBy,
resolvedAt: request.resolvedAt,
grantExpiresAt: grantedAt + GRANT_TTL_MS,
assignedSocketId,
},
});
requestEvents.emit('change', { reason: 'approved', requestId: request.id, roverId: request.roverId, socketId: assignedSocketId });
return { request, assignedSocketId };
}
function denyRequest(requestId, actorDiscordId = null) {
const request = findPendingRequestById(requestId);
if (!request) throw new Error('Request not found or already resolved.');
request.status = 'denied';
request.resolvedAt = Date.now();
request.resolvedBy = actorDiscordId ? String(actorDiscordId) : null;
pendingByRequesterRover.delete(`${request.requesterKey}:${request.roverId}`);
publishEvent({
source: 'privateRoverAccessRequest',
type: 'privateRoverAccess.resolved',
payload: {
requestId: request.id,
decision: 'denied',
roverId: request.roverId,
requesterKey: request.requesterKey,
resolvedBy: request.resolvedBy,
resolvedAt: request.resolvedAt,
},
});
requestEvents.emit('change', { reason: 'denied', requestId: request.id, roverId: request.roverId });
return { request };
}
function createRequest(socket, roverIdRaw) {
if (!socket?.id) throw new Error('Socket required');
const roverId = normalizeRoverId(roverIdRaw);
if (!roverId) throw new Error('roverId required');
const record = roverManager.rovers.get(roverId);
if (!record) throw new Error('Unknown rover');
if (!record?.private?.enabled) throw new Error('Rover is not private');
if (record.privateOpen) throw new Error('Private rover is already open');
if (isLockdownAdmin(socket)) throw new Error('Lockdown admins can open private rovers directly');
const requesterKey = buildRequesterKey(socket);
const dedupeKey = `${requesterKey}:${roverId}`;
const existingId = pendingByRequesterRover.get(dedupeKey);
if (existingId) {
const existing = pendingRequests.get(existingId);
if (existing && existing.status === 'pending') {
return { request: existing, isNew: false };
}
}
const now = Date.now();
const lastAt = Number(lastRequestAtByRequester.get(requesterKey) || 0);
if (lastAt && now - lastAt < REQUEST_COOLDOWN_MS) {
const remaining = Math.ceil((REQUEST_COOLDOWN_MS - (now - lastAt)) / 1000);
throw new Error(`Please wait ${remaining}s before sending another request`);
}
/*
Private rover requests are forwarded to lockdown admins through Discord, so
the request record should carry the canonical identity fields that admins
need for moderation and audit context. The requester key above decides
dedupe/cooldown behavior, while this summary keeps the human-facing payload
aligned with the newer identity service state on the socket.
*/
const identity = getIdentitySummary(socket);
const request = {
id: `prr_${crypto.randomBytes(8).toString('hex')}`,
status: 'pending',
createdAt: now,
resolvedAt: null,
requesterKey,
roverId,
roverName: record.meta?.name || record.id,
requester: {
socketId: socket.id,
nickname: getNickname(socket) || null,
role: getRole(socket),
isVerified: Boolean(socket?.data?.isVerified),
userId: identity.userId || null,
cookieUserId: normalizeCookieUserId(socket?.data?.cookieUserId) || null,
fingerprintId: identity.fingerprintId || null,
ip: normalizeIp(getSocketIp(socket)) || null,
},
};
pendingRequests.set(request.id, request);
pendingByRequesterRover.set(dedupeKey, request.id);
lastRequestAtByRequester.set(requesterKey, now);
publishEvent({ source: 'privateRoverAccessRequest', type: 'privateRoverAccess.requested', payload: request });
requestEvents.emit('change', { reason: 'created', requestId: request.id, roverId: request.roverId, socketId: socket.id });
logger.info('Private rover access requested', { requestId: request.id, roverId: request.roverId, socketId: socket.id });
return { request, isNew: true };
}
module.exports = {
getStateForSocket,
createRequest,
hasClosedPrivateAccessForSocket,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
applySocketGrantCache,
refreshAllSocketGrantCaches,
pruneExpiredGrantsAndRefresh,
clearPendingForRover,
};