From 00f96bbe0e10c211156dad3a624a30c37b8bd7ca Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:46:35 -0500 Subject: [PATCH] update --- js/historyView.js | 66 ++++++++++++++ js/nostr/watchHistory.js | 142 +++++++++++++++++++++++++++++-- tests/nostr/watchHistory.test.js | 61 +++++++++++++ 3 files changed, 260 insertions(+), 9 deletions(-) diff --git a/js/historyView.js b/js/historyView.js index 4246a440..33f27d48 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -2,6 +2,10 @@ import watchHistoryService from "./watchHistoryService.js"; import { nostrClient } from "./nostrClientFacade.js"; +import { + createFeedEngine, + createWatchHistoryFeedDefinition, +} from "./feedEngine/index.js"; import { updateWatchHistoryListWithDefaultClient as updateWatchHistoryList, } from "./nostrWatchHistoryFacade.js"; @@ -86,6 +90,21 @@ function getAppInstance() { return getApplication(); } +let localWatchHistoryFallbackEngine = null; + +function getLocalWatchHistoryFallbackEngine() { + if (localWatchHistoryFallbackEngine) { + return localWatchHistoryFallbackEngine; + } + const engine = createFeedEngine(); + engine.registerFeed( + "watch-history", + createWatchHistoryFeedDefinition({ service: watchHistoryService }), + ); + localWatchHistoryFallbackEngine = engine; + return localWatchHistoryFallbackEngine; +} + function buildWatchHistoryFeedRuntime({ actor, cursor = 0, @@ -228,8 +247,17 @@ function normalizeHistoryItems(rawItems) { if (!key) { continue; } + const baseMetadata = + candidate && + typeof candidate === "object" && + candidate.metadata && + typeof candidate.metadata === "object" + ? { ...candidate.metadata } + : {}; const watchedAtRaw = Number.isFinite(candidate?.watchedAt) ? candidate.watchedAt + : Number.isFinite(baseMetadata?.watchedAt) + ? baseMetadata.watchedAt : Number.isFinite(candidate?.timestamp) ? candidate.timestamp : Number.isFinite(pointer?.watchedAt) @@ -237,10 +265,23 @@ function normalizeHistoryItems(rawItems) { : null; const watchedAt = watchedAtRaw !== null ? Math.max(0, Math.floor(watchedAtRaw)) : 0; + const video = candidate?.video || baseMetadata.video || null; + const profile = candidate?.profile || baseMetadata.profile || null; + const metadata = { + ...baseMetadata, + pointerKey: key, + watchedAt: Number.isFinite(baseMetadata?.watchedAt) + ? baseMetadata.watchedAt + : watchedAt || null, + video: video || null, + profile: profile || null, + }; normalized.push({ pointer, pointerKey: key, watchedAt, + video: metadata.video, + metadata, raw: candidate }); } @@ -1439,6 +1480,31 @@ export function createWatchHistoryRenderer(config = {}) { ); } } + try { + const runtime = buildWatchHistoryFeedRuntime({ + actor: actorInput, + cursor, + forceRefresh, + }); + const fallbackEngine = getLocalWatchHistoryFallbackEngine(); + const hydrated = await fallbackEngine.run("watch-history", { runtime }); + return { + ...(hydrated && typeof hydrated === "object" ? hydrated : {}), + metadata: { + ...(hydrated?.metadata && typeof hydrated.metadata === "object" + ? hydrated.metadata + : {}), + engine: "local-feed-fallback", + }, + }; + } catch (fallbackError) { + if (isDevEnv) { + devLogger.warn( + "[historyView] local feed fallback failed; using raw service data.", + fallbackError, + ); + } + } const items = await watchHistoryService.loadLatest(actorInput, { allowStale: !forceRefresh, forceRefresh, diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 3b7f2b95..c9741cf7 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -693,6 +693,101 @@ async function resolveNostrToolkit(deps = {}, cacheRef = null) { return cacheRef?.current || null; } +async function decryptWatchHistoryCiphertext( + { + event = null, + ciphertext = "", + actorKey = "", + decryptSigner = null, + sessionPrivateKey = "", + deps = {}, + } = {}, + toolkitCacheRef = null, +) { + const normalizedCiphertext = + typeof ciphertext === "string" ? ciphertext.trim() : ""; + if (!normalizedCiphertext || !actorKey) { + return ""; + } + + const decryptors = new Map(); + const registerDecryptor = (scheme, decrypt) => { + if (typeof scheme !== "string" || !scheme || typeof decrypt !== "function") { + return; + } + if (!decryptors.has(scheme)) { + decryptors.set(scheme, decrypt); + } + }; + + if (decryptSigner && typeof decryptSigner === "object") { + if (typeof decryptSigner.nip44Decrypt === "function") { + registerDecryptor("nip44", (value) => + decryptSigner.nip44Decrypt(actorKey, value), + ); + } + if (typeof decryptSigner.nip04Decrypt === "function") { + registerDecryptor("nip04", (value) => + decryptSigner.nip04Decrypt(actorKey, value), + ); + } + } + + const normalizedSessionPrivateKey = + typeof sessionPrivateKey === "string" ? sessionPrivateKey.trim().toLowerCase() : ""; + const canUseSessionPrivateKey = /^[0-9a-f]{64}$/.test(normalizedSessionPrivateKey); + if (canUseSessionPrivateKey) { + const tools = await resolveNostrToolkit(deps, toolkitCacheRef); + if (tools?.nip04 && typeof tools.nip04.decrypt === "function") { + registerDecryptor("nip04", (value) => + tools.nip04.decrypt(normalizedSessionPrivateKey, actorKey, value), + ); + } + const nip44Suite = createNip44CipherSuite( + tools, + normalizedSessionPrivateKey, + actorKey, + ); + if (nip44Suite?.v2 && typeof nip44Suite.v2.decrypt === "function") { + registerDecryptor("nip44_v2", (value) => nip44Suite.v2.decrypt(value)); + } + if (nip44Suite?.legacy && typeof nip44Suite.legacy.decrypt === "function") { + registerDecryptor("nip44", (value) => nip44Suite.legacy.decrypt(value)); + } + } + + if (!decryptors.size) { + return ""; + } + + const availableSchemes = Array.from(decryptors.keys()); + const order = determineWatchHistoryDecryptionOrder( + event, + normalizedCiphertext, + availableSchemes, + ); + + for (const scheme of order) { + const decrypt = decryptors.get(scheme); + if (typeof decrypt !== "function") { + continue; + } + try { + const plaintext = await decrypt(normalizedCiphertext); + if (typeof plaintext === "string" && plaintext.trim()) { + return plaintext; + } + } catch (error) { + devLogger.warn( + `[nostr] Failed to decrypt watch history payload with ${scheme}:`, + error, + ); + } + } + + return ""; +} + function mergeWatchHistoryItemsWithFallback(parsed, fallbackItems) { if (!parsed || typeof parsed !== "object") { return { @@ -1900,8 +1995,13 @@ class WatchHistoryManager { } } + const sessionPrivateKey = + typeof sessionActor?.privateKey === "string" ? sessionActor.privateKey : ""; + const canAttemptDecrypt = Boolean(decryptSigner) || Boolean(sessionPrivateKey); + const toolkitCacheRef = { current: null }; + let decryptedItems = []; - if (chunkIdentifiers.length && decryptSigner) { + if (chunkIdentifiers.length && canAttemptDecrypt) { try { const results = await pool.list(readRelays, [ { @@ -1920,13 +2020,19 @@ class WatchHistoryManager { if (!ciphertext) { return []; } - let plaintext = ""; - if (typeof decryptSigner.nip04Decrypt === "function") { - try { - plaintext = await decryptSigner.nip04Decrypt(actorKey, ciphertext); - } catch (error) { - devLogger.warn("[nostr] Failed to decrypt watch history chunk:", error); - } + let plaintext = looksLikeJsonStructure(ciphertext) ? ciphertext : ""; + if (!plaintext) { + plaintext = await decryptWatchHistoryCiphertext( + { + event, + ciphertext, + actorKey, + decryptSigner, + sessionPrivateKey, + deps: this.deps, + }, + toolkitCacheRef, + ); } if (!plaintext) { return []; @@ -1962,9 +2068,27 @@ class WatchHistoryManager { const dTag = event.tags.find(t => t[0] === 'd'); const eventSnapshotId = dTag ? dTag[1] : ""; + let payloadContent = ciphertext; + if (!looksLikeJsonStructure(payloadContent) && canAttemptDecrypt) { + const decrypted = await decryptWatchHistoryCiphertext( + { + event, + ciphertext: payloadContent, + actorKey, + decryptSigner, + sessionPrivateKey, + deps: this.deps, + }, + toolkitCacheRef, + ); + if (decrypted) { + payloadContent = decrypted; + } + } + // Try parsing as plaintext first const payload = parseWatchHistoryContentWithFallback( - ciphertext, + payloadContent, fallbackPointers, { version: 0, diff --git a/tests/nostr/watchHistory.test.js b/tests/nostr/watchHistory.test.js index e8958e47..b5e90fc8 100644 --- a/tests/nostr/watchHistory.test.js +++ b/tests/nostr/watchHistory.test.js @@ -311,6 +311,67 @@ test("fetchWatchHistory falls back to pointer payload when nip04 decrypt fails", } }); +test("fetchWatchHistory decrypts encrypted pointer events with nip44 signer support", async () => { + const actorHex = "5".repeat(64); + const ciphertext = "nip44-ciphertext"; + const decryptCalls = []; + + const pointerEvent = { + id: "pointer-nip44", + pubkey: actorHex, + created_at: 1_700_200_000, + content: ciphertext, + tags: [ + ["encrypted", "nip44"], + ["a", "30078:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:fallback"], + ["d", "watch-history-month"], + ], + }; + + const signer = { + nip44Decrypt: async (pubkey, payload) => { + decryptCalls.push({ pubkey, payload }); + assert.equal(pubkey, actorHex, "nip44 decrypt should target the actor pubkey"); + assert.equal(payload, ciphertext, "nip44 decrypt should receive encrypted content"); + return JSON.stringify({ + version: 2, + items: [{ type: "e", value: "decrypted-from-pointer", watchedAt: 33 }], + }); + }, + }; + + const pool = { + async list() { + return [pointerEvent]; + }, + }; + + const manager = createWatchHistoryManager({ + getActivePubkey: () => actorHex, + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => false, + getPool: () => pool, + getReadRelays: () => ["wss://relay.example"], + }); + + try { + const result = await fetchWatchHistory(manager, actorHex, { forceRefresh: true }); + assert.equal(decryptCalls.length, 1, "nip44 decrypt should be attempted once"); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["decrypted-from-pointer"], + "decrypted pointer event payload should be used", + ); + assert.equal( + result.items[0]?.watchedAt, + 33, + "watched timestamp from decrypted payload should be preserved", + ); + } finally { + manager.clear(); + } +}); + test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer cannot encrypt", async () => { const actorPubkey = "f".repeat(64); const sessionPrivateKey = "a".repeat(64);