mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
b0526d5408
commit
0031d68b67
2 files changed
+119
No files matched your search
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user