mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
feat(chat): add client encryption and key lifecycle
This commit is contained in:
+106
-9
@@ -4,6 +4,8 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
|||||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||||
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
||||||
import { initTabManager } from './modules/tab-manager.js';
|
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 './page-api-seek-overrides.js';
|
import './page-api-seek-overrides.js';
|
||||||
|
|
||||||
// --- Uninstall URL Initialization ---
|
// --- Uninstall URL Initialization ---
|
||||||
@@ -79,6 +81,7 @@ let hostPeerId = null; // peerId of the room host (creator /
|
|||||||
// Features the connected relay advertises in ROOM_DATA. Empty against an older
|
// Features the connected relay advertises in ROOM_DATA. Empty against an older
|
||||||
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
|
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
|
||||||
let serverCapabilities = [];
|
let serverCapabilities = [];
|
||||||
|
let chatSecretGuard = '';
|
||||||
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
|
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
|
||||||
// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed
|
// 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
|
// in heartbeats so the host's popup UI can show "Solo" instead of silently
|
||||||
@@ -364,7 +367,7 @@ async function getSettings() {
|
|||||||
// (username) must NEVER come from storage.sync — syncing them across devices
|
// (username) must NEVER come from storage.sync — syncing them across devices
|
||||||
// both leaks them and resurrects dead rooms on reinstall (a fresh install
|
// both leaks them and resurrects dead rooms on reinstall (a fresh install
|
||||||
// has empty local storage but sync survives in the user's Google account).
|
// has empty local storage but sync survives in the user's Google account).
|
||||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
||||||
let username = data.username;
|
let username = data.username;
|
||||||
if (!username) {
|
if (!username) {
|
||||||
username = generateUsername();
|
username = generateUsername();
|
||||||
@@ -372,11 +375,14 @@ async function getSettings() {
|
|||||||
}
|
}
|
||||||
const legacyTitlePrivacyMode = normalizeTitlePrivacyMode(data.titlePrivacyMode);
|
const legacyTitlePrivacyMode = normalizeTitlePrivacyMode(data.titlePrivacyMode);
|
||||||
const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode);
|
const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode);
|
||||||
|
const chatKey = validateChatSecret(data.chatKey);
|
||||||
|
chatSecretGuard = chatKey;
|
||||||
return {
|
return {
|
||||||
serverUrl: data.serverUrl || '',
|
serverUrl: data.serverUrl || '',
|
||||||
useCustomServer: data.useCustomServer || false,
|
useCustomServer: data.useCustomServer || false,
|
||||||
roomId: data.roomId || '',
|
roomId: data.roomId || '',
|
||||||
password: data.password || '',
|
password: data.password || '',
|
||||||
|
chatKey,
|
||||||
username,
|
username,
|
||||||
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
|
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
|
||||||
mediaTitlePrivacyMode
|
mediaTitlePrivacyMode
|
||||||
@@ -412,7 +418,7 @@ function emitEpisodeLobbyForCurrentPrivacy() {
|
|||||||
// removes legacy keys that older versions wrote to sync (and that would
|
// removes legacy keys that older versions wrote to sync (and that would
|
||||||
// otherwise be redistributed across devices and resurrected on reinstall).
|
// otherwise be redistributed across devices and resurrected on reinstall).
|
||||||
const LEGACY_SYNC_KEYS = [
|
const LEGACY_SYNC_KEYS = [
|
||||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'username',
|
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username',
|
||||||
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
||||||
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
||||||
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
||||||
@@ -558,7 +564,9 @@ async function leaveRoomAfterIdleGrace(reason) {
|
|||||||
episodeLobby: null,
|
episodeLobby: null,
|
||||||
hcmDesynced: false
|
hcmDesynced: false
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
chatSecretGuard = '';
|
||||||
|
clearChatKeyCache();
|
||||||
|
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||||
addLog(reason, 'info');
|
addLog(reason, 'info');
|
||||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||||
updateBadgeStatus();
|
updateBadgeStatus();
|
||||||
@@ -686,7 +694,7 @@ async function connect() {
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(msg.substring(2));
|
const payload = JSON.parse(msg.substring(2));
|
||||||
try {
|
try {
|
||||||
handleServerEvent(payload[0], payload[1]);
|
await handleServerEvent(payload[0], payload[1]);
|
||||||
} catch (handlerErr) {
|
} catch (handlerErr) {
|
||||||
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
|
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
|
||||||
}
|
}
|
||||||
@@ -880,10 +888,14 @@ function scheduleReconnect() {
|
|||||||
|
|
||||||
function emit(event, data) {
|
function emit(event, data) {
|
||||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||||
const msg = `42${JSON.stringify([event, data])}`;
|
|
||||||
try {
|
try {
|
||||||
|
const msg = encodeSocketEvent(event, data, chatSecretGuard);
|
||||||
socket.send(msg);
|
socket.send(msg);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.message === 'Refusing to send chat secret to relay') {
|
||||||
|
addLog(e.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The socket can close between the readyState check and send()
|
// The socket can close between the readyState check and send()
|
||||||
// (race with a server-side disconnect). Re-queue so the event is
|
// (race with a server-side disconnect). Re-queue so the event is
|
||||||
// retried on the next successful (re)connect instead of being lost.
|
// retried on the next successful (re)connect instead of being lost.
|
||||||
@@ -895,6 +907,17 @@ function emit(event, data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitLive(event, data) {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return false;
|
||||||
|
try {
|
||||||
|
socket.send(encodeSocketEvent(event, data, chatSecretGuard));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
addLog(e.message === 'Refusing to send chat secret to relay' ? e.message : `Live send failed for ${event}: ${e.message}`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function queueEvent(event, data) {
|
function queueEvent(event, data) {
|
||||||
eventQueue.push({ event, data });
|
eventQueue.push({ event, data });
|
||||||
if (eventQueue.length > 50) {
|
if (eventQueue.length > 50) {
|
||||||
@@ -1000,7 +1023,7 @@ function stopPing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Event Handlers ---
|
// --- Event Handlers ---
|
||||||
function handleServerEvent(event, data) {
|
async function handleServerEvent(event, data) {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
|
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
|
||||||
return;
|
return;
|
||||||
@@ -1103,6 +1126,38 @@ function handleServerEvent(event, data) {
|
|||||||
case EVENTS.ROOM_LIST:
|
case EVENTS.ROOM_LIST:
|
||||||
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
||||||
break;
|
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(() => {});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
addLog('Discarded chat message that failed authentication', 'warn');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case EVENTS.ERROR:
|
case EVENTS.ERROR:
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
// If we get a server error before successfully joining a room,
|
// If we get a server error before successfully joining a room,
|
||||||
@@ -1851,6 +1906,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
|||||||
hostPeerId = null;
|
hostPeerId = null;
|
||||||
controllers = [];
|
controllers = [];
|
||||||
serverCapabilities = [];
|
serverCapabilities = [];
|
||||||
|
clearChatKeyCache();
|
||||||
hcmDesynced = false;
|
hcmDesynced = false;
|
||||||
// Notify content.js/popup so they drop any guest-side HCM state from the
|
// Notify content.js/popup so they drop any guest-side HCM state from the
|
||||||
// previous room (badge/dialog/desync) — H-2/H-3.
|
// previous room (badge/dialog/desync) — H-2/H-3.
|
||||||
@@ -1973,8 +2029,43 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
amHost: amHost(),
|
amHost: amHost(),
|
||||||
amController: amController(),
|
amController: amController(),
|
||||||
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
|
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
|
||||||
coHostSupported: serverSupports(CAPABILITIES.CO_HOST)
|
coHostSupported: serverSupports(CAPABILITIES.CO_HOST),
|
||||||
|
chatSupported: serverSupports(CAPABILITIES.CHAT),
|
||||||
|
hasChatKey: !!(await getSettings()).chatKey
|
||||||
});
|
});
|
||||||
|
} else if (message.type === 'CHAT_SEND') {
|
||||||
|
const senderTabId = sender.tab?.id;
|
||||||
|
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
|
||||||
|
sendResponse({ status: 'invalid_tab' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!serverSupports(CAPABILITIES.CHAT)) {
|
||||||
|
sendResponse({ status: 'unsupported' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings.chatKey) {
|
||||||
|
sendResponse({ status: 'missing_key' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ciphertext = await encryptChatMessage({
|
||||||
|
text: message.text,
|
||||||
|
roomId: currentRoom.roomId,
|
||||||
|
senderId: peerId,
|
||||||
|
secret: settings.chatKey
|
||||||
|
});
|
||||||
|
const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext));
|
||||||
|
sendResponse({ status: sent ? 'ok' : 'disconnected' });
|
||||||
|
} catch (err) {
|
||||||
|
sendResponse({ status: err instanceof RangeError ? 'too_long' : 'invalid_message' });
|
||||||
|
}
|
||||||
|
} else if (message.type === 'CREATE_CHAT_KEY') {
|
||||||
|
const chatKey = generateChatSecret();
|
||||||
|
chatSecretGuard = chatKey;
|
||||||
|
clearChatKeyCache();
|
||||||
|
await chrome.storage.local.set({ chatKey });
|
||||||
|
sendResponse({ status: 'ok', chatKey });
|
||||||
} else if (message.type === 'SET_CONTROL_MODE') {
|
} else if (message.type === 'SET_CONTROL_MODE') {
|
||||||
// Popup (host) toggles the room control mode. Server validates host authority
|
// Popup (host) toggles the room control mode. Server validates host authority
|
||||||
// and broadcasts CONTROL_MODE back, which updates our local state + UI.
|
// and broadcasts CONTROL_MODE back, which updates our local state + UI.
|
||||||
@@ -2087,7 +2178,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
expectedAcksCount: 0,
|
expectedAcksCount: 0,
|
||||||
hcmDesynced: false
|
hcmDesynced: false
|
||||||
});
|
});
|
||||||
chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
chatSecretGuard = '';
|
||||||
|
clearChatKeyCache();
|
||||||
|
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||||
addLog('Left Room', 'info');
|
addLog('Left Room', 'info');
|
||||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||||
forceDisconnect();
|
forceDisconnect();
|
||||||
@@ -2103,8 +2196,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
emit(EVENTS.GET_ROOMS, {});
|
emit(EVENTS.GET_ROOMS, {});
|
||||||
sendResponse({ status: 'ok' });
|
sendResponse({ status: 'ok' });
|
||||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||||
const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message;
|
const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message;
|
||||||
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
|
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
|
||||||
|
const chatKey = validateChatSecret(rawChatKey);
|
||||||
if (!roomId) {
|
if (!roomId) {
|
||||||
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
|
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
|
||||||
chrome.runtime.sendMessage(errMsg).catch(() => {});
|
chrome.runtime.sendMessage(errMsg).catch(() => {});
|
||||||
@@ -2118,9 +2212,12 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
|||||||
chrome.storage.local.set({
|
chrome.storage.local.set({
|
||||||
roomId,
|
roomId,
|
||||||
password,
|
password,
|
||||||
|
chatKey,
|
||||||
useCustomServer: !!useCustomServer,
|
useCustomServer: !!useCustomServer,
|
||||||
serverUrl: serverUrl || ''
|
serverUrl: serverUrl || ''
|
||||||
}, async () => {
|
}, async () => {
|
||||||
|
chatSecretGuard = chatKey;
|
||||||
|
clearChatKeyCache();
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const desiredUrl = resolveServerUrl(settings);
|
const desiredUrl = resolveServerUrl(settings);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
const CHAT_INFO = 'KoalaSync E2E Chat v1';
|
||||||
|
const SECRET_BYTES = 16;
|
||||||
|
const IV_BYTES = 12;
|
||||||
|
const MIN_ENCRYPTED_BYTES = 29;
|
||||||
|
const MAX_ENCRYPTED_BYTES = 2028;
|
||||||
|
|
||||||
|
let cachedKey = null;
|
||||||
|
let cachedRoomId = '';
|
||||||
|
let cachedSecret = '';
|
||||||
|
|
||||||
|
function bytesToBase64Url(bytes) {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return globalThis.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlToBytes(value) {
|
||||||
|
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
|
||||||
|
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||||
|
try {
|
||||||
|
const binary = globalThis.atob(padded);
|
||||||
|
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
|
||||||
|
return bytesToBase64Url(bytes) === value ? bytes : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateChatSecret(cryptoImpl = globalThis.crypto) {
|
||||||
|
const bytes = new Uint8Array(SECRET_BYTES);
|
||||||
|
cryptoImpl.getRandomValues(bytes);
|
||||||
|
return bytesToBase64Url(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateChatSecret(value) {
|
||||||
|
const bytes = base64UrlToBytes(value);
|
||||||
|
return bytes?.length === SECRET_BYTES ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countCodePoints(value) {
|
||||||
|
return [...String(value ?? '')].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOutgoingChatText(value) {
|
||||||
|
if (typeof value !== 'string') throw new TypeError('Chat message must be text');
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) throw new TypeError('Chat message must not be empty');
|
||||||
|
if (countCodePoints(text) > 500) throw new RangeError('Chat message exceeds 500 Unicode code points');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearChatKeyCache() {
|
||||||
|
cachedKey = null;
|
||||||
|
cachedRoomId = '';
|
||||||
|
cachedSecret = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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']);
|
||||||
|
cachedRoomId = roomId;
|
||||||
|
cachedSecret = validSecret;
|
||||||
|
return cachedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
|
||||||
|
const plaintext = normalizeOutgoingChatText(text);
|
||||||
|
if (!senderId) throw new TypeError('senderId is required');
|
||||||
|
const key = await deriveChatKey(roomId, secret, cryptoImpl);
|
||||||
|
const encoder = new globalThis.TextEncoder();
|
||||||
|
const iv = new Uint8Array(IV_BYTES);
|
||||||
|
cryptoImpl.getRandomValues(iv);
|
||||||
|
const encrypted = new Uint8Array(await cryptoImpl.subtle.encrypt({
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv,
|
||||||
|
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
||||||
|
}, key, encoder.encode(plaintext)));
|
||||||
|
const envelope = new Uint8Array(iv.length + encrypted.length);
|
||||||
|
envelope.set(iv);
|
||||||
|
envelope.set(encrypted, iv.length);
|
||||||
|
return bytesToBase64Url(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptChatMessage({ ciphertext, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
|
||||||
|
if (!senderId) throw new TypeError('senderId is required');
|
||||||
|
const bytes = base64UrlToBytes(ciphertext);
|
||||||
|
if (!bytes || bytes.length < MIN_ENCRYPTED_BYTES || bytes.length > MAX_ENCRYPTED_BYTES) {
|
||||||
|
throw new TypeError('Invalid chat ciphertext');
|
||||||
|
}
|
||||||
|
const key = await deriveChatKey(roomId, secret, cryptoImpl);
|
||||||
|
const encoder = new globalThis.TextEncoder();
|
||||||
|
const plaintext = await cryptoImpl.subtle.decrypt({
|
||||||
|
name: 'AES-GCM',
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { webcrypto } from 'node:crypto';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
clearChatKeyCache,
|
||||||
|
countCodePoints,
|
||||||
|
decryptChatMessage,
|
||||||
|
deriveChatKey,
|
||||||
|
encryptChatMessage,
|
||||||
|
generateChatSecret,
|
||||||
|
normalizeOutgoingChatText,
|
||||||
|
validateChatSecret
|
||||||
|
} from './chat-crypto.js';
|
||||||
|
|
||||||
|
afterEach(clearChatKeyCache);
|
||||||
|
|
||||||
|
describe('chat crypto', () => {
|
||||||
|
it('generates canonical 128-bit secrets', () => {
|
||||||
|
const secret = generateChatSecret(webcrypto);
|
||||||
|
expect(secret).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||||
|
expect(validateChatSecret(secret)).toBe(secret);
|
||||||
|
expect(validateChatSecret(`${secret}=`)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips Unicode with a cached room key and fresh IVs', async () => {
|
||||||
|
const secret = generateChatSecret(webcrypto);
|
||||||
|
const firstKey = await deriveChatKey('ROOM-1', secret, webcrypto);
|
||||||
|
const secondKey = await deriveChatKey('ROOM-1', secret, webcrypto);
|
||||||
|
expect(secondKey).toBe(firstKey);
|
||||||
|
|
||||||
|
const input = { text: 'Hello 🐨 **world**', roomId: 'ROOM-1', senderId: 'alice', secret };
|
||||||
|
const first = await encryptChatMessage(input, webcrypto);
|
||||||
|
const second = await encryptChatMessage(input, webcrypto);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
clearChatKeyCache();
|
||||||
|
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'bob', secret }, webcrypto)).rejects.toThrow();
|
||||||
|
clearChatKeyCache();
|
||||||
|
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-2', senderId: 'alice', secret }, webcrypto)).rejects.toThrow();
|
||||||
|
clearChatKeyCache();
|
||||||
|
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'alice', secret: generateChatSecret(webcrypto) }, webcrypto)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces 500 Unicode code points before encryption', () => {
|
||||||
|
expect(countCodePoints('😀'.repeat(500))).toBe(500);
|
||||||
|
expect(normalizeOutgoingChatText(` ${'😀'.repeat(500)} `)).toBe('😀'.repeat(500));
|
||||||
|
expect(() => normalizeOutgoingChatText('😀'.repeat(501))).toThrow(RangeError);
|
||||||
|
expect(() => normalizeOutgoingChatText(' ')).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the worst-case 500-codepoint payload within the relay byte bound', async () => {
|
||||||
|
const secret = generateChatSecret(webcrypto);
|
||||||
|
const ciphertext = await encryptChatMessage({
|
||||||
|
text: '😀'.repeat(500), roomId: 'ROOM-1', senderId: 'alice', secret
|
||||||
|
}, webcrypto);
|
||||||
|
const bytes = Buffer.from(ciphertext, 'base64url');
|
||||||
|
expect(bytes).toHaveLength(2028);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function buildChatRelayPayload(ciphertext) {
|
||||||
|
return { ciphertext };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeSocketEvent(event, data, forbiddenSecret = '') {
|
||||||
|
const payload = JSON.stringify([event, data]);
|
||||||
|
if (forbiddenSecret && payload.includes(forbiddenSecret)) {
|
||||||
|
throw new Error('Refusing to send chat secret to relay');
|
||||||
|
}
|
||||||
|
return `42${payload}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
|
||||||
|
|
||||||
|
describe('chat wire boundary', () => {
|
||||||
|
const secret = 'R5Ti1nxp0crfAFHf3gVncw';
|
||||||
|
|
||||||
|
it('sends only ciphertext for chat messages', () => {
|
||||||
|
expect(buildChatRelayPayload('ciphertext')).toEqual({ ciphertext: 'ciphertext' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the secret anywhere in any outgoing relay event', () => {
|
||||||
|
for (const [event, payload] of [
|
||||||
|
['join_room', { roomId: 'ROOM', password: 'PASS', chatKey: secret }],
|
||||||
|
['peer_status', { status: 'heartbeat', nested: { secret } }],
|
||||||
|
['chat_message', { ciphertext: `prefix-${secret}-suffix` }]
|
||||||
|
]) {
|
||||||
|
expect(() => encodeSocketEvent(event, payload, secret)).toThrow('Refusing to send chat secret');
|
||||||
|
}
|
||||||
|
expect(encodeSocketEvent('join_room', { roomId: 'ROOM', password: 'PASS' }, secret)).not.toContain(secret);
|
||||||
|
});
|
||||||
|
});
|
||||||
+54
-51
@@ -3,7 +3,14 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
|||||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
||||||
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
||||||
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
|
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
|
||||||
|
import './shared/invite-links.js';
|
||||||
|
|
||||||
|
let pendingInviteRoomId = '';
|
||||||
|
let pendingInviteChatKey = '';
|
||||||
|
|
||||||
|
function normalizeChatKey(value) {
|
||||||
|
return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
tabs: document.querySelectorAll('.tabs .tab-btn'),
|
tabs: document.querySelectorAll('.tabs .tab-btn'),
|
||||||
@@ -324,7 +331,7 @@ function setRoomRefreshCooldown() {
|
|||||||
async function init() {
|
async function init() {
|
||||||
// Local-only by design — settings and room credentials never come from
|
// Local-only by design — settings and room credentials never come from
|
||||||
// storage.sync (only onboardingComplete + dismissedHints live there).
|
// storage.sync (only onboardingComplete + dismissedHints live there).
|
||||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||||||
|
|
||||||
let activeLang = localData.locale;
|
let activeLang = localData.locale;
|
||||||
if (!activeLang) {
|
if (!activeLang) {
|
||||||
@@ -380,7 +387,7 @@ async function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleUIState(!!localData.roomId);
|
toggleUIState(!!localData.roomId);
|
||||||
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl);
|
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl, localData.chatKey);
|
||||||
refreshLogs();
|
refreshLogs();
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
|
|
||||||
@@ -552,15 +559,20 @@ if (elements.hostControlToggle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
|
function updateUI(roomId, password, useCustomServer = false, serverUrl = '', chatKey = '') {
|
||||||
const inRoom = !!roomId;
|
const inRoom = !!roomId;
|
||||||
toggleUIState(inRoom);
|
toggleUIState(inRoom);
|
||||||
if (inRoom) {
|
if (inRoom) {
|
||||||
let invite = `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
|
const validChatKey = normalizeChatKey(chatKey);
|
||||||
if (useCustomServer) {
|
let invite = validChatKey
|
||||||
const encodedUrl = encodeURIComponent(serverUrl || '');
|
? `${OFFICIAL_LANDING_PAGE_URL}/join.html${globalThis.KoalaSyncInviteLinks.buildJ2Hash({
|
||||||
invite += `:1:${encodedUrl}`;
|
roomId,
|
||||||
}
|
password,
|
||||||
|
chatKey: validChatKey,
|
||||||
|
serverUrl: useCustomServer ? serverUrl : ''
|
||||||
|
})}`
|
||||||
|
: `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
|
||||||
|
if (!validChatKey && useCustomServer) invite += `:1:${encodeURIComponent(serverUrl || '')}`;
|
||||||
elements.inviteLink.value = invite;
|
elements.inviteLink.value = invite;
|
||||||
|
|
||||||
if (window.justCreatedRoom) {
|
if (window.justCreatedRoom) {
|
||||||
@@ -1289,41 +1301,21 @@ function updateRoomList(rooms) {
|
|||||||
function checkInviteLink() {
|
function checkInviteLink() {
|
||||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
const tab = tabs[0];
|
const tab = tabs[0];
|
||||||
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/') && tab.url.includes('#join:')) {
|
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/')) {
|
||||||
try {
|
try {
|
||||||
const rawHash = tab.url.split('#join:')[1];
|
const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash);
|
||||||
if (!rawHash) return;
|
if (!invite) return;
|
||||||
const parts = rawHash.split(':');
|
elements.roomId.value = invite.roomId;
|
||||||
if (parts.length >= 2) {
|
elements.password.value = invite.password;
|
||||||
const roomId = parts.shift();
|
pendingInviteRoomId = invite.roomId;
|
||||||
let useCustomServer = false;
|
pendingInviteChatKey = normalizeChatKey(invite.chatKey);
|
||||||
let serverUrl = '';
|
|
||||||
|
|
||||||
const last = parts[parts.length - 1];
|
elements.serverUrl.value = invite.serverUrl;
|
||||||
const secondToLast = parts[parts.length - 2];
|
setServerMode(invite.useCustomServer);
|
||||||
const decodedLast = decodeURIComponent(last || '');
|
chrome.storage.local.set({ serverUrl: invite.serverUrl, useCustomServer: invite.useCustomServer });
|
||||||
const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://'));
|
|
||||||
const isOfficial = secondToLast === '0' && last === '';
|
|
||||||
|
|
||||||
if (parts.length >= 3 && (isCustom || isOfficial)) {
|
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
||||||
serverUrl = decodeURIComponent(parts.pop());
|
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
|
||||||
useCustomServer = parts.pop() === '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = parts.join(':');
|
|
||||||
|
|
||||||
elements.roomId.value = roomId;
|
|
||||||
elements.password.value = password;
|
|
||||||
|
|
||||||
if (serverUrl || useCustomServer) {
|
|
||||||
elements.serverUrl.value = serverUrl;
|
|
||||||
setServerMode(useCustomServer);
|
|
||||||
chrome.storage.local.set({ serverUrl, useCustomServer });
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
|
||||||
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Malformed invite link, ignore
|
// Malformed invite link, ignore
|
||||||
}
|
}
|
||||||
@@ -1468,15 +1460,15 @@ if (elements.langSelector) {
|
|||||||
lastKnownPeers = res.peers || [];
|
lastKnownPeers = res.peers || [];
|
||||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||||
|
|
||||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
|
||||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||||
|
|
||||||
await populateTabs(res.peers, res.targetTabId);
|
await populateTabs(res.peers, res.targetTabId);
|
||||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||||
} else {
|
} else {
|
||||||
applyConnectionStatus('disconnected');
|
applyConnectionStatus('disconnected');
|
||||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
|
||||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||||
await populateTabs();
|
await populateTabs();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1605,6 +1597,10 @@ function showError(msg) {
|
|||||||
// --- Action Handlers ---
|
// --- Action Handlers ---
|
||||||
elements.roomId.addEventListener('input', () => {
|
elements.roomId.addEventListener('input', () => {
|
||||||
elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, '');
|
elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, '');
|
||||||
|
if (pendingInviteRoomId && elements.roomId.value !== pendingInviteRoomId) {
|
||||||
|
pendingInviteRoomId = '';
|
||||||
|
pendingInviteChatKey = '';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.joinBtn.addEventListener('click', async () => {
|
elements.joinBtn.addEventListener('click', async () => {
|
||||||
@@ -1676,17 +1672,24 @@ elements.joinBtn.addEventListener('click', async () => {
|
|||||||
self.crypto.getRandomValues(array);
|
self.crypto.getRandomValues(array);
|
||||||
password = Array.from(array, byte => chars[byte % chars.length]).join('');
|
password = Array.from(array, byte => chars[byte % chars.length]).join('');
|
||||||
elements.password.value = password;
|
elements.password.value = password;
|
||||||
window.justCreatedRoom = true;
|
|
||||||
}
|
}
|
||||||
|
let chatKey = roomId === pendingInviteRoomId ? pendingInviteChatKey : '';
|
||||||
|
if (isCreating) {
|
||||||
|
const created = await new Promise(resolve => chrome.runtime.sendMessage({ type: 'CREATE_CHAT_KEY' }, resolve));
|
||||||
|
chatKey = normalizeChatKey(created?.chatKey);
|
||||||
|
}
|
||||||
|
if (isCreating) window.justCreatedRoom = true;
|
||||||
|
pendingInviteRoomId = '';
|
||||||
|
pendingInviteChatKey = '';
|
||||||
|
|
||||||
await chrome.storage.local.set({ serverUrl, roomId, password, useCustomServer: useCustom });
|
await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom });
|
||||||
elements.roomId.value = roomId;
|
elements.roomId.value = roomId;
|
||||||
|
|
||||||
// Tell background to connect
|
// Tell background to connect
|
||||||
chrome.runtime.sendMessage({ type: 'CONNECT' });
|
chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||||
|
|
||||||
// UI Feedback: Immediately switch state for better responsiveness
|
// UI Feedback: Immediately switch state for better responsiveness
|
||||||
updateUI(roomId, password, useCustom, serverUrl);
|
updateUI(roomId, password, useCustom, serverUrl, chatKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
elements.leaveBtn.addEventListener('click', async () => {
|
elements.leaveBtn.addEventListener('click', async () => {
|
||||||
@@ -1694,7 +1697,7 @@ elements.leaveBtn.addEventListener('click', async () => {
|
|||||||
isProcessingConnection = true;
|
isProcessingConnection = true;
|
||||||
clearConnectionErrorTimer();
|
clearConnectionErrorTimer();
|
||||||
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
|
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
|
||||||
await chrome.storage.local.set({ roomId: '', password: '' });
|
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
|
||||||
elements.roomId.value = '';
|
elements.roomId.value = '';
|
||||||
elements.password.value = '';
|
elements.password.value = '';
|
||||||
lastKnownPeers = [];
|
lastKnownPeers = [];
|
||||||
@@ -2167,15 +2170,15 @@ chrome.runtime.onMessage.addListener((msg) => {
|
|||||||
|
|
||||||
if (msg.success) {
|
if (msg.success) {
|
||||||
// Final confirmation of join from background
|
// Final confirmation of join from background
|
||||||
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
|
chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl'], (data) => {
|
||||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||||
if (syncTabBtn) syncTabBtn.click();
|
if (syncTabBtn) syncTabBtn.click();
|
||||||
showSelectVideoHint();
|
showSelectVideoHint();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Join failed: reset UI state
|
// Join failed: reset UI state
|
||||||
chrome.storage.local.set({ roomId: '', password: '' });
|
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
|
||||||
elements.roomId.value = '';
|
elements.roomId.value = '';
|
||||||
elements.password.value = '';
|
elements.password.value = '';
|
||||||
updateUI(null, null);
|
updateUI(null, null);
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,9 @@ export default defineConfig({
|
|||||||
'server/**/*.test.js',
|
'server/**/*.test.js',
|
||||||
'server/**/*.test.mjs',
|
'server/**/*.test.mjs',
|
||||||
'shared/**/*.test.js',
|
'shared/**/*.test.js',
|
||||||
'shared/**/*.test.mjs'
|
'shared/**/*.test.mjs',
|
||||||
|
'extension/**/*.test.js',
|
||||||
|
'extension/**/*.test.mjs'
|
||||||
],
|
],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
|
|||||||
Reference in New Issue
Block a user