diff --git a/benchmarks/kids_scoring_bench.js b/benchmarks/kids_scoring_bench.js new file mode 100644 index 00000000..ffb96a14 --- /dev/null +++ b/benchmarks/kids_scoring_bench.js @@ -0,0 +1,63 @@ +// benchmarks/kids_scoring_bench.js +import { createKidsScorerStage } from "../js/feedEngine/kidsScoring.js"; +import { performance } from "perf_hooks"; + +async function runBenchmark() { + const itemCount = 200000; + const items = []; + + // Create mock items with diverse data to exercise different paths + for (let i = 0; i < itemCount; i++) { + items.push({ + video: { + id: `v${i}`, + pubkey: `pubkey${i % 100}`, + duration: 300 + (i % 600), // 5 to 15 minutes + tags: [ + ["t", "kids"], + ["t", "learning"], + ["t", i % 2 === 0 ? "preschool" : "science"], + ], + viewCount: i * 10, + created_at: Date.now() / 1000 - (i % 10000), + moderation: { trustedCount: i % 5 }, + }, + metadata: {}, + }); + } + + const context = { + runtime: { + ageGroup: "preschool", + trustedAuthors: ["pubkey1", "pubkey2"], + }, + addWhy: () => {}, // Mock addWhy + }; + + const scorer = createKidsScorerStage(); + + console.log(`Starting kids scoring benchmark with ${itemCount} items...`); + + // Warmup + await scorer([...items.slice(0, 1000)], context); + + const iterations = 100; + let totalTime = 0; + + for (let i = 0; i < iterations; i++) { + // Reset metadata for each run to be cleaner + items.forEach(item => item.metadata = {}); + + const start = performance.now(); + await scorer(items, context); + const end = performance.now(); + totalTime += (end - start); + + if (i % 5 === 0) process.stdout.write("."); + } + + const avgTime = totalTime / iterations; + console.log(`\nAverage time per run: ${avgTime.toFixed(4)}ms`); +} + +runBenchmark().catch(console.error); diff --git a/config/instance-config.js b/config/instance-config.js index d937a9df..8f7313d9 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -33,6 +33,8 @@ export const IS_DEV_MODE = false; * a quieter dev mode without disabling other development helpers entirely. * Use the runtime override documented in `js/config.js` if you need to toggle * verbosity without editing this file. + * + * Security: This must be false in production. */ export const IS_VERBOSE_DEV_MODE = false; diff --git a/js/app.js b/js/app.js index 0f040880..6374f47b 100644 --- a/js/app.js +++ b/js/app.js @@ -177,7 +177,14 @@ import { createPlaybackCoordinator } from "./app/playbackCoordinator.js"; import { createAuthSessionCoordinator } from "./app/authSessionCoordinator.js"; import { createModalCoordinator } from "./app/modalCoordinator.js"; import { createModerationCoordinator } from "./app/moderationCoordinator.js"; +import { createRouterCoordinator } from "./app/routerCoordinator.js"; +import { createUiCoordinator } from "./app/uiCoordinator.js"; import { HEX64_REGEX } from "./utils/hex.js"; +import { + safeEncodeNpub, + safeDecodeNpub, + normalizeHexPubkey, +} from "./utils/nostrHelpers.js"; const recordVideoViewApi = (...args) => recordVideoView(nostrClient, ...args); @@ -240,7 +247,7 @@ class Application { } constructor({ services = {}, ui = {}, helpers = {}, loadView: viewLoader } = {}) { - this.loadView = typeof viewLoader === "function" ? viewLoader : null; + this.loadView = viewLoader || null; const bootstrapServices = { ...services, @@ -640,6 +647,22 @@ class Application { writable: true, configurable: true, }); + + Object.defineProperty(this, "_router", { + value: bindCoordinator(this, createRouterCoordinator({ + devLogger, + })), + writable: true, + configurable: true, + }); + + Object.defineProperty(this, "_ui", { + value: bindCoordinator(this, createUiCoordinator({ + devLogger, + })), + writable: true, + configurable: true, + }); } get modalVideo() { @@ -1208,92 +1231,72 @@ class Application { await watchHistoryInitPromise; } + async initializeServices() { + await this._initViewManager(); + this._warmupNostrExtension(); + this._initServiceWorker(); + this._initAuthAndModeration(); + } + + initializeUIResources() { + return this._initModals(); + } + + async initializeNetwork() { + await this._initNostr(); + } + + async initializeDataAndSession() { + await this._initTrustedSeeds(); + this._initViewCounter(); + this._initAccessControl(); + + await this._initSessionActor(); + this._initAccessControlListeners(); + } + + initializeUserInterface() { + this._initUI(); + } + + async performAutoLogin() { + await this._initAutoLogin(); + } + + async finalizeInitialization() { + this.setupEventListeners(); + await this._initWatchHistory(); + this.checkUrlParams(); + } + async init() { try { - await this._initViewManager(); - this._warmupNostrExtension(); - this._initServiceWorker(); - this._initAuthAndModeration(); + await this.initializeServices(); - const modalBootstrapPromise = this._initModals(); + const modalPromise = this.initializeUIResources(); + await this.initializeNetwork(); + await modalPromise; - await this._initNostr(); + await this.initializeDataAndSession(); - await modalBootstrapPromise; - - await this._initTrustedSeeds(); - this._initViewCounter(); - this._initAccessControl(); - - await this._initSessionActor(); - this._initAccessControlListeners(); - this._initUI(); - await this._initAutoLogin(); - - // 5. Setup general event listeners - this.setupEventListeners(); - - await this._initWatchHistory(); - - // 9. Check URL ?v= param - this.checkUrlParams(); + this.initializeUserInterface(); + await this.performAutoLogin(); + await this.finalizeInitialization(); } catch (error) { devLogger.error("Init failed:", error); this.showError("Failed to connect to Nostr relay"); } } - goToProfile(pubkey) { - if (typeof pubkey !== "string") { - this.showError("No creator info available."); - return; - } - - let candidate = pubkey.trim(); - if (!candidate) { - this.showError("No creator info available."); - return; - } - - if (candidate.startsWith("nostr:")) { - candidate = candidate.slice("nostr:".length); - } - - const normalizedHex = this.normalizeHexPubkey(candidate); - const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null; - - if (!npub) { - devLogger.warn( - "[Application] Invalid pubkey for profile navigation:", - candidate, - ); - this.showError("Invalid creator profile."); - return; - } - - window.location.hash = `#view=channel-profile&npub=${npub}`; + goToProfile(...args) { + this._initCoordinators(); + return this._router.goToProfile(...args); } - openCreatorChannel() { - if (!this.currentVideo || !this.currentVideo.pubkey) { - this.showError("No creator info available."); - return; - } - - try { - // Encode the hex pubkey to npub - const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey); - - // Close the video modal - this.hideModal(); - - // Switch to channel profile view - window.location.hash = `#view=channel-profile&npub=${npub}`; - } catch (err) { - devLogger.error("Failed to open creator channel:", err); - this.showError("Could not open channel."); - } + openCreatorChannel(...args) { + this._initCoordinators(); + return this._router.openCreatorChannel(...args); } openWalletPane() { @@ -1337,41 +1340,14 @@ class Application { } } - dispatchAuthLoadingState(detail = {}) { - if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") { - return false; - } - - try { - const payload = { ...(typeof detail === "object" && detail ? detail : {}) }; - window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload })); - return true; - } catch (error) { - devLogger.warn("[Application] Failed to dispatch auth loading state:", error); - return false; - } + dispatchAuthLoadingState(...args) { + this._initCoordinators(); + return this._ui.dispatchAuthLoadingState(...args); } - updateAuthLoadingState(partial = {}) { - const baseState = - this.authLoadingState && typeof this.authLoadingState === "object" - ? this.authLoadingState - : { profile: "idle", lists: "idle", dms: "idle" }; - const nextState = { - ...baseState, - ...(partial && typeof partial === "object" ? partial : {}), - }; - this.authLoadingState = nextState; - - const root = typeof document !== "undefined" ? document.documentElement : null; - if (root) { - root.dataset.authProfileLoading = nextState.profile || "idle"; - root.dataset.authListsLoading = nextState.lists || "idle"; - root.dataset.authDmsLoading = nextState.dms || "idle"; - } - - this.dispatchAuthLoadingState(nextState); - return nextState; + updateAuthLoadingState(...args) { + this._initCoordinators(); + return this._ui.updateAuthLoadingState(...args); } normalizeModalTrigger(...args) { @@ -1663,23 +1639,9 @@ class Application { this.closeAllMoreMenus(); } - handleProfileChannelLink(element) { - if (!(element instanceof HTMLElement)) { - return; - } - - const targetNpub = - typeof element.dataset.targetNpub === "string" - ? element.dataset.targetNpub - : ""; - if (this.profileController) { - this.profileController.hide(); - } - if (targetNpub) { - window.location.hash = `#view=channel-profile&npub=${encodeURIComponent( - targetNpub, - )}`; - } + handleProfileChannelLink(...args) { + this._initCoordinators(); + return this._router.handleProfileChannelLink(...args); } async handleLoginModalSuccess(payload = {}) { @@ -3260,36 +3222,9 @@ class Application { return this._moderation.refreshVisibleModerationUi(...args); } - updateActiveProfileUI(pubkey, profile = {}) { - if (this.profileController) { - this.profileController.handleProfileUpdated({ - pubkey, - profile, - }); - return; - } - - const picture = profile.picture || "assets/svg/default-profile.svg"; - - if (this.profileAvatar) { - this.profileAvatar.src = picture; - } - - const channelLink = document.getElementById("profileChannelLink"); - if (channelLink instanceof HTMLElement) { - const targetNpub = this.safeEncodeNpub(pubkey); - if (targetNpub) { - channelLink.href = `#view=channel-profile&npub=${targetNpub}`; - channelLink.dataset.targetNpub = targetNpub; - channelLink.classList.remove("hidden"); - } else { - channelLink.removeAttribute("href"); - if (channelLink.dataset) { - delete channelLink.dataset.targetNpub; - } - channelLink.classList.add("hidden"); - } - } + updateActiveProfileUI(...args) { + this._initCoordinators(); + return this._ui.updateActiveProfileUI(...args); } async hydrateNwcSettingsForPubkey(pubkey) { @@ -3370,226 +3305,49 @@ class Application { return this.nwcSettingsService.clearStoredNwcSettings(pubkey, options); } - applyAuthenticatedUiState() { - if (this.loginButton) { - this.loginButton.classList.add("hidden"); - this.loginButton.setAttribute("hidden", ""); - } - - if (this.logoutButton) { - this.logoutButton.classList.remove("hidden"); - } - - if (this.userStatus) { - this.userStatus.classList.add("hidden"); - } - - if (this.uploadButton) { - this.uploadButton.classList.remove("hidden"); - this.uploadButton.removeAttribute("hidden"); - } - - if (this.profileButton) { - this.profileButton.classList.remove("hidden"); - this.profileButton.removeAttribute("hidden"); - } - - const subscriptionsLink = this.resolveSubscriptionsLink(); - if (subscriptionsLink) { - subscriptionsLink.classList.remove("hidden"); - } - - const forYouLink = this.resolveForYouLink(); - if (forYouLink) { - forYouLink.classList.remove("hidden"); - } - - const exploreLink = this.resolveExploreLink(); - if (exploreLink) { - exploreLink.classList.remove("hidden"); - } + applyAuthenticatedUiState(...args) { + this._initCoordinators(); + return this._ui.applyAuthenticatedUiState(...args); } - applyLoggedOutUiState() { - if (this.loginButton) { - this.loginButton.classList.remove("hidden"); - this.loginButton.removeAttribute("hidden"); - } - - if (this.logoutButton) { - this.logoutButton.classList.add("hidden"); - } - - if (this.userStatus) { - this.userStatus.classList.add("hidden"); - } - - if (this.userPubKey) { - this.userPubKey.textContent = ""; - } - - if (this.uploadButton) { - this.uploadButton.classList.add("hidden"); - this.uploadButton.setAttribute("hidden", ""); - } - - if (this.profileButton) { - this.profileButton.classList.add("hidden"); - this.profileButton.setAttribute("hidden", ""); - } - - const subscriptionsLink = this.resolveSubscriptionsLink(); - if (subscriptionsLink) { - subscriptionsLink.classList.add("hidden"); - } - - const forYouLink = this.resolveForYouLink(); - if (forYouLink) { - forYouLink.classList.add("hidden"); - } - - const exploreLink = this.resolveExploreLink(); - if (exploreLink) { - exploreLink.classList.add("hidden"); - } + applyLoggedOutUiState(...args) { + this._initCoordinators(); + return this._ui.applyLoggedOutUiState(...args); } - syncAuthUiState() { - if (this.isUserLoggedIn()) { - this.applyAuthenticatedUiState(); - } else { - this.applyLoggedOutUiState(); - } + syncAuthUiState(...args) { + this._initCoordinators(); + return this._ui.syncAuthUiState(...args); } - refreshChromeElements() { - const doc = typeof document !== "undefined" ? document : null; - const findElement = (id) => { - if (!doc || typeof doc.getElementById !== "function") { - return null; - } - const element = doc.getElementById(id); - if (!element || typeof element.addEventListener !== "function") { - return null; - } - return element; - }; - - this.loginButton = findElement("loginButton"); - this.logoutButton = findElement("logoutButton"); - this.uploadButton = findElement("uploadButton"); - this.profileButton = findElement("profileButton"); - this.closeLoginModalBtn = findElement("closeLoginModal"); - - return { - loginButton: this.loginButton, - logoutButton: this.logoutButton, - uploadButton: this.uploadButton, - profileButton: this.profileButton, - closeLoginModalButton: this.closeLoginModalBtn, - }; + refreshChromeElements(...args) { + this._initCoordinators(); + return this._ui.refreshChromeElements(...args); } - resolveSubscriptionsLink() { - if ( - this.subscriptionsLink instanceof HTMLElement && - this.subscriptionsLink.isConnected - ) { - return this.subscriptionsLink; - } - - const linkCandidate = document.getElementById("subscriptionsLink"); - if (linkCandidate instanceof HTMLElement) { - this.subscriptionsLink = linkCandidate; - return this.subscriptionsLink; - } - - this.subscriptionsLink = null; - return null; + resolveSubscriptionsLink(...args) { + this._initCoordinators(); + return this._ui.resolveSubscriptionsLink(...args); } - resolveForYouLink() { - if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) { - return this.forYouLink; - } - - const linkCandidate = document.getElementById("forYouLink"); - if (linkCandidate instanceof HTMLElement) { - this.forYouLink = linkCandidate; - return this.forYouLink; - } - - this.forYouLink = null; - return null; + resolveForYouLink(...args) { + this._initCoordinators(); + return this._ui.resolveForYouLink(...args); } - resolveExploreLink() { - if ( - this.exploreLink instanceof HTMLElement && - this.exploreLink.isConnected - ) { - return this.exploreLink; - } - - const linkCandidate = document.getElementById("exploreLink"); - if (linkCandidate instanceof HTMLElement) { - this.exploreLink = linkCandidate; - return this.exploreLink; - } - - this.exploreLink = null; - return null; + resolveExploreLink(...args) { + this._initCoordinators(); + return this._ui.resolveExploreLink(...args); } - hydrateSidebarNavigation() { - const chromeElements = this.refreshChromeElements(); - this.resolveSubscriptionsLink(); - this.resolveForYouLink(); - this.resolveExploreLink(); - - if (this.appChromeController) { - if (typeof this.appChromeController.setElements === "function") { - this.appChromeController.setElements(chromeElements); - } else { - if (this.appChromeController.elements) { - Object.assign(this.appChromeController.elements, chromeElements); - } - this.appChromeController.initialize(); - } - } - - this.syncAuthUiState(); + hydrateSidebarNavigation(...args) { + this._initCoordinators(); + return this._ui.hydrateSidebarNavigation(...args); } - maybeShowExperimentalLoginWarning(provider) { - const normalizedProvider = - typeof provider === "string" ? provider.trim().toLowerCase() : ""; - - if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") { - return; - } - - const now = Date.now(); - if ( - this.lastExperimentalWarningKey === normalizedProvider && - typeof this.lastExperimentalWarningAt === "number" && - now - this.lastExperimentalWarningAt < 2000 - ) { - return; - } - - this.lastExperimentalWarningKey = normalizedProvider; - this.lastExperimentalWarningAt = now; - - const providerLabel = - normalizedProvider === "nsec" - ? "Direct nsec or seed" - : "NIP-46 remote signer"; - - this.showStatus( - `${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`, - { showSpinner: false, autoHideMs: 5000 }, - ); + maybeShowExperimentalLoginWarning(...args) { + this._initCoordinators(); + return this._ui.maybeShowExperimentalLoginWarning(...args); } async handlePermissionPromptRequest() { @@ -4886,46 +4644,11 @@ class Application { * Simple helper to safely encode an npub. */ safeEncodeNpub(pubkey) { - if (typeof pubkey !== "string") { - return null; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return null; - } - - if (trimmed.startsWith("npub1")) { - return trimmed; - } - - try { - return window.NostrTools.nip19.npubEncode(trimmed); - } catch (err) { - return null; - } + return safeEncodeNpub(pubkey); } safeDecodeNpub(npub) { - if (typeof npub !== "string") { - return null; - } - - const trimmed = npub.trim(); - if (!trimmed) { - return null; - } - - try { - const decoded = window.NostrTools.nip19.decode(trimmed); - if (decoded.type === "npub" && typeof decoded.data === "string") { - return decoded.data; - } - } catch (err) { - return null; - } - - return null; + return safeDecodeNpub(npub); } selectPreferredCreatorName(candidates = []) { @@ -5357,27 +5080,7 @@ class Application { } normalizeHexPubkey(pubkey) { - if (typeof pubkey !== "string") { - return null; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return null; - } - - if (HEX64_REGEX.test(trimmed)) { - return trimmed.toLowerCase(); - } - - if (trimmed.startsWith("npub1")) { - const decoded = this.safeDecodeNpub(trimmed); - if (decoded && HEX64_REGEX.test(decoded)) { - return decoded.toLowerCase(); - } - } - - return null; + return normalizeHexPubkey(pubkey); } /** diff --git a/js/app/routerCoordinator.js b/js/app/routerCoordinator.js new file mode 100644 index 00000000..92c5d713 --- /dev/null +++ b/js/app/routerCoordinator.js @@ -0,0 +1,88 @@ +// js/app/routerCoordinator.js + +/** + * Routing logic for the application. + * + * All module-level dependencies are injected from the Application + * composition root rather than imported at module scope. + * + * Methods use `this` which is bound to the Application instance. + */ +export function createRouterCoordinator(deps) { + const { + devLogger, + } = deps; + + return { + goToProfile(pubkey) { + if (typeof pubkey !== "string") { + this.showError("No creator info available."); + return; + } + + let candidate = pubkey.trim(); + if (!candidate) { + this.showError("No creator info available."); + return; + } + + if (candidate.startsWith("nostr:")) { + candidate = candidate.slice("nostr:".length); + } + + const normalizedHex = this.normalizeHexPubkey(candidate); + const npub = normalizedHex ? this.safeEncodeNpub(normalizedHex) : null; + + if (!npub) { + devLogger.warn( + "[Application] Invalid pubkey for profile navigation:", + candidate, + ); + this.showError("Invalid creator profile."); + return; + } + + window.location.hash = `#view=channel-profile&npub=${npub}`; + }, + + openCreatorChannel() { + if (!this.currentVideo || !this.currentVideo.pubkey) { + this.showError("No creator info available."); + return; + } + + try { + // Encode the hex pubkey to npub + const npub = window.NostrTools.nip19.npubEncode(this.currentVideo.pubkey); + + // Close the video modal + this.hideModal(); + + // Switch to channel profile view + window.location.hash = `#view=channel-profile&npub=${npub}`; + } catch (err) { + devLogger.error("Failed to open creator channel:", err); + this.showError("Could not open channel."); + } + }, + + handleProfileChannelLink(element) { + if (!(element instanceof HTMLElement)) { + return; + } + + const targetNpub = + typeof element.dataset.targetNpub === "string" + ? element.dataset.targetNpub + : ""; + if (this.profileController) { + this.profileController.hide(); + } + if (targetNpub) { + window.location.hash = `#view=channel-profile&npub=${encodeURIComponent( + targetNpub, + )}`; + } + } + }; +} diff --git a/js/app/uiCoordinator.js b/js/app/uiCoordinator.js new file mode 100644 index 00000000..2679346f --- /dev/null +++ b/js/app/uiCoordinator.js @@ -0,0 +1,308 @@ +// js/app/uiCoordinator.js + +/** + * UI State and Chrome management. + * + * All module-level dependencies are injected from the Application + * composition root rather than imported at module scope. + * + * Methods use `this` which is bound to the Application instance. + */ +export function createUiCoordinator(deps) { + const { + devLogger, + } = deps; + + return { + dispatchAuthLoadingState(detail = {}) { + if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") { + return false; + } + + try { + const payload = { ...(typeof detail === "object" && detail ? detail : {}) }; + window.dispatchEvent(new CustomEvent("bitvid:auth-loading-state", { detail: payload })); + return true; + } catch (error) { + devLogger.warn("[Application] Failed to dispatch auth loading state:", error); + return false; + } + }, + + updateAuthLoadingState(partial = {}) { + const baseState = + this.authLoadingState && typeof this.authLoadingState === "object" + ? this.authLoadingState + : { profile: "idle", lists: "idle", dms: "idle" }; + const nextState = { + ...baseState, + ...(partial && typeof partial === "object" ? partial : {}), + }; + this.authLoadingState = nextState; + + const root = typeof document !== "undefined" ? document.documentElement : null; + if (root) { + root.dataset.authProfileLoading = nextState.profile || "idle"; + root.dataset.authListsLoading = nextState.lists || "idle"; + root.dataset.authDmsLoading = nextState.dms || "idle"; + } + + this.dispatchAuthLoadingState(nextState); + return nextState; + }, + + updateActiveProfileUI(pubkey, profile = {}) { + if (this.profileController) { + this.profileController.handleProfileUpdated({ + pubkey, + profile, + }); + return; + } + + const picture = profile.picture || "assets/svg/default-profile.svg"; + + if (this.profileAvatar) { + this.profileAvatar.src = picture; + } + + const channelLink = document.getElementById("profileChannelLink"); + if (channelLink instanceof HTMLElement) { + const targetNpub = this.safeEncodeNpub(pubkey); + if (targetNpub) { + channelLink.href = `#view=channel-profile&npub=${targetNpub}`; + channelLink.dataset.targetNpub = targetNpub; + channelLink.classList.remove("hidden"); + } else { + channelLink.removeAttribute("href"); + if (channelLink.dataset) { + delete channelLink.dataset.targetNpub; + } + channelLink.classList.add("hidden"); + } + } + }, + + applyAuthenticatedUiState() { + if (this.loginButton) { + this.loginButton.classList.add("hidden"); + this.loginButton.setAttribute("hidden", ""); + } + + if (this.logoutButton) { + this.logoutButton.classList.remove("hidden"); + } + + if (this.userStatus) { + this.userStatus.classList.add("hidden"); + } + + if (this.uploadButton) { + this.uploadButton.classList.remove("hidden"); + this.uploadButton.removeAttribute("hidden"); + } + + if (this.profileButton) { + this.profileButton.classList.remove("hidden"); + this.profileButton.removeAttribute("hidden"); + } + + const subscriptionsLink = this.resolveSubscriptionsLink(); + if (subscriptionsLink) { + subscriptionsLink.classList.remove("hidden"); + } + + const forYouLink = this.resolveForYouLink(); + if (forYouLink) { + forYouLink.classList.remove("hidden"); + } + + const exploreLink = this.resolveExploreLink(); + if (exploreLink) { + exploreLink.classList.remove("hidden"); + } + }, + + applyLoggedOutUiState() { + if (this.loginButton) { + this.loginButton.classList.remove("hidden"); + this.loginButton.removeAttribute("hidden"); + } + + if (this.logoutButton) { + this.logoutButton.classList.add("hidden"); + } + + if (this.userStatus) { + this.userStatus.classList.add("hidden"); + } + + if (this.userPubKey) { + this.userPubKey.textContent = ""; + } + + if (this.uploadButton) { + this.uploadButton.classList.add("hidden"); + this.uploadButton.setAttribute("hidden", ""); + } + + if (this.profileButton) { + this.profileButton.classList.add("hidden"); + this.profileButton.setAttribute("hidden", ""); + } + + const subscriptionsLink = this.resolveSubscriptionsLink(); + if (subscriptionsLink) { + subscriptionsLink.classList.add("hidden"); + } + + const forYouLink = this.resolveForYouLink(); + if (forYouLink) { + forYouLink.classList.add("hidden"); + } + + const exploreLink = this.resolveExploreLink(); + if (exploreLink) { + exploreLink.classList.add("hidden"); + } + }, + + syncAuthUiState() { + if (this.isUserLoggedIn()) { + this.applyAuthenticatedUiState(); + } else { + this.applyLoggedOutUiState(); + } + }, + + refreshChromeElements() { + const doc = typeof document !== "undefined" ? document : null; + const findElement = (id) => { + if (!doc || typeof doc.getElementById !== "function") { + return null; + } + const element = doc.getElementById(id); + if (!element || typeof element.addEventListener !== "function") { + return null; + } + return element; + }; + + this.loginButton = findElement("loginButton"); + this.logoutButton = findElement("logoutButton"); + this.uploadButton = findElement("uploadButton"); + this.profileButton = findElement("profileButton"); + this.closeLoginModalBtn = findElement("closeLoginModal"); + + return { + loginButton: this.loginButton, + logoutButton: this.logoutButton, + uploadButton: this.uploadButton, + profileButton: this.profileButton, + closeLoginModalButton: this.closeLoginModalBtn, + }; + }, + + resolveSubscriptionsLink() { + if ( + this.subscriptionsLink instanceof HTMLElement && + this.subscriptionsLink.isConnected + ) { + return this.subscriptionsLink; + } + + const linkCandidate = document.getElementById("subscriptionsLink"); + if (linkCandidate instanceof HTMLElement) { + this.subscriptionsLink = linkCandidate; + return this.subscriptionsLink; + } + + this.subscriptionsLink = null; + return null; + }, + + resolveForYouLink() { + if (this.forYouLink instanceof HTMLElement && this.forYouLink.isConnected) { + return this.forYouLink; + } + + const linkCandidate = document.getElementById("forYouLink"); + if (linkCandidate instanceof HTMLElement) { + this.forYouLink = linkCandidate; + return this.forYouLink; + } + + this.forYouLink = null; + return null; + }, + + resolveExploreLink() { + if ( + this.exploreLink instanceof HTMLElement && + this.exploreLink.isConnected + ) { + return this.exploreLink; + } + + const linkCandidate = document.getElementById("exploreLink"); + if (linkCandidate instanceof HTMLElement) { + this.exploreLink = linkCandidate; + return this.exploreLink; + } + + this.exploreLink = null; + return null; + }, + + hydrateSidebarNavigation() { + const chromeElements = this.refreshChromeElements(); + this.resolveSubscriptionsLink(); + this.resolveForYouLink(); + this.resolveExploreLink(); + + if (this.appChromeController) { + if (typeof this.appChromeController.setElements === "function") { + this.appChromeController.setElements(chromeElements); + } else { + if (this.appChromeController.elements) { + Object.assign(this.appChromeController.elements, chromeElements); + } + this.appChromeController.initialize(); + } + } + + this.syncAuthUiState(); + }, + + maybeShowExperimentalLoginWarning(provider) { + const normalizedProvider = + typeof provider === "string" ? provider.trim().toLowerCase() : ""; + + if (normalizedProvider !== "nsec" && normalizedProvider !== "nip46") { + return; + } + + const now = Date.now(); + if ( + this.lastExperimentalWarningKey === normalizedProvider && + typeof this.lastExperimentalWarningAt === "number" && + now - this.lastExperimentalWarningAt < 2000 + ) { + return; + } + + this.lastExperimentalWarningKey = normalizedProvider; + this.lastExperimentalWarningAt = now; + + const providerLabel = + normalizedProvider === "nsec" + ? "Direct nsec or seed" + : "NIP-46 remote signer"; + + this.showStatus( + `${providerLabel} logins are still in development and may not work well yet. We recommend using a NIP-07 browser extension for the most reliable experience.`, + { showSpinner: false, autoHideMs: 5000 }, + ); + } + }; +} diff --git a/js/feedEngine/kidsScoring.js b/js/feedEngine/kidsScoring.js index 77c9ee7d..ca6af0aa 100644 --- a/js/feedEngine/kidsScoring.js +++ b/js/feedEngine/kidsScoring.js @@ -391,27 +391,32 @@ export function createKidsScorerStage({ const videoId = typeof video.id === "string" ? video.id : null; const pubkey = typeof video.pubkey === "string" ? video.pubkey : null; - const positiveComponents = [ - { key: "age-appropriateness", value: ageAppropriateness }, - { key: "educational-boost", value: educationalBoost }, - { key: "author-trust", value: authorTrust }, - { key: "popularity", value: popularityWithinKids }, - { key: "freshness", value: freshness }, - ]; + let maxKey = "age-appropriateness"; + let maxValue = ageAppropriateness; - let dominantPositive = positiveComponents[0]; - for (const component of positiveComponents) { - if (component.value > dominantPositive.value) { - dominantPositive = component; - } + if (educationalBoost > maxValue) { + maxKey = "educational-boost"; + maxValue = educationalBoost; + } + if (authorTrust > maxValue) { + maxKey = "author-trust"; + maxValue = authorTrust; + } + if (popularityWithinKids > maxValue) { + maxKey = "popularity"; + maxValue = popularityWithinKids; + } + if (freshness > maxValue) { + maxKey = "freshness"; + maxValue = freshness; } - if (dominantPositive.value > 0) { + if (maxValue > 0) { context?.addWhy?.({ stage: stageName, type: "score", - reason: dominantPositive.key, - value: dominantPositive.value, + reason: maxKey, + value: maxValue, score: kidsScore, videoId, pubkey, diff --git a/js/nostr/client.js b/js/nostr/client.js index 373c058a..bc8e700c 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -40,6 +40,7 @@ import { deriveStoragePointerFromUrl, getStoragePointerFromTags, normalizeStoragePointer, + resolveStoragePointerValue, } from "../utils/storagePointer.js"; import { createWatchHistoryManager, @@ -108,6 +109,7 @@ import { repostEvent as repostEventHelper, mirrorVideoEvent as mirrorVideoEventHelper, rebroadcastEvent as rebroadcastEventHelper, + buildRevertVideoPayload, summarizePublishResults, } from "./publishHelpers.js"; import { @@ -206,6 +208,7 @@ import { createNsecAdapter } from "./adapters/nsecAdapter.js"; import { createNip46Adapter } from "./adapters/nip46Adapter.js"; import { queueSignEvent } from "./signRequestQueue.js"; import { EventsMap } from "./eventsMap.js"; +import { Nip46Connector } from "./nip46Connector.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -983,40 +986,6 @@ function normalizeHexHash(candidate) { return HEX64_REGEX.test(trimmed) ? trimmed : ""; } -function resolveStoragePointerValue({ - storagePointer, - url, - infoHash, - fallbackId, - provider, -} = {}) { - const normalized = normalizeStoragePointer(storagePointer); - if (normalized) { - return normalized; - } - - const derivedFromUrl = deriveStoragePointerFromUrl(url, provider || "url"); - if (derivedFromUrl) { - return derivedFromUrl; - } - - if (typeof infoHash === "string" && infoHash.trim()) { - return buildStoragePointerValue({ - provider: "btih", - prefix: infoHash.trim().toLowerCase(), - }); - } - - if (typeof fallbackId === "string" && fallbackId.trim()) { - return buildStoragePointerValue({ - provider: "nostr", - prefix: fallbackId.trim(), - }); - } - - return ""; -} - const BlobConstructor = typeof Blob !== "undefined" ? Blob : null; let sharedTextEncoder = null; @@ -1433,6 +1402,7 @@ export class NostrClient { this.pendingHandshakeCancel = null; this.remoteSignerListeners = new Set(); this.sessionActorListeners = new Set(); + this.nip46Connector = new Nip46Connector(this); const storedRemoteSigner = this.getStoredNip46Metadata(); this.remoteSignerStatus = { state: storedRemoteSigner.hasSession ? "stored" : "idle", @@ -1643,115 +1613,14 @@ export class NostrClient { } async createNip46KeyPair(existingPrivateKey = "", existingPublicKey = "") { - let privateKey = - typeof existingPrivateKey === "string" && existingPrivateKey.trim() - ? existingPrivateKey.trim().toLowerCase() - : ""; - - if (privateKey && !HEX64_REGEX.test(privateKey)) { - const error = new Error("Invalid remote signer private key."); - error.code = "invalid-private-key"; - throw error; - } - - if (!privateKey) { - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (!tools) { - throw new Error("Unable to generate a remote signer key pair."); - } - - let generated = null; - if (typeof tools.generateSecretKey === "function") { - generated = tools.generateSecretKey(); - } - - if (generated instanceof Uint8Array) { - privateKey = bytesToHex(generated); - } else if (Array.isArray(generated)) { - privateKey = bytesToHex(Uint8Array.from(generated)); - } else if (typeof generated === "string") { - privateKey = generated.trim().toLowerCase(); - } - - if (!privateKey || !HEX64_REGEX.test(privateKey)) { - throw new Error("Generated remote signer key is invalid."); - } - - privateKey = privateKey.toLowerCase(); - } - - let publicKey = - typeof existingPublicKey === "string" && existingPublicKey.trim() - ? existingPublicKey.trim().toLowerCase() - : ""; - - if (!publicKey) { - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (!tools || typeof tools.getPublicKey !== "function") { - throw new Error("Public key derivation is unavailable for remote signing."); - } - publicKey = tools.getPublicKey(privateKey); - } - - if (!publicKey || !HEX64_REGEX.test(publicKey)) { - throw new Error("Derived remote signer public key is invalid."); - } - - return { privateKey, publicKey: publicKey.toLowerCase() }; + return this.nip46Connector.createKeyPair( + existingPrivateKey, + existingPublicKey, + ); } - async prepareRemoteSignerHandshake({ metadata, relays, secret, permissions } = {}) { - const keyPair = await this.createNip46KeyPair(); - const sanitizedMetadata = sanitizeNip46Metadata(metadata); - const requestedPermissions = - typeof permissions === "string" && permissions.trim() ? permissions.trim() : ""; - - const resolvedRelays = resolveNip46Relays(relays, this.relays); - const handshakeSecret = - typeof secret === "string" && secret.trim() ? secret.trim() : generateNip46Secret(); - - devLogger.debug("[nostr] Preparing remote signer handshake", { - clientPublicKey: summarizeHexForLog(keyPair.publicKey), - relays: resolvedRelays, - permissions: requestedPermissions || null, - secret: summarizeSecretForLog(handshakeSecret), - metadataKeys: summarizeMetadataForLog(sanitizedMetadata), - }); - - const params = []; - for (const relay of resolvedRelays) { - params.push(`relay=${encodeURIComponent(relay)}`); - } - if (handshakeSecret) { - params.push(`secret=${encodeURIComponent(handshakeSecret)}`); - } - if (requestedPermissions) { - params.push(`perms=${encodeURIComponent(requestedPermissions)}`); - } - for (const [key, value] of Object.entries(sanitizedMetadata)) { - params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); - } - - const query = params.length ? `?${params.join("&")}` : ""; - const uri = `nostrconnect://${keyPair.publicKey}${query}`; - - devLogger.debug("[nostr] Prepared nostrconnect URI", { - uri, - relayCount: resolvedRelays.length, - metadataKeys: summarizeMetadataForLog(sanitizedMetadata), - }); - - return { - type: "client", - connectionString: uri, - uri, - clientPrivateKey: keyPair.privateKey, - clientPublicKey: keyPair.publicKey, - relays: resolvedRelays, - secret: handshakeSecret, - permissions: requestedPermissions, - metadata: sanitizedMetadata, - }; + async prepareRemoteSignerHandshake(options = {}) { + return this.nip46Connector.prepareHandshake(options); } async installNip46Client(client, { userPubkey } = {}) { @@ -1774,296 +1643,6 @@ export class NostrClient { return signer; } - /** - * Waits for a remote signer (Nostr Connect) to acknowledge a connection request. - * - * **Protocol (NIP-46):** - * - Subscribes to Kind 24133 (NIP-46 RPC) events addressed to the client. - * - Decrypts incoming messages using the ephemeral `clientPrivateKey`. - * - Looks for "connect" acknowledgement or "auth_url" challenges. - * - * @param {object} params - * @param {string} params.clientPrivateKey - Ephemeral private key for the handshake. - * @param {string} params.clientPublicKey - Ephemeral public key. - * @param {string[]} params.relays - Relay list for the handshake. - * @param {string} [params.secret] - Optional secret to validate the "ack" response. - * @param {function} [params.onAuthUrl] - Callback for out-of-band auth challenges. - * @param {function} [params.onStatus] - Callback for status updates. - * @param {number} [params.timeoutMs] - Max wait time. - * @param {string} [params.expectedRemotePubkey] - If known, filters events to this sender. - * @returns {Promise<{remotePubkey: string, eventPubkey: string, response: object, algorithm: string}>} - */ - async waitForRemoteSignerHandshake({ - clientPrivateKey, - clientPublicKey, - relays, - secret, - onAuthUrl, - onStatus, - timeoutMs, - expectedRemotePubkey, - } = {}) { - const normalizedClientPublicKey = normalizeNostrPubkey(clientPublicKey); - if (!normalizedClientPublicKey) { - throw new Error("A client public key is required to await the remote signer handshake."); - } - - if (!clientPrivateKey || typeof clientPrivateKey !== "string" || !HEX64_REGEX.test(clientPrivateKey)) { - throw new Error("A client private key is required to await the remote signer handshake."); - } - - const resolvedRelays = resolveNip46Relays(relays, this.relays); - if (!resolvedRelays.length) { - throw new Error("No relays available to complete the remote signer handshake."); - } - - devLogger.debug("[nostr] Waiting for remote signer handshake", { - clientPubkey: summarizeHexForLog(normalizedClientPublicKey), - relays: resolvedRelays, - secret: summarizeSecretForLog(secret), - timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, - expectedRemotePubkey: summarizeHexForLog(expectedRemotePubkey || ""), - }); - - const pool = await this.ensurePool(); - const filters = [ - { - kinds: [NIP46_RPC_KIND], - "#p": [normalizedClientPublicKey], - }, - ]; - - const waitTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : NIP46_HANDSHAKE_TIMEOUT_MS; - - const coerceStructuredString = (value) => { - if (typeof value === "string") { - return value.trim(); - } - if (Array.isArray(value)) { - for (const entry of value) { - const candidate = coerceStructuredString(entry); - if (candidate) { - return candidate; - } - } - return ""; - } - if (value && typeof value === "object") { - const preferredKeys = [ - "secret", - "message", - "status", - "reason", - "detail", - "description", - "value", - "result", - "url", - ]; - for (const key of preferredKeys) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - const candidate = coerceStructuredString(value[key]); - if (candidate) { - return candidate; - } - } - } - for (const entry of Object.values(value)) { - const candidate = coerceStructuredString(entry); - if (candidate) { - return candidate; - } - } - } - return ""; - }; - - return new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => { - if (settled) { - return; - } - settled = true; - if (this.pendingHandshakeCancel === cancelHandshake) { - this.pendingHandshakeCancel = null; - } - try { - subscription?.unsub?.(); - } catch (error) { - // ignore subscription cleanup failures - } - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - - const cancelHandshake = () => { - cleanup(); - const error = new Error("Remote signer connection cancelled."); - error.code = "login-cancelled"; - reject(error); - }; - - this.pendingHandshakeCancel = cancelHandshake; - - const timeoutId = setTimeout(() => { - cleanup(); - const error = new Error("Timed out waiting for the remote signer to acknowledge the connection."); - error.code = "nip46-handshake-timeout"; - devLogger.warn("[nostr] Handshake wait timed out", { - clientPubkey: summarizeHexForLog(normalizedClientPublicKey), - relays: resolvedRelays, - }); - reject(error); - }, waitTimeout); - - let subscription; - try { - subscription = pool.sub(resolvedRelays, filters); - devLogger.debug("[nostr] Handshake subscription established", { - relays: resolvedRelays, - filters, - }); - } catch (error) { - cleanup(); - devLogger.warn("[nostr] Failed to subscribe for handshake responses", error); - reject(error); - return; - } - - subscription.on("event", (event) => { - if (settled) { - return; - } - - if (!event || event.kind !== NIP46_RPC_KIND) { - devLogger.debug("[nostr] Ignoring non-handshake event during wait", event); - return; - } - - const eventRemotePubkey = normalizeNostrPubkey(event.pubkey); - const candidateRemotePubkeys = []; - if (expectedRemotePubkey) { - candidateRemotePubkeys.push(expectedRemotePubkey); - } - if (eventRemotePubkey) { - candidateRemotePubkeys.push(eventRemotePubkey); - } - - devLogger.debug("[nostr] Processing handshake event", { - eventId: typeof event.id === "string" ? event.id : "", - eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), - candidateRemotePubkeys: candidateRemotePubkeys.map((key) => - summarizeHexForLog(key), - ), - contentLength: typeof event.content === "string" ? event.content.length : 0, - }); - - Promise.resolve() - .then(() => - attemptDecryptNip46HandshakePayload({ - clientPrivateKey, - candidateRemotePubkeys, - ciphertext: event.content, - }), - ) - .then((payloadResult) => { - const plaintext = payloadResult?.plaintext ?? ""; - let parsed; - try { - parsed = JSON.parse(plaintext); - } catch (error) { - devLogger.warn("[nostr] Failed to parse remote signer handshake payload:", error); - return; - } - - devLogger.debug("[nostr] Handshake payload parsed", { - remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || ""), - eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), - algorithm: payloadResult?.algorithm || null, - requestId: typeof parsed?.id === "string" ? parsed.id : "", - hasResult: parsed?.result !== undefined, - hasError: parsed?.error !== undefined, - }); - - const resultValue = coerceStructuredString(parsed?.result); - const errorValue = coerceStructuredString(parsed?.error); - - if (resultValue === "auth_url" && errorValue) { - const handshakeRemotePubkey = payloadResult?.remotePubkey || eventRemotePubkey || ""; - devLogger.debug("[nostr] Handshake provided auth_url challenge", { - eventId: typeof parsed?.id === "string" ? parsed.id : "", - remotePubkey: summarizeHexForLog(handshakeRemotePubkey), - url: summarizeUrlForLog(errorValue), - }); - if (typeof onAuthUrl === "function") { - try { - onAuthUrl(errorValue, { - phase: "handshake", - remotePubkey: handshakeRemotePubkey, - requestId: typeof parsed?.id === "string" ? parsed.id : "", - }); - } catch (callbackError) { - devLogger.warn("[nostr] Handshake auth_url callback threw:", callbackError); - } - } - return; - } - - if (secret) { - const normalizedResult = resultValue ? resultValue.toLowerCase() : ""; - if (resultValue !== secret && normalizedResult !== "ack") { - return; - } - } - - if (!secret && resultValue) { - const normalized = resultValue.toLowerCase(); - if (!["ack", "ok", "success"].includes(normalized)) { - return; - } - } - - cleanup(); - - if (typeof onStatus === "function") { - try { - onStatus({ - phase: "handshake", - state: "acknowledged", - message: "Remote signer acknowledged the connect request.", - remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "", - }); - } catch (callbackError) { - devLogger.warn("[nostr] Handshake status callback threw:", callbackError); - } - } - - resolve({ - remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "", - eventPubkey: eventRemotePubkey || "", - response: parsed, - algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm), - }); - - devLogger.debug("[nostr] Handshake wait resolved", { - remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || eventRemotePubkey || ""), - eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), - result: resultValue || null, - hasSecret: Boolean(secret), - }); - }) - .catch((error) => { - devLogger.warn("[nostr] Failed to decrypt remote signer handshake payload:", error); - }); - }); - - subscription.on("eose", () => { - // no-op: handshake responses are push-based - }); - }); - } /** * Establishes a NIP-46 connection with a remote signer (e.g., Nostr Connect / Bunker). @@ -2230,602 +1809,16 @@ export class NostrClient { return { pubkey, signer: adapter }; } - async connectRemoteSigner({ - connectionString, - remember = true, - clientPrivateKey: providedClientPrivateKey = "", - clientPublicKey: providedClientPublicKey = "", - relays: providedRelays = [], - secret: providedSecret = "", - permissions: providedPermissions = "", - metadata: providedMetadata = {}, - onAuthUrl, - onStatus, - handshakeTimeoutMs, - passphrase, - } = {}) { - const parsed = parseNip46ConnectionString(connectionString); - if (!parsed) { - const error = new Error( - "Unsupported NIP-46 URI. Provide a nostrconnect:// handshake or bunker:// pointer.", - ); - error.code = "invalid-connection-string"; - throw error; - } - - const baseMetadata = sanitizeNip46Metadata(parsed.metadata); - const overrideMetadata = sanitizeNip46Metadata(providedMetadata); - const metadata = { ...baseMetadata, ...overrideMetadata }; - - devLogger.debug("[nostr] Connecting to remote signer", { - connectionType: parsed.type, - parsedRemotePubkey: summarizeHexForLog(parsed.remotePubkey || ""), - providedClientPublicKey: summarizeHexForLog(providedClientPublicKey), - providedClientPrivateKey: summarizeSecretForLog( - typeof providedClientPrivateKey === "string" ? providedClientPrivateKey : "", - ), - providedSecret: summarizeSecretForLog(providedSecret), - providedPermissions: providedPermissions || null, - handshakeTimeoutMs, - parsedRelays: parsed.relays, - overrideRelayCount: Array.isArray(providedRelays) ? providedRelays.length : 0, - metadataKeys: summarizeMetadataForLog(metadata), - }); - - const handleStatus = (status) => { - if (typeof onStatus !== "function") { - return; - } - try { - onStatus(status); - } catch (error) { - devLogger.warn("[nostr] Remote signer status callback threw:", error); - } - }; - - const handleAuthChallenge = async (url, context = {}) => { - if (typeof onAuthUrl !== "function" || !url) { - return; - } - try { - devLogger.debug("[nostr] Remote signer auth challenge surfaced", { - url: summarizeUrlForLog(url), - context, - }); - const result = onAuthUrl(url, context); - if (result && typeof result.then === "function") { - await result.catch((error) => { - devLogger.warn("[nostr] Auth challenge callback promise rejected:", error); - }); - } - } catch (error) { - devLogger.warn("[nostr] Remote signer auth callback threw:", error); - } - }; - - const mergedRelaysSource = parsed.relays.length ? parsed.relays : providedRelays; - const relays = resolveNip46Relays(mergedRelaysSource, this.relays); - - let secret = typeof providedSecret === "string" && providedSecret.trim() ? providedSecret.trim() : parsed.secret; - let permissions = - typeof providedPermissions === "string" && providedPermissions.trim() - ? providedPermissions.trim() - : parsed.permissions; - - let clientPrivateKey = ""; - let clientPublicKey = ""; - let remotePubkey = normalizeNostrPubkey(parsed.remotePubkey); - let handshakeAlgorithm = ""; - - if (parsed.type === "client") { - clientPrivateKey = - typeof providedClientPrivateKey === "string" && providedClientPrivateKey.trim() - ? providedClientPrivateKey.trim().toLowerCase() - : ""; - - if (!clientPrivateKey || !HEX64_REGEX.test(clientPrivateKey)) { - const error = new Error( - "Remote signer handshake requires the generated client private key.", - ); - error.code = "missing-client-private-key"; - throw error; - } - - clientPublicKey = normalizeNostrPubkey(providedClientPublicKey) || parsed.clientPubkey; - if (!clientPublicKey) { - const tools = (await ensureNostrTools()) || getCachedNostrTools(); - if (!tools || typeof tools.getPublicKey !== "function") { - throw new Error("Public key derivation is unavailable for the remote signer handshake."); - } - clientPublicKey = normalizeNostrPubkey(tools.getPublicKey(clientPrivateKey)); - } - - if (!clientPublicKey || !HEX64_REGEX.test(clientPublicKey)) { - const error = new Error("Invalid client public key for the remote signer handshake."); - error.code = "invalid-client-public-key"; - throw error; - } - - if (parsed.clientPubkey && normalizeNostrPubkey(parsed.clientPubkey) !== clientPublicKey) { - const error = new Error("Handshake public key mismatch detected."); - error.code = "client-public-key-mismatch"; - throw error; - } - - if (!secret) { - secret = generateNip46Secret(); - } - - handleStatus({ - phase: "handshake", - state: "waiting", - message: "Waiting for the signer to acknowledge the connection…", - relays, - }); - - this.emitRemoteSignerChange({ - state: "connecting", - relays, - metadata, - remotePubkey: parsed.remotePubkey || "", - userPubkey: parsed.userPubkeyHint || "", - message: "Waiting for the signer to acknowledge the connection.", - }); - - let handshakeResult; - try { - handshakeResult = await this.waitForRemoteSignerHandshake({ - clientPrivateKey, - clientPublicKey, - relays, - secret, - onAuthUrl: (url, context) => handleAuthChallenge(url, context), - onStatus: handleStatus, - timeoutMs: handshakeTimeoutMs, - expectedRemotePubkey: parsed.remotePubkey, - }); - devLogger.debug("[nostr] Remote signer handshake completed", { - remotePubkey: summarizeHexForLog(handshakeResult?.remotePubkey || parsed.remotePubkey || ""), - algorithm: handshakeResult?.algorithm || null, - relays, - secret: summarizeSecretForLog(secret), - }); - } catch (error) { - this.emitRemoteSignerChange({ - state: "error", - relays, - metadata, - message: error?.message || "Remote signer handshake failed.", - error, - }); - throw error; - } - - remotePubkey = normalizeNostrPubkey(handshakeResult?.remotePubkey); - handshakeAlgorithm = normalizeNip46EncryptionAlgorithm( - handshakeResult?.algorithm, - ); - if (!remotePubkey) { - const error = new Error("Remote signer did not return a valid public key."); - error.code = "missing-remote-pubkey"; - throw error; - } - devLogger.debug("[nostr] Remote signer handshake provided final pubkey", { - remotePubkey: summarizeHexForLog(remotePubkey), - algorithm: handshakeAlgorithm || null, - clientPublicKey: summarizeHexForLog(clientPublicKey), - }); - } else { - const keyPair = await this.createNip46KeyPair( - providedClientPrivateKey, - providedClientPublicKey, - ); - clientPrivateKey = keyPair.privateKey; - clientPublicKey = keyPair.publicKey; - - this.emitRemoteSignerChange({ - state: "connecting", - remotePubkey, - relays, - metadata, - userPubkey: parsed.userPubkeyHint || "", - }); - devLogger.debug("[nostr] Using bunker URI for remote signer connect", { - remotePubkey: summarizeHexForLog(remotePubkey || parsed.remotePubkey || ""), - relays, - metadataKeys: summarizeMetadataForLog(metadata), - }); - } - - if (!remotePubkey) { - remotePubkey = normalizeNostrPubkey(parsed.remotePubkey); - } - - if (!remotePubkey) { - const error = new Error("Remote signer pubkey is required to establish the session."); - error.code = "missing-remote-pubkey"; - throw error; - } - - const client = new Nip46RpcClient({ - nostrClient: this, - clientPrivateKey, - clientPublicKey, - remotePubkey, - relays, - secret, - permissions, - metadata, - encryption: handshakeAlgorithm, - signEvent: (event, privateKey) => signEventWithPrivateKey(event, privateKey), - }); - - devLogger.debug("[nostr] Remote signer RPC client created", { - clientPublicKey: summarizeHexForLog(client.clientPublicKey), - remotePubkey: summarizeHexForLog(remotePubkey), - relays, - permissions, - secret: summarizeSecretForLog(secret), - metadataKeys: summarizeMetadataForLog(metadata), - handshakeAlgorithm, - }); - - try { - await client.ensureSubscription(); - - devLogger.debug("[nostr] Remote signer subscription ready", { - remotePubkey: summarizeHexForLog(remotePubkey), - relayCount: relays.length, - }); - - handleStatus({ - phase: "connect", - state: "request", - message: "Requesting approval from the remote signer…", - remotePubkey, - }); - - let attempts = 0; - // Attempt the connect RPC, handling auth challenges when provided. - for (;;) { - try { - devLogger.debug("[nostr] Sending NIP-46 connect request", { - remotePubkey: summarizeHexForLog(remotePubkey), - attempt: attempts + 1, - permissions: permissions || null, - }); - await client.connect({ permissions }); - break; - } catch (error) { - devLogger.warn("[nostr] Connect RPC attempt failed", { - remotePubkey: summarizeHexForLog(remotePubkey), - attempt: attempts + 1, - code: error?.code || null, - message: error?.message || String(error), - }); - if (error?.code === "auth-challenge" && error.authUrl) { - attempts += 1; - handleStatus({ - phase: "auth", - state: "waiting", - message: "Complete the authentication challenge in your signer…", - remotePubkey, - attempt: attempts, - }); - await handleAuthChallenge(error.authUrl, { - phase: "connect", - remotePubkey, - attempt: attempts, - }); - - if (attempts >= NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS) { - throw error; - } - continue; - } - throw error; - } - } - - const userPubkey = await client.getUserPubkey(); - devLogger.debug("[nostr] Retrieved user pubkey from remote signer", { - remotePubkey: summarizeHexForLog(remotePubkey), - userPubkey: summarizeHexForLog(userPubkey), - }); - - if (typeof options?.validator === "function") { - try { - const isValid = await options.validator(userPubkey); - if (!isValid) { - throw new Error("Access denied."); - } - } catch (validationError) { - const message = - validationError instanceof Error - ? validationError.message - : "Access denied."; - const error = new Error(message); - error.code = "access-denied"; - client.destroy(); // Cleanup client since we won't use it - throw error; - } - } - - client.metadata = metadata; - const signer = await this.installNip46Client(client, { userPubkey }); - - if (remember) { - devLogger.debug("[nostr] Persisting remote signer session", { - remotePubkey: summarizeHexForLog(remotePubkey), - relays, - encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", - permissions: permissions || null, - }); - await writeStoredNip46Session( - { - version: 1, - clientPublicKey, - remotePubkey, - relays, - encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", - permissions, - metadata, - userPubkey, - lastConnectedAt: Date.now(), - clientPrivateKey, - secret, - }, - passphrase, - ); - } else { - devLogger.debug( - "[nostr] Clearing stored remote signer session per request", - ); - clearStoredNip46Session(); - } - - this.emitRemoteSignerChange({ - state: "connected", - remotePubkey, - userPubkey, - relays, - metadata, - }); - - handleStatus({ - phase: "connected", - state: "ready", - message: "Remote signer connected successfully.", - remotePubkey, - userPubkey, - }); - - devLogger.debug("[nostr] Remote signer connection established", { - remotePubkey: summarizeHexForLog(remotePubkey), - userPubkey: summarizeHexForLog(userPubkey), - relays, - permissions: permissions || null, - secret: summarizeSecretForLog(secret), - }); - - return { pubkey: userPubkey, signer }; - } catch (error) { - devLogger.error("[nostr] Remote signer connection failed", { - remotePubkey: summarizeHexForLog(remotePubkey), - relays, - permissions: permissions || null, - secret: summarizeSecretForLog(secret), - message: error?.message || String(error), - code: error?.code || null, - }); - client.destroy(); - this.nip46Client = null; - if (!remember) { - clearStoredNip46Session(); - } - this.emitRemoteSignerChange({ - state: "error", - remotePubkey, - relays, - metadata, - message: error?.message || "Remote signer connection failed.", - error, - }); - throw error; - } + async connectRemoteSigner(options = {}) { + const { client, pubkey } = await this.nip46Connector.connect(options); + const signer = await this.installNip46Client(client, { userPubkey: pubkey }); + return { pubkey, signer }; } - /** - * Reconnects to a previously persisted NIP-46 remote signer session. - * - * **Usage:** - * Called during `init()` to restore the session without user interaction. - * It reads credentials from localStorage (`bitvid:nip46:session`), validates them, - * and re-establishes the RPC subscription. - * - * @param {object} [options] - * @param {boolean} [options.silent=false] - Suppress error logs if restore fails. - * @param {boolean} [options.forgetOnError=false] - Auto-delete session if invalid. - * @returns {Promise<{pubkey: string, signer: object}>} - */ async useStoredRemoteSigner(options = {}) { - const normalizedOptions = - options && typeof options === "object" ? options : {}; - const silent = normalizedOptions.silent === true; - const forgetOnError = normalizedOptions.forgetOnError === true; - const passphrase = - typeof normalizedOptions.passphrase === "string" - ? normalizedOptions.passphrase - : null; - const validator = - typeof normalizedOptions.validator === "function" - ? normalizedOptions.validator - : null; - - let stored = readStoredNip46Session(); - if (!stored) { - const error = new Error( - "No remote signer session is stored on this device.", - ); - error.code = "no-stored-session"; - throw error; - } - - if (stored.encryptedSecrets) { - if (!passphrase) { - const error = new Error("Passphrase required to unlock session."); - error.code = "passphrase-required"; - throw error; - } - try { - stored = await decryptNip46Session(stored, passphrase); - } catch (decryptError) { - const error = new Error("Failed to decrypt stored session."); - error.code = "decrypt-failed"; - error.cause = decryptError; - throw error; - } - } - - const relays = resolveNip46Relays(stored.relays, this.relays); - - devLogger.debug("[nostr] Reconnecting to stored remote signer", { - remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), - relays, - encryption: stored.encryption || "", - permissions: stored.permissions || null, - hasCredentials: Boolean(stored.clientPrivateKey && stored.secret), - }); - - this.emitRemoteSignerChange({ - state: "connecting", - remotePubkey: stored.remotePubkey, - relays, - metadata: stored.metadata, - }); - - if (!stored.clientPrivateKey || !stored.secret) { - const error = new Error( - "Stored remote signer credentials are unavailable.", - ); - error.code = "stored-session-missing-credentials"; - throw error; - } - - const client = new Nip46RpcClient({ - nostrClient: this, - clientPrivateKey: stored.clientPrivateKey, - clientPublicKey: stored.clientPublicKey, - remotePubkey: stored.remotePubkey, - relays, - encryption: stored.encryption, - secret: stored.secret, - permissions: stored.permissions, - metadata: stored.metadata, - signEvent: (event, privateKey) => signEventWithPrivateKey(event, privateKey), - }); - - try { - await client.ensureSubscription(); - await client.connect({ permissions: stored.permissions }); - const userPubkey = await client.getUserPubkey(); - - if (validator) { - try { - const isValid = await validator(userPubkey); - if (!isValid) { - throw new Error("Access denied."); - } - } catch (validationError) { - const message = - validationError instanceof Error - ? validationError.message - : "Access denied."; - const error = new Error(message); - error.code = "access-denied"; - client.destroy(); - throw error; - } - } - - client.metadata = stored.metadata; - const signer = await this.installNip46Client(client, { userPubkey }); - - await writeStoredNip46Session( - { - version: 1, - clientPublicKey: stored.clientPublicKey, - remotePubkey: stored.remotePubkey, - relays: stored.relays, - encryption: client.encryptionAlgorithm || stored.encryption || "", - permissions: stored.permissions, - metadata: stored.metadata, - userPubkey, - lastConnectedAt: Date.now(), - clientPrivateKey: stored.clientPrivateKey, - secret: stored.secret, - }, - passphrase, - ); - - devLogger.debug("[nostr] Stored remote signer session refreshed", { - remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), - userPubkey: summarizeHexForLog(userPubkey), - encryption: client.encryptionAlgorithm || stored.encryption || "", - }); - - this.emitRemoteSignerChange({ - state: "connected", - remotePubkey: stored.remotePubkey, - userPubkey, - relays, - metadata: stored.metadata, - }); - - return { pubkey: userPubkey, signer }; - } catch (error) { - await client.destroy().catch(() => {}); - this.nip46Client = null; - const fatalCodes = new Set([ - "nip46-secret-mismatch", - "invalid-private-key", - "invalid-connection-string", - ]); - const shouldForgetStored = forgetOnError || fatalCodes.has(error?.code); - if (shouldForgetStored) { - clearStoredNip46Session(); - devLogger.warn("[nostr] Stored remote signer session cleared after failure", { - remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), - error: error?.message || String(error), - code: error?.code || null, - }); - } - - const status = { - state: shouldForgetStored ? "idle" : silent ? "stored" : "error", - remotePubkey: stored.remotePubkey, - relays, - metadata: stored.metadata, - }; - - if (!silent || shouldForgetStored) { - status.message = - error?.message || "Failed to reconnect to the remote signer."; - } - status.error = error; - - this.emitRemoteSignerChange(status); - - if (silent) { - devLogger.log("[nostr] Silent remote signer restore failed:", error); - } else { - devLogger.warn( - "[nostr] Stored remote signer reconnection failed:", - error, - ); - } - throw error; - } + const { client, pubkey } = await this.nip46Connector.reconnectStored(options); + const signer = await this.installNip46Client(client, { userPubkey: pubkey }); + return { pubkey, signer }; } scheduleStoredRemoteSignerRestore() { @@ -2852,23 +1845,9 @@ export class NostrClient { return attempt; } - /** - * Terminates the NIP-46 remote signer connection. - * - * @param {object} [options] - * @param {boolean} [options.keepStored=true] - If false, wipes the session from localStorage. - */ async disconnectRemoteSigner({ keepStored = true } = {}) { this.pendingRemoteSignerRestore = null; - - if (this.pendingHandshakeCancel) { - try { - this.pendingHandshakeCancel(); - } catch (error) { - devLogger.warn("[nostr] Failed to cancel pending handshake:", error); - } - this.pendingHandshakeCancel = null; - } + await this.nip46Connector.disconnect({ keepStored }); if (this.nip46Client) { try { @@ -2884,10 +1863,6 @@ export class NostrClient { logoutSigner(activeSigner.pubkey); } - if (!keepStored) { - clearStoredNip46Session(); - } - const stored = keepStored ? this.getStoredNip46Metadata() : { hasSession: false }; this.emitRemoteSignerChange({ state: stored.hasSession ? "stored" : "idle", @@ -7071,52 +6046,12 @@ export class NostrClient { const stableDTag = existingD || baseEvent?.id || originalEvent?.id || null; - let oldContent = {}; - try { - oldContent = JSON.parse(baseEvent.content || "{}"); - } catch (err) { - devLogger.warn("[nostr] Failed to parse baseEvent.content while reverting:", err); - oldContent = {}; - } - const oldVersion = oldContent.version ?? 1; - - const finalRootId = - oldContent.videoRootId || - (existingD - ? `LEGACY:${baseEvent.pubkey}:${existingD}` - : stableDTag || baseEvent?.id || originalEvent?.id || ""); - - const oldIsNsfw = oldContent.isNsfw === true; - const oldIsForKids = oldContent.isForKids === true && !oldIsNsfw; - - const contentObject = { - videoRootId: finalRootId, - version: oldVersion, - deleted: true, - isPrivate: oldContent.isPrivate ?? false, - isNsfw: oldIsNsfw, - isForKids: oldIsForKids, - title: oldContent.title || "", - url: "", - magnet: "", - thumbnail: "", - description: "This version was reverted by the creator.", - mode: oldContent.mode || "live", - }; - - const storagePointer = resolveStoragePointerValue({ - storagePointer: getStoragePointerFromTags(baseEvent.tags), - url: oldContent.url, - infoHash: oldContent.infoHash, - fallbackId: finalRootId, - }); - const additionalTags = storagePointer ? [["s", storagePointer]] : []; - const event = buildVideoPostEvent({ + const event = buildRevertVideoPayload({ + baseEvent, + originalEventId: originalEvent?.id, pubkey, - created_at: Math.floor(Date.now() / 1000), - dTagValue: stableDTag, - content: contentObject, - additionalTags, + existingD, + stableDTag, }); await this.ensureActiveSignerForPubkey(pubkey); @@ -8064,85 +6999,28 @@ export class NostrClient { * @deprecated Use `subscribeVideos` for the main feed to support buffering and streaming. */ async fetchVideos(options = {}) { - const requestedLimit = Number(options?.limit); - const resolvedLimit = this.clampVideoRequestLimit(requestedLimit, DEFAULT_VIDEO_REQUEST_LIMIT); - - const filter = { - kinds: [30078], - "#t": ["video"], - limit: resolvedLimit, - since: 0, - }; - - const localAll = new Map(); - // NEW: track invalid - const invalidNotes = []; - - try { - await Promise.all( - this.getHealthyRelays(this.relays).map(async (url) => { - const events = await this.pool.list([url], [filter]); - for (const evt of events) { - if (evt && evt.id) { - this.rawEvents.set(evt.id, evt); - } - const vid = convertEventToVideo(evt); - if (vid.invalid) { - // Accumulate if invalid - invalidNotes.push({ id: vid.id, reason: vid.reason }); - } else { - // Only add if good - this.applyRootCreatedAt(vid); - const activeKey = getActiveKey(vid); - if (vid.deleted) { - this.recordTombstone(activeKey, vid.created_at); - } else { - this.applyTombstoneGuard(vid); - } - localAll.set(evt.id, vid); - } - } - }) - ); - - // Merge into allEvents - for (const [id, vid] of localAll.entries()) { - this.allEvents.set(id, vid); - this.dirtyEventIds.add(id); - this.applyRootCreatedAt(vid); + return new Promise((resolve) => { + // Default to since: 0 to force full fetch if not specified + const effectiveOptions = { ...options }; + if (effectiveOptions.since === undefined) { + effectiveOptions.since = 0; } - // Rebuild activeMap - this.activeMap.clear(); - for (const [id, video] of this.allEvents.entries()) { - if (video.deleted) continue; - const activeKey = getActiveKey(video); - const existing = this.activeMap.get(activeKey); + const sub = this.subscribeVideos(() => {}, effectiveOptions); - if (!existing || video.created_at > existing.created_at) { - this.activeMap.set(activeKey, video); - this.applyRootCreatedAt(video); + sub.on("eose", async () => { + sub.unsub(); + try { + const activeVideos = this.getActiveVideos(); + await this.populateNip71MetadataForVideos(activeVideos); + activeVideos.forEach((video) => this.applyRootCreatedAt(video)); + resolve(activeVideos); + } catch (err) { + userLogger.error("fetchVideos error:", err); + resolve([]); } - } - - // OPTIONAL: Log invalid stats - if (invalidNotes.length > 0 && isDevMode) { - userLogger.warn( - `Skipped ${invalidNotes.length} invalid video notes:\n`, - invalidNotes.map((n) => `${n.id.slice(0, 8)}.. => ${n.reason}`) - ); - } - - const activeVideos = Array.from(this.activeMap.values()).sort( - (a, b) => b.created_at - a.created_at - ); - await this.populateNip71MetadataForVideos(activeVideos); - activeVideos.forEach((video) => this.applyRootCreatedAt(video)); - return activeVideos; - } catch (err) { - userLogger.error("fetchVideos error:", err); - return []; - } + }); + }); } /** diff --git a/js/nostr/nip46Connector.js b/js/nostr/nip46Connector.js new file mode 100644 index 00000000..158a572a --- /dev/null +++ b/js/nostr/nip46Connector.js @@ -0,0 +1,1093 @@ +// js/nostr/nip46Connector.js + +import { isDevMode } from "../config.js"; +import { HEX64_REGEX } from "../utils/hex.js"; +import { devLogger, userLogger } from "../utils/logger.js"; +import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js"; +import { signEventWithPrivateKey } from "./publishHelpers.js"; +import { bytesToHex } from "../../vendor/crypto-helpers.bundle.min.js"; +import { + normalizeNostrPubkey, + encodeHexToNpub, + generateNip46Secret, + sanitizeNip46Metadata, + resolveNip46Relays, + normalizeNip46EncryptionAlgorithm, + parseNip46ConnectionString, + attemptDecryptNip46HandshakePayload, + readStoredNip46Session, + writeStoredNip46Session, + clearStoredNip46Session, + decryptNip46Session, + NIP46_RPC_KIND, + NIP46_HANDSHAKE_TIMEOUT_MS, + NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS, + Nip46RpcClient, +} from "./nip46Client.js"; +import { + summarizeHexForLog, + summarizeSecretForLog, + summarizeMetadataForLog, + summarizeUrlForLog, +} from "./nip46LoggingUtils.js"; + +const DEFAULT_DEPS = { + ensureNostrTools, + getCachedNostrTools, + signEventWithPrivateKey, + bytesToHex, + normalizeNostrPubkey, + encodeHexToNpub, + generateNip46Secret, + sanitizeNip46Metadata, + resolveNip46Relays, + normalizeNip46EncryptionAlgorithm, + parseNip46ConnectionString, + attemptDecryptNip46HandshakePayload, + readStoredNip46Session, + writeStoredNip46Session, + clearStoredNip46Session, + decryptNip46Session, + Nip46RpcClient, + NIP46_RPC_KIND, + NIP46_HANDSHAKE_TIMEOUT_MS, + NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS, +}; + +/** + * Manages the connection lifecycle for NIP-46 Remote Signers. + * Encapsulates handshake, connection establishment, and reconnection logic. + */ +export class Nip46Connector { + /** + * @param {import("./client.js").NostrClient} nostrClient + * @param {object} [deps] - Optional dependencies for testing + */ + constructor(nostrClient, deps = {}) { + this.nostrClient = nostrClient; + this.deps = { ...DEFAULT_DEPS, ...deps }; + this.pendingHandshakeCancel = null; + this.pendingRemoteSignerRestore = null; + } + + /** + * Creates a new key pair for the NIP-46 client session. + * If existing keys are provided, they are validated and returned. + * + * @param {string} [existingPrivateKey] + * @param {string} [existingPublicKey] + * @returns {Promise<{privateKey: string, publicKey: string}>} + */ + async createKeyPair(existingPrivateKey = "", existingPublicKey = "") { + let privateKey = + typeof existingPrivateKey === "string" && existingPrivateKey.trim() + ? existingPrivateKey.trim().toLowerCase() + : ""; + + if (privateKey && !HEX64_REGEX.test(privateKey)) { + const error = new Error("Invalid remote signer private key."); + error.code = "invalid-private-key"; + throw error; + } + + if (!privateKey) { + const tools = (await this.deps.ensureNostrTools()) || this.deps.getCachedNostrTools(); + if (!tools) { + throw new Error("Unable to generate a remote signer key pair."); + } + + let generated = null; + if (typeof tools.generateSecretKey === "function") { + generated = tools.generateSecretKey(); + } + + if (generated instanceof Uint8Array) { + privateKey = this.deps.bytesToHex(generated); + } else if (Array.isArray(generated)) { + privateKey = this.deps.bytesToHex(Uint8Array.from(generated)); + } else if (typeof generated === "string") { + privateKey = generated.trim().toLowerCase(); + } + + if (!privateKey || !HEX64_REGEX.test(privateKey)) { + throw new Error("Generated remote signer key is invalid."); + } + + privateKey = privateKey.toLowerCase(); + } + + let publicKey = + typeof existingPublicKey === "string" && existingPublicKey.trim() + ? existingPublicKey.trim().toLowerCase() + : ""; + + if (!publicKey) { + const tools = (await this.deps.ensureNostrTools()) || this.deps.getCachedNostrTools(); + if (!tools || typeof tools.getPublicKey !== "function") { + throw new Error("Public key derivation is unavailable for remote signing."); + } + publicKey = tools.getPublicKey(privateKey); + } + + if (!publicKey || !HEX64_REGEX.test(publicKey)) { + throw new Error("Derived remote signer public key is invalid."); + } + + return { privateKey, publicKey: publicKey.toLowerCase() }; + } + + /** + * Prepares the parameters for a NIP-46 handshake (generation of connection URI). + * + * @param {object} params + * @param {object} [params.metadata] + * @param {string[]} [params.relays] + * @param {string} [params.secret] + * @param {string} [params.permissions] + * @returns {Promise} + */ + async prepareHandshake({ metadata, relays, secret, permissions } = {}) { + const keyPair = await this.createKeyPair(); + const sanitizedMetadata = this.deps.sanitizeNip46Metadata(metadata); + const requestedPermissions = + typeof permissions === "string" && permissions.trim() ? permissions.trim() : ""; + + const resolvedRelays = this.deps.resolveNip46Relays(relays, this.nostrClient.relays); + const handshakeSecret = + typeof secret === "string" && secret.trim() ? secret.trim() : this.deps.generateNip46Secret(); + + devLogger.debug("[nostr] Preparing remote signer handshake", { + clientPublicKey: summarizeHexForLog(keyPair.publicKey), + relays: resolvedRelays, + permissions: requestedPermissions || null, + secret: summarizeSecretForLog(handshakeSecret), + metadataKeys: summarizeMetadataForLog(sanitizedMetadata), + }); + + const params = []; + for (const relay of resolvedRelays) { + params.push(`relay=${encodeURIComponent(relay)}`); + } + if (handshakeSecret) { + params.push(`secret=${encodeURIComponent(handshakeSecret)}`); + } + if (requestedPermissions) { + params.push(`perms=${encodeURIComponent(requestedPermissions)}`); + } + for (const [key, value] of Object.entries(sanitizedMetadata)) { + params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + + const query = params.length ? `?${params.join("&")}` : ""; + const uri = `nostrconnect://${keyPair.publicKey}${query}`; + + devLogger.debug("[nostr] Prepared nostrconnect URI", { + uri, + relayCount: resolvedRelays.length, + metadataKeys: summarizeMetadataForLog(sanitizedMetadata), + }); + + return { + type: "client", + connectionString: uri, + uri, + clientPrivateKey: keyPair.privateKey, + clientPublicKey: keyPair.publicKey, + relays: resolvedRelays, + secret: handshakeSecret, + permissions: requestedPermissions, + metadata: sanitizedMetadata, + }; + } + + /** + * Waits for a remote signer to acknowledge the handshake. + * + * @param {object} params + * @returns {Promise<{remotePubkey: string, eventPubkey: string, response: object, algorithm: string}>} + */ + async waitForHandshake({ + clientPrivateKey, + clientPublicKey, + relays, + secret, + onAuthUrl, + onStatus, + timeoutMs, + expectedRemotePubkey, + } = {}) { + const normalizedClientPublicKey = this.deps.normalizeNostrPubkey(clientPublicKey); + if (!normalizedClientPublicKey) { + throw new Error("A client public key is required to await the remote signer handshake."); + } + + if (!clientPrivateKey || typeof clientPrivateKey !== "string" || !HEX64_REGEX.test(clientPrivateKey)) { + throw new Error("A client private key is required to await the remote signer handshake."); + } + + const resolvedRelays = this.deps.resolveNip46Relays(relays, this.nostrClient.relays); + if (!resolvedRelays.length) { + throw new Error("No relays available to complete the remote signer handshake."); + } + + devLogger.debug("[nostr] Waiting for remote signer handshake", { + clientPubkey: summarizeHexForLog(normalizedClientPublicKey), + relays: resolvedRelays, + secret: summarizeSecretForLog(secret), + timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, + expectedRemotePubkey: summarizeHexForLog(expectedRemotePubkey || ""), + }); + + const pool = await this.nostrClient.ensurePool(); + const filters = [ + { + kinds: [this.deps.NIP46_RPC_KIND], + "#p": [normalizedClientPublicKey], + }, + ]; + + const waitTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : this.deps.NIP46_HANDSHAKE_TIMEOUT_MS; + + const coerceStructuredString = (value) => { + if (typeof value === "string") { + return value.trim(); + } + if (Array.isArray(value)) { + for (const entry of value) { + const candidate = coerceStructuredString(entry); + if (candidate) { + return candidate; + } + } + return ""; + } + if (value && typeof value === "object") { + const preferredKeys = [ + "secret", + "message", + "status", + "reason", + "detail", + "description", + "value", + "result", + "url", + ]; + for (const key of preferredKeys) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + const candidate = coerceStructuredString(value[key]); + if (candidate) { + return candidate; + } + } + } + for (const entry of Object.values(value)) { + const candidate = coerceStructuredString(entry); + if (candidate) { + return candidate; + } + } + } + return ""; + }; + + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + if (settled) { + return; + } + settled = true; + if (this.pendingHandshakeCancel === cancelHandshake) { + this.pendingHandshakeCancel = null; + } + try { + subscription?.unsub?.(); + } catch (error) { + // ignore subscription cleanup failures + } + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + + const cancelHandshake = () => { + cleanup(); + const error = new Error("Remote signer connection cancelled."); + error.code = "login-cancelled"; + reject(error); + }; + + this.pendingHandshakeCancel = cancelHandshake; + + const timeoutId = setTimeout(() => { + cleanup(); + const error = new Error("Timed out waiting for the remote signer to acknowledge the connection."); + error.code = "nip46-handshake-timeout"; + devLogger.warn("[nostr] Handshake wait timed out", { + clientPubkey: summarizeHexForLog(normalizedClientPublicKey), + relays: resolvedRelays, + }); + reject(error); + }, waitTimeout); + + let subscription; + try { + subscription = pool.sub(resolvedRelays, filters); + devLogger.debug("[nostr] Handshake subscription established", { + relays: resolvedRelays, + filters, + }); + } catch (error) { + cleanup(); + devLogger.warn("[nostr] Failed to subscribe for handshake responses", error); + reject(error); + return; + } + + subscription.on("event", (event) => { + if (settled) { + return; + } + + if (!event || event.kind !== this.deps.NIP46_RPC_KIND) { + devLogger.debug("[nostr] Ignoring non-handshake event during wait", event); + return; + } + + const eventRemotePubkey = this.deps.normalizeNostrPubkey(event.pubkey); + const candidateRemotePubkeys = []; + if (expectedRemotePubkey) { + candidateRemotePubkeys.push(expectedRemotePubkey); + } + if (eventRemotePubkey) { + candidateRemotePubkeys.push(eventRemotePubkey); + } + + devLogger.debug("[nostr] Processing handshake event", { + eventId: typeof event.id === "string" ? event.id : "", + eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), + candidateRemotePubkeys: candidateRemotePubkeys.map((key) => + summarizeHexForLog(key), + ), + contentLength: typeof event.content === "string" ? event.content.length : 0, + }); + + Promise.resolve() + .then(() => + this.deps.attemptDecryptNip46HandshakePayload({ + clientPrivateKey, + candidateRemotePubkeys, + ciphertext: event.content, + }), + ) + .then((payloadResult) => { + const plaintext = payloadResult?.plaintext ?? ""; + let parsed; + try { + parsed = JSON.parse(plaintext); + } catch (error) { + devLogger.warn("[nostr] Failed to parse remote signer handshake payload:", error); + return; + } + + devLogger.debug("[nostr] Handshake payload parsed", { + remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || ""), + eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), + algorithm: payloadResult?.algorithm || null, + requestId: typeof parsed?.id === "string" ? parsed.id : "", + hasResult: parsed?.result !== undefined, + hasError: parsed?.error !== undefined, + }); + + const resultValue = coerceStructuredString(parsed?.result); + const errorValue = coerceStructuredString(parsed?.error); + + if (resultValue === "auth_url" && errorValue) { + const handshakeRemotePubkey = payloadResult?.remotePubkey || eventRemotePubkey || ""; + devLogger.debug("[nostr] Handshake provided auth_url challenge", { + eventId: typeof parsed?.id === "string" ? parsed.id : "", + remotePubkey: summarizeHexForLog(handshakeRemotePubkey), + url: summarizeUrlForLog(errorValue), + }); + if (typeof onAuthUrl === "function") { + try { + onAuthUrl(errorValue, { + phase: "handshake", + remotePubkey: handshakeRemotePubkey, + requestId: typeof parsed?.id === "string" ? parsed.id : "", + }); + } catch (callbackError) { + devLogger.warn("[nostr] Handshake auth_url callback threw:", callbackError); + } + } + return; + } + + if (secret) { + const normalizedResult = resultValue ? resultValue.toLowerCase() : ""; + if (resultValue !== secret && normalizedResult !== "ack") { + return; + } + } + + if (!secret && resultValue) { + const normalized = resultValue.toLowerCase(); + if (!["ack", "ok", "success"].includes(normalized)) { + return; + } + } + + cleanup(); + + if (typeof onStatus === "function") { + try { + onStatus({ + phase: "handshake", + state: "acknowledged", + message: "Remote signer acknowledged the connect request.", + remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "", + }); + } catch (callbackError) { + devLogger.warn("[nostr] Handshake status callback threw:", callbackError); + } + } + + resolve({ + remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "", + eventPubkey: eventRemotePubkey || "", + response: parsed, + algorithm: this.deps.normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm), + }); + + devLogger.debug("[nostr] Handshake wait resolved", { + remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || eventRemotePubkey || ""), + eventPubkey: summarizeHexForLog(eventRemotePubkey || ""), + result: resultValue || null, + hasSecret: Boolean(secret), + }); + }) + .catch((error) => { + devLogger.warn("[nostr] Failed to decrypt remote signer handshake payload:", error); + }); + }); + + subscription.on("eose", () => { + // no-op: handshake responses are push-based + }); + }); + } + + /** + * Establishes a NIP-46 connection. + * + * @param {object} params + * @returns {Promise<{client: Nip46RpcClient, signer: object, userPubkey: string}>} + */ + async connect({ + connectionString, + remember = true, + clientPrivateKey: providedClientPrivateKey = "", + clientPublicKey: providedClientPublicKey = "", + relays: providedRelays = [], + secret: providedSecret = "", + permissions: providedPermissions = "", + metadata: providedMetadata = {}, + onAuthUrl, + onStatus, + handshakeTimeoutMs, + passphrase, + validator, + } = {}) { + const parsed = this.deps.parseNip46ConnectionString(connectionString); + if (!parsed) { + const error = new Error( + "Unsupported NIP-46 URI. Provide a nostrconnect:// handshake or bunker:// pointer.", + ); + error.code = "invalid-connection-string"; + throw error; + } + + const baseMetadata = this.deps.sanitizeNip46Metadata(parsed.metadata); + const overrideMetadata = this.deps.sanitizeNip46Metadata(providedMetadata); + const metadata = { ...baseMetadata, ...overrideMetadata }; + + devLogger.debug("[nostr] Connecting to remote signer", { + connectionType: parsed.type, + parsedRemotePubkey: summarizeHexForLog(parsed.remotePubkey || ""), + providedClientPublicKey: summarizeHexForLog(providedClientPublicKey), + providedClientPrivateKey: summarizeSecretForLog( + typeof providedClientPrivateKey === "string" ? providedClientPrivateKey : "", + ), + providedSecret: summarizeSecretForLog(providedSecret), + providedPermissions: providedPermissions || null, + handshakeTimeoutMs, + parsedRelays: parsed.relays, + overrideRelayCount: Array.isArray(providedRelays) ? providedRelays.length : 0, + metadataKeys: summarizeMetadataForLog(metadata), + }); + + const handleStatus = (status) => { + if (typeof onStatus !== "function") { + return; + } + try { + onStatus(status); + } catch (error) { + devLogger.warn("[nostr] Remote signer status callback threw:", error); + } + }; + + const handleAuthChallenge = async (url, context = {}) => { + if (typeof onAuthUrl !== "function" || !url) { + return; + } + try { + devLogger.debug("[nostr] Remote signer auth challenge surfaced", { + url: summarizeUrlForLog(url), + context, + }); + const result = onAuthUrl(url, context); + if (result && typeof result.then === "function") { + await result.catch((error) => { + devLogger.warn("[nostr] Auth challenge callback promise rejected:", error); + }); + } + } catch (error) { + devLogger.warn("[nostr] Remote signer auth callback threw:", error); + } + }; + + const mergedRelaysSource = parsed.relays.length ? parsed.relays : providedRelays; + const relays = this.deps.resolveNip46Relays(mergedRelaysSource, this.nostrClient.relays); + + let secret = typeof providedSecret === "string" && providedSecret.trim() ? providedSecret.trim() : parsed.secret; + let permissions = + typeof providedPermissions === "string" && providedPermissions.trim() + ? providedPermissions.trim() + : parsed.permissions; + + let clientPrivateKey = ""; + let clientPublicKey = ""; + let remotePubkey = this.deps.normalizeNostrPubkey(parsed.remotePubkey); + let handshakeAlgorithm = ""; + + if (parsed.type === "client") { + clientPrivateKey = + typeof providedClientPrivateKey === "string" && providedClientPrivateKey.trim() + ? providedClientPrivateKey.trim().toLowerCase() + : ""; + + if (!clientPrivateKey || !HEX64_REGEX.test(clientPrivateKey)) { + const error = new Error( + "Remote signer handshake requires the generated client private key.", + ); + error.code = "missing-client-private-key"; + throw error; + } + + clientPublicKey = this.deps.normalizeNostrPubkey(providedClientPublicKey) || parsed.clientPubkey; + if (!clientPublicKey) { + const tools = (await this.deps.ensureNostrTools()) || this.deps.getCachedNostrTools(); + if (!tools || typeof tools.getPublicKey !== "function") { + throw new Error("Public key derivation is unavailable for the remote signer handshake."); + } + clientPublicKey = this.deps.normalizeNostrPubkey(tools.getPublicKey(clientPrivateKey)); + } + + if (!clientPublicKey || !HEX64_REGEX.test(clientPublicKey)) { + const error = new Error("Invalid client public key for the remote signer handshake."); + error.code = "invalid-client-public-key"; + throw error; + } + + if (parsed.clientPubkey && this.deps.normalizeNostrPubkey(parsed.clientPubkey) !== clientPublicKey) { + const error = new Error("Handshake public key mismatch detected."); + error.code = "client-public-key-mismatch"; + throw error; + } + + if (!secret) { + secret = this.deps.generateNip46Secret(); + } + + handleStatus({ + phase: "handshake", + state: "waiting", + message: "Waiting for the signer to acknowledge the connection…", + relays, + }); + + this.nostrClient.emitRemoteSignerChange({ + state: "connecting", + relays, + metadata, + remotePubkey: parsed.remotePubkey || "", + userPubkey: parsed.userPubkeyHint || "", + message: "Waiting for the signer to acknowledge the connection.", + }); + + let handshakeResult; + try { + handshakeResult = await this.waitForHandshake({ + clientPrivateKey, + clientPublicKey, + relays, + secret, + onAuthUrl: (url, context) => handleAuthChallenge(url, context), + onStatus: handleStatus, + timeoutMs: handshakeTimeoutMs, + expectedRemotePubkey: parsed.remotePubkey, + }); + devLogger.debug("[nostr] Remote signer handshake completed", { + remotePubkey: summarizeHexForLog(handshakeResult?.remotePubkey || parsed.remotePubkey || ""), + algorithm: handshakeResult?.algorithm || null, + relays, + secret: summarizeSecretForLog(secret), + }); + } catch (error) { + this.nostrClient.emitRemoteSignerChange({ + state: "error", + relays, + metadata, + message: error?.message || "Remote signer handshake failed.", + error, + }); + throw error; + } + + remotePubkey = this.deps.normalizeNostrPubkey(handshakeResult?.remotePubkey); + handshakeAlgorithm = this.deps.normalizeNip46EncryptionAlgorithm( + handshakeResult?.algorithm, + ); + if (!remotePubkey) { + const error = new Error("Remote signer did not return a valid public key."); + error.code = "missing-remote-pubkey"; + throw error; + } + devLogger.debug("[nostr] Remote signer handshake provided final pubkey", { + remotePubkey: summarizeHexForLog(remotePubkey), + algorithm: handshakeAlgorithm || null, + clientPublicKey: summarizeHexForLog(clientPublicKey), + }); + } else { + const keyPair = await this.createKeyPair( + providedClientPrivateKey, + providedClientPublicKey, + ); + clientPrivateKey = keyPair.privateKey; + clientPublicKey = keyPair.publicKey; + + this.nostrClient.emitRemoteSignerChange({ + state: "connecting", + remotePubkey, + relays, + metadata, + userPubkey: parsed.userPubkeyHint || "", + }); + devLogger.debug("[nostr] Using bunker URI for remote signer connect", { + remotePubkey: summarizeHexForLog(remotePubkey || parsed.remotePubkey || ""), + relays, + metadataKeys: summarizeMetadataForLog(metadata), + }); + } + + if (!remotePubkey) { + remotePubkey = this.deps.normalizeNostrPubkey(parsed.remotePubkey); + } + + if (!remotePubkey) { + const error = new Error("Remote signer pubkey is required to establish the session."); + error.code = "missing-remote-pubkey"; + throw error; + } + + const client = new this.deps.Nip46RpcClient({ + nostrClient: this.nostrClient, + clientPrivateKey, + clientPublicKey, + remotePubkey, + relays, + secret, + permissions, + metadata, + encryption: handshakeAlgorithm, + signEvent: (event, privateKey) => this.deps.signEventWithPrivateKey(event, privateKey), + }); + + devLogger.debug("[nostr] Remote signer RPC client created", { + clientPublicKey: summarizeHexForLog(client.clientPublicKey), + remotePubkey: summarizeHexForLog(remotePubkey), + relays, + permissions, + secret: summarizeSecretForLog(secret), + metadataKeys: summarizeMetadataForLog(metadata), + handshakeAlgorithm, + }); + + try { + await client.ensureSubscription(); + + devLogger.debug("[nostr] Remote signer subscription ready", { + remotePubkey: summarizeHexForLog(remotePubkey), + relayCount: relays.length, + }); + + handleStatus({ + phase: "connect", + state: "request", + message: "Requesting approval from the remote signer…", + remotePubkey, + }); + + let attempts = 0; + // Attempt the connect RPC, handling auth challenges when provided. + for (;;) { + try { + devLogger.debug("[nostr] Sending NIP-46 connect request", { + remotePubkey: summarizeHexForLog(remotePubkey), + attempt: attempts + 1, + permissions: permissions || null, + }); + await client.connect({ permissions }); + break; + } catch (error) { + devLogger.warn("[nostr] Connect RPC attempt failed", { + remotePubkey: summarizeHexForLog(remotePubkey), + attempt: attempts + 1, + code: error?.code || null, + message: error?.message || String(error), + }); + if (error?.code === "auth-challenge" && error.authUrl) { + attempts += 1; + handleStatus({ + phase: "auth", + state: "waiting", + message: "Complete the authentication challenge in your signer…", + remotePubkey, + attempt: attempts, + }); + await handleAuthChallenge(error.authUrl, { + phase: "connect", + remotePubkey, + attempt: attempts, + }); + + if (attempts >= this.deps.NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS) { + throw error; + } + continue; + } + throw error; + } + } + + const userPubkey = await client.getUserPubkey(); + devLogger.debug("[nostr] Retrieved user pubkey from remote signer", { + remotePubkey: summarizeHexForLog(remotePubkey), + userPubkey: summarizeHexForLog(userPubkey), + }); + + if (typeof validator === "function") { + try { + const isValid = await validator(userPubkey); + if (!isValid) { + throw new Error("Access denied."); + } + } catch (validationError) { + const message = + validationError instanceof Error + ? validationError.message + : "Access denied."; + const error = new Error(message); + error.code = "access-denied"; + client.destroy(); // Cleanup client since we won't use it + throw error; + } + } + + client.metadata = metadata; + + if (remember) { + devLogger.debug("[nostr] Persisting remote signer session", { + remotePubkey: summarizeHexForLog(remotePubkey), + relays, + encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", + permissions: permissions || null, + }); + await this.deps.writeStoredNip46Session( + { + version: 1, + clientPublicKey, + remotePubkey, + relays, + encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", + permissions, + metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey, + secret, + }, + passphrase, + ); + } else { + devLogger.debug( + "[nostr] Clearing stored remote signer session per request", + ); + this.deps.clearStoredNip46Session(); + } + + this.nostrClient.emitRemoteSignerChange({ + state: "connected", + remotePubkey, + userPubkey, + relays, + metadata, + }); + + handleStatus({ + phase: "connected", + state: "ready", + message: "Remote signer connected successfully.", + remotePubkey, + userPubkey, + }); + + devLogger.debug("[nostr] Remote signer connection established", { + remotePubkey: summarizeHexForLog(remotePubkey), + userPubkey: summarizeHexForLog(userPubkey), + relays, + permissions: permissions || null, + secret: summarizeSecretForLog(secret), + }); + + return { pubkey: userPubkey, client }; + } catch (error) { + devLogger.error("[nostr] Remote signer connection failed", { + remotePubkey: summarizeHexForLog(remotePubkey), + relays, + permissions: permissions || null, + secret: summarizeSecretForLog(secret), + message: error?.message || String(error), + code: error?.code || null, + }); + client.destroy(); + if (!remember) { + this.deps.clearStoredNip46Session(); + } + this.nostrClient.emitRemoteSignerChange({ + state: "error", + remotePubkey, + relays, + metadata, + message: error?.message || "Remote signer connection failed.", + error, + }); + throw error; + } + } + + /** + * Reconnects to a stored session. + * + * @param {object} [options] + * @returns {Promise<{pubkey: string, client: Nip46RpcClient}>} + */ + async reconnectStored(options = {}) { + const normalizedOptions = + options && typeof options === "object" ? options : {}; + const silent = normalizedOptions.silent === true; + const forgetOnError = normalizedOptions.forgetOnError === true; + const passphrase = + typeof normalizedOptions.passphrase === "string" + ? normalizedOptions.passphrase + : null; + const validator = + typeof normalizedOptions.validator === "function" + ? normalizedOptions.validator + : null; + + let stored = this.deps.readStoredNip46Session(); + if (!stored) { + const error = new Error( + "No remote signer session is stored on this device.", + ); + error.code = "no-stored-session"; + throw error; + } + + if (stored.encryptedSecrets) { + if (!passphrase) { + const error = new Error("Passphrase required to unlock session."); + error.code = "passphrase-required"; + throw error; + } + try { + stored = await this.deps.decryptNip46Session(stored, passphrase); + } catch (decryptError) { + const error = new Error("Failed to decrypt stored session."); + error.code = "decrypt-failed"; + error.cause = decryptError; + throw error; + } + } + + const relays = this.deps.resolveNip46Relays(stored.relays, this.nostrClient.relays); + + devLogger.debug("[nostr] Reconnecting to stored remote signer", { + remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), + relays, + encryption: stored.encryption || "", + permissions: stored.permissions || null, + hasCredentials: Boolean(stored.clientPrivateKey && stored.secret), + }); + + this.nostrClient.emitRemoteSignerChange({ + state: "connecting", + remotePubkey: stored.remotePubkey, + relays, + metadata: stored.metadata, + }); + + if (!stored.clientPrivateKey || !stored.secret) { + const error = new Error( + "Stored remote signer credentials are unavailable.", + ); + error.code = "stored-session-missing-credentials"; + throw error; + } + + const client = new this.deps.Nip46RpcClient({ + nostrClient: this.nostrClient, + clientPrivateKey: stored.clientPrivateKey, + clientPublicKey: stored.clientPublicKey, + remotePubkey: stored.remotePubkey, + relays, + encryption: stored.encryption, + secret: stored.secret, + permissions: stored.permissions, + metadata: stored.metadata, + signEvent: (event, privateKey) => this.deps.signEventWithPrivateKey(event, privateKey), + }); + + try { + await client.ensureSubscription(); + await client.connect({ permissions: stored.permissions }); + const userPubkey = await client.getUserPubkey(); + + if (validator) { + try { + const isValid = await validator(userPubkey); + if (!isValid) { + throw new Error("Access denied."); + } + } catch (validationError) { + const message = + validationError instanceof Error + ? validationError.message + : "Access denied."; + const error = new Error(message); + error.code = "access-denied"; + client.destroy(); + throw error; + } + } + + client.metadata = stored.metadata; + + await this.deps.writeStoredNip46Session( + { + version: 1, + clientPublicKey: stored.clientPublicKey, + remotePubkey: stored.remotePubkey, + relays: stored.relays, + encryption: client.encryptionAlgorithm || stored.encryption || "", + permissions: stored.permissions, + metadata: stored.metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey: stored.clientPrivateKey, + secret: stored.secret, + }, + passphrase, + ); + + devLogger.debug("[nostr] Stored remote signer session refreshed", { + remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), + userPubkey: summarizeHexForLog(userPubkey), + encryption: client.encryptionAlgorithm || stored.encryption || "", + }); + + this.nostrClient.emitRemoteSignerChange({ + state: "connected", + remotePubkey: stored.remotePubkey, + userPubkey, + relays, + metadata: stored.metadata, + }); + + return { pubkey: userPubkey, client }; + } catch (error) { + await client.destroy().catch(() => {}); + const fatalCodes = new Set([ + "nip46-secret-mismatch", + "invalid-private-key", + "invalid-connection-string", + ]); + const shouldForgetStored = forgetOnError || fatalCodes.has(error?.code); + if (shouldForgetStored) { + this.deps.clearStoredNip46Session(); + devLogger.warn("[nostr] Stored remote signer session cleared after failure", { + remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), + error: error?.message || String(error), + code: error?.code || null, + }); + } + + const status = { + state: shouldForgetStored ? "idle" : silent ? "stored" : "error", + remotePubkey: stored.remotePubkey, + relays, + metadata: stored.metadata, + }; + + if (!silent || shouldForgetStored) { + status.message = + error?.message || "Failed to reconnect to the remote signer."; + } + status.error = error; + + this.nostrClient.emitRemoteSignerChange(status); + + if (silent) { + devLogger.log("[nostr] Silent remote signer restore failed:", error); + } else { + devLogger.warn( + "[nostr] Stored remote signer reconnection failed:", + error, + ); + } + throw error; + } + } + + async abort() { + if (this.pendingHandshakeCancel) { + try { + this.pendingHandshakeCancel(); + } catch (error) { + devLogger.warn("[nostr] Failed to cancel pending handshake:", error); + } + this.pendingHandshakeCancel = null; + } + } + + async disconnect({ keepStored = true } = {}) { + await this.abort(); + + if (!keepStored) { + this.deps.clearStoredNip46Session(); + } + } +} diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index ffc1f782..3931e574 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -5,10 +5,15 @@ import { summarizePublishResults, } from "../nostrPublish.js"; import { + buildVideoPostEvent, buildRepostEvent, buildVideoMirrorEvent, sanitizeAdditionalTags, } from "../nostrEventSchemas.js"; +import { + getStoragePointerFromTags, + resolveStoragePointerValue, +} from "../utils/storagePointer.js"; import { normalizePointerInput } from "./watchHistory.js"; import { sanitizeRelayList } from "./nip46Client.js"; import { @@ -1332,4 +1337,64 @@ export async function rebroadcastEvent({ client, eventId, options = {} }) { } } +export function buildRevertVideoPayload({ + baseEvent, + originalEventId, + pubkey, + existingD, + stableDTag, +}) { + let oldContent = {}; + try { + oldContent = JSON.parse(baseEvent.content || "{}"); + } catch (err) { + devLogger.warn( + "[nostr] Failed to parse baseEvent.content while reverting:", + err, + ); + oldContent = {}; + } + const oldVersion = oldContent.version ?? 1; + + const finalRootId = + oldContent.videoRootId || + (existingD + ? `LEGACY:${baseEvent.pubkey}:${existingD}` + : stableDTag || baseEvent.id || originalEventId || ""); + + const oldIsNsfw = oldContent.isNsfw === true; + const oldIsForKids = oldContent.isForKids === true && !oldIsNsfw; + + const contentObject = { + videoRootId: finalRootId, + version: oldVersion, + deleted: true, + isPrivate: oldContent.isPrivate ?? false, + isNsfw: oldIsNsfw, + isForKids: oldIsForKids, + title: oldContent.title || "", + url: "", + magnet: "", + thumbnail: "", + description: "This version was reverted by the creator.", + mode: oldContent.mode || "live", + }; + + const storagePointer = resolveStoragePointerValue({ + storagePointer: getStoragePointerFromTags(baseEvent.tags), + url: oldContent.url, + infoHash: oldContent.infoHash, + fallbackId: finalRootId, + }); + const additionalTags = storagePointer ? [["s", storagePointer]] : []; + + return buildVideoPostEvent({ + pubkey, + created_at: Math.floor(Date.now() / 1000), + dTagValue: stableDTag, + content: contentObject, + additionalTags, + }); +} + export { summarizePublishResults }; diff --git a/js/services/authService.js b/js/services/authService.js index db27bf70..9ba8994c 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -29,6 +29,11 @@ import getDefaultAuthProvider, { } from "./authProviders/index.js"; import { fetchProfileMetadata } from "./profileMetadataService.js"; import { HEX64_REGEX } from "../utils/hex.js"; +import { + safeEncodeNpub, + safeDecodeNpub, + normalizeHexPubkey, +} from "../utils/nostrHelpers.js"; class SimpleEventEmitter { constructor(logger = null) { @@ -267,70 +272,15 @@ export default class AuthService { } safeEncodeNpub(pubkey) { - if (typeof pubkey !== "string") { - return null; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return null; - } - - if (trimmed.startsWith("npub1")) { - return trimmed; - } - - try { - return window?.NostrTools?.nip19?.npubEncode(trimmed) || null; - } catch (error) { - return null; - } + return safeEncodeNpub(pubkey); } safeDecodeNpub(npub) { - if (typeof npub !== "string") { - return null; - } - - const trimmed = npub.trim(); - if (!trimmed) { - return null; - } - - try { - const decoded = window?.NostrTools?.nip19?.decode(trimmed); - if (decoded?.type === "npub" && typeof decoded.data === "string") { - return decoded.data; - } - } catch (error) { - return null; - } - - return null; + return safeDecodeNpub(npub); } normalizeHexPubkey(pubkey) { - if (typeof pubkey !== "string") { - return null; - } - - const trimmed = pubkey.trim(); - if (!trimmed) { - return null; - } - - if (HEX64_REGEX.test(trimmed)) { - return trimmed.toLowerCase(); - } - - if (trimmed.startsWith("npub1")) { - const decoded = this.safeDecodeNpub(trimmed); - if (decoded && HEX64_REGEX.test(decoded)) { - return decoded.toLowerCase(); - } - } - - return null; + return normalizeHexPubkey(pubkey); } async requestLogin(rawOptions = {}) { @@ -1030,8 +980,63 @@ export default class AuthService { return { switched: true, detail }; } + async fetchProfileFromRelay( + relayUrl, + filter, + normalizedPubkey, + timeoutMs, + requireEvent, + ) { + const fetchPromise = this.nostrClient.pool.list([relayUrl], filter); + let timeoutId; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const timeoutError = new Error( + `Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms`, + ); + timeoutError.code = "timeout"; + timeoutError.relay = relayUrl; + timeoutError.timeoutMs = timeoutMs; + reject(timeoutError); + }, timeoutMs); + }); + + try { + const result = await Promise.race([fetchPromise, timeoutPromise]); + clearTimeout(timeoutId); + + const events = Array.isArray(result) + ? result.filter((event) => event && event.pubkey === normalizedPubkey) + : []; + + if (requireEvent && !events.length) { + const emptyError = new Error( + `No profile events returned from ${relayUrl}`, + ); + emptyError.code = "empty"; + emptyError.relay = relayUrl; + throw emptyError; + } + + return { relayUrl, events }; + } catch (error) { + clearTimeout(timeoutId); + // Suppress unhandled rejection from fetchPromise if it's still running + fetchPromise.catch(() => {}); + + if (error.relay) { + throw error; + } + const wrapped = error instanceof Error ? error : new Error(String(error)); + wrapped.relay = relayUrl; + throw wrapped; + } + } + async loadOwnProfile(pubkey) { - const normalized = this.normalizeHexPubkey(pubkey) || + const normalized = + this.normalizeHexPubkey(pubkey) || (typeof pubkey === "string" ? pubkey.trim() : ""); if (!normalized) { return FALLBACK_PROFILE; @@ -1074,13 +1079,20 @@ export default class AuthService { return { name: data.display_name || data.name || FALLBACK_PROFILE.name, picture: data.picture || FALLBACK_PROFILE.picture, - about: typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about, + about: + typeof data.about === "string" ? data.about : FALLBACK_PROFILE.about, website: - typeof data.website === "string" ? data.website : FALLBACK_PROFILE.website, + typeof data.website === "string" + ? data.website + : FALLBACK_PROFILE.website, banner: - typeof data.banner === "string" ? data.banner : FALLBACK_PROFILE.banner, - lud16: typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16, - lud06: typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06, + typeof data.banner === "string" + ? data.banner + : FALLBACK_PROFILE.banner, + lud16: + typeof data.lud16 === "string" ? data.lud16 : FALLBACK_PROFILE.lud16, + lud06: + typeof data.lud06 === "string" ? data.lud06 : FALLBACK_PROFILE.lud06, }; } catch (error) { this.log("[AuthService] Failed to parse profile metadata", error); @@ -1088,67 +1100,26 @@ export default class AuthService { } }; - const fetchFromRelay = (relayUrl, timeoutMs, requireEvent) => { - return new Promise((resolve, reject) => { - let settled = false; - const timer = setTimeout(() => { - if (settled) { - return; - } - settled = true; - const timeoutError = new Error( - `Timed out fetching profile from ${relayUrl} after ${timeoutMs}ms` - ); - timeoutError.code = "timeout"; - timeoutError.relay = relayUrl; - timeoutError.timeoutMs = timeoutMs; - reject(timeoutError); - }, timeoutMs); - - Promise.resolve() - .then(() => this.nostrClient.pool.list([relayUrl], filter)) - .then((result) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - const events = Array.isArray(result) - ? result.filter((event) => event && event.pubkey === normalized) - : []; - if (requireEvent && !events.length) { - const emptyError = new Error( - `No profile events returned from ${relayUrl}` - ); - emptyError.code = "empty"; - emptyError.relay = relayUrl; - reject(emptyError); - return; - } - resolve({ relayUrl, events }); - }) - .catch((error) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - const wrapped = - error instanceof Error ? error : new Error(String(error)); - wrapped.relay = relayUrl; - reject(wrapped); - }); - }); - }; - const fastRelays = relays.slice(0, FAST_PROFILE_RELAY_LIMIT); const backgroundRelays = relays.slice(fastRelays.length); const fastPromises = fastRelays.map((relayUrl) => - fetchFromRelay(relayUrl, FAST_PROFILE_TIMEOUT_MS, true) + this.fetchProfileFromRelay( + relayUrl, + filter, + normalized, + FAST_PROFILE_TIMEOUT_MS, + true, + ), ); const backgroundPromises = backgroundRelays.map((relayUrl) => - fetchFromRelay(relayUrl, BACKGROUND_PROFILE_TIMEOUT_MS, false) + this.fetchProfileFromRelay( + relayUrl, + filter, + normalized, + BACKGROUND_PROFILE_TIMEOUT_MS, + false, + ), ); const background = Promise.allSettled([ diff --git a/js/utils/nostrHelpers.js b/js/utils/nostrHelpers.js new file mode 100644 index 00000000..d4d4a301 --- /dev/null +++ b/js/utils/nostrHelpers.js @@ -0,0 +1,102 @@ +import { HEX64_REGEX } from "./hex.js"; + +/** + * Safely encode a hex pubkey to npub. + * + * @param {string} pubkey - The hex public key. + * @returns {string|null} The npub string or null if encoding failed. + */ +export function safeEncodeNpub(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith("npub1")) { + return trimmed; + } + + try { + if ( + typeof window !== "undefined" && + window.NostrTools && + window.NostrTools.nip19 && + typeof window.NostrTools.nip19.npubEncode === "function" + ) { + return window.NostrTools.nip19.npubEncode(trimmed); + } + } catch (err) { + // ignore + } + + return null; +} + +/** + * Safely decode an npub to hex pubkey. + * + * @param {string} npub - The npub string. + * @returns {string|null} The hex public key or null if decoding failed. + */ +export function safeDecodeNpub(npub) { + if (typeof npub !== "string") { + return null; + } + + const trimmed = npub.trim(); + if (!trimmed) { + return null; + } + + try { + if ( + typeof window !== "undefined" && + window.NostrTools && + window.NostrTools.nip19 && + typeof window.NostrTools.nip19.decode === "function" + ) { + const decoded = window.NostrTools.nip19.decode(trimmed); + if (decoded.type === "npub" && typeof decoded.data === "string") { + return decoded.data; + } + } + } catch (err) { + // ignore + } + + return null; +} + +/** + * Normalize a public key to hex format, handling npub inputs. + * + * @param {string} pubkey - The public key (hex or npub). + * @returns {string|null} The normalized hex public key or null. + */ +export function normalizeHexPubkey(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + if (trimmed.startsWith("npub1")) { + const decoded = safeDecodeNpub(trimmed); + if (decoded && HEX64_REGEX.test(decoded)) { + return decoded.toLowerCase(); + } + } + + return null; +} diff --git a/js/utils/profileMedia.js b/js/utils/profileMedia.js index 4de331ed..a496f326 100644 --- a/js/utils/profileMedia.js +++ b/js/utils/profileMedia.js @@ -33,7 +33,7 @@ export function sanitizeProfileMediaUrl(value) { return ""; } - trimmed = trimmed.replace(/^['"]+|['"]+$/g, ""); + trimmed = trimmed.replace(/^['"]+|['"]+$/g, "").trim(); if (!trimmed) { return ""; } @@ -50,7 +50,7 @@ export function sanitizeProfileMediaUrl(value) { return ""; } - if (/^ipfs:\/\//i.test(trimmed)) { + if (/^ipfs(\/|:\/\/)/i.test(trimmed)) { return normalizeIpfsGatewayUrl(trimmed); } diff --git a/js/utils/storagePointer.js b/js/utils/storagePointer.js index 15fd58a2..74540990 100644 --- a/js/utils/storagePointer.js +++ b/js/utils/storagePointer.js @@ -121,3 +121,37 @@ export function resolveInfoJsonUrl({ storagePointer, url } = {}) { return normalizeInfoJsonBase(prefix); } + +export function resolveStoragePointerValue({ + storagePointer, + url, + infoHash, + fallbackId, + provider, +} = {}) { + const normalized = normalizeStoragePointer(storagePointer); + if (normalized) { + return normalized; + } + + const derivedFromUrl = deriveStoragePointerFromUrl(url, provider || "url"); + if (derivedFromUrl) { + return derivedFromUrl; + } + + if (typeof infoHash === "string" && infoHash.trim()) { + return buildStoragePointerValue({ + provider: "btih", + prefix: infoHash.trim().toLowerCase(), + }); + } + + if (typeof fallbackId === "string" && fallbackId.trim()) { + return buildStoragePointerValue({ + provider: "nostr", + prefix: fallbackId.trim(), + }); + } + + return ""; +} diff --git a/tests/auth-service.test.mjs b/tests/auth-service.test.mjs index 1866a2d7..3fb3a89b 100644 --- a/tests/auth-service.test.mjs +++ b/tests/auth-service.test.mjs @@ -305,3 +305,98 @@ await (async () => { assert.ok(detail); assert.equal(detail.pubkey, SAMPLE_PUBKEY); })(); + +// === Tests for loadOwnProfile logic === + +await (async () => { + resetState(); + const PUBKEY = SAMPLE_PUBKEY; + + // Test Fast Relay Success + { + const mockNostrClient = { + relays: ["wss://fast1.com", "wss://fast2.com", "wss://slow.com"], + pool: { + list: async (relays) => { + const url = relays[0]; + if (url === "wss://fast1.com") { + return [{ + pubkey: PUBKEY, + created_at: 100, + content: JSON.stringify({ name: "Fast User" }) + }]; + } + return new Promise(() => {}); // Hang others + } + }, + handleEvent: () => {} + }; + + const service = new AuthService({ nostrClient: mockNostrClient }); + // Override setProfileCacheEntry to avoid side effects or errors from real implementation if not needed, + // but here we just let it run. However, the service needs nostrClient.pool + + // We need to make sure setProfileCacheEntry doesn't fail. + // It imports from cache.js which uses profileCache. + // profileCache is imported from state/profileCache.js. + + // The previous tests didn't seem to mock setProfileCacheEntry. + + const profile = await service.loadOwnProfile(PUBKEY); + assert.equal(profile.name, "Fast User"); + } + + // Test All Relays Fail + { + const mockNostrClient = { + relays: ["wss://fail1.com", "wss://fail2.com"], + pool: { + list: async () => { + throw new Error("Failed"); + } + }, + handleEvent: () => {} + }; + + const service = new AuthService({ nostrClient: mockNostrClient }); + const profile = await service.loadOwnProfile(PUBKEY); + assert.equal(profile.name, "Unknown"); + } + + // Test Timeout Fallback + { + let backgroundResolve; + const backgroundPromise = new Promise(r => backgroundResolve = r); + + const mockNostrClient = { + relays: ["wss://fast-fail.com", "wss://fast-fail2.com", "wss://background-ok.com", "wss://background-ok-2.com"], + pool: { + list: async (relays) => { + const url = relays[0]; + if (url.includes("fast")) { + // Simulate timeout or error + throw new Error("Fast fail"); + } + if (url.includes("background")) { + await backgroundPromise; + return [{ + pubkey: PUBKEY, + created_at: 200, + content: JSON.stringify({ name: "Background User" }) + }]; + } + return []; + } + }, + handleEvent: () => {} + }; + + const service = new AuthService({ nostrClient: mockNostrClient }); + + const profile = await service.loadOwnProfile(PUBKEY); + assert.equal(profile.name, "Unknown"); + + // We resolve background promise to let background logic finish eventually (though test finishes here) + backgroundResolve(); + } +})(); diff --git a/tests/feed-engine/kids-scorer.test.mjs b/tests/feed-engine/kids-scorer.test.mjs new file mode 100644 index 00000000..b39919e6 --- /dev/null +++ b/tests/feed-engine/kids-scorer.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createKidsScorerStage } from "../../js/feedEngine/kidsScoring.js"; + +test("kids scorer correctly identifies dominant positive component", async () => { + const stage = createKidsScorerStage({ + weights: { + w_age: 0.2, + w_edu: 0.2, + w_author: 0.2, + w_pop: 0.2, + w_fresh: 0.2, + w_risk: 0 + } + }); + + // Create an item where one component is clearly dominant + // Age appropriateness: 1.0 (perfect duration + tags) + // Others: 0 + const items = [ + { + video: { + id: "v1", + pubkey: "pk1", + duration: 300, // 5 min (perfect for toddler/preschool) + tags: [["t", "preschool"], ["t", "learning"]], // Matches preferred + created_at: Date.now() / 1000 - 10000000, // Old + viewCount: 0 + }, + metadata: {}, + }, + ]; + + const why = []; + const context = { + runtime: { + ageGroup: "preschool", + trustedAuthors: [] + }, + addWhy: (detail) => why.push(detail) + }; + + await stage(items, context); + + assert.equal(why.length, 1); + assert.equal(why[0].reason, "age-appropriateness"); + assert.ok(why[0].value > 0); +}); + +test("kids scorer picks educational-boost when dominant", async () => { + const stage = createKidsScorerStage({ + educationalTags: ["math"], + weights: { w_age: 0, w_edu: 1, w_author: 0, w_pop: 0, w_fresh: 0, w_risk: 0 } + }); + + const items = [{ + video: { + id: "v2", + tags: [["t", "math"]], + duration: 300 + }, + metadata: {} + }]; + + const why = []; + await stage(items, { + runtime: { educationalTags: ["math"] }, + addWhy: (d) => why.push(d) + }); + + assert.equal(why.length, 1); + assert.equal(why[0].reason, "educational-boost"); +}); diff --git a/tests/nostr/client.test.mjs b/tests/nostr/client.test.mjs index 6e15cccc..cebc2a33 100644 --- a/tests/nostr/client.test.mjs +++ b/tests/nostr/client.test.mjs @@ -165,6 +165,53 @@ describe("NostrClient", () => { }); }); + describe("fetchVideos", () => { + it("should delegate to subscribeVideos and return result on eose", async () => { + let subOpts = null; + // Override subscribeVideos to verify delegation + client.subscribeVideos = (cb, opts) => { + subOpts = opts; + return { + on: (evt, handler) => { + if (evt === "eose") setTimeout(handler, 0); + }, + unsub: () => {}, + }; + }; + + // Mock helpers + client.getActiveVideos = () => [{ id: "v1" }]; + client.populateNip71MetadataForVideos = async () => {}; + client.applyRootCreatedAt = () => {}; + + const result = await client.fetchVideos({ limit: 5 }); + assert.equal(result[0].id, "v1"); + assert.equal(subOpts.limit, 5); + // Ensure we force full fetch by default + assert.equal(subOpts.since, 0); + }); + + it("should respect since parameter if provided", async () => { + let subOpts = null; + client.subscribeVideos = (cb, opts) => { + subOpts = opts; + return { + on: (evt, handler) => { + if (evt === "eose") setTimeout(handler, 0); + }, + unsub: () => {}, + }; + }; + + client.getActiveVideos = () => []; + client.populateNip71MetadataForVideos = async () => {}; + client.applyRootCreatedAt = () => {}; + + await client.fetchVideos({ since: 12345 }); + assert.equal(subOpts.since, 12345); + }); + }); + describe("publishVideo", () => { it("should throw if not logged in", async () => { await assert.rejects( diff --git a/tests/nostr/nip46Connector.test.mjs b/tests/nostr/nip46Connector.test.mjs new file mode 100644 index 00000000..cdb4753a --- /dev/null +++ b/tests/nostr/nip46Connector.test.mjs @@ -0,0 +1,164 @@ + +import { describe, it, beforeEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import { Nip46Connector } from "../../js/nostr/nip46Connector.js"; + +const VALID_HEX_KEY = "0000000000000000000000000000000000000000000000000000000000000001"; +const VALID_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000002"; + +// Mock dependencies +const mockDeps = { + parseNip46ConnectionString: mock.fn(), + generateNip46Secret: mock.fn(), + readStoredNip46Session: mock.fn(), + writeStoredNip46Session: mock.fn(), + clearStoredNip46Session: mock.fn(), + normalizeNip46EncryptionAlgorithm: mock.fn(), + decryptNip46Session: mock.fn(), + attemptDecryptNip46HandshakePayload: mock.fn(), + sanitizeNip46Metadata: mock.fn((m) => m || {}), + resolveNip46Relays: mock.fn((r) => r || []), + normalizeNostrPubkey: mock.fn((k) => k), + encodeHexToNpub: mock.fn((k) => "npub" + k), + bytesToHex: mock.fn((b) => VALID_HEX_KEY), + signEventWithPrivateKey: mock.fn(), + Nip46RpcClient: class MockRpcClient { + constructor() { + this.destroy = mock.fn(); + this.ensureSubscription = mock.fn(() => Promise.resolve()); + this.connect = mock.fn(() => Promise.resolve()); + this.getUserPubkey = mock.fn(() => Promise.resolve(VALID_PUBKEY)); + this.metadata = {}; + } + }, + ensureNostrTools: mock.fn(async () => ({ + generateSecretKey: () => new Uint8Array(32), + getPublicKey: () => VALID_PUBKEY, + })), + getCachedNostrTools: mock.fn(() => ({ + generateSecretKey: () => new Uint8Array(32), + getPublicKey: () => VALID_PUBKEY, + })), + NIP46_RPC_KIND: 24133, + NIP46_HANDSHAKE_TIMEOUT_MS: 100, + NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS: 3, +}; + +describe("Nip46Connector", () => { + let connector; + let mockNostrClient; + + beforeEach(() => { + // Reset mocks + Object.values(mockDeps).forEach((m) => { + if (m.mock) m.mock.resetCalls(); + }); + + mockNostrClient = { + pool: { + publish: mock.fn(() => Promise.resolve()), + sub: mock.fn(() => ({ + on: mock.fn(), + unsub: mock.fn(), + })), + }, + ensurePool: mock.fn(async () => mockNostrClient.pool), + connectToRelays: mock.fn(async () => []), + emitRemoteSignerChange: mock.fn(), + relays: ["wss://relay.example.com"], + }; + + connector = new Nip46Connector(mockNostrClient, mockDeps); + }); + + describe("createKeyPair", () => { + it("should generate a new key pair if none provided", async () => { + mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "mock-secret"); + + const result = await connector.createKeyPair(); + + assert.deepEqual(result, { + privateKey: VALID_HEX_KEY, + publicKey: VALID_PUBKEY, + }); + }); + + it("should use existing keys if provided", async () => { + const result = await connector.createKeyPair( + VALID_HEX_KEY, + VALID_PUBKEY + ); + assert.deepEqual(result, { + privateKey: VALID_HEX_KEY, + publicKey: VALID_PUBKEY, + }); + }); + }); + + describe("prepareHandshake", () => { + it("should parse connection string and return details", async () => { + mockDeps.generateNip46Secret.mock.mockImplementationOnce(() => "handshake-secret"); + + const result = await connector.prepareHandshake({ + connectionString: "bunker://...", + }); + + assert.equal(result.clientPrivateKey, VALID_HEX_KEY); + assert.equal(result.secret, "handshake-secret"); + assert.match(result.connectionString, /^nostrconnect:\/\//); + }); + }); + + describe("connect", () => { + it("should connect directly if remote pubkey is known", async () => { + const options = { + connectionString: "bunker://remote-pub?relay=wss://relay.com", + clientPrivateKey: VALID_HEX_KEY, + }; + + mockDeps.parseNip46ConnectionString.mock.mockImplementationOnce(() => ({ + relays: ["wss://relay.com"], + remotePubkey: VALID_PUBKEY, + type: "bunker", + })); + mockDeps.normalizeNip46EncryptionAlgorithm.mock.mockImplementationOnce(() => "nip44"); + mockDeps.normalizeNostrPubkey.mock.mockImplementation((k) => k); + + const result = await connector.connect(options); + + assert.equal(result.pubkey, VALID_PUBKEY); + assert.ok(result.client instanceof mockDeps.Nip46RpcClient); + assert.equal(mockDeps.writeStoredNip46Session.mock.callCount(), 1); + }); + }); + + describe("reconnectStored", () => { + it("should throw if no stored session", async () => { + mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => null); + await assert.rejects(connector.reconnectStored(), /No remote signer session is stored/); + }); + + it("should reconnect using stored session", async () => { + mockDeps.readStoredNip46Session.mock.mockImplementationOnce(() => ({ + userPubkey: "user-pub", + remotePubkey: "remote-pub", + relays: [], + clientPrivateKey: "client-priv", + secret: "secret", + })); + mockDeps.decryptNip46Session.mock.mockImplementationOnce((s) => s); + + const result = await connector.reconnectStored(); + + assert.equal(result.pubkey, VALID_PUBKEY); + assert.ok(result.client instanceof mockDeps.Nip46RpcClient); + }); + }); + + describe("disconnect", () => { + it("should clear stored session and emit change", async () => { + await connector.disconnect({ keepStored: false }); + assert.equal(mockDeps.clearStoredNip46Session.mock.callCount(), 1); + }); + }); +}); diff --git a/tests/utils/hex.test.mjs b/tests/utils/hex.test.mjs new file mode 100644 index 00000000..0d6fe98f --- /dev/null +++ b/tests/utils/hex.test.mjs @@ -0,0 +1,63 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + normalizeHexString, + normalizeHexId, + normalizeHexPubkey, + HEX64_REGEX, +} from "../../js/utils/hex.js"; + +describe("hex utils", () => { + describe("normalizeHexString", () => { + it("should return empty string for non-string inputs", () => { + assert.equal(normalizeHexString(undefined), ""); + assert.equal(normalizeHexString(null), ""); + assert.equal(normalizeHexString(123), ""); + assert.equal(normalizeHexString({}), ""); + }); + + it("should return empty string for empty or whitespace-only strings", () => { + assert.equal(normalizeHexString(""), ""); + assert.equal(normalizeHexString(" "), ""); + }); + + it("should trim and lowercase valid hex strings", () => { + assert.equal(normalizeHexString("ABC"), "abc"); + assert.equal(normalizeHexString(" DEF "), "def"); + assert.equal(normalizeHexString("AbCdEf"), "abcdef"); + }); + }); + + describe("aliases", () => { + it("should export normalizeHexId as an alias", () => { + assert.equal(normalizeHexId, normalizeHexString); + }); + + it("should export normalizeHexPubkey as an alias", () => { + assert.equal(normalizeHexPubkey, normalizeHexString); + }); + }); + + describe("HEX64_REGEX", () => { + it("should match valid 64-character hex strings", () => { + const validHex = "a".repeat(64); + assert.match(validHex, HEX64_REGEX); + const mixedCase = "A".repeat(32) + "b".repeat(32); + assert.match(mixedCase, HEX64_REGEX); + }); + + it("should not match strings with incorrect length", () => { + assert.doesNotMatch("a".repeat(63), HEX64_REGEX); + assert.doesNotMatch("a".repeat(65), HEX64_REGEX); + }); + + it("should not match strings with non-hex characters", () => { + const invalidChar = "a".repeat(63) + "g"; + assert.doesNotMatch(invalidChar, HEX64_REGEX); + }); + + it("should not match empty strings", () => { + assert.doesNotMatch("", HEX64_REGEX); + }); + }); +}); diff --git a/tests/utils/profileMedia.test.mjs b/tests/utils/profileMedia.test.mjs new file mode 100644 index 00000000..c85c3343 --- /dev/null +++ b/tests/utils/profileMedia.test.mjs @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { sanitizeProfileMediaUrl } from "../../js/utils/profileMedia.js"; + +test("sanitizeProfileMediaUrl handles non-string inputs", () => { + assert.equal(sanitizeProfileMediaUrl(null), ""); + assert.equal(sanitizeProfileMediaUrl(undefined), ""); + assert.equal(sanitizeProfileMediaUrl(123), ""); + assert.equal(sanitizeProfileMediaUrl({}), ""); +}); + +test("sanitizeProfileMediaUrl handles empty or whitespace-only strings", () => { + assert.equal(sanitizeProfileMediaUrl(""), ""); + assert.equal(sanitizeProfileMediaUrl(" "), ""); + assert.equal(sanitizeProfileMediaUrl("\t\n"), ""); +}); + +test("sanitizeProfileMediaUrl trims whitespace and removes quotes", () => { + assert.equal(sanitizeProfileMediaUrl(" http://example.com "), "https://example.com"); + assert.equal(sanitizeProfileMediaUrl("'http://example.com'"), "https://example.com"); + assert.equal(sanitizeProfileMediaUrl('"http://example.com"'), "https://example.com"); + assert.equal(sanitizeProfileMediaUrl(" ' http://example.com ' "), "https://example.com"); +}); + +test("sanitizeProfileMediaUrl allows data:image/ URLs", () => { + const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + assert.equal(sanitizeProfileMediaUrl(dataUrl), dataUrl); +}); + +test("sanitizeProfileMediaUrl allows blob: URLs", () => { + const blobUrl = "blob:http://example.com/550e8400-e29b-41d4-a716-446655440000"; + assert.equal(sanitizeProfileMediaUrl(blobUrl), blobUrl); +}); + +test("sanitizeProfileMediaUrl rejects specific placeholder images", () => { + assert.equal(sanitizeProfileMediaUrl("images/robot.png"), ""); +}); + +test("sanitizeProfileMediaUrl normalizes IPFS URLs", () => { + assert.equal(sanitizeProfileMediaUrl("ipfs://CID"), "https://ipfs.io/ipfs/CID"); + assert.equal(sanitizeProfileMediaUrl("ipfs/CID"), "https://ipfs.io/ipfs/CID"); +}); + +test("sanitizeProfileMediaUrl handles protocol-relative URLs", () => { + assert.equal(sanitizeProfileMediaUrl("//example.com/image.jpg"), "https://example.com/image.jpg"); +}); + +test("sanitizeProfileMediaUrl allows relative paths", () => { + assert.equal(sanitizeProfileMediaUrl("/assets/image.jpg"), "/assets/image.jpg"); + assert.equal(sanitizeProfileMediaUrl("./image.jpg"), "./image.jpg"); + assert.equal(sanitizeProfileMediaUrl("../image.jpg"), "../image.jpg"); + assert.equal(sanitizeProfileMediaUrl("assets/image.jpg"), "assets/image.jpg"); +}); + +test("sanitizeProfileMediaUrl adds protocol to domains and localhost", () => { + assert.equal(sanitizeProfileMediaUrl("localhost:8080/image.jpg"), "http://localhost:8080/image.jpg"); + assert.equal(sanitizeProfileMediaUrl("example.com/image.jpg"), "https://example.com/image.jpg"); + assert.equal(sanitizeProfileMediaUrl("sub.example.com/image.jpg"), "https://sub.example.com/image.jpg"); + // IP address pattern in code: (?:\d{1,3}\.){3}\d{1,3} + assert.equal(sanitizeProfileMediaUrl("127.0.0.1:8080/image.jpg"), "http://127.0.0.1:8080/image.jpg"); +}); + +test("sanitizeProfileMediaUrl coerces http to https except for localhost", () => { + assert.equal(sanitizeProfileMediaUrl("http://example.com/image.jpg"), "https://example.com/image.jpg"); + assert.equal(sanitizeProfileMediaUrl("http://localhost:8080/image.jpg"), "http://localhost:8080/image.jpg"); + assert.equal(sanitizeProfileMediaUrl("http://127.0.0.1:8080/image.jpg"), "http://127.0.0.1:8080/image.jpg"); +}); + +test("sanitizeProfileMediaUrl rejects unsupported patterns", () => { + // The code logs a warning and returns "" for things that don't match localhost or domain patterns + // if they don't start with http/https + assert.equal(sanitizeProfileMediaUrl("javascript:alert(1)"), ""); + assert.equal(sanitizeProfileMediaUrl("ftp://example.com/file"), ""); // Starts with ftp, not http/https/ipfs +}); diff --git a/tests/validate-config.test.mjs b/tests/validate-config.test.mjs index 630bb580..a974578b 100644 --- a/tests/validate-config.test.mjs +++ b/tests/validate-config.test.mjs @@ -3,13 +3,22 @@ import { test } from "node:test"; import { validateInstanceConfig } from "../config/validate-config.js"; +// Common valid overrides to use as a base +const VALID_OVERRIDES = { + ADMIN_SUPER_NPUB: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + PLATFORM_FEE_PERCENT: 5, + PLATFORM_LUD16_OVERRIDE: "test@example.com", +}; + +test("validateInstanceConfig succeeds with valid configuration", () => { + assert.doesNotThrow(() => validateInstanceConfig(VALID_OVERRIDES)); +}); + test( "validateInstanceConfig throws when platform fee is positive without a platform override", () => { const overrides = { - ADMIN_SUPER_NPUB: - "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", - PLATFORM_FEE_PERCENT: 5, + ...VALID_OVERRIDES, PLATFORM_LUD16_OVERRIDE: " ", }; @@ -19,3 +28,108 @@ test( }); } ); + +test("validateInstanceConfig throws when ADMIN_SUPER_NPUB is empty", () => { + const overrides = { + ...VALID_OVERRIDES, + ADMIN_SUPER_NPUB: " ", + }; + + assert.throws(() => validateInstanceConfig(overrides), { + message: /ADMIN_SUPER_NPUB is required/, + }); +}); + +test("validateInstanceConfig throws when ADMIN_SUPER_NPUB does not start with npub", () => { + const overrides = { + ...VALID_OVERRIDES, + ADMIN_SUPER_NPUB: "invalid_npub", + }; + + assert.throws(() => validateInstanceConfig(overrides), { + message: /ADMIN_SUPER_NPUB should be a bech32 npub/, + }); +}); + +test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is negative", () => { + const overrides = { + ...VALID_OVERRIDES, + PLATFORM_FEE_PERCENT: -1, + }; + + assert.throws(() => validateInstanceConfig(overrides), { + message: /PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive/, + }); +}); + +test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is greater than 100", () => { + const overrides = { + ...VALID_OVERRIDES, + PLATFORM_FEE_PERCENT: 101, + }; + + assert.throws(() => validateInstanceConfig(overrides), { + message: /PLATFORM_FEE_PERCENT must be between 0 and 100 inclusive/, + }); +}); + +test("validateInstanceConfig throws when PLATFORM_FEE_PERCENT is not finite", () => { + const overrides = { + ...VALID_OVERRIDES, + PLATFORM_FEE_PERCENT: Infinity, + }; + + assert.throws(() => validateInstanceConfig(overrides), { + message: /PLATFORM_FEE_PERCENT must be a finite number/, + }); +}); + +test("validateInstanceConfig throws when THEME_ACCENT_OVERRIDES has invalid hex color", () => { + const overrides = { + ...VALID_OVERRIDES, + THEME_ACCENT_OVERRIDES: { + light: { + accent: "invalid-hex", + }, + }, + }; + + assert.throws(() => validateInstanceConfig(overrides), { + message: /must be a #RRGGBB hex color/, + }); +}); + +test("validateInstanceConfig throws when THEME_ACCENT_OVERRIDES structure is invalid", () => { + const overrides = { + ...VALID_OVERRIDES, + THEME_ACCENT_OVERRIDES: { + light: "not-an-object", + }, + }; + + assert.throws(() => validateInstanceConfig(overrides), { + message: /must be an object with accent overrides/, + }); +}); + +test("validateInstanceConfig throws when optional URL is invalid", () => { + const overrides = { + ...VALID_OVERRIDES, + BLOG_URL: "not-a-valid-url", + }; + + assert.throws(() => validateInstanceConfig(overrides), { + message: /BLOG_URL must be a valid http\(s\) URL/, + }); +}); + +test("validateInstanceConfig throws when optional URL has invalid protocol", () => { + const overrides = { + ...VALID_OVERRIDES, + BLOG_URL: "ftp://example.com", + }; + + assert.throws(() => validateInstanceConfig(overrides), { + message: /BLOG_URL must be a valid http\(s\) URL/, + }); +});