From 5e3a6a2851a831205e2fedf4ffb3006821049778 Mon Sep 17 00:00:00 2001 From: legop3 Date: Mon, 1 Jun 2026 20:52:58 -0400 Subject: [PATCH] time expiration private requests --- .../privateRoverAccessRequestService/core.js | 49 +++++++++++++++++-- .../privateRoverAccessRequestService/hooks.js | 20 +++++++- .../privateRoverAccessRequestService/index.js | 2 + .../privateRoverAccessRequestService/state.js | 2 + .../services/roverManager/privateAccess.js | 13 ++++- .../services/roverManager/roverLifecycle.js | 5 +- 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/server/src/services/privateRoverAccessRequestService/core.js b/server/src/services/privateRoverAccessRequestService/core.js index e902f4f6..72f49821 100644 --- a/server/src/services/privateRoverAccessRequestService/core.js +++ b/server/src/services/privateRoverAccessRequestService/core.js @@ -12,6 +12,7 @@ const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver'); const { normalizeCookieUserId } = require('../identityService'); const { REQUEST_COOLDOWN_MS, + GRANT_TTL_MS, requestEvents, pendingRequests, pendingByRequesterRover, @@ -28,6 +29,30 @@ const { 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 = []; @@ -47,6 +72,7 @@ function listPendingForRequester(socket) { } function listGrantedRoversForRequester(requesterKey) { + pruneExpiredGrants(); const key = normalizeRequesterKey(requesterKey); if (!key) return []; const roverIds = []; @@ -70,7 +96,14 @@ function refreshAllSocketGrantCaches() { function getGrantForRequester(requesterKey, roverId) { if (!requesterKey || !roverId) return null; - return grants.get(buildGrantKey(requesterKey, roverId)) || 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) { @@ -79,11 +112,17 @@ function hasClosedPrivateAccessForSocket(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, requestId: grant.requestId || null }); + 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 { @@ -181,11 +220,13 @@ function approveRequest(requestId, 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: Date.now(), + grantedAt, + expiresAt: grantedAt + GRANT_TTL_MS, grantedBy: request.resolvedBy, }); @@ -217,6 +258,7 @@ function approveRequest(requestId, actorDiscordId = null) { requesterKey: request.requesterKey, resolvedBy: request.resolvedBy, resolvedAt: request.resolvedAt, + grantExpiresAt: grantedAt + GRANT_TTL_MS, assignedSocketId, }, }); @@ -317,5 +359,6 @@ module.exports = { denyRequest, applySocketGrantCache, refreshAllSocketGrantCaches, + pruneExpiredGrantsAndRefresh, clearPendingForRover, }; diff --git a/server/src/services/privateRoverAccessRequestService/hooks.js b/server/src/services/privateRoverAccessRequestService/hooks.js index 6773e4de..8bda1470 100644 --- a/server/src/services/privateRoverAccessRequestService/hooks.js +++ b/server/src/services/privateRoverAccessRequestService/hooks.js @@ -5,8 +5,26 @@ const io = require('../../globals/io'); const roverManager = require('../roverManager'); const { requestEvents, grants } = require('./state'); +const GRANT_PRUNE_INTERVAL_MS = 60 * 1000; +let grantPruneTimer = null; + function registerPrivateRoverAccessHooks(deps) { - const { applySocketGrantCache, refreshAllSocketGrantCaches, createRequest, clearPendingForRover } = deps; + const { + applySocketGrantCache, + refreshAllSocketGrantCaches, + pruneExpiredGrantsAndRefresh, + createRequest, + clearPendingForRover, + } = deps; + + if (!grantPruneTimer) { + grantPruneTimer = setInterval(() => { + pruneExpiredGrantsAndRefresh('grant_expired'); + }, GRANT_PRUNE_INTERVAL_MS); + if (typeof grantPruneTimer.unref === 'function') { + grantPruneTimer.unref(); + } + } roverManager.managerEvents.on('private', ({ roverId, open } = {}) => { if (!roverId) return; diff --git a/server/src/services/privateRoverAccessRequestService/index.js b/server/src/services/privateRoverAccessRequestService/index.js index 0953b630..2fbbad6e 100644 --- a/server/src/services/privateRoverAccessRequestService/index.js +++ b/server/src/services/privateRoverAccessRequestService/index.js @@ -12,6 +12,7 @@ const { denyRequest, applySocketGrantCache, refreshAllSocketGrantCaches, + pruneExpiredGrantsAndRefresh, clearPendingForRover, } = require('./core'); const { registerPrivateRoverAccessHooks } = require('./hooks'); @@ -19,6 +20,7 @@ const { registerPrivateRoverAccessHooks } = require('./hooks'); registerPrivateRoverAccessHooks({ applySocketGrantCache, refreshAllSocketGrantCaches, + pruneExpiredGrantsAndRefresh, createRequest, clearPendingForRover, }); diff --git a/server/src/services/privateRoverAccessRequestService/state.js b/server/src/services/privateRoverAccessRequestService/state.js index 095c5df9..a2396013 100644 --- a/server/src/services/privateRoverAccessRequestService/state.js +++ b/server/src/services/privateRoverAccessRequestService/state.js @@ -4,6 +4,7 @@ const EventEmitter = require('events'); const REQUEST_COOLDOWN_MS = 15 * 1000; +const GRANT_TTL_MS = 60 * 60 * 1000; const DM_APPROVE_EMOJI = '✅'; const DM_DENY_EMOJI = '❌'; @@ -16,6 +17,7 @@ const grants = new Map(); module.exports = { REQUEST_COOLDOWN_MS, + GRANT_TTL_MS, DM_APPROVE_EMOJI, DM_DENY_EMOJI, requestEvents, diff --git a/server/src/services/roverManager/privateAccess.js b/server/src/services/roverManager/privateAccess.js index 54ac1618..129324de 100644 --- a/server/src/services/roverManager/privateAccess.js +++ b/server/src/services/roverManager/privateAccess.js @@ -70,11 +70,16 @@ function createPrivateAccessPolicy(deps) { if (!isPrivateRecord(record)) return true; if (isPrivateOpen(record)) return true; if (isLockdownAdmin(socket)) return true; + if (socket?.id && record.drivers?.has(socket.id)) return true; return socketHasClosedPrivateAccess(socket, record.id); } function getControlDenialReason(record, socket, options = {}) { - const { allowUser = false, allowClosedPrivateGrantInLockdown = false } = options; + const { + allowUser = false, + allowClosedPrivateGrantInLockdown = false, + allowClosedPrivateCurrentDriver = false, + } = options; if (!record) return 'Unknown rover'; if (!allowUser && !isAdmin(socket)) return 'Only admins can request control'; if (record.locked && !isAdmin(socket)) return 'Rover locked'; @@ -89,6 +94,11 @@ function createPrivateAccessPolicy(deps) { isPrivateRecord(record) && !isPrivateOpen(record) && socketHasClosedPrivateAccess(socket, record.id) + ) && + !( + allowClosedPrivateCurrentDriver && + isPrivateRecord(record) && + !isPrivateOpen(record) ) ) { return 'Server in lockdown'; @@ -98,6 +108,7 @@ function createPrivateAccessPolicy(deps) { if (!isPrivateRecord(record)) return null; if (!isPrivateOpen(record)) { if (!isLockdownAdmin(socket)) { + if (allowClosedPrivateCurrentDriver) return null; if (socketHasClosedPrivateAccess(socket, record.id)) return null; return 'Private rover is closed'; } diff --git a/server/src/services/roverManager/roverLifecycle.js b/server/src/services/roverManager/roverLifecycle.js index 8565b28e..fae43afb 100644 --- a/server/src/services/roverManager/roverLifecycle.js +++ b/server/src/services/roverManager/roverLifecycle.js @@ -85,14 +85,15 @@ function createRoverLifecycle(deps) { function canDrive(roverId, socket) { const record = rovers.get(roverId); if (!record) return false; + if (isAdmin(socket)) return true; + if (!socket || !isDriver(roverId, socket)) return false; const denied = getControlDenialReason(record, socket, { allowUser: true, allowClosedPrivateGrantInLockdown: true, + allowClosedPrivateCurrentDriver: true, }); if (denied) return false; const _mode = getMode(); - if (isAdmin(socket)) return true; - if (!socket || !isDriver(roverId, socket)) return false; return turnService.canDrive(roverId, socket); }