From 36f5f8960a9157829159ef38e90e2c574d5ef877 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:43:04 -0400 Subject: [PATCH] Allow direct signers to load and publish subscriptions offline --- js/subscriptions.js | 28 ++++- tests/subscriptions-manager.test.mjs | 178 ++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 6 deletions(-) diff --git a/js/subscriptions.js b/js/subscriptions.js index 99a80fda..f4f3ba40 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -230,7 +230,22 @@ class SubscriptionsManager { const newest = events[0]; this.subsEventId = newest.id; - const permissionResult = await requestDefaultExtensionPermissions(); + const signer = getActiveSigner(); + const signerHasNip04 = typeof signer?.nip04Decrypt === "function"; + const signerHasNip44 = typeof signer?.nip44Decrypt === "function"; + const hints = extractEncryptionHints(newest); + const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2"); + const requiresNip04 = + !hints.length || hints.includes("nip04") || !requiresNip44; + + let permissionResult = { ok: true }; + const signerCoversRequiredSchemes = + (!requiresNip04 || signerHasNip04) && (!requiresNip44 || signerHasNip44); + + if (!signerCoversRequiredSchemes) { + permissionResult = await requestDefaultExtensionPermissions(); + } + if (!permissionResult.ok) { userLogger.warn( "[SubscriptionsManager] Extension permissions denied while loading subscriptions; treating list as empty.", @@ -300,18 +315,21 @@ class SubscriptionsManager { }; const signer = getActiveSigner(); - if (signer?.nip04Decrypt) { + const signerHasNip04 = typeof signer?.nip04Decrypt === "function"; + const signerHasNip44 = typeof signer?.nip44Decrypt === "function"; + + if (signerHasNip04) { registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload)); } - if (signer?.nip44Decrypt) { + if (signerHasNip44) { registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload)); } const nostrApi = typeof window !== "undefined" && window && window.nostr ? window.nostr : null; if (nostrApi) { - if (nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") { + if (!signerHasNip04 && nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") { registerDecryptor("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload) ); @@ -320,7 +338,7 @@ class SubscriptionsManager { const nip44 = nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null; if (nip44) { - if (typeof nip44.decrypt === "function") { + if (!signerHasNip44 && typeof nip44.decrypt === "function") { registerDecryptor("nip44", (payload) => nip44.decrypt(userPubkey, payload)); } diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index cb9f4e2a..bfe1d218 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { JSDOM } from "jsdom"; -import { nostrClient } from "../js/nostr.js"; +import { nostrClient, getActiveSigner, setActiveSigner } from "../js/nostr.js"; import { subscriptions } from "../js/subscriptions.js"; import { setApplication } from "../js/applicationContext.js"; import nostrService from "../js/services/nostrService.js"; @@ -311,6 +311,93 @@ test("loadSubscriptions handles nip44.v2 decryptors", async () => { } }); +test( + "loadSubscriptions uses active signer decryptors without requesting extension permissions", + async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSigner = getActiveSigner(); + + const hadWindow = typeof globalThis.window !== "undefined"; + if (!hadWindow) { + globalThis.window = {}; + } + const originalWindowNostr = globalThis.window.nostr; + delete globalThis.window.nostr; + + const relayUrls = ["wss://relay-direct.example"]; + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + + const event = { + id: "event-direct", + created_at: 600, + content: "cipher-direct", + }; + + nostrClient.pool = { + list() { + return Promise.resolve([event]); + }, + }; + + let permissionCalls = 0; + nostrClient.ensureExtensionPermissions = async () => { + permissionCalls += 1; + return { ok: true }; + }; + + const decryptCalls = []; + setActiveSigner({ + pubkey: "user-pubkey-123", + async nip04Decrypt(pubkey, ciphertext) { + decryptCalls.push({ pubkey, ciphertext }); + return JSON.stringify({ subPubkeys: ["pub-direct"] }); + }, + }); + + try { + await manager.loadSubscriptions("user-pubkey-123"); + + assert.equal(permissionCalls, 0, "should not request extension permissions"); + assert.equal(decryptCalls.length, 1, "signer decrypt should be invoked once"); + assert.deepEqual( + decryptCalls[0], + { pubkey: "user-pubkey-123", ciphertext: "cipher-direct" }, + "signer decrypt should receive the expected arguments", + ); + assert.deepEqual( + Array.from(manager.subscribedPubkeys), + ["pub-direct"], + "direct signer decrypt should populate subscriptions", + ); + } finally { + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + setActiveSigner(originalSigner); + if (typeof originalWindowNostr === "undefined") { + delete globalThis.window.nostr; + } else { + globalThis.window.nostr = originalWindowNostr; + } + if (!hadWindow) { + delete globalThis.window; + } + } + }, +); + test( "showSubscriptionVideos waits for nostrService warm-up and refreshes after updates", async () => { @@ -453,6 +540,95 @@ test( }, ); +test( + "publishSubscriptionList succeeds with direct signer without requesting extension permissions", + async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + const originalSigner = getActiveSigner(); + + const relayUrls = ["wss://relay-direct.example"]; + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + + let permissionCalls = 0; + nostrClient.ensureExtensionPermissions = async () => { + permissionCalls += 1; + return { ok: true }; + }; + + const publishCalls = []; + nostrClient.pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + handler(); + } + return true; + }, + }; + }, + }; + + const encryptCalls = []; + const signCalls = []; + setActiveSigner({ + type: "nsec", + pubkey: "user-pubkey-123", + async nip04Encrypt(pubkey, plaintext) { + encryptCalls.push({ pubkey, plaintext }); + return "cipher-direct"; + }, + async signEvent(event) { + signCalls.push(event); + return { ...event, id: "signed-direct-event" }; + }, + }); + + manager.subscribedPubkeys = new Set(["pub-direct"]); + + try { + await manager.publishSubscriptionList("user-pubkey-123"); + + assert.equal(permissionCalls, 0, "should not request extension permissions"); + assert.equal(encryptCalls.length, 1, "nip04Encrypt should be called once"); + assert.equal(signCalls.length, 1, "signEvent should be called once"); + assert.deepEqual( + encryptCalls[0], + { + pubkey: "user-pubkey-123", + plaintext: JSON.stringify({ subPubkeys: ["pub-direct"] }), + }, + "nip04Encrypt should receive the serialized subscription list", + ); + assert.equal( + signCalls[0].content, + "cipher-direct", + "signEvent should receive the encrypted content", + ); + assert.equal(publishCalls.length, relayUrls.length, "should publish to each relay"); + assert.equal(manager.subsEventId, "signed-direct-event"); + } finally { + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + setActiveSigner(originalSigner); + } + }, +); + test("renderSameGridStyle shows empty state message", async () => { const SubscriptionsManager = subscriptions.constructor;