From 463f8719583ac152b46cb4a62b36a1cabe366cee Mon Sep 17 00:00:00 2001 From: Skrockle Date: Wed, 1 Jul 2026 23:27:30 +0200 Subject: [PATCH 1/6] feat: Add protocol spec, rate limiting, vitest framework, and documentation ## Added - **WebSocket Protocol Specification** (docs/PROTOCOL.md) - Complete reference for all 20+ events with payload schemas, rate limits, and edge cases - **LEAVE_ROOM Rate Limiting** - 10 requests/socket/minute to prevent abuse - **Vitest Testing Framework** - Modern test setup with coverage reporting - **Host Control Mode Documentation** - Consolidated EN documentation (docs/host-control-mode.md) with references to internal implementation ## Changed - **Graceful Shutdown** - Socket.IO clients properly disconnected during server shutdown (server/index.js) - **CI Workflow** - Added test step with continue-on-error for gradual rollout - **README** - Added protocol documentation link - **CHANGELOG** - Updated with all new features and improvements ## Moved - Internal documentation moved to docs/internal/ for better organization - Host Control Mode docs consolidated from 4 files to 1 comprehensive guide ## Technical Details - Protocol spec: 495 lines, covers all events from shared/constants.js - Rate limiter: Follows existing pattern (checkAuthRate, checkEventRate) - Vitest: 6 tests for rate limiter, all passing - Documentation: Host Control Mode doc includes edge cases, testing checklist, architecture decisions --- .github/workflows/ci.yml | 8 +- README.md | 1 + docs/CHANGELOG.md | 8 + docs/PROTOCOL.md | 495 +++++ docs/host-control-mode.md | 287 +++ .../host-control-mode-COHOST-PLAN.md | 0 .../host-control-mode-EDGECASES.md | 0 .../host-control-mode-TESTING.md | 0 docs/{ => internal}/host-control-mode-plan.md | 0 package-lock.json | 1729 ++++++++++++++++- package.json | 6 +- server/index.js | 10 +- server/rate-limiter.js | 27 +- server/rate-limiter.test.mjs | 103 + vitest.config.mjs | 26 + 15 files changed, 2687 insertions(+), 13 deletions(-) create mode 100644 docs/PROTOCOL.md create mode 100644 docs/host-control-mode.md rename docs/{ => internal}/host-control-mode-COHOST-PLAN.md (100%) rename docs/{ => internal}/host-control-mode-EDGECASES.md (100%) rename docs/{ => internal}/host-control-mode-TESTING.md (100%) rename docs/{ => internal}/host-control-mode-plan.md (100%) create mode 100644 server/rate-limiter.test.mjs create mode 100644 vitest.config.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14bd8e9..7a16181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,5 +42,9 @@ jobs: run: npm ci working-directory: server - - name: Run verification suite - run: npm run verify + - name: Run tests + run: npm test + continue-on-error: true + + - name: Run verification suite + run: npm run verify diff --git a/README.md b/README.md index 2628a43..d61c0fe 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6b84b4f..588f701 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,9 +18,17 @@ All notable changes to the KoalaSync browser extension and relay server. - **Backward-compatible Host Control rollout** — The extension only shows Host Control when the connected relay supports it, so users on older self-hosted servers do not see controls that cannot work yet. - **Extension: Clear host and guest states** — The popup shows the current control mode, host status, peer roles, and localized guest guidance so participants understand when playback is controlled by the host. - **Website: FAQ clarification for streaming access** — The landing page and FAQ structured data now state clearly that KoalaSync does not stream, host, share, or bypass access to video content. Every participant watches locally and needs their own access to services such as Netflix. +- **Documentation: WebSocket Protocol Specification** — Complete reference for all protocol events, payload schemas, rate limits, and edge cases in PROTOCOL.md. +- **Server: Graceful Shutdown** — Socket.IO clients are now properly disconnected during server shutdown. +- **Server: LEAVE_ROOM Rate Limiting** — Prevents abuse with 10 requests per socket per minute limit. +- **Testing: Vitest Framework** — Modern testing setup with coverage reporting for server and shared modules. ### Changed - **Playback sync now follows the room's control setting** — When Host Control is enabled, only the host can drive room-wide playback changes; guests can still watch in sync without accidentally changing playback for everyone. +- **Architecture: Modularized background.js** — Refactored 2410-line file into 11 focused modules for better maintainability. +- **Documentation: Consolidated Host Control Mode** — Unified documentation in host-control-mode.md with references to internal implementation details. +- **README: Added protocol documentation** — Updated documentation links to include PROTOCOL.md reference. +- **ARCHITECTURE: Added protocol reference** — Updated architecture documentation with link to PROTOCOL.md. --- diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..33bbaf5 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,495 @@ +# WebSocket Protocol Specification + +## Overview + +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**: 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 + +## Protocol Basics + +### Message Format + +All messages follow the Socket.IO v4 protocol format: +- Engine.IO packet type `42` (WEBSOCKET_MESSAGE) +- JSON-encoded array: `[eventName, payload]` + +Example: `42["play",{"currentTime":123.45}]` + +### Event Flow + +``` +Client → Server: JOIN_ROOM, PLAY, PAUSE, SEEK, etc. +Server → Client: ROOM_DATA, PEER_STATUS, CONTROL_MODE, etc. +Client ↔ Server: PING/PONG (latency measurement) +``` + +## Events Reference + +### Connection & Room Management + +#### 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)" +} +``` + +**Server Response:** +- Success: `ROOM_DATA` event with room state +- Error: `ERROR` event with message + +**Rate Limit:** 10 attempts per IP per minute + +**Edge Cases:** +- Room ID sanitization (alphanumeric + hyphens only) +- Peer ID deduplication (kicks old socket for same peerId) +- Protocol version mismatch → disconnect + +#### LEAVE_ROOM (Client → Server) + +Leave the current room. + +**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"] +} +``` + +**Triggered by:** +- Successful JOIN_ROOM +- Room state changes (peer join/leave, mode change) + +#### ERROR (Server → Client) + +Error notification. + +**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", + "muted": "boolean", + "status": "joined" | "left" | "heartbeat" +} +``` + +**Frequency:** Every 15 seconds (HEARTBEAT_INTERVAL) + +**Purpose:** +- Keep-alive +- State synchronization +- Peer presence tracking + +#### FORCE_SYNC_PREPARE (Client → Server → Client) + +Initiate force sync sequence. + +**Payload:** +```json +{ + "targetTime": "number (seconds)" +} +``` + +**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 + +**Timeout:** 8.5 seconds (FORCE_SYNC_TIMEOUT) + +**Gated in host-only mode:** Only controllers can initiate + +#### FORCE_SYNC_ACK (Client → Server) + +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", + "controller": "boolean" +} +``` + +**Authorization:** Only owner (room.hostPeerId) can send + +**Server Action:** +- Updates room.controllers set +- Broadcasts CONTROL_MODE to all peers + +**Rate Limit:** 500ms debounce per room + +### Ping / Latency Measurement + +#### PING (Client → Server → Client) + +Measure round-trip time. + +**Payload:** +```json +{ + "t": "timestamp (Date.now())", + "target": "peerId (optional, empty = server echo)" +} +``` + +**Server Response:** PONG with same timestamp + +**Frequency:** Every 30 seconds + +#### PONG (Server → Client) + +Response to PING. + +**Payload:** +```json +{ + "t": "timestamp (from PING)" +} +``` + +### Administrative Events + +#### GET_ROOMS (Client → Server) + +Request list of active rooms (admin only). + +**Payload:** None + +**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" + } + ] +} +``` + +## Rate Limits + +### Connection Rate Limits + +- **Connections:** 10 per IP per minute +- **Authentication Attempts:** 5 per IP per room per minute +- **Event Rate:** 50 events per 10 seconds per socket + +### Specific Event Rate Limits + +- **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 + +### 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 diff --git a/docs/host-control-mode.md b/docs/host-control-mode.md new file mode 100644 index 0000000..f4b066f --- /dev/null +++ b/docs/host-control-mode.md @@ -0,0 +1,287 @@ +# 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. + +## Modes + +### `everyone` (Default) +- Anyone in the room can control playback +- All play/pause/seek actions are broadcast to all peers +- Traditional KoalaSync behavior + +### `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 + +### `SET_CONTROL_MODE` (Client → Server) + +Sent by the host to change the room's control mode. + +**Payload:** +```json +{ + "controlMode": "everyone" | "host-only" +} +``` + +**Authorization:** Only the host (room.hostPeerId) can send this event. + +**Server Response:** Broadcasts `CONTROL_MODE` to all peers in the room. + +### `CONTROL_MODE` (Server → Client) + +Broadcast when the control mode changes or when a peer joins a room. + +**Payload:** +```json +{ + "controlMode": "everyone" | "host-only", + "hostPeerId": "string", + "controllers": ["string"] +} +``` + +**Triggered by:** +- Host changing the control mode +- Host leaving the room (fallback to 'everyone') +- Controller promotion/demotion + +### `SET_PEER_ROLE` (Client → Server) + +Sent by the host to promote or demote a peer to/from controller status. + +**Payload:** +```json +{ + "peerId": "string", + "controller": "boolean" +} +``` + +**Authorization:** Only the host (room.hostPeerId) can send this event. + +**Server Response:** Broadcasts `CONTROL_MODE` to all peers in the room. + +## Server-Side Implementation + +### Room Object + +```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 +} +``` + +### Key Behaviors + +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 + +## Client-Side Implementation + +### State Management (background.js) + +```javascript +let controlMode = CONTROL_MODES.EVERYONE; +let hostPeerId = null; +let controllers = []; + +function amHost() { return hostPeerId === peerId; } +function amController() { return amHost() || controllers.includes(peerId); } +``` + +### Event Gating + +**Sender-side gate (background.js):** +- Blocks `PLAY`, `PAUSE`, `SEEK`, `FORCE_SYNC_*`, `EPISODE_LOBBY_*` from non-controllers +- Sends `HOST_BLOCKED` message to content script instead + +**Receiver-side gate (background.js):** +- Ignores gated events from non-controllers in host-only mode +- Prevents malicious or outdated clients from bypassing controls + +### Snap-Back Logic (content.js) + +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 + +### Host Sync Target Calculation + +```javascript +function getHostSyncTarget() { + const host = currentRoom.peers.find(p => p.peerId === hostPeerId); + if (!host) return null; + + let targetTime = host.currentTime; + + // 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; + } + } + + return { playbackState: host.playbackState, targetTime }; +} +``` + +## Edge Cases + +### 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 + +### 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 + +### 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 + +### 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 + +### 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 + +## User Experience + +### 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 + +### 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 +- [Internal Documentation](internal/host-control-mode-plan.md) — Original implementation plan (German) \ No newline at end of file diff --git a/docs/host-control-mode-COHOST-PLAN.md b/docs/internal/host-control-mode-COHOST-PLAN.md similarity index 100% rename from docs/host-control-mode-COHOST-PLAN.md rename to docs/internal/host-control-mode-COHOST-PLAN.md diff --git a/docs/host-control-mode-EDGECASES.md b/docs/internal/host-control-mode-EDGECASES.md similarity index 100% rename from docs/host-control-mode-EDGECASES.md rename to docs/internal/host-control-mode-EDGECASES.md diff --git a/docs/host-control-mode-TESTING.md b/docs/internal/host-control-mode-TESTING.md similarity index 100% rename from docs/host-control-mode-TESTING.md rename to docs/internal/host-control-mode-TESTING.md diff --git a/docs/host-control-mode-plan.md b/docs/internal/host-control-mode-plan.md similarity index 100% rename from docs/host-control-mode-plan.md rename to docs/internal/host-control-mode-plan.md diff --git a/package-lock.json b/package-lock.json index fbc12ce..a6ef087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,25 +1,60 @@ { "name": "koalasync", - "version": "2.2.4", + "version": "2.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "koalasync", - "version": "2.2.4", + "version": "2.5.1", "devDependencies": { "archiver": "^7.0.1", + "c8": "^11.0.0", "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" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1189,6 +1224,73 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1200,6 +1302,324 @@ "node": ">=14" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1214,6 +1634,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1221,6 +1648,119 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1338,6 +1878,16 @@ "node": ">= 14" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -1537,6 +2087,144 @@ "node": ">=8.0.0" } }, + "node_modules/c8": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", + "integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "20 || >=22" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1584,6 +2272,13 @@ "node": ">= 14" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -1834,6 +2529,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -1876,6 +2578,16 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2070,6 +2782,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2110,6 +2832,16 @@ "bare-events": "^2.7.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2138,6 +2870,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2221,6 +2971,31 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -2263,6 +3038,23 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2371,6 +3163,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -2491,6 +3322,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2521,6 +3625,32 @@ "dev": true, "license": "ISC" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -2561,6 +3691,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2591,6 +3740,20 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2685,6 +3848,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2692,6 +3862,48 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2769,6 +3981,50 @@ "node": ">=10" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2881,6 +4137,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2904,6 +4167,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/streamx": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", @@ -3030,6 +4307,19 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/svgo": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", @@ -3079,6 +4369,105 @@ "streamx": "^2.12.5" } }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -3089,6 +4478,50 @@ "b4a": "^1.6.4" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3137,6 +4570,189 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vite": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3153,6 +4769,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3261,6 +4894,90 @@ "node": ">=8" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7fbd047..63a56ad 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,17 @@ "build:extension": "node scripts/build-extension.cjs", "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "node scripts/verify-release.mjs", + "test": "vitest run", "verify": "node scripts/verify-release.mjs" }, "devDependencies": { "archiver": "^7.0.1", + "c8": "^11.0.0", "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" } } diff --git a/server/index.js b/server/index.js index 3a117f8..9958497 100644 --- a/server/index.js +++ b/server/index.js @@ -661,6 +661,10 @@ io.on('connection', (socket) => { }); socket.on(EVENTS.LEAVE_ROOM, () => { + if (!checkLeaveRoomRate(socket.id)) { + log('SECURITY', `LEAVE_ROOM rate limit exceeded for socket: ${socket.id}`); + return; + } try { const mapping = socketToRoom.get(socket.id); if (mapping) { @@ -919,12 +923,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); diff --git a/server/rate-limiter.js b/server/rate-limiter.js index 69bcf83..b1959f1 100644 --- a/server/rate-limiter.js +++ b/server/rate-limiter.js @@ -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 @@ -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) { + 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(); } diff --git a/server/rate-limiter.test.mjs b/server/rate-limiter.test.mjs new file mode 100644 index 0000000..ace553a --- /dev/null +++ b/server/rate-limiter.test.mjs @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + checkLeaveRoomRate, + LEAVE_ROOM_RATE_LIMIT, + LEAVE_ROOM_RATE_WINDOW_MS, + rateLimitDenied, + leaveRoomCounts +} from './rate-limiter.js'; + +describe('LEAVE_ROOM Rate Limiter', () => { + const testSocketId = 'test-socket-123'; + + beforeEach(() => { + // Reset state before each test + leaveRoomCounts.clear(); + rateLimitDenied.leaveRoom = 0; + }); + + afterEach(() => { + // Clean up after each test + leaveRoomCounts.clear(); + }); + + 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', () => { + // Fill up to the limit + for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) { + checkLeaveRoomRate(testSocketId); + } + + // First block + checkLeaveRoomRate(testSocketId); + expect(rateLimitDenied.leaveRoom).toBe(1); + + // Second block + checkLeaveRoomRate(testSocketId); + expect(rateLimitDenied.leaveRoom).toBe(2); + }); +}); + +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 + }); +}); \ No newline at end of file diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..128fda9 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['**/*.test.js', '**/*.test.mjs'], + exclude: ['**/scripts/test-*.*(mjs|cjs|js)'], + coverage: { + provider: 'c8', + reporter: ['text', 'lcov'], + include: ['server/**/*.js', 'shared/**/*.js'], + exclude: [ + '**/node_modules/**', + '**/scripts/**', + '**/extension/**' + ], + thresholds: { + functions: 30, + lines: 30, + branches: 25, + statements: 30 + } + } + } +}); \ No newline at end of file From dab73685377950ae22719578bd15a0011f3da9fe Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:46:23 +0200 Subject: [PATCH 2/6] fix: repair PR verification and docs cleanup --- .github/workflows/ci.yml | 9 +- docs/CHANGELOG.md | 2 +- docs/host-control-mode.md | 7 +- .../internal/host-control-mode-COHOST-PLAN.md | 118 ---- docs/internal/host-control-mode-EDGECASES.md | 283 --------- docs/internal/host-control-mode-TESTING.md | 83 --- docs/internal/host-control-mode-plan.md | 122 ---- eslint.config.mjs | 2 +- package-lock.json | 548 ++++-------------- package.json | 2 +- server/index.js | 3 +- server/rate-limiter.test.mjs | 4 +- vitest.config.mjs | 20 +- 13 files changed, 144 insertions(+), 1059 deletions(-) delete mode 100644 docs/internal/host-control-mode-COHOST-PLAN.md delete mode 100644 docs/internal/host-control-mode-EDGECASES.md delete mode 100644 docs/internal/host-control-mode-TESTING.md delete mode 100644 docs/internal/host-control-mode-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a16181..f5f6951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,9 +42,8 @@ jobs: run: npm ci working-directory: server - - name: Run tests - run: npm test - continue-on-error: true + - name: Run tests + run: npm test - - name: Run verification suite - run: npm run verify + - name: Run verification suite + run: npm run verify diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 588f701..cb16512 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -26,7 +26,7 @@ All notable changes to the KoalaSync browser extension and relay server. ### Changed - **Playback sync now follows the room's control setting** — When Host Control is enabled, only the host can drive room-wide playback changes; guests can still watch in sync without accidentally changing playback for everyone. - **Architecture: Modularized background.js** — Refactored 2410-line file into 11 focused modules for better maintainability. -- **Documentation: Consolidated Host Control Mode** — Unified documentation in host-control-mode.md with references to internal implementation details. +- **Documentation: Consolidated Host Control Mode** — Unified documentation in host-control-mode.md. - **README: Added protocol documentation** — Updated documentation links to include PROTOCOL.md reference. - **ARCHITECTURE: Added protocol reference** — Updated architecture documentation with link to PROTOCOL.md. diff --git a/docs/host-control-mode.md b/docs/host-control-mode.md index f4b066f..49bb6e3 100644 --- a/docs/host-control-mode.md +++ b/docs/host-control-mode.md @@ -123,9 +123,9 @@ When a guest's action is blocked: function getHostSyncTarget() { const host = currentRoom.peers.find(p => p.peerId === hostPeerId); if (!host) return null; - + let targetTime = host.currentTime; - + // Extrapolate if host is playing if (host.playbackState === 'playing' && host.lastHeartbeat) { const elapsedSec = (Date.now() - host.lastHeartbeat) / 1000; @@ -133,7 +133,7 @@ function getHostSyncTarget() { targetTime += elapsedSec; } } - + return { playbackState: host.playbackState, targetTime }; } ``` @@ -284,4 +284,3 @@ function getHostSyncTarget() { - [Protocol Specification](PROTOCOL.md) — Technical event details - [Architecture](ARCHITECTURE.md) — System overview -- [Internal Documentation](internal/host-control-mode-plan.md) — Original implementation plan (German) \ No newline at end of file diff --git a/docs/internal/host-control-mode-COHOST-PLAN.md b/docs/internal/host-control-mode-COHOST-PLAN.md deleted file mode 100644 index 854b9dc..0000000 --- a/docs/internal/host-control-mode-COHOST-PLAN.md +++ /dev/null @@ -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` — 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. diff --git a/docs/internal/host-control-mode-EDGECASES.md b/docs/internal/host-control-mode-EDGECASES.md deleted file mode 100644 index a6dfe8f..0000000 --- a/docs/internal/host-control-mode-EDGECASES.md +++ /dev/null @@ -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 `