From 95f47c761c7d64f0c78bf9e123ff743d9b849da4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 13:40:46 +0000 Subject: [PATCH 1/2] Fix moderation lists error: import and instantiate RelayBatchFetcher The NostrClient.fetchListIncrementally() method delegates to this.relayBatchFetcher, but RelayBatchFetcher was never imported or instantiated in client.js, causing "Cannot read properties of undefined (reading 'fetchListIncrementally')" on every page load when admin lists try to load. https://claude.ai/code/session_01KuHrohbGvitA41A2fPD5p5 --- js/nostr/client.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/nostr/client.js b/js/nostr/client.js index 1084c6ac..bdddc40c 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -42,6 +42,7 @@ import { normalizeStoragePointer, resolveStoragePointerValue, } from "../utils/storagePointer.js"; +import { RelayBatchFetcher } from "./relayBatchFetcher.js"; import { createWatchHistoryManager, normalizePointerInput, @@ -580,6 +581,7 @@ export class NostrClient { constructor() { this.connectionManager = new ConnectionManager(this); this.signerManager = new SignerManager(this); + this.relayBatchFetcher = new RelayBatchFetcher(this); /** * @type {Map} From b5df7517cd48b00260fddf7434485611e2f4b46f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 15:33:21 +0000 Subject: [PATCH 2/2] Fix CI test failures: signer wiring, permission checks, and missing methods - Export resolveSignerCapabilities, hydrateExtensionSignerCapabilities, and attachNipMethodAliases from SignerManager so they can be used by the module-level setActiveSigner in client.js - Fix ensureExtensionPermissions: pass the extension object to requestEnablePermissions (was passing methods array as extension), check response.ok instead of response.enabled - Hydrate extensionPermissionCache from localStorage on construction so fresh NostrClient instances recognize previously granted permissions - Cache individual permission method names (not just composite keys) so tests and code checking specific methods work correctly - Guard extension.signEvent.bind() against missing signEvent - Add extensionPermissionCache getter to NostrClient - Add derivePrivateKeyFromSecret and registerPrivateKeySigner methods to SignerManager and NostrClient (needed by nsec auth provider) - Add installNip46Client method for NIP-46 test infrastructure - Apply signer hydration in module-level setActiveSigner - Proxy ensureExtensionPermissions through NostrClient in loginWithExtension to support test monkey-patching - Fix nostr-signer-race test to use Map.set() instead of Set.add() Fixes 5 previously-failing CI tests: - tests/nostr/signers.test.mjs - tests/nostr/dm-direct-message-flow.test.mjs - tests/nostr/dmSignalEvents.test.mjs - tests/nostr-login-permissions.test.mjs - tests/nostr-signer-race.test.mjs https://claude.ai/code/session_01KuHrohbGvitA41A2fPD5p5 --- js/nostr/client.js | 48 +++++++++- js/nostr/managers/SignerManager.js | 147 +++++++++++++++++++++++++++-- tests/nostr-signer-race.test.mjs | 2 +- 3 files changed, 188 insertions(+), 9 deletions(-) diff --git a/js/nostr/client.js b/js/nostr/client.js index bdddc40c..859ebd08 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -215,7 +215,12 @@ import { queueSignEvent } from "./signRequestQueue.js"; import { EventsMap } from "./eventsMap.js"; import { PersistenceManager } from "./managers/PersistenceManager.js"; import { ConnectionManager } from "./managers/ConnectionManager.js"; -import { SignerManager } from "./managers/SignerManager.js"; +import { + SignerManager, + resolveSignerCapabilities, + hydrateExtensionSignerCapabilities, + attachNipMethodAliases, +} from "./managers/SignerManager.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -235,6 +240,15 @@ function resolveActiveSigner(pubkey) { } function setActiveSigner(signer) { + if (signer && typeof signer === "object") { + hydrateExtensionSignerCapabilities(signer); + attachNipMethodAliases(signer); + const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities"); + const isGetter = capsDescriptor && typeof capsDescriptor.get === "function"; + if (!isGetter) { + signer.capabilities = resolveSignerCapabilities(signer); + } + } setActiveSignerInRegistry(signer); } @@ -754,6 +768,8 @@ export class NostrClient { get sessionActorCipherClosuresPrivateKey() { return this.signerManager.sessionActorCipherClosuresPrivateKey; } set sessionActorCipherClosuresPrivateKey(val) { this.signerManager.sessionActorCipherClosuresPrivateKey = val; } + get extensionPermissionCache() { return this.signerManager.extensionPermissionCache; } + get pool() { return this.connectionManager.pool; } set pool(val) { this.connectionManager.pool = val; } @@ -2914,9 +2930,39 @@ export class NostrClient { } async loginWithExtension(options) { + // Check if ensureExtensionPermissions was overridden on this instance (e.g. by tests). + // Only proxy when the own property differs from the prototype method to avoid recursion. + const ownDesc = Object.getOwnPropertyDescriptor(this, "ensureExtensionPermissions"); + const isOverridden = + ownDesc && + typeof ownDesc.value === "function" && + ownDesc.value !== NostrClient.prototype.ensureExtensionPermissions; + + if (isOverridden) { + const sm = this.signerManager; + const smOriginal = sm.ensureExtensionPermissions.bind(sm); + sm.ensureExtensionPermissions = (...args) => ownDesc.value.call(this, ...args); + try { + return await sm.loginWithExtension(options); + } finally { + sm.ensureExtensionPermissions = smOriginal; + } + } return this.signerManager.loginWithExtension(options); } + async derivePrivateKeyFromSecret(secret) { + return this.signerManager.derivePrivateKeyFromSecret(secret); + } + + async registerPrivateKeySigner(params) { + return this.signerManager.registerPrivateKeySigner(params); + } + + installNip46Client(rpcClient, options) { + return this.signerManager.installNip46Client(rpcClient, options); + } + async connectRemoteSigner(params) { return this.signerManager.connectRemoteSigner(params); } diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index e794c862..2f1cd79e 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -48,12 +48,13 @@ import { } from "../nip46LoggingUtils.js"; import { devLogger, userLogger } from "../../utils/logger.js"; +import { HEX64_REGEX } from "../../utils/hex.js"; import { createPrivateKeyCipherClosures } from "../signerHelpers.js"; import { queueSignEvent } from "../signRequestQueue.js"; import { signEventWithPrivateKey } from "../publishHelpers.js"; import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js"; -function resolveSignerCapabilities(signer) { +export function resolveSignerCapabilities(signer) { const fallback = { sign: false, nip44: false, @@ -84,7 +85,7 @@ function resolveSignerCapabilities(signer) { }; } -function hydrateExtensionSignerCapabilities(signer) { +export function hydrateExtensionSignerCapabilities(signer) { if (!signer || typeof signer !== "object") { return; } @@ -115,7 +116,7 @@ function hydrateExtensionSignerCapabilities(signer) { } } -function attachNipMethodAliases(signer) { +export function attachNipMethodAliases(signer) { if (!signer || typeof signer !== "object") { return; } @@ -263,6 +264,21 @@ export class SignerManager { this.extensionReady = false; this.extensionPermissionsGranted = false; this.extensionPermissionCache = new Map(); + + // Hydrate permission cache from localStorage so fresh instances + // recognise previously granted permissions without re-prompting. + try { + const storedPermissions = readStoredNip07Permissions(); + if (storedPermissions.size > 0) { + for (const method of storedPermissions) { + this.extensionPermissionCache.set(method, true); + } + this.extensionPermissionsGranted = true; + } + } catch (_) { + // Storage unavailable or corrupt — start with empty cache + } + this.sessionActorCipherClosures = null; this.sessionActorCipherClosuresPrivateKey = null; this.remoteSignerListeners = new Set(); @@ -423,11 +439,15 @@ export class SignerManager { if (missing.length === 0) { this.extensionPermissionsGranted = true; this.extensionPermissionCache.set(cacheKey, true); + for (const method of methods) { + this.extensionPermissionCache.set(method, true); + } return { ok: true }; } + let extension; try { - await waitForNip07Extension(); + extension = await waitForNip07Extension(); } catch (error) { return { ok: false, error: "extension-missing" }; } @@ -435,11 +455,14 @@ export class SignerManager { const message = resolvePermissionStatusMessage(missing, context); try { - const response = await requestEnablePermissions(missing); - if (response?.enabled) { + const response = await requestEnablePermissions(extension, missing); + if (response?.ok) { writeStoredNip07Permissions(missing); this.extensionPermissionsGranted = true; this.extensionPermissionCache.set(cacheKey, true); + for (const method of methods) { + this.extensionPermissionCache.set(method, true); + } return { ok: true }; } return { ok: false, error: "permission-denied" }; @@ -538,7 +561,9 @@ export class SignerManager { const adapter = { type: "extension", pubkey: normalized, - signEvent: extension.signEvent.bind(extension), + signEvent: typeof extension.signEvent === "function" + ? extension.signEvent.bind(extension) + : undefined, nip04: extension.nip04, nip44: extension.nip44, }; @@ -927,4 +952,112 @@ export class SignerManager { this.extensionPermissionsGranted = false; this.emitRemoteSignerChange(); } + + installNip46Client(rpcClient, { userPubkey } = {}) { + this.nip46Client = rpcClient; + if (userPubkey) { + this.pubkey = userPubkey; + } + if (rpcClient && typeof rpcClient.getActiveSigner === "function") { + const signer = rpcClient.getActiveSigner(); + if (signer) { + this.setActiveSigner(signer); + } + } + this.emitRemoteSignerChange(); + } + + async derivePrivateKeyFromSecret(secret) { + if (!secret || typeof secret !== "string") { + throw new Error("A private key or nsec string is required."); + } + + const trimmed = secret.trim(); + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + + // Handle hex private key + if (HEX64_REGEX.test(trimmed)) { + const privateKeyHex = trimmed.toLowerCase(); + if (!tools || typeof tools.getPublicKey !== "function") { + throw new Error("Nostr tools unavailable for key derivation."); + } + const pubkey = tools.getPublicKey(privateKeyHex); + return { privateKey: privateKeyHex, pubkey }; + } + + // Handle nsec bech32 encoding + if (trimmed.startsWith("nsec1")) { + const nip19 = tools?.nip19; + if (!nip19 || typeof nip19.decode !== "function") { + throw new Error("Nostr tools unavailable for nsec decoding."); + } + const decoded = nip19.decode(trimmed); + if (!decoded || decoded.type !== "nsec" || !decoded.data) { + throw new Error("Invalid nsec key."); + } + const secretBytes = decoded.data; + const privateKeyHex = + typeof secretBytes === "string" + ? secretBytes + : Array.from(secretBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + if (!HEX64_REGEX.test(privateKeyHex)) { + throw new Error("Decoded nsec key is invalid."); + } + const pubkey = tools.getPublicKey(privateKeyHex); + return { privateKey: privateKeyHex, pubkey }; + } + + throw new Error("Unrecognized key format. Provide a hex private key or nsec string."); + } + + async registerPrivateKeySigner({ privateKey, pubkey, persist, passphrase, validator } = {}) { + if (!privateKey || typeof privateKey !== "string" || !HEX64_REGEX.test(privateKey)) { + throw new Error("A valid hex private key is required."); + } + + const normalizedPrivateKey = privateKey.toLowerCase(); + const normalizedPubkey = + typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim().toLowerCase() + : null; + + if (!normalizedPubkey || !HEX64_REGEX.test(normalizedPubkey)) { + throw new Error("A valid hex public key is required."); + } + + if (typeof validator === "function") { + validator(normalizedPubkey); + } + + const cipherClosures = await createPrivateKeyCipherClosures(normalizedPrivateKey); + + const adapter = { + type: "nsec", + pubkey: normalizedPubkey, + signEvent: (event) => signEventWithPrivateKey(event, normalizedPrivateKey), + ...cipherClosures, + }; + + this.setActiveSigner(adapter); + this.pubkey = normalizedPubkey; + + if (persist && passphrase) { + try { + const encrypted = await encryptSessionPrivateKey(normalizedPrivateKey, passphrase); + persistSessionActorEntry({ + pubkey: normalizedPubkey, + encrypted, + source: "nsec", + persisted: true, + }); + this.sessionActor = { pubkey: normalizedPubkey, source: "nsec", persisted: true }; + } catch (error) { + devLogger.warn("[nostr] Failed to persist private key signer:", error); + } + } + + return normalizedPubkey; + } } diff --git a/tests/nostr-signer-race.test.mjs b/tests/nostr-signer-race.test.mjs index 3cb34217..a43a07eb 100644 --- a/tests/nostr-signer-race.test.mjs +++ b/tests/nostr-signer-race.test.mjs @@ -15,7 +15,7 @@ test("ensureActiveSignerForPubkey prefers concurrent login over late extension i const client = new NostrClient(); client.pubkey = HEX_PUBKEY; // Pre-condition: Cache permissions to trigger the waitForNip07Extension logic - client.extensionPermissionCache.add("sign_event"); + client.extensionPermissionCache.set("sign_event", true); // Mock a NIP-46 signer that might be set during the race const mockNip46Signer = {