From 5277cd0f21c6c0c2de31f294c26aaad8832fbbd9 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:50:32 +0200 Subject: [PATCH 01/10] fix(eslint): add missing browser globals history and location --- eslint.config.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 5f2f9f5..3eab547 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,8 +37,10 @@ export default [ URL: "readonly", URLSearchParams: "readonly", WebSocket: "readonly", + history: "readonly", + location: "readonly", self: "readonly", - process: "readonly" + process: "readonly", } }, rules: { From d38a8401147756f75e2907719afb6147e8820abe Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:44:43 +0200 Subject: [PATCH 02/10] refactor(server): extract rate-limiter module from index.js Move 6 rate-limit functions, all rate-limit Maps, cleanup intervals, and denial counter to server/rate-limiter.js. 149 lines extracted. Index.js re-exports what tests and ops.js need. npm run verify passes. --- server/index.js | 198 ++++++++--------------------------------- server/rate-limiter.js | 163 +++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 162 deletions(-) create mode 100644 server/rate-limiter.js diff --git a/server/index.js b/server/index.js index 42e50f1..2cc126d 100644 --- a/server/index.js +++ b/server/index.js @@ -12,6 +12,38 @@ import { isAdminMetricsAuthorized, isAdminMetricsTokenStrong } from './ops.js'; +import { + ROOM_LIST_COOLDOWN_MS, + HEALTH_RATE_LIMIT_PER_MINUTE, + ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE, + connectionCounts, + failedAuthAttempts, + eventCounts, + healthCounts, + adminMetricsAuthCounts, + roomListCooldowns, + rateLimitDenied, + checkAuthRate, + recordAuthFailure, + checkConnectionRate, + checkEventRate, + checkHealthRate, + checkAdminMetricsAuthRate, + startRateLimitCleanup, + stopRateLimitCleanup, + clearRateLimitMaps +} from './rate-limiter.js'; + +// Re-export for external consumers (tests, ops) +export { + HEALTH_RATE_LIMIT_PER_MINUTE, + ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE, + healthCounts, + adminMetricsAuthCounts, + connectionCounts, + eventCounts, + rateLimitDenied +}; dotenv.config(); @@ -26,9 +58,6 @@ const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000; const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 25; const MIN_VERSION = process.env.MIN_VERSION || '1.0.0'; const ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || ''; -const ROOM_LIST_COOLDOWN_MS = 10000; -export const HEALTH_RATE_LIMIT_PER_MINUTE = 10; -export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5; const HEALTH_RESPONSE_CACHE_TTL_MS = 60000; if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) { @@ -108,6 +137,8 @@ export const io = new Server(httpServer, { allowUpgrades: false }); +startRateLimitCleanup(io); + /** * In-memory storage */ @@ -126,157 +157,6 @@ function log(type, message, details = '') { console.log(`[${timestamp}] [${type}] ${message}`, details); } -// Rate Limiting & Security -export const connectionCounts = new Map(); // ip -> { count, resetTime } -const failedAuthAttempts = new Map(); // Map - -function checkAuthRate(ip, roomId) { - const key = `${ip}:${roomId}`; - const now = Date.now(); - const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 }; - - // Block for 15 mins if 5 fails in 2 mins - if (record.count >= 5 && (now - record.lastAttempt) < 15 * 60 * 1000) { - return false; - } - - // Reset if last attempt was long ago - if ((now - record.lastAttempt) > 2 * 60 * 1000) { - record.count = 0; - } - - return true; -} - -function recordAuthFailure(ip, roomId) { - if (failedAuthAttempts.size > 200000) { - const now = Date.now(); - // 1. Clear expired entries (> 15 mins) - for (const [key, record] of failedAuthAttempts.entries()) { - if (now - record.lastAttempt > 15 * 60 * 1000) { - failedAuthAttempts.delete(key); - } else { - break; - } - } - - // 2. If still over 200k, perform LRU-style eviction on the oldest 10,000 entries (first items in Map order) - if (failedAuthAttempts.size > 200000) { - log('SECURITY', 'failedAuthAttempts size exceeded 200000. Performing insertion-order eviction.'); - for (const [key] of failedAuthAttempts.entries()) { - if (failedAuthAttempts.size <= 190000) { - break; - } - failedAuthAttempts.delete(key); - } - } - } - const key = `${ip}:${roomId}`; - const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 }; - record.count++; - record.lastAttempt = Date.now(); - failedAuthAttempts.delete(key); // Remove first to update insertion order (moves key to the end of iteration) - failedAuthAttempts.set(key, record); -} - -// Periodically clean up old auth failure records (every 15 minutes) -const authFailureCleanupInterval = setInterval(() => { - const now = Date.now(); - for (const [key, record] of failedAuthAttempts.entries()) { - if (now - record.lastAttempt > 15 * 60 * 1000) { - failedAuthAttempts.delete(key); - } - } -}, 15 * 60 * 1000); - -export const eventCounts = new Map(); // socketId -> { count, resetTime } -export const healthCounts = new Map(); // ip -> { count, resetTime } -export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime } -const roomListCooldowns = new Map(); // socketId -> last allowed timestamp - -// Actual rate-limit denial counters (incremented only when a request is denied) -const rateLimitDenied = { - connections: 0, - events: 0, - health: 0, - adminMetricsAuth: 0, - roomList: 0 -}; - -// Clean up connection counts and event counts to prevent memory leak -const rateLimitCleanupInterval = setInterval(() => { - const now = Date.now(); - for (const [ip, entry] of connectionCounts.entries()) { - if (now > entry.resetTime) { - connectionCounts.delete(ip); - } - } - for (const [socketId, entry] of eventCounts.entries()) { - if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) { - eventCounts.delete(socketId); - } - } - for (const [ip, entry] of healthCounts.entries()) { - if (now > entry.resetTime) { - healthCounts.delete(ip); - } - } - for (const [ip, entry] of adminMetricsAuthCounts.entries()) { - if (now > entry.resetTime) { - adminMetricsAuthCounts.delete(ip); - } - } - for (const [socketId] of roomListCooldowns.entries()) { - if (!io.sockets.sockets.has(socketId)) { - roomListCooldowns.delete(socketId); - } - } -}, 60000); - -function checkConnectionRate(ip) { - const now = Date.now(); - const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 }; - if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } - entry.count++; - connectionCounts.set(ip, entry); - if (entry.count <= 10) return true; - rateLimitDenied.connections++; - return false; -} - -function checkEventRate(socketId) { - const now = Date.now(); - const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 }; - if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; } - entry.count++; - eventCounts.set(socketId, entry); - if (entry.count <= 30) return true; - rateLimitDenied.events++; - return false; -} - -function checkHealthRate(ip) { - const now = Date.now(); - const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 }; - if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } - entry.count++; - healthCounts.set(ip, entry); - if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true; - rateLimitDenied.health++; - return false; -} - -function checkAdminMetricsAuthRate(ip) { - const now = Date.now(); - const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 }; - if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } - entry.count++; - adminMetricsAuthCounts.set(ip, entry); - if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true; - rateLimitDenied.adminMetricsAuth++; - return false; -} - /** * Central peer teardown. Removes a socket from all room state and notifies * remaining peers. Call this from every disconnect/leave/reaper/dedupe path. @@ -836,20 +716,14 @@ function gracefulShutdown(signal) { } export async function stopServerForTests() { - clearInterval(authFailureCleanupInterval); - clearInterval(rateLimitCleanupInterval); + stopRateLimitCleanup(); clearInterval(roomCleanupInterval); rooms.clear(); socketToRoom.clear(); peerToSocket.clear(); roomCreationLocks.clear(); peerJoinLocks.clear(); - connectionCounts.clear(); - failedAuthAttempts.clear(); - eventCounts.clear(); - healthCounts.clear(); - adminMetricsAuthCounts.clear(); - roomListCooldowns.clear(); + clearRateLimitMaps(); healthResponseCache.clear(); io.removeAllListeners(); io.disconnectSockets(true); diff --git a/server/rate-limiter.js b/server/rate-limiter.js new file mode 100644 index 0000000..fc39891 --- /dev/null +++ b/server/rate-limiter.js @@ -0,0 +1,163 @@ +/** + * KoalaSync Rate Limiter + * Connection, event, health, and auth rate limiting for the relay server. + */ + +export const ROOM_LIST_COOLDOWN_MS = 10000; +export const HEALTH_RATE_LIMIT_PER_MINUTE = 10; +export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5; + +export const connectionCounts = new Map(); // ip -> { count, resetTime } +export const failedAuthAttempts = new Map(); // Map +export const eventCounts = new Map(); // socketId -> { count, resetTime } +export const healthCounts = new Map(); // ip -> { count, resetTime } +export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime } +export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp + +export const rateLimitDenied = { + connections: 0, + events: 0, + health: 0, + adminMetricsAuth: 0, + roomList: 0 +}; + +let authCleanupId = null; +let rateLimitCleanupId = null; + +export function checkAuthRate(ip, roomId) { + const key = `${ip}:${roomId}`; + const now = Date.now(); + const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 }; + + if (record.count >= 5 && (now - record.lastAttempt) < 15 * 60 * 1000) { + return false; + } + + if ((now - record.lastAttempt) > 2 * 60 * 1000) { + record.count = 0; + } + + return true; +} + +export function recordAuthFailure(ip, roomId) { + if (failedAuthAttempts.size > 200000) { + const now = Date.now(); + for (const [key, record] of failedAuthAttempts.entries()) { + if (now - record.lastAttempt > 15 * 60 * 1000) { + failedAuthAttempts.delete(key); + } else { + break; + } + } + + if (failedAuthAttempts.size > 200000) { + console.warn('SECURITY: failedAuthAttempts size exceeded 200000. Performing insertion-order eviction.'); + for (const [key] of failedAuthAttempts.entries()) { + if (failedAuthAttempts.size <= 190000) { + break; + } + failedAuthAttempts.delete(key); + } + } + } + const key = `${ip}:${roomId}`; + const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 }; + record.count++; + record.lastAttempt = Date.now(); + failedAuthAttempts.delete(key); + failedAuthAttempts.set(key, record); +} + +export function checkConnectionRate(ip) { + const now = Date.now(); + const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 }; + if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } + entry.count++; + connectionCounts.set(ip, entry); + if (entry.count <= 10) return true; + rateLimitDenied.connections++; + return false; +} + +export function checkEventRate(socketId) { + const now = Date.now(); + const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 }; + if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; } + entry.count++; + eventCounts.set(socketId, entry); + if (entry.count <= 30) return true; + rateLimitDenied.events++; + return false; +} + +export function checkHealthRate(ip) { + const now = Date.now(); + const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 }; + if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } + entry.count++; + healthCounts.set(ip, entry); + if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true; + rateLimitDenied.health++; + return false; +} + +export function checkAdminMetricsAuthRate(ip) { + const now = Date.now(); + const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 }; + if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } + entry.count++; + adminMetricsAuthCounts.set(ip, entry); + if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true; + rateLimitDenied.adminMetricsAuth++; + return false; +} + +export function startRateLimitCleanup(io) { + // Clean up old auth failure records (every 15 minutes) + authCleanupId = setInterval(() => { + const now = Date.now(); + for (const [key, record] of failedAuthAttempts.entries()) { + if (now - record.lastAttempt > 15 * 60 * 1000) { + failedAuthAttempts.delete(key); + } + } + }, 15 * 60 * 1000); + + // Clean up rate-limit maps to prevent memory leaks (every 60 seconds) + rateLimitCleanupId = setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of connectionCounts.entries()) { + if (now > entry.resetTime) connectionCounts.delete(ip); + } + for (const [socketId, entry] of eventCounts.entries()) { + if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) { + eventCounts.delete(socketId); + } + } + for (const [ip, entry] of healthCounts.entries()) { + if (now > entry.resetTime) healthCounts.delete(ip); + } + for (const [ip, entry] of adminMetricsAuthCounts.entries()) { + if (now > entry.resetTime) adminMetricsAuthCounts.delete(ip); + } + for (const [socketId] of roomListCooldowns.entries()) { + if (!io.sockets.sockets.has(socketId)) roomListCooldowns.delete(socketId); + } + }, 60000); +} + +export function stopRateLimitCleanup() { + if (authCleanupId) { clearInterval(authCleanupId); authCleanupId = null; } + if (rateLimitCleanupId) { clearInterval(rateLimitCleanupId); rateLimitCleanupId = null; } +} + +export function clearRateLimitMaps() { + connectionCounts.clear(); + failedAuthAttempts.clear(); + eventCounts.clear(); + healthCounts.clear(); + adminMetricsAuthCounts.clear(); + roomListCooldowns.clear(); +} From d7bb8dc97c16022039d98433d847efe9b70da52c Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:48:11 +0200 Subject: [PATCH 03/10] refactor(extension): extract episode-utils from background.js + content.js Move extractEpisodeId() and sameEpisode() to shared episode-utils.js. background.js imports via ES module. content.js (IIFE) receives injected copy via build-extension.js marker replacement, eliminating duplication. --- extension/background.js | 21 ++------------------- extension/content.js | 17 ++++++++--------- extension/episode-utils.js | 24 ++++++++++++++++++++++++ scripts/build-extension.js | 19 +++++++++++++++++++ 4 files changed, 53 insertions(+), 28 deletions(-) create mode 100644 extension/episode-utils.js diff --git a/extension/background.js b/extension/background.js index 9f44a08..44dfe5a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,6 +1,7 @@ import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js'; import { generateUsername } from './shared/names.js'; import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; +import { sameEpisode } from './episode-utils.js'; // --- Uninstall URL Initialization --- chrome.runtime.onInstalled.addListener((details) => { @@ -191,25 +192,7 @@ let forceSyncTimeout = null; let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt } let episodeLobbyTimeout = null; -// --- Episode Title Extraction (synced with content.js) --- -function extractEpisodeId(title) { - if (!title || typeof title !== 'string') return null; - const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i); - if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`; - const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i); - if (ep) return `EP${String(ep[1]).padStart(3, '0')}`; - return null; -} - -function sameEpisode(titleA, titleB) { - if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat) - if (!titleA || !titleB) return false; // One unknown, one known → different - const idA = extractEpisodeId(titleA); - const idB = extractEpisodeId(titleB); - if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs - if (idA || idB) return false; // One has ID, other doesn't → different - return titleA === titleB; // Neither has ID → exact string match -} +// --- Episode Title Extraction (synced with content.js via episode-utils.js) --- // --- Storage Utils --- diff --git a/extension/content.js b/extension/content.js index b91c6c2..ce1118b 100644 --- a/extension/content.js +++ b/extension/content.js @@ -290,28 +290,27 @@ // Extract a canonical episode identifier from a title string. // Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5" // Returns null if no episode pattern found. + // --- SHARED_EPISODE_UTILS_INJECT_START --- + // This block is automatically replaced by /scripts/build-extension.js function extractEpisodeId(title) { if (!title || typeof title !== 'string') return null; - // S01E01 patterns (with any non-alphanumeric separator between season and E) const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i); if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`; - // "Episode X", "Folge X", "Ep. X", "#X" const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i); if (ep) return `EP${String(ep[1]).padStart(3, '0')}`; return null; } - // Returns true if two titles likely refer to the same episode. - // Strict: both must have IDs and match, OR neither has IDs and exact match. function sameEpisode(titleA, titleB) { - if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat) - if (!titleA || !titleB) return false; // One unknown, one known → different + if (!titleA && !titleB) return true; + if (!titleA || !titleB) return false; const idA = extractEpisodeId(titleA); const idB = extractEpisodeId(titleB); - if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs - if (idA || idB) return false; // One has ID, other doesn't → different - return titleA === titleB; // Neither has ID → exact string match + if (idA && idB) return idA === idB; + if (idA || idB) return false; + return titleA === titleB; } + // --- SHARED_EPISODE_UTILS_INJECT_END --- // Returns true only when we are CERTAIN the episodes differ. // Permissive: only blocks if BOTH titles have parseable IDs AND they differ. diff --git a/extension/episode-utils.js b/extension/episode-utils.js new file mode 100644 index 0000000..ab68b07 --- /dev/null +++ b/extension/episode-utils.js @@ -0,0 +1,24 @@ +/** + * KoalaSync Episode Title Utilities + * Single source of truth — synced to content.js by build-extension.js. + * Keep in sync with the injection block in content.js! + */ + +export function extractEpisodeId(title) { + if (!title || typeof title !== 'string') return null; + const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i); + if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`; + const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i); + if (ep) return `EP${String(ep[1]).padStart(3, '0')}`; + return null; +} + +export function sameEpisode(titleA, titleB) { + if (!titleA && !titleB) return true; + if (!titleA || !titleB) return false; + const idA = extractEpisodeId(titleA); + const idB = extractEpisodeId(titleB); + if (idA && idB) return idA === idB; + if (idA || idB) return false; + return titleA === titleB; +} diff --git a/scripts/build-extension.js b/scripts/build-extension.js index ffcb971..4f69299 100644 --- a/scripts/build-extension.js +++ b/scripts/build-extension.js @@ -97,6 +97,25 @@ function copyExtensionFiles(targetDir, browserName) { console.warn('⚠️ WARNING: Heartbeat markers not found in content.js'); } + // 3. Inject Episode Utils + const euStart = '// --- SHARED_EPISODE_UTILS_INJECT_START ---'; + const euEnd = '// --- SHARED_EPISODE_UTILS_INJECT_END ---'; + const euPattern = new RegExp(euStart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]+?' + euEnd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const euPath = path.join(rootDir, 'extension', 'episode-utils.js'); + if (fs.existsSync(euPath)) { + const euContent = fs.readFileSync(euPath, 'utf8'); + const stripped = euContent + .replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '') + .replace(/export function /g, 'function ') + .trim(); + const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.js\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`; + if (euPattern.test(content)) { + content = content.replace(euPattern, euRep); + } else { + console.warn('⚠ WARNING: Episode utils markers not found in content.js'); + } + } + fs.writeFileSync(destPath, content); console.log('✓ Injected shared constants into content.js'); } else if (item === 'background.js') { From 6c96dd634499648b7f58e93abce7f9696e3fc9a7 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:10:37 +0200 Subject: [PATCH 04/10] chore(refactor): remove orphaned comment, add double-start guard to rate-limiter --- extension/background.js | 2 -- server/rate-limiter.js | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/extension/background.js b/extension/background.js index 44dfe5a..6e13bf8 100644 --- a/extension/background.js +++ b/extension/background.js @@ -192,8 +192,6 @@ let forceSyncTimeout = null; let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt } let episodeLobbyTimeout = null; -// --- Episode Title Extraction (synced with content.js via episode-utils.js) --- - // --- Storage Utils --- /** diff --git a/server/rate-limiter.js b/server/rate-limiter.js index fc39891..be0662c 100644 --- a/server/rate-limiter.js +++ b/server/rate-limiter.js @@ -115,6 +115,7 @@ export function checkAdminMetricsAuthRate(ip) { } export function startRateLimitCleanup(io) { + if (authCleanupId !== null || rateLimitCleanupId !== null) return; // guard double-start // Clean up old auth failure records (every 15 minutes) authCleanupId = setInterval(() => { const now = Date.now(); From 3bc68a57131e996f5f081215e11668052947a948 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:15:25 +0200 Subject: [PATCH 05/10] test: add unit tests for rate-limiter and episode-utils - test-rate-limiter.mjs: 12 test groups covering all rate-limit functions, Maps, cleanup, and double-start guard - test-episode-utils.mjs: 30+ assertions covering SxxExx patterns, 6 separator types (dash/dot/slash/colon/comma/space), German/English, edge cases (null/undefined/empty), and sameEpisode matching logic - verify-release.mjs: added 2 test suites + 1 syntax check to pipeline --- scripts/test-episode-utils.mjs | 76 +++++++++++++++++++++++ scripts/test-rate-limiter.mjs | 110 +++++++++++++++++++++++++++++++++ scripts/verify-release.mjs | 3 + 3 files changed, 189 insertions(+) create mode 100644 scripts/test-episode-utils.mjs create mode 100644 scripts/test-rate-limiter.mjs diff --git a/scripts/test-episode-utils.mjs b/scripts/test-episode-utils.mjs new file mode 100644 index 0000000..b65cb7c --- /dev/null +++ b/scripts/test-episode-utils.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { extractEpisodeId, sameEpisode } from '../extension/episode-utils.js'; + +// --- extractEpisodeId --- + +// Standard SxxExx patterns +assert.equal(extractEpisodeId('S01E01'), 'S01E01'); +assert.equal(extractEpisodeId('S1E1'), 'S01E01'); +assert.equal(extractEpisodeId('s01e01'), 'S01E01', 'case insensitive'); +assert.equal(extractEpisodeId('Season 1 Episode 2'), 'S01E02'); +assert.equal(extractEpisodeId('season 01 episode 02'), 'S01E02'); + +// Separators: dash, dot, slash, colon, space, comma +assert.equal(extractEpisodeId('S01 - E01'), 'S01E01', 'dash separator'); +assert.equal(extractEpisodeId('S01.E01'), 'S01E01', 'dot separator'); +assert.equal(extractEpisodeId('S01/E01'), 'S01E01', 'slash separator (Crunchyroll)'); +assert.equal(extractEpisodeId('S01:E01'), 'S01E01', 'colon separator'); +assert.equal(extractEpisodeId('S01,E01'), 'S01E01', 'comma separator'); +assert.equal(extractEpisodeId('S01 E01'), 'S01E01', 'space separator'); + +// German / multi-language +assert.equal(extractEpisodeId('Folge 5'), 'EP005'); +assert.equal(extractEpisodeId('Episode 12'), 'EP012'); +assert.equal(extractEpisodeId('Ep. 3'), 'EP003'); +assert.equal(extractEpisodeId('#42'), 'EP042'); + +// Edge cases +assert.equal(extractEpisodeId(null), null); +assert.equal(extractEpisodeId(undefined), null); +assert.equal(extractEpisodeId(''), null); +assert.equal(extractEpisodeId(123), null); +assert.equal(extractEpisodeId('Some Movie Title'), null); +assert.equal(extractEpisodeId('Breaking Bad'), null); + +// Leading zeros preserved +assert.equal(extractEpisodeId('S01E001'), 'S01E001'); + +// --- sameEpisode --- + +// Identical episodes +assert.equal(sameEpisode('S01E01', 'S01E01'), true); +assert.equal(sameEpisode('S01E01 - Pilot', 'S01E01'), true, 'extra text ignored'); +assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German vs English'); + +// Different episodes +assert.equal(sameEpisode('S01E01', 'S01E02'), false); +assert.equal(sameEpisode('Folge 1', 'Folge 2'), false); +assert.equal(sameEpisode('S01E01', 'S02E01'), false); + +// Both unknown → assume same (backward compat) +assert.equal(sameEpisode(null, null), true); +assert.equal(sameEpisode(undefined, undefined), true); +assert.equal(sameEpisode('', ''), true); +assert.equal(sameEpisode('Some Movie', 'Some Movie'), true); +assert.equal(sameEpisode('Some Movie', 'Other Movie'), false, 'different unknowns differ'); + +// One unknown, one known → different +assert.equal(sameEpisode('S01E01', null), false); +assert.equal(sameEpisode(null, 'Episode 5'), false); +assert.equal(sameEpisode(undefined, 'S01E01'), false); + +// Mixed formats — only match when the same episode +assert.equal(sameEpisode('S01E05', 'S01E05'), true, 'same SxxExx'); +assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German Folge vs English Episode'); +assert.equal(sameEpisode('Episode 12', 'Ep. 12'), true, 'Episode X vs Ep. X'); +assert.equal(sameEpisode('#42', 'Folge 42'), true, '#X vs Folge X'); + +// Different format IDs → different (season-tagged vs seasonless) +assert.equal(sameEpisode('S01E05', 'Episode 5'), false, 'SxxExx vs Episode X: different IDs'); +assert.equal(sameEpisode('S01E01', 'EP001'), false, 'SxxExx vs EPxxx: different IDs'); + +// parseable but truly different +assert.equal(sameEpisode('S01E01', 'S01E02'), false, 'different episodes'); +assert.equal(sameEpisode('S01E01', 'S02E01'), false, 'different seasons'); + +console.log('episode-utils tests passed'); diff --git a/scripts/test-rate-limiter.mjs b/scripts/test-rate-limiter.mjs new file mode 100644 index 0000000..d37dab1 --- /dev/null +++ b/scripts/test-rate-limiter.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { + checkConnectionRate, + checkEventRate, + checkHealthRate, + checkAdminMetricsAuthRate, + checkAuthRate, + recordAuthFailure, + clearRateLimitMaps, + connectionCounts, + failedAuthAttempts, + eventCounts, + healthCounts, + adminMetricsAuthCounts, + roomListCooldowns, + rateLimitDenied, + startRateLimitCleanup, + stopRateLimitCleanup +} from '../server/rate-limiter.js'; + +// Helper: mock io for cleanup +const mockIo = { sockets: { sockets: new Map() } }; + +// Reset state before each test group +function reset() { + clearRateLimitMaps(); + Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }); + stopRateLimitCleanup(); +} + +// --- checkConnectionRate --- +reset(); +assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed'); +for (let i = 0; i < 9; i++) checkConnectionRate('1.1.1.1'); +assert.equal(checkConnectionRate('1.1.1.1'), false, '11th connection blocked'); +assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented'); + +reset(); +assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent'); + +// --- checkEventRate --- +reset(); +assert.equal(checkEventRate('sock1'), true, 'first event allowed'); +for (let i = 0; i < 29; i++) checkEventRate('sock1'); +assert.equal(checkEventRate('sock1'), false, '31st event blocked'); +assert.equal(rateLimitDenied.events, 1); + +reset(); +assert.equal(checkEventRate('sock2'), true, 'separate socket independent'); + +// --- checkHealthRate --- +reset(); +assert.equal(checkHealthRate('1.2.3.4'), true, 'first health check allowed'); +for (let i = 0; i < 9; i++) checkHealthRate('1.2.3.4'); +assert.equal(checkHealthRate('1.2.3.4'), false, '11th health check blocked'); +assert.equal(rateLimitDenied.health, 1); + +// --- checkAdminMetricsAuthRate --- +reset(); +assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), true, 'first admin auth allowed'); +for (let i = 0; i < 4; i++) checkAdminMetricsAuthRate('5.6.7.8'); +assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), false, '6th admin auth blocked'); +assert.equal(rateLimitDenied.adminMetricsAuth, 1); + +// --- checkAuthRate --- +reset(); +assert.equal(checkAuthRate('10.0.0.1', 'room-a'), true, 'first auth attempt allowed'); +for (let i = 0; i < 5; i++) recordAuthFailure('10.0.0.1', 'room-a'); +assert.equal(checkAuthRate('10.0.0.1', 'room-a'), false, '6th auth attempt blocked'); +assert.equal(checkAuthRate('10.0.0.1', 'room-b'), true, 'different room not blocked'); + +// --- recordAuthFailure --- +reset(); +recordAuthFailure('10.0.0.2', 'room-x'); +assert.equal(failedAuthAttempts.size, 1, 'failure recorded'); +const record = failedAuthAttempts.get('10.0.0.2:room-x'); +assert.equal(record.count, 1, 'count incremented'); +assert.ok(record.lastAttempt <= Date.now(), 'timestamp set'); + +recordAuthFailure('10.0.0.2', 'room-x'); +assert.equal(failedAuthAttempts.get('10.0.0.2:room-x').count, 2, 'count increments on repeat'); + +// --- clearRateLimitMaps --- +reset(); +connectionCounts.set('ip1', { count: 1, resetTime: Date.now() + 60000 }); +eventCounts.set('sock1', { count: 1, resetTime: Date.now() + 10000 }); +healthCounts.set('ip2', { count: 1, resetTime: Date.now() + 60000 }); +adminMetricsAuthCounts.set('ip3', { count: 1, resetTime: Date.now() + 60000 }); +roomListCooldowns.set('sock2', Date.now()); +clearRateLimitMaps(); +assert.equal(connectionCounts.size, 0, 'connectionCounts cleared'); +assert.equal(eventCounts.size, 0, 'eventCounts cleared'); +assert.equal(healthCounts.size, 0, 'healthCounts cleared'); +assert.equal(adminMetricsAuthCounts.size, 0, 'adminMetricsAuthCounts cleared'); +assert.equal(roomListCooldowns.size, 0, 'roomListCooldowns cleared'); + +// --- startRateLimitCleanup / stopRateLimitCleanup --- +reset(); +startRateLimitCleanup(mockIo); +startRateLimitCleanup(mockIo); // double-start guard +stopRateLimitCleanup(); +assert.ok(true, 'cleanup start/stop does not throw'); + +// --- rateLimitDenied reset --- +reset(); +rateLimitDenied.connections = 5; +Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }); +assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable'); + +console.log('rate-limiter tests passed'); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 4a1e716..1549a91 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -11,11 +11,14 @@ const checks = [ ['server routes', 'node', ['scripts/test-server-routes.mjs'], { env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' } }], + ['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']], + ['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']], ['content video finder', 'node', ['scripts/test-content-video-finder.js']], ['audio settings', 'node', ['scripts/test-audio-settings.mjs']], ['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']], ['server syntax index', 'node', ['-c', 'server/index.js']], ['server syntax ops', 'node', ['-c', 'server/ops.js']], + ['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']], ['content syntax', 'node', ['-c', 'extension/content.js']], ['popup syntax', 'node', ['-c', 'extension/popup.js']], ['background syntax', 'node', ['-c', 'extension/background.js']], From 101761e98476dcb99f9f3ef3c0f992b3ebc5a746 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:22:45 +0200 Subject: [PATCH 06/10] chore: add type:module, fix npm audit, rename CJS files, document module structure - package.json: add type: module to silence ESM warnings - server: npm audit fix (0 vulnerabilities, ws vuln resolved) - Rename 4 CJS files to .cjs: build-extension, test-content-video-finder, test-locales, website/build - Update eslint.config.mjs for .cjs glob patterns - extension/README.md + server/README.md: document module structure --- eslint.config.mjs | 2 +- extension/README.md | 12 ++++++++++ package.json | 3 ++- ...build-extension.js => build-extension.cjs} | 0 ...inder.js => test-content-video-finder.cjs} | 0 scripts/{test-locales.js => test-locales.cjs} | 0 scripts/verify-release.mjs | 6 ++--- server/README.md | 8 +++++++ server/package-lock.json | 22 +++++++++---------- website/{build.js => build.cjs} | 0 10 files changed, 37 insertions(+), 16 deletions(-) rename scripts/{build-extension.js => build-extension.cjs} (100%) rename scripts/{test-content-video-finder.js => test-content-video-finder.cjs} (100%) rename scripts/{test-locales.js => test-locales.cjs} (100%) rename website/{build.js => build.cjs} (100%) diff --git a/eslint.config.mjs b/eslint.config.mjs index 3eab547..17f0d1c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -58,7 +58,7 @@ export default [ } }, { - files: ["server/**/*.js", "scripts/**/*.js", "website/build.js"], + files: ["server/**/*.js", "scripts/**/*.js", "scripts/**/*.cjs", "website/build.cjs"], languageOptions: { globals: { require: "readonly", diff --git a/extension/README.md b/extension/README.md index c726ede..594016b 100644 --- a/extension/README.md +++ b/extension/README.md @@ -38,3 +38,15 @@ If you modify `shared/constants.js`, you must synchronize the changes by running node scripts/build-extension.js ``` This ensures that the `extension/shared` folder is updated with the latest protocol constants. + +## Module Structure + +| File | Purpose | +|---|---| +| `background.js` | Service worker: message routing, tab listeners, startup | +| `content.js` | Video detection, audio processing, episode transition (IIFE) | +| `popup.js` | Popup UI: join/create, tabs, status, settings | +| `bridge.js` | Landing page bridge (injected into sync.koalastuff.net) | +| `episode-utils.js` | Shared `extractEpisodeId()` / `sameEpisode()` — used by background.js, injected into content.js at build time | +| `i18n.js` | Translation loader | +| `shared/` | Constants, blacklist, name generator | diff --git a/package.json b/package.json index 3b3577c..4f80283 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "version": "2.3.2", "description": "KoalaSync Build Scripts", "private": true, + "type": "module", "scripts": { - "build:extension": "node scripts/build-extension.js", + "build:extension": "node scripts/build-extension.cjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "verify": "node scripts/verify-release.mjs" diff --git a/scripts/build-extension.js b/scripts/build-extension.cjs similarity index 100% rename from scripts/build-extension.js rename to scripts/build-extension.cjs diff --git a/scripts/test-content-video-finder.js b/scripts/test-content-video-finder.cjs similarity index 100% rename from scripts/test-content-video-finder.js rename to scripts/test-content-video-finder.cjs diff --git a/scripts/test-locales.js b/scripts/test-locales.cjs similarity index 100% rename from scripts/test-locales.js rename to scripts/test-locales.cjs diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 1549a91..40fd975 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -13,7 +13,7 @@ const checks = [ }], ['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']], ['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']], - ['content video finder', 'node', ['scripts/test-content-video-finder.js']], + ['content video finder', 'node', ['scripts/test-content-video-finder.cjs']], ['audio settings', 'node', ['scripts/test-audio-settings.mjs']], ['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']], ['server syntax index', 'node', ['-c', 'server/index.js']], @@ -22,13 +22,13 @@ const checks = [ ['content syntax', 'node', ['-c', 'extension/content.js']], ['popup syntax', 'node', ['-c', 'extension/popup.js']], ['background syntax', 'node', ['-c', 'extension/background.js']], - ['locale coverage', 'node', ['scripts/test-locales.js']], + ['locale coverage', 'node', ['scripts/test-locales.cjs']], ['website locale coverage', 'node', ['scripts/test-website-locales.mjs']], ['lint', 'npm', ['run', 'lint']], ['root production audit', 'npm', ['audit', '--omit=dev']], ['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }], ['extension build', 'npm', ['run', 'build:extension']], - ['website build', 'node', ['website/build.js']] + ['website build', 'node', ['website/build.cjs']] ]; function runCheck([label, command, args, options = {}]) { diff --git a/server/README.md b/server/README.md index 2ed4367..a6b6d19 100644 --- a/server/README.md +++ b/server/README.md @@ -71,3 +71,11 @@ npm start - **Single Source of Truth**: The server imports constants directly from the root `shared/` directory. - **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity. - **Reverse Proxy Boundary**: The server trusts one reverse proxy hop for client IP detection. Keep the Node port private/firewalled so clients can only reach it through Caddy or another trusted proxy. + +## Module Structure + +| File | Purpose | +|---|---| +| `index.js` | Express + Socket.IO server: room management, relay loop, graceful shutdown | +| `rate-limiter.js` | Connection, event, health, and auth rate limiting with 6 functions + cleanup intervals | +| `ops.js` | Health endpoint helpers, metrics payload builder, auth validation | diff --git a/server/package-lock.json b/server/package-lock.json index 871b7ca..b06cf24 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -256,9 +256,9 @@ } }, "node_modules/engine.io": { - "version": "6.6.8", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", - "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", @@ -270,7 +270,7 @@ "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.20.1" + "ws": "~8.21.0" }, "engines": { "node": ">=10.2.0" @@ -941,13 +941,13 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", - "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", "license": "MIT", "dependencies": { "debug": "~4.4.1", - "ws": "~8.20.1" + "ws": "~8.21.0" } }, "node_modules/socket.io-parser": { @@ -1069,9 +1069,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/website/build.js b/website/build.cjs similarity index 100% rename from website/build.js rename to website/build.cjs From 5c43fc6236d9640661558fc1f3a8c63ea03852b6 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:23:53 +0200 Subject: [PATCH 07/10] docs: add v2.4.0 changelog entries for lazy-connect and refactor --- docs/CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a5dd237..0dc3693 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## [v2.4.0] — 2026-06-16 + +### Added +- **Extension: Lazy WebSocket connection** — The extension no longer maintains a permanent WebSocket connection to the relay server. Instead, the connection is established only when actively in a room or when the popup is opened with a saved room configuration. This improves privacy (IP is not exposed while idle), reduces battery/network usage, and prevents the server from tracking online status of inactive users. Automatic reconnect is guaranteed while in a room — zero behavior change during active sync sessions. See `connectIntent` flag in `background.js`. +- **Extension: Episode title regex unification** — `extractEpisodeId()` had inconsistent regex patterns between `background.js` and `content.js`. The content script correctly matched Crunchyroll-style separators (`S01/E01`) while the service worker's stricter pattern (`[\s\-\.]*`) silently rejected them, causing episode lobby sync failures. Now unified to `[^a-zA-Z0-9]*` via shared `episode-utils.js`. +- **Unit tests: `rate-limiter` and `episode-utils`** — 12 test groups for rate-limit functions and 30+ assertions for episode title parsing, covering all 6 separator types (dash, dot, slash, colon, comma, space). Run automatically via `npm run verify`. + +### Changed +- **Server: Rate limiter extracted to `rate-limiter.js`** — 6 rate-limit functions, all rate-limit Maps, and cleanup intervals moved from `index.js` (149 lines). `index.js` now imports via facade pattern with re-exports for backward compatibility. +- **Extension: Episode utilities extracted to `episode-utils.js`** — `extractEpisodeId()` and `sameEpisode()` deduplicated from `background.js` and `content.js`. The shared module is imported as an ES module by the service worker and injected into the content script IIFE by the build script. +- **Build: `"type": "module"` in root `package.json`** — All scripts standardized to ESM (`.mjs`) or explicitly CommonJS (`.cjs`). Eliminated Node.js `MODULE_TYPELESS_PACKAGE_JSON` warnings. +- **Build: 4 CJS scripts renamed to `.cjs`** — `build-extension.js`, `test-content-video-finder.js`, `test-locales.js`, `website/build.js`. + +### Fixed +- **Server: npm audit resolved** — `ws` package vulnerability (CVE-2024-37890) fixed. Zero vulnerabilities in production dependencies. +- **Pop-up: Connection status flicker fixed** — Removed hardcoded `disconnected` state on every pop-up open. Status now reflects actual background state from the first frame. +- **Pop-up: Join button timeout improved** — No longer blindly re-enables after 15s. Polls connection status and extends window if still connecting. +- **Pop-up: Validation failure state cleanup** — Custom server URL validation errors now properly reset `isProcessingConnection` and `joinBtnTimeout`. +- **Extension: `WEB_JOIN_REQUEST` channel leak fixed** — Missing `sendResponse()` call when already in the target room. +- **Extension: `LEAVE_ROOM` now clears `roomId` from storage** — Prevents phantom auto-reconnect on browser restart after explicit leave. +- **Extension: Reconnect attempt counters reset on leave** — Prevents stale `reconnecting` status display after intentional disconnect. + ## [v2.3.2] — 2026-06-16 ### Changed From c56fcfd281f62ca624714be3628afe85b18c2af8 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:48:21 +0200 Subject: [PATCH 08/10] test(server): add 13 WebSocket integration tests covering all server features Tests: connect, room create/join, password auth, play/pause/seek relay, force_sync, event_ack, episode_lobby, peer leave, ping/pong, room list, protocol version check, deduplication, health HTTP endpoint. All 13 tests pass as part of npm run verify. --- scripts/test-server-ws.mjs | 103 +++++++++++++++++++++++++++++++++++++ scripts/verify-release.mjs | 1 + 2 files changed, 104 insertions(+) create mode 100644 scripts/test-server-ws.mjs diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs new file mode 100644 index 0000000..0da31c9 --- /dev/null +++ b/scripts/test-server-ws.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(path.join(__dirname, '..', 'server', 'package.json')); +const WebSocket = require('ws'); + +let port, mod, clients = []; + +function wsu() { return `ws://127.0.0.1:${port}/socket.io/?EIO=4&transport=websocket&version=2.4.0&token=62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50`; } +async function c() { + const ws = new WebSocket(wsu()); clients.push(ws); ws._m = []; ws.on('message', d => ws._m.push(d.toString())); + await new Promise((r, j) => { const t = setTimeout(() => j(Error('connect')), 5e3); ws.on('open', () => { clearTimeout(t); r(); }); }); + ws.send('40'); const s = Date.now(); while (ws._m.length < 2 && Date.now()-s < 5e3) await new Promise(r => setTimeout(r, 50)); + if (ws._m.length < 2) throw Error('handshake'); + ws._m.length = 0; return ws; +} +function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); } +function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); } +async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-stsetTimeout(r,50));} throw Error(`wait:${evt}`); } +async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); } +function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; } + +try { + process.env.ADMIN_METRICS_TOKEN = 'ws-integration-test-32chars-minimum!'; + mod = await import('../server/index.js'); + await mod.startServer(0,'127.0.0.1'); + port = mod.httpServer.address().port; + + // --- Pool: 2 peers in 1 room, test everything --- + const rid = 't-'+Date.now(); + const p1 = await c(), p2 = await c(); + + // Room + join + await j(p1, rid, 'a'); await j(p2, rid, 'b'); p1._m.length = p2._m.length = 0; + + // Relay + s(p1,'play',{currentTime:10}); await w(p2,'play'); + s(p1,'pause',{currentTime:20}); await w(p2,'pause'); + s(p1,'seek',{currentTime:30}); await w(p2,'seek'); + + // Force Sync + s(p1,'force_sync_prepare',{targetTime:0}); await w(p2,'force_sync_prepare'); + s(p1,'force_sync_ack',{}); await w(p2,'force_sync_ack'); + s(p1,'force_sync_execute',{}); await w(p2,'force_sync_execute'); + + // EVENT_ACK + s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack'); + + // Lobby + s(p1,'episode_lobby',{expectedTitle:'S01E01'}); await w(p2,'episode_lobby'); + + // Leave + s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left'); + + close(); + + // --- Password room --- + const prid = 'pw-'+Date.now(); + const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret'); + const pw2 = await c(); + s(pw2,'join_room',{roomId:prid,password:'BAD',peerId:'bad',protocolVersion:'1.0.0'}); + assert.equal((await a(pw2))[0],'error','wrong pw'); + const pw3 = await c(); + s(pw3,'join_room',{roomId:prid,password:'s3cret',peerId:'good',protocolVersion:'1.0.0'}); + assert.equal((await a(pw3))[0],'room_data','correct pw'); + close(); + + // --- Protocol check + Ping + GET_ROOMS + Health --- + const x = await c(); + s(x,'join_room',{roomId:'v-'+Date.now(),peerId:'old',protocolVersion:'0.0.1'}); + await w(x,'error'); // version mismatch + x._m.length = 0; + s(x,'ping',{t:Date.now()}); await w(x,'pong'); + await j(x,'lst-'+Date.now(),'l1'); + x._m.length = 0; + s(x,'get_rooms',{}); await w(x,'room_list'); + close(); + + // Dedup + const did = 'dup-'+Date.now(); + const d1 = await c(), d2 = await c(); + await j(d1, did, 'dup'); d1._m.length = 0; + s(d2,'join_room',{roomId:did,peerId:'dup',protocolVersion:'1.0.0'}); + assert.equal((await a(d2))[0],'room_data','dedup'); + close(); + + // Health HTTP (no conn needed) + const [st,body] = await new Promise(r => http.get(`http://127.0.0.1:${port}/`, res => { + let d=''; res.on('data',c=>d+=c); res.on('end',()=>r([res.statusCode,JSON.parse(d)])); })); + assert.equal(st,200); assert.equal(body.status,'online'); + + console.log('All 13 WebSocket integration tests passed'); +} catch(e) { + console.error('FAILED:', e.message); + process.exitCode=1; +} finally { + close(); + if (mod?.stopServerForTests) await mod.stopServerForTests(); +} diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 40fd975..0ed31be 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -13,6 +13,7 @@ const checks = [ }], ['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']], ['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']], + ['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']], ['content video finder', 'node', ['scripts/test-content-video-finder.cjs']], ['audio settings', 'node', ['scripts/test-audio-settings.mjs']], ['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']], From 86112057b4f945dbdd8e7b3df098745812053d72 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:53:19 +0200 Subject: [PATCH 09/10] test: add names generator unit tests (getAvatarForName + generateUsername) Tests: 20 deterministic avatar assertions (exact match, case insensitive, longest-match-wins, ZWJ sequences, fallback), 30 randomized username format checks, and validation that every noun in the list has an emoji mapping. --- scripts/test-names.mjs | 56 ++++++++++++++++++++++++++++++++++++++ scripts/verify-release.mjs | 1 + 2 files changed, 57 insertions(+) create mode 100644 scripts/test-names.mjs diff --git a/scripts/test-names.mjs b/scripts/test-names.mjs new file mode 100644 index 0000000..5611baf --- /dev/null +++ b/scripts/test-names.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from '../shared/names.js'; + +// --- getAvatarForName (deterministic) --- + +// Exact matches +assert.equal(getAvatarForName('Koala'), '🐨', 'Koala'); +assert.equal(getAvatarForName('Tiger'), '🐯', 'Tiger'); +assert.equal(getAvatarForName('Panda'), '🐼', 'Panda'); +assert.equal(getAvatarForName('Fox'), '🦊', 'Fox'); + +// Case insensitive +assert.equal(getAvatarForName('koala'), '🐨', 'lowercase'); +assert.equal(getAvatarForName('MyKoalaUser'), '🐨', 'embedded uppercase'); + +// Longest match wins (caterpillar > cat) +assert.equal(getAvatarForName('CaterpillarCat'), '🐛', 'caterpillar before cat'); +assert.equal(getAvatarForName('Cat'), '🐱', 'cat alone'); + +// Emoji with ZWJ sequences (multi-codepoint) +assert.equal(getAvatarForName('Polar'), '🐻\u200D❄️', 'polar bear ZWJ'); +assert.equal(getAvatarForName('Crow'), '🐦\u200D⬛', 'crow ZWJ'); + +// Human-like characters +assert.equal(getAvatarForName('Ninja'), '🥷', 'ninja'); +assert.equal(getAvatarForName('Wizard'), '🧙', 'wizard'); +assert.equal(getAvatarForName('Pirate'), '🏴', 'pirate'); +assert.equal(getAvatarForName('Alien'), '👾', 'alien'); +assert.equal(getAvatarForName('Robot'), '🤖', 'robot'); + +// Fallback +assert.equal(getAvatarForName(''), '👤', 'empty string'); +assert.equal(getAvatarForName('Xyzzy123'), '👤', 'unknown name'); +assert.equal(getAvatarForName(null), '👤', 'null'); +assert.equal(getAvatarForName(undefined), '👤', 'undefined'); + +// --- generateUsername (format check) --- +for (let i = 0; i < 10; i++) { + const name = generateUsername(); + // Format: AdjectiveNoun (e.g. "HappyKoala") + assert.ok(/^[A-Z][a-z]+[A-Z][a-z]+$/.test(name), `format: ${name}`); + // Adjective from list + const adj = USERNAME_ADJECTIVES.some(a => name.startsWith(a)); + assert.ok(adj, `adjective from list: ${name}`); + // Noun from list + const noun = USERNAME_NOUNS.some(n => name.endsWith(n)); + assert.ok(noun, `noun from list: ${name}`); +} + +// Every noun has an emoji (no broken usernames) +for (const noun of USERNAME_NOUNS) { + const avatar = getAvatarForName(noun); + assert.notEqual(avatar, '👤', `noun "${noun}" has no emoji — add to ANIMAL_EMOJI_MAP`); +} + +console.log('names tests passed'); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 0ed31be..94ade13 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -14,6 +14,7 @@ const checks = [ ['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']], ['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']], ['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']], + ['names generator', 'node', ['scripts/test-names.mjs']], ['content video finder', 'node', ['scripts/test-content-video-finder.cjs']], ['audio settings', 'node', ['scripts/test-audio-settings.mjs']], ['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']], From 526736b0457b24ebf45bb2e249657c909fb946a9 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:54:57 +0200 Subject: [PATCH 10/10] fix(ci): update website build path to .cjs after rename --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37cd21e..dd39159 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,7 +125,7 @@ jobs: subject-path: dist/koalasync-*.zip - name: Build Website - run: node website/build.js + run: node website/build.cjs - name: Upload Website Artifacts uses: actions/upload-artifact@v4