diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index a2f7828..a3606ef 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -186,6 +186,46 @@ try { close(); resetConnectionRate(); + // --- Co-Host: owner promotes a guest to controller (can drive); demote re-gates --- + const crid = 'cohost-'+Date.now(); + const co1 = await c(), co2 = await c(), co3 = await c(); // owner / to-promote / stays guest + await j(co1, crid, 'owner'); await j(co2, crid, 'cohost'); await j(co3, crid, 'guestx'); + co1._m.length = co2._m.length = co3._m.length = 0; + s(co1,'set_control_mode',{controlMode:'host-only'}); + await w(co1,'control_mode'); await w(co2,'control_mode'); await w(co3,'control_mode'); + co1._m.length = co2._m.length = co3._m.length = 0; + // before promotion the co-host is gated + s(co2,'pause',{currentTime:1}); + let coGatedBefore=false; try { await w(co1,'pause',500); } catch { coGatedBefore=true; } + assert.ok(coGatedBefore, 'co-host gated before promotion'); + // owner promotes co-host → controllers broadcast includes owner + cohost + s(co1,'set_peer_role',{peerId:'cohost',controller:true}); + let promo=null; const pps=Date.now(); + while(Date.now()-pps<800 && !promo){ for(let i=0;isetTimeout(r,30)); } + assert.ok(promo && Array.isArray(promo.controllers) && promo.controllers.includes('cohost') && promo.controllers.includes('owner'), + 'promotion broadcasts controllers (owner + cohost)'); + co1._m.length = co2._m.length = co3._m.length = 0; + // promoted co-host can now drive; a plain guest still cannot + s(co2,'pause',{currentTime:2}); await w(co1,'pause'); + s(co3,'play',{currentTime:3}); + let plainGuestGated=false; try { await w(co1,'play',500); } catch { plainGuestGated=true; } + assert.ok(plainGuestGated, 'plain guest still gated after a co-host is promoted'); + co1._m.length = co2._m.length = co3._m.length = 0; + // a non-owner (the co-host) cannot promote anyone + s(co2,'set_peer_role',{peerId:'guestx',controller:true}); + let nonOwnerBlocked=false; try { await w(co3,'control_mode',500); } catch { nonOwnerBlocked=true; } + assert.ok(nonOwnerBlocked, 'non-owner cannot promote (no room broadcast)'); + co1._m.length = co2._m.length = co3._m.length = 0; + // owner demotes the co-host → gated again + s(co1,'set_peer_role',{peerId:'cohost',controller:false}); + await w(co2,'control_mode'); + co1._m.length = co2._m.length = co3._m.length = 0; + s(co2,'seek',{currentTime:4}); + let coGatedAfter=false; try { await w(co1,'seek',500); } catch { coGatedAfter=true; } + assert.ok(coGatedAfter, 'demoted co-host is gated again'); + close(); + resetConnectionRate(); + // --- Password room --- const prid = 'pw-'+Date.now(); const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret'); diff --git a/server/index.js b/server/index.js index 8641d62..42cbc10 100644 --- a/server/index.js +++ b/server/index.js @@ -168,13 +168,27 @@ const HOST_ONLY_GATED_EVENTS = new Set([ // Features this relay supports, advertised to clients in ROOM_DATA so they can // enable matching UI/behavior only when the server actually backs it. Append a // flag here when a new server-gated feature ships (e.g. co-host promotion). -const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL]; +const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST]; // M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly // toggling host from thrashing every guest's UI (locked/unlocked/locked...) and // from generating one broadcast per toggle across all peers. const CONTROL_MODE_MIN_INTERVAL_MS = 500; +// Co-Host: max peers (incl. the owner) allowed to drive a 'host-only' room. Bounds +// the controller set + the CONTROL_MODE payload. Beyond this, just use 'everyone'. +const MAX_CONTROLLERS = 10; + +// Canonical control-mode/role snapshot sent to clients. controllers always includes +// the owner (hostPeerId). Built in one place so every emit stays consistent. +function controlModePayload(room) { + return { + controlMode: room.controlMode, + hostPeerId: room.hostPeerId, + controllers: room.controllers ? Array.from(room.controllers) : (room.hostPeerId ? [room.hostPeerId] : []) + }; +} + function log(type, message, details = '') { const debugLogging = process.env.DEBUG_LOGGING === '1'; const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS' || type === 'ACKDROP'; @@ -238,12 +252,24 @@ function removePeerFromRoom(socketId, roomId, reason) { // 'disconnect' the kicked old socket fires. Demoting there would silently // unlock the room on every host network blip. const peerRejoining = peerJoinLocks.has(peerId); - if (!peerRejoining && !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}`); + const peerGone = !isPeerStillConnected && !peerRejoining; + if (peerGone && room.controllers && room.peers.size > 0) { + const wasController = room.controllers.has(peerId); + room.controllers.delete(peerId); + if (room.hostPeerId === peerId) { + // Owner left → reassign owner + fall back to 'everyone' so the room is + // never stuck locked, and reset the controller set to just the new owner. + const nextPeerData = room.peerData.values().next().value; + room.hostPeerId = nextPeerData ? nextPeerData.peerId : null; + room.controlMode = CONTROL_MODES.EVERYONE; + room.controllers = new Set(room.hostPeerId ? [room.hostPeerId] : []); + io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); + log('ROOM', `Owner left room ${roomId.substring(0, 3)}*** — fell back to 'everyone', new owner: ${room.hostPeerId}`); + } else if (wasController) { + // A co-host left → keep the mode, just broadcast the updated controller list. + io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); + log('ROOM', `Controller ${peerId} left room ${roomId.substring(0, 3)}***`); + } } // 4. Delete empty room @@ -379,10 +405,12 @@ io.on('connection', (socket) => { peerIds: new Map(), peerData: new Map(), lastActivity: Date.now(), - // Host Control Mode: creator (first joiner) is the host. + // Host Control Mode: creator (first joiner) is the host/owner. hostPeerId: peerId, controlMode: CONTROL_MODES.EVERYONE, - lastControlModeChangeAt: 0 // M-4: per-room debounce for control-mode toggles + lastControlModeChangeAt: 0, // M-4: per-room debounce for control-mode toggles + // Co-Host: peers allowed to drive in 'host-only'. Always includes the owner. + controllers: new Set([peerId]) }; rooms.set(roomId, room); createdByMe = true; @@ -470,6 +498,7 @@ io.on('connection', (socket) => { activeLobby: room.activeLobby || null, hostPeerId: room.hostPeerId || null, controlMode: room.controlMode || CONTROL_MODES.EVERYONE, + controllers: room.controllers ? Array.from(room.controllers) : [], capabilities: SERVER_CAPABILITIES }); log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`); @@ -512,11 +541,11 @@ io.on('connection', (socket) => { 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. + // In 'host-only' mode, drop room-moving events from anyone who is not + // a controller (the owner + any promoted co-hosts). Robust chokepoint: + // independent of client behavior, kills spam. Heartbeats/ACKs pass. if (room.controlMode === CONTROL_MODES.HOST_ONLY && - mapping.peerId !== room.hostPeerId && + !(room.controllers && room.controllers.has(mapping.peerId)) && HOST_ONLY_GATED_EVENTS.has(eventName)) { log('ROOM', `Dropped ${eventName} from guest ${mapping.peerId} in host-only room ${mapping.roomId.substring(0, 3)}***`); return; @@ -633,12 +662,12 @@ io.on('connection', (socket) => { const room = rooms.get(mapping.roomId); if (!room) return; - // Only the host may change the control mode. + // Only the owner 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)}***`); // Re-sync the sender to the actual room state so any optimistic UI // (e.g. popup toggle) reverts — covers stale-local-amHost races (H-5). - socket.emit(EVENTS.CONTROL_MODE, { controlMode: room.controlMode, hostPeerId: room.hostPeerId }); + socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room)); return; } if (room.controlMode === mode) return; // no-op, ignore (UI debounce backstop) @@ -650,17 +679,62 @@ io.on('connection', (socket) => { if (now - room.lastControlModeChangeAt < CONTROL_MODE_MIN_INTERVAL_MS) { log('ROOM', `Control mode toggle debounced in ${mapping.roomId.substring(0, 3)}***`); // Re-sync the sender so any optimistic UI matches the actual (unchanged) state. - socket.emit(EVENTS.CONTROL_MODE, { controlMode: room.controlMode, hostPeerId: room.hostPeerId }); + socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room)); return; } room.controlMode = mode; room.lastControlModeChangeAt = now; room.lastActivity = now; - io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, { controlMode: mode, hostPeerId: room.hostPeerId }); + io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); log('ROOM', `Control mode set to '${mode}' by host in room ${mapping.roomId.substring(0, 3)}***`); }); + socket.on(EVENTS.SET_PEER_ROLE, (data) => { + if (!checkEventRate(socket.id)) { + log('SECURITY', `Event rate limit exceeded for socket (SET_PEER_ROLE): ${socket.id}`); + socket.disconnect(true); + return; + } + if (!data || typeof data !== 'object') return; + const targetPeerId = typeof data.peerId === 'string' ? data.peerId.substring(0, 16) : null; + const makeController = data.controller === true; + if (!targetPeerId) return; + + const mapping = socketToRoom.get(socket.id); + if (!mapping) return; + const room = rooms.get(mapping.roomId); + if (!room || !room.controllers) return; + + // Only the owner may promote/demote controllers. + if (mapping.peerId !== room.hostPeerId) { + log('AUTH', `Non-owner ${mapping.peerId} tried to set peer role in ${mapping.roomId.substring(0, 3)}***`); + socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room)); + return; + } + // The owner is always a controller and cannot be demoted. + if (targetPeerId === room.hostPeerId) return; + // Target must be a peer currently in the room. + const targetPresent = Array.from(room.peerData.values()).some(d => d.peerId === targetPeerId); + if (!targetPresent) return; + + if (makeController) { + if (room.controllers.has(targetPeerId)) return; // no-op + if (room.controllers.size >= MAX_CONTROLLERS) { + log('ROOM', `Controller cap (${MAX_CONTROLLERS}) reached in ${mapping.roomId.substring(0, 3)}***`); + socket.emit(EVENTS.CONTROL_MODE, controlModePayload(room)); // re-sync owner UI + return; + } + room.controllers.add(targetPeerId); + } else { + if (!room.controllers.has(targetPeerId)) return; // no-op + room.controllers.delete(targetPeerId); + } + room.lastActivity = Date.now(); + io.to(mapping.roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room)); + log('ROOM', `Peer ${targetPeerId} ${makeController ? 'promoted to' : 'demoted from'} controller in ${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}`); diff --git a/shared/constants.js b/shared/constants.js index 2281098..dd0137d 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -35,7 +35,8 @@ export const EVENTS = { // Host Control Mode SET_CONTROL_MODE: "set_control_mode", // Client -> Server: host sets room control mode ('everyone' | 'host-only') - CONTROL_MODE: "control_mode", // Server -> Client: room control mode changed { controlMode, hostPeerId } + CONTROL_MODE: "control_mode", // Server -> Client: control mode/role changed { controlMode, hostPeerId, controllers } + SET_PEER_ROLE: "set_peer_role", // Client -> Server: owner promotes/demotes a peer { peerId, controller } // Media Control PLAY: "play", @@ -75,8 +76,8 @@ export const CONTROL_MODES = { // relays (unknown/absent list → feature treated as unavailable) and old clients // simply ignore the field. Add a flag here as each server-gated feature lands. export const CAPABILITIES = { - HOST_CONTROL: 'host-control' - // Future: CO_HOST: 'co-host' // owner promotes guests to additional controllers + HOST_CONTROL: 'host-control', + CO_HOST: 'co-host' // owner promotes guests to additional controllers }; export const HEARTBEAT_INTERVAL = 15000; // 15s