mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
feat(co-host): server-side multi-controller (owner promotes peers to drive)
Generalizes host-only from a single host to a controller SET. The owner (creator)
can promote/demote peers; in host-only mode the relay gate allows any controller,
not just the owner. Single-host is now the special case controllers={owner}, so
existing behavior/tests are unchanged.
- shared: CAPABILITIES.CO_HOST enabled; new SET_PEER_ROLE event; CONTROL_MODE
payload carries controllers.
- server: room.controllers Set (always incl. owner), advertised in ROOM_DATA;
gate checks controllers.has(peerId); SET_PEER_ROLE handler (owner-only, target
must be present, MAX_CONTROLLERS=10 cap, re-sync on reject); controlModePayload
helper for consistent emits; owner-leave resets controllers + falls back to
everyone, co-host-leave broadcasts the updated list (dedup/reconnect guarded).
- WS test: promote→drive, plain guest still gated, non-owner can't promote,
demote→re-gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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;i<co2._m.length;i++){const r=co2._m[i];if(r.startsWith('42')){const[e,dd]=JSON.parse(r.substring(2));if(e==='control_mode'){co2._m.splice(i,1);promo=dd;break;}}} await new Promise(r=>setTimeout(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');
|
||||
|
||||
+91
-17
@@ -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}`);
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user