# WebSocket Protocol Reference

This document describes the relay behavior implemented by `server/index.js` and
the event names defined in `shared/constants.js`.

## Transport

- 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.

## Invite fragments

Current invitations use named URL-fragment fields:

```text
#j2:r=<roomId>&p=<password>&k=<base64url-chat-secret>[&u=<relayUrl>]
```

The fragment is parsed by the website and forwarded to the extension as structured
fields. `k` is client-only and must never appear in a relay payload. Legacy
`#join:<roomId>:<password>[:1:<relayUrl>]` fragments remain valid but provide no chat
secret.

## Connection Handshake

The Socket.IO handshake must include:

- `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`).

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.

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.

## Room Join

### `join_room` (client -> server)

Payload:

```json
{
  "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",
  "clientCapabilities": ["chat-v1"],
  "protocolVersion": "string, max 16"
}
```

Behavior:

- 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.

On success, the joining socket receives `room_data`.
Other room members receive `peer_status` with `status: "joined"`.

### `room_data` (server -> client)

Payload:

```json
{
  "roomId": "string",
  "peers": ["peer state objects"],
  "activeLobby": "object or null",
  "hostPeerId": "string or null",
  "controlMode": "everyone | host-only",
  "controllers": ["peerId"],
  "capabilities": ["host-control", "co-host", "chat", "chat-v1"]
}
```

`room_data` is sent to the joining socket. It is not the general broadcast used
for every later room update.

## Ephemeral encrypted chat

Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
the initial beta's `"chat"` flag during the transition. New clients announce
`"chat-v1"` in optional `join_room.clientCapabilities` (at most the first 16 entries
are inspected; unknown or malformed values are ignored). Old clients omit the field
and continue using the pre-chat protocol unchanged.

The relay sends `chat_message` only to sockets that announced `"chat-v1"`. As a
transition for the first chat beta, a socket that sends a valid v1 ciphertext is
marked capable for the rest of that connection. This prevents old non-chat
extensions from receiving unknown events while preserving the first beta's send
path.

### `chat_message`

Client to relay:

```json
{ "ciphertext": "<unpadded-base64url>" }
```

`ciphertext` contains a 12-byte AES-GCM IV followed by ciphertext and the 16-byte
authentication tag. The relay validates only canonical base64url and byte bounds.
It cannot inspect plaintext.

Relay to every chat-capable current room peer, including the sender:

```json
{
  "id": "<server-generated UUID>",
  "senderId": "<server-stamped peer ID>",
  "timestamp": 1710000000000,
  "ciphertext": "<unpadded-base64url>"
}
```

Client-provided `id`, `senderId`, `timestamp`, or plaintext fields are discarded.
The relay keeps no message collection and `room_data` contains no chat history.
Messages are limited to 10 per socket per 10 seconds in addition to the global event
budget. There are no typing, read-receipt, history, or chat-specific peer-management
events.

## Room Leave

### `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 the socket is disconnected.

## 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:

```json
{
  "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",
  "desynced": "boolean",
  "peerId": "sender peerId",
  "status": "string, max 16",
  "expectedTitle": "string, max 100",
  "title": "string, max 100",
  "actionTimestamp": "number"
}
```

Undefined fields are removed before relay. Raw client payloads are not forwarded.

## Media Control

### `play`, `pause`, `seek`

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:

```json
{
  "controlMode": "everyone | host-only"
}
```

Only the room host may change the mode. Non-host attempts are ignored and the
sender receives the current `control_mode` snapshot.

Mode changes are debounced per room with `CONTROL_MODE_MIN_INTERVAL_MS` (500 ms).

### `set_peer_role` (client -> server)

Payload:

```json
{
  "peerId": "string, max 16",
  "controller": "boolean"
}
```

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.

### `control_mode` (server -> client)

Payload:

```json
{
  "controlMode": "everyone | host-only",
  "hostPeerId": "string or null",
  "controllers": ["peerId"]
}
```

Sent when mode or controller state changes, when host migration changes room
authority, and when unauthorized role/mode attempts need to resync the sender.

## Room List

### `get_rooms` (client -> server)

Payload: none.

No admin token is required for this Socket.IO event.

Limits:

- Counts against the per-socket event limit.
- Also has a 10 second per-socket cooldown.

### `room_list` (server -> client)

Payload:

```json
{
  "rooms": [
    {
      "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

- 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 disconnects the socket.
- 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.

## Capabilities

`room_data.capabilities` advertises server-backed features:

- `host-control`
- `co-host`
- `chat`
- `chat-v1`

Clients should treat a missing or unknown capabilities list as unsupported.
