diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 0da31c9..2aea846 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -58,6 +58,40 @@ try { close(); + // --- Host Control Mode --- + const hrid = 'host-'+Date.now(); + const h1 = await c(), h2 = await c(); // h1 = host (first joiner), h2 = guest + await j(h1, hrid, 'host1'); await j(h2, hrid, 'guest1'); h1._m.length = h2._m.length = 0; + + // Host enables host-only -> both peers get the control_mode broadcast + s(h1,'set_control_mode',{controlMode:'host-only'}); + await w(h1,'control_mode'); await w(h2,'control_mode'); + h1._m.length = h2._m.length = 0; + + // Guest's room-moving event (pause) is dropped -> host must NOT receive it + s(h2,'pause',{currentTime:5}); + let guestPauseDropped = false; try { await w(h1,'pause',600); } catch { guestPauseDropped = true; } + assert.ok(guestPauseDropped, 'guest pause dropped in host-only'); + + // Host's own pause still relays to the guest + s(h1,'pause',{currentTime:7}); await w(h2,'pause'); + h1._m.length = h2._m.length = 0; + + // Guest cannot change the control mode -> no broadcast + s(h2,'set_control_mode',{controlMode:'everyone'}); + let guestSetBlocked = false; try { await w(h1,'control_mode',600); } catch { guestSetBlocked = true; } + assert.ok(guestSetBlocked, 'non-host cannot set control mode'); + + // Host leaves -> room falls back to 'everyone' and reassigns host to the guest + s(h1,'leave_room',{}); + let fb = null; const fbStart = Date.now(); + while (Date.now()-fbStart < 2000 && !fb) { + for (let i=0;isetTimeout(r,50)); + } + assert.ok(fb && fb.controlMode==='everyone' && fb.hostPeerId==='guest1', 'host leave -> fallback everyone + new host'); + close(); + // --- Password room --- const prid = 'pw-'+Date.now(); const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret'); @@ -93,7 +127,7 @@ try { let d=''; res.on('data',c=>d+=c); res.on('end',()=>r([res.statusCode,JSON.parse(d)])); })); assert.equal(st,200); assert.equal(body.status,'online'); - console.log('All 13 WebSocket integration tests passed'); + console.log('All WebSocket integration tests passed (incl. host control mode)'); } catch(e) { console.error('FAILED:', e.message); process.exitCode=1; diff --git a/server/index.js b/server/index.js index a8e78de..c7552d7 100644 --- a/server/index.js +++ b/server/index.js @@ -4,7 +4,7 @@ import { fileURLToPath } from 'url'; import { Server } from 'socket.io'; import crypto from 'crypto'; import dotenv from 'dotenv'; -import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js'; +import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES } from '../shared/constants.js'; import { buildHealthPayload, checkCooldown, @@ -148,6 +148,19 @@ const peerToSocket = new Map(); // peerId -> socketId (Global lookup) const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation) const peerJoinLocks = new Map(); // peerId -> Promise (prevents race on same peerId joins) +// Host Control Mode: events a non-host guest may NOT initiate while a room is in +// 'host-only' mode (they would move/disrupt everyone). Reactions like FORCE_SYNC_ACK, +// EPISODE_READY, PEER_STATUS heartbeats remain allowed for all peers. +const HOST_ONLY_GATED_EVENTS = new Set([ + EVENTS.PLAY, + EVENTS.PAUSE, + EVENTS.SEEK, + EVENTS.FORCE_SYNC_PREPARE, + EVENTS.FORCE_SYNC_EXECUTE, + EVENTS.EPISODE_LOBBY, + EVENTS.EPISODE_LOBBY_CANCEL +]); + function log(type, message, details = '') { const debugLogging = process.env.DEBUG_LOGGING === '1'; const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS' || type === 'ACKDROP'; @@ -201,6 +214,18 @@ function removePeerFromRoom(socketId, roomId, reason) { } } + // 3.6. Host Control Mode: if the host left (and isn't still connected via another + // socket), fall back to 'everyone' so the room never gets stuck locked, and + // reassign host to the earliest remaining peer so the feature stays usable. + // (v1: immediate fallback, no grace period — see host-control-mode docs.) + if (!isPeerStillConnected && room.hostPeerId === peerId && room.peers.size > 0) { + const nextPeerData = room.peerData.values().next().value; + room.hostPeerId = nextPeerData ? nextPeerData.peerId : null; + room.controlMode = CONTROL_MODES.EVERYONE; + io.to(roomId).emit(EVENTS.CONTROL_MODE, { controlMode: room.controlMode, hostPeerId: room.hostPeerId }); + log('ROOM', `Host left room ${roomId.substring(0, 3)}*** — fell back to 'everyone', new host: ${room.hostPeerId}`); + } + // 4. Delete empty room if (room.peers.size === 0) { rooms.delete(roomId); @@ -333,7 +358,10 @@ io.on('connection', (socket) => { peers: new Set(), peerIds: new Map(), peerData: new Map(), - lastActivity: Date.now() + lastActivity: Date.now(), + // Host Control Mode: creator (first joiner) is the host. + hostPeerId: peerId, + controlMode: CONTROL_MODES.EVERYONE }; rooms.set(roomId, room); createdByMe = true; @@ -415,10 +443,12 @@ io.on('connection', (socket) => { peerToSocket.set(peerId, socket.id); socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' }); - socket.emit(EVENTS.ROOM_DATA, { - roomId, + socket.emit(EVENTS.ROOM_DATA, { + roomId, peers: Array.from(room.peers).map(sid => room.peerData.get(sid)), - activeLobby: room.activeLobby || null + activeLobby: room.activeLobby || null, + hostPeerId: room.hostPeerId || null, + controlMode: room.controlMode || CONTROL_MODES.EVERYONE }); log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`); } finally { @@ -458,7 +488,18 @@ io.on('connection', (socket) => { const room = rooms.get(mapping.roomId); if (room) { room.lastActivity = Date.now(); - + + // --- Host Control Mode gate --- + // In 'host-only' mode, drop room-moving events from non-host guests. + // Robust chokepoint: independent of client behavior, kills spam (e.g. + // a guest spamming FORCE_SYNC to drag everyone). Heartbeats/ACKs pass. + if (room.controlMode === CONTROL_MODES.HOST_ONLY && + mapping.peerId !== room.hostPeerId && + HOST_ONLY_GATED_EVENTS.has(eventName)) { + log('ROOM', `Dropped ${eventName} from guest ${mapping.peerId} in host-only room ${mapping.roomId.substring(0, 3)}***`); + return; + } + // --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) --- const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined; const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined; @@ -553,6 +594,34 @@ io.on('connection', (socket) => { } }); + socket.on(EVENTS.SET_CONTROL_MODE, (data) => { + if (!checkEventRate(socket.id)) { + log('SECURITY', `Event rate limit exceeded for socket (SET_CONTROL_MODE): ${socket.id}`); + socket.disconnect(true); + return; + } + if (!data || typeof data !== 'object') return; + const mode = data.controlMode; + if (mode !== CONTROL_MODES.EVERYONE && mode !== CONTROL_MODES.HOST_ONLY) return; + + const mapping = socketToRoom.get(socket.id); + if (!mapping) return; + const room = rooms.get(mapping.roomId); + if (!room) return; + + // Only the host may change the control mode. + if (mapping.peerId !== room.hostPeerId) { + log('AUTH', `Non-host ${mapping.peerId} tried to set control mode in ${mapping.roomId.substring(0, 3)}***`); + return; + } + if (room.controlMode === mode) return; // no-op, ignore (UI debounce backstop) + + room.controlMode = mode; + room.lastActivity = Date.now(); + io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, { controlMode: mode, hostPeerId: room.hostPeerId }); + log('ROOM', `Control mode set to '${mode}' by host in room ${mapping.roomId.substring(0, 3)}***`); + }); + socket.on(EVENTS.EVENT_ACK, (data) => { if (!checkEventRate(socket.id)) { log('SECURITY', `Event rate limit exceeded for socket (ACK): ${socket.id}`);