From 0031d68b670b4ff4430333db9575632b33c2a0a7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 20 Jun 2026 07:39:05 -0400 Subject: [PATCH] fix(signer): register NIP-07 adapter on refresh restore (storage unlock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh login, loginWithExtension builds + registers the NIP-07 signer adapter. On a page REFRESH that path never runs — the app restores the logged-in pubkey/UI, but ensureActiveSignerForPubkey only RESOLVED from the signer registry; it never CONSTRUCTED an adapter from window.nostr. So the registry stayed empty and ensureActiveSignerForPubkey returned null forever. Decrypt paths survived via their own per-call window.nostr fallbacks, but features relying solely on the registry — profile Storage "Unlock" — failed with "No active signer found." despite a healthy, instantly-responsive extension. (Diagnosed via temporary instrumentation: getActiveSigner_truthy false AND ensureResult_truthy false, while extension getPublicKey responded in 1ms.) Fix: when a live extension's own pubkey matches the requested pubkey and no signer is registered, build + register the adapter (mirroring loginWithExtension), with a guard that never adopts a mismatched account. New tests in ensure-active-signer-refresh.test.mjs (own file for process-level isolation — the registry/permission state are module singletons): constructs + registers a usable signer from a live extension on an empty registry; rejects a mismatched account. Mutation-verified. Signer suite 26/26, full unit suite + build green. Co-Authored-By: Claude Opus 4.8 --- js/nostr/managers/SignerManager.js | 27 ++++++ tests/ensure-active-signer-refresh.test.mjs | 92 +++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 tests/ensure-active-signer-refresh.test.mjs 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; + } +});