fix(nostr): prevent nip-07 fallback from overwriting concurrent login

- Added a check in `ensureActiveSignerForPubkey` to verify if a valid signer (e.g. NIP-46) was registered during the extension wait period.
- Modified `nip07Permissions.js` to respect global timeout overrides for structured permission requests, fixing flaky tests.
- Added regression test `tests/nostr-signer-race.test.mjs` to simulate the race condition.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-01-26 21:19:47 +00:00
1 parent 0c479319e6
commit 7cb603b652
3 files changed
+72 -1

No files matched your search

+6
View File
@@ -2642,6 +2642,12 @@ export class NostrClient {
}
}
// Check if a signer appeared while we were waiting (e.g. NIP-46 or concurrent NIP-07 logic)
const recheckedSigner = resolveActiveSigner(normalizedPubkey);
if (recheckedSigner && typeof recheckedSigner.signEvent === "function") {
return recheckedSigner;
}
if (!extension) {
return existingSigner;
}
+1 -1
View File
@@ -275,7 +275,7 @@ export async function requestEnablePermissions(
// If we fall back to standard enable() (null), allow the full user interaction time.
const variantTimeoutOverrides = options
? {
timeoutMs: 3000,
timeoutMs: Math.min(3000, getEnableVariantTimeoutMs()),
retryMultiplier: 1,
}
: {
+65
View File
@@ -0,0 +1,65 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { NostrClient, setActiveSigner, getActiveSigner } from "../js/nostr/client.js";
const HEX_PUBKEY = "a".repeat(64);
test("ensureActiveSignerForPubkey prefers concurrent login over late extension injection", async () => {
// 1. Setup Environment
const originalWindow = global.window;
global.window = {};
try {
const client = new NostrClient();
client.pubkey = HEX_PUBKEY;
// Pre-condition: Cache permissions to trigger the waitForNip07Extension logic
client.extensionPermissionCache.add("sign_event");
// Mock a NIP-46 signer that might be set during the race
const mockNip46Signer = {
type: "nip46",
pubkey: HEX_PUBKEY,
signEvent: async () => ({ id: "mock-signed" }),
nip04: { encrypt: async () => "enc", decrypt: async () => "dec" }
};
// Mock the NIP-07 extension that appears late
const mockExtension = {
getPublicKey: async () => HEX_PUBKEY,
signEvent: async () => ({ id: "ext-signed" }),
nip04: { encrypt: async () => "ext-enc", decrypt: async () => "ext-dec" }
};
// 2. Start the race
// Calling ensureActiveSignerForPubkey while window.nostr is missing but permissions exist
// causes it to enter waitForNip07Extension loop (polling).
const ensurePromise = client.ensureActiveSignerForPubkey(HEX_PUBKEY);
// 3. Simulate Concurrent Login (e.g. NIP-46 restore completes)
// We wait a small amount to ensure ensureActiveSignerForPubkey has started polling
await new Promise(resolve => setTimeout(resolve, 50));
setActiveSigner(mockNip46Signer);
// 4. Simulate Extension Loading late
global.window.nostr = mockExtension;
// 5. Await result
const resultSigner = await ensurePromise;
// 6. Assertions
const finalActiveSigner = getActiveSigner();
// We expect the NIP-46 signer to be preserved and not overwritten by the NIP-07 fallback
assert.equal(finalActiveSigner.type, "nip46", "Race condition! NIP-46 signer was overwritten by NIP-07 fallback.");
assert.equal(resultSigner.type, "nip46", "Returned signer should be the NIP-46 one.");
} finally {
if (originalWindow) {
global.window = originalWindow;
} else {
delete global.window;
}
}
});