add identity to socket auth hopefully will fix a lot of probem

This commit is contained in:
legop3
2026-08-03 01:09:21 -04:00
parent 15a60a58e6
commit 208b89fd7f
11 changed files with 250 additions and 240 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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-KnOn2V1J.js"></script>
<script type="module" crossorigin src="/assets/index-gn8wwSnH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
</head>
<body>
@@ -21,7 +21,6 @@ function registerVerificationHooks(deps) {
socket.on('session:identify', (payload = {}, cb = () => {}) => {
try {
const result = identifySocket(socket, payload || {});
socket.data.lastClientIdentifyAt = Date.now();
cb({ success: true, ...result });
} catch (err) {
cb({ error: err.message });
@@ -38,8 +38,6 @@ const {
const { shouldEnforceSingleDriverTab } = require('../../helpers/bandwidthSavings');
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;
const DETERRED_DISCONNECT_DELAY_MS = 250;
@@ -157,9 +155,9 @@ function identifySocket(socket, payload = {}) {
}
/*
Spectator-style pages send identity heartbeats too, but only the driver
surface participates in duplicate-driver enforcement. This flag remains on
the socket because it is connection-specific, not person-specific.
Spectator-style pages carry identity too, but only the driver surface
participates in duplicate-driver enforcement. This flag remains on the
socket because it is connection-specific, not person-specific.
*/
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
@@ -644,16 +642,41 @@ function getVerificationStatus(socket) {
};
}
/*
Namespace middleware completes before Socket.IO emits `connection` to any
service. Attaching canonical identity here guarantees that authorization,
session construction, queues, chat, and media handlers never observe the old
intermediate state where a transport existed but session:identify had not
arrived yet. Missing keys remain valid: the canonical service creates the
first portable key and exposes it through the initial session sync.
*/
io.use((socket, next) => {
try {
socket.data = socket.data || {};
socket.data.connectedAt = Date.now();
identifySocket(socket, socket.handshake?.auth || {});
next();
} catch (err) {
logger.warn('Rejected socket with invalid handshake identity', {
socketId: socket.id,
error: err.message,
});
next(new Error(err.message));
}
});
io.on('connection', (socket) => {
socket.data = socket.data || {};
socket.data.connectedAt = Date.now();
installDeterredSocketGuard(socket);
identifySocket(socket, {});
/*
Duplicate-driver enforcement emits a normal socket event before disconnecting
the losing tab, so it runs after middleware admits the namespace connection.
Identity itself is already present before this point.
*/
enforceSingleDriverSocketPerIdentity(socket);
socket.on('session:identify', (payload = {}, cb = () => {}) => {
try {
const result = identifySocket(socket, payload || {});
socket.data.lastClientIdentifyAt = Date.now();
cb({ success: true, ...result });
} catch (err) {
cb({ error: err.message });
@@ -712,27 +735,6 @@ identityEvents.on('change', ({ userId, reason } = {}) => {
emitChange('identity_change', { userId, reason });
});
setInterval(() => {
const now = Date.now();
io.sockets.sockets.forEach((socket) => {
if (!socket?.id) return;
const role = getRole(socket);
if (role === 'admin' || role === 'lockdown' || role === 'spectator') return;
const connectedAt = Number(socket?.data?.connectedAt || 0);
const lastClientIdentifyAt = Number(socket?.data?.lastClientIdentifyAt || 0);
const referenceTs = lastClientIdentifyAt || connectedAt;
if (!referenceTs) return;
if (now - referenceTs < IDENTITY_TIMEOUT_MS) return;
logger.info('Disconnecting socket due to stale identity heartbeat', {
socketId: socket.id,
role,
ageMs: now - referenceTs,
hadClientIdentify: Boolean(lastClientIdentifyAt),
});
socket.disconnect(true);
});
}, IDENTITY_SWEEP_INTERVAL_MS);
module.exports = {
identifySocket,
getVerificationStatus,
+24 -56
View File
@@ -1,6 +1,6 @@
// Hook: useUserIdentitySync
// Purpose: Keeps local identity state synchronized with server session/auth updates. Scope: Handles identity hydration, change propagation, and persistence touch points.
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect } from 'react';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { useSocket } from '../context/SocketContext.jsx';
import { getBrowserFingerprintId } from '../lib/browserFingerprint.js';
@@ -9,6 +9,7 @@ import { useSettingsNamespace } from '../settings/index.js';
export default function useUserIdentitySync({ identitySurface = 'passive' } = {}) {
const socket = useSocket();
const connected = useSessionSelector((state) => state.connected);
const serverCookieUserId = useSessionSelector((state) => state.session?.identity?.cookieUserId || '');
const { identifySession } = useSessionActions();
const { value: identity, status: identityStatus, save: saveIdentity } = useSettingsNamespace('identity', {
cookieUserId: '',
@@ -19,9 +20,6 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
{ enabled: false },
);
const lastAckSocketRef = useRef(null);
const fingerprintRef = useRef('');
const ready =
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready';
const cookieUserId = (identity?.cookieUserId || '').trim();
@@ -32,23 +30,15 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
const sendIdentify = useCallback(async () => {
if (!ready || !connected || !socket?.id) return;
try {
if (!fingerprintRef.current) {
/*
The portable cookie key is still the cross-device identity signal.
Thumbmark adds a same-device signal that survives cookie clearing, so
both are sent together whenever the heartbeat identifies this socket.
*/
fingerprintRef.current = await getBrowserFingerprintId();
}
/*
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.
Handshake auth establishes identity before connection handlers run.
This event remains the live-update path for settings that change while
the current transport stays connected, so existing callers and server
behavior do not need a second update contract.
*/
const resp = await identifySession({
cookieUserId,
fingerprintId: fingerprintRef.current,
fingerprintId: await getBrowserFingerprintId(),
nickname,
overseerEnabled,
identitySurface: normalizedIdentitySurface,
@@ -57,12 +47,11 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
if (nextKey && nextKey !== cookieUserId) {
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
}
lastAckSocketRef.current = socket.id;
} catch {
/*
The permanent heartbeat below is the retry mechanism. A failed or
half-open request must not create separate timer state that can stop
future identity sends or disappear during a socket transition.
A failed live update must not replace Socket.IO's connection lifecycle
with custom retry state. The next reconnect reads the latest persisted
settings through handshake auth and re-establishes the complete identity.
*/
}
}, [
@@ -78,46 +67,25 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
]);
useEffect(() => {
if (!ready || !connected || !socket?.id) return;
if (lastAckSocketRef.current === socket.id) return;
sendIdentify();
}, [connected, ready, sendIdentify, socket?.id]);
useEffect(() => {
/*
This runs once when the route's settings become ready and again only when
an identity value changes. Reconnects receive the same data from the auth
callback, so there is intentionally no timer, visibility retry, or online
retry here.
*/
if (!ready || !connected || !socket?.id) return;
sendIdentify();
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
useEffect(() => {
const handleOnline = () => {
if (!socket?.connected) return;
sendIdentify();
};
const handleVisibility = () => {
if (typeof document === 'undefined' || document.visibilityState !== 'visible') return;
if (!socket?.connected) return;
sendIdentify();
};
window.addEventListener('online', handleOnline);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.removeEventListener('online', handleOnline);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [sendIdentify, socket?.connected]);
useEffect(() => {
/*
Keep this interval installed whenever persisted identity settings are
ready, including while Socket.IO is reconnecting. Each tick checks the
current connection before sending, so reconnects resume heartbeats without
depending on an acknowledgement or another effect recreating the timer.
A first-time browser has no portable key to send in its handshake, so the
canonical identity service creates one. Session sync is authoritative for
that generated value; persisting it here makes every later handshake carry
the same user key without depending on a session:identify acknowledgement.
*/
if (!ready) return undefined;
const timer = setInterval(() => {
if (!socket?.connected) return;
sendIdentify();
}, 2000);
return () => clearInterval(timer);
}, [ready, sendIdentify, socket]);
const nextKey = String(serverCookieUserId || '').trim();
if (!nextKey || nextKey === cookieUserId) return;
saveIdentity((current) => ({ ...(current || {}), cookieUserId: nextKey }));
}, [cookieUserId, saveIdentity, serverCookieUserId]);
}
+41
View File
@@ -1,6 +1,7 @@
// Socket Library Helper
// Purpose: Builds and exports the browser Socket.IO client with shared defaults. Scope: Centralizes connection URL/options so all modules use consistent socket behavior.
import { io } from 'socket.io-client';
import { getBrowserFingerprintId } from './browserFingerprint.js';
import { loadSettings } from '../settings/persistence.js';
const configured = import.meta.env.VITE_ROVERD_URL?.trim();
@@ -9,8 +10,48 @@ console.info('[socket] connecting to', resolvedUrl);
const settings = loadSettings();
const transportPref = settings?.page?.connectionTransport || 'websocket';
const transports = transportPref === 'polling' ? ['polling'] : ['websocket', 'polling'];
function getIdentitySurface() {
/*
Duplicate-driver protection is connection-specific, so the route must be
known during the handshake rather than waiting for a React route component
to mount. The main driver and dedicated PTZ controller are the two active
control surfaces; every other route is passive identity-wise.
*/
return window.location.pathname === '/' || window.location.pathname === '/ptz'
? 'driver'
: 'passive';
}
async function buildSocketIdentity() {
/*
Socket.IO invokes this auth callback again for every reconnection. Reading
persistence here, instead of reusing the module-level settings snapshot,
ensures that nickname, preferences, imported settings, and a server-issued
identity key are current whenever a new socket is accepted.
*/
const currentSettings = loadSettings();
const fingerprintId = await getBrowserFingerprintId();
return {
cookieUserId: String(currentSettings?.identity?.cookieUserId || '').trim(),
fingerprintId,
nickname: String(currentSettings?.profile?.nickname || '').trim(),
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
identitySurface: getIdentitySurface(),
};
}
export const socket = io(resolvedUrl, {
transports,
timeout: 15000,
/*
The callback form lets the existing asynchronous fingerprint finish before
Socket.IO sends its namespace CONNECT packet. Server connection middleware
can therefore attach the canonical identity before any feature service sees
the socket, eliminating the previous connected-but-unidentified window.
*/
auth: (callback) => {
buildSocketIdentity().then(callback);
},
});
socket.on('connect_error', (err) => console.error('connect_error', err.code, err.message, err.data));