This commit is contained in:
legop3
2026-03-27 16:15:08 -04:00
parent c220eb4022
commit 85fcaedc6a
5 changed files with 311 additions and 4 deletions
BIN
View File
Binary file not shown.
+4
View File
@@ -125,6 +125,10 @@ function reassignFromRover(roverId) {
assignments.delete(socketId); assignments.delete(socketId);
continue; continue;
} }
const access = roverManager.canRequestControl(roverId, socket, { allowUser: true });
if (access.ok) {
continue;
}
roverManager.releaseControl(rid, socket); roverManager.releaseControl(rid, socket);
assignments.delete(socketId); assignments.delete(socketId);
assignSocket(socket); assignSocket(socket);
+66 -2
View File
@@ -40,6 +40,14 @@ const {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
} = require('./verificationService'); } = require('./verificationService');
const {
DM_APPROVE_EMOJI: PRIVATE_ACCESS_APPROVE_EMOJI,
DM_DENY_EMOJI: PRIVATE_ACCESS_DENY_EMOJI,
attachDmMessage: attachPrivateAccessDmMessage,
getRequestByMessageId: getPrivateAccessRequestByMessageId,
approveRequest: approvePrivateAccessRequest,
denyRequest: denyPrivateAccessRequest,
} = require('./privateRoverAccessRequestService');
const config = loadConfig(); const config = loadConfig();
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.token); const enabled = Boolean(discordConfig.token);
@@ -1525,7 +1533,7 @@ async function sendPrivateRoverAccessRequestDms(event) {
`IP: \`${requester.ip || 'unknown'}\``, `IP: \`${requester.ip || 'unknown'}\``,
`Created: ${createdAt}`, `Created: ${createdAt}`,
'', '',
'Open the rover manually in the admin UI if approved.', `React with ${PRIVATE_ACCESS_APPROVE_EMOJI} to approve or ${PRIVATE_ACCESS_DENY_EMOJI} to deny.`,
].join('\n'); ].join('\n');
await Promise.all( await Promise.all(
@@ -1534,7 +1542,14 @@ async function sendPrivateRoverAccessRequestDms(event) {
const user = await client.users.fetch(String(adminId)); const user = await client.users.fetch(String(adminId));
if (!user) return; if (!user) return;
const dm = await user.createDM(); const dm = await user.createDM();
await dm.send({ content, allowedMentions: { parse: [] } }); const message = await dm.send({ content, allowedMentions: { parse: [] } });
try {
await message.react(PRIVATE_ACCESS_APPROVE_EMOJI);
await message.react(PRIVATE_ACCESS_DENY_EMOJI);
} catch (err) {
logger.warn('Failed to add private rover access reactions', { requestId, adminId, error: err.message });
}
attachPrivateAccessDmMessage(requestId, message.id, adminId);
} catch (err) { } catch (err) {
logger.warn('Failed to DM lockdown admin for private rover access request', { logger.warn('Failed to DM lockdown admin for private rover access request', {
requestId, requestId,
@@ -1589,6 +1604,52 @@ async function handleVerificationReaction(reaction, user) {
} }
} }
async function handlePrivateAccessReaction(reaction, user) {
if (!reaction || !user || user.bot) return;
const emoji = reaction.emoji?.name;
if (emoji !== PRIVATE_ACCESS_APPROVE_EMOJI && emoji !== PRIVATE_ACCESS_DENY_EMOJI) return;
if (!isLockdownAdminUser(user.id)) return;
const maybePartial = reaction.message?.partial || reaction.partial;
if (maybePartial) {
try {
await reaction.fetch();
} catch (err) {
logger.warn('Failed to fetch partial private access reaction', err.message);
return;
}
}
const messageId = reaction.message?.id;
if (!messageId) return;
const linked = getPrivateAccessRequestByMessageId(messageId);
if (!linked?.request || linked.request.status !== 'pending') return;
try {
if (emoji === PRIVATE_ACCESS_APPROVE_EMOJI) {
const { assignedSocketId } = approvePrivateAccessRequest(linked.request.id, user.id);
const assignmentNote = assignedSocketId
? ` Granted and assigned to socket \`${assignedSocketId}\`.`
: ' Granted.';
await reaction.message.reply({
content: `Approved private access request \`${linked.request.id}\`.${assignmentNote}`,
allowedMentions: { parse: [] },
});
} else {
denyPrivateAccessRequest(linked.request.id, user.id);
await reaction.message.reply({
content: `Denied private access request \`${linked.request.id}\`.`,
allowedMentions: { parse: [] },
});
}
} catch (err) {
logger.warn('Failed to resolve private rover access request from reaction', {
requestId: linked.request.id,
error: err.message,
});
}
}
function handleChatBridgeOutbound(event) { function handleChatBridgeOutbound(event) {
const payload = event?.payload; const payload = event?.payload;
if (!payload) return; if (!payload) return;
@@ -1692,6 +1753,9 @@ client.on('messageReactionAdd', (reaction, user) => {
handleVerificationReaction(reaction, user).catch((err) => { handleVerificationReaction(reaction, user).catch((err) => {
logger.warn('Error handling verification reaction', err.message); logger.warn('Error handling verification reaction', err.message);
}); });
handlePrivateAccessReaction(reaction, user).catch((err) => {
logger.warn('Error handling private access reaction', err.message);
});
}); });
client.once('ready', () => { client.once('ready', () => {
@@ -10,10 +10,14 @@ const { getSocketIp, normalizeIp } = require('../helpers/ipResolver');
const requestEvents = new EventEmitter(); const requestEvents = new EventEmitter();
const REQUEST_COOLDOWN_MS = 15 * 1000; const REQUEST_COOLDOWN_MS = 15 * 1000;
const DM_APPROVE_EMOJI = '✅';
const DM_DENY_EMOJI = '❌';
const pendingRequests = new Map(); // requestId -> request const pendingRequests = new Map(); // requestId -> request
const pendingByRequesterRover = new Map(); // `${requesterKey}:${roverId}` -> requestId const pendingByRequesterRover = new Map(); // `${requesterKey}:${roverId}` -> requestId
const lastRequestAtByRequester = new Map(); // requesterKey -> ts const lastRequestAtByRequester = new Map(); // requesterKey -> ts
const dmMessages = new Map(); // messageId -> { requestId, adminDiscordId, createdAt }
const grants = new Map(); // `${requesterKey}:${roverId}` -> { requesterKey, roverId, grantedAt, grantedBy, requestId }
function normalizeRoverId(value) { function normalizeRoverId(value) {
return String(value || '').trim(); return String(value || '').trim();
@@ -25,6 +29,14 @@ function buildRequesterKey(socket) {
return `socket:${socket?.id || 'unknown'}`; return `socket:${socket?.id || 'unknown'}`;
} }
function normalizeRequesterKey(value) {
return String(value || '').trim().toLowerCase();
}
function buildGrantKey(requesterKey, roverId) {
return `${normalizeRequesterKey(requesterKey)}:${normalizeRoverId(roverId)}`;
}
function isClosedPrivateRoverRecord(record) { function isClosedPrivateRoverRecord(record) {
return Boolean(record?.private?.enabled && !record?.privateOpen); return Boolean(record?.private?.enabled && !record?.privateOpen);
} }
@@ -60,9 +72,21 @@ function listPendingForRequester(socket) {
} }
function getStateForSocket(socket) { 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 { return {
requestableRovers: listClosedPrivateRovers(), requestableRovers: listClosedPrivateRovers(),
pendingRequests: listPendingForRequester(socket), pendingRequests: listPendingForRequester(socket),
grantedRovers,
}; };
} }
@@ -83,6 +107,188 @@ function clearPendingForRover(roverId, reason = 'resolved') {
} }
} }
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;
const requesterKey = buildRequesterKey(socket);
return Boolean(getGrantForRequester(requesterKey, roverId));
}
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 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 getSocketByRequesterKey(requesterKey) {
if (!requesterKey) return null;
if (requesterKey.startsWith('socket:')) {
const socketId = requesterKey.slice('socket:'.length);
return io.sockets.sockets.get(socketId) || null;
}
return null;
}
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) { function createRequest(socket, roverIdRaw) {
if (!socket?.id) { if (!socket?.id) {
throw new Error('Socket required'); throw new Error('Socket required');
@@ -167,6 +373,11 @@ roverManager.managerEvents.on('private', ({ roverId, open } = {}) => {
if (!roverId) return; if (!roverId) return;
if (open) { if (open) {
clearPendingForRover(roverId, 'opened'); clearPendingForRover(roverId, 'opened');
for (const [key, grant] of grants.entries()) {
if (String(grant.roverId) === String(roverId)) {
grants.delete(key);
}
}
} }
requestEvents.emit('change', { requestEvents.emit('change', {
reason: 'private_state', reason: 'private_state',
@@ -179,6 +390,11 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return; if (!roverId) return;
if (action === 'removed') { if (action === 'removed') {
clearPendingForRover(roverId, 'rover_removed'); clearPendingForRover(roverId, 'rover_removed');
for (const [key, grant] of grants.entries()) {
if (String(grant.roverId) === String(roverId)) {
grants.delete(key);
}
}
} }
requestEvents.emit('change', { requestEvents.emit('change', {
reason: 'rover', reason: 'rover',
@@ -188,6 +404,8 @@ roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
}); });
io.on('connection', (socket) => { io.on('connection', (socket) => {
applySocketGrantCache(socket);
function handleRequest({ roverId } = {}, cb = () => {}) { function handleRequest({ roverId } = {}, cb = () => {}) {
try { try {
const { request, isNew } = createRequest(socket, roverId); const { request, isNew } = createRequest(socket, roverId);
@@ -204,11 +422,21 @@ io.on('connection', (socket) => {
socket.on('privateRover:requestAccess', handleRequest); socket.on('privateRover:requestAccess', handleRequest);
socket.on('session:privateRover:requestAccess', handleRequest); socket.on('session:privateRover:requestAccess', handleRequest);
socket.on('session:identify', () => {
applySocketGrantCache(socket);
requestEvents.emit('change', { reason: 'identify', socketId: socket.id });
});
}); });
module.exports = { module.exports = {
DM_APPROVE_EMOJI,
DM_DENY_EMOJI,
requestEvents, requestEvents,
getStateForSocket, getStateForSocket,
createRequest, createRequest,
hasClosedPrivateAccessForSocket,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
}; };
+12 -1
View File
@@ -150,11 +150,19 @@ function shouldApplyPrivateSensorSafety(record) {
return !isLockdownAdmin(activeSocket); return !isLockdownAdmin(activeSocket);
} }
function socketHasClosedPrivateAccess(socket, roverId) {
const list = Array.isArray(socket?.data?.privateClosedAccessRovers)
? socket.data.privateClosedAccessRovers
: [];
return list.some((id) => String(id) === String(roverId));
}
function isRoverVisibleToSocket(record, socket) { function isRoverVisibleToSocket(record, socket) {
if (!record) return false; if (!record) return false;
if (!isPrivateRecord(record)) return true; if (!isPrivateRecord(record)) return true;
if (isPrivateOpen(record)) return true; if (isPrivateOpen(record)) return true;
return isLockdownAdmin(socket); if (isLockdownAdmin(socket)) return true;
return socketHasClosedPrivateAccess(socket, record.id);
} }
function getControlDenialReason(record, socket, options = {}) { function getControlDenialReason(record, socket, options = {}) {
@@ -183,6 +191,9 @@ function getControlDenialReason(record, socket, options = {}) {
} }
if (!isPrivateOpen(record)) { if (!isPrivateOpen(record)) {
if (!isLockdownAdmin(socket)) { if (!isLockdownAdmin(socket)) {
if (socketHasClosedPrivateAccess(socket, record.id)) {
return null;
}
return 'Private rover is closed'; return 'Private rover is closed';
} }
return null; return null;