diff --git a/extension/background.js b/extension/background.js index 3cc6dbb..f2bf39c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -6,6 +6,7 @@ import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, norm import { initTabManager } from './modules/tab-manager.js'; import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js'; import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; +import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js'; import './page-api-seek-overrides.js'; // --- Uninstall URL Initialization --- @@ -82,7 +83,26 @@ let hostPeerId = null; // peerId of the room host (creator / // relay (no capabilities field) → host-control UI/behavior stays unavailable. let serverCapabilities = []; let chatSecretGuard = ''; +let chatSessionGeneration = 0; +let chatReceiveQueue = Promise.resolve(); +const chatSendLimiter = createChatSendLimiter(); +const webJoinCoordinator = createLatestTaskQueue(); function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); } + +function invalidateChatSession() { + chatSessionGeneration++; + chatReceiveQueue = Promise.resolve(); + chatSendLimiter.reset(); + clearChatKeyCache(); +} + +async function clearFailedJoinCredentials() { + webJoinCoordinator.invalidate(); + connectIntent = false; + chatSecretGuard = ''; + invalidateChatSession(); + await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); +} // Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed // in heartbeats so the host's popup UI can show "Solo" instead of silently // appearing un-ACK'd. @@ -377,10 +397,12 @@ async function getSettings() { const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode); const chatKey = validateChatSecret(data.chatKey); chatSecretGuard = chatKey; + const roomId = normalizeRoomId(data.roomId); + if (data.roomId && data.roomId !== roomId) chrome.storage.local.set({ roomId }).catch(() => {}); return { serverUrl: data.serverUrl || '', useCustomServer: data.useCustomServer || false, - roomId: data.roomId || '', + roomId, password: data.password || '', chatKey, username, @@ -474,6 +496,7 @@ function forceDisconnect() { currentServerUrl = null; isConnecting = false; isNamespaceJoined = false; + invalidateChatSession(); isForceSyncInitiator = false; expectedAcksCount = 0; roomIdleSince = null; @@ -567,7 +590,7 @@ async function leaveRoomAfterIdleGrace(reason) { hcmDesynced: false }).catch(() => {}); chatSecretGuard = ''; - clearChatKeyCache(); + invalidateChatSession(); await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); addLog(reason, 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); @@ -709,6 +732,7 @@ async function connect() { socket.onclose = () => { isConnecting = false; isNamespaceJoined = false; + invalidateChatSession(); stopPing(); if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } @@ -790,6 +814,7 @@ function broadcastConnectionStatus(status) { status = 'idle'; } chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {}); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CONNECTION_STATUS', status }).catch(() => {}); updateBadgeStatus(); } @@ -1045,6 +1070,7 @@ async function handleServerEvent(event, data) { } switch (event) { case EVENTS.ROOM_DATA: + if (currentRoom?.roomId !== data.roomId) invalidateChatSession(); currentRoom = data; // Host Control Mode: adopt room role/mode on (re)join. controlMode = data.controlMode || CONTROL_MODES.EVERYONE; @@ -1131,42 +1157,54 @@ async function handleServerEvent(event, data) { break; case EVENTS.CHAT_MESSAGE: { if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break; - const settings = await getSettings(); - if (!settings.chatKey) break; - try { - const text = await decryptChatMessage({ - ciphertext: data.ciphertext, - roomId: currentRoom.roomId, - senderId: data.senderId, - secret: settings.chatKey - }); - const senderPeer = currentRoom.peers?.find(candidate => - (typeof candidate === 'object' ? candidate.peerId : candidate) === data.senderId - ); - const tabId = Number(currentTabId); - if (Number.isInteger(tabId)) { - chrome.tabs.sendMessage(tabId, { - type: 'CHAT_MESSAGE', - message: { - id: data.id, - senderId: data.senderId, - username: typeof senderPeer === 'object' ? senderPeer.username : null, - timestamp: data.timestamp, - text - } - }).catch(() => {}); + const generation = chatSessionGeneration; + const roomId = currentRoom.roomId; + const tabId = Number(currentTabId); + const received = { ...data }; + const isCurrentSession = () => generation === chatSessionGeneration && + currentRoom?.roomId === roomId && Number(currentTabId) === tabId; + chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => { + if (!isCurrentSession()) return; + const settings = await getSettings(); + if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return; + const chatKey = settings.chatKey; + try { + const text = await decryptChatMessage({ + ciphertext: received.ciphertext, + roomId, + senderId: received.senderId, + secret: chatKey + }); + if (!isCurrentSession() || chatSecretGuard !== chatKey) return; + const senderPeer = currentRoom.peers?.find(candidate => + (typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId + ); + if (Number.isInteger(tabId)) { + chrome.tabs.sendMessage(tabId, { + type: 'CHAT_MESSAGE', + message: { + id: received.id, + senderId: received.senderId, + username: typeof senderPeer === 'object' ? senderPeer.username : null, + timestamp: received.timestamp, + text + } + }).catch(() => {}); + } + } catch (_) { + if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn'); } - } catch (_) { - addLog('Discarded chat message that failed authentication', 'warn'); - } + }); + await chatReceiveQueue; break; } case EVENTS.ERROR: isConnecting = false; // If we get a server error before successfully joining a room, - // clear connectIntent to prevent an infinite reconnect loop. - if (!currentRoom) { - connectIntent = false; + // clear persisted credentials as well, otherwise service-worker + // restart would immediately retry the rejected room. + if (!currentRoom && connectIntent) { + await clearFailedJoinCredentials(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } reconnectAttempts = 0; reconnectFailed = false; @@ -1910,7 +1948,7 @@ function leaveOldRoomIfSwitching(newRoomId) { hostPeerId = null; controllers = []; serverCapabilities = []; - clearChatKeyCache(); + invalidateChatSession(); hcmDesynced = false; // Notify content.js/popup so they drop any guest-side HCM state from the // previous room (badge/dialog/desync) — H-2/H-3. @@ -1952,21 +1990,33 @@ async function applyAudioSettingsToTab(tabId) { // --- Extension Message Listeners --- chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - handleAsyncMessage(message, sender, sendResponse); + handleAsyncMessage(message, sender, sendResponse).catch(error => { + addLog(`Message handler failed for ${message?.type || 'unknown'}: ${error.message}`, 'error'); + try { sendResponse({ status: 'error' }); } catch (_) { /* channel already closed */ } + }); return true; // Keep channel open for async responses }); +chrome.storage.onChanged.addListener((changes, area) => { + if (area !== 'local' || (!changes.roomId && !changes.chatKey)) return; + if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue); + invalidateChatSession(); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); +}); + async function handleAsyncMessage(message, sender, sendResponse) { if (!message) return; await ensureState(); if (message.type === 'CONNECT') { + webJoinCoordinator.invalidate(); const settings = await getSettings(); connectIntent = !!settings.roomId; const desiredUrl = resolveServerUrl(settings); if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { broadcastConnectionStatus('connected'); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve)); for (const tab of tabs) { chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); @@ -2053,6 +2103,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ supported: serverSupports(CAPABILITIES.CHAT), hasKey: !!settings.chatKey, + connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined), peerId, roomId: currentRoom.roomId, strings: { @@ -2082,18 +2133,39 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'unsupported' }); return; } + if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { + sendResponse({ status: 'disconnected' }); + return; + } + const generation = chatSessionGeneration; + const roomId = currentRoom.roomId; + const tabId = Number(currentTabId); + const socketSnapshot = socket; + const isCurrentSession = () => generation === chatSessionGeneration && + currentRoom?.roomId === roomId && Number(currentTabId) === tabId && + socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined; const settings = await getSettings(); - if (!settings.chatKey) { - sendResponse({ status: 'missing_key' }); + if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) { + sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' }); + return; + } + const chatKey = settings.chatKey; + const rateLimit = chatSendLimiter.take(); + if (!rateLimit.allowed) { + sendResponse({ status: 'rate_limited', retryAfterMs: rateLimit.retryAfterMs }); return; } try { const ciphertext = await encryptChatMessage({ text: message.text, - roomId: currentRoom.roomId, + roomId, senderId: peerId, - secret: settings.chatKey + secret: chatKey }); + if (!isCurrentSession() || chatSecretGuard !== chatKey) { + sendResponse({ status: 'session_changed' }); + return; + } const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext)); sendResponse({ status: sent ? 'ok' : 'disconnected' }); } catch (err) { @@ -2102,7 +2174,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'CREATE_CHAT_KEY') { const chatKey = generateChatSecret(); chatSecretGuard = chatKey; - clearChatKeyCache(); + invalidateChatSession(); await chrome.storage.local.set({ chatKey }); sendResponse({ status: 'ok', chatKey }); } else if (message.type === 'SET_CONTROL_MODE') { @@ -2174,6 +2246,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (storageInitialized) chrome.storage.session.set({ hcmDesynced }); sendResponse({ status: 'ok' }); } else if (message.type === 'LEAVE_ROOM') { + webJoinCoordinator.invalidate(); connectIntent = false; reconnectFailed = false; reconnectAttempts = 0; @@ -2219,7 +2292,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { hcmDesynced: false }); chatSecretGuard = ''; - clearChatKeyCache(); + invalidateChatSession(); chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); addLog('Left Room', 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); @@ -2237,7 +2310,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'ok' }); } else if (message.type === 'WEB_JOIN_REQUEST') { const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message; - const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : ''; + const roomId = normalizeRoomId(rawRoomId); const chatKey = validateChatSecret(rawChatKey); if (!roomId) { const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' }; @@ -2248,53 +2321,77 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'invalid_room_id' }); return; } - connectIntent = true; - chrome.storage.local.set({ - roomId, - password, - chatKey, - useCustomServer: !!useCustomServer, - serverUrl: serverUrl || '' - }, async () => { - chatSecretGuard = chatKey; - clearChatKeyCache(); - const settings = await getSettings(); - const desiredUrl = resolveServerUrl(settings); - - if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { - broadcastConnectionStatus('connected'); - if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); - const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve)); - for (const tab of tabs) { - chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); - } - sendResponse({ status: 'already_joined' }); - return; + await webJoinCoordinator.run(async isCurrentJoin => { + if (!isCurrentJoin()) { + sendResponse({ status: 'superseded' }); + return { status: 'superseded' }; } - - reconnectFailed = false; - reconnectStartTime = null; - reconnectAttempts = 0; - chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); - broadcastConnectionStatus('connecting'); - leaveOldRoomIfSwitching(roomId); - - if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { - if (desiredUrl !== currentServerUrl) forceDisconnect(); - connect(); - } else if (roomId) { - const sharedTitles = getSharedTitleFields(settings); - emit(EVENTS.JOIN_ROOM, { - roomId, - password, - peerId, - username: settings.username, - tabTitle: sharedTitles.tabTitle, - protocolVersion: PROTOCOL_VERSION + try { + connectIntent = true; + await chrome.storage.local.set({ + roomId, + password: typeof password === 'string' ? password : '', + chatKey, + useCustomServer: !!useCustomServer, + serverUrl: typeof serverUrl === 'string' ? serverUrl : '' }); + if (!isCurrentJoin()) { + sendResponse({ status: 'superseded' }); + return { status: 'superseded' }; + } + chatSecretGuard = chatKey; + invalidateChatSession(); + const settings = await getSettings(); + if (!isCurrentJoin()) { + sendResponse({ status: 'superseded' }); + return { status: 'superseded' }; + } + const desiredUrl = resolveServerUrl(settings); + + if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { + broadcastConnectionStatus('connected'); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve)); + if (!isCurrentJoin()) { + sendResponse({ status: 'superseded' }); + return { status: 'superseded' }; + } + for (const tab of tabs) { + chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); + } + sendResponse({ status: 'already_joined' }); + return { status: 'already_joined' }; + } + + reconnectFailed = false; + reconnectStartTime = null; + reconnectAttempts = 0; + chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); + broadcastConnectionStatus('connecting'); + leaveOldRoomIfSwitching(roomId); + + if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { + if (desiredUrl !== currentServerUrl) forceDisconnect(); + connect(); + } else if (roomId) { + const sharedTitles = getSharedTitleFields(settings); + emit(EVENTS.JOIN_ROOM, { + roomId, + password, + peerId, + username: settings.username, + tabTitle: sharedTitles.tabTitle, + protocolVersion: PROTOCOL_VERSION + }); + } + addLog(`Joining room via link: ${roomId}`, 'info'); + sendResponse({ status: 'ok' }); + return { status: 'ok' }; + } catch (_) { + if (isCurrentJoin()) await clearFailedJoinCredentials(); + sendResponse({ status: 'storage_error' }); + return { status: 'storage_error' }; } - addLog(`Joining room via link: ${roomId}`, 'info'); - sendResponse({ status: 'ok' }); }); } else if (message.type === 'REGENERATE_ID') { // Match getPeerId()'s 16-hex-char generation — see comment there. diff --git a/extension/chat-crypto.js b/extension/chat-crypto.js index c06443d..9129884 100644 --- a/extension/chat-crypto.js +++ b/extension/chat-crypto.js @@ -4,9 +4,10 @@ const IV_BYTES = 12; const MIN_ENCRYPTED_BYTES = 29; const MAX_ENCRYPTED_BYTES = 2028; -let cachedKey = null; +let cachedKeyPromise = null; let cachedRoomId = ''; let cachedSecret = ''; +let cacheGeneration = 0; function bytesToBase64Url(bytes) { let binary = ''; @@ -50,7 +51,8 @@ export function normalizeOutgoingChatText(value) { } export function clearChatKeyCache() { - cachedKey = null; + cacheGeneration++; + cachedKeyPromise = null; cachedRoomId = ''; cachedSecret = ''; } @@ -58,21 +60,30 @@ export function clearChatKeyCache() { export async function deriveChatKey(roomId, secret, cryptoImpl = globalThis.crypto) { const validSecret = validateChatSecret(secret); if (!roomId || !validSecret) throw new TypeError('Valid roomId and chat secret are required'); - if (cachedKey && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKey; + if (cachedKeyPromise && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKeyPromise; const encoder = new globalThis.TextEncoder(); - const material = await cryptoImpl.subtle.importKey( - 'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey'] - ); - cachedKey = await cryptoImpl.subtle.deriveKey({ - name: 'HKDF', - hash: 'SHA-256', - salt: encoder.encode(roomId), - info: encoder.encode(CHAT_INFO) - }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + const generation = cacheGeneration; + const keyPromise = (async () => { + const material = await cryptoImpl.subtle.importKey( + 'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey'] + ); + return cryptoImpl.subtle.deriveKey({ + name: 'HKDF', + hash: 'SHA-256', + salt: encoder.encode(roomId), + info: encoder.encode(CHAT_INFO) + }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + })(); + cachedKeyPromise = keyPromise; cachedRoomId = roomId; cachedSecret = validSecret; - return cachedKey; + try { + return await keyPromise; + } catch (error) { + if (generation === cacheGeneration && cachedKeyPromise === keyPromise) clearChatKeyCache(); + throw error; + } } export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) { @@ -106,7 +117,6 @@ export async function decryptChatMessage({ ciphertext, roomId, senderId, secret iv: bytes.slice(0, IV_BYTES), additionalData: encoder.encode(`${roomId}|${senderId}`) }, key, bytes.slice(IV_BYTES)); - const text = new globalThis.TextDecoder().decode(plaintext); - if (!text || countCodePoints(text) > 500) throw new RangeError('Decrypted chat message exceeds client limits'); - return text; + const text = new globalThis.TextDecoder('utf-8', { fatal: true }).decode(plaintext); + return normalizeOutgoingChatText(text); } diff --git a/extension/chat-crypto.test.mjs b/extension/chat-crypto.test.mjs index e5c6caa..a2e205d 100644 --- a/extension/chat-crypto.test.mjs +++ b/extension/chat-crypto.test.mjs @@ -35,6 +35,33 @@ describe('chat crypto', () => { await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text); }); + it('deduplicates in-flight key derivations without restoring a cleared cache', async () => { + const secret = generateChatSecret(webcrypto); + let deriveCalls = 0; + let releaseDerive; + const delayedCrypto = { + ...webcrypto, + subtle: { + importKey: (...args) => webcrypto.subtle.importKey(...args), + deriveKey: async (...args) => { + deriveCalls++; + await new Promise(resolve => { releaseDerive = resolve; }); + return webcrypto.subtle.deriveKey(...args); + } + } + }; + const first = deriveChatKey('ROOM-1', secret, delayedCrypto); + const second = deriveChatKey('ROOM-1', secret, delayedCrypto); + await Promise.resolve(); + expect(deriveCalls).toBe(1); + clearChatKeyCache(); + releaseDerive(); + await Promise.all([first, second]); + const fresh = deriveChatKey('ROOM-1', secret, webcrypto); + expect(fresh).not.toBe(first); + await fresh; + }); + it('rejects relabeling, cross-room replay, and the wrong secret', async () => { const secret = generateChatSecret(webcrypto); const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto); @@ -61,4 +88,27 @@ describe('chat crypto', () => { const bytes = Buffer.from(ciphertext, 'base64url'); expect(bytes).toHaveLength(2028); }); + + it('rejects non-canonical plaintext and malformed UTF-8 after authentication', async () => { + const secret = generateChatSecret(webcrypto); + const roomId = 'ROOM-1'; + const senderId = 'alice'; + const key = await deriveChatKey(roomId, secret, webcrypto); + const encoder = new globalThis.TextEncoder(); + + async function encryptRaw(plaintext) { + const iv = webcrypto.getRandomValues(new Uint8Array(12)); + const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({ + name: 'AES-GCM', + iv, + additionalData: encoder.encode(`${roomId}|${senderId}`) + }, key, plaintext)); + return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString('base64url'); + } + + const whitespace = await encryptRaw(encoder.encode(' ')); + await expect(decryptChatMessage({ ciphertext: whitespace, roomId, senderId, secret }, webcrypto)).rejects.toThrow(TypeError); + const malformed = await encryptRaw(Uint8Array.of(0xff)); + await expect(decryptChatMessage({ ciphertext: malformed, roomId, senderId, secret }, webcrypto)).rejects.toThrow(); + }); }); diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs index 8604c1c..9f093f4 100644 --- a/extension/chat-overlay-contract.test.mjs +++ b/extension/chat-overlay-contract.test.mjs @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; const extensionDir = path.dirname(new URL(import.meta.url).pathname); const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8'); const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8'); +const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8'); const localeDir = path.join(extensionDir, 'locales'); const chatKeys = [ 'CHAT_TITLE', @@ -44,6 +45,33 @@ describe('chat overlay contract', () => { expect(overlaySource).toContain("#app[data-palette=\"graphite\"][data-theme=\"light\"]"); expect(overlaySource).toContain('const MAX_MESSAGES = 200'); expect(overlaySource).toContain('while (messages.querySelectorAll(\'.message\').length > MAX_MESSAGES)'); + expect(overlaySource).toContain('Math.max(1, window.innerWidth)'); + expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))'); + }); + + it('guards async refresh/send work and clears all composer state on room reset', () => { + expect(overlaySource).toContain('generation !== refreshGeneration'); + expect(overlaySource).toContain('if (sending || !context?.enabled) return'); + expect(overlaySource).toContain('textarea.value === submittedValue'); + expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/); + expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)'); + expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then'); + expect(backgroundSource).toContain("status: 'rate_limited'"); + }); + + it('keeps unavailable chat controls discoverable to assistive technology', () => { + expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'"); + expect(overlaySource).not.toContain('launcher.disabled ='); + expect(overlaySource).toContain("launcher.setAttribute('aria-describedby', launcherHint.id)"); + expect(overlaySource).toContain("textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status')"); + expect(overlaySource).toContain("status.setAttribute('role', 'status')"); + }); + + it('creates a chat key for both generated-room entry points', () => { + expect(popupSource).toContain('let pendingRoomCreation = false'); + expect(popupSource).toContain('const isCreating = pendingRoomCreation || !roomIdInput'); + expect(popupSource).toMatch(/function handleCreateRoom\(\)[\s\S]*pendingRoomCreation = true[\s\S]*elements\.joinBtn\.click\(\)/); + expect(popupSource).toContain("type: 'CREATE_CHAT_KEY'"); }); it('contains every chat string in all 15 extension locales', () => { diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js index c6be649..d2e7ed2 100644 --- a/extension/chat-overlay.js +++ b/extension/chat-overlay.js @@ -16,6 +16,9 @@ let destroyed = false; let saveTimer = null; let applyingLayout = false; + let refreshGeneration = 0; + let sendGeneration = 0; + let sending = false; let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, detachedInitialized: false }; let themeMode = 'system'; let themePalette = 'eucalyptus'; @@ -85,11 +88,11 @@ border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer; pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); font-size: 22px; } - .launcher:hover:not(:disabled) { border-color: var(--accent); transform: translateY(-1px); } - .launcher:disabled { cursor: not-allowed; opacity: .58; } + .launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); } + .launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; } .panel { position: fixed; display: none; flex-direction: column; overflow: hidden; - min-width: ${MIN_WIDTH}px; min-height: ${MIN_HEIGHT}px; max-width: calc(100vw - 16px); + min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text); background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px; box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0); @@ -120,6 +123,7 @@ .send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; } .send:hover:not(:disabled) { background: var(--accent-hover); } .send:disabled { opacity: .55; cursor: wait; } + .visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } @media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } `; @@ -135,6 +139,8 @@ app.id = 'app'; const launcher = element('button', 'launcher', '💬'); launcher.type = 'button'; + const launcherHint = element('span', 'visually-hidden'); + launcherHint.id = 'chat-launcher-hint'; const panel = element('section', 'panel'); panel.setAttribute('role', 'dialog'); const header = element('header', 'header'); @@ -155,28 +161,43 @@ const empty = element('div', 'empty'); messages.append(empty); const composer = element('form', 'composer'); + const textareaLabel = element('label', 'visually-hidden'); + textareaLabel.htmlFor = 'chat-composer-input'; const textarea = element('textarea'); + textarea.id = 'chat-composer-input'; + textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status'); const composerRow = element('div', 'composer-row'); const count = element('span', 'count', '0/500'); + count.id = 'chat-composer-count'; const sendButton = element('button', 'send'); sendButton.type = 'submit'; composerRow.append(count, sendButton); const status = element('div', 'status'); - composer.append(textarea, composerRow, status); + status.id = 'chat-composer-status'; + status.setAttribute('role', 'status'); + composer.append(textareaLabel, textarea, composerRow, status); panel.append(header, messages, composer); - app.append(launcher, panel); + app.append(launcherHint, launcher, panel); shadow.append(style, app); document.documentElement.append(host); - function messageRuntime(payload) { + function messageRuntime(payload, timeoutMs = 5000) { return new Promise(resolve => { + let settled = false; + const finish = response => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(response || null); + }; + const timeout = setTimeout(() => finish(null), timeoutMs); try { chrome.runtime.sendMessage(payload, response => { - if (chrome.runtime.lastError) resolve(null); - else resolve(response || null); + if (chrome.runtime.lastError) finish(null); + else finish(response); }); } catch (_) { - resolve(null); + finish(null); } }); } @@ -190,9 +211,16 @@ title.textContent = text.title || ''; subtitle.textContent = text.liveOnly || ''; launcher.setAttribute('aria-label', text.open || ''); - launcher.title = context?.supported && !context?.hasKey ? (text.missingKey || '') : (text.open || ''); + const unavailableHint = context?.supported && !context?.hasKey + ? (text.missingKey || '') + : context?.supported && !context?.connected ? (text.sendFailed || '') : ''; + launcher.title = unavailableHint || text.open || ''; + launcherHint.textContent = unavailableHint; + if (unavailableHint) launcher.setAttribute('aria-describedby', launcherHint.id); + else launcher.removeAttribute('aria-describedby'); panel.setAttribute('aria-label', text.title || ''); textarea.placeholder = text.placeholder || ''; + textareaLabel.textContent = text.placeholder || text.title || ''; sendButton.textContent = text.send || ''; empty.textContent = text.empty || ''; leftButton.title = text.dockLeft || ''; @@ -212,15 +240,19 @@ } function viewportBounds() { - return { width: Math.max(320, window.innerWidth), height: Math.max(360, window.innerHeight) }; + return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) }; } function clampDetached() { const viewport = viewportBounds(); - layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, MIN_WIDTH), Math.min(600, viewport.width - 16)); - layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, MIN_HEIGHT), viewport.height - 16); - layout.x = Math.min(Math.max(Number(layout.x) || 8, 8), Math.max(8, viewport.width - layout.width - 8)); - layout.y = Math.min(Math.max(Number(layout.y) || 8, 8), Math.max(8, viewport.height - layout.height - 8)); + const maxWidth = Math.max(1, Math.min(600, viewport.width - 16)); + const maxHeight = Math.max(1, viewport.height - 16); + const horizontalInset = Math.min(8, Math.max(0, Math.floor((viewport.width - 1) / 2))); + const verticalInset = Math.min(8, Math.max(0, Math.floor((viewport.height - 1) / 2))); + layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, Math.min(MIN_WIDTH, maxWidth)), maxWidth); + layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, Math.min(MIN_HEIGHT, maxHeight)), maxHeight); + layout.x = Math.min(Math.max(Number(layout.x) || horizontalInset, horizontalInset), Math.max(horizontalInset, viewport.width - layout.width - horizontalInset)); + layout.y = Math.min(Math.max(Number(layout.y) || verticalInset, verticalInset), Math.max(verticalInset, viewport.height - layout.height - verticalInset)); } function saveLayout() { @@ -252,13 +284,16 @@ launcher.style.right = 'auto'; launcher.style.top = `${layout.y}px`; } else { - panel.style.top = '64px'; - panel.style.width = `${DEFAULT_WIDTH}px`; - panel.style.height = 'min(560px, calc(100vh - 80px))'; - panel.style[layout.mode] = '16px'; - launcher.style.top = 'calc(50vh - 24px)'; - launcher.style.left = layout.mode === 'left' ? '16px' : 'auto'; - launcher.style.right = layout.mode === 'right' ? '16px' : 'auto'; + const viewport = viewportBounds(); + const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2))); + const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64; + panel.style.top = `${panelTop}px`; + panel.style.width = `${Math.max(1, Math.min(DEFAULT_WIDTH, viewport.width - gutter * 2))}px`; + panel.style.height = `${Math.max(1, Math.min(560, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`; + panel.style[layout.mode] = `${gutter}px`; + launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`; + launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto'; + launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto'; } globalThis.queueMicrotask(() => { applyingLayout = false; }); } @@ -290,18 +325,22 @@ context = next || null; const supported = !!context?.supported; const hasKey = !!context?.hasKey; - context = context ? { ...context, enabled: supported && hasKey } : null; + const connected = !!context?.connected; + context = context ? { ...context, enabled: supported && hasKey && connected } : null; if (previousRoomId && previousRoomId !== context?.roomId) clearMessages(); host.style.display = supported ? '' : 'none'; - launcher.disabled = !hasKey; - if (!supported || !hasKey) setOpened(false); + launcher.setAttribute('aria-disabled', String(!context?.enabled)); + if (!context?.enabled) setOpened(false); applyStrings(); applyLayout(); } async function refresh() { if (destroyed) return; - applyContext(await messageRuntime({ type: 'GET_CHAT_CONTEXT' })); + const generation = ++refreshGeneration; + const next = await messageRuntime({ type: 'GET_CHAT_CONTEXT' }); + if (destroyed || generation !== refreshGeneration) return; + applyContext(next); } function appendMessage(message) { @@ -332,6 +371,16 @@ messages.replaceChildren(empty); } + function resetComposer() { + sendGeneration++; + sending = false; + textarea.value = ''; + count.textContent = '0/500'; + count.style.color = ''; + status.textContent = ''; + sendButton.disabled = false; + } + launcher.addEventListener('click', () => setOpened(true)); closeButton.addEventListener('click', () => setOpened(false)); leftButton.addEventListener('click', () => setMode('left')); @@ -347,24 +396,32 @@ textarea.addEventListener('keydown', event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); - composer.requestSubmit(); + if (!sending) composer.requestSubmit(); } }); composer.addEventListener('submit', async event => { event.preventDefault(); + if (sending || !context?.enabled) return; + const submittedValue = textarea.value; const text = textarea.value.trim(); if (!text) return; if ([...text].length > 500) { status.textContent = strings().tooLong || ''; return; } + const generation = ++sendGeneration; + sending = true; sendButton.disabled = true; status.textContent = ''; const response = await messageRuntime({ type: 'CHAT_SEND', text }); + if (destroyed || generation !== sendGeneration) return; + sending = false; sendButton.disabled = false; if (response?.status === 'ok') { - textarea.value = ''; - count.textContent = '0/500'; + if (textarea.value === submittedValue) { + textarea.value = ''; + count.textContent = '0/500'; + } } else { status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || ''); } @@ -428,9 +485,10 @@ function handleRuntime(message) { if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message); - if (message?.type === 'CHAT_CONTEXT_UPDATE') refresh(); + if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh(); if (message?.type === 'CHAT_RESET') { clearMessages(); + resetComposer(); refresh(); } if (message?.type === 'CHAT_DESTROY') destroy(); @@ -439,6 +497,8 @@ function destroy() { if (destroyed) return; destroyed = true; + refreshGeneration++; + sendGeneration++; if (saveTimer) clearTimeout(saveTimer); resizeObserver.disconnect(); document.removeEventListener('fullscreenchange', moveIntoFullscreen); diff --git a/extension/chat-session.js b/extension/chat-session.js new file mode 100644 index 0000000..1458904 --- /dev/null +++ b/extension/chat-session.js @@ -0,0 +1,58 @@ +export const MAX_ROOM_ID_LENGTH = 64; +export const CHAT_SEND_LIMIT = 9; +export const CHAT_SEND_WINDOW_MS = 10000; + +export function normalizeRoomId(value) { + if (typeof value !== 'string') return ''; + return value.trim().replace(/[^a-zA-Z0-9\-]/g, '').slice(0, MAX_ROOM_ID_LENGTH); +} + +export function createChatSendLimiter({ + limit = CHAT_SEND_LIMIT, + windowMs = CHAT_SEND_WINDOW_MS, + now = () => Date.now() +} = {}) { + let timestamps = []; + + return { + take() { + const current = now(); + timestamps = timestamps.filter(timestamp => current - timestamp < windowMs); + if (timestamps.length >= limit) { + return { + allowed: false, + retryAfterMs: Math.max(1, windowMs - (current - timestamps[0])) + }; + } + timestamps.push(current); + return { allowed: true, retryAfterMs: 0 }; + }, + reset() { + timestamps = []; + } + }; +} + +export function createLatestTaskQueue() { + let generation = 0; + let tail = Promise.resolve(); + + return { + invalidate() { + generation++; + }, + async run(task) { + const requestGeneration = ++generation; + const previous = tail; + let release; + tail = new Promise(resolve => { release = resolve; }); + await previous.catch(() => {}); + const isCurrent = () => requestGeneration === generation; + try { + return await task(isCurrent); + } finally { + release(); + } + } + }; +} diff --git a/extension/chat-session.test.mjs b/extension/chat-session.test.mjs new file mode 100644 index 0000000..3c5d5b2 --- /dev/null +++ b/extension/chat-session.test.mjs @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + CHAT_SEND_LIMIT, + createChatSendLimiter, + createLatestTaskQueue, + MAX_ROOM_ID_LENGTH, + normalizeRoomId +} from './chat-session.js'; + +describe('chat session boundaries', () => { + it('matches the relay room ID sanitizer and length limit', () => { + expect(normalizeRoomId(' ROOM_!42 ')).toBe('ROOM42'); + expect(normalizeRoomId('A'.repeat(MAX_ROOM_ID_LENGTH + 10))).toBe('A'.repeat(MAX_ROOM_ID_LENGTH)); + expect(normalizeRoomId(null)).toBe(''); + }); + + it('keeps client chat bursts below the relay disconnect threshold', () => { + let current = 1000; + const limiter = createChatSendLimiter({ now: () => current }); + for (let index = 0; index < CHAT_SEND_LIMIT; index++) { + expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 }); + } + expect(limiter.take()).toEqual({ allowed: false, retryAfterMs: 10000 }); + current += 10000; + expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 }); + limiter.reset(); + expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 }); + }); + + it('serializes join work and prevents an older request from winning storage races', async () => { + const queue = createLatestTaskQueue(); + let releaseFirst; + const writes = []; + const first = queue.run(async isCurrent => { + await new Promise(resolve => { releaseFirst = resolve; }); + if (isCurrent()) writes.push('first'); + return { status: isCurrent() ? 'ok' : 'superseded' }; + }); + await new Promise(resolve => setTimeout(resolve, 0)); + const second = queue.run(async isCurrent => { + if (isCurrent()) writes.push('second'); + return { status: 'ok' }; + }); + releaseFirst(); + await expect(first).resolves.toEqual({ status: 'superseded' }); + await expect(second).resolves.toEqual({ status: 'ok' }); + expect(writes).toEqual(['second']); + }); +}); diff --git a/extension/popup.html b/extension/popup.html index 33db0ff..7077b24 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1353,7 +1353,7 @@