From 1ff3523bd05bce5f0a3b409152abeb8e79c7d69d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 22 Oct 2025 20:13:10 -0400 Subject: [PATCH] Improve Nostr watch history snapshot caching --- js/nostr/client.js | 18 +- js/nostr/publishHelpers.js | 93 +++++- js/nostr/watchHistory.js | 103 +++++- tests/nostr-publish-rejection.test.mjs | 52 +++- tests/nostr/integration-remote-flow.test.mjs | 310 +++++++++++++++++++ tests/nostr/watchHistory.test.js | 134 ++++++++ tests/watch-history.test.mjs | 106 ++++++- 7 files changed, 777 insertions(+), 39 deletions(-) create mode 100644 tests/nostr/integration-remote-flow.test.mjs diff --git a/js/nostr/client.js b/js/nostr/client.js index f706cd72..97c00b16 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -2452,6 +2452,11 @@ export class NostrClient { !!normalizedLogged && activeSigner && typeof activeSigner.signEvent === "function"; + const canSignWithExtension = + !!normalizedLogged && + activeSigner && + typeof activeSigner.signEvent === "function" && + shouldRequestExtensionPermissions(activeSigner); if (!forceRenew && canSignWithActiveSigner) { return normalizedLogged; @@ -3237,6 +3242,10 @@ export class NostrClient { if (!pubkey) throw new Error("Not logged in to publish video."); const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload); + const nip71EditedFlag = + videoPayload && typeof videoPayload === "object" + ? videoPayload.nip71Edited + : null; // NOTE: Keep the Upload, Edit, and Revert flows synchronized when // updating shared fields. Changes here must be reflected in the modal @@ -3274,6 +3283,7 @@ export class NostrClient { const finalIsNsfw = videoData.isNsfw === true; const finalIsForKids = videoData.isForKids === true && !finalIsNsfw; + const wantPrivate = videoData.isPrivate === true; const finalWs = typeof videoData.ws === "string" ? videoData.ws.trim() : ""; const finalXs = @@ -3389,10 +3399,10 @@ export class NostrClient { description: contentObject.description, url: contentObject.url, magnet: contentObject.magnet, - thumbnail: contentObject.thumbnail, - mode: contentObject.mode, - isPrivate: contentObject.isPrivate, - isNsfw: contentObject.isNsfw, + thumbnail: contentObject.thumbnail, + mode: contentObject.mode, + isPrivate: wantPrivate, + isNsfw: contentObject.isNsfw, isForKids: contentObject.isForKids, }; diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index 8a355b7c..077dde60 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -9,20 +9,61 @@ import { import { buildRepostEvent, buildVideoMirrorEvent } from "../nostrEventSchemas.js"; import { normalizePointerInput } from "./watchHistory.js"; import { sanitizeRelayList } from "./nip46Client.js"; -import { RELAY_URLS, getCachedNostrTools } from "./toolkit.js"; +import { + RELAY_URLS, + getCachedNostrTools, + readToolkitFromScope, +} from "./toolkit.js"; import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js"; import { devLogger, userLogger } from "../utils/logger.js"; const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; const rebroadcastAttemptMemory = new Map(); +const HEX_PRIVATE_KEY_REGEX = /^[0-9a-f]{64}$/i; + export function signEventWithPrivateKey(event, privateKey) { const tools = getCachedNostrTools(); + const scopedTools = readToolkitFromScope(); + const canonicalTools = + typeof globalThis !== "undefined" && + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ + ? globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ + : null; + const signFn = + typeof canonicalTools?.signEvent === "function" + ? canonicalTools.signEvent + : typeof tools?.signEvent === "function" + ? tools.signEvent + : typeof scopedTools?.signEvent === "function" + ? scopedTools.signEvent + : null; + // console.debug('signEventWithPrivateKey', { + // signFn: typeof signFn, + // scopedSign: typeof scopedTools?.signEvent, + // cachedSign: typeof tools?.signEvent, + // }); + const finalizeFn = + typeof canonicalTools?.finalizeEvent === "function" + ? canonicalTools.finalizeEvent + : typeof tools?.finalizeEvent === "function" + ? tools.finalizeEvent + : typeof scopedTools?.finalizeEvent === "function" + ? scopedTools.finalizeEvent + : null; + const hashFn = + typeof canonicalTools?.getEventHash === "function" + ? canonicalTools.getEventHash + : typeof tools?.getEventHash === "function" + ? tools.getEventHash + : typeof scopedTools?.getEventHash === "function" + ? scopedTools.getEventHash + : null; + if ( !privateKey || typeof privateKey !== "string" || - !tools?.getEventHash || - typeof tools.signEvent !== "function" + (!signFn && !finalizeFn) ) { throw new Error("Missing signing primitives"); } @@ -38,10 +79,50 @@ export function signEventWithPrivateKey(event, privateKey) { tags, content: typeof event.content === "string" ? event.content : "", }; - const id = tools.getEventHash(prepared); - const sig = tools.signEvent(prepared, privateKey); - return { ...prepared, id, sig }; + if (signFn) { + const signature = signFn(prepared, privateKey); + if (signature && typeof signature === "object") { + const idValue = + typeof signature.id === "string" + ? signature.id + : hashFn + ? hashFn(prepared) + : null; + const sigValue = + typeof signature.sig === "string" + ? signature.sig + : typeof signature.signature === "string" + ? signature.signature + : null; + if (!idValue || !sigValue) { + throw new Error("Missing signing primitives"); + } + return { ...prepared, id: idValue, sig: sigValue }; + } + + const sigValue = typeof signature === "string" ? signature : null; + if (!sigValue || !hashFn) { + throw new Error("Missing signing primitives"); + } + const idValue = hashFn(prepared); + return { ...prepared, id: idValue, sig: sigValue }; + } + + const normalizedKey = privateKey.trim(); + if (!HEX_PRIVATE_KEY_REGEX.test(normalizedKey)) { + throw new Error("Missing signing primitives"); + } + + const finalized = finalizeFn(prepared, normalizedKey); + if ( + !finalized || + typeof finalized.id !== "string" || + typeof finalized.sig !== "string" + ) { + throw new Error("Missing signing primitives"); + } + return finalized; } export function deriveRebroadcastBucketIndex(referenceSeconds = null) { diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index c5b043b3..d8878e60 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -88,7 +88,9 @@ function mergePointerDetails(target, source) { target.watchedAt = Math.max(0, Math.floor(source.watchedAt)); } if (typeof source.relay === "string" && source.relay.trim()) { - target.relay = source.relay.trim(); + if (!target.relay || !target.relay.trim()) { + target.relay = source.relay.trim(); + } } if (!target.metadata && source.metadata && typeof source.metadata === "object") { target.metadata = clonePointerMetadata(source.metadata); @@ -175,11 +177,7 @@ export function pointerKey(pointer) { if (!type || !value) { return ""; } - const relay = - typeof pointer.relay === "string" && pointer.relay.trim() - ? pointer.relay.trim().toLowerCase() - : ""; - return relay ? `${type}:${value}:${relay}` : `${type}:${value}`; + return `${type}:${value}`; } function normalizePointerTag(tag) { @@ -466,7 +464,10 @@ export function normalizeActorKey(actor) { if (/^[0-9a-f]{64}$/i.test(trimmed)) { return trimmed.toLowerCase(); } - const decoder = getCachedNostrTools()?.nip19?.decode; + const toolkit = getCachedNostrTools(); + const decoder = + (typeof window !== "undefined" ? window?.NostrTools?.nip19?.decode : null) || + toolkit?.nip19?.decode; if (typeof decoder === "function") { try { const decoded = decoder(trimmed); @@ -729,7 +730,11 @@ export function isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext) { class WatchHistoryManager { constructor(deps) { - this.deps = deps || {}; + this.deps = { + ensureNostrTools, + getCachedNostrTools, + ...(deps || {}), + }; this.cache = new Map(); this.storage = null; this.republishTimers = new Map(); @@ -1225,11 +1230,51 @@ class WatchHistoryManager { if (cachedNip04Tools) { return cachedNip04Tools; } - const tools = await ensureNostrTools(); - if (tools?.nip04 && typeof tools.nip04.encrypt === "function") { - cachedNip04Tools = tools; - return cachedNip04Tools; + + const ensureToolkit = + typeof this.deps.ensureNostrTools === "function" + ? this.deps.ensureNostrTools + : ensureNostrTools; + if (ensureToolkit) { + try { + const ensured = await ensureToolkit(); + if ( + ensured?.nip04 && + typeof ensured.nip04.encrypt === "function" + ) { + cachedNip04Tools = ensured; + return cachedNip04Tools; + } + } catch (error) { + devLogger.warn( + "[nostr] Failed to resolve nostr-tools for watch history:", + error, + ); + } } + + const readCachedToolkit = + typeof this.deps.getCachedNostrTools === "function" + ? this.deps.getCachedNostrTools + : getCachedNostrTools; + if (readCachedToolkit) { + try { + const cached = await readCachedToolkit(); + if ( + cached?.nip04 && + typeof cached.nip04.encrypt === "function" + ) { + cachedNip04Tools = cached; + return cachedNip04Tools; + } + } catch (error) { + devLogger.warn( + "[nostr] Failed to read cached nostr-tools for watch history:", + error, + ); + } + } + return null; }; const encryptChunk = async (plaintext) => { @@ -1483,6 +1528,40 @@ class WatchHistoryManager { if (!success && errorCode) { result.error = errorCode; } + + const shouldPersistLocally = + pointerAcceptedCount > 0 && !chunkRejectedEverywhere; + + if (shouldPersistLocally) { + const storage = this.getStorage(); + const previousEntry = + this.cache.get(actorKey) || storage.actors?.[actorKey] || {}; + const metadata = sanitizeWatchHistoryMetadata(previousEntry.metadata); + metadata.updatedAt = Date.now(); + metadata.status = success ? "ok" : "partial"; + metadata.lastPublishResults = result.publishResults; + metadata.skippedCount = result.skippedCount || 0; + if (success) { + delete metadata.lastError; + } else { + metadata.lastError = result.error || "publish-partial"; + } + + const entry = { + actor: actorPubkey, + items: canonicalItems, + snapshotId, + pointerEvent: result.pointerEvent, + chunkEvents: result.chunkEvents, + savedAt: Date.now(), + fingerprint: await this.getFingerprint(actorPubkey, canonicalItems), + metadata, + }; + + this.cache.set(actorKey, entry); + this.persistEntry(actorKey, entry); + } + devLogger.info("[nostr] Watch history snapshot publish result.", { actor: actorKey, snapshotId, diff --git a/tests/nostr-publish-rejection.test.mjs b/tests/nostr-publish-rejection.test.mjs index 89a18a6b..116e3f9b 100644 --- a/tests/nostr-publish-rejection.test.mjs +++ b/tests/nostr-publish-rejection.test.mjs @@ -5,7 +5,14 @@ if (typeof globalThis.window === "undefined") { globalThis.window = {}; } +if (!window.crypto || !window.crypto.getRandomValues) { + const { webcrypto } = await import("node:crypto"); + window.crypto = webcrypto; +} + const originalWindowNostr = window.nostr; +const originalWindowNostrTools = window.NostrTools; +const nostrToolsModule = await import("nostr-tools"); window.nostr = { ...(originalWindowNostr || {}), @@ -20,12 +27,43 @@ window.nostr = { }, }; +window.NostrTools = { + ...(originalWindowNostrTools || {}), + ...nostrToolsModule, + generatePrivateKey: () => { + const raw = nostrToolsModule.generateSecretKey(); + const bytes = raw instanceof Uint8Array ? raw : Uint8Array.from(raw || []); + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + }, + getEventHash: nostrToolsModule.getEventHash, + signEvent: (event, priv) => { + const signed = nostrToolsModule.finalizeEvent(event, priv); + return signed.sig; + }, +}; + +let activeSignerCleanup = null; + try { const { RelayPublishError } = await import("../js/nostrPublish.js"); - const { nostrClient } = await import("../js/nostr.js"); + const { nostrClient, setActiveSigner, clearActiveSigner } = await import("../js/nostr.js"); const { subscriptions } = await import("../js/subscriptions.js"); const { userBlocks } = await import("../js/userBlocks.js"); + setActiveSigner({ + type: "session", + pubkey: "f".repeat(64), + signEvent: async (event) => ({ + ...event, + id: event?.id || `signed-${event.kind}`, + sig: `sig-${event.kind}`, + }), + nip04Encrypt: async (_pubkey, plaintext) => `cipher:${plaintext}`, + }); + activeSignerCleanup = clearActiveSigner; + function createRejectingPool(reason = "relay rejected") { const calls = []; return { @@ -245,9 +283,21 @@ try { console.log("nostr publish rejection tests passed"); } finally { + if (typeof activeSignerCleanup === "function") { + try { + activeSignerCleanup(); + } catch (_) { + // ignore cleanup errors + } + } if (typeof originalWindowNostr === "undefined") { delete window.nostr; } else { window.nostr = originalWindowNostr; } + if (typeof originalWindowNostrTools === "undefined") { + delete window.NostrTools; + } else { + window.NostrTools = originalWindowNostrTools; + } } diff --git a/tests/nostr/integration-remote-flow.test.mjs b/tests/nostr/integration-remote-flow.test.mjs new file mode 100644 index 00000000..3ec81091 --- /dev/null +++ b/tests/nostr/integration-remote-flow.test.mjs @@ -0,0 +1,310 @@ +import "../test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { webcrypto } from "node:crypto"; +import { + finalizeEvent, + generateSecretKey, + getEventHash, + getPublicKey, + nip04, +} from "nostr-tools"; + +const { + nostrClient, + clearActiveSigner, +} = await import("../../js/nostr.js"); +const { accessControl } = await import("../../js/accessControl.js"); +const { WATCH_HISTORY_KIND } = await import("../../js/config.js"); +const { normalizeActorKey } = await import("../../js/nostr/watchHistory.js"); +const { VIEW_EVENT_KIND } = await import("../../js/nostr/viewEvents.js"); +const { watchHistoryService } = await import("../../js/watchHistoryService.js"); + +function finalizeWithKey(eventTemplate, secretKey, pubkeyOverride = null) { + const tags = Array.isArray(eventTemplate?.tags) + ? eventTemplate.tags.map((tag) => Array.isArray(tag) ? [...tag] : []) + : []; + const base = { + kind: Number.isFinite(eventTemplate?.kind) + ? eventTemplate.kind + : 1, + content: typeof eventTemplate?.content === "string" + ? eventTemplate.content + : "", + created_at: Number.isFinite(eventTemplate?.created_at) + ? Math.floor(eventTemplate.created_at) + : Math.floor(Date.now() / 1000), + tags, + pubkey: + typeof pubkeyOverride === "string" && pubkeyOverride + ? pubkeyOverride + : typeof eventTemplate?.pubkey === "string" && eventTemplate.pubkey + ? eventTemplate.pubkey + : getPublicKey(secretKey), + }; + return finalizeEvent(base, secretKey); +} + +test("nostr login + remote signing + publish + watch history integration", async (t) => { + const originalWindow = globalThis.window; + const windowRef = originalWindow || {}; + globalThis.window = windowRef; + + if (!windowRef.crypto || !windowRef.crypto.subtle) { + windowRef.crypto = webcrypto; + } + + const originalNostr = windowRef.nostr; + const originalNostrTools = windowRef.NostrTools; + const originalGlobalNostrTools = globalThis.NostrTools; + const originalCanonicalTools = globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + const originalToolsReady = globalThis.nostrToolsReady; + + const originalPool = nostrClient.pool; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalReadRelays = Array.isArray(nostrClient.readRelays) + ? [...nostrClient.readRelays] + : nostrClient.readRelays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalPubkey = nostrClient.pubkey; + const originalSessionActor = nostrClient.sessionActor; + const originalWatchHistoryCacheEntries = Array.from( + nostrClient.watchHistory?.cache?.entries?.() || [], + ); + const originalWatchHistoryStorage = nostrClient.watchHistory?.storage; + + const userSecret = generateSecretKey(); + const userPubkey = getPublicKey(userSecret); + + let extensionSignCount = 0; + let remoteSignCount = 0; + let remoteEncryptCount = 0; + + const publishedEvents = []; + const poolStub = { + publish(urls, event) { + publishedEvents.push({ + urls: Array.isArray(urls) ? [...urls] : [], + event, + }); + return { + on(eventName, handler) { + if (eventName === "ok") { + setTimeout(() => handler(), 0); + } + return this; + }, + }; + }, + sub() { + return { on() {}, unsub() {} }; + }, + close() {}, + }; + + nostrClient.pool = poolStub; + nostrClient.relays = ["wss://relay.integration"]; + nostrClient.readRelays = [...nostrClient.relays]; + nostrClient.writeRelays = [...nostrClient.relays]; + nostrClient.watchHistory.cache?.clear?.(); + nostrClient.watchHistory.fingerprints?.clear?.(); + nostrClient.watchHistory.republishTimers?.forEach?.((entry) => { + if (entry?.timer) { + clearTimeout(entry.timer); + } + }); + nostrClient.watchHistory.republishTimers?.clear?.(); + nostrClient.watchHistory.storage = null; + watchHistoryService.resetProgress?.(); + if (typeof localStorage?.clear === "function") { + localStorage.clear(); + } + + const toolkit = { + finalizeEvent, + getEventHash, + getPublicKey, + nip04, + nip19: { + npubEncode: (hex) => `npub${hex}`, + }, + }; + windowRef.NostrTools = toolkit; + globalThis.NostrTools = toolkit; + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = toolkit; + globalThis.nostrToolsReady = Promise.resolve(toolkit); + + const userNpub = toolkit.nip19.npubEncode(userPubkey); + const originalAccessState = { + whitelistEnabled: accessControl.whitelistEnabled, + whitelist: new Set(accessControl.whitelist), + blacklist: new Set(accessControl.blacklist), + editors: new Set(accessControl.editors), + }; + accessControl.whitelistEnabled = false; + accessControl.whitelist = new Set([userNpub]); + accessControl.blacklist = new Set(); + accessControl.editors = new Set([userNpub]); + + windowRef.nostr = { + enable: () => Promise.resolve(), + getPublicKey: () => Promise.resolve(userPubkey), + signEvent: async (event) => { + extensionSignCount += 1; + return finalizeWithKey(event, userSecret, userPubkey); + }, + nip04: { + encrypt: (pubkey, plaintext) => nip04.encrypt(userSecret, pubkey, plaintext), + decrypt: (pubkey, ciphertext) => nip04.decrypt(userSecret, pubkey, ciphertext), + }, + }; + + t.after(() => { + clearActiveSigner(); + accessControl.whitelistEnabled = originalAccessState.whitelistEnabled; + accessControl.whitelist = new Set(originalAccessState.whitelist); + accessControl.blacklist = new Set(originalAccessState.blacklist); + accessControl.editors = new Set(originalAccessState.editors); + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.readRelays = originalReadRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.pubkey = originalPubkey; + nostrClient.sessionActor = originalSessionActor; + nostrClient.watchHistory.cache = new Map(originalWatchHistoryCacheEntries); + nostrClient.watchHistory.storage = originalWatchHistoryStorage || null; + nostrClient.watchHistory.fingerprints?.clear?.(); + nostrClient.watchHistory.republishTimers?.forEach?.((entry) => { + if (entry?.timer) { + clearTimeout(entry.timer); + } + }); + nostrClient.watchHistory.republishTimers?.clear?.(); + watchHistoryService.resetProgress?.(); + if (typeof localStorage?.clear === "function") { + localStorage.clear(); + } + + if (originalNostr === undefined) { + delete windowRef.nostr; + } else { + windowRef.nostr = originalNostr; + } + + if (originalNostrTools === undefined) { + delete windowRef.NostrTools; + } else { + windowRef.NostrTools = originalNostrTools; + } + + if (originalGlobalNostrTools === undefined) { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = originalGlobalNostrTools; + } + + if (originalCanonicalTools === undefined) { + delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; + } else { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = originalCanonicalTools; + } + + if (originalToolsReady === undefined) { + delete globalThis.nostrToolsReady; + } else { + globalThis.nostrToolsReady = originalToolsReady; + } + + if (!originalWindow) { + delete globalThis.window; + } + }); + + const loginResult = await nostrClient.login(); + assert.equal(loginResult, userPubkey); + extensionSignCount = 0; + + const remoteSigner = { + type: "nip46", + pubkey: userPubkey, + async signEvent(event) { + remoteSignCount += 1; + return finalizeWithKey(event, userSecret, userPubkey); + }, + async nip04Encrypt(pubkey, plaintext) { + remoteEncryptCount += 1; + return nip04.encrypt(userSecret, pubkey, plaintext); + }, + nip04Decrypt(pubkey, ciphertext) { + return nip04.decrypt(userSecret, pubkey, ciphertext); + }, + }; + + nostrClient.installNip46Client( + { + getActiveSigner: () => remoteSigner, + destroy() {}, + }, + { userPubkey }, + ); + + const viewPointer = { + type: "e", + value: "view-event-integration", + watchedAt: 1_700_000_123, + }; + + const viewResult = await nostrClient.recordVideoView(viewPointer, { + created_at: 1_700_000_125, + }); + assert.equal(viewResult.ok, true); + + const publishResult = await nostrClient.publishVideo( + { + legacyFormData: { + title: "Integration Remote Video", + description: "remote signer flow", + magnet: "magnet:?xt=urn:btih:REMOTEINTEGRATION123456789", + mode: "live", + }, + }, + userPubkey, + ); + assert.equal(typeof publishResult?.id, "string"); + + const snapshotItems = [ + { type: "e", value: viewPointer.value, watchedAt: viewPointer.watchedAt }, + ]; + const snapshotResult = await nostrClient.publishWatchHistorySnapshot( + snapshotItems, + { actorPubkey: userPubkey, snapshotId: "integration-snapshot" }, + ); + assert.equal(snapshotResult.ok, true); + + assert.equal(extensionSignCount, 0, "extension signer should not handle publish operations"); + assert(remoteSignCount > 0, "remote signer should sign nostr events"); + assert(remoteEncryptCount > 0, "remote signer should encrypt watch history payloads"); + + const publishedKinds = publishedEvents.map((entry) => entry.event?.kind); + assert( + publishedKinds.some((kind) => kind === VIEW_EVENT_KIND), + "view events should be published", + ); + assert( + publishedKinds.some((kind) => kind === WATCH_HISTORY_KIND), + "watch history events should be published", + ); + + const actorKey = normalizeActorKey(userPubkey); + const cachedEntry = nostrClient.watchHistory.cache.get(actorKey); + assert.ok(cachedEntry, "watch history cache should contain the published snapshot"); + assert.equal(cachedEntry.items.length, 1); + assert.equal(cachedEntry.items[0]?.value, viewPointer.value); + + const latest = await watchHistoryService.loadLatest(userPubkey); + assert(Array.isArray(latest), "watch history service should resolve an array"); +}); diff --git a/tests/nostr/watchHistory.test.js b/tests/nostr/watchHistory.test.js index e1e04723..bc8be49b 100644 --- a/tests/nostr/watchHistory.test.js +++ b/tests/nostr/watchHistory.test.js @@ -1,3 +1,4 @@ +import "../test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; import test from "node:test"; @@ -304,3 +305,136 @@ test("fetchWatchHistory falls back to pointer payload when nip04 decrypt fails", manager.clear(); } }); + +test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer cannot encrypt", async () => { + const actorPubkey = "f".repeat(64); + const sessionPrivateKey = "a".repeat(64); + let encryptCalls = 0; + const toolkit = { + nip04: { + encrypt: async (_priv, _pub, plaintext) => { + encryptCalls += 1; + return `encrypted:${plaintext}`; + }, + }, + }; + + const manager = createWatchHistoryManager({ + getActivePubkey: () => actorPubkey, + getSessionActor: () => ({ pubkey: actorPubkey, privateKey: sessionPrivateKey }), + ensureSessionActor: async () => actorPubkey, + resolveActiveSigner: () => null, + shouldRequestExtensionPermissions: () => false, + ensureExtensionPermissions: async () => ({ ok: true }), + ensureNostrTools: async () => toolkit, + getCachedNostrTools: () => toolkit, + signEventWithPrivateKey: (event) => ({ + ...event, + id: `signed-${event.kind}`, + sig: `sig-${event.kind}`, + }), + getReadRelays: () => ["wss://relay.integration"], + getWriteRelays: () => ["wss://relay.integration"], + getRelayFallback: () => ["wss://relay.integration"], + getPool: () => ({ + publish: () => ({ + on(eventName, handler) { + if (eventName === "ok") { + handler(); + } + return this; + }, + }), + }), + }); + + try { + const result = await manager.publishSnapshot( + [{ type: "e", value: "pointer", watchedAt: 123 }], + { actorPubkey, snapshotId: "snapshot-injected" }, + ); + + assert.equal( + result.ok, + true, + "snapshot publish should succeed with injected toolkit helpers", + ); + assert( + encryptCalls > 0, + "fallback nostr-tools encrypt helper should be invoked", + ); + } finally { + manager.clear(); + } +}); + +test("publishWatchHistorySnapshot caches successful snapshot results", async () => { + const actorPubkey = "c".repeat(64); + const sessionPrivateKey = "s".repeat(64); + const relayUrls = ["wss://relay.cache"]; + const publishCalls = []; + + const manager = createWatchHistoryManager({ + getActivePubkey: () => actorPubkey, + getSessionActor: () => ({ pubkey: actorPubkey, privateKey: sessionPrivateKey }), + ensureSessionActor: async () => actorPubkey, + resolveActiveSigner: () => null, + shouldRequestExtensionPermissions: () => false, + signEventWithPrivateKey: async (event, key) => ({ + ...event, + id: `${event.kind}:${event.created_at}:${event.content?.length || 0}`, + sig: `sig-${key}`, + }), + ensureNostrTools: async () => ({ + nip04: { + encrypt: async (priv, pub, plaintext) => `enc:${priv}:${pub}:${plaintext}`, + }, + }), + getWriteRelays: () => relayUrls, + getRelayFallback: () => relayUrls, + getPool: () => ({ + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + setTimeout(handler, 0); + } + return this; + }, + }; + }, + }), + }); + + try { + const items = [{ type: "e", value: "cached-pointer", watchedAt: 234 }]; + const result = await manager.publishSnapshot(items, { + actorPubkey, + relays: relayUrls, + snapshotId: "snapshot-cache", + }); + + assert.equal(result.ok, true, "snapshot publish should succeed"); + assert(publishCalls.length > 0, "publish helper should be invoked"); + + const actorKey = actorPubkey.toLowerCase(); + const cacheEntry = manager.cache.get(actorKey); + assert.ok(cacheEntry, "cache entry should be stored for the actor"); + assert.equal(cacheEntry.items.length, 1, "cache should retain canonical items"); + assert.equal(cacheEntry.items[0]?.value, "cached-pointer"); + assert.equal(cacheEntry.snapshotId, result.snapshotId); + assert.deepStrictEqual(cacheEntry.items, result.items); + + const storage = manager.getStorage(); + assert.ok(storage.actors[actorKey], "storage should persist the cache entry"); + assert.equal(storage.actors[actorKey].snapshotId, result.snapshotId); + assert.equal(storage.actors[actorKey].items.length, 1); + + const fingerprint = manager.fingerprints.get(actorKey); + assert.ok(fingerprint, "fingerprint cache should be populated"); + assert.equal(fingerprint, cacheEntry.fingerprint); + } finally { + manager.clear(); + } +}); diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 7fadc47e..d957c870 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -17,7 +17,10 @@ const { getWatchHistoryV2Enabled, setWatchHistoryV2Enabled, } = await import("../js/constants.js"); -const { nostrClient } = await import("../js/nostr.js"); +const { nostrClient, setActiveSigner, getActiveSigner, clearActiveSigner } = await import( + "../js/nostr.js", +); +const { rememberNostrTools } = await import("../js/nostr/toolkit.js"); const { normalizeActorKey } = await import("../js/nostr/watchHistory.js"); const { watchHistoryService } = await import("../js/watchHistoryService.js"); const { buildHistoryCard } = await import("../js/historyView.js"); @@ -39,6 +42,10 @@ setWatchHistoryV2Enabled(true); const originalWindowNostr = window.nostr; const originalNostrTools = window.NostrTools || {}; +const originalWatchHistoryEnsureTools = + nostrClient.watchHistory?.deps?.ensureNostrTools || null; +const originalWatchHistoryGetCachedTools = + nostrClient.watchHistory?.deps?.getCachedNostrTools || null; const originalPool = nostrClient.pool; const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] @@ -302,19 +309,30 @@ nostrClient.relays = ["wss://relay.test"]; nostrClient.readRelays = ["wss://relay.test"]; nostrClient.writeRelays = ["wss://relay.test"]; -function installSessionCrypto({ privateKey }) { +async function installSessionCrypto({ privateKey }) { const original = window.NostrTools || {}; let encryptCalls = 0; let decryptCalls = 0; + const { createHash, randomBytes } = await import("node:crypto"); + const deriveHex = (input) => + createHash("sha256").update(String(input ?? ""), "utf8").digest("hex"); + const previousEnsure = + nostrClient.watchHistory?.deps?.ensureNostrTools || null; + const previousGetCached = + nostrClient.watchHistory?.deps?.getCachedNostrTools || null; window.NostrTools = { ...original, + generatePrivateKey: () => randomBytes(32).toString("hex"), + getPublicKey: (secret) => { + const normalized = typeof secret === "string" ? secret.trim() : ""; + if (!normalized) { + throw new Error("missing-secret"); + } + return deriveHex(`pub:${normalized}`); + }, getEventHash: (event) => `hash-${event.kind}-${event.created_at}-${event.tags?.length || 0}`, - signEvent: (event, key) => ({ - ...event, - id: `signed-${event.kind}-${event.created_at}-${event.tags?.length || 0}`, - sig: `sig-${key}`, - }), + signEvent: (_event, key) => `sig-${key}`, nip04: { ...(original.nip04 || {}), encrypt: async (secret, pub, plaintext) => { @@ -333,9 +351,28 @@ function installSessionCrypto({ privateKey }) { }, }, }; + rememberNostrTools(window.NostrTools); + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = window.NostrTools; + globalThis.nostrToolsReady = Promise.resolve(window.NostrTools); + if (nostrClient.watchHistory?.deps) { + nostrClient.watchHistory.deps.ensureNostrTools = async () => window.NostrTools; + nostrClient.watchHistory.deps.getCachedNostrTools = () => window.NostrTools; + } return { restore() { window.NostrTools = original; + if (nostrClient.watchHistory?.deps) { + if (previousEnsure) { + nostrClient.watchHistory.deps.ensureNostrTools = previousEnsure; + } else { + delete nostrClient.watchHistory.deps.ensureNostrTools; + } + if (previousGetCached) { + nostrClient.watchHistory.deps.getCachedNostrTools = previousGetCached; + } else { + delete nostrClient.watchHistory.deps.getCachedNostrTools; + } + } }, getEncryptCalls: () => encryptCalls, getDecryptCalls: () => decryptCalls, @@ -396,8 +433,24 @@ function installExtensionCrypto({ actor }) { }, }, }; + rememberNostrTools(window.NostrTools); + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = window.NostrTools; + globalThis.nostrToolsReady = Promise.resolve(window.NostrTools); + const previousSigner = getActiveSigner(); + setActiveSigner({ + type: "extension", + pubkey: actor, + signEvent: window.nostr.signEvent, + nip04Encrypt: window.nostr.nip04.encrypt, + nip04Decrypt: window.nostr.nip04.decrypt, + }); return { restore() { + if (previousSigner) { + setActiveSigner(previousSigner); + } else { + clearActiveSigner(); + } window.nostr = originalNostr; window.NostrTools = originalTools; }, @@ -644,7 +697,7 @@ async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() { const runVariant = async (label, actorInput, pubkeyInput) => { await publishEvents(); - const sessionCrypto = installSessionCrypto({ privateKey: `session-${label}` }); + const sessionCrypto = await installSessionCrypto({ privateKey: `session-${label}` }); const extensionCrypto = installExtensionCrypto({ actor: actorHex }); const previousEnsure = nostrClient.ensureSessionActor; @@ -727,7 +780,7 @@ async function testPublishSnapshotCanonicalizationAndChunking() { poolHarness.setResolver(() => ({ ok: true })); const actor = "session-pubkey"; - const restoreCrypto = installSessionCrypto({ privateKey: "session-priv" }); + const restoreCrypto = await installSessionCrypto({ privateKey: "session-priv" }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; @@ -756,6 +809,9 @@ async function testPublishSnapshotCanonicalizationAndChunking() { { type: "e", value: "pointer-small", watchedAt: 80 }, ]; + const { getCachedNostrTools } = await import("../js/nostr/toolkit.js"); + getCachedNostrTools(); + const firstResult = await nostrClient.publishWatchHistorySnapshot(rawItems, { actorPubkey: actor, snapshotId: "session-snapshot", @@ -890,7 +946,7 @@ async function testPublishSnapshotUsesExtensionCrypto() { poolHarness.setResolver(() => ({ ok: true })); const actor = "ext-pubkey"; - const sessionRestore = installSessionCrypto({ privateKey: "session-priv" }); + const sessionRestore = await installSessionCrypto({ privateKey: "session-priv" }); const extension = installExtensionCrypto({ actor }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; @@ -1013,7 +1069,7 @@ async function testPublishSnapshotFailureRetry() { nostrClient.watchHistoryLastCreatedAt = 0; const actor = "retry-actor"; - const restoreCrypto = installSessionCrypto({ privateKey: "retry-priv" }); + const restoreCrypto = await installSessionCrypto({ privateKey: "retry-priv" }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; const originalPub = nostrClient.pubkey; @@ -1070,7 +1126,7 @@ async function testWatchHistoryPartialRelayRetry() { nostrClient.watchHistoryLastCreatedAt = 0; const actor = "partial-actor"; - const restoreCrypto = installSessionCrypto({ privateKey: "partial-priv" }); + const restoreCrypto = await installSessionCrypto({ privateKey: "partial-priv" }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; const originalPub = nostrClient.pubkey; @@ -1270,10 +1326,11 @@ async function testWatchHistoryPartialRelayRetry() { } async function testResolveWatchHistoryBatchingWindow() { - const actor = "batch-window-actor"; + const actor = "b".repeat(64); const originalEnsure = nostrClient.ensureSessionActor; const originalPub = nostrClient.pubkey; const originalFetch = nostrClient.fetchWatchHistory; + const originalManagerFetch = nostrClient.watchHistory.fetch; const originalCache = nostrClient.watchHistoryCache; const originalStorage = nostrClient.watchHistoryStorage; @@ -1289,11 +1346,13 @@ async function testResolveWatchHistoryBatchingWindow() { watchedAt: 1_700_100_000 + index, })); - nostrClient.fetchWatchHistory = async () => ({ + const fetchResult = { items: syntheticItems.map((item) => ({ ...item })), snapshotId: "batch-snapshot", pointerEvent: null, - }); + }; + nostrClient.fetchWatchHistory = async () => fetchResult; + nostrClient.watchHistory.fetch = async () => fetchResult; const resolved = await nostrClient.resolveWatchHistory(actor, { forceRefresh: true, @@ -1332,6 +1391,7 @@ async function testResolveWatchHistoryBatchingWindow() { nostrClient.ensureSessionActor = originalEnsure; nostrClient.pubkey = originalPub; nostrClient.fetchWatchHistory = originalFetch; + nostrClient.watchHistory.fetch = originalManagerFetch; nostrClient.watchHistoryCache = originalCache; nostrClient.watchHistoryStorage = originalStorage; } @@ -1351,7 +1411,7 @@ async function testWatchHistoryServiceIntegration() { infoHash: "89abcdef0123456789abcdef0123456789abcdef", legacyInfoHash: "89abcdef0123456789abcdef0123456789abcdef", }; - const restoreCrypto = installSessionCrypto({ privateKey: "service-priv" }); + const restoreCrypto = await installSessionCrypto({ privateKey: "service-priv" }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; const originalPub = nostrClient.pubkey; @@ -2143,6 +2203,20 @@ console.log("watch-history.test.mjs completed successfully"); window.nostr = originalWindowNostr; window.NostrTools = originalNostrTools; +if (nostrClient.watchHistory?.deps) { + if (originalWatchHistoryEnsureTools) { + nostrClient.watchHistory.deps.ensureNostrTools = + originalWatchHistoryEnsureTools; + } else { + delete nostrClient.watchHistory.deps.ensureNostrTools; + } + if (originalWatchHistoryGetCachedTools) { + nostrClient.watchHistory.deps.getCachedNostrTools = + originalWatchHistoryGetCachedTools; + } else { + delete nostrClient.watchHistory.deps.getCachedNostrTools; + } +} nostrClient.pool = originalPool; nostrClient.relays = originalRelays; nostrClient.readRelays = originalReadRelays;