duplicate tab protection

This commit is contained in:
legop3
2026-06-21 16:21:25 -04:00
parent e95eeeb526
commit 9cf71e8df3
10 changed files with 316 additions and 146 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DYZ0CDIw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--DqrzMoo.css">
<script type="module" crossorigin src="/assets/index-Cp0GiTXH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CflZqP0e.css">
</head>
<body>
<div id="root"></div>
@@ -30,6 +30,7 @@ const { registerVerificationHooks } = require('./hooks');
const verificationEvents = new EventEmitter();
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
const IDENTITY_SWEEP_INTERVAL_MS = 15 * 1000;
const DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS = 250;
function emitChange(reason, payload = {}) {
verificationEvents.emit('change', { reason, ...payload });
@@ -119,8 +120,17 @@ function identifySocket(socket, payload = {}) {
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.
*/
data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
const verification = reevaluateSocketVerification(socket);
const deterrence = reevaluateSocketDeterrence(socket);
enforceSingleUnverifiedSocketPerIdentity(socket);
emitChange('identify', { socketId: socket.id });
return {
cookieUserId: data.cookieUserId,
@@ -132,6 +142,92 @@ function identifySocket(socket, payload = {}) {
};
}
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.',
...payload,
});
setTimeout(() => {
if (!socket.disconnected) {
socket.disconnect(true);
}
}, DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS);
}
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
const currentKey = normalizeCookieUserId(currentSocket?.data?.cookieUserId);
if (
!currentSocket?.id ||
!currentKey ||
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);
});
if (duplicates.length === 0) {
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', {
socketId: currentSocket.id,
retainedSocketId: verifiedDuplicate.id,
cookieUserId: currentKey,
});
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', {
socketId: duplicate.id,
retainedSocketId: currentSocket.id,
cookieUserId: currentKey,
});
emitDuplicateIdentityAndDisconnect(duplicate, {
retainedSocketId: currentSocket.id,
});
});
}
function getVerificationStateForSocket(socket) {
return verificationFlow.getVerificationStateForSocket(socket, requestFlow.getPendingRequestForIdentity);
}
+3 -1
View File
@@ -45,6 +45,7 @@ import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
import OdometerPanel from './components/OdometerPanel/index.jsx';
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
import { trackAnalyticsEvent } from './analytics/index.js';
@@ -304,7 +305,7 @@ function App() {
function AppWithProviders({ layout, isDesktop, fullscreen }) {
useDefaultNickname();
useUserIdentitySync();
useUserIdentitySync({ identitySurface: 'driver' });
useTelemetryVisualPolicy({ mobile: !isDesktop });
const {
visible: fullscreenVisible,
@@ -425,6 +426,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
{renderedLayout}
</main>
<AlertFeed />
<DuplicateIdentityOverlay />
<RewardRunOverlay />
<TurnAlertListener />
<ModeGateOverlay />
@@ -0,0 +1,36 @@
// Duplicate Identity Overlay
// Purpose: Blocks an older non-verified driver tab after the server detects the same identity in another driver tab.
// Scope: Presents a driver-page-only message while leaving enforcement owned by the server.
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function DuplicateIdentityOverlay() {
const duplicateIdentityBlock = useSessionSelector((state) => state.duplicateIdentityBlock);
if (!duplicateIdentityBlock) {
return null;
}
const message =
typeof duplicateIdentityBlock?.message === 'string' && duplicateIdentityBlock.message.trim()
? duplicateIdentityBlock.message.trim()
: 'This driver session is already active in another tab.';
return (
<div className="pointer-events-auto fixed inset-0 z-[2000] flex items-center justify-center bg-black px-0.5 py-0.5 text-slate-100">
<section className="surface w-full max-w-md space-y-0.5 text-center shadow-2xl">
<div className="space-y-0.5">
<h1 className="text-lg font-semibold text-white">Another driver tab is already open</h1>
<p className="text-sm text-slate-300">{message}</p>
</div>
{/* <div className="surface-muted space-y-0.5">
<p className="text-sm text-slate-100">
Continue from the newest tab or close this one.
</p>
<p className="text-xs text-slate-400">
Verified users are allowed to keep multiple tabs open.
</p>
</div> */}
</section>
</div>
);
}
+25 -2
View File
@@ -18,6 +18,7 @@ const INITIAL_STATE = {
replayJobs: {},
latestReplay: null,
latestRequestedReplay: null,
duplicateIdentityBlock: null,
};
const SessionContext = createContext(null);
@@ -283,6 +284,26 @@ export function SessionProvider({ children }) {
const memory = payload && typeof payload === 'object' ? payload : null;
setState((prev) => ({ ...prev, overseerMemory: memory }));
}
function handleDuplicateIdentity(payload = {}) {
/*
The server sends this event immediately before closing an older duplicate
tab. Persisting the reason in app state lets the UI replace the whole
page with a clear blocking screen instead of leaving users on a normal
disconnected interface that looks recoverable.
*/
const message =
typeof payload?.message === 'string' && payload.message.trim()
? payload.message.trim()
: 'This driver session is already active in another tab.';
setState((prev) => ({
...prev,
duplicateIdentityBlock: {
...payload,
message,
receivedAt: Date.now(),
},
}));
}
socket.on('session:sync', handleSession);
socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry);
@@ -295,6 +316,7 @@ export function SessionProvider({ children }) {
socket.on('replay:status', handleReplayStatus);
socket.on('replay:ready', handleReplayReady);
socket.on('replay:failed', handleReplayFailed);
socket.on('session:duplicateIdentity', handleDuplicateIdentity);
return () => {
socket.off('session:sync', handleSession);
socket.off('log:init', handleLogInit);
@@ -308,14 +330,15 @@ export function SessionProvider({ children }) {
socket.off('replay:status', handleReplayStatus);
socket.off('replay:ready', handleReplayReady);
socket.off('replay:failed', handleReplayFailed);
socket.off('session:duplicateIdentity', handleDuplicateIdentity);
};
}, [setState, socket]);
const actions = useMemo(
() => ({
login: (username, password) => emitWithAck('auth:login', { username, password }),
identifySession: ({ cookieUserId, nickname, overseerEnabled } = {}) =>
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled }),
identifySession: ({ cookieUserId, nickname, overseerEnabled, identitySurface } = {}) =>
emitWithAck('session:identify', { cookieUserId, nickname, overseerEnabled, identitySurface }),
setRole: (role) => emitWithAck('session:setRole', { role }),
requestControl: (roverId, options = {}) =>
emitWithAck('session:requestControl', { roverId, ...options }),
+16 -3
View File
@@ -5,7 +5,7 @@ import { useSessionActions, useSessionSelector } from '../context/SessionContext
import { useSocket } from '../context/SocketContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
export default function useUserIdentitySync() {
export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) {
const socket = useSocket();
const connected = useSessionSelector((state) => state.connected);
const { identifySession } = useSessionActions();
@@ -27,6 +27,7 @@ export default function useUserIdentitySync() {
const cookieUserId = (identity?.cookieUserId || '').trim();
const nickname = (profile?.nickname || '').trim();
const overseerEnabled = Boolean(overseerPreference?.enabled);
const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive';
const clearRetry = useCallback(() => {
if (retryTimerRef.current) {
@@ -39,7 +40,18 @@ export default function useUserIdentitySync() {
if (!ready || !connected || !socket?.id || inFlightRef.current) return;
inFlightRef.current = true;
try {
const resp = await identifySession({ cookieUserId, nickname, overseerEnabled });
/*
Every route shares the same persisted identity key, but only the main
driver page should trigger duplicate-tab prevention. Sending the surface
with the heartbeat lets the server make that decision before spectator
pages finish their role switch.
*/
const resp = await identifySession({
cookieUserId,
nickname,
overseerEnabled,
identitySurface: normalizedIdentitySurface,
});
const nextKey = (resp?.cookieUserId || '').trim();
if (nextKey && nextKey !== cookieUserId) {
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
@@ -59,6 +71,7 @@ export default function useUserIdentitySync() {
connected,
cookieUserId,
identifySession,
normalizedIdentitySurface,
nickname,
overseerEnabled,
ready,
@@ -75,7 +88,7 @@ export default function useUserIdentitySync() {
useEffect(() => {
if (!ready || !connected || !socket?.id) return;
sendIdentify();
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, sendIdentify]);
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
useEffect(() => {
const handleOnline = () => {