fix(signer): register NIP-07 adapter on refresh restore (storage unlock)

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 07:39:05 -04:00
1 parent b0526d5408
commit 0031d68b67
2 files changed
+119

No files matched your search

+27
View File
@@ -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;
}
@@ -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;
}
});