From 51924d16193f02a98f710a86c3629dcdff96c755 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:19:44 +0000 Subject: [PATCH 1/3] fix(nostr): handle lazy NIP-44 injection in NIP-07 adapter Updates `js/nostr/adapters/nip07Adapter.js` to unconditionally expose encryption methods (`nip04Encrypt`, `nip44Decrypt`, etc.) and use dynamic property getters for capabilities. This fixes a race condition where the adapter would initialize without NIP-44 support if the browser extension injected the `nip44` API slightly after `window.nostr` became available, preventing encrypted user lists (subscriptions, blocks, hashtags) from loading. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostr/adapters/nip07Adapter.js | 49 +++++++++++++------------------ 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/js/nostr/adapters/nip07Adapter.js b/js/nostr/adapters/nip07Adapter.js index 0c52dd4b..1e5826e2 100644 --- a/js/nostr/adapters/nip07Adapter.js +++ b/js/nostr/adapters/nip07Adapter.js @@ -90,12 +90,6 @@ export async function createNip07Adapter(initialExtension) { ? normalizeActorKey(await getPublicKey()) : ""; - const hasNip04 = - bootstrapExtension.nip04 && typeof bootstrapExtension.nip04 === "object"; - - const bootstrapNip44 = resolveNip44Module(bootstrapExtension); - const hasNip44 = !!bootstrapNip44; - // Wrapper builders to ensure dynamic lookup at call time const createNip04Encrypt = () => (pubkey, plaintext, options = {}) => { const ext = getExtension(); @@ -206,30 +200,29 @@ export async function createNip07Adapter(initialExtension) { requestPermissions, destroy: async () => {}, canSign: () => typeof getExtension()?.signEvent === "function", - capabilities: { - sign: typeof bootstrapExtension.signEvent === "function", - nip44: hasNip44, - nip04: hasNip04, - }, + // Always expose these methods; they perform their own existence checks at runtime. + // This handles race conditions where NIP-04/44 APIs are injected lazily. + nip04Encrypt: createNip04Encrypt(), + nip04Decrypt: createNip04Decrypt(), + nip44Encrypt: createNip44Encrypt(), + nip44Decrypt: createNip44Decrypt(), }; - if (hasNip04) { - if (typeof bootstrapExtension.nip04.encrypt === "function") { - signer.nip04Encrypt = createNip04Encrypt(); - } - if (typeof bootstrapExtension.nip04.decrypt === "function") { - signer.nip04Decrypt = createNip04Decrypt(); - } - } - - if (hasNip44) { - if (typeof bootstrapNip44.encrypt === "function") { - signer.nip44Encrypt = createNip44Encrypt(); - } - if (typeof bootstrapNip44.decrypt === "function") { - signer.nip44Decrypt = createNip44Decrypt(); - } - } + // Define capabilities as dynamic properties to reflect current extension state + Object.defineProperty(signer, "capabilities", { + get: () => { + const ext = getExtension(); + const hasNip04 = ext?.nip04 && typeof ext.nip04 === "object"; + const hasNip44 = !!resolveNip44Module(ext); + return { + sign: typeof ext?.signEvent === "function", + nip44: hasNip44, + nip04: hasNip04, + }; + }, + enumerable: true, + configurable: true, + }); return signer; } From d38585f376ff17c7c1b4a40c762526b4bb841a0f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:47:08 +0000 Subject: [PATCH 2/3] Fix NIP-07 adapter race condition with lazy encryption capability injection - Modified `js/nostr/adapters/nip07Adapter.js` to use dynamic getters for `capabilities` (nip04/nip44) and unconditionally expose encryption methods. - Updated `js/nostr/client.js` to use `Object.getOwnPropertyDescriptor` when handling `signer.capabilities`. - Added `tests/nostr/nip07Adapter-race-condition.test.mjs` to verify the fix. - Fixed test environment issues in `tests/nostr/adapters.test.mjs` and `tests/nostr-login-permissions.test.mjs`. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostr/adapters/nip07Adapter.js | 2 +- js/nostr/client.js | 28 +++++++- tests/nostr-login-permissions.test.mjs | 11 ++- tests/nostr/adapters.test.mjs | 20 +++++- .../nip07Adapter-race-condition.test.mjs | 67 +++++++++++++++++++ 5 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 tests/nostr/nip07Adapter-race-condition.test.mjs diff --git a/js/nostr/adapters/nip07Adapter.js b/js/nostr/adapters/nip07Adapter.js index 1e5826e2..dbe0f564 100644 --- a/js/nostr/adapters/nip07Adapter.js +++ b/js/nostr/adapters/nip07Adapter.js @@ -212,7 +212,7 @@ export async function createNip07Adapter(initialExtension) { Object.defineProperty(signer, "capabilities", { get: () => { const ext = getExtension(); - const hasNip04 = ext?.nip04 && typeof ext.nip04 === "object"; + const hasNip04 = !!(ext?.nip04 && typeof ext.nip04 === "object"); const hasNip44 = !!resolveNip44Module(ext); return { sign: typeof ext?.signEvent === "function", diff --git a/js/nostr/client.js b/js/nostr/client.js index fcdb54fb..9c473f41 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -358,7 +358,13 @@ function setActiveSigner(signer) { hydrateExtensionSignerCapabilities(signer); attachNipMethodAliases(signer); - signer.capabilities = resolveSignerCapabilities(signer); + + const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities"); + const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; + + if (!isGetter) { + signer.capabilities = resolveSignerCapabilities(signer); + } const pubkey = typeof signer.pubkey === "string" && signer.pubkey.trim() @@ -376,7 +382,15 @@ function getActiveSigner() { hydrateExtensionSignerCapabilities(signer); attachNipMethodAliases(signer); if (signer && typeof signer === "object") { - signer.capabilities = resolveSignerCapabilities(signer); + const capsDescriptor = Object.getOwnPropertyDescriptor( + signer, + "capabilities", + ); + const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; + + if (!isGetter) { + signer.capabilities = resolveSignerCapabilities(signer); + } } return signer; } @@ -394,7 +408,15 @@ function resolveActiveSigner(pubkey) { hydrateExtensionSignerCapabilities(signer); attachNipMethodAliases(signer); if (signer && typeof signer === "object") { - signer.capabilities = resolveSignerCapabilities(signer); + const capsDescriptor = Object.getOwnPropertyDescriptor( + signer, + "capabilities", + ); + const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; + + if (!isGetter) { + signer.capabilities = resolveSignerCapabilities(signer); + } } return signer; } diff --git a/tests/nostr-login-permissions.test.mjs b/tests/nostr-login-permissions.test.mjs index d7370f25..13516f4a 100644 --- a/tests/nostr-login-permissions.test.mjs +++ b/tests/nostr-login-permissions.test.mjs @@ -76,7 +76,16 @@ function setupLoginEnvironment({ enableImpl, getPublicKey = HEX_PUBKEY } = {}) { windowRef.NostrTools = toolkitStub; global.NostrTools = toolkitStub; - global.__BITVID_CANONICAL_NOSTR_TOOLS__ = toolkitStub; + // Use Object.defineProperty to override potential read-only global from bootstrap + try { + Object.defineProperty(global, "__BITVID_CANONICAL_NOSTR_TOOLS__", { + value: toolkitStub, + writable: true, + configurable: true, + }); + } catch (e) { + global.__BITVID_CANONICAL_NOSTR_TOOLS__ = toolkitStub; + } global.nostrToolsReady = Promise.resolve({ ok: true, value: toolkitStub }); const originalAccessControl = { diff --git a/tests/nostr/adapters.test.mjs b/tests/nostr/adapters.test.mjs index 9eebc119..e111ee77 100644 --- a/tests/nostr/adapters.test.mjs +++ b/tests/nostr/adapters.test.mjs @@ -25,7 +25,15 @@ test("createNsecAdapter wires signing and cipher capabilities", async () => { const canonicalTools = { ...nostrTools }; try { - globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools; + try { + Object.defineProperty(globalThis, "__BITVID_CANONICAL_NOSTR_TOOLS__", { + value: canonicalTools, + writable: true, + configurable: true, + }); + } catch (e) { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools; + } globalThis.NostrTools = canonicalTools; globalThis.nostrToolsReady = Promise.resolve({ ok: true, @@ -55,7 +63,15 @@ test("createNsecAdapter wires signing and cipher capabilities", async () => { if (previousCanonical === undefined) { delete globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__; } else { - globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical; + try { + Object.defineProperty(globalThis, "__BITVID_CANONICAL_NOSTR_TOOLS__", { + value: previousCanonical, + writable: true, + configurable: true, + }); + } catch (e) { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical; + } } if (previousNostrTools === undefined) { diff --git a/tests/nostr/nip07Adapter-race-condition.test.mjs b/tests/nostr/nip07Adapter-race-condition.test.mjs new file mode 100644 index 00000000..a7e076cd --- /dev/null +++ b/tests/nostr/nip07Adapter-race-condition.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert'; +import { test, describe, beforeEach, afterEach } from 'node:test'; +import { createNip07Adapter } from '../../js/nostr/adapters/nip07Adapter.js'; + +describe('Nip07Adapter Race Condition', () => { + let originalWindow; + + beforeEach(() => { + originalWindow = global.window; + global.window = {}; + }); + + afterEach(() => { + global.window = originalWindow; + }); + + test('should detect capabilities dynamically even if injected late', async () => { + // 1. Setup initial window.nostr with NO encryption capabilities + global.window.nostr = { + getPublicKey: async () => 'pubkey', + signEvent: async (e) => e, + // nip04 and nip44 are missing initially + }; + + // 2. Initialize adapter + const adapter = await createNip07Adapter(); + + // 3. Verify initial state: capabilities should be false + assert.strictEqual(adapter.capabilities.nip04, false, 'Should not have nip04 capability initially'); + assert.strictEqual(adapter.capabilities.nip44, false, 'Should not have nip44 capability initially'); + + // 4. Simulate lazy injection of encryption APIs + global.window.nostr.nip04 = { encrypt: () => {}, decrypt: () => {} }; + global.window.nostr.nip44 = { encrypt: () => {}, decrypt: () => {} }; + + // 5. Verify dynamic state: capabilities should now be true + assert.strictEqual(adapter.capabilities.nip04, true, 'Should detect nip04 capability after injection'); + assert.strictEqual(adapter.capabilities.nip44, true, 'Should detect nip44 capability after injection'); + }); + + test('should call the injected method even if added late', async () => { + // 1. Setup initial window.nostr with NO encryption capabilities + global.window.nostr = { + getPublicKey: async () => 'pubkey', + signEvent: async (e) => e, + }; + + const adapter = await createNip07Adapter(); + + // 2. Call nip04Encrypt - should fail or throw because it's missing + await assert.rejects(async () => { + await adapter.nip04Encrypt('pubkey', 'message'); + }, /NIP-04/); // Expect some error related to missing NIP-04 + + // 3. Inject + let called = false; + global.window.nostr.nip04 = { + encrypt: async () => { called = true; return 'encrypted'; }, + decrypt: async () => {} + }; + + // 4. Call again - should succeed + const result = await adapter.nip04Encrypt('pubkey', 'message'); + assert.strictEqual(result, 'encrypted'); + assert.strictEqual(called, true); + }); +}); From 14dfedc98d592cc9274f866906789b8eae6e23c5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:04:55 +0000 Subject: [PATCH 3/3] Fix NIP-07 adapter race condition and CI test crashes - Modified `js/nostr/adapters/nip07Adapter.js` to use dynamic getters for capabilities. - Updated `js/nostr/client.js` to respect getter descriptors on signers. - Fixed `tests/nostr-login-permissions.test.mjs` and `tests/nostr/adapters.test.mjs` to safely handle read-only global properties in CI. - Added hardened regression test `tests/nostr/nip07Adapter-race-condition.test.mjs`. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- tests/nostr-login-permissions.test.mjs | 6 +++++- tests/nostr/adapters.test.mjs | 8 ++++++-- tests/nostr/nip07Adapter-race-condition.test.mjs | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/nostr-login-permissions.test.mjs b/tests/nostr-login-permissions.test.mjs index 13516f4a..576facb2 100644 --- a/tests/nostr-login-permissions.test.mjs +++ b/tests/nostr-login-permissions.test.mjs @@ -84,7 +84,11 @@ function setupLoginEnvironment({ enableImpl, getPublicKey = HEX_PUBKEY } = {}) { configurable: true, }); } catch (e) { - global.__BITVID_CANONICAL_NOSTR_TOOLS__ = toolkitStub; + try { + global.__BITVID_CANONICAL_NOSTR_TOOLS__ = toolkitStub; + } catch (ignore) { + // Ignore assignment errors if property is read-only + } } global.nostrToolsReady = Promise.resolve({ ok: true, value: toolkitStub }); diff --git a/tests/nostr/adapters.test.mjs b/tests/nostr/adapters.test.mjs index e111ee77..e101ff72 100644 --- a/tests/nostr/adapters.test.mjs +++ b/tests/nostr/adapters.test.mjs @@ -32,7 +32,9 @@ test("createNsecAdapter wires signing and cipher capabilities", async () => { configurable: true, }); } catch (e) { - globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools; + try { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools; + } catch (ignore) {} } globalThis.NostrTools = canonicalTools; globalThis.nostrToolsReady = Promise.resolve({ @@ -70,7 +72,9 @@ test("createNsecAdapter wires signing and cipher capabilities", async () => { configurable: true, }); } catch (e) { - globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical; + try { + globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonical; + } catch (ignore) {} } } diff --git a/tests/nostr/nip07Adapter-race-condition.test.mjs b/tests/nostr/nip07Adapter-race-condition.test.mjs index a7e076cd..8534d6e5 100644 --- a/tests/nostr/nip07Adapter-race-condition.test.mjs +++ b/tests/nostr/nip07Adapter-race-condition.test.mjs @@ -1,3 +1,4 @@ +import '../test-helpers/setup-localstorage.mjs'; import assert from 'node:assert'; import { test, describe, beforeEach, afterEach } from 'node:test'; import { createNip07Adapter } from '../../js/nostr/adapters/nip07Adapter.js';