// 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 { normalizeCookieUserId } = require('../identityService'); const { REQUEST_COOLDOWN_MS, requestEvents, pendingRequests, pendingByRequesterRover, lastRequestAtByRequester, dmMessages, grants, } = require('./state'); const { normalizeRoverId, buildRequesterKey, normalizeRequesterKey, buildGrantKey, listClosedPrivateRovers, getSocketByRequesterKey, } = require('./helpers'); 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) { 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; socket.data = socket.data || {}; const requesterKey = buildRequesterKey(socket); socket.data.privateClosedAccessRovers = listGrantedRoversForRequester(requesterKey); } function getGrantForRequester(requesterKey, roverId) { if (!requesterKey || !roverId) return null; return grants.get(buildGrantKey(requesterKey, roverId)) || null; } function hasClosedPrivateAccessForSocket(socket, roverId) { if (!socket || !roverId) return false; return Boolean(getGrantForRequester(buildRequesterKey(socket), roverId)); } function getStateForSocket(socket) { 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, 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 }); if (!access.ok) { throw new Error(access.reason || 'Control denied'); } const previousJoined = roverManager.getRoversForSocket(socket.id); roverManager.requestControl(roverId, socket, { allowUser: 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) }); 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); grants.set(grantKey, { requesterKey: request.requesterKey, roverId: request.roverId, requestId: request.id, grantedAt: Date.now(), 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, 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`); } 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), cookieUserId: normalizeCookieUserId(socket?.data?.cookieUserId) || 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, clearPendingForRover, };