mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-27 20:39:10 +00:00
Merge pull request #17 from Shik3i/feat/protocol-rate-limit-vitest
feat: Protocol spec, rate limiting, vitest, and documentation
This commit is contained in:
@@ -131,6 +131,7 @@ gh attestation verify dist/koalasync-chrome.zip \
|
||||
- **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)**: Our community standards and expectations.
|
||||
- **[HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md)**: Step-by-step walkthrough of the complete user flow.
|
||||
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)**: Deep-dive into the two-phase sync and heartbeat logic.
|
||||
- **[PROTOCOL.md](docs/PROTOCOL.md)**: WebSocket protocol specification and event reference.
|
||||
- **[ROADMAP.md](docs/ROADMAP.md)**: Planned features, backlog, and rejected ideas.
|
||||
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
|
||||
- **[Caddyfile.example](examples/Caddyfile.example)**: Production Caddy configuration for website and relay.
|
||||
|
||||
@@ -8,6 +8,8 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
### Added
|
||||
- **Extension: Privacy title controls** - Advanced users can now disable sending browser tab titles separately from media titles. Media titles can still be sent in full, reduced to detected episode identifiers such as `S01E04`, or hidden entirely. Defaults remain full titles for backwards compatibility.
|
||||
- **Relay: Cleaner restart handling** — Connected clients are now disconnected explicitly during relay shutdown so reconnects recover more predictably.
|
||||
- **Relay: Stronger abuse protection** — Rapid room-leave spam is now rate-limited.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# 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.
|
||||
|
||||
## 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",
|
||||
"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"]
|
||||
}
|
||||
```
|
||||
|
||||
`room_data` is sent to the joining socket. It is not the general broadcast used
|
||||
for every later room update.
|
||||
|
||||
## 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`
|
||||
|
||||
Clients should treat a missing or unknown capabilities list as unsupported.
|
||||
@@ -1,118 +0,0 @@
|
||||
# Co-Host (Multi-Controller) — Implementation Plan
|
||||
|
||||
Branch base: `feature/host-control-mode` (builds directly on it).
|
||||
Goal: let the room owner grant **playback control to several peers** (co-hosts), not
|
||||
just one — e.g. 4 of N people in a room may drive play/pause/seek, the rest are guests.
|
||||
|
||||
This is the second server-gated feature the `capabilities` hook was designed for
|
||||
(`CAPABILITIES.CO_HOST = 'co-host'`, already stubbed in `shared/constants.js`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Roles
|
||||
|
||||
| Role | Can drive (play/pause/seek/force-sync/episode-lobby) | Can promote/demote + toggle mode |
|
||||
|------|------|------|
|
||||
| **Owner** (room creator, = today's "host") | yes (always a controller) | **yes** |
|
||||
| **Controller** (co-host) | yes (in `host-only` mode) | no |
|
||||
| **Guest** | only in `everyone` mode | no |
|
||||
|
||||
The single-host feature is just the special case `controllers = { owner }`.
|
||||
|
||||
## 2. Data model
|
||||
|
||||
### Server (`room` object)
|
||||
- `ownerPeerId` — the creator / manager. Keep `hostPeerId` as an **alias** (= ownerPeerId)
|
||||
so older clients keep working.
|
||||
- `controllers: Set<peerId>` — peers allowed to drive. **Always contains ownerPeerId.**
|
||||
- `controlMode: 'everyone' | 'host-only'` — unchanged wire values (`'host-only'` now means
|
||||
"restricted to controllers", not "single host").
|
||||
- `MAX_CONTROLLERS` cap (e.g. 10) to bound the set + payload.
|
||||
|
||||
### Shared constants
|
||||
- `CAPABILITIES.CO_HOST = 'co-host'` (un-stub it) → add to `SERVER_CAPABILITIES`.
|
||||
- New events:
|
||||
- `SET_PEER_ROLE` (client→server): `{ peerId, controller: boolean }` — owner promotes/demotes.
|
||||
- Extend `CONTROL_MODE` (server→client) payload: `{ controlMode, ownerPeerId, hostPeerId, controllers: [peerId...] }`.
|
||||
- `ROOM_DATA` gains `ownerPeerId` + `controllers`.
|
||||
|
||||
## 3. Gate generalization (the core change)
|
||||
|
||||
Today the gate compares against a single `hostPeerId`. Generalize to set membership:
|
||||
|
||||
- **Server relay gate** (`server/index.js`): `controlMode === 'host-only' && !room.controllers.has(mapping.peerId)` → drop. (Was `mapping.peerId !== room.hostPeerId`.)
|
||||
- **Background gates** (sender + receiver): replace `amHost()` / `senderId !== hostPeerId`
|
||||
with controller-set membership: `controllers.includes(myPeerId)` / `senderId ∈ controllers`.
|
||||
- **Helpers:** split `amHost()` into `amOwner()` (manage rights) and `amController()`
|
||||
(drive rights). The desync/snap-back path keys on `!amController()` instead of `!amHost()`.
|
||||
|
||||
`SET_PEER_ROLE` handler (server): validate sender is owner, target is a current peer in the
|
||||
room, enforce `MAX_CONTROLLERS`, always keep owner in the set, then broadcast `CONTROL_MODE`
|
||||
with the new `controllers`.
|
||||
|
||||
## 4. Client + UI
|
||||
|
||||
- **Owner** sees the peer list with a per-peer **"Controller" toggle** (promote/demote) plus
|
||||
the existing mode toggle.
|
||||
- **Controllers** see a "Controller" badge and are NOT locked out of the remote-control buttons.
|
||||
- **Guests** see "Guest" + the host-only notice (unchanged).
|
||||
- The promote UI + co-host badges render only when the relay advertises the `co-host`
|
||||
capability (feature detection, same pattern as `hostControlSupported`).
|
||||
- i18n: new keys (`ROLE_CONTROLLER`, `BTN_PROMOTE`, `BTN_DEMOTE`, …) across all locales.
|
||||
|
||||
## 5. Backwards compatibility
|
||||
|
||||
- **New client + old server** (host-control only, no `co-host` capability): no co-host UI;
|
||||
behaves as today's single-host. ✓
|
||||
- **Old client + new server**: ignores `controllers` / `SET_PEER_ROLE`. An old client that
|
||||
the owner promotes still **gates itself** (its sender-gate only knows `!amHost`), so it
|
||||
can't drive — it degrades to a guest. Co-host requires a client that understands
|
||||
`controllers`. Document this; not a crash. ✓
|
||||
- No `PROTOCOL_VERSION` bump needed — purely additive, same as host-control.
|
||||
|
||||
## 6. Edge cases
|
||||
- **Controller leaves** → `removePeerFromRoom` also does `room.controllers.delete(peerId)`.
|
||||
- **Owner leaves** → fallback: promote the earliest remaining **controller** to owner (prefer
|
||||
a controller over a random peer); if none, earliest peer; keep the rest of the set. Reuse
|
||||
the `peerJoinLocks` guard so a reconnect/second-tab doesn't demote (same fix as host).
|
||||
- **Promote a peer not in the room** → server rejects (target must be a live peer).
|
||||
- **Promote beyond `MAX_CONTROLLERS`** → server rejects, re-syncs the owner's UI.
|
||||
- **`everyone` mode** → the `controllers` set is still maintained (so flipping to `host-only`
|
||||
keeps the chosen co-hosts), it just isn't enforced while in `everyone`.
|
||||
- **peerId spoofing** → unchanged accepted limitation (see `docs/KNOWN_LIMITATIONS.md`);
|
||||
co-host doesn't widen it materially (still bounded to a temporary room).
|
||||
|
||||
## 7. Scale: the "4 of 510 people" part — read this
|
||||
|
||||
The role change above is moderate. **Putting 510 people in one room is a separate, larger
|
||||
problem** and should be its own track:
|
||||
|
||||
- `MAX_PEERS_PER_ROOM` is **25** today. 510 needs a large raise + load testing.
|
||||
- **The real bottleneck at scale is heartbeat fan-out, not control events.** Every peer
|
||||
heartbeats and the relay broadcasts each to all peers → O(N²) per interval. At 510 that's
|
||||
~510×509 / 15s ≈ **17k msg/s just for heartbeats** — the scaling wall.
|
||||
- **Co-host actually *helps* the control-event side:** in `host-only` mode only the few
|
||||
controllers emit play/pause/seek, so event *sources* drop from N to K (e.g. 4). Restricting
|
||||
who can drive is synergistic with big rooms.
|
||||
- Large rooms therefore need (independent of co-host):
|
||||
- **Heartbeat fan-out reduction** — e.g. only relay controller/owner heartbeats to everyone,
|
||||
relay guest heartbeats only to the owner/controllers (for the UI), or server-side
|
||||
aggregation into periodic snapshots instead of per-peer relay.
|
||||
- **`ROOM_DATA` payload trimming** — a 510-entry peer list is large; send counts + controller
|
||||
details, lazy-load the full roster.
|
||||
- Possibly the **socket.io Redis adapter** for horizontal scaling, and broadcast tuning.
|
||||
|
||||
## 8. Effort estimate
|
||||
- **Co-host roles** (server gate generalization + `SET_PEER_ROLE` + owner-leave fallback +
|
||||
client gates + promote UI + i18n), at the current ≤25-peer scale: **~3–4 dev days** (same
|
||||
shape as host-control itself — mostly generalizing host→controller-set).
|
||||
- **Large-room scaling (510)**: a **separate ~1–2 week** track (heartbeat redesign + payload
|
||||
trimming + cap raise + load testing), independent of co-host. Recommend shipping co-host at
|
||||
the current cap first, then scaling rooms as its own project.
|
||||
|
||||
## 9. Suggested sequencing
|
||||
1. `CAPABILITIES.CO_HOST` + `controllers`/`ownerPeerId` in room state + `ROOM_DATA` (additive).
|
||||
2. Server `SET_PEER_ROLE` + gate generalization + owner-leave fallback + WS tests.
|
||||
3. Background: controller-set membership in both gates + `amOwner`/`amController`.
|
||||
4. Popup: promote/demote toggles (owner) + Controller badge + i18n.
|
||||
5. (Separate track) large-room scaling.
|
||||
@@ -1,283 +0,0 @@
|
||||
# Host Control Mode — Branch Overview, Goals & Edge Cases
|
||||
|
||||
> **This is the canonical entry-point doc for branch `feature/host-control-mode`.**
|
||||
> If you're an agent or contributor picking this up: read this file first, then the
|
||||
> implementation plan in [`host-control-mode-plan.md`](./host-control-mode-plan.md).
|
||||
> Temporary working doc — delete or fold into permanent docs before merging to `main`.
|
||||
>
|
||||
> Status legend: 🔴 open / unresolved · 🟡 idea, needs testing · 🟢 decided
|
||||
|
||||
---
|
||||
|
||||
## 0. Implementation status
|
||||
|
||||
All five layers are implemented & pushed (server + background + content + popup + i18n).
|
||||
Automated checks green: ESLint, WS integration tests (incl. host-only gate, toggle
|
||||
reject, host-leave fallback), content video-finder, locale consistency (15 langs),
|
||||
full release verification.
|
||||
|
||||
**Still needs real-device testing** — the EC test matrix in §7 (YouTube/Netflix/
|
||||
Twitch/Disney+/Jellyfin): involuntary-pause classification (EC-1/EC-5/EC-8), snap-back
|
||||
reliability and fight-loops (EC-4), and the desync/resync flow across players. The
|
||||
intent classifier (EC-9) and snap-back cooldown are first-pass heuristics tuned by
|
||||
reading the code, not yet by watching them behave on each site.
|
||||
|
||||
Deferred by decision (see §8): host grace period on disconnect (EC-10).
|
||||
|
||||
### Capability detection (forward-compat hook)
|
||||
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`
|
||||
(`SERVER_CAPABILITIES` in server, `CAPABILITIES` in shared/constants). The client
|
||||
enables host-control UI/behavior only when the flag is present, so the feature
|
||||
degrades cleanly on an older relay (absent → off) and old clients ignore the
|
||||
field. This is the extensible hook for the planned **co-host** feature (owner
|
||||
promotes guests to additional controllers): it will add a `'co-host'` capability
|
||||
+ events without a protocol bump or breaking older relays/clients. Add new flags
|
||||
to `CAPABILITIES` / `SERVER_CAPABILITIES` as features land.
|
||||
|
||||
### Pre-test self-audit (fixed)
|
||||
- **Popup remote buttons froze for guests** — in host-only a guest's Play/Pause/SYNC
|
||||
click was gated server-side but the button stuck on "Playing"/disabled with no
|
||||
feedback. Now the remote controls are locked (disabled + tooltip) for guests, with
|
||||
backstop guards in the handlers. (popup.js)
|
||||
- **Desync dialog could break under strict CSP** — it used `innerHTML` with inline
|
||||
`style=""` attributes, which Netflix/YouTube/Disney+ strip via `style-src`. Rebuilt
|
||||
with the DOM API (CSSOM `.style` is CSP-safe) inside a **Shadow DOM** so page CSS
|
||||
can't restyle/hide it. (content.js)
|
||||
- **Live-DVR not detected (EC-15)** — `duration === Infinity` misses Twitch/YouTube
|
||||
live-DVR (finite, sliding duration). Added a `seekable.start(0) > 1` sliding-window
|
||||
heuristic in `hcmIsLive()`. (content.js)
|
||||
|
||||
### Pre-test self-audit
|
||||
- ~~**EC-4/EC-1 snap-back thrash:**~~ FIXED — implemented the **buffer-aware deferred
|
||||
snap-back** (`hcmDeferredSnapBack`): on an involuntary event, if the player isn't
|
||||
ready (`readyState<3` or seeking) we wait (poll, 8s cap) until it can play, then snap
|
||||
ONCE to the host's re-queried position instead of repeatedly fighting the buffer.
|
||||
Done defensively/player-agnostically — we can't enumerate every site, so this is safe
|
||||
whether a player fires `pause()` or only `waiting`. Aborts if the user goes solo or is
|
||||
no longer a gated guest; single pending poll (no stacking).
|
||||
- ~~**Control-mode race at join:**~~ FIXED — `hcmHandleBlocked` now treats `HOST_BLOCKED`
|
||||
as authoritative (adopts host-only/guest role) instead of re-checking local mode,
|
||||
since background only sends it to gated guests.
|
||||
- ~~**Dialog/badge text is English-only**~~ FIXED — background resolves the strings
|
||||
via GET_HCM_STRINGS (it has the i18n loader); content fetches them on init with
|
||||
English fallback. 6 new keys (HCM_DIALOG_*/HCM_BADGE_*) across all 15 locales.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this branch is for
|
||||
|
||||
Origin: a GitHub feature request. When watching with larger groups, anyone can pause
|
||||
or seek and disrupt everyone else. The requester wants the room creator to optionally
|
||||
restrict control to a single **host**, the way Teleparty works — guests who try to
|
||||
pause get asked whether they want to pause *only their own* player (and desync), and
|
||||
otherwise get snapped back to the room's position.
|
||||
|
||||
**Goal:** Add an optional per-room **Host Control Mode**. A room can be switched
|
||||
between:
|
||||
- **`everyone`** (default, current behavior): anyone can play/pause/seek for the room.
|
||||
- **`host-only`**: only the host drives the room. A guest's deliberate play/pause/seek
|
||||
is not broadcast; instead they're snapped back to the room position — unless they
|
||||
explicitly choose to desync (go solo) with a "Resync" escape hatch.
|
||||
|
||||
## 2. Trust model (read this before over-engineering)
|
||||
|
||||
This is **client-side trust, by design**. It's a watch party, not a security boundary.
|
||||
The point is preventing *accidental* and *casual* disruption, not stopping someone
|
||||
determined to patch their own extension. We do **not** add auth, tokens, or
|
||||
cryptographic host identity. `peerId` is unauthenticated and that's fine here.
|
||||
|
||||
(We still gate server-side as the robust chokepoint — see plan — but that's about
|
||||
killing spam reliably, not about defeating a hostile client.)
|
||||
|
||||
## 3. Scope / non-goals
|
||||
|
||||
In scope:
|
||||
- Host designation (first joiner = host), mode toggle, host-only gating of all
|
||||
room-moving events, guest snap-back, deliberate-desync flow + resync, host UI.
|
||||
|
||||
Explicit non-goals (for this branch):
|
||||
- Authenticated / spoof-proof host identity.
|
||||
- Persistent host across server restarts (room state is in-memory).
|
||||
- Syncing around personalized ad breaks.
|
||||
- Host transfer UI (auto-fallback to `everyone` when host leaves; manual transfer
|
||||
is a possible later add).
|
||||
|
||||
## 4. Architecture summary
|
||||
|
||||
Three-layer gate for room-moving events from a non-host in host-only mode
|
||||
(`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`):
|
||||
1. **Server** — doesn't relay them (robust chokepoint, kills spam regardless of client).
|
||||
2. **Sender (guest)** — doesn't emit; shows confirm dialog / disables host-only buttons.
|
||||
3. **Receiver** — drops any that slip through (covers old/buggy/modified clients).
|
||||
|
||||
Snap-back reuses the existing `_setSuppress` mechanism (content.js:442) so applying
|
||||
the room state programmatically doesn't echo back as a new event. Target position is
|
||||
extrapolated from the host's last known state (±1s). Full detail + code hooks in the
|
||||
plan doc.
|
||||
|
||||
---
|
||||
|
||||
## 5. The central challenge
|
||||
|
||||
Everything hard about this feature reduces to **one question** (see EC-9):
|
||||
|
||||
> How do we reliably tell a **deliberate** guest pause/seek from an **involuntary**
|
||||
> player/browser event (buffering, ads, tab throttling, source swaps, DRM hiccups)?
|
||||
|
||||
If we get this wrong, guests get spammed with desync dialogs and snap-back loops for
|
||||
things they never did. The host/role plumbing is the easy part; this classifier is the
|
||||
real work. **Design the intent-classifier before writing the gate.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge cases
|
||||
|
||||
### EC-1 🔴 Buffering / loading fires a `pause` event
|
||||
content.js listens to `play`/`pause`/`seeked`/`loadeddata` only (content.js:1000-1003),
|
||||
not `waiting`/`stalled`. Pure HTML5 buffering fires `waiting` → harmless. But custom
|
||||
players (Netflix/YouTube/Twitch/JW) often call `video.pause()` during buffering/ads →
|
||||
real `pause` → guest gate would mis-classify as deliberate. Sub-cases: (a) initial load
|
||||
sits paused, no event, fine; (b) mid-stream stall, player-dependent; (c) seek-induced
|
||||
re-buffering may outlast the suppress window and leak. Mitigation: `isBuffering` flag
|
||||
from `waiting`/`playing`, or grace window; in host-only the guest's own state is
|
||||
irrelevant so just ignore involuntary pauses and let catch-up logic (content.js:489)
|
||||
re-sync them.
|
||||
|
||||
### EC-2 🟢 Force-Sync / Episode-Lobby abuse by guests
|
||||
Guest could seek + spam Force-Sync to drag everyone, or spam Episode-Lobby to pause
|
||||
everyone. Decision: host-only blocks guest *initiation* of `FORCE_SYNC_*` and
|
||||
`EPISODE_LOBBY`; guests may only respond (`FORCE_SYNC_ACK`, `EPISODE_READY`). Guests'
|
||||
legitimate path is the personal "Resync" button.
|
||||
|
||||
### EC-3 🟢 Host leaves the room
|
||||
Fall back to `controlMode = 'everyone'`, broadcast `CONTROL_MODE`. Never a stuck locked
|
||||
room. (Auto-promote next peer deferred.)
|
||||
|
||||
### EC-4 🔴 Snap-back fight loop (pause/play/skip back/pause/play)
|
||||
Mashing controls or a janky player → each snap-back may emit events → ping-pong.
|
||||
Mitigation: cooldown (~600ms) after snap-back; ensure snap-back runs fully under
|
||||
suppress. Also: if target is unreachable (seek past buffered range), retry K times then
|
||||
give up — no infinite loop.
|
||||
|
||||
### EC-5 🔴 Ad breaks (YouTube/Twitch/…)
|
||||
Mid-roll ads pause/swap the media element, differ per peer → desync is unavoidable and
|
||||
must NOT spam the dialog. Probably covered by EC-1 buffering grace; flag for explicit
|
||||
testing.
|
||||
|
||||
### EC-6 🟡 Snap-back target accuracy
|
||||
No continuous room clock; extrapolate from host's `currentTime` + `lastHeartbeat`
|
||||
(±1s, worse if stale). The follow-up host correction must also be suppressed so it
|
||||
doesn't read as guest input.
|
||||
|
||||
### EC-7 🟢 Old / buggy / modified guest client
|
||||
Covered by receiver-side + server-side gates.
|
||||
|
||||
### EC-8 🔴 Tab throttling / background tab
|
||||
Backgrounding throttles timers and may pause media. There's existing
|
||||
`visibilityGraceUntil` handling for seeks (content.js:892). Confirm a
|
||||
background-induced pause isn't treated as deliberate in host-only; reuse the grace flag.
|
||||
|
||||
### EC-9 🔴 What counts as "deliberate" — the central unresolved question
|
||||
Collapses EC-1/EC-5/EC-8. Candidate signals: `readyState`/`networkState`/`video.seeking`
|
||||
at event time; recent `waiting`; recent user gesture (`navigator.userActivation`,
|
||||
keydown/click); visibility/focus. Build one shared **intent-classifier** helper in
|
||||
content.js that all host-only gating flows through.
|
||||
|
||||
### EC-10 🔴 Host brief disconnect / reconnect (network blip)
|
||||
Host's wifi drops for 3s and reconnects. With "host leaves → fallback to everyone",
|
||||
a blip would silently unlock the room and demote the host (peerId persists in
|
||||
chrome.storage so they rejoin with the same id, but the server already cleared
|
||||
`hostPeerId`). Mitigation idea: short **host grace period** (e.g. keep `hostPeerId`
|
||||
reserved for ~30s after disconnect; if the same peerId rejoins, restore host + mode).
|
||||
Needs the server reaper (server:644) and `removePeerFromRoom` (server:168) to cooperate.
|
||||
|
||||
### EC-11 🔴 New guest joins mid-session in host-only mode
|
||||
On join they must (a) immediately sync to the host's current position without the host
|
||||
doing anything, and (b) see they're a guest in the UI. ROOM_DATA already carries peers;
|
||||
add `hostPeerId`/`controlMode` so a fresh client knows its role instantly. Verify the
|
||||
existing "newcomer syncs without waiting" path (content.js:542) still fires.
|
||||
|
||||
### EC-12 🔴 Desync semantics — what does "solo" actually mean?
|
||||
When a guest chooses "pause only me", do they (a) fully ignore all subsequent host
|
||||
events until they Resync, or (b) keep receiving but not auto-applying? Define clearly.
|
||||
Proposed: full solo — ignore host play/pause/seek while desynced; Resync re-attaches and
|
||||
snaps to current host position. Also: what state does Resync land them in if the host is
|
||||
currently paused vs playing?
|
||||
|
||||
### EC-13 🔴 Race: host flips to host-only exactly as a guest pauses
|
||||
Event ordering between `SET_CONTROL_MODE`/`CONTROL_MODE` and an in-flight guest `PAUSE`.
|
||||
The `seq` ordering helps, but define the tie-break. Likely: server is authoritative —
|
||||
once it has `host-only`, it drops the guest event regardless of client-side timing.
|
||||
|
||||
### EC-14 🟡 Volume / mute / audio-options must NOT be gated
|
||||
Those are per-peer, not room control. The gate must target only play/pause/seek +
|
||||
forcesync/episode — not `PEER_STATUS` volume/mute fields. Easy to over-block; add a test.
|
||||
|
||||
### EC-15 🔴 Live streams (Twitch live, live DVR)
|
||||
"Room timestamp" is fuzzy on live edge; seeking semantics differ. Decide whether
|
||||
host-only even makes sense for live, or degrade gracefully. Low priority but log it.
|
||||
|
||||
### EC-16 🟡 Host's own involuntary events still drive the room
|
||||
If the host buffers and the player auto-pauses, that pause is "allowed" and pauses
|
||||
everyone. That's existing behavior, but in host-only it means the host's buffering
|
||||
stalls the whole room. Acceptable? Probably yes (host is authoritative), but note it.
|
||||
|
||||
### EC-17 🟡 Server restart drops room state
|
||||
`hostPeerId`/`controlMode` are in-memory. After a server restart, whoever rejoins first
|
||||
becomes the new host and mode resets to `everyone`. Acceptable for now (non-goal), but
|
||||
document so it's not a surprise.
|
||||
|
||||
### EC-18 🟡 Dialog dismissed without choosing
|
||||
Guest clicks away / presses Esc on the desync prompt. Default = treat as "No" → snap
|
||||
back. Make sure an un-answered dialog can't leave them in limbo (paused + not desynced +
|
||||
no dialog).
|
||||
|
||||
### EC-19 🟡 Multiple video elements / element swap (SPA, ad → content)
|
||||
Players that swap the `<video>` element mid-session: re-attach handlers (content.js
|
||||
already re-binds on `loadeddata`) and make sure host-only gating follows the new element.
|
||||
|
||||
### EC-20 🟡 Peer list shape (object vs legacy string)
|
||||
Throughout background.js peers may be objects or bare peerId strings
|
||||
(`typeof p === 'object' ? p.peerId : p`). All new `hostPeerId` comparisons must handle
|
||||
both forms, or we get a host that's never recognized.
|
||||
|
||||
### EC-21 🟡 Mode toggle spam / rate limiting
|
||||
Host hammering the toggle → many `SET_CONTROL_MODE`. Covered by existing
|
||||
`checkEventRate` (server), but debounce in the UI and ignore no-op transitions.
|
||||
|
||||
---
|
||||
|
||||
## 7. Test matrix (fill in during dev)
|
||||
|
||||
| Player | Buffer→`pause`? | Ad behavior | Snap-back works? | Element swap? | Notes |
|
||||
|-------------------|-----------------|-------------|------------------|---------------|-------|
|
||||
| Generic HTML5 | | | | | |
|
||||
| YouTube | | | | | |
|
||||
| Netflix | | | | | |
|
||||
| Twitch (VOD) | | | | | |
|
||||
| Twitch (live) | | | | | |
|
||||
| Disney+ / DRM | | | | | |
|
||||
| Jellyfin / Emby | | | | | |
|
||||
|
||||
## 8. Decisions (audited)
|
||||
- [x] **Intent-classifier (EC-9):** A `pause`/`seek` is **involuntary** if ANY of:
|
||||
`readyState < 3`, `video.seeking`, a `waiting` fired < ~1500ms ago (`isBuffering`
|
||||
flag), inside `visibilityGraceUntil`, OR no own-tracked user gesture
|
||||
(`Date.now() - lastUserGestureAt < 1000`, via capturing keydown/pointerdown — do
|
||||
NOT use sticky `navigator.userActivation.hasBeenActive`). Bias: only *clearly*
|
||||
involuntary is ignored; everything else = deliberate. **Note:** in host-only the
|
||||
guest never broadcasts anyway, so this only decides dialog-vs-silent — a UX call,
|
||||
not a room-integrity call. Start simple, tune later.
|
||||
- [x] **Host grace on disconnect (EC-10): NOT in v1.** Immediate fallback to
|
||||
`everyone` (EC-3). A grace window risks a multi-second hard-lock if the host never
|
||||
returns. Revisit as polish once the core flow works.
|
||||
- [x] **Desync semantics (EC-12): full solo.** Ignore host play/pause/seek while
|
||||
desynced; Resync snaps to host position + adopts host play/pause state. Desync
|
||||
auto-clears on new media/episode. Requires a persistent, obvious "You are desynced"
|
||||
UI.
|
||||
- [x] **Snap-back cooldown (EC-4): until-settled, not fixed.** Suppress re-trigger
|
||||
until `readyState>=3 && playing && |Δt|<tol`, hard-cap ~1500ms. Retry target up to
|
||||
3×, then give up (no infinite loop).
|
||||
- [x] **Live streams (EC-15): degrade.** Disable the gate when
|
||||
`video.duration === Infinity`. Caveat: live-DVR may report finite duration and slip
|
||||
through — acceptable for v1.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Host Control Mode — Beta Testing Guide
|
||||
|
||||
> Temporary doc for branch `feature/host-control-mode`. Remove before merge.
|
||||
> The feature needs the **server** half too — the official relay
|
||||
> (`wss://syncserver.koalastuff.net`) doesn't run it yet, so test against a beta
|
||||
> server first.
|
||||
|
||||
## 1. Run the beta relay (Docker)
|
||||
|
||||
The branch publishes the server image to GHCR under non-production tags
|
||||
(`:beta` = newest branch build, `:sha-<commit>` = immutable pin). `:latest` is
|
||||
never touched.
|
||||
|
||||
```bash
|
||||
# Log in (the package is private → PAT with read:packages)
|
||||
echo "$GHCR_PAT" | docker login ghcr.io -u <your-gh-user> --password-stdin
|
||||
|
||||
# Pull + (re)create — NOTE: `docker restart` does NOT pick up a new image,
|
||||
# you must remove and re-run (or use compose / Watchtower).
|
||||
docker pull ghcr.io/shik3i/koalasync:beta
|
||||
docker rm -f koala-beta 2>/dev/null || true
|
||||
docker run -d --name koala-beta -p 3000:3000 \
|
||||
-e SERVER_SALT='choose-your-own-salt' \
|
||||
ghcr.io/shik3i/koalasync:beta
|
||||
```
|
||||
|
||||
To always run the newest beta automatically, point **Watchtower** at the
|
||||
container — it does the pull → recreate whenever `:beta` moves.
|
||||
|
||||
The `OFFICIAL_SERVER_TOKEN` is baked into `shared/constants.js`, so no token env
|
||||
is needed. Set `SERVER_SALT` (used for room-password hashing).
|
||||
|
||||
## 2. Connect the extension to it
|
||||
|
||||
⚠️ The client **force-upgrades `ws://` to `wss://` for any non-localhost host**
|
||||
(see background.js "Upgraded to wss:// for remote host"). So a bare
|
||||
`ws://your-server:3000` will fail without TLS. Two options:
|
||||
|
||||
- **Quick (no TLS):** SSH-tunnel the port so it counts as local:
|
||||
```bash
|
||||
ssh -L 3000:localhost:3000 your-beta-server
|
||||
```
|
||||
then in the popup → **Manual Connect / Advanced → Custom →** `ws://localhost:3000`.
|
||||
- **Proper:** put the container behind a TLS reverse proxy (Caddy does automatic
|
||||
HTTPS) → `wss://beta.yourdomain`.
|
||||
|
||||
Then create/join a room.
|
||||
|
||||
## 3. ⚠️ Use two *new* clients
|
||||
|
||||
Test with **two browser profiles both running this branch build** (load unpacked
|
||||
from `extension/`, or install the built zip). A stock release client as a guest
|
||||
will be correctly gated by the server but has none of the content-side code, so
|
||||
it silently desyncs with no dialog/snap-back — that's expected degradation, not a
|
||||
bug, but it looks like one during testing.
|
||||
|
||||
## 4. Verification checklist
|
||||
|
||||
| # | Step | Expect |
|
||||
|---|------|--------|
|
||||
| 1 | Create a room (host) | Host Control card shows **Host** + the toggle |
|
||||
| 2 | Second profile joins | Guest sees no card while mode is "everyone" |
|
||||
| 3 | Host enables "Only I can control" | Guest's Play/Pause/SYNC buttons lock; card shows **Guest** |
|
||||
| 4 | Guest presses pause/space on the video | Brief flicker, snaps back to host position |
|
||||
| 5 | Guest pauses again | Dialog: "Stay in sync" / "Watch on my own" |
|
||||
| 6 | Guest → "Watch on my own" | Persistent "Solo" badge; host's peer list shows **Solo** |
|
||||
| 7 | Guest → "Resync" | Snaps back to host; Solo badge clears on both sides |
|
||||
| 8 | Guest tries Force-Sync / seek spam | Nothing propagates to the room |
|
||||
| 9 | Host disables host-only | Card hides for guest; controls unlock |
|
||||
| 10 | Host leaves the room | Room falls back to "everyone"; a remaining peer becomes host |
|
||||
| 11 | Reload the guest's page while desynced | Still shows Solo (state survives reload) |
|
||||
| 12 | Switch the popup language | Dialog/badge text is localized |
|
||||
|
||||
## 5. Capability detection
|
||||
|
||||
The relay advertises `capabilities: ['host-control']` in `ROOM_DATA`. The client
|
||||
only enables the feature when that flag is present, so:
|
||||
- against this beta server → feature on;
|
||||
- against the old official server → feature cleanly hidden (no errors).
|
||||
|
||||
This is the extensible hook for the planned **co-host** feature (owner promotes
|
||||
guests to additional controllers) — it'll add a `'co-host'` capability + events
|
||||
without breaking older relays/clients.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Host Control Mode — Implementierungsplan
|
||||
|
||||
Branch: `feature/host-control-mode`
|
||||
Issue: GitHub feature request (wasserrutschentester) — nur Host darf den Raum steuern; Gäste werden zurückgesnappt oder gehen bewusst in Desync.
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Raum kann zwischen zwei Modi umgeschaltet werden:
|
||||
- **`everyone`** (Default, heutiges Verhalten): jeder kann play/pause/seek für alle auslösen.
|
||||
- **`host-only`**: nur der Host steuert den Raum. Pause/Seek eines Gasts wird **nicht** gebroadcastet; stattdessen snappt die eigene Extension den Gast zurück auf den Raum-Zustand — es sei denn, der Gast entscheidet sich bewusst für Desync.
|
||||
|
||||
Trust-Modell: client-seitig durchgesetzt. Kein Token, keine Auth. Es geht um versehentliches Stören, nicht um Angriffsschutz.
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell
|
||||
|
||||
### Server (`server/index.js`, Room-Objekt ~Z.331)
|
||||
Room bekommt zwei neue Felder:
|
||||
```js
|
||||
room = {
|
||||
...,
|
||||
hostPeerId: peerId, // gesetzt beim Anlegen = erster Joiner
|
||||
controlMode: 'everyone', // 'everyone' | 'host-only'
|
||||
}
|
||||
```
|
||||
- In `ROOM_DATA` (~Z.418) mitschicken: `hostPeerId`, `controlMode`.
|
||||
- Neues Event `SET_CONTROL_MODE` (siehe unten): nur akzeptieren, wenn `senderPeerId === room.hostPeerId`. Server setzt `room.controlMode`, broadcastet die Änderung an alle.
|
||||
- **Host-Migration:** in `removePeerFromRoom` (~Z.168) — wenn der gehende Peer `hostPeerId` war: entweder neuen Host bestimmen (nächster Peer) **oder** `controlMode` auf `everyone` zurückfallen lassen. → Entscheidung: **Fallback auf `everyone`** (simpel, nie verwaister gesperrter Raum). Optional später: Host-Transfer-Button.
|
||||
|
||||
### Shared Constants (`shared/constants.js`)
|
||||
Neue Events im `EVENTS`-Objekt:
|
||||
```js
|
||||
SET_CONTROL_MODE: "set_control_mode", // Client->Server: Host ändert Modus
|
||||
CONTROL_MODE: "control_mode", // Server->Client: Modus geändert { controlMode, hostPeerId }
|
||||
```
|
||||
⚠️ Danach `node scripts/build-extension.cjs` laufen lassen (Single Source of Truth propagieren). Ggf. `PROTOCOL_VERSION` bumpen — **nein, nur wenn alte Clients brechen würden**. Da alles additiv ist und alte Clients die neuen Felder/Events einfach ignorieren, ist KEIN Protokoll-Bump nötig. (Alter Client in host-only-Raum kennt den Modus nicht und sendet weiter → Host-Extensions ignorieren fremde Events nicht... → siehe Edge Case 7. Evtl. doch Bump erwägen.)
|
||||
|
||||
### Extension State (`background.js`)
|
||||
```js
|
||||
let controlMode = 'everyone';
|
||||
let hostPeerId = null;
|
||||
// abgeleitet: const amHost = () => hostPeerId === peerId;
|
||||
```
|
||||
Aus `ROOM_DATA` / `CONTROL_MODE` befüllen, in `chrome.storage.session` persistieren (wie `currentRoom`).
|
||||
|
||||
---
|
||||
|
||||
## Implementierung nach Schichten
|
||||
|
||||
### 1. Server (`server/index.js`)
|
||||
- [ ] Room-Objekt um `hostPeerId` + `controlMode` erweitern (~Z.331).
|
||||
- [ ] `ROOM_DATA`-Payload erweitern (~Z.418).
|
||||
- [ ] Handler `SET_CONTROL_MODE`: validieren (Host-Check + Wert in {everyone, host-only}), setzen, `CONTROL_MODE` an Raum broadcasten.
|
||||
- [ ] Host-Migration in `removePeerFromRoom`: Fallback auf `everyone` + neues `CONTROL_MODE` broadcasten, wenn Host geht.
|
||||
- [ ] `SET_CONTROL_MODE` in die `relayEvents`-Liste? **Nein** — eigener Handler, da Sonderlogik + Host-Check. (relayEvents broadcastet blind.)
|
||||
|
||||
### 2. Shared / Build
|
||||
- [ ] `EVENTS.SET_CONTROL_MODE`, `EVENTS.CONTROL_MODE` ergänzen.
|
||||
- [ ] `node scripts/build-extension.cjs`.
|
||||
|
||||
### 3. background.js (Gast-Logik = Kern)
|
||||
- [ ] `controlMode` / `hostPeerId` aus `ROOM_DATA` (~Z.875) und neuem `CONTROL_MODE`-Case übernehmen + persistieren + an Popup/Content pushen.
|
||||
- [ ] **Emit-Gate** im SEND-Pfad (~Z.1786): bei `host-only && !amHost()` und action ∈ {play, pause, seek}:
|
||||
- NICHT `emit`en.
|
||||
- Stattdessen Content-Script anweisen: "snap back" ODER Desync-Confirm anzeigen.
|
||||
- [ ] **Snap-Back-Zielzeit berechnen:** aus Host-Peer-State (`playbackState`, `currentTime`, `lastHeartbeat`) extrapolieren:
|
||||
`targetTime = host.currentTime + (host.playbackState==='playing' ? (now - host.lastHeartbeat)/1000 : 0)`.
|
||||
Genauigkeit ~±1s, für Watchparty ok. (Force-Sync-Maschinerie als Referenz für Ziel-Zeit-Koordination.)
|
||||
- [ ] Nachricht an content.js: `{ type: 'HOST_BLOCK', action, targetTime, hostPlaybackState }`.
|
||||
|
||||
### 4. content.js (Player-Reaktion + Dialog)
|
||||
- [ ] Handler für `HOST_BLOCK`: Confirm-Dialog im Player-Overlay rendern:
|
||||
"Pause only your own player and desync from the group? [Yes] [No]".
|
||||
- **No** (Default): Player via bestehende `_setSuppress`-Mechanik ([content.js:442](../extension/content.js:442)) wieder in Raum-Zustand zwingen (play + seek auf targetTime). Suppress verhindert Re-Broadcast.
|
||||
- **Yes:** lokal pausiert lassen, `isDesynced = true` setzen, dezenten "Desynced — Resync"-Button zeigen.
|
||||
- [ ] "Resync"-Button → snappt zurück auf aktuelle Raum-Zeit, `isDesynced = false`.
|
||||
- [ ] **Loop-Schutz:** nach einer Snap-Back-Aktion kurzes Cooldown-Fenster (z.B. 600ms), in dem weitere lokale pause/seek-Events nicht erneut den Dialog triggern (verhindert pause→play→pause-Pingpong).
|
||||
|
||||
### 5. popup (Host-UI)
|
||||
- [ ] Host-Toggle "Only I can control" (nur sichtbar wenn `amHost()`), sendet `SET_CONTROL_MODE`.
|
||||
- [ ] Rollen-Badge: "Host" / "Guest" + aktueller Modus.
|
||||
- [ ] Gast-Hinweis wenn host-only aktiv: "The host controls playback".
|
||||
- [ ] i18n-Keys in `extension/_locales` / `locales` für ~15 Sprachen.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases (Test-Checkliste)
|
||||
|
||||
1. **Pause nicht verhinderbar, nur revidierbar** → kurzer Flicker (~½s) beim Gast ist erwartet/akzeptabel.
|
||||
2. **Snap-Back-Zielzeit** aus Heartbeat extrapoliert, ±1s. Bei stark veraltetem Host-State (kein Heartbeat) → letzten bekannten Wert nehmen.
|
||||
3. **Kampf-Loop pause/play/skip back/pause/play** → Cooldown-Fenster nach Snap-Back. Testen mit aggressivem Mashing.
|
||||
4. **Desync-Escape:** Gast kann bewusst pausieren (Klo/Telefon) → "Yes" → solo, dann Resync.
|
||||
5. **Host verlässt Raum** → Fallback auf `everyone`, alle bekommen `CONTROL_MODE`-Update. Testen: Host schließt Tab / Disconnect / Netzabbruch.
|
||||
6. **host-only + Episode-Auto-Sync / Force-Sync** (server/index.js:503-516): **Gast darf NICHT initiieren.** Force-Sync trägt eine `targetTime` und zwingt ALLE darauf (background.js:1261) — ein Gast könnte seeken → Force-Sync spammen und damit host-only komplett aushebeln. Episode-Lobby pausiert ebenfalls alle. → Im host-only-Modus dürfen `FORCE_SYNC_PREPARE`/`FORCE_SYNC_EXECUTE` und `EPISODE_LOBBY` nur vom Host **initiiert** werden. Gäste dürfen weiterhin nur **reagieren**: `FORCE_SYNC_ACK`, `EPISODE_READY`. Gäste brauchen Force-Sync nicht — ihr legitimer Fall ist der "Resync"-Button (snappt nur sie selbst, nicht alle).
|
||||
7. **Alter Client (ohne Feature) in host-only-Raum** → kennt Modus nicht, sendet weiter pause/seek → andere Extensions wenden es an. Mitigation: Empfänger-seitiges Gate (host-only-Clients ignorieren play/pause/seek von Nicht-Host) ODER `MIN_VERSION`/Protokoll-Bump. → **Empfehlung: zusätzlich Empfänger-seitig filtern** (robuster als nur Sender-Gate).
|
||||
8. **Seek getrennt von Pause** → host-only blockt auch Gast-Seeks, nicht nur Pausen.
|
||||
9. **Mehrere Tabs / Multi-Peer mit gleicher peerId** (Dedup, server:381) → Host-Identität bleibt an peerId hängen, ok.
|
||||
10. **DAU-Verwirrung** "warum kann ich nicht mehr pausieren?" → klare UI-Botschaft + der Desync-Dialog erklärt sich selbst.
|
||||
|
||||
## Architektur-Entscheidung zu Edge Case 7 (wichtig)
|
||||
Wir setzen das Gate **doppelt** und über **alle raum-verschiebenden Events**, nicht nur play/pause/seek:
|
||||
Geblockte Initiierungen für Nicht-Host im host-only-Modus:
|
||||
`PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_PREPARE`, `FORCE_SYNC_EXECUTE`, `EPISODE_LOBBY`.
|
||||
Weiterhin erlaubt für Gäste (reine Reaktion, verschiebt niemanden): `FORCE_SYNC_ACK`, `EPISODE_READY`, `PEER_STATUS`, `PING`/`PONG`.
|
||||
|
||||
- **Sender-seitig** (Gast sendet erst gar nicht) → saubere UX, Confirm-Dialog bei play/pause/seek; Force-Sync-/Episode-Lobby-Buttons im Gast-UI deaktiviert/ausgeblendet.
|
||||
- **Empfänger-seitig** (in `handleServerEvent`, background.js:969 + Force-Sync-/Episode-Cases): wenn `host-only` und `data.senderId !== hostPeerId` → Event verwerfen (nicht an Content routen, keine State-Mutation).
|
||||
So sind auch alte/buggy/manipulierte Clients abgedeckt, ohne harten Protokoll-Bump.
|
||||
|
||||
Optional zusätzlich **server-seitig** in den `relayEvents` (server/index.js:445): im host-only-Modus Initiierungs-Events von Nicht-Host gar nicht erst relayen. Spart Traffic + deckt alles zentral ab. Empfehlenswert, da der Server `hostPeerId`/`controlMode` ohnehin kennt.
|
||||
|
||||
---
|
||||
|
||||
## Reihenfolge der Umsetzung (kleine, testbare Schritte)
|
||||
1. Constants + Build (Events da, nichts kaputt).
|
||||
2. Server: hostPeerId/controlMode + ROOM_DATA + SET_CONTROL_MODE + Migration.
|
||||
3. background.js: State übernehmen + Empfänger-seitiges Gate (Edge 7) — testbar ohne UI.
|
||||
4. background.js: Sender-seitiges Gate + Snap-Back-Zielzeit.
|
||||
5. content.js: Snap-Back-Apply + Confirm-Dialog + Loop-Cooldown.
|
||||
6. popup: Host-Toggle + Badge + i18n.
|
||||
7. Durchtesten der Edge-Case-Liste auf YT / Netflix / generischem HTML5-Player.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Host Control Mode
|
||||
|
||||
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 room mode.
|
||||
- Any peer may send room-moving playback events.
|
||||
|
||||
### `host-only`
|
||||
|
||||
- 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.
|
||||
|
||||
Room-moving events are:
|
||||
|
||||
- `play`
|
||||
- `pause`
|
||||
- `seek`
|
||||
- `force_sync_prepare`
|
||||
- `force_sync_execute`
|
||||
- `episode_lobby`
|
||||
- `episode_lobby_cancel`
|
||||
|
||||
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
|
||||
{
|
||||
hostPeerId,
|
||||
controlMode,
|
||||
controllers,
|
||||
lastControlModeChangeAt,
|
||||
lastRoleChangeAt,
|
||||
forceSyncInitiator
|
||||
}
|
||||
```
|
||||
|
||||
`controllers` is a `Set` on the server and is serialized as an array in
|
||||
`room_data` and `control_mode`.
|
||||
|
||||
State is not persisted across relay restarts.
|
||||
|
||||
## Authority Rules
|
||||
|
||||
### Changing mode
|
||||
|
||||
Only `hostPeerId` may send `set_control_mode`.
|
||||
|
||||
Valid values:
|
||||
|
||||
- `everyone`
|
||||
- `host-only`
|
||||
|
||||
Invalid values are ignored. Non-host attempts are ignored and the sender receives
|
||||
the current `control_mode` snapshot so optimistic UI can revert.
|
||||
|
||||
Mode changes are debounced per room for 500 ms.
|
||||
|
||||
### Promoting and demoting controllers
|
||||
|
||||
Only `hostPeerId` may send `set_peer_role`.
|
||||
|
||||
The host cannot demote themself. No-op role changes are ignored. Role changes are
|
||||
debounced per room for 500 ms.
|
||||
|
||||
### Host leaving
|
||||
|
||||
When the host leaves and peers remain:
|
||||
|
||||
- the next peer becomes `hostPeerId`;
|
||||
- `controlMode` falls back to `everyone`;
|
||||
- `controllers` is reset to the new host;
|
||||
- the relay broadcasts `control_mode`.
|
||||
|
||||
When a non-host controller leaves, the relay removes that peer from
|
||||
`controllers` and broadcasts `control_mode`.
|
||||
|
||||
## Enforcement
|
||||
|
||||
The implementation has two enforcement points:
|
||||
|
||||
- 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.
|
||||
|
||||
The relay is the authority for room-wide effects.
|
||||
|
||||
## Guest UX
|
||||
|
||||
When a guest action is blocked locally, the content script classifies it:
|
||||
|
||||
- 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.
|
||||
|
||||
The dialog offers:
|
||||
|
||||
- stay in sync: resync to the host;
|
||||
- watch on my own: enter solo/desynced mode.
|
||||
|
||||
In solo/desynced mode:
|
||||
|
||||
- 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.
|
||||
|
||||
The extension reports `desynced` in peer status so the host UI can show that a
|
||||
guest is watching solo.
|
||||
|
||||
## Force Sync Edge Case
|
||||
|
||||
The relay tracks `forceSyncInitiator` after a controller sends
|
||||
`force_sync_prepare`.
|
||||
|
||||
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.
|
||||
|
||||
The relay clears `forceSyncInitiator` after execute or when the initiator leaves.
|
||||
|
||||
## Capabilities
|
||||
|
||||
The relay advertises Host Control support in `room_data.capabilities`:
|
||||
|
||||
- `host-control`
|
||||
- `co-host`
|
||||
|
||||
The extension hides or disables matching UI when capabilities are missing.
|
||||
|
||||
## Related Events
|
||||
|
||||
See [PROTOCOL.md](PROTOCOL.md) for payloads and relay behavior for:
|
||||
|
||||
- `set_control_mode`
|
||||
- `control_mode`
|
||||
- `set_peer_role`
|
||||
- host-only gated relay events
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "scratch/**"]
|
||||
ignores: ["coverage/**", "dist/**", "node_modules/**", "scratch/**"]
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
|
||||
Generated
+1440
-6
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -8,15 +8,18 @@
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node scripts/verify-release.mjs",
|
||||
"test": "npm run verify",
|
||||
"test:unit": "vitest run",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1"
|
||||
"svgo": "^4.0.1",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
checkAuthRate,
|
||||
recordAuthFailure,
|
||||
clearRateLimitMaps,
|
||||
@@ -13,11 +14,13 @@ import {
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
leaveRoomCounts,
|
||||
rateLimitDenied,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup,
|
||||
CONNECTION_RATE_LIMIT,
|
||||
EVENT_RATE_LIMIT
|
||||
EVENT_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_LIMIT
|
||||
} from '../server/rate-limiter.js';
|
||||
|
||||
// Helper: mock io for cleanup
|
||||
@@ -26,7 +29,7 @@ const mockIo = { sockets: { sockets: new Map() } };
|
||||
// Reset state before each test group
|
||||
function reset() {
|
||||
clearRateLimitMaps();
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
|
||||
stopRateLimitCleanup();
|
||||
}
|
||||
|
||||
@@ -52,6 +55,16 @@ assert.equal(rateLimitDenied.events, 1);
|
||||
reset();
|
||||
assert.equal(checkEventRate('sock2'), true, 'separate socket independent');
|
||||
|
||||
// --- checkLeaveRoomRate ---
|
||||
reset();
|
||||
assert.equal(checkLeaveRoomRate('sock-leave-1'), true, 'first leave-room event allowed');
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT - 1; i++) checkLeaveRoomRate('sock-leave-1');
|
||||
assert.equal(checkLeaveRoomRate('sock-leave-1'), false, `leave-room beyond ${LEAVE_ROOM_RATE_LIMIT}/window blocked`);
|
||||
assert.equal(rateLimitDenied.leaveRoom, 1);
|
||||
|
||||
reset();
|
||||
assert.equal(checkLeaveRoomRate('sock-leave-2'), true, 'separate leave-room socket independent');
|
||||
|
||||
// --- checkHealthRate ---
|
||||
reset();
|
||||
assert.equal(checkHealthRate('1.2.3.4'), true, 'first health check allowed');
|
||||
@@ -91,12 +104,14 @@ eventCounts.set('sock1', { count: 1, resetTime: Date.now() + 10000 });
|
||||
healthCounts.set('ip2', { count: 1, resetTime: Date.now() + 60000 });
|
||||
adminMetricsAuthCounts.set('ip3', { count: 1, resetTime: Date.now() + 60000 });
|
||||
roomListCooldowns.set('sock2', Date.now());
|
||||
leaveRoomCounts.set('sock3', { count: 1, resetTime: Date.now() + 60000 });
|
||||
clearRateLimitMaps();
|
||||
assert.equal(connectionCounts.size, 0, 'connectionCounts cleared');
|
||||
assert.equal(eventCounts.size, 0, 'eventCounts cleared');
|
||||
assert.equal(healthCounts.size, 0, 'healthCounts cleared');
|
||||
assert.equal(adminMetricsAuthCounts.size, 0, 'adminMetricsAuthCounts cleared');
|
||||
assert.equal(roomListCooldowns.size, 0, 'roomListCooldowns cleared');
|
||||
assert.equal(leaveRoomCounts.size, 0, 'leaveRoomCounts cleared');
|
||||
|
||||
// --- startRateLimitCleanup / stopRateLimitCleanup ---
|
||||
reset();
|
||||
@@ -108,7 +123,9 @@ assert.ok(true, 'cleanup start/stop does not throw');
|
||||
// --- rateLimitDenied reset ---
|
||||
reset();
|
||||
rateLimitDenied.connections = 5;
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
|
||||
rateLimitDenied.leaveRoom = 5;
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
|
||||
assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable');
|
||||
assert.equal(rateLimitDenied.leaveRoom, 0, 'leave-room denial counter resettable');
|
||||
|
||||
console.log('rate-limiter tests passed');
|
||||
|
||||
@@ -52,7 +52,7 @@ const basicHealth = buildHealthPayload({
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 }
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
@@ -68,7 +68,8 @@ const adminHealth = buildHealthPayload({
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
|
||||
rateLimitDenied: { leaveRoom: 8 }
|
||||
});
|
||||
|
||||
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
|
||||
@@ -79,8 +80,8 @@ assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'ad
|
||||
assert.deepEqual(
|
||||
adminHealth.rateLimits,
|
||||
{
|
||||
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
|
||||
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }
|
||||
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
|
||||
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 8 }
|
||||
},
|
||||
'admin metrics should expose rate-limit tracking and denial counts'
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from 'node:path';
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const checks = [
|
||||
['vitest unit tests', 'npm', ['run', 'test:unit']],
|
||||
['server ops', 'node', ['scripts/test-server-ops.mjs']],
|
||||
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
|
||||
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
|
||||
|
||||
+15
-4
@@ -22,6 +22,7 @@ import {
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
leaveRoomCounts,
|
||||
rateLimitDenied,
|
||||
checkAuthRate,
|
||||
recordAuthFailure,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup,
|
||||
clearRateLimitMaps
|
||||
@@ -114,7 +116,8 @@ app.get('/health', (req, res) => {
|
||||
health: healthCounts.size,
|
||||
adminMetricsAuth: adminMetricsAuthCounts.size,
|
||||
authFailures: failedAuthAttempts.size,
|
||||
roomList: roomListCooldowns.size
|
||||
roomList: roomListCooldowns.size,
|
||||
leaveRoom: leaveRoomCounts.size
|
||||
},
|
||||
rateLimitDenied
|
||||
})
|
||||
@@ -661,6 +664,11 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
socket.on(EVENTS.LEAVE_ROOM, () => {
|
||||
if (!checkLeaveRoomRate(socket.id)) {
|
||||
log('SECURITY', `LEAVE_ROOM rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
@@ -839,6 +847,7 @@ io.on('connection', (socket) => {
|
||||
socket.on('disconnect', () => {
|
||||
eventCounts.delete(socket.id);
|
||||
roomListCooldowns.delete(socket.id);
|
||||
leaveRoomCounts.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
try {
|
||||
@@ -919,12 +928,14 @@ function gracefulShutdown(signal) {
|
||||
log('SERVER', `${signal} received — starting graceful shutdown...`);
|
||||
// 1. Notify all connected clients so they can display a meaningful message
|
||||
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Reconnecting automatically...' });
|
||||
// 2. Stop accepting new HTTP connections
|
||||
// 2. Gracefully disconnect all Socket.IO clients
|
||||
io.disconnectSockets(true);
|
||||
// 3. Stop accepting new HTTP connections
|
||||
httpServer.close(() => {
|
||||
log('SERVER', 'HTTP server closed. Exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
// 3. Safety net: force-exit after 5s if connections don't drain
|
||||
// 4. Safety net: force-exit after 5s if connections don't drain
|
||||
setTimeout(() => {
|
||||
log('SERVER', 'Force-exit after timeout.');
|
||||
process.exit(1);
|
||||
@@ -943,7 +954,7 @@ export async function stopServerForTests() {
|
||||
healthResponseCache.clear();
|
||||
io.removeAllListeners();
|
||||
io.disconnectSockets(true);
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
|
||||
if (!httpServer.listening) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
httpServer.close((err) => err ? reject(err) : resolve());
|
||||
|
||||
+4
-2
@@ -97,14 +97,16 @@ export function buildHealthPayload({
|
||||
health: rateLimitSizes.health || 0,
|
||||
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
|
||||
authFailures: rateLimitSizes.authFailures || 0,
|
||||
roomList: rateLimitSizes.roomList || 0
|
||||
roomList: rateLimitSizes.roomList || 0,
|
||||
leaveRoom: rateLimitSizes.leaveRoom || 0
|
||||
},
|
||||
denied: {
|
||||
connections: rateLimitDenied.connections || 0,
|
||||
events: rateLimitDenied.events || 0,
|
||||
health: rateLimitDenied.health || 0,
|
||||
adminMetricsAuth: rateLimitDenied.adminMetricsAuth || 0,
|
||||
roomList: rateLimitDenied.roomList || 0
|
||||
roomList: rateLimitDenied.roomList || 0,
|
||||
leaveRoom: rateLimitDenied.leaveRoom || 0
|
||||
}
|
||||
},
|
||||
memory: {
|
||||
|
||||
+26
-1
@@ -14,6 +14,8 @@ export const EVENT_RATE_LIMIT = 50; // max relayed events per soc
|
||||
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
|
||||
export const LEAVE_ROOM_RATE_LIMIT = 10; // max LEAVE_ROOM events per socket per window
|
||||
export const LEAVE_ROOM_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
@@ -21,13 +23,15 @@ export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
export const leaveRoomCounts = new Map(); // socketId -> { count, resetTime }
|
||||
|
||||
export const rateLimitDenied = {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0
|
||||
roomList: 0,
|
||||
leaveRoom: 0
|
||||
};
|
||||
|
||||
let authCleanupId = null;
|
||||
@@ -122,6 +126,20 @@ export function checkAdminMetricsAuthRate(ip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkLeaveRoomRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = leaveRoomCounts.get(socketId) || { count: 0, resetTime: now + LEAVE_ROOM_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) {
|
||||
entry.count = 0;
|
||||
entry.resetTime = now + LEAVE_ROOM_RATE_WINDOW_MS;
|
||||
}
|
||||
entry.count++;
|
||||
leaveRoomCounts.set(socketId, entry);
|
||||
if (entry.count <= LEAVE_ROOM_RATE_LIMIT) return true;
|
||||
rateLimitDenied.leaveRoom++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function startRateLimitCleanup(io) {
|
||||
if (authCleanupId !== null || rateLimitCleanupId !== null) return; // guard double-start
|
||||
// Clean up old auth failure records (every 15 minutes)
|
||||
@@ -145,6 +163,11 @@ export function startRateLimitCleanup(io) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of leaveRoomCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
leaveRoomCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) healthCounts.delete(ip);
|
||||
}
|
||||
@@ -166,7 +189,9 @@ export function clearRateLimitMaps() {
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
roomListCooldowns.clear();
|
||||
leaveRoomCounts.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
checkLeaveRoomRate,
|
||||
LEAVE_ROOM_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||
rateLimitDenied,
|
||||
leaveRoomCounts,
|
||||
clearRateLimitMaps
|
||||
} from './rate-limiter.js';
|
||||
|
||||
describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
const testSocketId = 'test-socket-123';
|
||||
|
||||
beforeEach(() => {
|
||||
clearRateLimitMaps();
|
||||
rateLimitDenied.leaveRoom = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRateLimitMaps();
|
||||
});
|
||||
|
||||
it('should allow LEAVE_ROOM within limit', () => {
|
||||
// Test within the rate limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
const result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
expect(rateLimitDenied.leaveRoom).toBe(0);
|
||||
});
|
||||
|
||||
it('should block LEAVE_ROOM when exceeding limit', () => {
|
||||
// Fill up to the limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// Next request should be blocked
|
||||
const result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(false);
|
||||
expect(rateLimitDenied.leaveRoom).toBe(1);
|
||||
});
|
||||
|
||||
it('should reset count after window expires', () => {
|
||||
// Fill up to the limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// Verify we're at the limit
|
||||
let result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Fast-forward time beyond the rate limit window
|
||||
const entry = leaveRoomCounts.get(testSocketId);
|
||||
entry.resetTime = Date.now() - LEAVE_ROOM_RATE_WINDOW_MS - 1000;
|
||||
leaveRoomCounts.set(testSocketId, entry);
|
||||
|
||||
// Next request should be allowed again
|
||||
result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple sockets independently', () => {
|
||||
const socketId2 = 'test-socket-456';
|
||||
|
||||
// Fill up first socket
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// Second socket should still be allowed
|
||||
const result = checkLeaveRoomRate(socketId2);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// First socket should be blocked
|
||||
const result2 = checkLeaveRoomRate(testSocketId);
|
||||
expect(result2).toBe(false);
|
||||
});
|
||||
|
||||
it('should increment rateLimitDenied counter on block', () => {
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(rateLimitDenied.leaveRoom).toBe(1);
|
||||
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(rateLimitDenied.leaveRoom).toBe(2);
|
||||
});
|
||||
|
||||
it('should be cleared by the shared reset helper', () => {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(leaveRoomCounts.size).toBe(1);
|
||||
|
||||
clearRateLimitMaps();
|
||||
expect(leaveRoomCounts.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Constants', () => {
|
||||
it('should have correct rate limit values', () => {
|
||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||
expect(LEAVE_ROOM_RATE_WINDOW_MS).toBe(60000); // 1 minute
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
include: [
|
||||
'server/**/*.test.js',
|
||||
'server/**/*.test.mjs',
|
||||
'shared/**/*.test.js',
|
||||
'shared/**/*.test.mjs'
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov'],
|
||||
include: ['server/**/*.js', 'shared/**/*.js'],
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/scripts/**',
|
||||
'**/extension/**'
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user