diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0d78b..80c04dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,95 @@ # Changelog +## v5.6.0 — Survive a dropped connection + +A chat no longer dies when the network moves under it. Switching Wi-Fi → LTE, +a NAT rebind, a lift, a tunnel: the session now repairs its own network path and +the messages you typed meanwhile go out when it comes back. + +This is done without adding any server. An ICE restart renegotiates *only* the +transport path; the DTLS handshake, the session keys and the SCTP association +carrying the data channel all sit above ICE and survive it. So the renegotiation +SDP travels over the existing end-to-end encrypted, SAS-verified channel — there +is still no signalling service anywhere in the design, and an attacker who cannot +already decrypt the session cannot inject a reconnection. + +### Added + +- **Automatic session recovery.** A broken path is repaired in place with an + in-band ICE restart, retried with a 1/2/4/8/15/30 s backoff for up to two + minutes. Keys, SAS verification and message history are all preserved — no + re-handshake, no comparing codes again. +- **Liveness detection that understands sleeping devices.** A data channel keeps + reporting `readyState: 'open'` long after the path underneath it has died — the + classic Wi-Fi → LTE switch, where nothing closes and nothing errors, packets + just stop. Silence alone is deliberately not treated as death, because a + browser freezes a backgrounded tab outright and a healthy peer then answers + nothing at all. What survives that freeze is ICE consent, which the browser + runs in its network stack rather than on the page's thread — so a connected ICE + state means a silent peer is asleep, not gone, and the session is left alone. + Only when ICE itself is degraded does an unanswered probe end the session. +- **Recovery is given up promptly when it cannot possibly work.** Every route out + of a broken path runs over the data channel, so if nothing at all has reached + us since the drop, no further attempt can succeed. Likewise an ICE agent left + bound to a network that is gone — every candidate times out, every restart ends + with zero candidate pairs — cannot be repaired by restarting it. Both are now + recognised in seconds instead of being retried for two minutes. +- **A session that cannot be recovered is closed, not left half-alive.** When the + path is gone for good, the chat is ended and its data wiped — keys, queued + messages and transcript together. There is deliberately no manual fallback: a + conversation whose transport is gone should not leave its plaintext sitting in + a tab, and starting a fresh one is a single, honest step. +- **Store-and-forward while reconnecting.** Messages typed during a repair are + queued and delivered when the path returns, in order. A send that races a + still-settling path is re-queued rather than marked failed. +- **The conversation stays on screen** during a repair, with a "Restoring + connection…" state, instead of dropping you back to the connect screen. +- **A device with no network holds the session open.** Five minutes underground + no longer costs a session: the give-up deadline does not run while this device + has no connectivity, and recovery retries the moment the radio returns or the + tab comes back to the foreground. +- `tests/session-recovery.test.mjs` covers the state machine, the backoff, the + offline hold and the identity check below. + +### Security + +- **A reconnection cannot re-point a session at a different peer.** The DTLS + fingerprint in an incoming restart offer or answer is checked against the + fingerprint of the live, already-verified session *before* anything is applied + to the peer connection. A mismatch aborts recovery. If there is no live + fingerprint to compare against, the restart is refused rather than trusted. +- Only the side that created the original offer may drive a restart; the other + side asks. With no signalling server there is no referee to resolve glare. +- Calls cannot be placed onto a path that is mid-repair, where the media + renegotiation would race the ICE restart on the same connection. + +### Fixed + +- **Every inbound heartbeat threw a `TypeError`.** `handleHeartbeat()` was + dispatched to but never defined, so peer liveness was never actually observed. +- **Heartbeats were sent every 5 minutes, not the intended interval** — the send + was folded into the general maintenance cycle, far too coarse to notice a dead + path. It now runs on its own timer, and answering one no longer requires the + peer to have finished verifying: the two sides confirm a SAS code at different + moments, and for that whole window one of them could not reply and was being + declared dead on a healthy connection. +- **The answering side never started its watchdog.** `ondatachannel` can hand over + a channel that is already open, so the `open` event had been dispatched before + the handler was assigned and never fired — leaving that side with no heartbeat, + no liveness watchdog and no file-transfer init. The peer whose network was fine + kept showing "connected" indefinitely because nothing was running to notice. +- **A failed send no longer fails silently.** Sending on a channel that was not + ready simply returned: the text stayed in the box, nothing was transmitted and + nothing said why. +- **A transient `disconnected` no longer tears down the session.** ICE reports it + routinely and the browser usually recovers unaided; it is now given a grace + window before a restart is spent, and it never clears verification on its own. +- A reconnected session no longer re-announces "secure connection established" — + it is the same session resuming, and no handshake took place. +- Liveness bookkeeping can no longer throw ahead of message routing, where the + surrounding catch would have swallowed it and silently dropped every inbound + message. + ## v5.5.4 — Fix the desktop download buttons ### Fixed diff --git a/README.md b/README.md index b37c4f6..38d5c69 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ No accounts. No servers storing your messages. No installation required. [![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-5.5.4-3ecf8e.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-5.6.0-3ecf8e.svg)](CHANGELOG.md) [![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app) [![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model) diff --git a/dist/app-boot.js b/dist/app-boot.js index ac182ed..012da5c 100644 --- a/dist/app-boot.js +++ b/dist/app-boot.js @@ -6694,8 +6694,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // 5 minutes CONNECTION_TIMEOUT: 1e4, // 10 seconds - HEARTBEAT_INTERVAL: 3e4, - // 30 seconds + // Kept below LIVENESS_PROBE_AFTER so a healthy peer's own heartbeats keep + // the liveness clock fresh and probing never happens on a working link. + HEARTBEAT_INTERVAL: 1e4, + // 10 seconds SECURITY_CALC_DELAY: 1e3, // 1 second SECURITY_CALC_RETRY_DELAY: 3e3, @@ -6730,11 +6732,71 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // 25 seconds REORDER_TIMEOUT: 3e3, // 3 seconds - RETRY_CONNECTION_DELAY: 2e3 + RETRY_CONNECTION_DELAY: 2e3, + // 2 seconds + // --- Session recovery --- + // How long to let a 'disconnected' path heal itself before renegotiating. + // + // The browser enters 'disconnected' after only ~5 s without a consent + // binding response, which ordinary packet loss produces, and then holds + // that state for roughly 25 s before declaring 'failed'. That whole + // window exists precisely so the connection can come back on its own — + // and it very often does, especially against a phone whose screen went + // off, which generates these episodes constantly. + // + // So restart LATE in the window, not at the start: early enough to still + // beat 'failed', late enough that self-healing has had its chance. An + // earlier 3 s value meant every backgrounded phone was answered with a + // renegotiation — tearing down a connection that was about to recover. + // See https://blog.mozilla.org/webrtc/ice-disconnected-not/ + ICE_DISCONNECT_GRACE: 8e3, + // 8 seconds + // How long one restart round-trip (offer → gather → answer) may take. No + // new attempt is launched while one is in flight: the round-trip is far + // longer than the head of the backoff, so retrying blindly cancels the + // attempt already running and recovery never converges. + ICE_RESTART_TIMEOUT: 2e4, + // 20 seconds + // Gathering budget inside a restart. Deliberately far below the initial + // handshake's 10 s: host and server-reflexive candidates arrive in well + // under a second, and waiting out the full budget for a relay candidate + // that may never come would blow the round-trip deadline above. + ICE_RESTART_GATHERING: 4e3, + // 4 seconds + // Give up on automatic recovery after this long. There is no manual + // fallback: the session is ended and its data wiped. + RECONNECT_MAX_DURATION: 12e4, + // 2 minutes + // In-band recovery needs the data channel to carry the renegotiation. If + // nothing at all arrives from the peer for this long once recovery has + // started, it cannot — and no number of further attempts will change + // that, so the session is ended promptly instead of after a two-minute + // wait that was never going to succeed. + RECOVERY_SILENCE_LIMIT: 15e3, + // 15 seconds + // Liveness is established by an explicit probe/ack, not by silence alone. + // Silence on its own is not proof of death: a browser throttles timers in a + // backgrounded tab (Chrome down to roughly one per minute, iOS Safari + // freezes them outright), so a perfectly healthy peer can stop sending for + // a long time. Inbound message handling is NOT throttled that way, so a + // live peer — even a backgrounded one — answers a probe within milliseconds + // while a peer whose network is gone cannot answer at all. + LIVENESS_PROBE_AFTER: 12e3, + // silence before probing the peer + LIVENESS_PROBE_TIMEOUT: 5e3, + // how long the ack may take + LIVENESS_CHECK_INTERVAL: 2e3 // 2 seconds }; + // Backoff between automatic ICE-restart attempts (ms). Deliberately short at + // the head: most real drops recover on the first or second try. + static RECONNECT_BACKOFF = Object.freeze([1e3, 2e3, 4e3, 8e3, 15e3, 3e4]); static LIMITS = { MAX_CONNECTION_ATTEMPTS: 3, + // Consecutive ICE failures that produced zero candidate pairs before + // concluding the PeerConnection itself is unusable, rather than the path + // merely being flaky. + MAX_BARREN_ICE_FAILURES: 2, MAX_OLD_KEYS: 3, MAX_PROCESSED_MESSAGE_IDS: 1e3, MAX_OUT_OF_ORDER_PACKETS: 5, @@ -6797,6 +6859,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { CALL_ICE: "call_ice", CALL_DECLINE: "call_decline", CALL_END: "call_end", + // Session recovery. An ICE restart renegotiates ONLY the transport path + // (new candidates after a NAT rebind / IP change); the DTLS handshake and + // the SCTP association that carries this data channel survive it, so the + // session keys, the SAS verification and the message history all stay + // valid. The renegotiation SDP therefore rides the existing E2E channel — + // still no signalling server, and an attacker cannot inject a restart + // without already holding the session keys. + ICE_RESTART_OFFER: "ice_restart_offer", + ICE_RESTART_ANSWER: "ice_restart_answer", + // Sent by the answerer side, which must not create offers itself (glare): + // it asks the offerer to drive the restart. + ICE_RESTART_REQUEST: "ice_restart_request", // Fake traffic FAKE: "fake" }; @@ -6910,6 +6984,25 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._secureLog("info", "\u{1F512} Enhanced Mutex system fully initialized and validated"); this.heartbeatInterval = null; this.messageQueue = []; + this._reconnect = { + phase: "idle", + // idle | grace | restarting | waiting | exhausted + attempts: 0, + startedAt: 0, + graceTimer: null, + retryTimer: null, + restartTimer: null, + inFlightAt: 0, + // when the current restart round-trip was launched + barrenFailures: 0, + // consecutive failures that produced no candidate pairs + pendingRole: null + // 'offerer' | 'answerer' during a restart round-trip + }; + this._lastInboundAt = 0; + this._livenessProbeAt = 0; + this._livenessTimer = null; + this._heartbeatTimer = null; this.ecdhKeyPair = null; this.ecdsaKeyPair = null; if (this.fileTransferSystem) { @@ -7715,9 +7808,6 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { if (this._debugMode) { this._monitorGlobalExposure(); } - if (this._heartbeatConfig && this._heartbeatConfig.enabled && this.isConnected()) { - this._sendHeartbeat(); - } this._secureLog("info", "\u{1F527} Maintenance cycle completed successfully"); } catch (error) { this._secureLog("error", "\u274C Maintenance cycle failed", { @@ -7884,21 +7974,29 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { /** * Send heartbeat message (called by unified scheduler) */ - _sendHeartbeat() { + /** + * @param {boolean} ack - true when replying to a peer's probe. An ack is never + * itself acked, otherwise the two sides would ping-pong forever. + */ + _sendHeartbeat(ack = false) { try { - if (this.isConnected() && this.dataChannel && this.dataChannel.readyState === "open") { + if (this.dataChannel && this.dataChannel.readyState === "open") { this.dataChannel.send(JSON.stringify({ type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.HEARTBEAT, + ack, timestamp: Date.now() })); this._heartbeatConfig.lastHeartbeat = Date.now(); - this._secureLog("debug", "\u{1F493} Heartbeat sent"); + this._secureLog("debug", ack ? "\u{1F493} Heartbeat ack sent" : "\u{1F493} Heartbeat sent"); + return true; } + return false; } catch (error) { this._secureLog("error", "\u274C Heartbeat failed:", { errorType: error?.constructor?.name || "Unknown", message: error?.message || "Unknown error" }); + return false; } } /** @@ -11806,7 +11904,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return "FAKE_MESSAGE_FILTERED"; } - if (jsonData.type && ["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "security_upgrade"].includes(jsonData.type)) { + if (jsonData.type && ["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "security_upgrade", "ice_restart_offer", "ice_restart_answer", "ice_restart_request"].includes(jsonData.type)) { return "SYSTEM_MESSAGE_FILTERED"; } if (jsonData.type && ["file_transfer_start", "file_transfer_response", "file_chunk", "chunk_confirmation", "file_transfer_complete", "file_transfer_error"].includes(jsonData.type)) { @@ -11875,7 +11973,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return data; } - if (!jsonData.type || jsonData.type !== "fake" && !["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "enhanced_message", "security_upgrade", "file_transfer_start", "file_transfer_response", "file_chunk", "chunk_confirmation", "file_transfer_complete", "file_transfer_error"].includes(jsonData.type)) { + if (!jsonData.type || jsonData.type !== "fake" && !["heartbeat", "verification", "verification_response", "peer_disconnect", "key_rotation_signal", "key_rotation_ready", "enhanced_message", "security_upgrade", "ice_restart_offer", "ice_restart_answer", "ice_restart_request", "file_transfer_start", "file_transfer_response", "file_chunk", "chunk_confirmation", "file_transfer_complete", "file_transfer_error"].includes(jsonData.type)) { if (this._debugMode) { this._secureLog("debug", "\u{1F4DD} Regular message detected, returning for display"); } @@ -12215,6 +12313,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { // FIX 1: Simplified mutex system for message processing async processMessage(data) { try { + this._noteInboundActivity?.(); this._secureLog("debug", "\uFFFD\uFFFD Processing message", { dataType: typeof data, isArrayBuffer: data instanceof ArrayBuffer, @@ -12329,6 +12428,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return; } + if (parsed.type && [ + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST + ].includes(parsed.type)) { + try { + await this._handleIceRestartSignal(parsed.type, parsed.data || {}); + } catch (e) { + this._secureLog("error", "\u274C ICE restart signal handling failed", { errorType: e?.constructor?.name }); + } + return; + } if (parsed.type && ["heartbeat", "verification", "verification_response", "verification_confirmed", "verification_both_confirmed", "peer_disconnect", "security_upgrade"].includes(parsed.type)) { this.handleSystemMessage(parsed); return; @@ -12410,7 +12521,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { "peer_disconnect", "key_rotation_signal", "key_rotation_ready", - "security_upgrade" + "security_upgrade", + "ice_restart_offer", + "ice_restart_answer", + "ice_restart_request" ]; if (finalCheck.type && blockedTypes.includes(finalCheck.type)) { this._secureLog("warn", `\u{1F4C1} Final system/file message check blocked: ${finalCheck.type}`); @@ -12486,7 +12600,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._secureLog("debug", "\u{1F527} Handling system message:", { type: message.type }); switch (message.type) { case "heartbeat": - this.handleHeartbeat(); + this.handleHeartbeat(message); break; case "verification": this.handleVerificationRequest(message.data); @@ -12983,28 +13097,32 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { if (state === "connected" && !this.isVerified) { this._notifyVerificationReadyIfPossible(); } else if (state === "connected" && this.isVerified) { - this.onStatusChange("connected"); - } else if (state === "disconnected" || state === "closed") { + if (!this._onPathRecovered()) this.onStatusChange("connected"); + } else if (state === "disconnected") { if (this.intentionalDisconnect) { this.onStatusChange("disconnected"); setTimeout(() => this.disconnect(), 100); + } else if (this.isVerified) { + this._onPathDegraded("ice_disconnected"); } else { - if (this.isVerified || state === "closed") { - this.onStatusChange("disconnected"); - this._clearVerificationStates(); - } else { - console.warn(`[SecureBit ICE] State is ${state} but not verified yet. Keeping session open for manual exchange.`); - } + console.warn(`[SecureBit ICE] State is ${state} but not verified yet. Keeping session open for manual exchange.`); } + } else if (state === "closed") { + this._resetReconnectState(); + this.onStatusChange("disconnected"); + this._clearVerificationStates(); + if (this.intentionalDisconnect) setTimeout(() => this.disconnect(), 100); } else if (state === "failed") { this._collectIceFailureDiagnostics().then((diagnostics) => { console.warn("[SecureBit ICE] failure diagnostics", diagnostics); + this._noteIceFailureDiagnostics(diagnostics); }); if (this.isVerified) { - this.onStatusChange("disconnected"); + this._onPathLost("ice_failed"); } else { console.warn("[SecureBit ICE] State is failed but not verified yet. Keeping session open for manual exchange."); } + } else if (this.isReconnecting() && (state === "connecting" || state === "new")) { } else { this.onStatusChange(state); } @@ -13043,7 +13161,10 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } setupDataChannel(channel) { this.dataChannel = channel; - this.dataChannel.onopen = async () => { + let openHandled = false; + const handleChannelOpen = async () => { + if (openHandled) return; + openHandled = true; try { if (this.dataChannel && typeof this.dataChannel.bufferedAmountLowThreshold === "number") { this.dataChannel.bufferedAmountLowThreshold = 1024 * 1024; @@ -13087,7 +13208,17 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } this.startHeartbeat(); }; + this.dataChannel.onopen = handleChannelOpen; + if (this.dataChannel.readyState === "open") { + Promise.resolve().then(() => handleChannelOpen()).catch((error) => { + this._secureLog("error", "Deferred data channel open handling failed", { + errorType: error?.constructor?.name || "Unknown" + }); + }); + } this.dataChannel.onclose = () => { + this._resetReconnectState?.(); + this._teardownRecoveryLifecycleListeners?.(); if (!this.intentionalDisconnect) { this.onStatusChange("disconnected"); this._clearVerificationStates(); @@ -13109,6 +13240,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { }; this.dataChannel.onmessage = async (event) => { try { + this._noteInboundActivity?.(); if (typeof event.data === "string") { try { const parsed = JSON.parse(event.data); @@ -13192,6 +13324,18 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { } return; } + if (parsed.type && [ + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_ANSWER, + _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST + ].includes(parsed.type)) { + try { + await this._handleIceRestartSignal(parsed.type, parsed.data || {}); + } catch (e) { + this._secureLog("error", "\u274C ICE restart signal handling failed", { errorType: e?.constructor?.name }); + } + return; + } if (parsed.type && ["heartbeat", "verification", "verification_response", "verification_confirmed", "verification_both_confirmed", "sas_code", "peer_disconnect", "security_upgrade"].includes(parsed.type)) { this.handleSystemMessage(parsed); return; @@ -16256,18 +16400,520 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.sendSecureMessage(message).catch(console.error); } } + // Heartbeat runs on its own HEARTBEAT_INTERVAL timer. It used to be folded + // into the unified maintenance cycle, which ticks every 5 minutes — far too + // coarse to notice a dead path, and long enough that a drop looked like + // silence. The maintenance cycle no longer sends heartbeats. startHeartbeat() { - this._secureLog("info", "Heartbeat moved to unified scheduler"); this._heartbeatConfig = { enabled: true, interval: _EnhancedSecureWebRTCManager.TIMEOUTS.HEARTBEAT_INTERVAL, lastHeartbeat: 0 }; + this.stopHeartbeat( + /* keepConfig */ + true + ); + this._heartbeatTimer = setInterval(() => { + if (!this._heartbeatConfig?.enabled) return; + if (this.dataChannel?.readyState === "open") { + this._sendHeartbeat(); + } + }, _EnhancedSecureWebRTCManager.TIMEOUTS.HEARTBEAT_INTERVAL); + this._trackActiveTimer(this._heartbeatTimer); + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + this._livenessArmed = false; + this._startLivenessWatchdog(); + this._setupRecoveryLifecycleListeners(); + this._secureLog("info", "\u{1F504} Liveness watchdog started", { + heartbeatMs: _EnhancedSecureWebRTCManager.TIMEOUTS.HEARTBEAT_INTERVAL, + probeAfterMs: _EnhancedSecureWebRTCManager.TIMEOUTS.LIVENESS_PROBE_AFTER, + probeTimeoutMs: _EnhancedSecureWebRTCManager.TIMEOUTS.LIVENESS_PROBE_TIMEOUT + }); } - stopHeartbeat() { - if (this._heartbeatConfig) { + stopHeartbeat(keepConfig = false) { + if (!keepConfig && this._heartbeatConfig) { this._heartbeatConfig.enabled = false; } + if (this._heartbeatTimer) { + clearInterval(this._heartbeatTimer); + this._activeTimers?.delete(this._heartbeatTimer); + this._heartbeatTimer = null; + } + if (!keepConfig) this._stopLivenessWatchdog(); + } + /** + * Inbound heartbeat from the peer. This method used to be missing entirely + * while handleSystemMessage still dispatched to it, so every heartbeat threw + * a TypeError and liveness was never actually observed. + * + * A non-ack heartbeat is a probe and must be answered immediately: that reply + * is what proves this side is alive even when its tab is backgrounded and its + * own timers have been throttled to a standstill. + */ + handleHeartbeat(message) { + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + const isAck = message?.ack === true || message?.data?.ack === true; + if (!isAck) this._sendHeartbeat(true); + this._secureLog("debug", isAck ? "\u{1F493} Heartbeat ack received" : "\u{1F493} Heartbeat probe received"); + } + /** + * Any authenticated inbound frame proves the path is alive, not just + * heartbeats — a busy conversation must never trip the watchdog. + */ + _noteInboundActivity() { + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + this._livenessArmed = true; + } + _startLivenessWatchdog() { + this._stopLivenessWatchdog(); + this._livenessTimer = setInterval(() => { + try { + this._checkLiveness(); + } catch (error) { + this._secureLog("error", "\u274C Liveness check failed", { + errorType: error?.constructor?.name || "Unknown" + }); + } + }, _EnhancedSecureWebRTCManager.TIMEOUTS.LIVENESS_CHECK_INTERVAL); + this._trackActiveTimer(this._livenessTimer); + } + _stopLivenessWatchdog() { + if (this._livenessTimer) { + clearInterval(this._livenessTimer); + this._activeTimers?.delete(this._livenessTimer); + this._livenessTimer = null; + } + } + /** + * A data channel keeps reporting readyState === 'open' long after the + * underlying path has died (the classic Wi-Fi → LTE switch: nothing closes, + * nothing errors, packets simply stop). Nothing tells us — so we ask. + * + * Two steps, because silence alone is not evidence of death. A backgrounded + * tab has its timers throttled to roughly one tick per minute (frozen + * outright on iOS), so a healthy peer routinely goes quiet. What a healthy + * peer cannot do is fail to ANSWER: inbound message handling is not throttled + * the way timers are. So after a period of silence we send a probe, and only + * an unanswered probe is treated as a dead path. + */ + _checkLiveness() { + if (!this.isVerified) return; + if (this._reconnect.phase !== "idle") return; + if (this.dataChannel?.readyState !== "open") return; + if (!this._lastInboundAt) return; + if (!this._livenessArmed) return; + const T = _EnhancedSecureWebRTCManager.TIMEOUTS; + const now = Date.now(); + const iceHealthy = this.peerConnection?.connectionState === "connected"; + if (iceHealthy) { + this._livenessProbeAt = 0; + return; + } + if (this._livenessProbeAt) { + if (now - this._livenessProbeAt < T.LIVENESS_PROBE_TIMEOUT) return; + this._livenessProbeAt = 0; + this._secureLog("warn", "\u26A0\uFE0F liveness probe unanswered and ICE is not connected \u2014 path presumed dead"); + this._onPathLost("liveness_probe_timeout"); + return; + } + if (now - this._lastInboundAt < T.LIVENESS_PROBE_AFTER) return; + this._livenessProbeAt = now; + const delivered = this._sendHeartbeat(false); + this._secureLog("info", "\u{1F504} peer silent and ICE degraded, probing", { + silentForMs: now - this._lastInboundAt, + connectionState: this.peerConnection?.connectionState, + probeSent: delivered + }); + } + // ============================================ + // SESSION RECOVERY (serverless, in-band) + // ============================================ + // + // What survives an ICE restart and what does not: + // + // ICE restart replaces the candidate pair — i.e. the network path. The + // DTLS handshake, the negotiated keys and the SCTP association that the + // data channel rides on are all layered ABOVE ICE and survive untouched. + // That is why a restart can recover a Wi-Fi → LTE switch without a new + // handshake, without a new SAS, and without losing message history. + // + // The restart SDP travels over that same still-established data channel, + // so it inherits the channel's authentication: an attacker who cannot + // already decrypt the session cannot inject one. No signalling server is + // involved at any point. + // + // The one thing a restart must never do is change peer identity, so the + // DTLS fingerprint in the incoming SDP is checked against the fingerprint + // of the live session before anything is applied. A mismatch is treated + // as an attack and aborts recovery rather than re-keying to a stranger. + // + // What it cannot recover: a closed data channel (SCTP gone) or a path so + // dead that the restart offer itself cannot be delivered. Those fall + // through to _giveUpAutoReconnect and require a fresh, manually exchanged + // handshake — the existing offer/answer flow. + isReconnecting() { + return this._reconnect.phase !== "idle" && this._reconnect.phase !== "exhausted"; + } + /** + * Device-level signals that a path is worth re-checking right now, instead of + * waiting out a backoff: this device regained network, or a mobile browser + * brought the tab back to the foreground (where it may have frozen the + * connection while backgrounded). + */ + _setupRecoveryLifecycleListeners() { + if (typeof window === "undefined" || this._recoveryLifecycleBound) return; + this._recoveryLifecycleBound = true; + this._onDeviceOnline = () => { + if (!this.isVerified) return; + if (this.isReconnecting()) { + this._secureLog("info", "\u{1F504} Device back online \u2014 retrying immediately"); + this._attemptIceRestart(); + } else { + this._checkLiveness(); + } + }; + this._onVisibilityRestored = () => { + if (typeof document === "undefined" || document.visibilityState !== "visible") return; + if (!this.isVerified) return; + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + if (this._reconnect.phase === "idle" && this.dataChannel?.readyState === "open") { + this._livenessProbeAt = Date.now(); + this._sendHeartbeat(false); + this._secureLog("info", "\u{1F504} returned to foreground, probing peer"); + } + }; + window.addEventListener("online", this._onDeviceOnline); + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", this._onVisibilityRestored); + } + } + _teardownRecoveryLifecycleListeners() { + if (!this._recoveryLifecycleBound || typeof window === "undefined") return; + this._recoveryLifecycleBound = false; + if (this._onDeviceOnline) window.removeEventListener("online", this._onDeviceOnline); + if (this._onVisibilityRestored && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this._onVisibilityRestored); + } + this._onDeviceOnline = null; + this._onVisibilityRestored = null; + } + _resetReconnectState() { + const r = this._reconnect; + if (!r) return; + if (r.graceTimer) { + clearTimeout(r.graceTimer); + this._activeTimers?.delete(r.graceTimer); + } + if (r.retryTimer) { + clearTimeout(r.retryTimer); + this._activeTimers?.delete(r.retryTimer); + } + if (r.restartTimer) { + clearTimeout(r.restartTimer); + this._activeTimers?.delete(r.restartTimer); + } + r.graceTimer = null; + r.retryTimer = null; + r.restartTimer = null; + r.phase = "idle"; + r.attempts = 0; + r.startedAt = 0; + r.inFlightAt = 0; + r.barrenFailures = 0; + r.pendingRole = null; + } + /** + * ICE reported 'disconnected'. This is usually transient — the browser's own + * consent freshness checks recover it within a couple of seconds — so hold a + * grace window before spending a restart, but tell the UI right away so the + * user sees "reconnecting" rather than a silently stalled chat. + */ + _onPathDegraded(reason = "ice_disconnected") { + if (!this.isVerified) return; + if (this._reconnect.phase !== "idle") return; + this._reconnect.phase = "grace"; + this._reconnect.startedAt = Date.now(); + this._secureLog("info", "\u{1F504} path degraded, holding grace window", { reason }); + this.onStatusChange("reconnecting"); + this._reconnect.graceTimer = setTimeout(() => { + this._reconnect.graceTimer = null; + if (this.peerConnection?.connectionState === "connected") { + this._onPathRecovered(); + return; + } + this._attemptIceRestart(); + }, _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_DISCONNECT_GRACE); + this._trackActiveTimer(this._reconnect.graceTimer); + } + /** ICE failed outright, or the peer went silent — restart without waiting. */ + _onPathLost(reason = "ice_failed") { + if (!this.isVerified) return; + if (this._reconnect.phase === "restarting" || this._reconnect.phase === "exhausted") return; + if (this._reconnect.phase === "idle") { + this._reconnect.startedAt = Date.now(); + this.onStatusChange("reconnecting"); + } + if (this._reconnect.graceTimer) { + clearTimeout(this._reconnect.graceTimer); + this._activeTimers?.delete(this._reconnect.graceTimer); + this._reconnect.graceTimer = null; + } + this._secureLog("info", "\u{1F504} path lost, restarting ICE", { reason }); + this._attemptIceRestart(); + } + /** + * A restart is only worth trying while the ICE agent can still produce + * candidates. After the device changes network, a PeerConnection is often + * left bound to interfaces that no longer exist: every STUN binding and TURN + * allocation times out, gathering yields nothing, and each restart fails with + * zero candidate pairs. restartIce() does not rebind it — only a brand-new + * PeerConnection will, and building one needs a whole new handshake. + * + * Recognising that early matters: retrying it for the full two-minute + * deadline is two minutes of the user watching nothing happen, when the way + * out was available immediately. + */ + _noteIceFailureDiagnostics(diagnostics) { + if (!this.isReconnecting()) return; + if (!diagnostics) return; + if (diagnostics.pairCount > 0) { + this._reconnect.barrenFailures = 0; + return; + } + this._reconnect.barrenFailures = (this._reconnect.barrenFailures || 0) + 1; + if (this._reconnect.barrenFailures < _EnhancedSecureWebRTCManager.LIMITS.MAX_BARREN_ICE_FAILURES) return; + this._secureLog("warn", "\u26A0\uFE0F ICE cannot gather any usable candidate \u2014 this connection is bound to a network that is gone", { + consecutiveBarrenFailures: this._reconnect.barrenFailures + }); + this._giveUpAutoReconnect("ice_agent_unusable"); + } + /** + * Path is back. Same keys, same verification, same history — carry on. + * Returns true if it actually handled a recovery (and therefore already + * emitted 'connected'), so the caller does not emit it twice. + */ + _onPathRecovered() { + const wasRecovering = this.isReconnecting(); + this._resetReconnectState(); + this._lastInboundAt = Date.now(); + this._livenessProbeAt = 0; + if (!wasRecovering) return false; + this._secureLog("info", "\u{1F504} connection recovered, session preserved"); + this.onStatusChange("connected"); + this.processMessageQueue(); + try { + document.dispatchEvent(new CustomEvent("connection-recovered", { + detail: { timestamp: Date.now() } + })); + } catch (_) { + } + return true; + } + /** + * Only the side that created the original offer drives restarts. Both sides + * offering at once produces glare, and with no signalling server there is no + * referee to break the tie — so the answerer asks instead of acting. + */ + async _attemptIceRestart() { + if (!this.isVerified || !this.peerConnection) return; + if (this.peerConnection.connectionState === "connected") { + this._onPathRecovered(); + return; + } + const r = this._reconnect; + if (typeof navigator !== "undefined" && navigator.onLine === false) { + r.phase = "waiting"; + r.startedAt = Date.now(); + this._secureLog("debug", "\u{1F504} Device offline \u2014 holding recovery open"); + this._scheduleReconnectRetry(); + return; + } + const elapsed = Date.now() - (r.startedAt || Date.now()); + if (elapsed > _EnhancedSecureWebRTCManager.TIMEOUTS.RECONNECT_MAX_DURATION) { + this._giveUpAutoReconnect("timeout"); + return; + } + if (r.inFlightAt && Date.now() - r.inFlightAt < _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_TIMEOUT) { + this._scheduleReconnectRetry(); + return; + } + if (this.dataChannel?.readyState !== "open") { + this._giveUpAutoReconnect("data_channel_closed"); + return; + } + const silentFor = Date.now() - Math.max(this._lastInboundAt || 0, r.startedAt); + if (r.attempts >= 2 && silentFor > _EnhancedSecureWebRTCManager.TIMEOUTS.RECOVERY_SILENCE_LIMIT) { + this._secureLog("warn", "\u26A0\uFE0F nothing has reached us since the drop \u2014 the channel cannot carry a renegotiation", { + silentForMs: silentFor, + attempts: r.attempts + }); + this._giveUpAutoReconnect("no_signalling_path"); + return; + } + r.phase = "restarting"; + r.attempts += 1; + r.inFlightAt = Date.now(); + this._secureLog("info", "\u{1F504} ICE restart attempt", { + attempt: r.attempts, + role: this.isInitiator ? "offerer" : "answerer" + }); + try { + if (this.isInitiator) { + await this._sendIceRestartOffer(); + } else { + await this.sendSystemMessage({ + type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_REQUEST, + timestamp: Date.now() + }); + } + } catch (error) { + this._secureLog("warn", "\u26A0\uFE0F ICE restart attempt failed to send", { + errorType: error?.constructor?.name || "Unknown" + }); + } + this._scheduleReconnectRetry(); + } + _scheduleReconnectRetry() { + const r = this._reconnect; + if (r.retryTimer) { + clearTimeout(r.retryTimer); + this._activeTimers?.delete(r.retryTimer); + } + const backoff = _EnhancedSecureWebRTCManager.RECONNECT_BACKOFF; + const delay = backoff[Math.min(Math.max(r.attempts - 1, 0), backoff.length - 1)]; + r.retryTimer = setTimeout(() => { + r.retryTimer = null; + if (this.peerConnection?.connectionState === "connected") { + this._onPathRecovered(); + return; + } + this._attemptIceRestart(); + }, delay); + this._trackActiveTimer(r.retryTimer); + } + async _sendIceRestartOffer() { + const pc = this.peerConnection; + if (!pc) return; + if (pc.signalingState === "have-local-offer") { + try { + await pc.setLocalDescription({ type: "rollback" }); + } catch (_) { + } + } + const offer = await pc.createOffer({ iceRestart: true }); + await pc.setLocalDescription(offer); + await this.waitForIceGathering(_EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING); + await this.sendSystemMessage({ + type: _EnhancedSecureWebRTCManager.MESSAGE_TYPES.ICE_RESTART_OFFER, + sdp: pc.localDescription.sdp, + timestamp: Date.now() + }); + this._secureLog("debug", "\u{1F504} ICE restart offer sent"); + } + /** + * The fingerprint of the live, already-SAS-verified session. Recovery must + * re-point the path at the SAME peer, never re-key to a new one. + */ + _currentRemoteDtlsFingerprint() { + const sdp = this.peerConnection?.currentRemoteDescription?.sdp || this.peerConnection?.remoteDescription?.sdp; + if (!sdp) return null; + try { + return this._extractDTLSFingerprintFromSDP(sdp); + } catch (_) { + return null; + } + } + async _assertSameRemoteIdentity(sdp, context) { + const expected = this._currentRemoteDtlsFingerprint(); + if (!expected) { + throw new Error(`Cannot verify peer identity for ${context}`); + } + const received = this._extractDTLSFingerprintFromSDP(sdp); + await this._validateDTLSFingerprint(received, expected, context); + } + /** Inbound recovery signalling, routed from processMessage. */ + async _handleIceRestartSignal(type, data) { + const T = _EnhancedSecureWebRTCManager.MESSAGE_TYPES; + const pc = this.peerConnection; + if (!pc) return; + this._noteInboundActivity(); + switch (type) { + case T.ICE_RESTART_REQUEST: { + if (!this.isInitiator) return; + if (this._reconnect.phase === "idle") { + this._reconnect.startedAt = Date.now(); + this._reconnect.phase = "restarting"; + this.onStatusChange("reconnecting"); + } + await this._sendIceRestartOffer(); + return; + } + case T.ICE_RESTART_OFFER: { + if (!data.sdp) return; + await this._assertSameRemoteIdentity(data.sdp, "ice_restart_offer"); + if (this._reconnect.phase === "idle") { + this._reconnect.startedAt = Date.now(); + this.onStatusChange("reconnecting"); + } + this._reconnect.phase = "restarting"; + await pc.setRemoteDescription({ type: "offer", sdp: data.sdp }); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + await this.waitForIceGathering(_EnhancedSecureWebRTCManager.TIMEOUTS.ICE_RESTART_GATHERING); + await this.sendSystemMessage({ + type: T.ICE_RESTART_ANSWER, + sdp: pc.localDescription.sdp, + timestamp: Date.now() + }); + this._secureLog("debug", "\u{1F504} ICE restart answer sent"); + return; + } + case T.ICE_RESTART_ANSWER: { + if (!data.sdp) return; + if (pc.signalingState !== "have-local-offer") { + this._secureLog("warn", "\u26A0\uFE0F Ignoring restart answer in unexpected state", { + signalingState: pc.signalingState + }); + return; + } + await this._assertSameRemoteIdentity(data.sdp, "ice_restart_answer"); + await pc.setRemoteDescription({ type: "answer", sdp: data.sdp }); + this._reconnect.inFlightAt = 0; + this._secureLog("debug", "\u{1F504} ICE restart answer applied"); + return; + } + default: + } + } + /** + * Automatic recovery is out of road, and there is no fallback: with no + * signalling server, a path that cannot carry a renegotiation cannot be + * rebuilt without a fresh, manually exchanged handshake. + * + * So the session ends here rather than lingering half-alive. Everything goes + * with it — keys, queued messages, transcript — which is also the safer + * default: a conversation whose transport is gone should not leave its + * plaintext sitting in a tab the user has stopped watching. + */ + _giveUpAutoReconnect(reason) { + this._resetReconnectState(); + this._reconnect.phase = "exhausted"; + this._teardownRecoveryLifecycleListeners?.(); + this._secureLog("warn", "\u26A0\uFE0F automatic reconnection exhausted \u2014 ending session", { reason }); + if (!this.reconnectionFailedNotificationSent) { + this.reconnectionFailedNotificationSent = true; + this.deliverMessageToUI( + "Could not restore the connection. This chat is being closed and its data wiped \u2014 start a new one to continue.", + "system" + ); + } + this.onStatusChange("recovery_failed"); + this._clearVerificationStates(); } /** * Stop all active timers and cleanup scheduler @@ -16278,9 +16924,8 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { clearInterval(this._maintenanceScheduler); this._maintenanceScheduler = null; } - if (this._heartbeatConfig) { - this._heartbeatConfig.enabled = false; - } + this.stopHeartbeat?.(); + this._resetReconnectState?.(); if (this._activeTimers) { this._activeTimers.forEach((timer) => { if (timer) { @@ -16296,7 +16941,12 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this._logCleanupInterval = null; this._secureLog("info", "All timers stopped successfully"); } - waitForIceGathering() { + /** + * @param {number} [timeoutMs] - gathering budget. Recovery uses a much shorter + * one than the initial handshake: a restart round-trip must finish well + * inside the retry backoff, or the next attempt cancels the one in flight. + */ + waitForIceGathering(timeoutMs = _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT) { return new Promise((resolve) => { if (this.peerConnection.iceGatheringState === "complete") { resolve(true); @@ -16314,7 +16964,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.peerConnection.removeEventListener("icegatheringstatechange", checkState); } resolve(this.peerConnection?.iceGatheringState === "complete"); - }, _EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT); + }, timeoutMs); }); } retryConnection() { @@ -16391,11 +17041,25 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { }); } } + /** + * Manual "try again" from the UI. Restarts the automatic recovery cycle from + * scratch (fresh attempt counter and deadline) as long as the data channel is + * still there to carry the renegotiation. + */ attemptReconnection() { - if (!this.reconnectionFailedNotificationSent) { - this.reconnectionFailedNotificationSent = true; - this.deliverMessageToUI("Unable to reconnect. A new connection is required.", "system"); + if (!this.isVerified || this.dataChannel?.readyState !== "open") { + if (!this.reconnectionFailedNotificationSent) { + this.reconnectionFailedNotificationSent = true; + this.deliverMessageToUI("Unable to reconnect. A new connection is required.", "system"); + } + return false; } + this._resetReconnectState(); + this.reconnectionFailedNotificationSent = false; + this._reconnect.startedAt = Date.now(); + this.onStatusChange("reconnecting"); + this._attemptIceRestart(); + return true; } handlePeerDisconnectNotification(data) { const reason = data.reason || "unknown"; @@ -16448,6 +17112,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { this.intentionalDisconnect = true; window.EnhancedSecureCryptoUtils.secureLog.log("info", "Starting intentional disconnect"); this.sendDisconnectNotification(); + this._teardownRecoveryLifecycleListeners?.(); this._stopAllTimers(); this._peerDisconnectCleanupTimer = null; this.stopHeartbeat(); @@ -17024,7 +17689,7 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager { _callCanStart() { const connected = typeof this.isConnected === "function" ? this.isConnected() : false; const channelOpen = this.dataChannel && this.dataChannel.readyState === "open"; - const ok = !!(connected && channelOpen && this.isVerified); + const ok = !!(connected && channelOpen && this.isVerified && !this.isReconnecting()); return ok; } async _sendCallSignal(type, data) { @@ -18910,7 +19575,7 @@ Right-click or Ctrl+click to disconnect`, React.createElement("div", { key: "txt", style: { lineHeight: 1.2, minWidth: 0 } }, [ React.createElement("div", { key: "r1", style: { display: "flex", alignItems: "baseline", gap: "7px" } }, [ React.createElement("span", { key: "n", style: { fontSize: "16px", fontWeight: 800, letterSpacing: "-0.3px", color: "#e8e8eb" } }, "SecureBit"), - React.createElement("span", { key: "v", style: { fontFamily: MONO, fontSize: "10px", fontWeight: 500, color: "#56565e" } }, "v5.5.4") + React.createElement("span", { key: "v", style: { fontFamily: MONO, fontSize: "10px", fontWeight: 500, color: "#56565e" } }, "v5.6.0") ]), React.createElement("div", { key: "r2", className: "hidden sm:block", style: { fontSize: "11px", color: "#6b6b73", fontWeight: 500 } }, "End-to-end encrypted") ]) diff --git a/dist/app-boot.js.map b/dist/app-boot.js.map index d44b86f..75d67ba 100644 --- a/dist/app-boot.js.map +++ b/dist/app-boot.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../src/notifications/SecureNotificationManager.js", "../src/notifications/NotificationIntegration.js", "../node_modules/dompurify/src/utils.ts", "../node_modules/dompurify/src/tags.ts", "../node_modules/dompurify/src/attrs.ts", "../node_modules/dompurify/src/regexp.ts", "../node_modules/dompurify/src/purify.ts", "../src/crypto/EnhancedSecureCryptoUtils.js", "../src/transfer/EnhancedSecureFileTransfer.js", "../src/network/webrtc/config.js", "../src/network/webrtc/sdp.js", "../src/network/webrtc/audio.js", "../src/network/webrtc/video.js", "../src/network/webrtc/adaptation/metrics.js", "../src/network/webrtc/adaptation/controller.js", "../src/network/EnhancedSecureWebRTCManager.js", "../src/scripts/app-boot.js", "../src/components/ui/Header.jsx", "../src/components/ui/DownloadApps.jsx", "../src/components/ui/BecomePartner.jsx", "../src/components/ui/UniqueFeatureSlider.jsx", "../src/components/ui/Roadmap.jsx", "../src/components/ui/CommunityCTA.jsx", "../src/components/ui/FileTransfer.jsx", "../src/network/iceServers.js", "../src/components/ui/IceServerSettings.jsx", "../src/components/ui/CallUI.jsx"], - "sourcesContent": ["/**\n * Secure and Reliable Notification Manager for P2P WebRTC Chat\n * Follows best practices: OWASP, MDN, Chrome DevRel\n * \n * @version 1.0.0\n * @author SecureBit Team\n * @license MIT\n */\n\nclass SecureChatNotificationManager {\n constructor(config = {}) {\n // Safely read Notification permission (iOS Safari may not define Notification)\n this.permission = (typeof Notification !== 'undefined' && Notification && typeof Notification.permission === 'string')\n ? Notification.permission\n : 'denied';\n this.isTabActive = this.checkTabActive(); // Initialize with proper check\n this.unreadCount = 0;\n this.originalTitle = document.title;\n this.notificationQueue = [];\n this.maxQueueSize = config.maxQueueSize || 5;\n this.rateLimitMs = config.rateLimitMs || 2000; // Spam protection\n this.lastNotificationTime = 0;\n this.trustedOrigins = config.trustedOrigins || [];\n \n // Secure context flag\n this.isSecureContext = window.isSecureContext;\n \n // Cross-browser compatibility for Page Visibility API\n this.hidden = this.getHiddenProperty();\n this.visibilityChange = this.getVisibilityChangeEvent();\n \n this.initVisibilityTracking();\n this.initSecurityChecks();\n }\n\n /**\n * Initialize security checks and validation\n * @private\n */\n initSecurityChecks() {\n // Security checks are performed silently\n }\n\n /**\n * Get hidden property name for cross-browser compatibility\n * @returns {string} Hidden property name\n * @private\n */\n getHiddenProperty() {\n if (typeof document.hidden !== \"undefined\") {\n return \"hidden\";\n } else if (typeof document.msHidden !== \"undefined\") {\n return \"msHidden\";\n } else if (typeof document.webkitHidden !== \"undefined\") {\n return \"webkitHidden\";\n }\n return \"hidden\"; // fallback\n }\n\n /**\n * Get visibility change event name for cross-browser compatibility\n * @returns {string} Visibility change event name\n * @private\n */\n getVisibilityChangeEvent() {\n if (typeof document.hidden !== \"undefined\") {\n return \"visibilitychange\";\n } else if (typeof document.msHidden !== \"undefined\") {\n return \"msvisibilitychange\";\n } else if (typeof document.webkitHidden !== \"undefined\") {\n return \"webkitvisibilitychange\";\n }\n return \"visibilitychange\"; // fallback\n }\n\n /**\n * Check if tab is currently active using multiple methods\n * @returns {boolean} True if tab is active\n * @private\n */\n checkTabActive() {\n // Primary method: Page Visibility API\n if (this.hidden && typeof document[this.hidden] !== \"undefined\") {\n return !document[this.hidden];\n }\n \n // Fallback method: document.hasFocus()\n if (typeof document.hasFocus === \"function\") {\n return document.hasFocus();\n }\n \n // Ultimate fallback: assume active\n return true;\n }\n\n /**\n * Initialize page visibility tracking (Page Visibility API)\n * @private\n */\n initVisibilityTracking() {\n // Primary method: Page Visibility API with cross-browser support\n if (typeof document.addEventListener !== \"undefined\" && typeof document[this.hidden] !== \"undefined\") {\n document.addEventListener(this.visibilityChange, () => {\n this.isTabActive = this.checkTabActive();\n \n if (this.isTabActive) {\n this.resetUnreadCount();\n this.clearNotificationQueue();\n }\n });\n }\n\n // Fallback method: Window focus/blur events\n window.addEventListener('focus', () => {\n this.isTabActive = this.checkTabActive();\n if (this.isTabActive) {\n this.resetUnreadCount();\n }\n });\n\n window.addEventListener('blur', () => {\n this.isTabActive = this.checkTabActive();\n });\n\n // Page unload cleanup\n window.addEventListener('beforeunload', () => {\n this.clearNotificationQueue();\n });\n }\n\n /**\n * Request notification permission (BEST PRACTICE: Only call in response to user action)\n * Never call on page load!\n * @returns {Promise} Permission granted status\n */\n async requestPermission() {\n // Secure context check\n if (!this.isSecureContext || !('Notification' in window)) {\n return false;\n }\n\n if (this.permission === 'granted') {\n return true;\n }\n\n if (this.permission === 'denied') {\n return false;\n }\n\n try {\n this.permission = await Notification.requestPermission();\n return this.permission === 'granted';\n } catch (error) {\n return false;\n }\n }\n\n /**\n * Update page title with unread count\n * @private\n */\n updateTitle() {\n if (this.unreadCount > 0) {\n document.title = `(${this.unreadCount}) ${this.originalTitle}`;\n } else {\n document.title = this.originalTitle;\n }\n }\n\n /**\n * XSS Protection: Sanitize input text\n * @param {string} text - Text to sanitize\n * @returns {string} Sanitized text\n * @private\n */\n sanitizeText(text) {\n if (typeof text !== 'string') {\n return '';\n }\n \n // Remove HTML tags and potentially dangerous characters\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n .substring(0, 500); // Length limit\n }\n\n /**\n * Validate icon URL (XSS protection)\n * @param {string} url - URL to validate\n * @returns {string|null} Validated URL or null\n * @private\n */\n validateIconUrl(url) {\n if (!url) return null;\n \n try {\n const parsedUrl = new URL(url, window.location.origin);\n \n // Only allow HTTPS and data URLs\n if (parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'data:') {\n // Check trusted origins if specified\n if (this.trustedOrigins.length > 0) {\n const isTrusted = this.trustedOrigins.some(origin => \n parsedUrl.origin === origin\n );\n return isTrusted ? parsedUrl.href : null;\n }\n return parsedUrl.href;\n }\n \n return null;\n } catch (error) {\n return null;\n }\n }\n\n /**\n * Rate limiting for spam protection\n * @returns {boolean} Rate limit check passed\n * @private\n */\n checkRateLimit() {\n const now = Date.now();\n if (now - this.lastNotificationTime < this.rateLimitMs) {\n return false;\n }\n this.lastNotificationTime = now;\n return true;\n }\n\n /**\n * Send secure notification\n * @param {string} senderName - Name of message sender\n * @param {string} message - Message content\n * @param {Object} options - Notification options\n * @returns {Notification|null} Created notification or null\n */\n notify(senderName, message, options = {}) {\n // Abort if Notifications API is not available (e.g., iOS Safari)\n if (typeof Notification === 'undefined') {\n return null;\n }\n // Update tab active state before checking\n this.isTabActive = this.checkTabActive();\n \n // Only show if tab is NOT active (user is on another tab or minimized)\n if (this.isTabActive) {\n return null;\n }\n\n // Permission check\n if (this.permission !== 'granted') {\n return null;\n }\n\n // Rate limiting\n if (!this.checkRateLimit()) {\n return null;\n }\n\n // Data sanitization (XSS Protection)\n const safeSenderName = this.sanitizeText(senderName || 'Unknown');\n const safeMessage = this.sanitizeText(message || '');\n const safeIcon = this.validateIconUrl(options.icon) || '/logo/icon-192x192.png';\n\n // Queue overflow protection\n if (this.notificationQueue.length >= this.maxQueueSize) {\n this.clearNotificationQueue();\n }\n\n try {\n \n const notification = new Notification(\n `${safeSenderName}`,\n {\n body: safeMessage.substring(0, 200), // Length limit\n icon: safeIcon,\n badge: safeIcon,\n tag: `chat-${options.senderId || 'unknown'}`, // Grouping\n requireInteraction: false, // Don't block user\n silent: options.silent || false,\n // Vibrate only for mobile and if supported\n vibrate: navigator.vibrate ? [200, 100, 200] : undefined,\n // Safe metadata\n data: {\n senderId: this.sanitizeText(options.senderId),\n timestamp: Date.now(),\n // Don't include sensitive data!\n }\n }\n );\n\n // Increment counter\n this.unreadCount++;\n this.updateTitle();\n\n // Add to queue for management\n this.notificationQueue.push(notification);\n\n // Safe click handler\n notification.onclick = (event) => {\n event.preventDefault(); // Prevent default behavior\n window.focus();\n notification.close();\n \n // Safe callback\n if (typeof options.onClick === 'function') {\n try {\n options.onClick(options.senderId);\n } catch (error) {\n console.error('[Notifications] Error in onClick handler:', error);\n }\n }\n };\n\n // Error handler\n notification.onerror = (event) => {\n console.error('[Notifications] Error showing notification:', event);\n };\n\n // Auto-close after reasonable time\n const autoCloseTimeout = Math.min(options.autoClose || 5000, 10000);\n setTimeout(() => {\n notification.close();\n this.removeFromQueue(notification);\n }, autoCloseTimeout);\n\n return notification;\n \n } catch (error) {\n console.error('[Notifications] Failed to create notification:', error);\n return null;\n }\n }\n\n /**\n * Remove notification from queue\n * @param {Notification} notification - Notification to remove\n * @private\n */\n removeFromQueue(notification) {\n const index = this.notificationQueue.indexOf(notification);\n if (index > -1) {\n this.notificationQueue.splice(index, 1);\n }\n }\n\n /**\n * Clear all notifications\n */\n clearNotificationQueue() {\n this.notificationQueue.forEach(notification => {\n try {\n notification.close();\n } catch (error) {\n // Ignore errors when closing\n }\n });\n this.notificationQueue = [];\n }\n\n /**\n * Reset unread counter\n */\n resetUnreadCount() {\n this.unreadCount = 0;\n this.updateTitle();\n }\n\n /**\n * Get current status\n * @returns {Object} Current notification status\n */\n getStatus() {\n return {\n permission: this.permission,\n isTabActive: this.isTabActive,\n unreadCount: this.unreadCount,\n isSecureContext: this.isSecureContext,\n queueSize: this.notificationQueue.length\n };\n }\n}\n\n/**\n * Secure integration with WebRTC\n */\nclass SecureP2PChat {\n constructor() {\n this.notificationManager = new SecureChatNotificationManager({\n maxQueueSize: 5,\n rateLimitMs: 2000,\n trustedOrigins: [\n window.location.origin,\n // Add other trusted origins for CDN icons\n ]\n });\n \n this.dataChannel = null;\n this.peerConnection = null;\n this.remotePeerName = 'Peer';\n this.messageHistory = [];\n this.maxHistorySize = 100;\n }\n\n /**\n * Initialize when user connects\n */\n async init() {\n // Initialize notification manager silently\n }\n\n /**\n * Method for manual permission request (called on click)\n * @returns {Promise} Permission granted status\n */\n async enableNotifications() {\n const granted = await this.notificationManager.requestPermission();\n return granted;\n }\n\n /**\n * Setup DataChannel with security checks\n * @param {RTCDataChannel} dataChannel - WebRTC data channel\n */\n setupDataChannel(dataChannel) {\n if (!dataChannel) {\n console.error('[Chat] Invalid DataChannel');\n return;\n }\n\n this.dataChannel = dataChannel;\n \n // Setup handlers\n this.dataChannel.onmessage = (event) => {\n this.handleIncomingMessage(event.data);\n };\n\n this.dataChannel.onerror = (error) => {\n // Handle error silently\n };\n }\n\n /**\n * XSS Protection: Validate incoming messages\n * @param {string|Object} data - Message data\n * @returns {Object|null} Validated message or null\n * @private\n */\n validateMessage(data) {\n try {\n const message = typeof data === 'string' ? JSON.parse(data) : data;\n \n // Check message structure\n if (!message || typeof message !== 'object') {\n throw new Error('Invalid message structure');\n }\n\n // Check required fields\n if (!message.text || typeof message.text !== 'string') {\n throw new Error('Invalid message text');\n }\n\n // Message length limit (DoS protection)\n if (message.text.length > 10000) {\n throw new Error('Message too long');\n }\n\n return {\n text: message.text,\n senderName: message.senderName || 'Unknown',\n senderId: message.senderId || 'unknown',\n timestamp: message.timestamp || Date.now(),\n senderAvatar: message.senderAvatar || null\n };\n \n } catch (error) {\n console.error('[Chat] Message validation failed:', error);\n return null;\n }\n }\n\n /**\n * Secure handling of incoming messages\n * @param {string|Object} data - Message data\n * @private\n */\n handleIncomingMessage(data) {\n const message = this.validateMessage(data);\n \n if (!message) {\n return;\n }\n\n // Save to history (with limit)\n this.messageHistory.push(message);\n if (this.messageHistory.length > this.maxHistorySize) {\n this.messageHistory.shift();\n }\n\n // Display in UI (with sanitization)\n this.displayMessage(message);\n\n // Send notification only if tab is inactive\n this.notificationManager.notify(\n message.senderName,\n message.text,\n {\n icon: message.senderAvatar,\n senderId: message.senderId,\n onClick: (senderId) => {\n this.scrollToLatestMessage();\n }\n }\n );\n\n // Optional: sound (with check)\n if (!this.notificationManager.isTabActive) {\n this.playNotificationSound();\n }\n }\n\n /**\n * XSS Protection: Safe message display\n * @param {Object} message - Message to display\n * @private\n */\n displayMessage(message) {\n const container = document.getElementById('messages');\n if (!container) {\n return;\n }\n\n const messageEl = document.createElement('div');\n messageEl.className = 'message';\n \n // Use textContent to prevent XSS\n const nameEl = document.createElement('strong');\n nameEl.textContent = message.senderName + ': ';\n \n const textEl = document.createElement('span');\n textEl.textContent = message.text;\n textEl.style.wordWrap = 'break-word';\n textEl.style.overflowWrap = 'break-word';\n textEl.style.whiteSpace = 'normal';\n \n const timeEl = document.createElement('small');\n timeEl.textContent = new Date(message.timestamp).toLocaleTimeString();\n \n messageEl.appendChild(nameEl);\n messageEl.appendChild(textEl);\n messageEl.appendChild(document.createElement('br'));\n messageEl.appendChild(timeEl);\n \n container.appendChild(messageEl);\n this.scrollToLatestMessage();\n }\n\n /**\n * Safe sound playback\n * @private\n */\n playNotificationSound() {\n try {\n // Use only local audio files\n const audio = new Audio('/assets/audio/notification.mp3');\n audio.volume = 0.3; // Moderate volume\n \n // Error handling\n audio.play().catch(error => {\n // Handle audio error silently\n });\n } catch (error) {\n // Handle audio creation error silently\n }\n }\n\n /**\n * Scroll to latest message\n * @private\n */\n scrollToLatestMessage() {\n const container = document.getElementById('messages');\n if (container) {\n container.scrollTop = container.scrollHeight;\n }\n }\n\n /**\n * Get status\n * @returns {Object} Current chat status\n */\n getStatus() {\n return {\n notifications: this.notificationManager.getStatus(),\n messageCount: this.messageHistory.length,\n connected: this.dataChannel?.readyState === 'open'\n };\n }\n}\n\n// Export for use in other modules\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = { SecureChatNotificationManager, SecureP2PChat };\n}\n\n// Global export for browser usage\nif (typeof window !== 'undefined') {\n window.SecureChatNotificationManager = SecureChatNotificationManager;\n window.SecureP2PChat = SecureP2PChat;\n}\n", "/**\n * Notification Integration Module for SecureBit WebRTC Chat\n * Integrates secure notifications with existing WebRTC architecture\n * \n * @version 1.0.0\n * @author SecureBit Team\n * @license MIT\n */\n\nimport { SecureChatNotificationManager } from './SecureNotificationManager.js';\n\nclass NotificationIntegration {\n constructor(webrtcManager) {\n this.webrtcManager = webrtcManager;\n this.notificationManager = new SecureChatNotificationManager({\n maxQueueSize: 10,\n rateLimitMs: 1000, // Reduced from 2000ms to 1000ms\n trustedOrigins: [\n window.location.origin,\n // Add other trusted origins for CDN icons\n ]\n });\n \n this.isInitialized = false;\n this.originalOnMessage = null;\n this.originalOnStatusChange = null;\n this.processedMessages = new Set(); // Track processed messages to avoid duplicates\n }\n\n /**\n * Initialize notification integration\n * @returns {Promise} Initialization success\n */\n async init() {\n try {\n if (this.isInitialized) {\n return true;\n }\n\n // Store original callbacks\n this.originalOnMessage = this.webrtcManager.onMessage;\n this.originalOnStatusChange = this.webrtcManager.onStatusChange;\n\n\n // Wrap the original onMessage callback.\n // IMPORTANT: forward ALL arguments (incl. per-message `meta`) so the app\n // still receives view-once / disappearing / unsend metadata.\n this.webrtcManager.onMessage = (message, type, ...rest) => {\n this.handleIncomingMessage(message, type);\n\n // Call original callback if it exists\n if (this.originalOnMessage) {\n this.originalOnMessage(message, type, ...rest);\n }\n };\n\n // Wrap the original onStatusChange callback\n this.webrtcManager.onStatusChange = (status) => {\n this.handleStatusChange(status);\n \n // Call original callback if it exists\n if (this.originalOnStatusChange) {\n this.originalOnStatusChange(status);\n }\n };\n\n // Also hook into the deliverMessageToUI method if it exists.\n // IMPORTANT: forward ALL arguments (incl. per-message `meta`) to the\n // original, otherwise view-once / disappearing / unsend metadata is lost.\n if (this.webrtcManager.deliverMessageToUI) {\n this.originalDeliverMessageToUI = this.webrtcManager.deliverMessageToUI.bind(this.webrtcManager);\n this.webrtcManager.deliverMessageToUI = (message, type, ...rest) => {\n this.handleIncomingMessage(message, type);\n this.originalDeliverMessageToUI(message, type, ...rest);\n };\n }\n\n this.isInitialized = true;\n return true;\n\n } catch (error) {\n return false;\n }\n }\n\n /**\n * Handle incoming messages and trigger notifications\n * @param {*} message - Message content\n * @param {string} type - Message type\n * @private\n */\n handleIncomingMessage(message, type) {\n try {\n // Create a unique key for this message to avoid duplicates\n const messageKey = `${type}:${typeof message === 'string' ? message : JSON.stringify(message)}`;\n \n // Skip if we've already processed this message\n if (this.processedMessages.has(messageKey)) {\n return;\n }\n \n // Mark message as processed\n this.processedMessages.add(messageKey);\n \n // Clean up old processed messages (keep only last 100)\n if (this.processedMessages.size > 100) {\n const messagesArray = Array.from(this.processedMessages);\n this.processedMessages.clear();\n messagesArray.slice(-50).forEach(msg => this.processedMessages.add(msg));\n }\n \n \n // Only process chat messages, not system messages\n if (type === 'system' || type === 'file-transfer' || type === 'heartbeat') {\n return;\n }\n\n // Extract message information\n const messageInfo = this.extractMessageInfo(message, type);\n if (!messageInfo) {\n return;\n }\n\n // Send notification\n const notificationResult = this.notificationManager.notify(\n messageInfo.senderName,\n messageInfo.text,\n {\n icon: messageInfo.senderAvatar,\n senderId: messageInfo.senderId,\n onClick: (senderId) => {\n this.focusChatWindow();\n }\n }\n );\n\n } catch (error) {\n // Handle error silently\n }\n }\n\n /**\n * Handle status changes\n * @param {string} status - Connection status\n * @private\n */\n handleStatusChange(status) {\n try {\n // Clear notifications when connection is lost\n if (status === 'disconnected' || status === 'failed') {\n this.notificationManager.clearNotificationQueue();\n this.notificationManager.resetUnreadCount();\n }\n } catch (error) {\n // Handle error silently\n }\n }\n\n /**\n * Extract message information for notifications\n * @param {*} message - Message content\n * @param {string} type - Message type\n * @returns {Object|null} Extracted message info or null\n * @private\n */\n extractMessageInfo(message, type) {\n try {\n let messageData = message;\n\n // Handle different message formats\n if (typeof message === 'string') {\n try {\n messageData = JSON.parse(message);\n } catch (e) {\n // Plain text message\n return {\n senderName: 'Peer',\n text: message,\n senderId: 'peer',\n senderAvatar: null\n };\n }\n }\n\n // Handle structured message data\n if (typeof messageData === 'object' && messageData !== null) {\n return {\n senderName: messageData.senderName || messageData.name || 'Peer',\n text: messageData.text || messageData.message || messageData.content || '',\n senderId: messageData.senderId || messageData.id || 'peer',\n senderAvatar: messageData.senderAvatar || messageData.avatar || null\n };\n }\n\n return null;\n } catch (error) {\n return null;\n }\n }\n\n /**\n * Focus chat window when notification is clicked\n * @private\n */\n focusChatWindow() {\n try {\n window.focus();\n \n // Scroll to bottom of messages if container exists\n const messagesContainer = document.getElementById('messages');\n if (messagesContainer) {\n messagesContainer.scrollTop = messagesContainer.scrollHeight;\n }\n } catch (error) {\n // Handle error silently\n }\n }\n\n /**\n * Request notification permission\n * @returns {Promise} Permission granted status\n */\n async requestPermission() {\n try {\n return await this.notificationManager.requestPermission();\n } catch (error) {\n return false;\n }\n }\n\n /**\n * Get notification status\n * @returns {Object} Notification status\n */\n getStatus() {\n return this.notificationManager.getStatus();\n }\n\n /**\n * Clear all notifications\n */\n clearNotifications() {\n this.notificationManager.clearNotificationQueue();\n this.notificationManager.resetUnreadCount();\n }\n\n /**\n * Cleanup integration\n */\n cleanup() {\n try {\n if (this.isInitialized) {\n // Restore original callbacks\n if (this.originalOnMessage) {\n this.webrtcManager.onMessage = this.originalOnMessage;\n }\n if (this.originalOnStatusChange) {\n this.webrtcManager.onStatusChange = this.originalOnStatusChange;\n }\n if (this.originalDeliverMessageToUI) {\n this.webrtcManager.deliverMessageToUI = this.originalDeliverMessageToUI;\n }\n\n // Clear notifications\n this.clearNotifications();\n\n this.isInitialized = false;\n }\n } catch (error) {\n // Handle error silently\n }\n }\n}\n\n// Export for use in other modules\nif (typeof module !== 'undefined' && module.exports) {\n module.exports = { NotificationIntegration };\n}\n\n// Global export for browser usage\nif (typeof window !== 'undefined') {\n window.NotificationIntegration = NotificationIntegration;\n}\n", "const {\n entries,\n setPrototypeOf,\n isFrozen,\n getPrototypeOf,\n getOwnPropertyDescriptor,\n} = Object;\n\nlet { freeze, seal, create } = Object; // eslint-disable-line import/no-mutable-exports\nlet { apply, construct } = typeof Reflect !== 'undefined' && Reflect;\n\nif (!freeze) {\n freeze = function (x: T): T {\n return x;\n };\n}\n\nif (!seal) {\n seal = function (x: T): T {\n return x;\n };\n}\n\nif (!apply) {\n apply = function (\n func: (thisArg: any, ...args: any[]) => T,\n thisArg: any,\n ...args: any[]\n ): T {\n return func.apply(thisArg, args);\n };\n}\n\nif (!construct) {\n construct = function (Func: new (...args: any[]) => T, ...args: any[]): T {\n return new Func(...args);\n };\n}\n\nconst arrayForEach = unapply(Array.prototype.forEach);\nconst arrayIndexOf = unapply(Array.prototype.indexOf);\nconst arrayLastIndexOf = unapply(Array.prototype.lastIndexOf);\nconst arrayPop = unapply(Array.prototype.pop);\nconst arrayPush = unapply(Array.prototype.push);\nconst arraySlice = unapply(Array.prototype.slice);\nconst arraySplice = unapply(Array.prototype.splice);\nconst arrayIsArray = Array.isArray;\n\nconst stringToLowerCase = unapply(String.prototype.toLowerCase);\nconst stringToString = unapply(String.prototype.toString);\nconst stringMatch = unapply(String.prototype.match);\nconst stringReplace = unapply(String.prototype.replace);\nconst stringIndexOf = unapply(String.prototype.indexOf);\nconst stringTrim = unapply(String.prototype.trim);\n\nconst numberToString = unapply(Number.prototype.toString);\nconst booleanToString = unapply(Boolean.prototype.toString);\nconst bigintToString =\n typeof BigInt === 'undefined' ? null : unapply(BigInt.prototype.toString);\nconst symbolToString =\n typeof Symbol === 'undefined' ? null : unapply(Symbol.prototype.toString);\n\nconst objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty);\nconst objectToString = unapply(Object.prototype.toString);\n\nconst regExpTest = unapply(RegExp.prototype.test);\n\nconst typeErrorCreate = unconstruct(TypeError);\n\n/**\n * Creates a new function that calls the given function with a specified thisArg and arguments.\n *\n * @param func - The function to be wrapped and called.\n * @returns A new function that calls the given function with a specified thisArg and arguments.\n */\nfunction unapply(\n func: (thisArg: any, ...args: any[]) => T\n): (thisArg: any, ...args: any[]) => T {\n return (thisArg: any, ...args: any[]): T => {\n if (thisArg instanceof RegExp) {\n thisArg.lastIndex = 0;\n }\n\n return apply(func, thisArg, args);\n };\n}\n\n/**\n * Creates a new function that constructs an instance of the given constructor function with the provided arguments.\n *\n * @param func - The constructor function to be wrapped and called.\n * @returns A new function that constructs an instance of the given constructor function with the provided arguments.\n */\nfunction unconstruct(\n Func: new (...args: any[]) => T\n): (...args: any[]) => T {\n return (...args: any[]): T => construct(Func, args);\n}\n\n/**\n * Add properties to a lookup table\n *\n * @param set - The set to which elements will be added.\n * @param array - The array containing elements to be added to the set.\n * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set.\n * @returns The modified set with added elements.\n */\nfunction addToSet(\n set: Record,\n array: readonly unknown[],\n transformCaseFunc: ReturnType> = stringToLowerCase\n): Record {\n if (setPrototypeOf) {\n // Make 'in' and truthy checks like Boolean(set.constructor)\n // independent of any properties defined on Object.prototype.\n // Prevent prototype setters from intercepting set as a this value.\n setPrototypeOf(set, null);\n }\n\n if (!arrayIsArray(array)) {\n return set;\n }\n\n let l = array.length;\n while (l--) {\n let element = array[l];\n\n if (typeof element === 'string') {\n const lcElement = transformCaseFunc(element);\n\n if (lcElement !== element) {\n // Config presets (e.g. tags.js, attrs.js) are immutable.\n if (!isFrozen(array)) {\n (array as unknown[])[l] = lcElement;\n }\n\n element = lcElement;\n }\n }\n\n set[element as string] = true;\n }\n\n return set;\n}\n\n/**\n * Clean up an array to harden against CSPP\n *\n * @param array - The array to be cleaned.\n * @returns The cleaned version of the array\n */\nfunction cleanArray(array: T[]): Array {\n for (let index = 0; index < array.length; index++) {\n const isPropertyExist = objectHasOwnProperty(array, index);\n\n if (!isPropertyExist) {\n array[index] = null;\n }\n }\n\n return array;\n}\n\n/**\n * Shallow clone an object\n *\n * @param object - The object to be cloned.\n * @returns A new object that copies the original.\n */\nfunction clone>(object: T): T {\n const newObject = create(null);\n\n for (const [property, value] of entries(object)) {\n const isPropertyExist = objectHasOwnProperty(object, property);\n\n if (isPropertyExist) {\n if (arrayIsArray(value)) {\n newObject[property] = cleanArray(value);\n } else if (\n value &&\n typeof value === 'object' &&\n value.constructor === Object\n ) {\n newObject[property] = clone(value);\n } else {\n newObject[property] = value;\n }\n }\n }\n\n return newObject;\n}\n\n/**\n * Convert non-node values into strings without depending on direct property access.\n *\n * @param value - The value to stringify.\n * @returns A string representation of the provided value.\n */\nfunction stringifyValue(value: unknown): string {\n switch (typeof value) {\n case 'string': {\n return value;\n }\n\n case 'number': {\n return numberToString(value);\n }\n\n case 'boolean': {\n return booleanToString(value);\n }\n\n case 'bigint': {\n return bigintToString ? bigintToString(value) : '0';\n }\n\n case 'symbol': {\n return symbolToString ? symbolToString(value) : 'Symbol()';\n }\n\n case 'undefined': {\n return objectToString(value);\n }\n\n case 'function':\n case 'object': {\n if (value === null) {\n return objectToString(value);\n }\n\n const valueAsRecord = value as Record;\n const valueToString = lookupGetter(valueAsRecord, 'toString');\n\n if (typeof valueToString === 'function') {\n const stringified = valueToString(valueAsRecord);\n\n return typeof stringified === 'string'\n ? stringified\n : objectToString(stringified);\n }\n\n return objectToString(value);\n }\n\n default: {\n return objectToString(value);\n }\n }\n}\n\n/**\n * This method automatically checks if the prop is function or getter and behaves accordingly.\n *\n * @param object - The object to look up the getter function in its prototype chain.\n * @param prop - The property name for which to find the getter function.\n * @returns The getter function found in the prototype chain or a fallback function.\n */\nfunction lookupGetter>(\n object: T,\n prop: string\n): ReturnType> | (() => null) {\n while (object !== null) {\n const desc = getOwnPropertyDescriptor(object, prop);\n\n if (desc) {\n if (desc.get) {\n return unapply(desc.get);\n }\n\n if (typeof desc.value === 'function') {\n return unapply(desc.value);\n }\n }\n\n object = getPrototypeOf(object);\n }\n\n function fallbackValue(): null {\n return null;\n }\n\n return fallbackValue;\n}\n\nfunction isRegex(value: unknown): value is RegExp {\n try {\n regExpTest(value as RegExp, '');\n return true;\n } catch {\n return false;\n }\n}\n\nexport {\n // Array\n arrayForEach,\n arrayIndexOf,\n arrayIsArray,\n arrayLastIndexOf,\n arrayPop,\n arrayPush,\n arraySlice,\n arraySplice,\n // Object\n entries,\n freeze,\n getPrototypeOf,\n getOwnPropertyDescriptor,\n isFrozen,\n setPrototypeOf,\n seal,\n clone,\n create,\n objectHasOwnProperty,\n objectToString,\n // RegExp\n regExpTest,\n isRegex,\n // String\n stringIndexOf,\n stringMatch,\n stringReplace,\n stringToLowerCase,\n stringToString,\n stringTrim,\n // Other conversion\n stringifyValue,\n // Errors\n typeErrorCreate,\n // Other\n lookupGetter,\n addToSet,\n // Reflect\n unapply,\n unconstruct,\n};\n", "import { freeze } from './utils.js';\n\nexport const html = freeze([\n 'a',\n 'abbr',\n 'acronym',\n 'address',\n 'area',\n 'article',\n 'aside',\n 'audio',\n 'b',\n 'bdi',\n 'bdo',\n 'big',\n 'blink',\n 'blockquote',\n 'body',\n 'br',\n 'button',\n 'canvas',\n 'caption',\n 'center',\n 'cite',\n 'code',\n 'col',\n 'colgroup',\n 'content',\n 'data',\n 'datalist',\n 'dd',\n 'decorator',\n 'del',\n 'details',\n 'dfn',\n 'dialog',\n 'dir',\n 'div',\n 'dl',\n 'dt',\n 'element',\n 'em',\n 'fieldset',\n 'figcaption',\n 'figure',\n 'font',\n 'footer',\n 'form',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'head',\n 'header',\n 'hgroup',\n 'hr',\n 'html',\n 'i',\n 'img',\n 'input',\n 'ins',\n 'kbd',\n 'label',\n 'legend',\n 'li',\n 'main',\n 'map',\n 'mark',\n 'marquee',\n 'menu',\n 'menuitem',\n 'meter',\n 'nav',\n 'nobr',\n 'ol',\n 'optgroup',\n 'option',\n 'output',\n 'p',\n 'picture',\n 'pre',\n 'progress',\n 'q',\n 'rp',\n 'rt',\n 'ruby',\n 's',\n 'samp',\n 'search',\n 'section',\n 'select',\n 'shadow',\n 'slot',\n 'small',\n 'source',\n 'spacer',\n 'span',\n 'strike',\n 'strong',\n 'style',\n 'sub',\n 'summary',\n 'sup',\n 'table',\n 'tbody',\n 'td',\n 'template',\n 'textarea',\n 'tfoot',\n 'th',\n 'thead',\n 'time',\n 'tr',\n 'track',\n 'tt',\n 'u',\n 'ul',\n 'var',\n 'video',\n 'wbr',\n] as const);\n\nexport const svg = freeze([\n 'svg',\n 'a',\n 'altglyph',\n 'altglyphdef',\n 'altglyphitem',\n 'animatecolor',\n 'animatemotion',\n 'animatetransform',\n 'circle',\n 'clippath',\n 'defs',\n 'desc',\n 'ellipse',\n 'enterkeyhint',\n 'exportparts',\n 'filter',\n 'font',\n 'g',\n 'glyph',\n 'glyphref',\n 'hkern',\n 'image',\n 'inputmode',\n 'line',\n 'lineargradient',\n 'marker',\n 'mask',\n 'metadata',\n 'mpath',\n 'part',\n 'path',\n 'pattern',\n 'polygon',\n 'polyline',\n 'radialgradient',\n 'rect',\n 'stop',\n 'style',\n 'switch',\n 'symbol',\n 'text',\n 'textpath',\n 'title',\n 'tref',\n 'tspan',\n 'view',\n 'vkern',\n] as const);\n\nexport const svgFilters = freeze([\n 'feBlend',\n 'feColorMatrix',\n 'feComponentTransfer',\n 'feComposite',\n 'feConvolveMatrix',\n 'feDiffuseLighting',\n 'feDisplacementMap',\n 'feDistantLight',\n 'feDropShadow',\n 'feFlood',\n 'feFuncA',\n 'feFuncB',\n 'feFuncG',\n 'feFuncR',\n 'feGaussianBlur',\n 'feImage',\n 'feMerge',\n 'feMergeNode',\n 'feMorphology',\n 'feOffset',\n 'fePointLight',\n 'feSpecularLighting',\n 'feSpotLight',\n 'feTile',\n 'feTurbulence',\n] as const);\n\n// List of SVG elements that are disallowed by default.\n// We still need to know them so that we can do namespace\n// checks properly in case one wants to add them to\n// allow-list.\nexport const svgDisallowed = freeze([\n 'animate',\n 'color-profile',\n 'cursor',\n 'discard',\n 'font-face',\n 'font-face-format',\n 'font-face-name',\n 'font-face-src',\n 'font-face-uri',\n 'foreignobject',\n 'hatch',\n 'hatchpath',\n 'mesh',\n 'meshgradient',\n 'meshpatch',\n 'meshrow',\n 'missing-glyph',\n 'script',\n 'set',\n 'solidcolor',\n 'unknown',\n 'use',\n] as const);\n\nexport const mathMl = freeze([\n 'math',\n 'menclose',\n 'merror',\n 'mfenced',\n 'mfrac',\n 'mglyph',\n 'mi',\n 'mlabeledtr',\n 'mmultiscripts',\n 'mn',\n 'mo',\n 'mover',\n 'mpadded',\n 'mphantom',\n 'mroot',\n 'mrow',\n 'ms',\n 'mspace',\n 'msqrt',\n 'mstyle',\n 'msub',\n 'msup',\n 'msubsup',\n 'mtable',\n 'mtd',\n 'mtext',\n 'mtr',\n 'munder',\n 'munderover',\n 'mprescripts',\n] as const);\n\n// Similarly to SVG, we want to know all MathML elements,\n// even those that we disallow by default.\nexport const mathMlDisallowed = freeze([\n 'maction',\n 'maligngroup',\n 'malignmark',\n 'mlongdiv',\n 'mscarries',\n 'mscarry',\n 'msgroup',\n 'mstack',\n 'msline',\n 'msrow',\n 'semantics',\n 'annotation',\n 'annotation-xml',\n 'mprescripts',\n 'none',\n] as const);\n\nexport const text = freeze(['#text'] as const);\n", "import { freeze } from './utils.js';\n\nexport const html = freeze([\n 'accept',\n 'action',\n 'align',\n 'alt',\n 'autocapitalize',\n 'autocomplete',\n 'autopictureinpicture',\n 'autoplay',\n 'background',\n 'bgcolor',\n 'border',\n 'capture',\n 'cellpadding',\n 'cellspacing',\n 'checked',\n 'cite',\n 'class',\n 'clear',\n 'color',\n 'cols',\n 'colspan',\n 'command',\n 'commandfor',\n 'controls',\n 'controlslist',\n 'coords',\n 'crossorigin',\n 'datetime',\n 'decoding',\n 'default',\n 'dir',\n 'disabled',\n 'disablepictureinpicture',\n 'disableremoteplayback',\n 'download',\n 'draggable',\n 'enctype',\n 'enterkeyhint',\n 'exportparts',\n 'face',\n 'for',\n 'headers',\n 'height',\n 'hidden',\n 'high',\n 'href',\n 'hreflang',\n 'id',\n 'inert',\n 'inputmode',\n 'integrity',\n 'ismap',\n 'kind',\n 'label',\n 'lang',\n 'list',\n 'loading',\n 'loop',\n 'low',\n 'max',\n 'maxlength',\n 'media',\n 'method',\n 'min',\n 'minlength',\n 'multiple',\n 'muted',\n 'name',\n 'nonce',\n 'noshade',\n 'novalidate',\n 'nowrap',\n 'open',\n 'optimum',\n 'part',\n 'pattern',\n 'placeholder',\n 'playsinline',\n 'popover',\n 'popovertarget',\n 'popovertargetaction',\n 'poster',\n 'preload',\n 'pubdate',\n 'radiogroup',\n 'readonly',\n 'rel',\n 'required',\n 'rev',\n 'reversed',\n 'role',\n 'rows',\n 'rowspan',\n 'spellcheck',\n 'scope',\n 'selected',\n 'shape',\n 'size',\n 'sizes',\n 'slot',\n 'span',\n 'srclang',\n 'start',\n 'src',\n 'srcset',\n 'step',\n 'style',\n 'summary',\n 'tabindex',\n 'title',\n 'translate',\n 'type',\n 'usemap',\n 'valign',\n 'value',\n 'width',\n 'wrap',\n 'xmlns',\n] as const);\n\nexport const svg = freeze([\n 'accent-height',\n 'accumulate',\n 'additive',\n 'alignment-baseline',\n 'amplitude',\n 'ascent',\n 'attributename',\n 'attributetype',\n 'azimuth',\n 'basefrequency',\n 'baseline-shift',\n 'begin',\n 'bias',\n 'by',\n 'class',\n 'clip',\n 'clippathunits',\n 'clip-path',\n 'clip-rule',\n 'color',\n 'color-interpolation',\n 'color-interpolation-filters',\n 'color-profile',\n 'color-rendering',\n 'cx',\n 'cy',\n 'd',\n 'dx',\n 'dy',\n 'diffuseconstant',\n 'direction',\n 'display',\n 'divisor',\n 'dur',\n 'edgemode',\n 'elevation',\n 'end',\n 'exponent',\n 'fill',\n 'fill-opacity',\n 'fill-rule',\n 'filter',\n 'filterunits',\n 'flood-color',\n 'flood-opacity',\n 'font-family',\n 'font-size',\n 'font-size-adjust',\n 'font-stretch',\n 'font-style',\n 'font-variant',\n 'font-weight',\n 'fx',\n 'fy',\n 'g1',\n 'g2',\n 'glyph-name',\n 'glyphref',\n 'gradientunits',\n 'gradienttransform',\n 'height',\n 'href',\n 'id',\n 'image-rendering',\n 'in',\n 'in2',\n 'intercept',\n 'k',\n 'k1',\n 'k2',\n 'k3',\n 'k4',\n 'kerning',\n 'keypoints',\n 'keysplines',\n 'keytimes',\n 'lang',\n 'lengthadjust',\n 'letter-spacing',\n 'kernelmatrix',\n 'kernelunitlength',\n 'lighting-color',\n 'local',\n 'marker-end',\n 'marker-mid',\n 'marker-start',\n 'markerheight',\n 'markerunits',\n 'markerwidth',\n 'maskcontentunits',\n 'maskunits',\n 'max',\n 'mask',\n 'mask-type',\n 'media',\n 'method',\n 'mode',\n 'min',\n 'name',\n 'numoctaves',\n 'offset',\n 'operator',\n 'opacity',\n 'order',\n 'orient',\n 'orientation',\n 'origin',\n 'overflow',\n 'paint-order',\n 'path',\n 'pathlength',\n 'patterncontentunits',\n 'patterntransform',\n 'patternunits',\n 'points',\n 'preservealpha',\n 'preserveaspectratio',\n 'primitiveunits',\n 'r',\n 'rx',\n 'ry',\n 'radius',\n 'refx',\n 'refy',\n 'repeatcount',\n 'repeatdur',\n 'restart',\n 'result',\n 'rotate',\n 'scale',\n 'seed',\n 'shape-rendering',\n 'slope',\n 'specularconstant',\n 'specularexponent',\n 'spreadmethod',\n 'startoffset',\n 'stddeviation',\n 'stitchtiles',\n 'stop-color',\n 'stop-opacity',\n 'stroke-dasharray',\n 'stroke-dashoffset',\n 'stroke-linecap',\n 'stroke-linejoin',\n 'stroke-miterlimit',\n 'stroke-opacity',\n 'stroke',\n 'stroke-width',\n 'style',\n 'surfacescale',\n 'systemlanguage',\n 'tabindex',\n 'tablevalues',\n 'targetx',\n 'targety',\n 'transform',\n 'transform-origin',\n 'text-anchor',\n 'text-decoration',\n 'text-rendering',\n 'textlength',\n 'type',\n 'u1',\n 'u2',\n 'unicode',\n 'values',\n 'viewbox',\n 'visibility',\n 'version',\n 'vert-adv-y',\n 'vert-origin-x',\n 'vert-origin-y',\n 'width',\n 'word-spacing',\n 'wrap',\n 'writing-mode',\n 'xchannelselector',\n 'ychannelselector',\n 'x',\n 'x1',\n 'x2',\n 'xmlns',\n 'y',\n 'y1',\n 'y2',\n 'z',\n 'zoomandpan',\n] as const);\n\nexport const mathMl = freeze([\n 'accent',\n 'accentunder',\n 'align',\n 'bevelled',\n 'close',\n 'columnalign',\n 'columnlines',\n 'columnspacing',\n 'columnspan',\n 'denomalign',\n 'depth',\n 'dir',\n 'display',\n 'displaystyle',\n 'encoding',\n 'fence',\n 'frame',\n 'height',\n 'href',\n 'id',\n 'largeop',\n 'length',\n 'linethickness',\n 'lquote',\n 'lspace',\n 'mathbackground',\n 'mathcolor',\n 'mathsize',\n 'mathvariant',\n 'maxsize',\n 'minsize',\n 'movablelimits',\n 'notation',\n 'numalign',\n 'open',\n 'rowalign',\n 'rowlines',\n 'rowspacing',\n 'rowspan',\n 'rspace',\n 'rquote',\n 'scriptlevel',\n 'scriptminsize',\n 'scriptsizemultiplier',\n 'selection',\n 'separator',\n 'separators',\n 'stretchy',\n 'subscriptshift',\n 'supscriptshift',\n 'symmetric',\n 'voffset',\n 'width',\n 'xmlns',\n]);\n\nexport const xml = freeze([\n 'xlink:href',\n 'xml:id',\n 'xlink:title',\n 'xml:space',\n 'xmlns:xlink',\n] as const);\n", "import { seal } from './utils.js';\n\nexport const MUSTACHE_EXPR = seal(/{{[\\w\\W]*|^[\\w\\W]*}}/g);\nexport const ERB_EXPR = seal(/<%[\\w\\W]*|^[\\w\\W]*%>/g);\nexport const TMPLIT_EXPR = seal(/\\${[\\w\\W]*/g);\nexport const DATA_ATTR = seal(/^data-[\\-\\w.\\u00B7-\\uFFFF]+$/); // eslint-disable-line no-useless-escape\nexport const ARIA_ATTR = seal(/^aria-[\\-\\w]+$/); // eslint-disable-line no-useless-escape\nexport const IS_ALLOWED_URI = seal(\n /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\\-]+(?:[^a-z+.\\-:]|$))/i // eslint-disable-line no-useless-escape\n);\nexport const IS_SCRIPT_OR_DATA = seal(/^(?:\\w+script|data):/i);\nexport const ATTR_WHITESPACE = seal(\n /[\\u0000-\\u0020\\u00A0\\u1680\\u180E\\u2000-\\u2029\\u205F\\u3000]/g // eslint-disable-line no-control-regex\n);\nexport const DOCTYPE_NAME = seal(/^html$/i);\nexport const CUSTOM_ELEMENT = seal(/^[a-z][.\\w]*(-[.\\w]+)+$/i);\n\n// Markup-significant character probes used by _sanitizeElements.\n// Shared module-level instances are safe despite the sticky /g flags:\n// unapply() resets lastIndex for RegExp receivers before every call.\nexport const ELEMENT_MARKUP_PROBE = seal(/<[/\\w!]/g);\nexport const COMMENT_MARKUP_PROBE = seal(/<[/\\w]/g);\nexport const FALLBACK_TAG_CLOSE = seal(/<\\/no(script|embed|frames)/i);\nexport const SELF_CLOSING_TAG = seal(/\\/>/i);\n", "/* eslint-disable @typescript-eslint/indent */\n\nimport type { Config, UseProfilesConfig } from './config';\nimport type { DOMPurify, HooksMap, HookFunction, WindowLike } from './types';\nimport * as TAGS from './tags.js';\nimport * as ATTRS from './attrs.js';\nimport * as EXPRESSIONS from './regexp.js';\nimport {\n addToSet,\n clone,\n entries,\n freeze,\n seal,\n arrayForEach,\n arrayIsArray,\n arrayLastIndexOf,\n arrayPop,\n arrayPush,\n arraySplice,\n stringMatch,\n stringReplace,\n stringToLowerCase,\n stringToString,\n stringIndexOf,\n stringTrim,\n regExpTest,\n isRegex,\n typeErrorCreate,\n lookupGetter,\n create,\n objectHasOwnProperty,\n stringifyValue,\n} from './utils.js';\n\nexport type { Config } from './config';\n\nexport type {\n DOMPurify,\n RemovedElement,\n RemovedAttribute,\n HookName,\n NodeHook,\n ElementHook,\n DocumentFragmentHook,\n UponSanitizeElementHook,\n UponSanitizeAttributeHook,\n UponSanitizeElementHookEvent,\n UponSanitizeAttributeHookEvent,\n WindowLike,\n} from './types';\n\ndeclare const VERSION: string;\n\n// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType\nconst NODE_TYPE = {\n element: 1,\n attribute: 2,\n text: 3,\n cdataSection: 4,\n entityReference: 5, // Deprecated\n entityNode: 6, // Deprecated\n processingInstruction: 7,\n comment: 8,\n document: 9,\n documentType: 10,\n documentFragment: 11,\n notation: 12, // Deprecated\n};\n\nconst getGlobal = function (): WindowLike {\n return typeof window === 'undefined' ? null : window;\n};\n\n/**\n * Creates a no-op policy for internal use only.\n * Don't export this function outside this module!\n * @param trustedTypes The policy factory.\n * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix).\n * @return The policy created (or null, if Trusted Types\n * are not supported or creating the policy failed).\n */\nconst _createTrustedTypesPolicy = function (\n trustedTypes: TrustedTypePolicyFactory,\n purifyHostElement: HTMLScriptElement\n) {\n if (\n typeof trustedTypes !== 'object' ||\n typeof trustedTypes.createPolicy !== 'function'\n ) {\n return null;\n }\n\n // Allow the callers to control the unique policy name\n // by adding a data-tt-policy-suffix to the script element with the DOMPurify.\n // Policy creation with duplicate names throws in Trusted Types.\n let suffix = null;\n const ATTR_NAME = 'data-tt-policy-suffix';\n if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) {\n suffix = purifyHostElement.getAttribute(ATTR_NAME);\n }\n\n const policyName = 'dompurify' + (suffix ? '#' + suffix : '');\n\n try {\n return trustedTypes.createPolicy(policyName, {\n createHTML(html) {\n return html;\n },\n createScriptURL(scriptUrl) {\n return scriptUrl;\n },\n });\n } catch (_) {\n // Policy creation failed (most likely another DOMPurify script has\n // already run). Skip creating the policy, as this will only cause errors\n // if TT are enforced.\n console.warn(\n 'TrustedTypes policy ' + policyName + ' could not be created.'\n );\n return null;\n }\n};\n\nconst _createHooksMap = function (): HooksMap {\n return {\n afterSanitizeAttributes: [],\n afterSanitizeElements: [],\n afterSanitizeShadowDOM: [],\n beforeSanitizeAttributes: [],\n beforeSanitizeElements: [],\n beforeSanitizeShadowDOM: [],\n uponSanitizeAttribute: [],\n uponSanitizeElement: [],\n uponSanitizeShadowNode: [],\n };\n};\n\n/**\n * Resolve a set-valued configuration option: a fresh set built from\n * cfg[key] when it is an own array property (seeded with a clone of\n * options.base when given, case-normalized via options.transform),\n * the fallback set otherwise.\n *\n * @param cfg the cloned, prototype-free configuration object\n * @param key the configuration property to read\n * @param fallback the set to use when the option is absent or not an array\n * @param options transform and optional base set to merge into\n * @returns the resolved set\n */\nconst _resolveSetOption = function (\n cfg: Config,\n key: keyof Config,\n fallback: Record,\n options: {\n transform: Parameters[2];\n base?: Record;\n }\n): Record {\n return objectHasOwnProperty(cfg, key) && arrayIsArray(cfg[key])\n ? addToSet(\n options.base ? clone(options.base) : {},\n cfg[key] as readonly unknown[],\n options.transform\n )\n : fallback;\n};\n\nfunction createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {\n const DOMPurify: DOMPurify = (root: WindowLike) => createDOMPurify(root);\n\n DOMPurify.version = VERSION;\n\n DOMPurify.removed = [];\n\n if (\n !window ||\n !window.document ||\n window.document.nodeType !== NODE_TYPE.document ||\n !window.Element\n ) {\n // Not running in a browser, provide a factory function\n // so that you can pass your own Window\n DOMPurify.isSupported = false;\n\n return DOMPurify;\n }\n\n let { document } = window;\n\n const originalDocument = document;\n const currentScript: HTMLScriptElement =\n originalDocument.currentScript as HTMLScriptElement;\n const {\n DocumentFragment,\n HTMLTemplateElement,\n Node,\n Element,\n NodeFilter,\n NamedNodeMap = window.NamedNodeMap || (window as any).MozNamedAttrMap,\n HTMLFormElement,\n DOMParser,\n trustedTypes,\n } = window;\n\n const ElementPrototype = Element.prototype;\n\n const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');\n const remove = lookupGetter(ElementPrototype, 'remove');\n const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');\n const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');\n const getParentNode = lookupGetter(ElementPrototype, 'parentNode');\n const getShadowRoot = lookupGetter(ElementPrototype, 'shadowRoot');\n const getAttributes = lookupGetter(ElementPrototype, 'attributes');\n const getNodeType =\n Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;\n const getNodeName =\n Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;\n\n // As per issue #47, the web-components registry is inherited by a\n // new document created via createHTMLDocument. As per the spec\n // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)\n // a new empty registry is used when creating a template contents owner\n // document, so we use that as our parent document to ensure nothing\n // is inherited.\n if (typeof HTMLTemplateElement === 'function') {\n const template = document.createElement('template');\n if (template.content && template.content.ownerDocument) {\n document = template.content.ownerDocument;\n }\n }\n\n let trustedTypesPolicy;\n let emptyHTML = '';\n\n // The instance's own internal Trusted Types policy. Unlike a caller-supplied\n // `TRUSTED_TYPES_POLICY`, this is created at most once \u2014 Trusted Types throws\n // on duplicate policy names \u2014 and is the only policy allowed to persist\n // across configurations and survive `clearConfig()`.\n let defaultTrustedTypesPolicy;\n let defaultTrustedTypesPolicyResolved = false;\n\n // Tracks whether we are already inside a call to the configured Trusted Types\n // policy (`createHTML` or `createScriptURL`). If a supplied policy callback\n // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would\n // re-enter the policy and recurse until the stack overflows. We detect that\n // re-entry and throw a clear, actionable error instead. The guard is shared\n // across both callbacks, because either one re-entering `sanitize` triggers\n // the same unbounded recursion.\n let IN_TRUSTED_TYPES_POLICY = 0;\n const _assertNotInTrustedTypesPolicy = function (): void {\n if (IN_TRUSTED_TYPES_POLICY > 0) {\n throw typeErrorCreate(\n 'A configured TRUSTED_TYPES_POLICY callback (createHTML or ' +\n 'createScriptURL) must not call DOMPurify.sanitize, as that causes ' +\n 'infinite recursion. Do not pass a policy whose callbacks wrap ' +\n 'DOMPurify as TRUSTED_TYPES_POLICY; see the \"DOMPurify and Trusted ' +\n 'Types\" section of the README.'\n );\n }\n };\n\n const _createTrustedHTML = function (html: string): string {\n _assertNotInTrustedTypesPolicy();\n\n IN_TRUSTED_TYPES_POLICY++;\n try {\n return trustedTypesPolicy.createHTML(html);\n } finally {\n IN_TRUSTED_TYPES_POLICY--;\n }\n };\n\n const _createTrustedScriptURL = function (scriptUrl: string): string {\n _assertNotInTrustedTypesPolicy();\n\n IN_TRUSTED_TYPES_POLICY++;\n try {\n return trustedTypesPolicy.createScriptURL(scriptUrl);\n } finally {\n IN_TRUSTED_TYPES_POLICY--;\n }\n };\n\n // Lazily resolve (and cache) the instance's internal default policy.\n // Resolution is attempted at most once: a successful `createPolicy` cannot be\n // repeated (Trusted Types throws on duplicate names), and a failed or\n // unsupported attempt must not be retried on every parse.\n const _getDefaultTrustedTypesPolicy = function () {\n if (!defaultTrustedTypesPolicyResolved) {\n defaultTrustedTypesPolicy = _createTrustedTypesPolicy(\n trustedTypes,\n currentScript\n );\n defaultTrustedTypesPolicyResolved = true;\n }\n\n return defaultTrustedTypesPolicy;\n };\n\n const {\n implementation,\n createNodeIterator,\n createDocumentFragment,\n getElementsByTagName,\n } = document;\n const { importNode } = originalDocument;\n\n let hooks = _createHooksMap();\n\n /**\n * Expose whether this browser supports running the full DOMPurify.\n */\n DOMPurify.isSupported =\n typeof entries === 'function' &&\n typeof getParentNode === 'function' &&\n implementation &&\n implementation.createHTMLDocument !== undefined;\n\n const {\n MUSTACHE_EXPR,\n ERB_EXPR,\n TMPLIT_EXPR,\n DATA_ATTR,\n ARIA_ATTR,\n IS_SCRIPT_OR_DATA,\n ATTR_WHITESPACE,\n CUSTOM_ELEMENT,\n } = EXPRESSIONS;\n\n let { IS_ALLOWED_URI } = EXPRESSIONS;\n\n /**\n * We consider the elements and attributes below to be safe. Ideally\n * don't add any new ones but feel free to remove unwanted ones.\n */\n\n /* allowed element names */\n let ALLOWED_TAGS = null;\n const DEFAULT_ALLOWED_TAGS = addToSet({}, [\n ...TAGS.html,\n ...TAGS.svg,\n ...TAGS.svgFilters,\n ...TAGS.mathMl,\n ...TAGS.text,\n ]);\n\n /* Allowed attribute names */\n let ALLOWED_ATTR = null;\n const DEFAULT_ALLOWED_ATTR = addToSet({}, [\n ...ATTRS.html,\n ...ATTRS.svg,\n ...ATTRS.mathMl,\n ...ATTRS.xml,\n ]);\n\n /*\n * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements.\n * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements)\n * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list)\n * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`.\n */\n let CUSTOM_ELEMENT_HANDLING = Object.seal(\n create(null, {\n tagNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n attributeNameCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n allowCustomizedBuiltInElements: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: false,\n },\n })\n );\n\n /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */\n let FORBID_TAGS = null;\n\n /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */\n let FORBID_ATTR = null;\n\n /* Config object to store ADD_TAGS/ADD_ATTR functions (when used as functions) */\n const EXTRA_ELEMENT_HANDLING = Object.seal(\n create(null, {\n tagCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n attributeCheck: {\n writable: true,\n configurable: false,\n enumerable: true,\n value: null,\n },\n })\n );\n\n /* Decide if ARIA attributes are okay */\n let ALLOW_ARIA_ATTR = true;\n\n /* Decide if custom data attributes are okay */\n let ALLOW_DATA_ATTR = true;\n\n /* Decide if unknown protocols are okay */\n let ALLOW_UNKNOWN_PROTOCOLS = false;\n\n /* Decide if self-closing tags in attributes are allowed.\n * Usually removed due to a mXSS issue in jQuery 3.0 */\n let ALLOW_SELF_CLOSE_IN_ATTR = true;\n\n /* Output should be safe for common template engines.\n * This means, DOMPurify removes data attributes, mustaches and ERB\n */\n let SAFE_FOR_TEMPLATES = false;\n\n /* Output should be safe even for XML used within HTML and alike.\n * This means, DOMPurify removes comments when containing risky content.\n */\n let SAFE_FOR_XML = true;\n\n /* Decide if document with ... should be returned */\n let WHOLE_DOCUMENT = false;\n\n /* Track whether config is already set on this instance of DOMPurify. */\n let SET_CONFIG = false;\n\n /* Decide if all elements (e.g. style, script) must be children of\n * document.body. By default, browsers might move them to document.head */\n let FORCE_BODY = false;\n\n /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported).\n * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead\n */\n let RETURN_DOM = false;\n\n /* Decide if a DOM `DocumentFragment` should be returned, instead of a html\n * string (or a TrustedHTML object if Trusted Types are supported) */\n let RETURN_DOM_FRAGMENT = false;\n\n /* Try to return a Trusted Type object instead of a string, return a string in\n * case Trusted Types are not supported */\n let RETURN_TRUSTED_TYPE = false;\n\n /* Output should be free from DOM clobbering attacks?\n * This sanitizes markups named with colliding, clobberable built-in DOM APIs.\n */\n let SANITIZE_DOM = true;\n\n /* Achieve full DOM Clobbering protection by isolating the namespace of named\n * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules.\n *\n * HTML/DOM spec rules that enable DOM Clobbering:\n * - Named Access on Window (\u00A77.3.3)\n * - DOM Tree Accessors (\u00A73.1.5)\n * - Form Element Parent-Child Relations (\u00A74.10.3)\n * - Iframe srcdoc / Nested WindowProxies (\u00A74.8.5)\n * - HTMLCollection (\u00A74.2.10.2)\n *\n * Namespace isolation is implemented by prefixing `id` and `name` attributes\n * with a constant string, i.e., `user-content-`\n */\n let SANITIZE_NAMED_PROPS = false;\n const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-';\n\n /* Keep element content when removing element? */\n let KEEP_CONTENT = true;\n\n /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead\n * of importing it into a new Document and returning a sanitized copy */\n let IN_PLACE = false;\n\n /* Allow usage of profiles like html, svg and mathMl */\n let USE_PROFILES: UseProfilesConfig | false = {};\n\n /* Tags to ignore content of when KEEP_CONTENT is true */\n let FORBID_CONTENTS = null;\n const DEFAULT_FORBID_CONTENTS = addToSet({}, [\n 'annotation-xml',\n 'audio',\n 'colgroup',\n 'desc',\n 'foreignobject',\n 'head',\n 'iframe',\n 'math',\n 'mi',\n 'mn',\n 'mo',\n 'ms',\n 'mtext',\n 'noembed',\n 'noframes',\n 'noscript',\n 'plaintext',\n 'script',\n // mirrors the selected