From 39d1eda0583d45cf77c881f02d62e88f06f9afb0 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:46:23 +0000 Subject: [PATCH] feat(services): add window.nostr fallback for hashtag preference decryption This commit updates `HashtagPreferencesService.decryptEvent` to register and utilize decryption methods from `window.nostr` (NIP-04 and NIP-44) as a fallback mechanism. This ensures that users without an active internal signer (e.g., read-only or extension-only users) can still decrypt their preferences if they have granted permission to the extension. - Mirrors the decryption logic from `UserBlockListManager` (`js/userBlocks.js`). - Adds logging for fallback attempts and failures. - Preserves existing scheme selection logic (`determineDecryptionOrder`). - Adds new test cases to `tests/hashtag-preferences.test.mjs` verifying fallback behavior when the active signer is missing or incapable. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/services/hashtagPreferencesService.js | 78 ++++++++++++++++++-- tests/hashtag-preferences.test.mjs | 92 ++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 5 deletions(-) diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 0779682d..3850ead2 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -726,22 +726,76 @@ class HashtagPreferencesService { } const decryptors = new Map(); - const registerDecryptor = (scheme, handler) => { + const sources = new Map(); + const registerDecryptor = (scheme, handler, source = "unknown") => { if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) { return; } decryptors.set(scheme, handler); + sources.set(scheme, source); }; if (signerHasNip44) { - registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload)); - registerDecryptor("nip44_v2", (payload) => - signer.nip44Decrypt(userPubkey, payload), + registerDecryptor( + "nip44", + (payload) => signer.nip44Decrypt(userPubkey, payload), + "active-signer", + ); + registerDecryptor( + "nip44_v2", + (payload) => signer.nip44Decrypt(userPubkey, payload), + "active-signer", ); } if (signerHasNip04) { - registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload)); + registerDecryptor( + "nip04", + (payload) => signer.nip04Decrypt(userPubkey, payload), + "active-signer", + ); + } + + const nostrApi = typeof window !== "undefined" ? window?.nostr : null; + if (nostrApi) { + if (typeof nostrApi.nip04?.decrypt === "function") { + registerDecryptor( + "nip04", + (payload) => nostrApi.nip04.decrypt(userPubkey, payload), + "extension", + ); + } + + const nip44 = + nostrApi.nip44 && typeof nostrApi.nip44 === "object" + ? nostrApi.nip44 + : null; + if (nip44) { + if (typeof nip44.decrypt === "function") { + registerDecryptor( + "nip44", + (payload) => nip44.decrypt(userPubkey, payload), + "extension", + ); + } + + const nip44v2 = + nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; + if (nip44v2 && typeof nip44v2.decrypt === "function") { + registerDecryptor( + "nip44_v2", + (payload) => nip44v2.decrypt(userPubkey, payload), + "extension", + ); + if (!decryptors.has("nip44")) { + registerDecryptor( + "nip44", + (payload) => nip44v2.decrypt(userPubkey, payload), + "extension", + ); + } + } + } } if (!decryptors.size) { @@ -760,6 +814,14 @@ class HashtagPreferencesService { if (!decryptFn) { continue; } + const source = sources.get(scheme); + + if (source === "extension") { + userLogger.info( + `${LOG_PREFIX} Attempting decryption via window.nostr fallback (${scheme})`, + ); + } + try { const plaintext = await decryptFn(ciphertext); if (typeof plaintext !== "string") { @@ -771,6 +833,12 @@ class HashtagPreferencesService { } return { ok: true, plaintext, scheme }; } catch (error) { + if (source === "extension") { + userLogger.warn( + `${LOG_PREFIX} window.nostr fallback decryption failed (${scheme})`, + error, + ); + } attemptErrors.push({ scheme, error }); } } diff --git a/tests/hashtag-preferences.test.mjs b/tests/hashtag-preferences.test.mjs index bde0acbd..6d93738b 100644 --- a/tests/hashtag-preferences.test.mjs +++ b/tests/hashtag-preferences.test.mjs @@ -463,3 +463,95 @@ test( }, ); +test( + "load falls back to window.nostr when active signer is missing", + { concurrency: false }, + async (t) => { + const pubkey = "abcdef".repeat(10) + "1234"; + + // Ensure no active signer (override beforeEach) + clearActiveSigner(); + + // Mock window.nostr + window.nostr = { + nip04: { + decrypt: async (pk, ciphertext) => { + if (pk !== pubkey) throw new Error("Wrong pubkey"); + if (ciphertext === "window-encrypted") { + return JSON.stringify({ + version: 1, + interests: ["WindowSuccess"], + disinterests: [] + }); + } + throw new Error("Decrypt failed"); + } + } + }; + + // Mock pool list to return an event + nostrClient.pool = { + list: async () => [{ + id: "evt1", + created_at: 1000, + pubkey, + content: "window-encrypted", + tags: [["encrypted", "nip04"]] + }] + }; + + relayManager.setEntries([{ url: "wss://relay.fallback", mode: "both" }], { allowEmpty: false, updateClient: false }); + + await hashtagPreferences.load(pubkey); + + assert.deepEqual(hashtagPreferences.getInterests(), ["windowsuccess"]); + } +); + +test( + "load falls back to window.nostr when active signer lacks decrypt capabilities", + { concurrency: false }, + async (t) => { + const pubkey = "abcdef".repeat(10) + "5678"; + + // Set a signer that can sign but NOT decrypt (override beforeEach) + setActiveSigner({ + signEvent: async (e) => ({ ...e, id: "fake" }), + // No nip04Decrypt or nip44Decrypt + }); + + // Mock window.nostr + window.nostr = { + nip04: { + decrypt: async (pk, ciphertext) => { + if (pk !== pubkey) throw new Error("Wrong pubkey"); + if (ciphertext === "window-encrypted-2") { + return JSON.stringify({ + version: 1, + interests: ["SignerFallback"], + disinterests: [] + }); + } + throw new Error("Decrypt failed"); + } + } + }; + + // Mock pool list to return an event + nostrClient.pool = { + list: async () => [{ + id: "evt2", + created_at: 1000, + pubkey, + content: "window-encrypted-2", + tags: [["encrypted", "nip04"]] + }] + }; + + relayManager.setEntries([{ url: "wss://relay.fallback", mode: "both" }], { allowEmpty: false, updateClient: false }); + + await hashtagPreferences.load(pubkey); + + assert.deepEqual(hashtagPreferences.getInterests(), ["signerfallback"]); + } + );