From ad83c9f8db6ae703e3f77bf17fd772392488c59e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:49:42 -0400 Subject: [PATCH 01/48] [ui] route all native alert()/confirm() through the site notification system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every browser alert()/confirm() now uses the app's UI instead of native dialogs (no prompt() existed). Two new pieces: - js/ui/confirmDialog.js — showConfirm(message, opts) -> Promise, a styled `bv-modal modal-always-on-top` dialog (Confirm/Cancel/backdrop/Escape; danger variant focuses Cancel). Importable anywhere, so future confirmations use it too. It stacks above other modals and is typable thanks to the recent stacked-modal focus fix. - js/ui/appNotify.js — notifyError/notifyStatus/notifySuccess routing to the app's NotificationController, for modules without a local showError; re-exports showConfirm. Migrated: UploadModal (10 alert -> this.showError, 1 confirm), subscriptionHistory (3 alert -> notify*, 1 confirm), MyVideosController (2), ProfileStorage/Wallet/Relay controllers, profileModalController (2), settingsRestorePrompt (default confirm now showConfirm), nostr/client deleteAllVersions. confirmSyncOverwrite helpers made async (syncConflict already awaits confirmOverwrite). Tests: tests/confirm-dialog.test.mjs (6 — confirm/cancel/backdrop/Escape resolution, DOM cleanup, danger focus, no-DOM fallback). Build + lint clean; affected suites green. (Note: build/lint don't parse JS — caught a multi-line-import split + a wrong import path via module-import checks.) Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 8 +- js/services/settingsRestorePrompt.js | 8 +- js/ui/appNotify.js | 34 ++++++ js/ui/components/UploadModal.js | 23 ++-- js/ui/confirmDialog.js | 108 ++++++++++++++++++ js/ui/profileModal/MyVideosController.js | 27 +++-- js/ui/profileModal/ProfileRelayController.js | 10 +- .../profileModal/ProfileStorageController.js | 9 +- js/ui/profileModal/ProfileWalletController.js | 23 ++-- js/ui/profileModalController.js | 14 ++- js/ui/subscriptionHistoryController.js | 16 +-- tests/confirm-dialog.test.mjs | 75 ++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 19 +++ 13 files changed, 309 insertions(+), 65 deletions(-) create mode 100644 js/ui/appNotify.js create mode 100644 js/ui/confirmDialog.js create mode 100644 tests/confirm-dialog.test.mjs diff --git a/js/nostr/client.js b/js/nostr/client.js index 4faf16e0..053639da 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -18,6 +18,7 @@ */ import { isDevMode } from "../config.js"; +import { showConfirm } from "../ui/confirmDialog.js"; import { infoHashFromMagnet } from "../magnets.js"; // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable import { @@ -3465,9 +3466,10 @@ export class NostrClient { options && typeof options.video === "object" ? options.video : null; let confirmed = true; - if (shouldConfirm && typeof window?.confirm === "function") { - confirmed = window.confirm( - "Are you sure you want to delete all versions of this video? This action cannot be undone." + if (shouldConfirm) { + confirmed = await showConfirm( + "Are you sure you want to delete all versions of this video? This action cannot be undone.", + { confirmLabel: "Delete all", danger: true }, ); } diff --git a/js/services/settingsRestorePrompt.js b/js/services/settingsRestorePrompt.js index f1072211..306715d4 100644 --- a/js/services/settingsRestorePrompt.js +++ b/js/services/settingsRestorePrompt.js @@ -6,6 +6,7 @@ // if the user accepts. import { storageSyncService as defaultStorageSync } from "./storageSyncService.js"; +import { showConfirm } from "../ui/confirmDialog.js"; const OFFERED_KEY = "bitvid:settings-sync:offered:v1"; @@ -63,10 +64,7 @@ const ITEM_LABELS = { export function createSettingsRestorePrompt({ storageSync = defaultStorageSync, walletSync = null, - confirm = (message) => - typeof window !== "undefined" && typeof window.confirm === "function" - ? window.confirm(message) - : false, + confirm = (message) => showConfirm(message, { confirmLabel: "Restore" }), logger = null, } = {}) { // Items not already enabled / not already offered on THIS device that have a @@ -117,7 +115,7 @@ export function createSettingsRestorePrompt({ } const list = candidates.map((kind) => ITEM_LABELS[kind]).join(" and "); - const accepted = confirm( + const accepted = await confirm( `Found an encrypted copy of your ${list} on your Nostr account. ` + `Restore ${candidates.length > 1 ? "them" : "it"} to this device?` ); diff --git a/js/ui/appNotify.js b/js/ui/appNotify.js new file mode 100644 index 00000000..a36f03c2 --- /dev/null +++ b/js/ui/appNotify.js @@ -0,0 +1,34 @@ +// Single import surface for app notifications, so every alert/confirm goes through the +// site's notification system instead of native dialogs. +// +// - notifyError / notifyStatus / notifySuccess → the app's toast banners +// (NotificationController) for components/services without a local showError. +// - showConfirm → the styled promise-based confirm dialog (replaces window.confirm). +// +// Components that already receive a showError/showSuccess callback should keep using it; +// this is for modules that don't (e.g. standalone controllers/services). + +import { getApplication } from "../applicationContext.js"; +import { showConfirm } from "./confirmDialog.js"; + +function app() { + try { + return getApplication(); + } catch (error) { + return null; + } +} + +export function notifyError(message) { + app()?.showError?.(message); +} + +export function notifyStatus(message, options) { + app()?.showStatus?.(message, options); +} + +export function notifySuccess(message) { + app()?.showSuccess?.(message); +} + +export { showConfirm }; diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 3e0910e3..e144adea 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -26,6 +26,7 @@ import { requestDefaultExtensionPermissions, } from "../../nostrClientFacade.js"; import { UI_FEEDBACK_DELAY_MS } from "../../constants.js"; +import { showConfirm } from "../confirmDialog.js"; const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; @@ -518,12 +519,12 @@ export class UploadModal { void this.captureVideoMetadata(file); if (!this.storageConfigured) { - alert("Please configure storage before selecting a file."); + this.showError("Please configure storage before selecting a file."); e.target.value = ""; // Clear selection return; } if (!this.isStorageUnlocked) { - alert("Please unlock storage before selecting a file."); + this.showError("Please unlock storage before selecting a file."); e.target.value = ""; return; } @@ -614,7 +615,7 @@ export class UploadModal { if (this.results.magnet) this.results.magnet.value = "Upload Failed"; if (this.results.torrentUrl) this.results.torrentUrl.value = "Upload Failed"; - alert(`Upload failed: ${err.message}`); + this.showError(`Upload failed: ${err.message}`); if (this.inputs.file) this.inputs.file.value = ""; // Reset } @@ -676,7 +677,7 @@ export class UploadModal { userLogger.error("Thumbnail upload failed:", err); this.thumbnailUploadState.status = 'error'; this.updateThumbnailProgress(null, "Failed."); - alert("Thumbnail upload failed."); + this.showError("Thumbnail upload failed."); } } @@ -824,7 +825,7 @@ export class UploadModal { if (this.promptStoredNsecUnlock(pubkey)) { return; } - alert("No signer available to unlock storage."); + this.showError("No signer available to unlock storage."); return; } @@ -832,7 +833,7 @@ export class UploadModal { if (signer?.type === "extension" || signer?.type === "nip07") { const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult?.ok) { - alert("Extension permissions are required to unlock storage."); + this.showError("Extension permissions are required to unlock storage."); return; } } @@ -846,7 +847,7 @@ export class UploadModal { await this.loadFromStorage(); } catch (err) { userLogger.error("Unlock failed", err); - alert("Failed to unlock storage: " + err.message); + this.showError("Failed to unlock storage: " + err.message); } finally { if (this.toggles.storageUnlock) { this.toggles.storageUnlock.textContent = "Unlock"; @@ -945,17 +946,17 @@ export class UploadModal { // Check upload state if (this.activeSource === "upload") { if (this.videoUploadState.status === 'uploading' || this.thumbnailUploadState.status === 'uploading') { - alert("Please wait for uploads to complete."); + this.showError("Please wait for uploads to complete."); return; } if (this.videoUploadState.status === 'error') { - alert("Video upload failed. Please try again."); + this.showError("Video upload failed. Please try again."); return; } if (this.videoUploadState.status !== 'complete') { // Fallback: If no file selected, maybe they want to submit without a new file? // (Not supported in this simplified modal, assume file required) - alert("Please select a video file and wait for it to upload."); + this.showError("Please select a video file and wait for it to upload."); return; } } @@ -1104,7 +1105,7 @@ export class UploadModal { } if (!hasUrl && !hasImeta && hasMagnet) { - if (!confirm("Magnet-only uploads require active seeding. Proceed?")) return; + if (!(await showConfirm("Magnet-only uploads require active seeding. Proceed?"))) return; } await this.publish(metadata); diff --git a/js/ui/confirmDialog.js b/js/ui/confirmDialog.js new file mode 100644 index 00000000..6f821485 --- /dev/null +++ b/js/ui/confirmDialog.js @@ -0,0 +1,108 @@ +// Promise-based confirm dialog rendered with the site's modal styling, so yes/no +// confirmations live in the app's UI system instead of a native window.confirm. +// +// Importable anywhere (components AND services) — it builds its own +// `bv-modal modal-always-on-top` overlay, so it stacks above any open modal and is +// keyboard/click dismissible. Returns a Promise: true = confirmed. + +function makeButton(label, { ghost = false, danger = false } = {}) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = ghost ? "btn-ghost focus-ring" : "btn focus-ring"; + if (danger) { + btn.setAttribute("data-variant", "danger"); + } + btn.textContent = label; + return btn; +} + +export function showConfirm(message, options = {}) { + const { + title = "", + confirmLabel = "Confirm", + cancelLabel = "Cancel", + danger = false, + } = options || {}; + + // No DOM (tests without jsdom / SSR): fall back to window.confirm, else decline. + if (typeof document === "undefined" || !document.body) { + if (typeof window !== "undefined" && typeof window.confirm === "function") { + return Promise.resolve(window.confirm(String(message || ""))); + } + return Promise.resolve(false); + } + + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = + "bv-modal modal-always-on-top items-start justify-center md:items-center"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + + const backdrop = document.createElement("div"); + backdrop.className = "bv-modal-backdrop"; + overlay.appendChild(backdrop); + + const sheet = document.createElement("div"); + sheet.className = "modal-sheet w-full max-w-md flex flex-col"; + sheet.tabIndex = -1; + overlay.appendChild(sheet); + + if (title) { + const header = document.createElement("div"); + header.className = "modal-header"; + const heading = document.createElement("h2"); + heading.className = "text-lg font-bold text-text"; + heading.textContent = title; + header.appendChild(heading); + sheet.appendChild(header); + } + + const body = document.createElement("div"); + body.className = "p-5"; + const text = document.createElement("p"); + text.className = "text-sm text-muted-strong whitespace-pre-line"; + text.textContent = String(message || ""); + body.appendChild(text); + sheet.appendChild(body); + + const footer = document.createElement("div"); + footer.className = + "flex items-center justify-end gap-3 border-t border-border/60 p-4"; + const cancelBtn = makeButton(cancelLabel, { ghost: true }); + const confirmBtn = makeButton(confirmLabel, { danger }); + footer.appendChild(cancelBtn); + footer.appendChild(confirmBtn); + sheet.appendChild(footer); + + let settled = false; + const cleanup = (result) => { + if (settled) { + return; + } + settled = true; + document.removeEventListener("keydown", onKeydown, true); + overlay.remove(); + resolve(result); + }; + function onKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + cleanup(false); + } else if (event.key === "Enter" && document.activeElement === confirmBtn) { + cleanup(true); + } + } + + cancelBtn.addEventListener("click", () => cleanup(false)); + backdrop.addEventListener("click", () => cleanup(false)); + confirmBtn.addEventListener("click", () => cleanup(true)); + document.addEventListener("keydown", onKeydown, true); + + document.body.appendChild(overlay); + // Focus the safe action by default (Cancel for destructive prompts). + (danger ? cancelBtn : confirmBtn).focus(); + }); +} + +export default showConfirm; diff --git a/js/ui/profileModal/MyVideosController.js b/js/ui/profileModal/MyVideosController.js index 2d086e8d..0496f070 100644 --- a/js/ui/profileModal/MyVideosController.js +++ b/js/ui/profileModal/MyVideosController.js @@ -6,6 +6,7 @@ import { classifyVideoHealth, isUrlUnderBase } from "./myVideosHealth.js"; import { reconcileStorage } from "./myVideosReconcile.js"; import { FEATURE_NIP71_MIRROR } from "../../constants.js"; import { nip71MirrorService } from "../../services/nip71MirrorService.js"; +import { showConfirm } from "../confirmDialog.js"; import { isMirrorEnabled, setMirrorEnabled, @@ -282,10 +283,13 @@ export class MyVideosController { if (!r2Service || typeof r2Service.deleteStorageKeys !== "function") { return; } - if (typeof window !== "undefined" && typeof window.confirm === "function") { - if (!window.confirm(`Permanently delete this file from your bucket?\n\n${key}`)) { - return; - } + if ( + !(await showConfirm(`Permanently delete this file from your bucket?\n\n${key}`, { + confirmLabel: "Delete", + danger: true, + })) + ) { + return; } let result; try { @@ -314,14 +318,13 @@ export class MyVideosController { if (!r2Service || typeof r2Service.deleteStorageKeys !== "function" || !keys.length) { return; } - if (typeof window !== "undefined" && typeof window.confirm === "function") { - if ( - !window.confirm( - `Permanently delete all ${keys.length} orphaned files from your bucket?`, - ) - ) { - return; - } + if ( + !(await showConfirm( + `Permanently delete all ${keys.length} orphaned files from your bucket?`, + { confirmLabel: "Delete all", danger: true }, + )) + ) { + return; } if (this.orphanDeleteAllBtn instanceof HTMLElement) { this.orphanDeleteAllBtn.disabled = true; diff --git a/js/ui/profileModal/ProfileRelayController.js b/js/ui/profileModal/ProfileRelayController.js index 0eeb32ba..5cb44287 100644 --- a/js/ui/profileModal/ProfileRelayController.js +++ b/js/ui/profileModal/ProfileRelayController.js @@ -1,4 +1,5 @@ import { devLogger, userLogger } from "../../utils/logger.js"; +import { showConfirm } from "../confirmDialog.js"; import { sanitizeRelayList } from "../../nostr/nip46Client.js"; const noop = () => {}; @@ -556,7 +557,7 @@ export class ProfileRelayController { reason: null, }; - const confirmed = window.confirm("Restore the recommended relay defaults?"); + const confirmed = await showConfirm("Restore the recommended relay defaults?", { confirmLabel: "Restore" }); context.confirmed = confirmed; if (!confirmed) { context.reason = "cancelled"; @@ -604,9 +605,10 @@ export class ProfileRelayController { return; } - const confirmed = window.confirm( - `Remove ${url} from your relay list?`, - ); + const confirmed = await showConfirm(`Remove ${url} from your relay list?`, { + confirmLabel: "Remove", + danger: true, + }); if (!confirmed) { return; } diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js index 4a770de2..7f95fabf 100644 --- a/js/ui/profileModal/ProfileStorageController.js +++ b/js/ui/profileModal/ProfileStorageController.js @@ -10,6 +10,7 @@ import { getActiveSigner } from "../../nostr/client.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js"; import { storageSyncService } from "../../services/storageSyncService.js"; import { StorageCorsHelp } from "./storageCorsHelp.js"; +import { showConfirm } from "../confirmDialog.js"; import { fillStorageForm, clearCredentialFields, @@ -241,12 +242,10 @@ export class ProfileStorageController { } confirmSyncOverwrite() { - if (typeof window === "undefined" || typeof window.confirm !== "function") { - return true; - } - return window.confirm( + return showConfirm( "A newer copy of your storage settings is on your account (changed on " + - "another device). Overwrite it with this one?" + "another device). Overwrite it with this one?", + { confirmLabel: "Overwrite", danger: true }, ); } diff --git a/js/ui/profileModal/ProfileWalletController.js b/js/ui/profileModal/ProfileWalletController.js index 317fdd31..17a90f47 100644 --- a/js/ui/profileModal/ProfileWalletController.js +++ b/js/ui/profileModal/ProfileWalletController.js @@ -1,4 +1,5 @@ import { devLogger } from "../../utils/logger.js"; +import { showConfirm } from "../confirmDialog.js"; import { NWC_URI_SCHEME } from "../profileModalContract.js"; import { createWalletSyncService } from "../../services/walletSyncService.js"; import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js"; @@ -104,12 +105,10 @@ export class ProfileWalletController { } confirmSyncOverwrite() { - if (typeof window === "undefined" || typeof window.confirm !== "function") { - return true; - } - return window.confirm( + return showConfirm( "A newer copy of your wallet connection is on your account (changed on " + - "another device). Overwrite it with this one?" + "another device). Overwrite it with this one?", + { confirmLabel: "Overwrite", danger: true }, ); } @@ -158,14 +157,12 @@ export class ProfileWalletController { try { if (enabled) { // Spending capability — require explicit confirmation before publishing. - const confirmed = - typeof window !== "undefined" && typeof window.confirm === "function" - ? window.confirm( - "This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" + - "A wallet connect URI can SPEND from your wallet. Anyone who controls your " + - "Nostr key could decrypt this and spend from this wallet.\n\nContinue?" - ) - : true; + const confirmed = await showConfirm( + "This publishes an ENCRYPTED copy of your wallet connection to public relays.\n\n" + + "A wallet connect URI can SPEND from your wallet. Anyone who controls your " + + "Nostr key could decrypt this and spend from this wallet.\n\nContinue?", + { confirmLabel: "Publish", danger: true }, + ); if (!confirmed) { if (this.walletSyncToggle instanceof HTMLInputElement) { this.walletSyncToggle.checked = false; diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index e5c293f2..4cfe80bb 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -6,6 +6,7 @@ import { MAX_WALLET_DEFAULT_ZAP as CONFIG_MAX_WALLET_DEFAULT_ZAP, ENABLE_NIP17_RELAY_WARNING as CONFIG_ENABLE_NIP17_RELAY_WARNING, } from "../config.js"; +import { showConfirm } from "./confirmDialog.js"; import { normalizeDesignSystemContext } from "../designSystem.js"; import { RUNTIME_FLAGS } from "../constants.js"; import { @@ -1926,14 +1927,17 @@ export class ProfileModalController { } if (this.profileSubscriptionsBackupBtn) { - this.profileSubscriptionsBackupBtn.addEventListener("click", () => { + this.profileSubscriptionsBackupBtn.addEventListener("click", async () => { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); - if (pubkey) { - if (confirm("Create a backup of your current subscription list?")) { + if ( + pubkey && + (await showConfirm("Create a backup of your current subscription list?", { + confirmLabel: "Create backup", + })) + ) { this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => { this.showSuccess("Backup created."); }); - } } else { this.showError("Please log in to backup subscriptions."); } @@ -2930,7 +2934,7 @@ export class ProfileModalController { ? confirmValue.trim() : "this entry"; const prompt = confirmMessage.replace("{npub}", replacement); - if (!window.confirm(prompt)) { + if (!(await showConfirm(prompt, { danger: true }))) { return; } } diff --git a/js/ui/subscriptionHistoryController.js b/js/ui/subscriptionHistoryController.js index 78c1312c..0d3fbf44 100644 --- a/js/ui/subscriptionHistoryController.js +++ b/js/ui/subscriptionHistoryController.js @@ -3,6 +3,7 @@ import { profileCache } from "../state/profileCache.js"; import { formatTimeAgo } from "../utils/formatters.js"; import { userLogger } from "../utils/logger.js"; import { getApplication } from "../applicationContext.js"; +import { notifyError, notifySuccess, showConfirm } from "./appNotify.js"; export class SubscriptionHistoryController { constructor(options = {}) { @@ -302,9 +303,10 @@ export class SubscriptionHistoryController { async handleRestore() { if (!this.selectedEvent) return; if ( - !confirm( - "Are you sure you want to restore this list? It will overwrite your current subscriptions." - ) + !(await showConfirm( + "Are you sure you want to restore this list? It will overwrite your current subscriptions.", + { confirmLabel: "Restore", danger: true }, + )) ) return; @@ -313,11 +315,11 @@ export class SubscriptionHistoryController { try { await subscriptions.restoreBackup(this.selectedEvent, this.activePubkey); - // alert("Subscriptions restored successfully!"); // alert blocks, toast is better if available, but alert is fine for now + notifySuccess("Subscriptions restored."); this.hide(); } catch (error) { userLogger.error("Restore failed:", error); - alert("Failed to restore: " + error.message); + notifyError("Failed to restore: " + error.message); } finally { this.restoreBtn.disabled = false; this.restoreBtn.textContent = "Restore as Active"; @@ -338,7 +340,7 @@ export class SubscriptionHistoryController { this.fetchHistory(); } catch (error) { userLogger.error("Rebroadcast failed:", error); - alert("Failed to rebroadcast: " + error.message); + notifyError("Failed to rebroadcast: " + error.message); } finally { this.rebroadcastBtn.disabled = false; this.rebroadcastBtn.textContent = "Rebroadcast"; @@ -364,7 +366,7 @@ export class SubscriptionHistoryController { } } catch (error) { userLogger.error("Backup failed:", error); - alert("Backup failed: " + error.message); + notifyError("Backup failed: " + error.message); } } } diff --git a/tests/confirm-dialog.test.mjs b/tests/confirm-dialog.test.mjs new file mode 100644 index 00000000..e6963c49 --- /dev/null +++ b/tests/confirm-dialog.test.mjs @@ -0,0 +1,75 @@ +// showConfirm() replaces native window.confirm with a styled, promise-based dialog so +// all confirmations live in the app's UI system. It must resolve true on Confirm, false +// on Cancel/backdrop/Escape, clean up its DOM, and degrade gracefully without a DOM. + +import test, { beforeEach, afterEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { showConfirm } from "../js/ui/confirmDialog.js"; + +let dom; +beforeEach(() => { + dom = new JSDOM(""); + globalThis.document = dom.window.document; + globalThis.window = dom.window; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.KeyboardEvent = dom.window.KeyboardEvent; +}); +afterEach(() => { + delete globalThis.document; + delete globalThis.window; + delete globalThis.HTMLElement; + delete globalThis.KeyboardEvent; +}); + +function overlay() { + return dom.window.document.querySelector(".bv-modal"); +} + +test("renders a dialog with the message and resolves TRUE when Confirm is clicked", async () => { + const p = showConfirm("Delete this thing?", { confirmLabel: "Delete", danger: true }); + const modal = overlay(); + assert.ok(modal, "an overlay was added to the DOM"); + assert.match(modal.textContent, /Delete this thing\?/); + modal.querySelector(".btn").click(); // confirm button (cancel is .btn-ghost) + assert.equal(await p, true); + assert.equal(overlay(), null, "overlay removed after resolving"); +}); + +test("resolves FALSE when Cancel is clicked", async () => { + const p = showConfirm("Sure?"); + overlay().querySelector(".btn-ghost").click(); + assert.equal(await p, false); + assert.equal(overlay(), null); +}); + +test("resolves FALSE when the backdrop is clicked", async () => { + const p = showConfirm("Sure?"); + overlay().querySelector(".bv-modal-backdrop").click(); + assert.equal(await p, false); +}); + +test("resolves FALSE on Escape", async () => { + const p = showConfirm("Sure?"); + dom.window.document.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + assert.equal(await p, false); + assert.equal(overlay(), null); +}); + +test("a danger prompt focuses Cancel (the safe action) by default", async () => { + const p = showConfirm("Delete?", { danger: true }); + assert.equal( + dom.window.document.activeElement, + overlay().querySelector(".btn-ghost"), + ); + overlay().querySelector(".btn-ghost").click(); + await p; +}); + +test("without a DOM it declines rather than throwing", async () => { + delete globalThis.document; + delete globalThis.window; + assert.equal(await showConfirm("anything"), false); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index d46934ba..a20b49a5 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -504,6 +504,25 @@ toward freshness and looked identical. Gave each a structural identity: - [ ] Consider removing the CDN/WebTorrent source badge from the card (clutter). - [ ] Run `npm run test:visual` after layout changes; update baselines deliberately. +### 41. Native browser dialogs → site notification system +- [x] **DONE 2026-06-30.** Replaced every `alert()`/`confirm()` (no `prompt()` existed) + with the app's notification system so dialogs are styled + consistent. + - `alert()` (13) → toasts: `this.showError` where the component already had it + (UploadModal), else `notifyError`/`notifySuccess` via a new `js/ui/appNotify.js` + (routes to the app's NotificationController). + - `confirm()` (~13) → a new promise-based **`showConfirm()`** dialog + (`js/ui/confirmDialog.js`) — a `bv-modal modal-always-on-top` overlay returning + `Promise` (Confirm/Cancel/backdrop/Escape, danger variant focuses + Cancel). Importable anywhere, so future confirmations use it too. Stacks above + other modals and is typable thanks to the stacked-modal focus fix. + - Migrated: UploadModal, MyVideosController, ProfileStorage/Wallet/Relay + controllers, profileModalController, subscriptionHistoryController, + settingsRestorePrompt (default `confirm` now `showConfirm`), nostr/client + (deleteAllVersions). `confirmSyncOverwrite` helpers made async (the sync layer + already `await`s `confirmOverwrite`). Tests: `tests/confirm-dialog.test.mjs` (6). + Build + lint clean; affected suites green. NOTE: `npm run build`/`lint` do NOT + parse JS — verify migrations by importing the module / running its test. + ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no live note references and offers per-file delete (under the user's prefix only). From c48083dfcc3390dceb481a8b64bd309518689cd3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:00:57 -0400 Subject: [PATCH 02/48] [admin] fix whitelist/blacklist edits with nsec & NIP-46; loosen file-size limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin list edits worked with a NIP-07 extension but silently failed with nsec and NIP-46 (Amber). persistNostrState signed with window.nostr.signEvent directly (and required requestDefaultExtensionPermissions), so non-extension logins threw "nostr-extension-missing". Fix: sign with the ACTIVE signer (getActiveSigner() from the registry — extension/nsec/ NIP-46 all expose signEvent), and only run the extension-permission prompt when the signer is an extension (type extension/nip07). Regression test in tests/admin-list-store.test.mjs (nsec signer used while window.nostr.signEvent throws). Also (per maintainer request): raise the file-size lint margins to stop churning on an arbitrary cap — THRESHOLD 1000→1200 (new files), GROWTH_MARGIN 50→200 (grandfathered). TODOs added: #42 follow-up audit of every signing path (must use the active signer, not window.nostr); #43 logged-in profile card at the top of the profile modal (half-on/half- off); #44 in-modal storage selector + unlock for the upload modal. Build + lint clean; admin-list suite green. Co-Authored-By: Claude Opus 4.8 --- js/adminListStore.js | 36 +++++++++++++++--------------- scripts/check-file-size.mjs | 4 ++-- tests/admin-list-store.test.mjs | 28 +++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 35 +++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/js/adminListStore.js b/js/adminListStore.js index 3059d9cd..6b739297 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -12,6 +12,7 @@ import { import { SHORT_TIMEOUT_MS } from "./constants.js"; import { getRegisteredNostrClient, + getActiveSigner, requestDefaultExtensionPermissions, } from "./nostrClientRegistry.js"; import { devLogger, userLogger } from "./utils/logger.js"; @@ -574,8 +575,7 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") { pubkey, dTag, relayUrls: relays, - // Force a full fetch: admin lists are replaceable (see - // tests/nostr/relayBatchFetcherReplaceable.test.mjs). + // Full fetch: admin lists are replaceable (relayBatchFetcherReplaceable.test). since: 0, }), timeoutPromise, @@ -682,8 +682,7 @@ async function loadCommunityBlacklistEntries() { return []; } - // Batch all curator blacklists into ONE REQ (was the cold-start kind-30000 storm). - // Best-effort: on failure the cached admin state carries over. + // Batch all curator blacklists into ONE REQ (best-effort; cache carries over on fail). try { const matched = await fetchBatchedReferenceEvents({ references, @@ -874,20 +873,21 @@ async function persistNostrState(actorNpub, updates = {}) { } ensureNostrReady(); - const permissionResult = await requestDefaultExtensionPermissions(); - if (!permissionResult.ok) { - throw createError( - "extension-permission-denied", - "The NIP-07 extension must allow signing before updating admin lists.", - permissionResult.error, - ); + // Sign with the ACTIVE signer (extension / nsec / NIP-46), not window.nostr. + const signer = getActiveSigner(); + if (!signer || typeof signer.signEvent !== "function") { + throw createError("nostr-signer-missing", "No active signer to update admin lists. Please log in."); } - const extension = window?.nostr; - if (!extension || typeof extension.signEvent !== "function") { - throw createError( - "nostr-extension-missing", - "A Nostr extension with signEvent support is required." - ); + // Only a NIP-07 extension needs an explicit permission prompt before signing. + if (signer.type === "extension" || signer.type === "nip07") { + const permissionResult = await requestDefaultExtensionPermissions(); + if (!permissionResult.ok) { + throw createError( + "extension-permission-denied", + "The NIP-07 extension must allow signing before updating admin lists.", + permissionResult.error, + ); + } } const actorHex = decodeNpubToHex(actorNpub); @@ -950,7 +950,7 @@ async function persistNostrState(actorNpub, updates = {}) { let signedEvent; try { - signedEvent = await extension.signEvent(event); + signedEvent = await signer.signEvent(event); } catch (error) { throw createError("signature-failed", "Failed to sign admin list event.", error); } diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 3b3fcb81..113a6a69 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -19,8 +19,8 @@ import { join, relative } from "node:path"; const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); const SOURCE_DIR = join(ROOT, "js"); -const THRESHOLD = 1000; // lines — new files must stay under this -const GROWTH_MARGIN = 50; // lines — allowlisted files may grow by this much for minor fixes +const THRESHOLD = 1200; // lines — new files must stay under this (soft ceiling) +const GROWTH_MARGIN = 200; // lines — allowlisted files may grow by this much for fixes // Grandfathered files: path relative to repo root → line count when recorded. // These files are known to be oversized and are decomposition targets. diff --git a/tests/admin-list-store.test.mjs b/tests/admin-list-store.test.mjs index 0c062f2c..6d321c87 100644 --- a/tests/admin-list-store.test.mjs +++ b/tests/admin-list-store.test.mjs @@ -667,4 +667,32 @@ assert.ok( "should warn when blacklist updates fail on a relay", ); +// Regression: admin list edits must sign with the ACTIVE signer (nsec / NIP-46), not +// window.nostr — persistNostrState previously hardcoded the NIP-07 extension, so nsec +// and NIP-46 logins couldn't edit the whitelist. +let nsecSignerCalls = 0; +globalThis.window.nostr.signEvent = async () => { + throw new Error("window.nostr must NOT be used to sign admin lists"); +}; +setActiveSigner({ + pubkey: superHex, + type: "nsec", + signEvent: async (event) => { + nsecSignerCalls += 1; + return { ...event, id: "nsec-signed" }; + }, +}); +nostrClient.writeRelays = [relays[1]]; +setPublishBehaviors([{ url: relays[1], success: true }]); + +await assert.doesNotReject( + () => + persistAdminState(ADMIN_SUPER_NPUB, { whitelist: ["npub1nsecwhitelistmember"] }), + "admin list edit must succeed with an nsec signer and no usable window.nostr", +); +assert.ok( + nsecSignerCalls > 0, + "should sign admin list updates with the active (nsec) signer, not window.nostr", +); + console.log("admin-list-store tests passed"); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index a20b49a5..51fc43c9 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -523,6 +523,41 @@ toward freshness and looked identical. Gave each a structural identity: Build + lint clean; affected suites green. NOTE: `npm run build`/`lint` do NOT parse JS — verify migrations by importing the module / running its test. +### 42. Admin whitelist edit fails with nsec / NIP-46 (only NIP-07 worked) — FIXED +- [x] **DONE 2026-06-30.** Editing the admin whitelist/blacklist worked with a NIP-07 + extension but silently failed with nsec and NIP-46 (Amber). Root cause: + `adminListStore.persistNostrState` signed with `window.nostr.signEvent` directly + (and required `requestDefaultExtensionPermissions`), so non-extension logins threw + "nostr-extension-missing". Fix: sign with the **active signer** + (`getActiveSigner()` from the registry — works for extension/nsec/NIP-46, all expose + `signEvent`), and only run the extension-permission prompt when the signer is an + extension (`type === "extension"/"nip07"`). Regression test added to + `tests/admin-list-store.test.mjs` (nsec signer used while `window.nostr.signEvent` + throws). Build + lint clean. +- [ ] **AUDIT (follow-up):** sweep EVERY place that signs an event and confirm it uses the + active-signer path (`getActiveSigner().signEvent` / the client's sign helper), NOT + `window.nostr` directly. `window.nostr`-only signing breaks nsec + NIP-46. Grep for + `window.nostr`, `extension.signEvent`, and direct `signEvent` calls across publish + paths (lists, reactions, reposts, comments, profile metadata, NWC, watch history, + deletes, NIP-71 mirror, view events) and fix any that bypass the active signer. + +### 43. Logged-in profile card at the top of the profile modal (UX) +- [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the + profile modal, styled as a "profile card" that sits **half-on / half-off** the + modal edge (overlapping the top border) for a polished look. Goal: users can see + WHICH profile they're acting as (e.g. uploading as) at a glance. Pull avatar/name + from the profile cache for the active pubkey; update on profile switch. + +### 44. In-modal storage selector for uploads (UX) +- [ ] Make storage unlock + selection easier from the **video upload modal**: let the + user **pick any configured storage connection** (R2 / B2 / Custom S3) and unlock it + **without leaving the upload modal** — today they must close it, open Profile → + Storage, change the default/selected connection, then reopen upload. Add a provider + picker (driven by `storageService.listConnections`) + inline unlock in the upload + modal's storage section; selecting one sets it as the active upload target for this + upload (and optionally the default). Reuse `loadFromStorage` / the per-provider + connection model. + ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no live note references and offers per-file delete (under the user's prefix only). From 7a11473d0d29cee48d4cd36d5b0e9cceb0632e5d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:54:43 -0400 Subject: [PATCH 03/48] =?UTF-8?q?[docs]=20#42=20signer=20audit=20complete?= =?UTF-8?q?=20=E2=80=94=20active-signer=20path=20clean=20(adminListStore?= =?UTF-8?q?=20was=20the=20only=20bug)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swept every .signEvent(/window.nostr/ensureExtensionPermissions caller. No window.nostr is used to sign or encrypt anywhere; all publishing uses the active signer, and every extension-permission gate is guarded on signer type/capability so nsec + NIP-46 pass through. Recorded the findings + one dead-code note (auth/signingAdapter.js createNip07SigningAdapter is unused by app code). Co-Authored-By: Claude Opus 4.8 --- todo/TODO_2026-06-20_pre-launch.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 51fc43c9..b5dbeda7 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -534,12 +534,25 @@ toward freshness and looked identical. Gave each a structural identity: extension (`type === "extension"/"nip07"`). Regression test added to `tests/admin-list-store.test.mjs` (nsec signer used while `window.nostr.signEvent` throws). Build + lint clean. -- [ ] **AUDIT (follow-up):** sweep EVERY place that signs an event and confirm it uses the - active-signer path (`getActiveSigner().signEvent` / the client's sign helper), NOT - `window.nostr` directly. `window.nostr`-only signing breaks nsec + NIP-46. Grep for - `window.nostr`, `extension.signEvent`, and direct `signEvent` calls across publish - paths (lists, reactions, reposts, comments, profile metadata, NWC, watch history, - deletes, NIP-71 mirror, view events) and fix any that bypass the active signer. +- [x] **AUDIT DONE 2026-06-30 — clean (adminListStore was the only bug).** Swept every + `.signEvent(`, `window.nostr`, and `ensureExtensionPermissions`/ + `requestDefaultExtensionPermissions` caller: + - **Signing:** NO `window.nostr` is used to sign/encrypt anywhere. Every publish + path signs with the active signer (`signer.signEvent`): subscriptions, userBlocks, + hashtag prefs, relay prefs, moderationService reports, nip71MirrorService, + encryptedSyncFacade, DM seal + legacy DM (`nostr/client` signingAdapter defaults + to the active signer), DM relay list, admin lists. + - **Extension-permission gates:** every caller (reactions, comments, subscriptions, + userBlocks, watchHistory, relayManager, DM relays) guards the request behind a + signer-type (`extension`/`nip07`) or capability check, so nsec/NIP-46 skip it and + go straight to active-signer signing — they never hit the `extension-missing` + failure that broke admin lists. + - **Decrypt:** gates on the active signer's nip04/nip44 capability with `window.nostr` + only as a fallback; nsec (closures) + NIP-46 (remote) both decrypt fine. + - **Cleanup note (low priority):** `js/auth/signingAdapter.js` + (`createNip07SigningAdapter`, defaults to `window.nostr`) is dead production code — + referenced only by its own test, no app callers. Harmless; remove or repoint at + the active signer if ever reused. ### 43. Logged-in profile card at the top of the profile modal (UX) - [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the From 577e5d27c56718ad69e29bd7079e28c66b971353 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:00:15 -0400 Subject: [PATCH 04/48] [docs] add TODOs #45-#48 (tag suggestions, feed auto-refresh, Most Zapped tab, fluent profile switching) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer: #45 upload-modal tag suggestions from the user's previous videos; #46 auto-refresh the feed after posting a video; #47 "Most Zapped" sidebar tab (trending-by-zaps); #48 fluent profile switching across signing methods. #48 verified NOT already done: switching to a saved nsec profile throws "secret-required" (switchProfile -> requestLogin -> nsec provider.login with no passphrase) and silently fails — it never prompts for the PIN. Same root gap as #36 but in the switch path; NIP-46 likely needs an analogous reconnect on switch. Co-Authored-By: Claude Opus 4.8 --- todo/TODO_2026-06-20_pre-launch.md | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index b5dbeda7..041cc7bb 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -571,6 +571,44 @@ toward freshness and looked identical. Gave each a structural identity: upload (and optionally the default). Reuse `loadFromStorage` / the per-provider connection model. +### 45. Upload modal: suggest tags from the user's previous videos (UX) +- [ ] In the upload modal, let the user **pick tags reused from their other videos** — + surface a set of their **commonly-used tags** (frequency-ranked across their past + uploads) as one-tap chips, plus their less-common individual tags. Source from the + user's own video events' `t` tags (My Videos / their kind-30078+NIP-71 posts). + Rank by usage count; clicking a chip adds it to the new video's tags. + +### 46. Feed auto-refreshes after posting a video (UX) +- [ ] After a video note is published, **automatically refresh the feed** so the new + video appears without a manual page reload. Hook the post-publish success (upload + modal `upload:submit` / `publishVideoNote` completion) to + `refreshAllVideoGrids`/`onVideosShouldRefresh` (optimistically inject the just- + published event so it shows instantly). Mind the feed-identity + dedupe paths (#20/ + #21) so the injected event isn't dropped or duplicated. + +### 47. "Most Zapped" sidebar tab (trending-by-zaps) +- [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by + **total zaps** (sats / zap count) per video. Reuse the zap accounting (kind-9735 + receipts) already used for the zap system; build a feed source/ranker that sorts by + zap total over a window, wired into the feed engine like Trending. Depends on + reliable zap data (couples with the zap system already shipped in #3). + +### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07) +- [ ] **CONFIRMED NOT DONE (verified 2026-06-30).** Switching to a saved **nsec** profile + fails: `switchProfile(pubkey, { providerId: "nsec" })` → `requestLogin` → the nsec + provider's `login()` is called with **no secret/passphrase**, so it throws + `"secret-required"` and the switch silently fails (caught + logged in + `profileModalController`). It never prompts for the PIN or routes to the stored-key + unlock flow. Same root gap as #36 (nsec signer needs the passphrase to be restored), + but in the SWITCH path. Fix: when switching to a saved nsec profile, detect the + stored encrypted key (`getStoredSessionActorMetadata`) and prompt for the pin/ + passphrase (route through `unlockStoredSessionActor`, e.g. open the login modal's + unlock flow), then activate. Apply the same for **NIP-46** (reconnect / re-authorize + the remote signer on switch if needed). Goal: switch fluently between NIP-07 ⇄ nsec + ⇄ NIP-46 accounts regardless of the current signing method. NOTE: multiple saved + nsec accounts share ONE stored session entry today — confirm per-account key storage + is needed for switching among several nsec profiles. + ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no live note references and offers per-file delete (under the user's prefix only). From fb2f12b49d1747c43e96ac2436427725454b0bda Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:11:43 -0400 Subject: [PATCH 05/48] [auth] #48: fluent switch to a saved nsec account (prompt for PIN + unlock stored key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a saved nsec profile silently failed: switchProfile -> requestLogin -> the nsec provider was called with no passphrase, so it threw "secret-required" and the switch was caught+logged with no user feedback. It never prompted for the PIN. Now handleProfileSwitchRequest detects an nsec target and, before switching, prepares the stored-key unlock: - evaluateStoredNsecSwitch(meta, target) (pure) gates on a persisted nsec key for THIS account (via getStoredSessionActorMetadata); errors clearly otherwise ("log in with its nsec", "different account's key saved"). - showPasswordPrompt (js/ui/promptDialog.js) collects the PIN/passphrase in a styled dialog (Promise, stacks above modals). - switchProfile(pubkey, { unlockStored: true, passphrase }) — authService.switchProfile now forwards unlockStored/passphrase to requestLogin, which routes to the nsec provider's unlockStoredSessionActor to restore the signer and activate the account. - Wrong PIN -> "Incorrect PIN/passphrase" toast (decrypt-failed). So NIP-07 -> nsec switching works fluently. NIP-46 switching is unchanged (follow-up to verify/extend); multiple-nsec accounts still share one stored slot (follow-up). Tests: tests/stored-nsec-switch.test.mjs (6), tests/password-prompt.test.mjs (6). Build + lint clean; auth suites 25/25. Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 60 +++++++++++++++- js/app/storedNsecSwitch.js | 24 +++++++ js/services/authService.js | 11 +++ js/ui/promptDialog.js | 111 +++++++++++++++++++++++++++++ tests/password-prompt.test.mjs | 71 ++++++++++++++++++ tests/stored-nsec-switch.test.mjs | 49 +++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 35 +++++---- 7 files changed, 346 insertions(+), 15 deletions(-) create mode 100644 js/app/storedNsecSwitch.js create mode 100644 js/ui/promptDialog.js create mode 100644 tests/password-prompt.test.mjs create mode 100644 tests/stored-nsec-switch.test.mjs diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 22fc2f9a..e156e7d3 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -13,6 +13,8 @@ import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; import { clearWatchHistoryConversationKeyCache } from "../nostr/watchHistory.js"; import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecryptCache.js"; import { FEED_TYPES, FEATURE_TRENDING_FEED } from "../constants.js"; +import { showPasswordPrompt } from "../ui/promptDialog.js"; +import { evaluateStoredNsecSwitch } from "./storedNsecSwitch.js"; /** * Kick a background watch-history refresh after login, once the feed-driving @@ -1372,12 +1374,68 @@ export function createAuthSessionCoordinator(deps) { } }, + // For an nsec target, ensure its stored key can be unlocked and collect the + // passphrase. Returns { status: "ok", passphrase } | { status: "cancelled" } | + // { status: "error", reason }. + async prepareStoredNsecSwitch(targetPubkey) { + const meta = + typeof nostrClient?.getStoredSessionActorMetadata === "function" + ? nostrClient.getStoredSessionActorMetadata() + : null; + const decision = evaluateStoredNsecSwitch(meta, targetPubkey); + if (decision.action === "error") { + this.showError?.( + decision.reason === "pubkey-mismatch" + ? "A different account's key is saved on this device. Log in with this account's nsec to switch to it." + : "This account's key isn't saved on this device. Log in with its nsec to switch to it.", + ); + return { status: "error", reason: decision.reason }; + } + const passphrase = await showPasswordPrompt( + "Enter your PIN / passphrase to unlock this account's saved key.", + { title: "Switch account", confirmLabel: "Switch" }, + ); + if (passphrase == null || passphrase === "") { + return { status: "cancelled", reason: "cancelled" }; + } + return { status: "ok", passphrase }; + }, + async handleProfileSwitchRequest({ pubkey, providerId } = {}) { if (!pubkey) { throw new Error("Missing pubkey for profile switch request."); } - const result = await this.authService.switchProfile(pubkey, { providerId }); + // Switching to a saved nsec account needs its passphrase — the private key is + // stored encrypted, so switchProfile alone throws "secret-required". Prompt for + // the PIN and unlock the stored key so the switch actually works (matches #36). + let switchOptions = { providerId }; + const isNsecTarget = + String(providerId || "").trim().toLowerCase() === "nsec"; + if (isNsecTarget) { + const prep = await this.prepareStoredNsecSwitch(pubkey); + if (prep.status !== "ok") { + return { switched: false, reason: prep.reason || prep.status }; + } + switchOptions = { + providerId, + unlockStored: true, + passphrase: prep.passphrase, + }; + } + + let result; + try { + result = await this.authService.switchProfile(pubkey, switchOptions); + } catch (error) { + if (isNsecTarget && error?.code === "decrypt-failed") { + this.showError?.( + "Incorrect PIN / passphrase. Try switching to this account again.", + ); + return { switched: false, reason: "bad-passphrase" }; + } + throw error; + } if (result?.switched) { const detail = result.detail || null; diff --git a/js/app/storedNsecSwitch.js b/js/app/storedNsecSwitch.js new file mode 100644 index 00000000..37b6a036 --- /dev/null +++ b/js/app/storedNsecSwitch.js @@ -0,0 +1,24 @@ +// Decide whether switching to a saved nsec account can proceed via the stored key. +// Pure so the branching is unit-testable; the coordinator handles the prompt + I/O. +// +// { action: "prompt" } → stored nsec key matches; ask for the PIN +// { action: "error", reason: "no-stored-key" } → no persisted nsec key on this device +// { action: "error", reason: "pubkey-mismatch" } → the saved key is a different account + +function normalizeHex(value) { + return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value.trim()) + ? value.trim().toLowerCase() + : ""; +} + +export function evaluateStoredNsecSwitch(meta, targetPubkey) { + if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") { + return { action: "error", reason: "no-stored-key" }; + } + const stored = normalizeHex(meta.pubkey); + const target = normalizeHex(targetPubkey); + if (stored && target && stored !== target) { + return { action: "error", reason: "pubkey-mismatch" }; + } + return { action: "prompt" }; +} diff --git a/js/services/authService.js b/js/services/authService.js index 1715f2ef..16d131b2 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -992,6 +992,17 @@ export default class AuthService { expectPubkey: normalizedTarget, persistActive: true, ...(providerId ? { providerId } : {}), + // Switching to a saved nsec account: unlock its stored key with the passphrase + // the caller collected (the private key can't be restored otherwise). + ...(normalizedOptions.unlockStored + ? { + unlockStored: true, + passphrase: + typeof normalizedOptions.passphrase === "string" + ? normalizedOptions.passphrase + : "", + } + : {}), }; const autoApply = requestOptions.autoApply !== false; diff --git a/js/ui/promptDialog.js b/js/ui/promptDialog.js new file mode 100644 index 00000000..33d6c171 --- /dev/null +++ b/js/ui/promptDialog.js @@ -0,0 +1,111 @@ +// Promise-based password/PIN prompt rendered with the site's modal styling (sibling of +// confirmDialog). Used to collect the passphrase that unlocks a stored nsec key — e.g. +// when switching to a saved nsec account. Resolves the entered string, or null if the +// user cancels. Builds its own `bv-modal modal-always-on-top` overlay so it stacks above +// other modals (and is typable thanks to the stacked-modal focus fix). + +export function showPasswordPrompt(message, options = {}) { + const { + title = "", + confirmLabel = "Unlock", + cancelLabel = "Cancel", + placeholder = "PIN / passphrase", + } = options || {}; + + if (typeof document === "undefined" || !document.body) { + return Promise.resolve(null); + } + + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = + "bv-modal modal-always-on-top items-start justify-center md:items-center"; + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + + const backdrop = document.createElement("div"); + backdrop.className = "bv-modal-backdrop"; + overlay.appendChild(backdrop); + + const sheet = document.createElement("div"); + sheet.className = "modal-sheet w-full max-w-md flex flex-col"; + sheet.tabIndex = -1; + overlay.appendChild(sheet); + + if (title) { + const header = document.createElement("div"); + header.className = "modal-header"; + const heading = document.createElement("h2"); + heading.className = "text-lg font-bold text-text"; + heading.textContent = title; + header.appendChild(heading); + sheet.appendChild(header); + } + + const body = document.createElement("div"); + body.className = "p-5 space-y-3"; + if (message) { + const text = document.createElement("p"); + text.className = "text-sm text-muted-strong whitespace-pre-line"; + text.textContent = String(message); + body.appendChild(text); + } + const input = document.createElement("input"); + input.type = "password"; + input.className = "input w-full"; + input.placeholder = placeholder; + input.autocomplete = "off"; + input.setAttribute("aria-label", placeholder); + body.appendChild(input); + sheet.appendChild(body); + + const footer = document.createElement("div"); + footer.className = + "flex items-center justify-end gap-3 border-t border-border/60 p-4"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "btn-ghost focus-ring"; + cancelBtn.textContent = cancelLabel; + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "btn focus-ring"; + confirmBtn.textContent = confirmLabel; + footer.appendChild(cancelBtn); + footer.appendChild(confirmBtn); + sheet.appendChild(footer); + + let settled = false; + const cleanup = (result) => { + if (settled) { + return; + } + settled = true; + document.removeEventListener("keydown", onKeydown, true); + overlay.remove(); + resolve(result); + }; + const submit = () => cleanup(input.value); + function onKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + cleanup(null); + } + } + + cancelBtn.addEventListener("click", () => cleanup(null)); + backdrop.addEventListener("click", () => cleanup(null)); + confirmBtn.addEventListener("click", submit); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + submit(); + } + }); + document.addEventListener("keydown", onKeydown, true); + + document.body.appendChild(overlay); + input.focus(); + }); +} + +export default showPasswordPrompt; diff --git a/tests/password-prompt.test.mjs b/tests/password-prompt.test.mjs new file mode 100644 index 00000000..86ee8fa2 --- /dev/null +++ b/tests/password-prompt.test.mjs @@ -0,0 +1,71 @@ +// showPasswordPrompt() collects a passphrase/PIN via a styled dialog (used to unlock a +// stored nsec key when switching accounts). Resolves the entered string, or null when +// the user cancels/escapes. + +import test, { beforeEach, afterEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { showPasswordPrompt } from "../js/ui/promptDialog.js"; + +let dom; +beforeEach(() => { + dom = new JSDOM(""); + globalThis.document = dom.window.document; + globalThis.window = dom.window; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.KeyboardEvent = dom.window.KeyboardEvent; +}); +afterEach(() => { + delete globalThis.document; + delete globalThis.window; + delete globalThis.HTMLElement; + delete globalThis.KeyboardEvent; +}); + +const overlay = () => dom.window.document.querySelector(".bv-modal"); +const input = () => overlay().querySelector('input[type="password"]'); + +test("resolves the entered value on Confirm and removes the dialog", async () => { + const p = showPasswordPrompt("Enter your PIN", { confirmLabel: "Switch" }); + input().value = "hunter2"; + overlay().querySelector(".btn").click(); // confirm (cancel is .btn-ghost) + assert.equal(await p, "hunter2"); + assert.equal(overlay(), null, "dialog removed after resolving"); +}); + +test("resolves the value when Enter is pressed in the input", async () => { + const p = showPasswordPrompt("PIN"); + const el = input(); + el.value = "s3cret"; + el.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + assert.equal(await p, "s3cret"); +}); + +test("resolves null when cancelled", async () => { + const p = showPasswordPrompt("PIN"); + overlay().querySelector(".btn-ghost").click(); + assert.equal(await p, null); +}); + +test("resolves null on Escape", async () => { + const p = showPasswordPrompt("PIN"); + dom.window.document.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + assert.equal(await p, null); +}); + +test("focuses the input so the user can type immediately", async () => { + const p = showPasswordPrompt("PIN"); + assert.equal(dom.window.document.activeElement, input()); + overlay().querySelector(".btn-ghost").click(); + await p; +}); + +test("without a DOM it resolves null rather than throwing", async () => { + delete globalThis.document; + delete globalThis.window; + assert.equal(await showPasswordPrompt("PIN"), null); +}); diff --git a/tests/stored-nsec-switch.test.mjs b/tests/stored-nsec-switch.test.mjs new file mode 100644 index 00000000..e3aa89d6 --- /dev/null +++ b/tests/stored-nsec-switch.test.mjs @@ -0,0 +1,49 @@ +// #48: switching to a saved nsec account must prompt for the PIN and unlock its stored +// key (switchProfile alone threw "secret-required"). evaluateStoredNsecSwitch is the +// pure gate: proceed to prompt when a matching stored nsec key exists; error otherwise. + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { evaluateStoredNsecSwitch } from "../js/app/storedNsecSwitch.js"; + +const A = "a".repeat(64); +const B = "b".repeat(64); +const nsecMeta = (pubkey = A) => ({ hasEncryptedKey: true, pubkey, source: "nsec" }); + +test("prompts when a stored nsec key matches the target account", () => { + assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A), A), { action: "prompt" }); +}); + +test("prompts when the stored key has no pubkey to compare (can't rule it out)", () => { + assert.deepEqual( + evaluateStoredNsecSwitch({ hasEncryptedKey: true, source: "nsec" }, A), + { action: "prompt" }, + ); +}); + +test("errors when there is no stored session at all", () => { + assert.deepEqual(evaluateStoredNsecSwitch(null, A), { + action: "error", + reason: "no-stored-key", + }); +}); + +test("errors when the stored key isn't an nsec (e.g. left-over non-nsec metadata)", () => { + assert.deepEqual( + evaluateStoredNsecSwitch({ hasEncryptedKey: true, pubkey: A, source: "extension" }, A), + { action: "error", reason: "no-stored-key" }, + ); +}); + +test("errors when the stored key belongs to a DIFFERENT account", () => { + assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A), B), { + action: "error", + reason: "pubkey-mismatch", + }); +}); + +test("case-insensitive pubkey match", () => { + assert.deepEqual(evaluateStoredNsecSwitch(nsecMeta(A.toUpperCase()), A), { + action: "prompt", + }); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 041cc7bb..8b70a0cd 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -594,20 +594,27 @@ toward freshness and looked identical. Gave each a structural identity: reliable zap data (couples with the zap system already shipped in #3). ### 48. Fluent profile switching across signing methods (nsec / NIP-46 / NIP-07) -- [ ] **CONFIRMED NOT DONE (verified 2026-06-30).** Switching to a saved **nsec** profile - fails: `switchProfile(pubkey, { providerId: "nsec" })` → `requestLogin` → the nsec - provider's `login()` is called with **no secret/passphrase**, so it throws - `"secret-required"` and the switch silently fails (caught + logged in - `profileModalController`). It never prompts for the PIN or routes to the stored-key - unlock flow. Same root gap as #36 (nsec signer needs the passphrase to be restored), - but in the SWITCH path. Fix: when switching to a saved nsec profile, detect the - stored encrypted key (`getStoredSessionActorMetadata`) and prompt for the pin/ - passphrase (route through `unlockStoredSessionActor`, e.g. open the login modal's - unlock flow), then activate. Apply the same for **NIP-46** (reconnect / re-authorize - the remote signer on switch if needed). Goal: switch fluently between NIP-07 ⇄ nsec - ⇄ NIP-46 accounts regardless of the current signing method. NOTE: multiple saved - nsec accounts share ONE stored session entry today — confirm per-account key storage - is needed for switching among several nsec profiles. +- [x] **nsec switching FIXED 2026-06-30.** Switching to a saved nsec profile used to fail + silently ("secret-required") — `switchProfile` called the nsec provider with no + passphrase and never prompted. Now `handleProfileSwitchRequest` detects an nsec + target, checks the stored key (`getStoredSessionActorMetadata` + + `evaluateStoredNsecSwitch` — must be a persisted nsec for THIS account), prompts for + the PIN/passphrase via a new `showPasswordPrompt` dialog + (`js/ui/promptDialog.js`), and calls `switchProfile(pubkey, { unlockStored: true, + passphrase })`. `authService.switchProfile` now forwards `unlockStored`/`passphrase` + → `requestLogin` → the nsec provider's `unlockStoredSessionActor` restores the signer + and activates the account. Wrong PIN → "Incorrect PIN/passphrase" toast; no stored + key / different account → a clear "log in with its nsec" toast. So you can switch + NIP-07 → nsec fluently. Tests: `tests/stored-nsec-switch.test.mjs` (6, pure gate) + + `tests/password-prompt.test.mjs` (6, dialog). Build + lint clean; auth suites 25/25. +- [ ] **VERIFY + extend to NIP-46 (follow-up).** My change only intercepts nsec targets; + NIP-46 switching is unchanged (`switchProfile({ providerId: "nip46" })`). Verify a + NIP-07 → NIP-46 (and nsec → NIP-46) switch actually reconnects/re-authorizes the + remote signer; if it fails, apply the same "prep before switch" pattern (reconnect + the bunker / re-open the NIP-46 connect flow). +- [ ] **Multiple nsec accounts:** the stored session is a SINGLE slot + (`bitvid:sessionActor:v1`), so only the last-persisted nsec key is on the device — + switching among several saved nsec accounts needs per-account encrypted-key storage. ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no From abdd11b18c505f710f7b5525d865d395031ad2e8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:19:13 -0400 Subject: [PATCH 06/48] [auth] #48: NIP-46 switch reconnects stored session + reject wrong-account switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes fluent switching across signing methods (NIP-07 / nsec / NIP-46): - NIP-46 switching: it had the same bug — switchProfile called the provider with no reuseStored, so it started a fresh handshake (needs a new connect URI/QR) instead of reconnecting. Now handleProfileSwitchRequest sends { providerId: "nip46", reuseStored: true } for NIP-46 targets; authService.switchProfile forwards reuseStored; the nip46 provider's useStoredRemoteSigner reconnects the stored session and activates the account. On failure -> a clear "reconnect your remote signer" toast. - Wrong-account guard (all methods): requestLogin now enforces expectPubkey (only set by switchProfile). A single-slot stored nsec/NIP-46 session resolving to a DIFFERENT account than requested is rejected with `pubkey-mismatch` instead of silently switching to the wrong profile. Also protects NIP-07 (switching to an account the extension isn't currently on fails clearly instead of grabbing the extension's active account). Tests: tests/authService.test.mjs (+3 — unlockStored/passphrase + reuseStored forwarding, expectPubkey rejection). Build + lint clean; auth suites green. Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 20 ++++++++++++----- js/services/authService.js | 15 +++++++++++++ tests/authService.test.mjs | 35 ++++++++++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 19 +++++++++++----- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index e156e7d3..0ba298eb 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -1406,12 +1406,14 @@ export function createAuthSessionCoordinator(deps) { throw new Error("Missing pubkey for profile switch request."); } - // Switching to a saved nsec account needs its passphrase — the private key is - // stored encrypted, so switchProfile alone throws "secret-required". Prompt for - // the PIN and unlock the stored key so the switch actually works (matches #36). + // Different signing methods need help to activate on switch: + // - nsec: its key is passphrase-encrypted → prompt for the PIN + unlock it. + // - NIP-46: reuse the stored remote-signer session (don't restart a handshake). + // - NIP-07: the extension connects directly, no prep needed. + const normalizedProviderId = String(providerId || "").trim().toLowerCase(); + const isNsecTarget = normalizedProviderId === "nsec"; + const isNip46Target = normalizedProviderId === "nip46"; let switchOptions = { providerId }; - const isNsecTarget = - String(providerId || "").trim().toLowerCase() === "nsec"; if (isNsecTarget) { const prep = await this.prepareStoredNsecSwitch(pubkey); if (prep.status !== "ok") { @@ -1422,6 +1424,8 @@ export function createAuthSessionCoordinator(deps) { unlockStored: true, passphrase: prep.passphrase, }; + } else if (isNip46Target) { + switchOptions = { providerId, reuseStored: true }; } let result; @@ -1434,6 +1438,12 @@ export function createAuthSessionCoordinator(deps) { ); return { switched: false, reason: "bad-passphrase" }; } + if (isNip46Target) { + this.showError?.( + "Couldn't reconnect to this account's remote signer. Reconnect it (Login → remote signer) to switch to it.", + ); + return { switched: false, reason: "nip46-reconnect-failed" }; + } throw error; } diff --git a/js/services/authService.js b/js/services/authService.js index 16d131b2..67542a09 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -248,6 +248,18 @@ export default class AuthService { return { pubkey: null, providerId, authType: detailAuthType, signer }; } + // Only switchProfile sets expectPubkey: don't activate a DIFFERENT account than the + // one requested (e.g. a single-slot stored nsec/NIP-46 session resolving to another + // pubkey). Reject instead of silently switching to the wrong profile. + const expectPubkey = this.normalizeHexPubkey(providerOptions?.expectPubkey); + if (expectPubkey && this.normalizeHexPubkey(trimmed) !== expectPubkey) { + const mismatchError = new Error( + "Signed in as a different account than the one selected.", + ); + mismatchError.code = "pubkey-mismatch"; + throw mismatchError; + } + if (autoApply === false) { return { pubkey: trimmed, providerId, authType: detailAuthType, signer }; } @@ -1003,6 +1015,9 @@ export default class AuthService { : "", } : {}), + // Switching to a saved NIP-46 account: reuse the stored remote-signer session + // instead of starting a fresh handshake (which would need a new connect URI/QR). + ...(normalizedOptions.reuseStored ? { reuseStored: true } : {}), }; const autoApply = requestOptions.autoApply !== false; diff --git a/tests/authService.test.mjs b/tests/authService.test.mjs index 3481aef0..16b92027 100644 --- a/tests/authService.test.mjs +++ b/tests/authService.test.mjs @@ -204,5 +204,40 @@ describe("AuthService Coverage", () => { // So yes, moved to front. assert.equal(saved[0].pubkey, pubkey1); }); + + // #48: fluent switching across signing methods — switchProfile must forward the + // per-method restore options to the provider. + test("forwards unlockStored + passphrase to the provider (nsec switch)", async () => { + const target = "00000000000000000000000000000000000000000000000000000000000000a1"; + mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: target, authType: "nsec" })); + await authService.switchProfile(target, { + providerId: "nsec", + unlockStored: true, + passphrase: "pin1234", + }); + const opts = mockAuthProvider.login.mock.calls.at(-1).arguments[0].options; + assert.equal(opts.unlockStored, true); + assert.equal(opts.passphrase, "pin1234"); + }); + + test("forwards reuseStored to the provider (NIP-46 switch)", async () => { + const target = "00000000000000000000000000000000000000000000000000000000000000b2"; + mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: target, authType: "nip46" })); + await authService.switchProfile(target, { providerId: "nip46", reuseStored: true }); + const opts = mockAuthProvider.login.mock.calls.at(-1).arguments[0].options; + assert.equal(opts.reuseStored, true); + }); + + test("rejects and does NOT activate a DIFFERENT account than requested (expectPubkey)", async () => { + const target = "00000000000000000000000000000000000000000000000000000000000000c3"; + const other = "00000000000000000000000000000000000000000000000000000000000000d4"; + // A single-slot stored session that resolves to another account: + mockAuthProvider.login.mock.mockImplementation(async () => ({ pubkey: other, authType: "nip46" })); + await assert.rejects( + () => authService.switchProfile(target, { providerId: "nip46", reuseStored: true }), + { code: "pubkey-mismatch" }, + ); + assert.notEqual(getPubkey(), other, "must not silently switch to the wrong account"); + }); }); }); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 8b70a0cd..b201aaa6 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -607,11 +607,20 @@ toward freshness and looked identical. Gave each a structural identity: key / different account → a clear "log in with its nsec" toast. So you can switch NIP-07 → nsec fluently. Tests: `tests/stored-nsec-switch.test.mjs` (6, pure gate) + `tests/password-prompt.test.mjs` (6, dialog). Build + lint clean; auth suites 25/25. -- [ ] **VERIFY + extend to NIP-46 (follow-up).** My change only intercepts nsec targets; - NIP-46 switching is unchanged (`switchProfile({ providerId: "nip46" })`). Verify a - NIP-07 → NIP-46 (and nsec → NIP-46) switch actually reconnects/re-authorizes the - remote signer; if it fails, apply the same "prep before switch" pattern (reconnect - the bunker / re-open the NIP-46 connect flow). +- [x] **NIP-46 switching FIXED 2026-06-30.** Confirmed the same bug: switching to a saved + NIP-46 account called the provider with no `reuseStored`, so it started a fresh + handshake (needing a new connect URI/QR) instead of reconnecting. Now + `handleProfileSwitchRequest` sends `{ providerId: "nip46", reuseStored: true }` for + NIP-46 targets → `switchProfile` forwards `reuseStored` → the nip46 provider's + `useStoredRemoteSigner` reconnects the stored session and activates the account. On + failure → "Couldn't reconnect to this account's remote signer" toast. +- [x] **Wrong-account guard (all methods) 2026-06-30.** `requestLogin` now enforces + `expectPubkey` (only set by `switchProfile`): if a single-slot stored session + resolves to a DIFFERENT account than requested, it rejects with `pubkey-mismatch` + instead of silently switching to the wrong profile. Also protects NIP-07 (switching + to an account the extension isn't currently on now fails clearly instead of grabbing + the extension's active account). Tests in `tests/authService.test.mjs` (option + forwarding + expectPubkey rejection). - [ ] **Multiple nsec accounts:** the stored session is a SINGLE slot (`bitvid:sessionActor:v1`), so only the last-persisted nsec key is on the device — switching among several saved nsec accounts needs per-account encrypted-key storage. From 8692dac6715f39065d193487857b9d6a091e6b3f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:33:03 -0400 Subject: [PATCH 07/48] [nostr-core] Per-account nsec key storage for multi-account switching (#48) Move the encrypted-key store from a single slot to a per-pubkey map so several saved nsec accounts each keep their own key on the device and the profile switcher can move between them. Previously bitvid:sessionActor:v1 held one key (last-saved wins), so switching among saved nsec accounts silently failed. - sessionActor.js: new bitvid:sessionActors:v2 map keyed by pubkey (follows the existing bitvid:: convention). readStoredSessionActorEntry, persistSessionActor, clearStoredSessionActor take an optional target pubkey; legacy v1 slot is kept in sync as the last-saved default and migrated into the map on first read. IndexedDB mirror keyed per-pubkey. Adds listStoredSessionActorPubkeys(). - client.js: getStoredSessionActorMetadata / unlockStoredSessionActor accept a target pubkey; access-denial cleanup forgets only the blocked account. - nsec provider threads expectPubkey into the unlock so a switch unlocks the requested account, with the expectPubkey guard as a backstop. - authSessionCoordinator: look up the target account's stored-key metadata. NIP-46 multi-account (single slot bitvid:nip46:session:v1) remains as a follow-up. Tests: tests/session-actor-multi-account.test.mjs (7). Co-Authored-By: Claude Opus 4.8 --- js/app/authSessionCoordinator.js | 2 +- js/nostr/client.js | 13 +- js/nostr/sessionActor.js | 221 ++++++++++++++++++++- js/services/authProviders/nsec.js | 6 +- tests/session-actor-multi-account.test.mjs | 123 ++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 22 +- 6 files changed, 372 insertions(+), 15 deletions(-) create mode 100644 tests/session-actor-multi-account.test.mjs diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 0ba298eb..f6511407 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -1380,7 +1380,7 @@ export function createAuthSessionCoordinator(deps) { async prepareStoredNsecSwitch(targetPubkey) { const meta = typeof nostrClient?.getStoredSessionActorMetadata === "function" - ? nostrClient.getStoredSessionActorMetadata() + ? nostrClient.getStoredSessionActorMetadata(targetPubkey) : null; const decision = evaluateStoredNsecSwitch(meta, targetPubkey); if (decision.action === "error") { diff --git a/js/nostr/client.js b/js/nostr/client.js index 053639da..64da3edc 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -2597,10 +2597,10 @@ export class NostrClient { // Metadata about a stored, passphrase-encrypted nsec session (no secrets). // The login modal reads `hasEncryptedKey` to offer the "unlock saved key" // flow; without this the unlock UI never appeared. - getStoredSessionActorMetadata() { + getStoredSessionActorMetadata(pubkey) { let entry = null; try { - entry = readStoredSessionActorEntry(); + entry = readStoredSessionActorEntry(pubkey); } catch (error) { devLogger.warn( "[nostr] Failed to read stored session actor metadata:", @@ -2623,14 +2623,16 @@ export class NostrClient { // passphrase and re-activating the signer via the same path as fresh login. // The access-control validator is enforced before activation, and a // now-blocked key is forgotten. - async unlockStoredSessionActor(passphrase, { validator } = {}) { + async unlockStoredSessionActor(passphrase, { validator, pubkey: targetPubkey } = {}) { if (typeof passphrase !== "string" || !passphrase.trim()) { const error = new Error("A passphrase is required to unlock the saved key."); error.code = "passphrase-required"; throw error; } - const entry = readStoredSessionActorEntry(); + // When switching accounts, `targetPubkey` selects that account's stored key + // so we unlock the one the user asked for — not just the last-saved default. + const entry = readStoredSessionActorEntry(targetPubkey); if (!entry || !entry.privateKeyEncrypted || !entry.encryption) { const error = new Error("There is no saved key to unlock."); error.code = "no-stored-session"; @@ -2646,7 +2648,8 @@ export class NostrClient { validator(storedPubkey); } catch (error) { try { - clearStoredSessionActorEntry(); + // Forget only the now-blocked account, not every saved key. + clearStoredSessionActorEntry(storedPubkey); } catch (cleanupError) { devLogger.warn( "[nostr] Failed to clear stored session after access denial:", diff --git a/js/nostr/sessionActor.js b/js/nostr/sessionActor.js index 937a087f..18b98b99 100644 --- a/js/nostr/sessionActor.js +++ b/js/nostr/sessionActor.js @@ -1,6 +1,13 @@ import { devLogger } from "../utils/logger.js"; export const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; +// Per-pubkey encrypted-key store. Follows the existing `bitvid::` +// convention (profileCache, nwcSettings, …) so several saved nsec accounts can +// each keep their own encrypted key on the device and be switched between. The +// v1 single slot above is kept in sync as the "active/last-saved" default and is +// migrated into this map on first read. +export const SESSION_ACTORS_MAP_STORAGE_KEY = "bitvid:sessionActors:v2"; +export const SESSION_ACTORS_MAP_VERSION = 2; export const SESSION_ACTOR_ENCRYPTION_VERSION = 1; export const SESSION_ACTOR_KDF_ITERATIONS = 250_000; export const SESSION_ACTOR_KDF_HASH = "SHA-256"; @@ -114,7 +121,9 @@ function persistSessionActorToIndexedDb(payload) { return new Promise((resolve, reject) => { const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite"); const store = tx.objectStore(SESSION_ACTOR_STORE); - store.put(payload, SESSION_ACTOR_STORAGE_KEY); + // Key each account's payload by pubkey so multiple saved accounts don't + // clobber one another (legacy single-slot records used the storage key). + store.put(payload, normalizePubkeyKey(payload?.pubkey) || SESSION_ACTOR_STORAGE_KEY); tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("Session actor IndexedDB write failed")); @@ -128,7 +137,7 @@ function persistSessionActorToIndexedDb(payload) { }); } -function clearStoredSessionActorIndexedDb() { +function clearStoredSessionActorIndexedDb(pubkey) { return openSessionActorDb() .then((db) => { if (!db) { @@ -137,7 +146,14 @@ function clearStoredSessionActorIndexedDb() { return new Promise((resolve, reject) => { const tx = db.transaction(SESSION_ACTOR_STORE, "readwrite"); const store = tx.objectStore(SESSION_ACTOR_STORE); - store.delete(SESSION_ACTOR_STORAGE_KEY); + const key = normalizePubkeyKey(pubkey); + if (key) { + // Forget just this account's key… + store.delete(key); + } else { + // …or wipe everything (logout / legacy single-slot record too). + store.clear(); + } tx.oncomplete = () => resolve(true); tx.onerror = () => reject(tx.error || new Error("Session actor IndexedDB delete failed")); @@ -351,7 +367,113 @@ export async function decryptSessionPrivateKey(payload, passphrase) { return decoder.decode(decrypted); } -export function readStoredSessionActorEntry() { +function normalizePubkeyKey(pubkey) { + return typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim().toLowerCase() + : ""; +} + +// Turn a parsed stored payload into a canonical entry, or null if it lacks a +// usable encrypted key. Never exposes plaintext (privateKey is always ""). +function normalizeStoredActorEntry(parsed) { + const pubkey = typeof parsed?.pubkey === "string" ? parsed.pubkey.trim() : ""; + const privateKeyEncrypted = + typeof parsed?.privateKeyEncrypted === "string" + ? parsed.privateKeyEncrypted.trim() + : ""; + const encryption = normalizeStoredEncryptionMetadata(parsed?.encryption); + const createdAt = Number.isFinite(parsed?.createdAt) + ? parsed.createdAt + : Date.now(); + if (!privateKeyEncrypted || !encryption) { + return null; + } + return { pubkey, privateKey: "", privateKeyEncrypted, encryption, createdAt }; +} + +// Read the per-pubkey map { [pubkeyLower]: entry } from localStorage. +function readSessionActorsMap() { + if (typeof localStorage === "undefined") { + return {}; + } + let raw = null; + try { + raw = localStorage.getItem(SESSION_ACTORS_MAP_STORAGE_KEY); + } catch (error) { + devLogger.warn("[nostr] Failed to read session actor map:", error); + return {}; + } + if (!raw || typeof raw !== "string") { + return {}; + } + try { + const parsed = JSON.parse(raw); + const entries = + parsed && typeof parsed === "object" ? parsed.entries : null; + if (!entries || typeof entries !== "object") { + return {}; + } + const map = {}; + for (const key of Object.keys(entries)) { + const entry = normalizeStoredActorEntry(entries[key]); + if (!entry) { + continue; + } + const normalizedKey = normalizePubkeyKey(entry.pubkey || key); + if (normalizedKey) { + map[normalizedKey] = entry; + } + } + return map; + } catch (error) { + devLogger.warn("[nostr] Failed to parse session actor map:", error); + try { + localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY); + } catch (cleanupError) { + devLogger.warn( + "[nostr] Failed to clear corrupt session actor map:", + cleanupError, + ); + } + return {}; + } +} + +function writeSessionActorsMap(map) { + if (typeof localStorage === "undefined") { + return; + } + const entries = {}; + for (const key of Object.keys(map || {})) { + const entry = map[key]; + if (!entry || !entry.privateKeyEncrypted || !entry.encryption) { + continue; + } + entries[key] = { + pubkey: entry.pubkey || key, + privateKeyEncrypted: entry.privateKeyEncrypted, + encryption: entry.encryption, + createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(), + }; + } + try { + if (!Object.keys(entries).length) { + localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY); + return; + } + localStorage.setItem( + SESSION_ACTORS_MAP_STORAGE_KEY, + JSON.stringify({ version: SESSION_ACTORS_MAP_VERSION, entries }), + ); + } catch (error) { + devLogger.warn("[nostr] Failed to persist session actor map:", error); + } +} + +// Read the legacy single-slot (v1) entry. Also handles the legacy plaintext +// migration: a payload that still carries `privateKey` is re-persisted in the +// encrypted-only shape, or cleared if it can't be salvaged. +function readV1Entry() { if (typeof localStorage === "undefined") { return null; } @@ -427,6 +549,50 @@ export function readStoredSessionActorEntry() { return null; } +// Read a stored, passphrase-encrypted nsec entry. +// readStoredSessionActorEntry(pubkey) → that specific account's entry (or null) +// readStoredSessionActorEntry() → the active/last-saved account (v1 slot), +// for boot restore / backward-compat. +// The v1 single slot is migrated into the per-pubkey map on first read so +// existing single-account installs keep working and gain multi-account storage. +export function readStoredSessionActorEntry(pubkey) { + const v1 = readV1Entry(); + const map = readSessionActorsMap(); + + // Migrate the legacy single slot into the per-pubkey map. + if (v1 && v1.pubkey) { + const key = normalizePubkeyKey(v1.pubkey); + if (key && !map[key]) { + map[key] = v1; + writeSessionActorsMap(map); + } + } + + const target = normalizePubkeyKey(pubkey); + if (target) { + if (map[target]) { + return map[target]; + } + if (v1 && normalizePubkeyKey(v1.pubkey) === target) { + return v1; + } + return null; + } + + // No target: the active/last-saved account (v1 slot). Fall back to the sole + // stored account if the v1 slot was cleared but the map still has exactly one. + if (v1) { + return v1; + } + const keys = Object.keys(map); + return keys.length === 1 ? map[keys[0]] : null; +} + +// Pubkeys (hex, lowercase) of every account with an encrypted key on this device. +export function listStoredSessionActorPubkeys() { + return Object.keys(readSessionActorsMap()); +} + export function persistSessionActor(actor) { if ( !actor || @@ -458,6 +624,21 @@ export function persistSessionActor(actor) { persistSessionActorToIndexedDb(payload); + // Store under the account's pubkey so several saved accounts coexist… + const key = normalizePubkeyKey(payload.pubkey); + if (key) { + const map = readSessionActorsMap(); + map[key] = { + pubkey: payload.pubkey, + privateKeyEncrypted: payload.privateKeyEncrypted, + encryption: payload.encryption, + createdAt, + }; + writeSessionActorsMap(map); + } + + // …and keep the v1 single slot pointed at the last-saved account so boot + // restore and no-arg reads resolve a sensible default. if (typeof localStorage !== "undefined") { try { localStorage.setItem( @@ -470,7 +651,32 @@ export function persistSessionActor(actor) { } } -export function clearStoredSessionActor() { +// Forget a stored key. With a pubkey, only that account is removed (the others +// stay switchable); without one, every stored account is wiped (logout). +export function clearStoredSessionActor(pubkey) { + const target = normalizePubkeyKey(pubkey); + + if (target) { + clearStoredSessionActorIndexedDb(target); + const map = readSessionActorsMap(); + if (map[target]) { + delete map[target]; + writeSessionActorsMap(map); + } + // Drop the v1 default only if it pointed at the account we just removed. + if (typeof localStorage !== "undefined") { + const v1 = readV1Entry(); + if (v1 && normalizePubkeyKey(v1.pubkey) === target) { + try { + localStorage.removeItem(SESSION_ACTOR_STORAGE_KEY); + } catch (error) { + devLogger.warn("[nostr] Failed to clear stored session actor:", error); + } + } + } + return; + } + clearStoredSessionActorIndexedDb(); if (typeof localStorage !== "undefined") { @@ -479,6 +685,11 @@ export function clearStoredSessionActor() { } catch (error) { devLogger.warn("[nostr] Failed to clear stored session actor:", error); } + try { + localStorage.removeItem(SESSION_ACTORS_MAP_STORAGE_KEY); + } catch (error) { + devLogger.warn("[nostr] Failed to clear session actor map:", error); + } } } diff --git a/js/services/authProviders/nsec.js b/js/services/authProviders/nsec.js index 705005ca..5b1aacbc 100644 --- a/js/services/authProviders/nsec.js +++ b/js/services/authProviders/nsec.js @@ -60,6 +60,10 @@ function normalizeOptions(options) { persist: options.persist === true, passphrase: typeof options.passphrase === "string" ? options.passphrase : "", unlockStored: options.unlockStored === true, + // Set when switching to a specific saved account, so the stored-key unlock + // targets that account rather than the last-saved default. + expectPubkey: + typeof options.expectPubkey === "string" ? options.expectPubkey.trim() : "", }; } @@ -120,7 +124,7 @@ export default { const unlockResult = await nostrClient.unlockStoredSessionActor( suppliedPassphrase, - { validator }, + { validator, pubkey: normalized.expectPubkey || undefined }, ); const unlockedPubkey = normalizePubkey(unlockResult); diff --git a/tests/session-actor-multi-account.test.mjs b/tests/session-actor-multi-account.test.mjs new file mode 100644 index 00000000..a54cf89c --- /dev/null +++ b/tests/session-actor-multi-account.test.mjs @@ -0,0 +1,123 @@ +// Per-account nsec key storage: several saved accounts each keep their own +// encrypted key on the device so the profile switcher can move between them. +// Before this, a single slot (bitvid:sessionActor:v1) meant only the last-saved +// key survived, so switching among saved nsec accounts silently failed. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-nsec-multi-account-storage +// given: "two nsec accounts each persist their encrypted key" +// when: "the store is read by a specific pubkey / cleared for one account" +// then: "each account resolves its own key; clearing one keeps the other" +// observable_outcomes: +// - "readStoredSessionActorEntry(pubkey) returns that account's ciphertext" +// - "clearStoredSessionActor(pubkey) removes only the named account" +// - "the legacy v1 single slot migrates into the per-pubkey map on read" +// determinism_controls: +// - "in-memory localStorage polyfill; no network/clock dependence" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test, { beforeEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { + persistSessionActor, + readStoredSessionActorEntry, + clearStoredSessionActor, + listStoredSessionActorPubkeys, + SESSION_ACTOR_STORAGE_KEY, + SESSION_ACTORS_MAP_STORAGE_KEY, +} from "../js/nostr/sessionActor.js"; + +const PUB_A = + "00000000000000000000000000000000000000000000000000000000000000a1"; +const PUB_B = + "00000000000000000000000000000000000000000000000000000000000000b2"; + +function actor(pubkey, cipher) { + return { + pubkey, + privateKeyEncrypted: cipher, + encryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" }, + createdAt: 1700000000000, + }; +} + +beforeEach(() => { + localStorage.clear(); +}); + +test("each saved account resolves its own encrypted key", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + + assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "cipher-A"); + assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B"); + // Never leak plaintext through the read path. + assert.equal(readStoredSessionActorEntry(PUB_A).privateKey, ""); +}); + +test("lists every account that has a stored key", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + assert.deepEqual(listStoredSessionActorPubkeys().sort(), [PUB_A, PUB_B].sort()); +}); + +test("clearing one account keeps the others switchable", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + + clearStoredSessionActor(PUB_A); + + assert.equal(readStoredSessionActorEntry(PUB_A), null); + assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B"); +}); + +test("no-arg read returns the last-saved account as the default", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + assert.equal(readStoredSessionActorEntry().privateKeyEncrypted, "cipher-B"); +}); + +test("no-arg clear wipes every stored account (logout)", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + persistSessionActor(actor(PUB_B, "cipher-B")); + + clearStoredSessionActor(); + + assert.deepEqual(listStoredSessionActorPubkeys(), []); + assert.equal(readStoredSessionActorEntry(PUB_A), null); + assert.equal(readStoredSessionActorEntry(), null); +}); + +test("a legacy v1 single-slot entry migrates into the per-pubkey map", () => { + // Simulate an install that only ever had the old single slot. + localStorage.setItem( + SESSION_ACTOR_STORAGE_KEY, + JSON.stringify({ + pubkey: PUB_A, + privateKeyEncrypted: "legacy-cipher", + encryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" }, + createdAt: 1699999999000, + }), + ); + assert.equal(localStorage.getItem(SESSION_ACTORS_MAP_STORAGE_KEY), null); + + // Reading by the account's pubkey works and seeds the v2 map. + assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "legacy-cipher"); + assert.deepEqual(listStoredSessionActorPubkeys(), [PUB_A]); + + // A second account can now be added alongside the migrated one. + persistSessionActor(actor(PUB_B, "cipher-B")); + assert.equal(readStoredSessionActorEntry(PUB_A).privateKeyEncrypted, "legacy-cipher"); + assert.equal(readStoredSessionActorEntry(PUB_B).privateKeyEncrypted, "cipher-B"); +}); + +test("requesting an account with no stored key returns null (not a wrong one)", () => { + persistSessionActor(actor(PUB_A, "cipher-A")); + assert.equal(readStoredSessionActorEntry(PUB_B), null); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index b201aaa6..2c360cf0 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -621,9 +621,25 @@ toward freshness and looked identical. Gave each a structural identity: to an account the extension isn't currently on now fails clearly instead of grabbing the extension's active account). Tests in `tests/authService.test.mjs` (option forwarding + expectPubkey rejection). -- [ ] **Multiple nsec accounts:** the stored session is a SINGLE slot - (`bitvid:sessionActor:v1`), so only the last-persisted nsec key is on the device — - switching among several saved nsec accounts needs per-account encrypted-key storage. +- [x] **Multiple nsec accounts — DONE 2026-06-30.** The encrypted-key store is now + per-pubkey (`bitvid:sessionActors:v2` = `{ [pubkeyLower]: { privateKeyEncrypted, + encryption, createdAt } }`), following the existing `bitvid::` + convention (profileCache, nwcSettings, …). Several saved nsec accounts each keep + their own key on the device, so the switcher can move between them. The legacy v1 + single slot is kept in sync as the "last-saved default" (boot restore / no-arg + reads) and is migrated into the map on first read. `readStoredSessionActorEntry`, + `getStoredSessionActorMetadata`, and `unlockStoredSessionActor` all take an optional + target pubkey; the nsec provider threads `expectPubkey` through so a switch unlocks + the requested account (not the last-saved one), with the `expectPubkey` guard as a + backstop. IndexedDB mirror is now keyed per-pubkey too. Added + `listStoredSessionActorPubkeys()`. Tests: `tests/session-actor-multi-account.test.mjs` + (7 — per-account resolve, list, clear-one-keeps-others, no-arg default, no-arg wipe, + v1→v2 migration, missing-account returns null). +- [ ] **Multiple NIP-46 accounts (remaining):** the remote-signer session is still a + SINGLE slot (`bitvid:nip46:session:v1`), so only the last-connected remote signer is + restorable — switching among several saved NIP-46 accounts needs the same per-pubkey + treatment applied to `js/nostr/nip46Client.js` (session store keyed by user pubkey + + `useStoredRemoteSigner({ pubkey })` selecting it). Separate, larger change than nsec. ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no From 95fe88784a4f5e78e4783cd0d853611d2120d429 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:40:22 -0400 Subject: [PATCH 08/48] [nostr-core] Per-account NIP-46 session storage for multi-account switching (#48) Mirror the per-account nsec key store for remote-signer sessions: move from a single slot (bitvid:nip46:session:v1) to a per-user-pubkey map (bitvid:nip46:sessions:v2) so several saved NIP-46 accounts each keep their own reconnectable session and the profile switcher can move between them. Previously only the last-connected session survived, so switching among saved NIP-46 accounts reconnected the wrong one (or failed). - nip46Client.js: new per-pubkey session map keyed by userPubkey. readStoredNip46Session takes an optional target pubkey; writeStoredNip46- SessionSync writes both the map and the v1 last-connected default; clearStoredNip46Session(pubkey) forgets one account (no-arg wipes all). Legacy v1 slot migrated into the map on first read. Adds listStoredNip46SessionPubkeys(). - SignerManager: useStoredRemoteSigner({ pubkey }) selects the target account's session and, on access-denial, forgets only that account. getStoredNip46- Metadata(pubkey) is per-account. - nip46 provider threads expectPubkey into the reuse call so a switch reconnects the requested account, with the expectPubkey guard as a backstop. nip46Connector.js is dead code; the live path is entirely in SignerManager. Tests: tests/nip46-multi-account.test.mjs (7). Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 4 +- js/nostr/managers/SignerManager.js | 17 ++- js/nostr/nip46Client.js | 173 ++++++++++++++++++++++++++++- js/services/authProviders/nip46.js | 9 +- tests/nip46-multi-account.test.mjs | 127 +++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 19 +++- 6 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 tests/nip46-multi-account.test.mjs diff --git a/js/nostr/client.js b/js/nostr/client.js index 64da3edc..38d8993c 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -2919,8 +2919,8 @@ export class NostrClient { return this.signerManager.onRemoteSignerChange(listener); } - getStoredNip46Metadata() { - return this.signerManager.getStoredNip46Metadata(); + getStoredNip46Metadata(pubkey) { + return this.signerManager.getStoredNip46Metadata(pubkey); } async ensureExtensionPermissions( diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index 84da75f4..6338d4fc 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -778,8 +778,14 @@ export class SignerManager { typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null; + // When switching accounts, select that account's stored session so we + // reconnect the one the user asked for — not just the last-connected default. + const targetPubkey = + typeof normalizedOptions.pubkey === "string" + ? normalizedOptions.pubkey.trim().toLowerCase() + : ""; - let stored = readStoredNip46Session(); + let stored = readStoredNip46Session(targetPubkey || undefined); if (!stored) { const error = new Error( "No remote signer session is stored on this device.", @@ -850,9 +856,10 @@ export class SignerManager { this.nip46Client = null; this.emitRemoteSignerChange({ state: "error", error: err }); // Always forget a stored session that was rejected by access control — - // no point keeping a blocked pubkey's session to retry. + // no point keeping a blocked pubkey's session to retry. Forget only the + // account we tried, leaving other saved sessions switchable. if (forgetOnError || err?.code === "remote-signer-access-denied") { - clearStoredNip46Session(); + clearStoredNip46Session(stored?.userPubkey || targetPubkey || undefined); } throw err; } @@ -947,8 +954,8 @@ export class SignerManager { this.emitRemoteSignerChange(); } - getStoredNip46Metadata() { - const stored = readStoredNip46Session() || {}; + getStoredNip46Metadata(pubkey) { + const stored = readStoredNip46Session(pubkey) || {}; const userPubkey = typeof stored.userPubkey === "string" ? stored.userPubkey : ""; return { diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index 893d25b5..310e4906 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -46,6 +46,13 @@ import { SHORT_TIMEOUT_MS } from "../constants.js"; export const NIP46_RPC_KIND = 24_133; const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; +// Per-user-pubkey remote-signer session store, mirroring the per-account nsec +// key store (bitvid:sessionActors:v2). Lets several saved NIP-46 accounts each +// keep their own reconnectable session so the profile switcher can move between +// them. The v1 single slot above is kept in sync as the last-connected default +// (boot restore) and migrated into this map on first read. +const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2"; +const NIP46_SESSIONS_MAP_VERSION = 2; const NIP46_PUBLISH_TIMEOUT_MS = 8_000; const NIP46_PING_TIMEOUT_MS = SHORT_TIMEOUT_MS; const NIP46_RESPONSE_TIMEOUT_MS = 15_000; @@ -248,7 +255,88 @@ export function sanitizeStoredNip46Session(candidate) { }; } -export function readStoredNip46Session() { +function normalizeNip46PubkeyKey(pubkey) { + return typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim().toLowerCase() + : ""; +} + +// Read the per-user-pubkey session map { [userPubkeyLower]: session }. +function readNip46SessionsMap() { + const storage = getNip46Storage(); + if (!storage) { + return {}; + } + let raw = null; + try { + raw = storage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY); + } catch (error) { + return {}; + } + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw); + const entries = + parsed && typeof parsed === "object" ? parsed.entries : null; + if (!entries || typeof entries !== "object") { + return {}; + } + const map = {}; + for (const key of Object.keys(entries)) { + const session = sanitizeStoredNip46Session(entries[key]); + if (!session) { + continue; + } + const normalizedKey = normalizeNip46PubkeyKey(session.userPubkey || key); + if (normalizedKey) { + map[normalizedKey] = session; + } + } + return map; + } catch (error) { + try { + storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY); + } catch (cleanupError) { + devLogger.warn( + "[nostr] Failed to clear corrupt NIP-46 session map:", + cleanupError, + ); + } + return {}; + } +} + +function writeNip46SessionsMap(map) { + const storage = getNip46Storage(); + if (!storage) { + return; + } + const entries = {}; + for (const key of Object.keys(map || {})) { + const session = sanitizeStoredNip46Session(map[key]); + if (session) { + entries[key] = session; + } + } + try { + if (!Object.keys(entries).length) { + storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY); + return; + } + storage.setItem( + NIP46_SESSIONS_MAP_STORAGE_KEY, + JSON.stringify({ version: NIP46_SESSIONS_MAP_VERSION, entries }), + ); + } catch (error) { + // ignore persistence failures + } +} + +// Read the legacy v1 single slot, re-persisting it in the sanitized (no +// plaintext) shape if it still carries raw credentials. +function readV1Nip46Session() { const storage = getNip46Storage(); if (!storage) { return null; @@ -289,6 +377,49 @@ export function readStoredNip46Session() { } } +// Read a stored remote-signer session. +// readStoredNip46Session(pubkey) → that account's session (or null) +// readStoredNip46Session() → the last-connected account (v1 slot), +// for boot restore / backward-compat. +// The v1 single slot is migrated into the per-pubkey map on first read. +export function readStoredNip46Session(pubkey) { + const v1 = readV1Nip46Session(); + const map = readNip46SessionsMap(); + + // Migrate the legacy single slot into the per-pubkey map. + if (v1 && v1.userPubkey) { + const key = normalizeNip46PubkeyKey(v1.userPubkey); + if (key && !map[key]) { + map[key] = v1; + writeNip46SessionsMap(map); + } + } + + const target = normalizeNip46PubkeyKey(pubkey); + if (target) { + if (map[target]) { + return map[target]; + } + if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) { + return v1; + } + return null; + } + + // No target: the last-connected account. Fall back to the sole stored session + // if the v1 slot was cleared but the map still holds exactly one. + if (v1) { + return v1; + } + const keys = Object.keys(map); + return keys.length === 1 ? map[keys[0]] : null; +} + +// User pubkeys (hex, lowercase) of every account with a stored session. +export function listStoredNip46SessionPubkeys() { + return Object.keys(readNip46SessionsMap()); +} + export function writeStoredNip46SessionSync(payload) { const storage = getNip46Storage(); if (!storage) { @@ -321,6 +452,16 @@ export function writeStoredNip46SessionSync(payload) { return; } + // Store under the account's user pubkey so several saved accounts coexist… + const key = normalizeNip46PubkeyKey(normalized.userPubkey); + if (key) { + const map = readNip46SessionsMap(); + map[key] = normalized; + writeNip46SessionsMap(map); + } + + // …and keep the v1 single slot pointed at the last-connected account so boot + // restore and no-arg reads resolve a sensible default. try { storage.setItem(NIP46_SESSION_STORAGE_KEY, JSON.stringify(normalized)); } catch (error) { @@ -412,17 +553,45 @@ export async function decryptNip46Session(session, passphrase) { }; } -export function clearStoredNip46Session() { +// Forget a stored session. With a pubkey, only that account is removed (the +// others stay reconnectable); without one, every stored session is wiped +// (logout). +export function clearStoredNip46Session(pubkey) { const storage = getNip46Storage(); if (!storage) { return; } + const target = normalizeNip46PubkeyKey(pubkey); + + if (target) { + const map = readNip46SessionsMap(); + if (map[target]) { + delete map[target]; + writeNip46SessionsMap(map); + } + // Drop the v1 default only if it pointed at the account we just removed. + const v1 = readV1Nip46Session(); + if (v1 && normalizeNip46PubkeyKey(v1.userPubkey) === target) { + try { + storage.removeItem(NIP46_SESSION_STORAGE_KEY); + } catch (error) { + // ignore cleanup issues + } + } + return; + } + try { storage.removeItem(NIP46_SESSION_STORAGE_KEY); } catch (error) { // ignore cleanup issues } + try { + storage.removeItem(NIP46_SESSIONS_MAP_STORAGE_KEY); + } catch (error) { + // ignore cleanup issues + } } export function decodeNpubToHex(npub) { diff --git a/js/services/authProviders/nip46.js b/js/services/authProviders/nip46.js index 3e421c87..ac8a8841 100644 --- a/js/services/authProviders/nip46.js +++ b/js/services/authProviders/nip46.js @@ -97,6 +97,10 @@ function normalizeOptions(options) { handshakeTimeoutMs: Number.isFinite(options.handshakeTimeoutMs) ? Number(options.handshakeTimeoutMs) : undefined, + // Set when switching to a specific saved account, so reusing a stored + // session targets that account rather than the last-connected default. + expectPubkey: + typeof options.expectPubkey === "string" ? options.expectPubkey.trim() : "", }; } @@ -163,7 +167,10 @@ export default { // For now, I will assume I will fix client.js later or accept that stored sessions are trusted? // No, I must fix useStoredRemoteSigner too. - result = await nostrClient.useStoredRemoteSigner({ validator }); + result = await nostrClient.useStoredRemoteSigner({ + validator, + pubkey: normalized.expectPubkey || undefined, + }); devLogger.debug("[nip46] Stored remote signer returned", { pubkey: summarizeHexForLog(normalizePubkey(result)), }); diff --git a/tests/nip46-multi-account.test.mjs b/tests/nip46-multi-account.test.mjs new file mode 100644 index 00000000..9d18d896 --- /dev/null +++ b/tests/nip46-multi-account.test.mjs @@ -0,0 +1,127 @@ +// Per-account NIP-46 remote-signer session storage: several saved NIP-46 +// accounts each keep their own reconnectable session so the profile switcher +// can move between them. Before this, a single slot (bitvid:nip46:session:v1) +// meant only the last-connected session survived, so switching among saved +// NIP-46 accounts silently reconnected the wrong one (or failed). +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-nip46-multi-account-storage +// given: "two NIP-46 accounts each persist a stored session" +// when: "the store is read by user pubkey / cleared for one account" +// then: "each account resolves its own session; clearing one keeps the other" +// observable_outcomes: +// - "readStoredNip46Session(pubkey) returns that account's remotePubkey/relays" +// - "clearStoredNip46Session(pubkey) removes only the named account" +// - "the legacy v1 single slot migrates into the per-pubkey map on read" +// determinism_controls: +// - "in-memory localStorage polyfill; no network/clock dependence" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test, { beforeEach } from "node:test"; +import { strict as assert } from "node:assert"; +import { + writeStoredNip46SessionSync, + readStoredNip46Session, + clearStoredNip46Session, + listStoredNip46SessionPubkeys, +} from "../js/nostr/nip46Client.js"; + +const USER_A = + "00000000000000000000000000000000000000000000000000000000000000a1"; +const USER_B = + "00000000000000000000000000000000000000000000000000000000000000b2"; +const REMOTE_A = + "1111111111111111111111111111111111111111111111111111111111111111"; +const REMOTE_B = + "2222222222222222222222222222222222222222222222222222222222222222"; +const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; +const NIP46_SESSIONS_MAP_STORAGE_KEY = "bitvid:nip46:sessions:v2"; + +function session(userPubkey, remotePubkey, relay) { + return { + version: 1, + clientPublicKey: + "3333333333333333333333333333333333333333333333333333333333333333", + remotePubkey, + relays: [relay], + encryption: "nip44.v2", + userPubkey, + lastConnectedAt: 1700000000000, + encryptedSecrets: "cipher", + keyEncryption: { salt: "c2FsdA==", iv: "aXY=", iterations: 1000, hash: "SHA-256" }, + }; +} + +beforeEach(() => { + localStorage.clear(); +}); + +test("each saved account resolves its own session", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + + assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A); + assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B); + assert.deepEqual(readStoredNip46Session(USER_A).relays, ["wss://a.example"]); +}); + +test("lists every account that has a stored session", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + assert.deepEqual(listStoredNip46SessionPubkeys().sort(), [USER_A, USER_B].sort()); +}); + +test("clearing one account keeps the others reconnectable", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + + clearStoredNip46Session(USER_A); + + assert.equal(readStoredNip46Session(USER_A), null); + assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B); +}); + +test("no-arg read returns the last-connected account as the default", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + assert.equal(readStoredNip46Session().remotePubkey, REMOTE_B); +}); + +test("no-arg clear wipes every stored session (logout)", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + + clearStoredNip46Session(); + + assert.deepEqual(listStoredNip46SessionPubkeys(), []); + assert.equal(readStoredNip46Session(USER_A), null); + assert.equal(readStoredNip46Session(), null); +}); + +test("a legacy v1 single-slot session migrates into the per-pubkey map", () => { + // Simulate an install that only ever had the old single slot. + localStorage.setItem( + NIP46_SESSION_STORAGE_KEY, + JSON.stringify(session(USER_A, REMOTE_A, "wss://a.example")), + ); + assert.equal(localStorage.getItem(NIP46_SESSIONS_MAP_STORAGE_KEY), null); + + assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A); + assert.deepEqual(listStoredNip46SessionPubkeys(), [USER_A]); + + // A second account can now be added alongside the migrated one. + writeStoredNip46SessionSync(session(USER_B, REMOTE_B, "wss://b.example")); + assert.equal(readStoredNip46Session(USER_A).remotePubkey, REMOTE_A); + assert.equal(readStoredNip46Session(USER_B).remotePubkey, REMOTE_B); +}); + +test("requesting an account with no stored session returns null (not a wrong one)", () => { + writeStoredNip46SessionSync(session(USER_A, REMOTE_A, "wss://a.example")); + assert.equal(readStoredNip46Session(USER_B), null); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 2c360cf0..0dc12b4f 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -635,11 +635,20 @@ toward freshness and looked identical. Gave each a structural identity: `listStoredSessionActorPubkeys()`. Tests: `tests/session-actor-multi-account.test.mjs` (7 — per-account resolve, list, clear-one-keeps-others, no-arg default, no-arg wipe, v1→v2 migration, missing-account returns null). -- [ ] **Multiple NIP-46 accounts (remaining):** the remote-signer session is still a - SINGLE slot (`bitvid:nip46:session:v1`), so only the last-connected remote signer is - restorable — switching among several saved NIP-46 accounts needs the same per-pubkey - treatment applied to `js/nostr/nip46Client.js` (session store keyed by user pubkey + - `useStoredRemoteSigner({ pubkey })` selecting it). Separate, larger change than nsec. +- [x] **Multiple NIP-46 accounts — DONE 2026-06-30.** The remote-signer session store is + now per-user-pubkey (`bitvid:nip46:sessions:v2` = `{ [userPubkeyLower]: session }`), + mirroring the nsec store. Several saved NIP-46 accounts each keep their own + reconnectable session, so the switcher can move between them. The legacy v1 single + slot is kept in sync as the last-connected default (boot restore) and migrated into + the map on first read. `readStoredNip46Session` and `getStoredNip46Metadata` take an + optional target pubkey; `writeStoredNip46SessionSync` writes both the map (keyed by + `userPubkey`) and the v1 default; `clearStoredNip46Session(pubkey)` forgets one + account (no-arg wipes all, for logout). `SignerManager.useStoredRemoteSigner({ pubkey + })` selects the target account's session and, on access-denial, forgets only that + account; the nsec/nip46 providers thread `expectPubkey` through so a switch + reconnects the requested account (with the `expectPubkey` guard as a backstop). Added + `listStoredNip46SessionPubkeys()`. (`nip46Connector.js` is dead code — the live path + is entirely in `SignerManager`.) Tests: `tests/nip46-multi-account.test.mjs` (7). ### 8. Orphan storage garbage-collection tool - [x] **Largely delivered by the My Videos tab** (`9d3a0df0`): lists bucket objects no From 61e4b25de707e32c4dca5b1f491da7efd5a3173f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:49:02 -0400 Subject: [PATCH 09/48] [feed] Auto-refresh + optimistic insert after publishing a video (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful publish, the new video now appears in the feed instantly without a manual page reload. - Add nostrClient.ingestLocalVideoEvent(rawEvent) + a thin nostrService wrapper: optimistically insert the just-published event into the shared active-video cache. Mirrors live ingestion (convert -> root created_at -> tombstone guard -> allEvents/activeMap), so it obeys the same dedupe/active-key rules — a later relay echo of the same event won't duplicate it and an older revision can't overwrite a newer active entry (#20/#21). - app.publishVideoNote: inject publishResult.legacy, then refresh via the canonical refreshAllVideoGrids({ reason: "video-published", forceMainReload: true }) instead of a bare loadVideos() — refreshes whichever feed is active (For You / Explore / Recent) plus subscription/channel grids and forces a render (bypassing the load cooldown) so the injected video shows. Tests: tests/optimistic-feed-insert.test.mjs (6). Co-Authored-By: Claude Opus 4.8 --- js/app.js | 26 +++++- js/nostr/client.js | 56 +++++++++++++ js/services/nostrService.js | 18 +++++ tests/optimistic-feed-insert.test.mjs | 112 ++++++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 21 +++-- 5 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 tests/optimistic-feed-insert.test.mjs diff --git a/js/app.js b/js/app.js index 3954d30c..8c3fea39 100644 --- a/js/app.js +++ b/js/app.js @@ -3197,8 +3197,9 @@ class Application { } let relaySummary = null; + let publishResult = null; try { - const publishResult = await this.nostrService.publishVideoNote( + publishResult = await this.nostrService.publishVideoNote( publishPayload, this.pubkey, ); @@ -3219,10 +3220,31 @@ class Application { this.uploadModal.close(); } + // Optimistically insert the just-published note into the shared cache so it + // appears in the feed instantly, before the relays echo it back. Obeys the + // same dedupe/active-key rules as live ingestion, so a later relay copy + // won't duplicate it (see #20/#21). + try { + if (publishResult?.legacy) { + this.nostrService.ingestLocalVideoEvent(publishResult.legacy); + } + } catch (error) { + devLogger.warn( + "[Application] Optimistic feed insert after publishing failed:", + error, + ); + } + let loadVideosError = null; try { - await this.loadVideos(); + // Refresh whichever feed is active (For You / Explore / Recent) plus the + // subscription and channel grids — not just the main recent feed — and + // force a render so the newly injected video is shown. + await this.refreshAllVideoGrids({ + reason: "video-published", + forceMainReload: true, + }); } catch (error) { loadVideosError = error; devLogger.error( diff --git a/js/nostr/client.js b/js/nostr/client.js index 38d8993c..e7fa1fd2 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -4306,6 +4306,62 @@ export class NostrClient { ); } + // Optimistically add a just-published video event to the local caches so it + // shows in the feed immediately, without waiting for it to propagate back + // from the relays. Mirrors the per-event ingestion the live subscription and + // fetchVideosByAuthors use (convert → root created_at → tombstone guard → + // allEvents/activeMap), so it obeys the same dedupe/active-key rules — a later + // relay copy of the same event (same created_at) won't displace it, and an + // older revision can't overwrite a newer active entry. Returns the ingested + // video, or null if the event isn't a usable video note. + ingestLocalVideoEvent(rawEvent) { + if (!rawEvent || typeof rawEvent !== "object" || !rawEvent.id) { + return null; + } + + let video; + try { + video = convertEventToVideo(rawEvent); + } catch (error) { + devLogger.warn( + "[nostr] Failed to convert published event for optimistic feed insert:", + error, + ); + return null; + } + if (!video || video.invalid) { + return null; + } + + this.applyRootCreatedAt(video); + if (this.rawEvents && typeof this.rawEvents.set === "function") { + this.rawEvents.set(rawEvent.id, rawEvent); + } + + const activeKey = getActiveKey(video); + if (video.deleted) { + this.recordTombstone(activeKey, video.created_at); + return video; + } + + this.applyTombstoneGuard(video); + if (video.deleted) { + // A tombstone guard flipped it to deleted — don't surface it. + return video; + } + + this.allEvents.set(rawEvent.id, video); + this.dirtyEventIds.add(rawEvent.id); + + const existing = this.activeMap.get(activeKey); + if (!existing || video.created_at > existing.created_at) { + this.activeMap.set(activeKey, video); + this.applyRootCreatedAt(video); + } + + return video; + } + handleEvent(event) { if (!event || typeof event !== "object") return; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 639f2713..203d915f 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -1575,6 +1575,24 @@ export class NostrService { return this.filterVideos(all, options); } + // Optimistically insert a just-published video into the shared client caches + // so the feed can render it before it round-trips through the relays. Keeps + // the author index in sync so channel/subscription grids reflect it too. + ingestLocalVideoEvent(rawEvent) { + if ( + !this.nostrClient || + typeof this.nostrClient.ingestLocalVideoEvent !== "function" + ) { + return null; + } + const video = this.nostrClient.ingestLocalVideoEvent(rawEvent); + if (video && !video.deleted && !video.invalid) { + this.markAuthorIndexDirty(); + this.emit("videos:updated", { videos: null, reason: "local-publish" }); + } + return video; + } + getActiveVideosByAuthors(authors = [], options = {}) { const candidates = ensureSet(authors); const normalizedAuthors = new Set(); diff --git a/tests/optimistic-feed-insert.test.mjs b/tests/optimistic-feed-insert.test.mjs new file mode 100644 index 00000000..fe2896bc --- /dev/null +++ b/tests/optimistic-feed-insert.test.mjs @@ -0,0 +1,112 @@ +// Optimistic feed insert after publishing (#46): a just-published video is +// added to the client's active-video cache immediately so it shows in the feed +// without waiting for the relays to echo it back — while still obeying the same +// dedupe / active-key rules as live ingestion, so a relay copy of the same +// event can't duplicate it and an older revision can't overwrite a newer one. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-optimistic-feed-insert +// given: "a freshly signed video event" +// when: "ingestLocalVideoEvent is called with it" +// then: "getActiveVideos includes it exactly once, newest-revision-wins" +// observable_outcomes: +// - "getActiveVideos() surfaces the ingested video" +// - "re-ingesting the same event does not create a duplicate active entry" +// - "an older revision of the same root does not replace a newer active one" +// - "an invalid event returns null and inserts nothing" +// determinism_controls: +// - "in-memory NostrClient + localStorage polyfill; explicit created_at values" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { NostrClient } from "../js/nostr/client.js"; + +const PUB = "f".repeat(64); + +function videoEvent({ id, root, title, createdAt }) { + return { + id, + pubkey: PUB, + kind: 30078, + created_at: createdAt, + content: JSON.stringify({ + version: 3, + title, + url: "https://cdn.example/v.mp4", + videoRootId: root, + }), + tags: [["d", root]], + }; +} + +test("a published event is surfaced in the active feed immediately", () => { + const client = new NostrClient(); + const video = client.ingestLocalVideoEvent( + videoEvent({ id: "evt-1", root: "root-1", title: "Hello", createdAt: 1000 }), + ); + + assert.ok(video, "returns the ingested video"); + const active = client.getActiveVideos(); + assert.equal(active.length, 1); + assert.equal(active[0].id, "evt-1"); + assert.equal(active[0].title, "Hello"); +}); + +test("re-ingesting the same event does not duplicate it in the feed", () => { + const client = new NostrClient(); + const evt = videoEvent({ id: "evt-1", root: "root-1", title: "Hello", createdAt: 1000 }); + client.ingestLocalVideoEvent(evt); + client.ingestLocalVideoEvent(evt); + + assert.equal(client.getActiveVideos().length, 1, "same event stays a single active entry"); +}); + +test("a newer revision of the same root replaces the active entry", () => { + const client = new NostrClient(); + client.ingestLocalVideoEvent( + videoEvent({ id: "evt-old", root: "root-1", title: "Old", createdAt: 1000 }), + ); + client.ingestLocalVideoEvent( + videoEvent({ id: "evt-new", root: "root-1", title: "New", createdAt: 2000 }), + ); + + const active = client.getActiveVideos(); + assert.equal(active.length, 1, "one active entry per root"); + assert.equal(active[0].title, "New", "newest revision wins"); +}); + +test("an older revision does NOT overwrite a newer active entry", () => { + const client = new NostrClient(); + client.ingestLocalVideoEvent( + videoEvent({ id: "evt-new", root: "root-1", title: "New", createdAt: 2000 }), + ); + // A late-arriving older copy (e.g. a stale relay echo) must not clobber it. + client.ingestLocalVideoEvent( + videoEvent({ id: "evt-old", root: "root-1", title: "Old", createdAt: 1000 }), + ); + + const active = client.getActiveVideos(); + assert.equal(active.length, 1); + assert.equal(active[0].title, "New", "older revision must not displace the newer active entry"); +}); + +test("an invalid event returns null and inserts nothing", () => { + const client = new NostrClient(); + const result = client.ingestLocalVideoEvent({ id: "bad", content: "not-json", tags: [] }); + assert.equal(result, null); + assert.equal(client.getActiveVideos().length, 0); +}); + +test("a null/garbage argument is handled without throwing", () => { + const client = new NostrClient(); + assert.equal(client.ingestLocalVideoEvent(null), null); + assert.equal(client.ingestLocalVideoEvent({}), null); + assert.equal(client.getActiveVideos().length, 0); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 0dc12b4f..e2cb65dc 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -579,12 +579,21 @@ toward freshness and looked identical. Gave each a structural identity: Rank by usage count; clicking a chip adds it to the new video's tags. ### 46. Feed auto-refreshes after posting a video (UX) -- [ ] After a video note is published, **automatically refresh the feed** so the new - video appears without a manual page reload. Hook the post-publish success (upload - modal `upload:submit` / `publishVideoNote` completion) to - `refreshAllVideoGrids`/`onVideosShouldRefresh` (optimistically inject the just- - published event so it shows instantly). Mind the feed-identity + dedupe paths (#20/ - #21) so the injected event isn't dropped or duplicated. +- [x] **DONE 2026-06-30.** `publishVideoNote` now (1) **optimistically injects** the + just-published legacy event into the shared client cache via a new + `nostrClient.ingestLocalVideoEvent(rawEvent)` (+ thin `nostrService` wrapper), so the + new video shows in the feed **instantly**, before the relays echo it back; and (2) + refreshes via the canonical `refreshAllVideoGrids({ reason: "video-published", + forceMainReload: true })` instead of a bare `loadVideos()` — so it refreshes + **whichever feed is active** (For You / Explore / Recent) plus the subscription and + channel grids, and forces a render (bypassing the load cooldown) so the injected + video is actually shown. `ingestLocalVideoEvent` mirrors live ingestion (convert → + root created_at → tombstone guard → allEvents/activeMap), so it obeys the same + dedupe/active-key rules (#20/#21): a later relay copy of the same event (same + `created_at`) won't duplicate it, and an older revision can't overwrite a newer + active entry. Tests: `tests/optimistic-feed-insert.test.mjs` (6 — surfaced instantly, + no-duplicate on re-ingest, newer-wins, older-doesn't-clobber, invalid→null, garbage + handled). ### 47. "Most Zapped" sidebar tab (trending-by-zaps) - [ ] Add a **Most Zapped** sidebar tab: like Trending (#27, view-count) but ranked by From a4549f967f82604f535bdaa1ce5268ed4e5fc0f3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:47:54 -0400 Subject: [PATCH 10/48] [channel] Fix slow/incomplete channel video grid: coalesce loads, bound relays, parity source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for "the channel grid loads slowly and not all videos come in even when they show on other grids": 1. Race condition (primary "won't load" cause). loadUserVideos() bumps a shared currentVideoLoadToken and only renders if its token is still current. A burst of refresh triggers — e.g. the NIP-71 ingest flushing repeatedly right after a channel opens (RELAY STORM in the logs) — supersedes every in-flight load before it paints, so the grid stays on "Loading videos…". Add requestChannelVideoLoad(): while a load for the same channel is in flight, a refresh flags a single trailing rerun instead of starting a competing load; navigation still starts immediately. All three call sites routed through it. 2. Slowness from hung relays. The per-relay pool.list() fan-out had no timeout, so one relay that never sends EOSE stalled the merged render. Add raceListWithTimeout() (exported, pure) bounding each list() to 8s, resolving to [] on timeout/rejection. 3. Completeness/parity. The channel grid sourced only nostrClient.allEvents + a scoped relay fetch, which can diverge from the shared feed cache the other grids render from. getCachedChannelVideoEvents() now also unions the author's videos from nostrService.getActiveVideosByAuthors() (same videosMap-backed author index), so a video shown on another grid is present here by construction. Tests: tests/channel-relay-timeout.test.mjs (5). Co-Authored-By: Claude Opus 4.8 --- js/channelProfile.js | 115 +++++++++++++++++++++++++-- tests/channel-relay-timeout.test.mjs | 65 +++++++++++++++ 2 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 tests/channel-relay-timeout.test.mjs diff --git a/js/channelProfile.js b/js/channelProfile.js index b6e59fe4..26f47a4c 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -76,6 +76,36 @@ import { RELAY_BACKGROUND_CONCURRENCY } from "./nostr/relayConstants.js"; const getApp = () => getApplication(); +// A single hung relay (one that never sends EOSE) used to stall the whole +// channel-grid fetch because `pool.list` has no built-in timeout. Bound each +// relay's list() so a slow relay resolves to "no events" instead of holding up +// the merged render — the other relays + the shared cache still fill the grid. +export const CHANNEL_RELAY_LIST_TIMEOUT_MS = 8000; + +// Pure, testable core: race a list() call (via a factory) against a timeout. +// Resolves to the list's array on success, or [] on timeout OR rejection — a +// failing/hung relay contributes nothing rather than stalling or throwing (and +// a late rejection after the timeout wins can't surface as unhandled). +export function raceListWithTimeout(listFn, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) { + let timer = null; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve([]), timeoutMs); + }); + const listPromise = Promise.resolve() + .then(() => listFn()) + .then((result) => (Array.isArray(result) ? result : [])) + .catch(() => []); + return Promise.race([listPromise, timeout]).finally(() => { + if (timer) { + clearTimeout(timer); + } + }); +} + +function listRelayWithTimeout(relays, filters, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) { + return raceListWithTimeout(() => nostrClient.pool.list(relays, filters), timeoutMs); +} + const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const CHANNEL_MODERATION_BADGE_ID = "channel-moderation-badge"; @@ -1310,6 +1340,17 @@ let zapPopoverOpenPromise = null; let channelMenuPopover = null; let channelMenuOpen = false; let currentVideoLoadToken = 0; +// Coalescing state for channel-grid loads. loadUserVideos() bumps +// currentVideoLoadToken and only renders if its token is still current, so a +// burst of refresh triggers (e.g. the NIP-71 ingest flushing repeatedly right +// after open) can supersede every in-flight load before it paints — the grid +// gets stuck on "Loading videos…". requestChannelVideoLoad() collapses those: +// while a load for the same channel is in flight, a refresh flags a single +// trailing rerun instead of starting a competing load. Navigation to a channel +// (isRefresh:false) still starts immediately (and supersedes a stale channel). +let channelVideoLoadInFlight = null; +let channelVideoLoadPubkey = null; +let channelVideoRerunPending = false; let currentProfileLoadToken = 0; const FALLBACK_CHANNEL_BANNER = "assets/jpg/bitvid.jpg"; @@ -3821,7 +3862,7 @@ export async function initChannelProfileView() { syncChannelShareButtonState(); }); - const videosPromise = loadUserVideos(hexPub).catch((error) => { + const videosPromise = requestChannelVideoLoad(hexPub).catch((error) => { userLogger.error("Failed to load channel videos:", error); }); @@ -4448,7 +4489,26 @@ function getCachedChannelVideoEvents(pubkey) { } ); - return [...rawMatches, ...processedMatches]; + // Parity with the other grids: also pull the author's videos from the shared + // feed cache (nostrService videosMap → author index) — the SAME source the + // main/subscriptions grids render from. The channel grid otherwise sources + // only nostrClient.allEvents + a scoped relay fetch, which can DIVERGE from + // that cache, so a video shown on another grid could be missing here. Appended + // last so it only fills gaps (richer raw/processed copies win the id-dedupe). + let parityMatches = []; + try { + const service = getApp()?.nostrService; + if (service && typeof service.getActiveVideosByAuthors === "function") { + const parity = service.getActiveVideosByAuthors([normalized]); + if (Array.isArray(parity)) { + parityMatches = parity; + } + } + } catch (error) { + // Best-effort; the allEvents scan + scoped relay fetch still populate the grid. + } + + return [...rawMatches, ...processedMatches, ...parityMatches]; } function normalizeRenderableChannelVideo(entry) { @@ -5315,7 +5375,7 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) { } try { - const result = loadUserVideos(currentChannelHex); + const result = requestChannelVideoLoad(currentChannelHex, { isRefresh: true }); return result instanceof Promise ? result : Promise.resolve(result); } catch (error) { userLogger.warn( @@ -5328,6 +5388,49 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) { } } +// Entry point for channel-grid loads. Coalesces refresh-driven loads so a +// storm of refreshes can't perpetually cancel each other before rendering. +// isRefresh:true → if a load for the same channel is in flight, flag one +// trailing rerun and reuse the in-flight promise. +// isRefresh:false → navigation; always start (supersedes a stale channel). +function requestChannelVideoLoad(pubkey, { isRefresh = false } = {}) { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if ( + isRefresh && + channelVideoLoadInFlight && + channelVideoLoadPubkey === normalized + ) { + channelVideoRerunPending = true; + return channelVideoLoadInFlight; + } + return startChannelVideoLoad(pubkey); +} + +function startChannelVideoLoad(pubkey) { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + channelVideoLoadPubkey = normalized; + const promise = Promise.resolve(loadUserVideos(pubkey)).finally(() => { + if (channelVideoLoadInFlight === promise) { + channelVideoLoadInFlight = null; + } + // Fire at most one trailing reload, and only if we're still on this + // channel (navigation elsewhere will have started its own load). + if (channelVideoRerunPending) { + channelVideoRerunPending = false; + const stillHere = + typeof currentChannelHex === "string" && + currentChannelHex.trim().toLowerCase() === normalized; + if (stillHere) { + startChannelVideoLoad(currentChannelHex); + } + } + }); + channelVideoLoadInFlight = promise; + return promise; +} + async function loadUserVideos(pubkey) { const app = getApp(); const container = document.getElementById("channelVideoList"); @@ -5398,7 +5501,7 @@ async function loadUserVideos(pubkey) { if (relayList.length === 0) { try { - const fallbackEvents = await nostrClient.pool.list( + const fallbackEvents = await listRelayWithTimeout( Array.from(DEFAULT_RELAY_URLS), filters ); @@ -5411,7 +5514,7 @@ async function loadUserVideos(pubkey) { } else { const settled = await pMapSettled( relayList, - (url) => nostrClient.pool.list([url], filters), + (url) => listRelayWithTimeout([url], filters), { concurrency: RELAY_BACKGROUND_CONCURRENCY } ); settled.forEach((result, index) => { @@ -5493,7 +5596,7 @@ if (typeof window !== "undefined" && typeof window.addEventListener === "functio return; } - loadUserVideos(currentChannelHex).catch((error) => { + requestChannelVideoLoad(currentChannelHex, { isRefresh: true }).catch((error) => { userLogger.error( "Failed to refresh channel videos after admin update:", error diff --git a/tests/channel-relay-timeout.test.mjs b/tests/channel-relay-timeout.test.mjs new file mode 100644 index 00000000..2892cd3e --- /dev/null +++ b/tests/channel-relay-timeout.test.mjs @@ -0,0 +1,65 @@ +// Channel-grid relay fetch is bounded (raceListWithTimeout): a hung relay (one +// that never sends EOSE) must resolve to "no events" instead of stalling the +// whole grid render. A failing relay likewise contributes [] rather than +// throwing, and a late rejection after the timeout can't surface as unhandled. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-channel-relay-timeout +// given: "a relay list() that resolves / hangs / rejects / returns non-array" +// when: "raceListWithTimeout wraps it with a short timeout" +// then: "success returns the array; hang/reject/garbage resolve to []" +// observable_outcomes: +// - "a fast success passes the array through unchanged" +// - "a hang resolves to [] within the timeout bound (does not stall)" +// - "a rejection resolves to [] (does not throw)" +// determinism_controls: +// - "explicit short timeoutMs; hang modeled as a never-resolving promise" +// anti_cheat_rationale: +// prevents: ["retry/sleep-based flake masking", "hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { raceListWithTimeout } from "../js/channelProfile.js"; + +test("passes a fast successful result through unchanged", async () => { + const events = [{ id: "a" }, { id: "b" }]; + const result = await raceListWithTimeout(async () => events, 1000); + assert.deepEqual(result, events); +}); + +test("a hung relay resolves to [] within the timeout instead of stalling", async () => { + const started = Date.now(); + // Never resolves — models a relay that never sends EOSE. + const result = await raceListWithTimeout(() => new Promise(() => {}), 30); + assert.deepEqual(result, []); + assert.ok(Date.now() - started < 500, "must resolve promptly via the timeout"); +}); + +test("a rejecting relay resolves to [] rather than throwing", async () => { + const result = await raceListWithTimeout(async () => { + throw new Error("relay exploded"); + }, 1000); + assert.deepEqual(result, []); +}); + +test("a non-array result is normalized to []", async () => { + const result = await raceListWithTimeout(async () => null, 1000); + assert.deepEqual(result, []); +}); + +test("a late rejection after the timeout wins does not throw (no unhandled)", async () => { + // The list rejects AFTER the timeout has already resolved to []. The helper + // must swallow it so it can't surface as an unhandled rejection. + const result = await raceListWithTimeout( + () => new Promise((_, reject) => setTimeout(() => reject(new Error("late")), 40)), + 10, + ); + assert.deepEqual(result, []); + // Give the late rejection a tick to (not) blow up. + await new Promise((resolve) => setTimeout(resolve, 60)); +}); From a1ed04b22c7f5079ea62c38afbf6634c1dc39fd5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:29:43 -0400 Subject: [PATCH 11/48] [dm] Fix NIP-17 send failing with nip17-keygen-failed (missing bytesToHex import) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending a NIP-17 DM builds a kind-1059 gift wrap, which needs a fresh ephemeral keypair. generateEphemeralKeypair() hex-encodes the generated secret with bytesToHex — but bytesToHex was never imported in client.js, so the call threw ReferenceError. The try/catch swallowed it into privateKey="", keygen returned null, and every NIP-17 send failed with "We couldn't create secure wrapper keys for NIP-17 delivery." (nip17-keygen-failed). The seal (kind 13) signed fine because it uses the active signer; only the ephemeral keygen was broken. Build and lint don't parse JS, so the missing import wasn't caught. - Add bytesToHex to the canonical js/utils/hex.js and import it in client.js. - Add tests/nip17-send-gift-wrap.test.mjs: forces the NIP-17 path (useNip17 + relay hints, nip44-capable signer) and asserts a kind-1059 gift wrap is published. Reverting the import reproduces the exact nip17-keygen-failed error; the existing DM tests only covered the legacy kind-4/NIP-04 path (they delete nip44Encrypt), so this path had no coverage. Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 2 +- js/utils/hex.js | 10 ++ tests/nip17-send-gift-wrap.test.mjs | 151 ++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 tests/nip17-send-gift-wrap.test.mjs diff --git a/js/nostr/client.js b/js/nostr/client.js index e7fa1fd2..c1452801 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -173,7 +173,7 @@ import { readStoredSessionActorEntry, isSessionActor, } from "./sessionActor.js"; -import { HEX64_REGEX, normalizeHexHash } from "../utils/hex.js"; +import { HEX64_REGEX, normalizeHexHash, bytesToHex } from "../utils/hex.js"; import { NIP46_RPC_KIND, NIP46_HANDSHAKE_TIMEOUT_MS, diff --git a/js/utils/hex.js b/js/utils/hex.js index 49307eb0..247ead81 100644 --- a/js/utils/hex.js +++ b/js/utils/hex.js @@ -12,6 +12,16 @@ export function normalizeHexString(value) { export const normalizeHexId = normalizeHexString; export const normalizeHexPubkey = normalizeHexString; +// Lowercase hex encoding of a byte array (e.g. a generated secret key). +export function bytesToHex(bytes) { + if (!bytes) { + return ""; + } + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + export function normalizeHexHash(value) { if (value === undefined || value === null) { return ""; diff --git a/tests/nip17-send-gift-wrap.test.mjs b/tests/nip17-send-gift-wrap.test.mjs new file mode 100644 index 00000000..56ef3beb --- /dev/null +++ b/tests/nip17-send-gift-wrap.test.mjs @@ -0,0 +1,151 @@ +// Sending a NIP-17 DM (kind 1059 gift wrap) must generate an ephemeral wrapper +// keypair. That keygen hex-encodes the generated secret via bytesToHex; a +// missing import made bytesToHex throw (swallowed into "") so keygen returned +// null and every NIP-17 send failed with `nip17-keygen-failed`. The legacy +// (kind 4 / NIP-04) send tests delete nip44Encrypt, so they never exercised +// this path. This test forces the NIP-17 path and asserts a gift wrap is built +// and published. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-nip17-send-gift-wrap +// given: "a nip44-capable signer and known DM relays" +// when: "sendDirectMessage is called with useNip17:true" +// then: "it resolves ok and publishes a kind-1059 gift wrap (no keygen failure)" +// observable_outcomes: +// - "result.ok === true (not { error: 'nip17-keygen-failed' })" +// - "a kind 1059 gift-wrap event is published to the recipient relay" +// determinism_controls: +// - "real nostr-tools injected; pool.publish/list stubbed; relay hints supplied" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import assert from "node:assert/strict"; +import test, { after } from "node:test"; + +const RELAY_URL = "wss://relay.nip17.test"; + +after(() => { + setTimeout(() => process.exit(0), 100); +}); + +test("sendDirectMessage (NIP-17) builds and publishes a kind-1059 gift wrap", async () => { + const previousCanonical = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + const previousNostrTools = globalThis.NostrTools; + const previousReady = globalThis.nostrToolsReady; + + const nostrTools = await import("nostr-tools"); + const canonicalTools = { ...nostrTools }; + + try { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools; + globalThis.NostrTools = canonicalTools; + globalThis.nostrToolsReady = Promise.resolve({ ok: true, value: canonicalTools }); + + const [{ nostrClient }, { getActiveSigner, clearActiveSigner }] = + await Promise.all([ + import("../js/nostrClientFacade.js"), + import("../js/nostr/client.js"), + ]); + + const previousPool = nostrClient.pool; + const previousRelays = nostrClient.relays; + const previousPubkey = nostrClient.pubkey; + + const publishInvocations = []; + nostrClient.pool = { + publish(urls, event) { + publishInvocations.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") setTimeout(() => handler(), 0); + return true; + }, + }; + }, + list() { + return Promise.resolve([]); + }, + }; + nostrClient.relays = [RELAY_URL]; + nostrClient.readRelays = [RELAY_URL]; + nostrClient.writeRelays = [RELAY_URL]; + + const senderSecret = canonicalTools.generateSecretKey(); + const senderPubkey = canonicalTools.getPublicKey(senderSecret); + const recipientSecret = canonicalTools.generateSecretKey(); + const recipientPubkey = canonicalTools.getPublicKey(recipientSecret); + + await nostrClient.registerPrivateKeySigner({ + privateKey: canonicalTools.utils.bytesToHex(senderSecret), + pubkey: senderPubkey, + }); + nostrClient.pubkey = senderPubkey; + + const signer = getActiveSigner(); + // Keep nip44 (so the NIP-17 path is taken) and give it a working impl. + signer.signEvent = (event) => { + const finalized = canonicalTools.finalizeEvent(event, senderSecret); + return { ...event, id: finalized.id, sig: finalized.sig }; + }; + signer.nip44Encrypt = async (peerPubkey, plaintext) => { + const key = canonicalTools.nip44.v2.utils.getConversationKey( + senderSecret, + peerPubkey, + ); + return canonicalTools.nip44.v2.encrypt(plaintext, key); + }; + + const result = await nostrClient.sendDirectMessage( + recipientPubkey, + "nip17 gift wrap smoke test", + { + useNip17: true, + recipientRelayHints: [RELAY_URL], + senderRelayHints: [RELAY_URL], + }, + ); + + assert.equal( + result.ok, + true, + `NIP-17 send should succeed, got: ${JSON.stringify(result)}`, + ); + assert.notEqual( + result.error, + "nip17-keygen-failed", + "ephemeral wrapper keygen must not fail", + ); + + const giftWraps = publishInvocations.filter( + ({ event }) => event?.kind === 1059, + ); + assert.ok( + giftWraps.length >= 1, + "at least one kind-1059 gift wrap should be published", + ); + // A gift wrap is signed by a fresh ephemeral key, never the sender's. + assert.notEqual( + giftWraps[0].event.pubkey, + senderPubkey, + "gift wrap must be authored by the ephemeral wrapper key, not the sender", + ); + + signer.signEvent = undefined; + signer.nip44Encrypt = undefined; + clearActiveSigner(); + nostrClient.pool = previousPool; + nostrClient.relays = previousRelays; + nostrClient.pubkey = previousPubkey; + } finally { + if (previousCanonical === undefined) delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + else globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical; + if (previousNostrTools === undefined) delete globalThis.NostrTools; + else globalThis.NostrTools = previousNostrTools; + if (previousReady === undefined) delete globalThis.nostrToolsReady; + else globalThis.nostrToolsReady = previousReady; + } +}); From e3139e8712a043950f5469b09d6201b69d651fa3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:14:03 -0400 Subject: [PATCH 12/48] [feed] Break up per-creator clustering in For You / Trending grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The For You (personalized) and Trending tabs ranked strictly by tier/score and view count, which stacked a creator's whole back-catalogue into one block. Add spreadAuthors() to the feed sorters: a gentle diversity re-rank that keeps the caller's ranked order as the priority but avoids runs of the same author — at each step it takes the highest-ranked remaining item whose author wasn't among the last `window` picks (default 1), falling back to the plain top item when every remaining candidate is a recent author. The #1 ranked item is always chosen first, so "best-fit"/"most-viewed" stays topmost, and each author's items keep their relative ranked order. Applied to createForYouScoreSorter (personalized branch) and createTrendingSorter (live items). Unlike interleaveByAuthor (full round-robin, used only for For You's no-signal fallback + Explore), this preserves ranking rather than flattening it, so each tab keeps its unique ordering process — just de-clustered. Recent (chronological) and Explore (already diversified) are unchanged. Tests: tests/feed-engine/spread-authors.test.mjs (7 — top preserved, no avoidable adjacent dupes, per-author order kept, single-author/short/anon edge cases, window:2 spread). Co-Authored-By: Claude Opus 4.8 --- js/feedEngine/sorters.js | 48 +++++++- tests/feed-engine/spread-authors.test.mjs | 132 ++++++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 tests/feed-engine/spread-authors.test.mjs diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index 66b52878..f23bd52c 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -55,7 +55,9 @@ export function createTrendingSorter() { const live = items.filter((entry) => !isMuted(entry)); const muted = items.filter((entry) => isMuted(entry)); - return [...live.sort(compare), ...muted.sort(compare)]; + // Rank by views, then spread authors so the tab isn't walls of one creator. + // The most-viewed item is still chosen first, so it stays topmost. + return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)]; }; } @@ -98,6 +100,47 @@ export function interleaveByAuthor(items, { authorOf } = {}) { return result; } +// Gentle author-diversity re-rank. Unlike interleaveByAuthor (full round-robin, +// which flattens the ranking), this keeps the caller's ranked order as the +// priority and only breaks up runs of the same author: at each step it takes the +// highest-ranked remaining item whose author wasn't among the last `window` +// picks, falling back to the plain top item when every remaining candidate is a +// recent author. The #1 ranked item is always chosen first, so "best/most-X on +// top" is preserved while consecutive cards stop clustering by creator. +export function spreadAuthors(items, { authorOf, window: windowSize = 1 } = {}) { + if (!Array.isArray(items) || items.length < 3) { + return Array.isArray(items) ? [...items] : []; + } + const size = Number.isFinite(windowSize) && windowSize > 0 ? Math.floor(windowSize) : 1; + const keyOf = + typeof authorOf === "function" + ? authorOf + : (entry) => + typeof entry?.video?.pubkey === "string" ? entry.video.pubkey : ""; + + const remaining = items.map((item, index) => ({ item, key: keyOf(item), index })); + const result = []; + const recent = []; // authors of the last `size` picks + while (remaining.length) { + let pickIdx = 0; // default: the highest-ranked remaining item + for (let i = 0; i < remaining.length; i += 1) { + const { key } = remaining[i]; + // Anonymous/unknown authors ("") never count as a repeat. + if (!key || !recent.includes(key)) { + pickIdx = i; + break; + } + } + const [picked] = remaining.splice(pickIdx, 1); + result.push(picked.item); + if (picked.key) { + recent.push(picked.key); + if (recent.length > size) recent.shift(); + } + } + return result; +} + // Ranks the For You feed "your people first": followed authors and interest/ // watch-topic matches (forYouTier, set by the scorer) lead regardless of // recency, then everyone else by score. When NOTHING is tiered (logged-out / no @@ -154,6 +197,9 @@ export function createForYouScoreSorter() { if (tierDiff !== 0) return tierDiff; return byScore(a, b); }); + // Break up per-creator clusters (a followed author's whole back-catalogue + // would otherwise sit in one block) while keeping the best-fit item on top. + ordered = spreadAuthors(ordered); } else { // No signals → discovery fallback: score order, then interleave authors so // it doesn't read as a chronological clone of Recently-added. diff --git a/tests/feed-engine/spread-authors.test.mjs b/tests/feed-engine/spread-authors.test.mjs new file mode 100644 index 00000000..5a73db9f --- /dev/null +++ b/tests/feed-engine/spread-authors.test.mjs @@ -0,0 +1,132 @@ +// spreadAuthors() is a gentle diversity re-rank: it keeps the caller's ranked +// order as the priority but breaks up runs of the same creator so a feed tab +// doesn't read as walls of one uploader. The #1 ranked item is always chosen +// first (so "best-fit"/"most-viewed" stays on top), and each author's items keep +// their relative ranked order. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-spread-authors +// given: "a ranked list with clustered authors" +// when: "spreadAuthors re-ranks it (window 1)" +// then: "top item is unchanged, no avoidable adjacent same-author, order-within-author preserved" +// observable_outcomes: +// - "result[0] === input[0] (top rank preserved)" +// - "no two consecutive items share an author when another author remains" +// - "same-author items keep their input relative order" +// determinism_controls: +// - "pure function; fixed input arrays; no clock/network" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "snapshot rubber-stamping"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { spreadAuthors } from "../../js/feedEngine/sorters.js"; + +const item = (id, pubkey) => ({ video: { id, pubkey } }); +const ids = (list) => list.map((i) => i.video.id); +const authorsOf = (list) => list.map((i) => i.video.pubkey); + +function hasAvoidableAdjacentDupe(list) { + const authors = authorsOf(list); + const distinct = new Set(authors).size; + if (distinct <= 1) return false; // unavoidable + for (let i = 1; i < authors.length; i += 1) { + if (authors[i] && authors[i] === authors[i - 1]) { + return true; + } + } + return false; +} + +test("keeps the #1 ranked item on top", () => { + const input = [ + item("a1", "A"), + item("a2", "A"), + item("b1", "B"), + item("c1", "C"), + ]; + const out = spreadAuthors(input); + assert.equal(out[0].video.id, "a1", "highest-ranked item stays first"); +}); + +test("breaks up a run of one author into non-adjacent slots", () => { + // Ranked order clusters author A at the top. + const input = [ + item("a1", "A"), + item("a2", "A"), + item("a3", "A"), + item("b1", "B"), + item("c1", "C"), + ]; + const out = spreadAuthors(input); + assert.equal(hasAvoidableAdjacentDupe(out), false, "no avoidable adjacent same-author"); + assert.equal(out.length, input.length, "no items dropped or duplicated"); + assert.deepEqual(ids(out).sort(), ids(input).sort(), "same set of items"); +}); + +test("preserves each author's relative ranked order", () => { + const input = [ + item("a1", "A"), + item("a2", "A"), + item("b1", "B"), + item("a3", "A"), + item("b2", "B"), + ]; + const out = ids(spreadAuthors(input)); + // A's items stay in a1 < a2 < a3 order; B's in b1 < b2 order. + assert.ok(out.indexOf("a1") < out.indexOf("a2"), "a1 before a2"); + assert.ok(out.indexOf("a2") < out.indexOf("a3"), "a2 before a3"); + assert.ok(out.indexOf("b1") < out.indexOf("b2"), "b1 before b2"); +}); + +test("all-one-author input is returned in original order", () => { + const input = [item("a1", "A"), item("a2", "A"), item("a3", "A")]; + assert.deepEqual(ids(spreadAuthors(input)), ["a1", "a2", "a3"]); +}); + +test("lists shorter than 3 are returned unchanged (copy)", () => { + const input = [item("a1", "A"), item("a2", "A")]; + const out = spreadAuthors(input); + assert.deepEqual(ids(out), ["a1", "a2"]); + assert.notEqual(out, input, "returns a copy, not the same reference"); +}); + +test("anonymous authors are allowed to sit adjacent (not treated as repeats)", () => { + const input = [ + item("x1", ""), + item("x2", ""), + item("x3", ""), + ]; + // No real author to diversify against → order preserved, no infinite defer. + assert.deepEqual(ids(spreadAuthors(input)), ["x1", "x2", "x3"]); +}); + +test("window:2 spreads a clustered author across the feed (tail dupes only when forced)", () => { + // A is 50% of the list; a larger window pushes A's items further apart. + const input = [ + item("a1", "A"), + item("a2", "A"), + item("a3", "A"), + item("b1", "B"), + item("c1", "C"), + item("d1", "D"), + ]; + const out = authorsOf(spreadAuthors(input, { window: 2 })); + assert.equal(out[0], "A", "top item preserved"); + // No adjacent dupes (always achievable here since no author exceeds 50%). + for (let i = 1; i < out.length; i += 1) { + assert.notEqual(out[i], out[i - 1], `no adjacent dupe at ${i}`); + } + // A's three items are genuinely spread out, not stacked at the front. + const aPositions = out + .map((author, idx) => (author === "A" ? idx : -1)) + .filter((idx) => idx >= 0); + assert.ok( + aPositions[aPositions.length - 1] - aPositions[0] >= 4, + "A's items span most of the feed rather than clustering", + ); +}); From cf4ce5c3b8efff98b31a8b5f842498155fc1bc28 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:56:38 -0400 Subject: [PATCH 13/48] [admin] Add sub-tab toolbar + Blocked-videos tool to the admin pane (#25 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin pane stacked every list (Moderators / Whitelist / Blacklist) in one scroll, and the per-event block list had no admin UI (block-only via the ⋯ menu, no view/unblock). This reorganizes the pane into a sub-tab toolbar and adds the missing Blocked-videos tool. - Sub-tabs: a role="tablist" toolbar in #profilePaneAdmin switches between Whitelist (default), Blacklist, Blocked videos, and Moderators. Moderators is super-admin-only — its tab button is hidden otherwise. selectAdminSubtab() shows one section at a time (ARIA + data-state), falling back to the default when a hidden tab is requested. - Blocked videos: view the per-event admin block list, unblock per row (confirm → removeFromEventBlacklist), and add by nevent/hex id. Rows show the video title+author when it's in the local cache, else the shortened id. Reuses the existing accessControl event-blacklist API + onEventBlacklistChange (live re-render) and refreshes the grids on change. No backend changes. - Row-building/render logic in js/ui/profileModal/blockedVideosSection.js (keeps ProfileAdminController under its size cap; unit-testable without a DOM). Spec correction: profile-modal-controller.test's admin assertion updated for the sub-tab layout (Moderators section now hidden until its tab is active; its tab button is shown to a super admin; selecting it reveals the section). Tests: tests/profile-blocked-videos.test.mjs (7 — rows dedupe/enrich/fallback, render, unblock wiring); profile-modal-controller admin test updated. Co-Authored-By: Claude Opus 4.8 --- components/profile-modal.html | 102 ++++++- js/ui/profileModal/ProfileAdminController.js | 278 ++++++++++++++++++- js/ui/profileModal/blockedVideosSection.js | 136 +++++++++ tests/profile-blocked-videos.test.mjs | 124 +++++++++ tests/profile-modal-controller.test.mjs | 21 +- 5 files changed, 652 insertions(+), 9 deletions(-) create mode 100644 js/ui/profileModal/blockedVideosSection.js create mode 100644 tests/profile-blocked-videos.test.mjs diff --git a/components/profile-modal.html b/components/profile-modal.html index f6a268a9..198f88d8 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1693,6 +1693,59 @@

+
+ + + + +
+