mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-28 11:37:16 +00:00
feat(chat): add stateless ciphertext relay
This commit is contained in:
+36
-1
@@ -82,13 +82,47 @@ Payload:
|
|||||||
"hostPeerId": "string or null",
|
"hostPeerId": "string or null",
|
||||||
"controlMode": "everyone | host-only",
|
"controlMode": "everyone | host-only",
|
||||||
"controllers": ["peerId"],
|
"controllers": ["peerId"],
|
||||||
"capabilities": ["host-control", "co-host"]
|
"capabilities": ["host-control", "co-host", "chat"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`room_data` is sent to the joining socket. It is not the general broadcast used
|
`room_data` is sent to the joining socket. It is not the general broadcast used
|
||||||
for every later room update.
|
for every later room update.
|
||||||
|
|
||||||
|
## Ephemeral encrypted chat
|
||||||
|
|
||||||
|
Relays advertise chat support with `"chat"` in `room_data.capabilities`. Clients
|
||||||
|
must not infer support from another field.
|
||||||
|
|
||||||
|
### `chat_message`
|
||||||
|
|
||||||
|
Client to relay:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ciphertext": "<unpadded-base64url>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`ciphertext` contains a 12-byte AES-GCM IV followed by ciphertext and the 16-byte
|
||||||
|
authentication tag. The relay validates only canonical base64url and byte bounds.
|
||||||
|
It cannot inspect plaintext.
|
||||||
|
|
||||||
|
Relay to every current room peer, including the sender:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<server-generated UUID>",
|
||||||
|
"senderId": "<server-stamped peer ID>",
|
||||||
|
"timestamp": 1710000000000,
|
||||||
|
"ciphertext": "<unpadded-base64url>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Client-provided `id`, `senderId`, `timestamp`, or plaintext fields are discarded.
|
||||||
|
The relay keeps no message collection and `room_data` contains no chat history.
|
||||||
|
Messages are limited to 10 per socket per 10 seconds in addition to the global event
|
||||||
|
budget. There are no typing, read-receipt, history, or chat-specific peer-management
|
||||||
|
events.
|
||||||
|
|
||||||
## Room Leave
|
## Room Leave
|
||||||
|
|
||||||
### `leave_room` (client -> server)
|
### `leave_room` (client -> server)
|
||||||
@@ -378,5 +412,6 @@ If sender and target are still in the same room, the relay emits:
|
|||||||
|
|
||||||
- `host-control`
|
- `host-control`
|
||||||
- `co-host`
|
- `co-host`
|
||||||
|
- `chat`
|
||||||
|
|
||||||
Clients should treat a missing or unknown capabilities list as unsupported.
|
Clients should treat a missing or unknown capabilities list as unsupported.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -70,6 +71,38 @@ try {
|
|||||||
assert.equal(capEv, 'room_data');
|
assert.equal(capEv, 'room_data');
|
||||||
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
|
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
|
||||||
'ROOM_DATA advertises the host-control capability');
|
'ROOM_DATA advertises the host-control capability');
|
||||||
|
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
|
||||||
|
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
|
||||||
|
close();
|
||||||
|
resetConnectionRate();
|
||||||
|
|
||||||
|
// --- Encrypted chat is a live-only canonical relay ---
|
||||||
|
const chatRoom = 'chat-'+Date.now();
|
||||||
|
const chat1 = await c(), chat2 = await c();
|
||||||
|
await j(chat1, chatRoom, 'alice'); await j(chat2, chatRoom, 'bob');
|
||||||
|
chat1._m.length = chat2._m.length = 0;
|
||||||
|
const ciphertext = Buffer.alloc(64, 7).toString('base64url');
|
||||||
|
s(chat1, 'chat_message', {
|
||||||
|
ciphertext,
|
||||||
|
id: 'spoofed-id', senderId: 'mallory', timestamp: 1, text: 'plaintext'
|
||||||
|
});
|
||||||
|
const [chat1Event, chat1Data] = await a(chat1);
|
||||||
|
const [chat2Event, chat2Data] = await a(chat2);
|
||||||
|
assert.equal(chat1Event, 'chat_message', 'sender receives canonical chat envelope');
|
||||||
|
assert.equal(chat2Event, 'chat_message', 'peer receives canonical chat envelope');
|
||||||
|
assert.deepEqual(chat2Data, chat1Data, 'all peers receive the same canonical envelope');
|
||||||
|
assert.equal(chat1Data.senderId, 'alice', 'relay stamps senderId');
|
||||||
|
assert.equal(chat1Data.ciphertext, ciphertext, 'relay preserves ciphertext');
|
||||||
|
assert.equal(chat1Data.text, undefined, 'relay drops plaintext fields');
|
||||||
|
assert.notEqual(chat1Data.id, 'spoofed-id', 'relay replaces client IDs');
|
||||||
|
assert.ok(Number.isFinite(chat1Data.timestamp) && chat1Data.timestamp > 1, 'relay stamps timestamp');
|
||||||
|
|
||||||
|
const late = await c();
|
||||||
|
s(late, 'join_room', { roomId: chatRoom, peerId: 'late', protocolVersion: '1.0.0' });
|
||||||
|
const [lateEvent, lateRoomData] = await a(late);
|
||||||
|
assert.equal(lateEvent, 'room_data');
|
||||||
|
assert.equal(lateRoomData.chatHistory, undefined, 'late joiner gets no chat backlog');
|
||||||
|
assert.equal(late._m.some(raw => raw.includes('chat_message')), false, 'late joiner receives no old message');
|
||||||
close();
|
close();
|
||||||
resetConnectionRate();
|
resetConnectionRate();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
export const CHAT_CIPHERTEXT_MAX_BYTES = 2028;
|
||||||
|
export const CHAT_CIPHERTEXT_MIN_BYTES = 29;
|
||||||
|
|
||||||
|
export function normalizeChatCiphertext(value) {
|
||||||
|
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
|
||||||
|
const bytes = Buffer.from(value, 'base64url');
|
||||||
|
if (bytes.length < CHAT_CIPHERTEXT_MIN_BYTES || bytes.length > CHAT_CIPHERTEXT_MAX_BYTES) return null;
|
||||||
|
if (bytes.toString('base64url') !== value) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatEnvelope(data, senderId, now = Date.now, createId = randomUUID) {
|
||||||
|
if (!data || typeof data !== 'object' || typeof senderId !== 'string' || !senderId) return null;
|
||||||
|
const ciphertext = normalizeChatCiphertext(data.ciphertext);
|
||||||
|
if (!ciphertext) return null;
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
senderId,
|
||||||
|
timestamp: now(),
|
||||||
|
ciphertext
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import {
|
||||||
|
CHAT_CIPHERTEXT_MAX_BYTES,
|
||||||
|
createChatEnvelope,
|
||||||
|
normalizeChatCiphertext
|
||||||
|
} from './chat.js';
|
||||||
|
|
||||||
|
const encoded = (length) => Buffer.alloc(length, 7).toString('base64url');
|
||||||
|
|
||||||
|
describe('encrypted chat relay envelopes', () => {
|
||||||
|
it('accepts canonical base64url within the encrypted message bounds', () => {
|
||||||
|
expect(normalizeChatCiphertext(encoded(29))).toBe(encoded(29));
|
||||||
|
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES))).toBe(encoded(CHAT_CIPHERTEXT_MAX_BYTES));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed, padded, too-small, and oversized payloads', () => {
|
||||||
|
expect(normalizeChatCiphertext('<script>')).toBeNull();
|
||||||
|
expect(normalizeChatCiphertext(`${encoded(29)}=`)).toBeNull();
|
||||||
|
expect(normalizeChatCiphertext(encoded(28))).toBeNull();
|
||||||
|
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES + 1))).toBeNull();
|
||||||
|
expect(normalizeChatCiphertext('A')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps authoritative metadata and keeps only ciphertext from the sender', () => {
|
||||||
|
const ciphertext = encoded(64);
|
||||||
|
const envelope = createChatEnvelope({
|
||||||
|
ciphertext,
|
||||||
|
id: 'spoofed',
|
||||||
|
senderId: 'mallory',
|
||||||
|
timestamp: 1,
|
||||||
|
text: 'plaintext must not survive'
|
||||||
|
}, 'alice', () => 1234, () => 'server-id');
|
||||||
|
|
||||||
|
expect(envelope).toEqual({
|
||||||
|
id: 'server-id',
|
||||||
|
senderId: 'alice',
|
||||||
|
timestamp: 1234,
|
||||||
|
ciphertext
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(envelope)).not.toContain('plaintext');
|
||||||
|
});
|
||||||
|
});
|
||||||
+27
-1
@@ -5,6 +5,7 @@ import { Server } from 'socket.io';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
||||||
|
import { createChatEnvelope } from './chat.js';
|
||||||
import {
|
import {
|
||||||
buildHealthPayload,
|
buildHealthPayload,
|
||||||
checkCooldown,
|
checkCooldown,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
connectionCounts,
|
connectionCounts,
|
||||||
failedAuthAttempts,
|
failedAuthAttempts,
|
||||||
eventCounts,
|
eventCounts,
|
||||||
|
chatMessageCounts,
|
||||||
healthCounts,
|
healthCounts,
|
||||||
adminMetricsAuthCounts,
|
adminMetricsAuthCounts,
|
||||||
roomListCooldowns,
|
roomListCooldowns,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
recordAuthFailure,
|
recordAuthFailure,
|
||||||
checkConnectionRate,
|
checkConnectionRate,
|
||||||
checkEventRate,
|
checkEventRate,
|
||||||
|
checkChatMessageRate,
|
||||||
checkHealthRate,
|
checkHealthRate,
|
||||||
checkAdminMetricsAuthRate,
|
checkAdminMetricsAuthRate,
|
||||||
checkLeaveRoomRate,
|
checkLeaveRoomRate,
|
||||||
@@ -171,7 +174,7 @@ const HOST_ONLY_GATED_EVENTS = new Set([
|
|||||||
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
||||||
// enable matching UI/behavior only when the server actually backs it. Append a
|
// enable matching UI/behavior only when the server actually backs it. Append a
|
||||||
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
||||||
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST];
|
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST, CAPABILITIES.CHAT];
|
||||||
|
|
||||||
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
||||||
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
||||||
@@ -844,8 +847,31 @@ io.on('connection', (socket) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on(EVENTS.CHAT_MESSAGE, (data) => {
|
||||||
|
try {
|
||||||
|
if (!checkEventRate(socket.id) || !checkChatMessageRate(socket.id)) {
|
||||||
|
log('SECURITY', `Encrypted chat rate limit exceeded for socket: ${socket.id}`);
|
||||||
|
socket.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = socketToRoom.get(socket.id);
|
||||||
|
if (!mapping) return;
|
||||||
|
const room = rooms.get(mapping.roomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const envelope = createChatEnvelope(data, mapping.peerId);
|
||||||
|
if (!envelope) return;
|
||||||
|
room.lastActivity = Date.now();
|
||||||
|
io.to(mapping.roomId).emit(EVENTS.CHAT_MESSAGE, envelope);
|
||||||
|
} catch (err) {
|
||||||
|
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
eventCounts.delete(socket.id);
|
eventCounts.delete(socket.id);
|
||||||
|
chatMessageCounts.delete(socket.id);
|
||||||
roomListCooldowns.delete(socket.id);
|
roomListCooldowns.delete(socket.id);
|
||||||
leaveRoomCounts.delete(socket.id);
|
leaveRoomCounts.delete(socket.id);
|
||||||
const mapping = socketToRoom.get(socket.id);
|
const mapping = socketToRoom.get(socket.id);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP
|
|||||||
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
|
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
|
||||||
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
|
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
|
||||||
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
|
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||||
|
export const CHAT_MESSAGE_RATE_LIMIT = 10; // max encrypted chat messages per socket per window
|
||||||
|
export const CHAT_MESSAGE_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||||
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
|
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
|
||||||
export const ADMIN_METRICS_AUTH_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_LIMIT = 10; // max LEAVE_ROOM events per socket per window
|
||||||
@@ -20,6 +22,7 @@ export const LEAVE_ROOM_RATE_WINDOW_MS = 60000; // 1 minute
|
|||||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||||
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||||
|
export const chatMessageCounts = new Map(); // socketId -> { count, resetTime }
|
||||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||||
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||||
@@ -28,6 +31,7 @@ export const leaveRoomCounts = new Map(); // socketId -> { count, resetTime }
|
|||||||
export const rateLimitDenied = {
|
export const rateLimitDenied = {
|
||||||
connections: 0,
|
connections: 0,
|
||||||
events: 0,
|
events: 0,
|
||||||
|
chatMessages: 0,
|
||||||
health: 0,
|
health: 0,
|
||||||
adminMetricsAuth: 0,
|
adminMetricsAuth: 0,
|
||||||
roomList: 0,
|
roomList: 0,
|
||||||
@@ -104,6 +108,17 @@ export function checkEventRate(socketId) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkChatMessageRate(socketId) {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = chatMessageCounts.get(socketId) || { count: 0, resetTime: now + CHAT_MESSAGE_RATE_WINDOW_MS };
|
||||||
|
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CHAT_MESSAGE_RATE_WINDOW_MS; }
|
||||||
|
entry.count++;
|
||||||
|
chatMessageCounts.set(socketId, entry);
|
||||||
|
if (entry.count <= CHAT_MESSAGE_RATE_LIMIT) return true;
|
||||||
|
rateLimitDenied.chatMessages++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function checkHealthRate(ip) {
|
export function checkHealthRate(ip) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
|
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
|
||||||
@@ -163,6 +178,11 @@ export function startRateLimitCleanup(io) {
|
|||||||
eventCounts.delete(socketId);
|
eventCounts.delete(socketId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const [socketId, entry] of chatMessageCounts.entries()) {
|
||||||
|
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||||
|
chatMessageCounts.delete(socketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const [socketId, entry] of leaveRoomCounts.entries()) {
|
for (const [socketId, entry] of leaveRoomCounts.entries()) {
|
||||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||||
leaveRoomCounts.delete(socketId);
|
leaveRoomCounts.delete(socketId);
|
||||||
@@ -189,6 +209,7 @@ export function clearRateLimitMaps() {
|
|||||||
connectionCounts.clear();
|
connectionCounts.clear();
|
||||||
failedAuthAttempts.clear();
|
failedAuthAttempts.clear();
|
||||||
eventCounts.clear();
|
eventCounts.clear();
|
||||||
|
chatMessageCounts.clear();
|
||||||
|
|
||||||
healthCounts.clear();
|
healthCounts.clear();
|
||||||
adminMetricsAuthCounts.clear();
|
adminMetricsAuthCounts.clear();
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import {
|
import {
|
||||||
checkLeaveRoomRate,
|
checkLeaveRoomRate,
|
||||||
|
checkChatMessageRate,
|
||||||
|
CHAT_MESSAGE_RATE_LIMIT,
|
||||||
|
CHAT_MESSAGE_RATE_WINDOW_MS,
|
||||||
|
chatMessageCounts,
|
||||||
LEAVE_ROOM_RATE_LIMIT,
|
LEAVE_ROOM_RATE_LIMIT,
|
||||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||||
rateLimitDenied,
|
rateLimitDenied,
|
||||||
@@ -99,6 +103,32 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CHAT_MESSAGE Rate Limiter', () => {
|
||||||
|
const socketId = 'chat-socket';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearRateLimitMaps();
|
||||||
|
rateLimitDenied.chatMessages = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => clearRateLimitMaps());
|
||||||
|
|
||||||
|
it('allows ten messages per ten-second window and blocks the next', () => {
|
||||||
|
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
|
||||||
|
expect(checkChatMessageRate(socketId)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(checkChatMessageRate(socketId)).toBe(false);
|
||||||
|
expect(rateLimitDenied.chatMessages).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets after the window expires', () => {
|
||||||
|
for (let i = 0; i <= CHAT_MESSAGE_RATE_LIMIT; i++) checkChatMessageRate(socketId);
|
||||||
|
const entry = chatMessageCounts.get(socketId);
|
||||||
|
entry.resetTime = Date.now() - CHAT_MESSAGE_RATE_WINDOW_MS - 1;
|
||||||
|
expect(checkChatMessageRate(socketId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Rate Limit Constants', () => {
|
describe('Rate Limit Constants', () => {
|
||||||
it('should have correct rate limit values', () => {
|
it('should have correct rate limit values', () => {
|
||||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||||
|
|||||||
+5
-1
@@ -57,6 +57,9 @@ export const EVENTS = {
|
|||||||
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
||||||
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
|
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
|
||||||
|
|
||||||
|
// Ephemeral end-to-end encrypted chat
|
||||||
|
CHAT_MESSAGE: "chat_message", // Ciphertext relay; no server history
|
||||||
|
|
||||||
// Ping / Latency
|
// Ping / Latency
|
||||||
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
|
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
|
||||||
PONG: "pong" // server responds with same { t } for client RTT calculation
|
PONG: "pong" // server responds with same { t } for client RTT calculation
|
||||||
@@ -77,7 +80,8 @@ export const CONTROL_MODES = {
|
|||||||
// simply ignore the field. Add a flag here as each server-gated feature lands.
|
// simply ignore the field. Add a flag here as each server-gated feature lands.
|
||||||
export const CAPABILITIES = {
|
export const CAPABILITIES = {
|
||||||
HOST_CONTROL: 'host-control',
|
HOST_CONTROL: 'host-control',
|
||||||
CO_HOST: 'co-host' // owner promotes guests to additional controllers
|
CO_HOST: 'co-host', // owner promotes guests to additional controllers
|
||||||
|
CHAT: 'chat'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||||
|
|||||||
Reference in New Issue
Block a user