diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index 30143156..41ef92b6 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -255,6 +255,33 @@ export class SignerManager { return raceWinner; } + // The registry has no signer yet (e.g. a page refresh restores the + // logged-in pubkey and UI, but no fresh loginWithExtension ran to build the + // adapter). Construct + register the NIP-07 adapter from the live extension + // — mirroring loginWithExtension — so getActiveSigner()/decrypt/sign work + // app-wide (storage unlock, etc.) instead of relying on per-call window.nostr + // fallbacks. Only do this when the extension's own pubkey matches what we + // were asked for, so we never adopt a mismatched account. + const resolvedPubkey = extensionPubkey || normalizedPubkey; + if ( + resolvedPubkey && + (!normalizedPubkey || resolvedPubkey === normalizedPubkey) && + typeof extension.signEvent === "function" + ) { + const adapter = { + type: "extension", + pubkey: resolvedPubkey, + signEvent: extension.signEvent.bind(extension), + nip04: extension.nip04, + nip44: extension.nip44, + }; + this.pubkey = resolvedPubkey; + this.setActiveSigner(adapter); + // Return through resolveActiveSigner so capability aliases + // (nip04Decrypt/nip44Decrypt) are hydrated onto the adapter. + return this.resolveActiveSigner(resolvedPubkey) || adapter; + } + return null; } diff --git a/tests/ensure-active-signer-refresh.test.mjs b/tests/ensure-active-signer-refresh.test.mjs new file mode 100644 index 00000000..8980c02d --- /dev/null +++ b/tests/ensure-active-signer-refresh.test.mjs @@ -0,0 +1,92 @@ +// Regression: on a page refresh the logged-in pubkey/UI is restored but no +// fresh loginWithExtension runs, so the signer registry starts empty. +// ensureActiveSignerForPubkey only RESOLVED from the registry — it never +// CONSTRUCTED a NIP-07 adapter from window.nostr — so it returned null forever. +// Decrypt paths limped along via per-call window.nostr fallbacks, but features +// that rely solely on the registry (e.g. profile Storage "Unlock") failed with +// "No active signer found." despite a healthy, responsive extension. +// +// Fix: when a live extension's own pubkey matches the requested pubkey and no +// signer is registered, build + register the adapter (mirroring +// loginWithExtension) so getActiveSigner()/sign/decrypt work app-wide. +// +// In its own file for process-level isolation: the signer registry and NIP-07 +// permission/circuit-breaker state are module singletons, so sharing a file +// with other signer tests bleeds state. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + NostrClient, + getActiveSigner, + clearActiveSigner, +} from "../js/nostr/client.js"; + +const PK = "a".repeat(64); + +function makeExtension(getPubkey) { + return { + getPublicKey: async () => getPubkey, + signEvent: async () => ({ id: "ext-signed" }), + nip04: { encrypt: async () => "enc04", decrypt: async () => "dec04" }, + nip44: { encrypt: async () => "enc44", decrypt: async () => "dec44" }, + }; +} + +test("constructs + registers a usable signer from the live extension when the registry is empty", async () => { + const originalWindow = global.window; + global.window = { nostr: makeExtension(PK) }; + try { + clearActiveSigner(); + const client = new NostrClient(); + client.pubkey = PK; + assert.equal(getActiveSigner(), null, "precondition: empty registry (post-refresh)"); + + const resolved = await client.ensureActiveSignerForPubkey(PK); + + assert.ok(resolved, "must resolve a signer instead of returning null"); + assert.equal(typeof resolved.signEvent, "function", "resolved signer can sign"); + assert.equal( + typeof resolved.nip44Decrypt, + "function", + "resolved signer exposes a hydrated nip44 decrypt alias", + ); + assert.equal( + typeof resolved.nip04Decrypt, + "function", + "resolved signer exposes a hydrated nip04 decrypt alias", + ); + + // The whole point: it is now the registered active signer app-wide. + const active = getActiveSigner(); + assert.ok(active, "getActiveSigner() now returns the registered signer"); + assert.equal(typeof active.nip44Decrypt, "function", "active signer can decrypt"); + } finally { + if (originalWindow) global.window = originalWindow; + else delete global.window; + } +}); + +test("does NOT adopt a mismatched extension account", async () => { + const originalWindow = global.window; + // Distinct, never-registered pubkeys — the registry's registeredSigners map is + // a module singleton that clearActiveSigner() does not purge, so reusing the + // previous test's pubkey would resolve its leftover entry. + const WANT = "c".repeat(64); + const OTHER = "b".repeat(64); + global.window = { nostr: makeExtension(OTHER) }; // extension is a different account + try { + clearActiveSigner(); + const client = new NostrClient(); + client.pubkey = WANT; + + const resolved = await client.ensureActiveSignerForPubkey(WANT); + + assert.equal(resolved, null, "must not adopt a mismatched account"); + assert.equal(getActiveSigner(), null, "registry stays empty on mismatch"); + } finally { + if (originalWindow) global.window = originalWindow; + else delete global.window; + } +});