From 9fe83f89b2c61f425d6f036df319d0bfdd2cfd3d Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:04:49 +0200 Subject: [PATCH] fix: make protocol docs match implementation --- docs/PROTOCOL.md | 689 ++++++++++++++++---------------------- docs/host-control-mode.md | 331 ++++++------------ 2 files changed, 380 insertions(+), 640 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 33bbaf5..4fcf274 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1,495 +1,370 @@ -# WebSocket Protocol Specification +# WebSocket Protocol Reference -## Overview +This document describes the relay behavior implemented by `server/index.js` and +the event names defined in `shared/constants.js`. -KoalaSync uses WebSocket for real-time communication between clients and the relay server. The protocol is based on Socket.IO events with a custom message format. +## Transport -- **Transport**: WebSocket only (no long-polling fallback) -- **Server URL**: `wss://syncserver.koalastuff.net` (official) or custom server -- **Protocol Version**: `1.0.0` (current), `MIN_VERSION` not enforced (backward compatible) -- **Authentication**: Optional server token for custom relays +- The relay uses Socket.IO v4 events over WebSocket. +- Long-polling is disabled (`transports: ['websocket']`, `allowUpgrades: false`). +- Messages are Socket.IO event packets whose payload is an event name plus an + object payload. +- The relay caps incoming Socket.IO message size at 4 KB. -## Protocol Basics +## Connection Handshake -### Message Format +The Socket.IO handshake must include: -All messages follow the Socket.IO v4 protocol format: -- Engine.IO packet type `42` (WEBSOCKET_MESSAGE) -- JSON-encoded array: `[eventName, payload]` +- `token`: must match `OFFICIAL_SERVER_TOKEN`. +- `version`: optional app version. If present, it must be a valid semver-like + string and not older than `MIN_VERSION` (default `1.0.0`). -Example: `42["play",{"currentTime":123.45}]` +If the token is invalid, the relay emits `error` and disconnects the socket. +If `version` is invalid or too old, the relay emits `error` and disconnects the +socket. -### Event Flow +After the socket is connected, `join_room` must include `protocolVersion`. +It must equal `PROTOCOL_VERSION` exactly. A mismatch emits `error` and rejects +the join attempt; it does not currently disconnect the socket. -``` -Client → Server: JOIN_ROOM, PLAY, PAUSE, SEEK, etc. -Server → Client: ROOM_DATA, PEER_STATUS, CONTROL_MODE, etc. -Client ↔ Server: PING/PONG (latency measurement) -``` +## Room Join -## Events Reference +### `join_room` (client -> server) -### Connection & Room Management +Payload: -#### JOIN_ROOM (Client → Server) - -Join an existing room or create a new one. - -**Payload:** ```json { - "roomId": "string (alphanum + hyphens, 64 max)", - "peerId": "string (16 chars max)", - "username": "string (30 chars max)", - "password": "string (128 chars max, optional)", - "tabTitle": "string (100 chars max, optional)", - "mediaTitle": "string (100 chars max, optional)", - "protocolVersion": "string (16 chars max)" + "roomId": "string, sanitized to [A-Za-z0-9-], max 64", + "peerId": "string, max 16", + "username": "string, max 30", + "password": "string, max 128, optional", + "tabTitle": "string, max 100, optional", + "mediaTitle": "string, max 100, optional", + "protocolVersion": "string, max 16" } ``` -**Server Response:** -- Success: `ROOM_DATA` event with room state -- Error: `ERROR` event with message +Behavior: -**Rate Limit:** 10 attempts per IP per minute +- Creates the room if it does not exist and capacity allows it. +- The first peer becomes `hostPeerId`. +- Rooms may have an optional password hash. +- Joining with a duplicate `peerId` disconnects the previous socket for that peer. +- Joining the same room with the same socket and peer is ignored as a no-op. +- Switching rooms removes the socket from the old room first. -**Edge Cases:** -- Room ID sanitization (alphanumeric + hyphens only) -- Peer ID deduplication (kicks old socket for same peerId) -- Protocol version mismatch → disconnect +On success, the joining socket receives `room_data`. +Other room members receive `peer_status` with `status: "joined"`. -#### LEAVE_ROOM (Client → Server) +### `room_data` (server -> client) -Leave the current room. +Payload: -**Payload:** None - -**Server Action:** -- Removes peer from room -- Broadcasts `PEER_STATUS` with `status: 'left'` to remaining peers -- If room becomes empty, deletes room - -**Rate Limit:** 10 requests per socket per minute - -**Edge Cases:** -- Host leaving → controlMode falls back to 'everyone' -- Last peer leaving → room deletion - -#### ROOM_DATA (Server → Client) - -Current room state snapshot sent on join or when room changes. - -**Payload:** ```json { "roomId": "string", - "peers": [ - { - "peerId": "string", - "username": "string", - "tabTitle": "string", - "mediaTitle": "string", - "playbackState": "playing" | "paused", - "currentTime": "number", - "volume": "number", - "muted": "boolean", - "lastHeartbeat": "timestamp" - } - ], - "activeLobby": "object | null", - "hostPeerId": "string | null", - "controlMode": "everyone" | "host-only", - "controllers": ["string"], - "capabilities": ["string"] + "peers": ["peer state objects"], + "activeLobby": "object or null", + "hostPeerId": "string or null", + "controlMode": "everyone | host-only", + "controllers": ["peerId"], + "capabilities": ["host-control", "co-host"] } ``` -**Triggered by:** -- Successful JOIN_ROOM -- Room state changes (peer join/leave, mode change) +`room_data` is sent to the joining socket. It is not the general broadcast used +for every later room update. -#### ERROR (Server → Client) +## Room Leave -Error notification. +### `leave_room` (client -> server) + +Payload: none. + +Behavior: + +- Rate-limited to 10 events per socket per minute. +- If the socket is mapped to a room, the relay removes it from that room. +- Remaining room members receive `peer_status` with `status: "left"` when the + peer is no longer represented by another socket. +- Empty rooms are deleted. +- If the host leaves and peers remain, the relay assigns the next peer as host, + falls back to `controlMode: "everyone"`, resets controllers to the new host, + and broadcasts `control_mode`. + +Exceeding the `leave_room` limit is logged and ignored; the socket is not +disconnected for this specific limit. + +## Relayed Room Events + +The relay accepts and sanitizes these events, then emits the same event to other +peers in the room: + +- `play` +- `pause` +- `seek` +- `peer_status` +- `force_sync_prepare` +- `force_sync_ack` +- `force_sync_execute` +- `episode_lobby` +- `episode_ready` +- `episode_lobby_cancel` + +Relayed payload fields are sanitized and may include: -**Payload:** ```json { - "message": "string" -} -``` - -**Common Errors:** -- "Incompatible protocol version" -- "Invalid password" -- "Room full" -- "Server capacity reached" - -### Media Control Events - -#### PLAY (Client → Server → Client) - -Resume playback for all peers in the room. - -**Payload:** -```json -{ - "currentTime": "number (seconds)" -} -``` - -**Broadcast:** Relayed to all peers in room - -**Gated in host-only mode:** Only controllers can initiate - -#### PAUSE (Client → Server → Client) - -Pause playback for all peers in the room. - -**Payload:** -```json -{ - "currentTime": "number (seconds)" -} -``` - -**Broadcast:** Relayed to all peers in room - -**Gated in host-only mode:** Only controllers can initiate - -#### SEEK (Client → Server → Client) - -Seek to specific time in the media. - -**Payload:** -```json -{ - "targetTime": "number (seconds)" -} -``` - -**Broadcast:** Relayed to all peers in room - -**Gated in host-only mode:** Only controllers can initiate - -**Edge Cases:** -- Minimum seek delta (0.5s) to avoid spam -- Coalesced with subsequent seeks within 200ms - -### Sync Coordination - -#### PEER_STATUS (Client → Server → Client) - -Heartbeat with current peer state. - -**Payload:** -```json -{ - "peerId": "string", - "username": "string", - "tabTitle": "string", - "mediaTitle": "string", - "playbackState": "playing" | "paused", - "currentTime": "number", - "volume": "number", + "senderId": "peerId of sender", + "seq": "number", + "currentTime": "number 0..86400 or null", + "targetTime": "number 0..86400", + "playbackState": "playing | paused", + "username": "string, max 30", + "tabTitle": "string, max 100 or null", + "mediaTitle": "string, max 100 or null", + "volume": "number 0..1", "muted": "boolean", - "status": "joined" | "left" | "heartbeat" + "desynced": "boolean", + "peerId": "sender peerId", + "status": "string, max 16", + "expectedTitle": "string, max 100", + "title": "string, max 100", + "actionTimestamp": "number" } ``` -**Frequency:** Every 15 seconds (HEARTBEAT_INTERVAL) +Undefined fields are removed before relay. Raw client payloads are not forwarded. -**Purpose:** -- Keep-alive -- State synchronization -- Peer presence tracking +## Media Control -#### FORCE_SYNC_PREPARE (Client → Server → Client) +### `play`, `pause`, `seek` -Initiate force sync sequence. +These are room-moving actions. In `host-only` mode, the relay drops them unless +the sender is a controller. + +Common payload fields: + +- `currentTime` for `play`/`pause`. +- `targetTime` for `seek`. +- `seq` and `actionTimestamp` when the extension needs stale-command or ACK + handling. + +The content script applies additional client-side filtering for noisy native +player events before it sends these events. + +## Peer Status + +### `peer_status` + +Used for heartbeats and peer state updates. The extension sends it every +`HEARTBEAT_INTERVAL` while syncing is active. + +Typical fields: + +- `peerId` +- `username` +- `tabTitle` +- `mediaTitle` +- `playbackState` +- `currentTime` +- `volume` +- `muted` +- `desynced` +- `status` + +The relay stores sanitized peer state and relays the sanitized update to other +peers. + +## Force Sync + +Force sync coordination is implemented primarily in the extension. The relay +sanitizes and relays the events. + +### `force_sync_prepare` + +Payload includes `targetTime`. The initiator waits for ACKs or for +`FORCE_SYNC_TIMEOUT` before sending `force_sync_execute`. + +In `host-only` mode, only controllers may initiate it. + +### `force_sync_ack` + +The extension sends ACKs with peer identity and sequence data. The relay relays +them with the same sanitized relay envelope as other room events, including +`senderId`. + +### `force_sync_execute` + +Payload includes `targetTime`. In `host-only` mode, only controllers may send it. +The relay also allows a matching initiator's execute event after that initiator +started the prepare step, even if their controller state changed before execute. + +## Episode Lobby + +Episode lobby coordination is implemented primarily in the extension. The relay +tracks enough state to include `activeLobby` in `room_data` for later joiners. + +### `episode_lobby` + +Payload uses `expectedTitle`. The relay creates `activeLobby` when this field is +present and no lobby is already active. + +In `host-only` mode, only controllers may initiate it. + +### `episode_ready` + +Payload may include `title`. The relay adds the sender to the active lobby's +ready list when a lobby exists. + +### `episode_lobby_cancel` + +Clears the active lobby and is relayed to peers. In `host-only` mode, only +controllers may initiate it. + +## Host Control Mode + +### `set_control_mode` (client -> server) + +Payload: -**Payload:** ```json { - "targetTime": "number (seconds)" + "controlMode": "everyone | host-only" } ``` -**Sequence:** -1. Initiator sends PREPARE -2. Server broadcasts PREPARE to all peers -3. Peers respond with FORCE_SYNC_ACK -4. Initiator sends FORCE_SYNC_EXECUTE after timeout or when all ACKs received -5. Server broadcasts EXECUTE to all peers +Only the room host may change the mode. Non-host attempts are ignored and the +sender receives the current `control_mode` snapshot. -**Timeout:** 8.5 seconds (FORCE_SYNC_TIMEOUT) +Mode changes are debounced per room with `CONTROL_MODE_MIN_INTERVAL_MS` (500 ms). -**Gated in host-only mode:** Only controllers can initiate +### `set_peer_role` (client -> server) -#### FORCE_SYNC_ACK (Client → Server) +Payload: -Acknowledgment of force sync preparation. - -**Payload:** None - -**Purpose:** Let initiator know peer is ready - -#### FORCE_SYNC_EXECUTE (Client → Server → Client) - -Execute the force sync (seek + play). - -**Payload:** ```json { - "targetTime": "number (seconds)" -} -``` - -**Broadcast:** Relayed to all peers - -**Effect:** All peers seek to targetTime and play - -### Episode Auto-Sync - -#### EPISODE_LOBBY (Client → Server → Client) - -Wait for all peers to load the next episode. - -**Payload:** -```json -{ - "episodeId": "string", - "targetTime": "number (seconds, usually 0)" -} -``` - -**Sequence:** -1. Initiator sends EPISODE_LOBBY -2. Server broadcasts to all peers -3. Peers load episode and send EPISODE_READY when paused at targetTime -4. When all peers ready or timeout, initiator sends EPISODE_LOBBY_CANCEL or room resumes - -**Timeout:** 60 seconds (EPISODE_LOBBY_TIMEOUT) - -**Gated in host-only mode:** Only controllers can initiate - -#### EPISODE_READY (Client → Server) - -Peer is ready for episode sync. - -**Payload:** -```json -{ - "episodeId": "string" -} -``` - -#### EPISODE_LOBBY_CANCEL (Client → Server → Client) - -Cancel active episode lobby and resume playback. - -**Payload:** None - -### Host Control Mode - -#### SET_CONTROL_MODE (Client → Server) - -Host changes room control mode. - -**Payload:** -```json -{ - "controlMode": "everyone" | "host-only" -} -``` - -**Authorization:** Only host (room.hostPeerId) can send - -**Server Action:** -- Validates sender is host -- Updates room.controlMode -- Broadcasts CONTROL_MODE to all peers - -**Rate Limit:** 500ms debounce per room - -#### CONTROL_MODE (Server → Client) - -Control mode or role changed. - -**Payload:** -```json -{ - "controlMode": "everyone" | "host-only", - "hostPeerId": "string", - "controllers": ["string"] -} -``` - -**Triggered by:** -- SET_CONTROL_MODE from host -- Host leaving room (fallback to 'everyone') -- Controller promotion/demotion - -#### SET_PEER_ROLE (Client → Server) - -Owner promotes/demotes a peer to/from controller. - -**Payload:** -```json -{ - "peerId": "string", + "peerId": "string, max 16", "controller": "boolean" } ``` -**Authorization:** Only owner (room.hostPeerId) can send +Only the room host may promote or demote controllers. The host cannot demote +themself. Role changes use the same 500 ms per-room debounce as mode changes. -**Server Action:** -- Updates room.controllers set -- Broadcasts CONTROL_MODE to all peers +### `control_mode` (server -> client) -**Rate Limit:** 500ms debounce per room +Payload: -### Ping / Latency Measurement - -#### PING (Client → Server → Client) - -Measure round-trip time. - -**Payload:** ```json { - "t": "timestamp (Date.now())", - "target": "peerId (optional, empty = server echo)" + "controlMode": "everyone | host-only", + "hostPeerId": "string or null", + "controllers": ["peerId"] } ``` -**Server Response:** PONG with same timestamp +Sent when mode or controller state changes, when host migration changes room +authority, and when unauthorized role/mode attempts need to resync the sender. -**Frequency:** Every 30 seconds +## Room List -#### PONG (Server → Client) +### `get_rooms` (client -> server) -Response to PING. +Payload: none. -**Payload:** -```json -{ - "t": "timestamp (from PING)" -} -``` +No admin token is required for this Socket.IO event. -### Administrative Events +Limits: -#### GET_ROOMS (Client → Server) +- Counts against the per-socket event limit. +- Also has a 10 second per-socket cooldown. -Request list of active rooms (admin only). +### `room_list` (server -> client) -**Payload:** None +Payload: -**Authorization:** Requires admin token - -**Rate Limit:** 10 requests per IP per minute - -**Server Response:** ROOM_LIST event - -#### ROOM_LIST (Server → Client) - -List of active rooms. - -**Payload:** ```json { "rooms": [ { - "roomId": "string", - "peerCount": "number", - "createdAt": "timestamp", - "lastActivity": "timestamp" + "id": "room id", + "peerCount": 2, + "hasPassword": false } ] } ``` +## Ping, Pong, and ACK + +### `ping` + +Payload: + +```json +{ + "t": 1234567890, + "target": "peerId, optional" +} +``` + +If `target` is omitted, the relay responds to the sender with `pong`. +If `target` is another peer in the same room, the relay sends `ping` to that peer +with `{ "t": ..., "sender": "senderPeerId" }`. + +### `pong` + +Payload: + +```json +{ + "t": 1234567890, + "target": "peerId, optional" +} +``` + +If `target` is a peer in the same room, the relay sends `pong` to that peer with +`{ "t": ... }`. + +### `event_ack` + +Client payload: + +```json +{ + "targetId": "peerId", + "actionTimestamp": 1234567890 +} +``` + +If sender and target are still in the same room, the relay emits: + +```json +{ + "senderId": "sender peerId", + "actionTimestamp": 1234567890 +} +``` + ## Rate Limits -### Connection Rate Limits +- Connections: 10 per IP per minute; excess connections are disconnected. +- Relayed/events: 50 per socket per 10 seconds; excess disconnects the socket. +- `get_rooms`: 10 second cooldown per socket plus the event limit. +- `leave_room`: 10 per socket per minute; excess is ignored. +- Invalid room passwords: tracked per IP and room. Five recent failures block + more password attempts for that room until the failure window ages out. +- HTTP health and admin-metrics endpoints have their own rate limits outside this + Socket.IO protocol. -- **Connections:** 10 per IP per minute -- **Authentication Attempts:** 5 per IP per room per minute -- **Event Rate:** 50 events per 10 seconds per socket +## Capabilities -### Specific Event Rate Limits +`room_data.capabilities` advertises server-backed features: -- **Health Checks:** 10 per IP per minute -- **Admin Metrics Auth:** 5 per IP per minute -- **Room List:** 10 per IP per minute (cooldown) -- **LEAVE_ROOM:** 10 per socket per minute +- `host-control` +- `co-host` -### Debounce Intervals - -- **Control Mode Changes:** 500ms per room -- **Role Changes:** 500ms per room - -## Server Capabilities - -The server advertises supported features in ROOM_DATA.capabilities: - -- `host-control`: Host Control Mode feature -- `co-host`: Co-host/promotion feature - -Clients should check capabilities before using features. - -## Protocol Versioning - -- **PROTOCOL_VERSION**: "1.0.0" (current) -- **Backward Compatibility**: Older clients can connect but may not support all features -- **Version Check**: Server validates protocolVersion on JOIN_ROOM - -## Edge Cases & Error Handling - -### Connection Issues - -- **Protocol Mismatch**: Client disconnected with ERROR message -- **Rate Limit Exceeded**: Socket disconnected immediately -- **Server Restart**: Clients auto-reconnect with exponential backoff - -### Room Management - -- **Host Leaves**: controlMode falls back to 'everyone' -- **Room Full**: Error response on JOIN_ROOM -- **Duplicate PeerId**: Old socket kicked on new join - -### Media Sync - -- **Seek Spam**: Coalesced within 200ms window -- **Force Sync Timeout**: Auto-executes after 8.5s if not all peers ACK -- **Episode Lobby Timeout**: Auto-cancels after 60s - -### Host Control Mode - -- **Non-host Attempts**: Event ignored, CONTROL_MODE sent to sync UI -- **Host-only Gating**: play/pause/seek/forceSync/episodeLobby blocked for guests -- **Desync Handling**: Guests can opt-out via dialog, resync button available - -## Security Considerations - -- **No Authentication**: Trust model is client-enforced (no tokens) -- **Rate Limiting**: Prevents abuse and DoS -- **Input Sanitization**: All strings truncated and validated -- **No Sensitive Data**: Only public room/peer metadata transmitted - -## Testing Recommendations - -1. **Connection Flow**: Join, leave, reconnect, room creation -2. **Media Sync**: Play/pause/seek propagation, force sync sequence -3. **Host Control**: Mode toggle, guest blocking, desync/resync -4. **Rate Limits**: Verify limits enforced, errors logged -5. **Edge Cases**: Host leave, network blips, seek spam - -## Changelog - -- **1.0.0**: Initial protocol specification (2026) -- Documented all events from shared/constants.js -- Added rate limit details and edge cases +Clients should treat a missing or unknown capabilities list as unsupported. diff --git a/docs/host-control-mode.md b/docs/host-control-mode.md index 49bb6e3..2396c82 100644 --- a/docs/host-control-mode.md +++ b/docs/host-control-mode.md @@ -1,286 +1,151 @@ # Host Control Mode -## Overview - -Host Control Mode allows the room host to control playback for all participants. Guests can attempt to play/pause/seek, but their actions are not broadcast to the room unless they explicitly choose to desync and watch on their own. +This document describes the Host Control Mode implementation in the relay and +extension. It only covers behavior implemented in the current codebase. ## Modes -### `everyone` (Default) -- Anyone in the room can control playback -- All play/pause/seek actions are broadcast to all peers -- Traditional KoalaSync behavior +### `everyone` + +- Default room mode. +- Any peer may send room-moving playback events. ### `host-only` -- Only the host (and promoted controllers) can control the room -- Guest actions are blocked and they are snapped back to the host's position -- Guests can choose to desync and watch independently -## Protocol Events +- Only controllers may send room-moving playback events. +- The host is always a controller. +- The host can promote additional peers to controllers. +- Guests can keep watching locally in solo/desynced mode, but their local actions + still do not drive the shared room. -### `SET_CONTROL_MODE` (Client → Server) +Room-moving events are: -Sent by the host to change the room's control mode. +- `play` +- `pause` +- `seek` +- `force_sync_prepare` +- `force_sync_execute` +- `episode_lobby` +- `episode_lobby_cancel` -**Payload:** -```json +Heartbeats, force-sync ACKs, episode-ready events, ping/pong, and command ACKs +remain allowed for guests. + +## Server State + +Rooms store Host Control state in memory: + +```js { - "controlMode": "everyone" | "host-only" + hostPeerId, + controlMode, + controllers, + lastControlModeChangeAt, + lastRoleChangeAt, + forceSyncInitiator } ``` -**Authorization:** Only the host (room.hostPeerId) can send this event. +`controllers` is a `Set` on the server and is serialized as an array in +`room_data` and `control_mode`. -**Server Response:** Broadcasts `CONTROL_MODE` to all peers in the room. +State is not persisted across relay restarts. -### `CONTROL_MODE` (Server → Client) +## Authority Rules -Broadcast when the control mode changes or when a peer joins a room. +### Changing mode -**Payload:** -```json -{ - "controlMode": "everyone" | "host-only", - "hostPeerId": "string", - "controllers": ["string"] -} -``` +Only `hostPeerId` may send `set_control_mode`. -**Triggered by:** -- Host changing the control mode -- Host leaving the room (fallback to 'everyone') -- Controller promotion/demotion +Valid values: -### `SET_PEER_ROLE` (Client → Server) +- `everyone` +- `host-only` -Sent by the host to promote or demote a peer to/from controller status. +Invalid values are ignored. Non-host attempts are ignored and the sender receives +the current `control_mode` snapshot so optimistic UI can revert. -**Payload:** -```json -{ - "peerId": "string", - "controller": "boolean" -} -``` +Mode changes are debounced per room for 500 ms. -**Authorization:** Only the host (room.hostPeerId) can send this event. +### Promoting and demoting controllers -**Server Response:** Broadcasts `CONTROL_MODE` to all peers in the room. +Only `hostPeerId` may send `set_peer_role`. -## Server-Side Implementation +The host cannot demote themself. No-op role changes are ignored. Role changes are +debounced per room for 500 ms. -### Room Object +### Host leaving -```javascript -room = { - hostPeerId: "peer-id", // First peer to join the room - controlMode: "everyone", // 'everyone' | 'host-only' - controllers: ["peer-id"], // Peers allowed to control in host-only mode - lastControlModeChangeAt: 0, // Timestamp for rate limiting - lastRoleChangeAt: 0 // Timestamp for rate limiting -} -``` +When the host leaves and peers remain: -### Key Behaviors +- the next peer becomes `hostPeerId`; +- `controlMode` falls back to `everyone`; +- `controllers` is reset to the new host; +- the relay broadcasts `control_mode`. -1. **Host Migration:** When the host leaves, the room falls back to 'everyone' mode -2. **Controller Set:** Always includes the host, plus any promoted peers -3. **Rate Limiting:** Control mode changes are debounced to 500ms per room +When a non-host controller leaves, the relay removes that peer from +`controllers` and broadcasts `control_mode`. -## Client-Side Implementation +## Enforcement -### State Management (background.js) +The implementation has two enforcement points: -```javascript -let controlMode = CONTROL_MODES.EVERYONE; -let hostPeerId = null; -let controllers = []; +- The extension background script blocks local guest attempts in `host-only` and + sends `HOST_BLOCKED` to the content script for local UX. +- The relay drops room-moving events from non-controllers in `host-only`, so old + or modified clients cannot drive the room. -function amHost() { return hostPeerId === peerId; } -function amController() { return amHost() || controllers.includes(peerId); } -``` +The relay is the authority for room-wide effects. -### Event Gating +## Guest UX -**Sender-side gate (background.js):** -- Blocks `PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_*`, `EPISODE_LOBBY_*` from non-controllers -- Sends `HOST_BLOCKED` message to content script instead +When a guest action is blocked locally, the content script classifies it: -**Receiver-side gate (background.js):** -- Ignores gated events from non-controllers in host-only mode -- Prevents malicious or outdated clients from bypassing controls +- deliberate user action: show the host-control dialog; +- likely involuntary player action (buffering, tab refocus, no recent gesture): + silently snap back when safe; +- live/DVR stream: degrade without forcing snap-back. -### Snap-Back Logic (content.js) +The dialog offers: -When a guest's action is blocked: -1. Show dialog: "Stay in sync" (default) or "Watch on my own" -2. If "Stay in sync": snap player back to host's position -3. If "Watch on my own": set `hcmDesynced = true`, show resync button +- stay in sync: resync to the host; +- watch on my own: enter solo/desynced mode. -### Host Sync Target Calculation +In solo/desynced mode: -```javascript -function getHostSyncTarget() { - const host = currentRoom.peers.find(p => p.peerId === hostPeerId); - if (!host) return null; +- the guest can control their local video; +- host room commands are ignored locally, except force-sync preparation is ACKed + so the host's flow can continue; +- the guest can resync to the host. - let targetTime = host.currentTime; +The extension reports `desynced` in peer status so the host UI can show that a +guest is watching solo. - // Extrapolate if host is playing - if (host.playbackState === 'playing' && host.lastHeartbeat) { - const elapsedSec = (Date.now() - host.lastHeartbeat) / 1000; - if (elapsedSec > 0 && elapsedSec <= 30) { // Max 30s extrapolation - targetTime += elapsedSec; - } - } +## Force Sync Edge Case - return { playbackState: host.playbackState, targetTime }; -} -``` +The relay tracks `forceSyncInitiator` after a controller sends +`force_sync_prepare`. -## Edge Cases +This allows that same initiator's `force_sync_execute` through even if their +controller role changes before execute arrives. Without this, a demotion in the +middle of a force-sync flow could leave peers waiting after prepare. -### 1. Host Leaves Room -- **Behavior:** Room falls back to 'everyone' mode -- **Implementation:** `removePeerFromRoom` checks if leaving peer is host -- **Broadcast:** `CONTROL_MODE` event sent to all remaining peers +The relay clears `forceSyncInitiator` after execute or when the initiator leaves. -### 2. Controller Promoted During Force Sync -- **Problem:** Controller's `FORCE_SYNC_EXECUTE` would be blocked -- **Solution:** `forceSyncInitiator` field tracks who started the sync -- **Behavior:** Initiator's EXECUTE is allowed even if demoted mid-sync +## Capabilities -### 3. Guest Seek Spam -- **Problem:** Guest could spam seeks to disrupt -- **Solution:** Cooldown period after snap-back (1000ms) -- **Behavior:** Subsequent actions within cooldown are ignored +The relay advertises Host Control support in `room_data.capabilities`: -### 4. Host State Stale -- **Problem:** Host heartbeat hasn't arrived recently -- **Solution:** Max 30s extrapolation, then use last known position -- **Behavior:** Prevents overshooting if host paused but heartbeat delayed +- `host-control` +- `co-host` -### 5. Old Client Without Feature -- **Problem:** Client doesn't know about host-only mode -- **Solution:** Receiver-side gate in all clients -- **Behavior:** Modern clients ignore events from non-controllers +The extension hides or disables matching UI when capabilities are missing. -## User Experience +## Related Events -### Host UI -- Toggle: "Only I can control" (visible only to host) -- Role badges: "Host", "Controller", or "Guest" -- Guest notice: "The host controls playback" (when host-only active) -- Controller management: Promote/demote peers in participant list +See [PROTOCOL.md](PROTOCOL.md) for payloads and relay behavior for: -### Guest UI -- **Blocked Action:** Dialog with choices: - - "Stay in sync" (default, auto-closes after 8s) - - "Watch on my own" (enters desync mode) -- **Desync Mode:** "Solo" badge with "Resync" button -- **Resync:** Returns to synchronized state with host - -### Co-Host UI -- Same controls as host (can play/pause/seek/force-sync) -- "Controller" badge instead of "Host" -- Cannot promote/demote other controllers - -## Testing Checklist - -### Basic Functionality -1. ✅ Host can toggle between 'everyone' and 'host-only' -2. ✅ Guests see "host controls playback" notice in host-only -3. ✅ Guest play/pause/seek blocked in host-only -4. ✅ Guest snapped back to host position when blocked -5. ✅ Guest can choose "watch on my own" and desync -6. ✅ Desynced guest can resync with host - -### Edge Cases -7. ✅ Host leaving room falls back to 'everyone' -8. ✅ Multiple rapid guest actions don't cause loop -9. ✅ Guest can desync during buffering/throttling -10. ✅ Old client events ignored by modern clients -11. ✅ Force sync initiated by controller works -12. ✅ Episode lobby initiated by controller works - -### UI/UX -13. ✅ Host toggle only visible to host -14. ✅ Role badges show correct roles -15. ✅ Desync dialog auto-closes after timeout -16. ✅ Resync button visible when desynced -17. ✅ Controller promotion UI visible to host - -## Architecture Decisions - -### Why Double Gating? -**Sender-side + Receiver-side gates provide defense in depth:** -- Sender-side: Clean UX with dialog for guests -- Receiver-side: Protects against old/buggy/malicious clients -- Server-side: Central enforcement point (optional but recommended) - -### Why Extrapolation? -**Linear extrapolation from last heartbeat provides ~±1s accuracy:** -- Better than freezing at last known position -- Handles minor network jitter gracefully -- Capped at 30s to avoid overshooting on stale data - -### Why Cooldown? -**600-1000ms cooldown prevents control loops:** -- Guest pause → snap-back → pause → snap-back... -- Allows legitimate desync after cooldown expires -- Doesn't block deliberate user actions - -## Migration Path - -### From 'everyone' to 'host-only' -1. Host toggles mode to 'host-only' -2. Server validates host authority -3. Server broadcasts `CONTROL_MODE` to all peers -4. All clients update local state -5. Existing playback continues uninterrupted -6. Future guest actions are gated - -### From 'host-only' to 'everyone' -1. Host toggles mode to 'everyone' -2. Server validates host authority -3. Server broadcasts `CONTROL_MODE` to all peers -4. All clients update local state -5. All peers regain control immediately - -## Performance Considerations - -- **Memory:** Minimal overhead (~100 bytes per room for HCM state) -- **CPU:** Snap-back calculation is O(1), negligible impact -- **Network:** No additional traffic beyond existing heartbeats -- **Storage:** Control mode persisted in room state, no additional storage - -## Security Considerations - -- **No Authentication:** Trust model is client-enforced (no tokens) -- **No Encryption:** Feature doesn't handle sensitive data -- **Rate Limiting:** Control mode changes debounced to prevent spam -- **Validation:** All mode changes validated server-side - -## Future Enhancements - -### Planned -- Host transfer button (manual host migration) -- Temporary controller promotion (time-limited) -- Guest request control (notification to host) - -### Considered but Rejected -- Password-protected host transfer (too complex) -- Vote-based control mode (social complexity) -- Per-action permissions (UI overload) - -## Changelog - -- **2.5.0**: Initial implementation (July 2026) -- **2.5.1**: Added co-host support and controller promotion -- **2.5.2**: Improved desync UI with resync button -- **2.6.0**: Added server-side rate limiting for mode changes - -## See Also - -- [Protocol Specification](PROTOCOL.md) — Technical event details -- [Architecture](ARCHITECTURE.md) — System overview +- `set_control_mode` +- `control_mode` +- `set_peer_role` +- host-only gated relay events