diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index 137baea0..c8b1a429 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -549,7 +549,12 @@ export class SignerManager { throw new Error("Please install a Nostr extension (Alby, nos2x, etc.)."); } - const { allowAccountSelection = false, expectPubkey } = + const { + allowAccountSelection = false, + expectPubkey, + getPublicKeyTimeoutMs, + getPublicKeyRetryMultiplier, + } = typeof options === "object" && options !== null ? options : {}; const normalizedExpectedPubkey = typeof expectPubkey === "string" && expectPubkey.trim() @@ -576,7 +581,14 @@ export class SignerManager { throw denialError; } - const pubkey = await extension.getPublicKey(); + const pubkey = await runNip07WithRetry( + () => extension.getPublicKey(), + { + label: "extension.getPublicKey", + timeoutMs: getPublicKeyTimeoutMs, + retryMultiplier: getPublicKeyRetryMultiplier, + }, + ); if (!pubkey) { throw new Error("Extension did not return a public key."); } diff --git a/js/nostr/nip07Permissions.js b/js/nostr/nip07Permissions.js index 1f4e1e6a..1864fec5 100644 --- a/js/nostr/nip07Permissions.js +++ b/js/nostr/nip07Permissions.js @@ -318,9 +318,8 @@ export async function requestEnablePermissions( return { ok: false, error: new Error("extension-unavailable") }; } - if (typeof extension.enable !== "function") { - return { ok: true, code: "enable-unavailable" }; - } + const hasRequestPermissions = typeof extension.requestPermissions === "function"; + const hasEnable = typeof extension.enable === "function"; const normalized = Array.isArray(outstandingMethods) ? outstandingMethods @@ -328,16 +327,70 @@ export async function requestEnablePermissions( .filter(Boolean) : []; - const permissionVariants = []; - if (normalized.length) { - permissionVariants.push({ - permissions: normalized.map((method) => ({ method })), - }); - permissionVariants.push({ permissions: normalized }); + const permissionVariants = (() => { + const variants = []; + if (normalized.length) { + variants.push({ + permissions: normalized.map((method) => ({ method })), + }); + variants.push({ permissions: normalized }); + variants.push(normalized); + } + variants.push(null); + return variants; + })(); + + if (!hasRequestPermissions && !hasEnable) { + return { ok: true, code: "enable-unavailable" }; } - permissionVariants.push(null); let lastError = null; + if (hasRequestPermissions) { + for (const options of permissionVariants) { + const variantTimeoutOverrides = options + ? { + timeoutMs: Math.min(3000, getEnableVariantTimeoutMs()), + retryMultiplier: 1, + } + : { + timeoutMs: Math.min( + NIP07_LOGIN_TIMEOUT_MS, + getEnableVariantTimeoutMs(), + ), + retryMultiplier: 1, + }; + + try { + await runNip07WithRetry( + () => + options === null + ? extension.requestPermissions() + : extension.requestPermissions(options), + { + label: "extension.requestPermissions", + ...variantTimeoutOverrides, + }, + ); + return { ok: true }; + } catch (error) { + lastError = error; + if (options && isDevMode) { + userLogger.warn( + "[nostr] extension.requestPermissions call failed:", + error, + ); + } + } + } + } + + if (!hasEnable) { + return { + ok: false, + error: lastError || new Error("permission-denied"), + }; + } + for (const options of permissionVariants) { // If specific permissions are requested (variants 1 & 2), fail fast (3s) // to avoid hanging if the extension doesn't support the structured format. diff --git a/tests/nostr-login-permissions.test.mjs b/tests/nostr-login-permissions.test.mjs index 22f4b5a9..a5c37db4 100644 --- a/tests/nostr-login-permissions.test.mjs +++ b/tests/nostr-login-permissions.test.mjs @@ -392,8 +392,14 @@ describe("NIP-07 Login Permissions", () => { const [objectCall, stringCall, plainCall] = env.enableCalls; assert.ok(objectCall !== undefined); assert.ok(stringCall !== undefined); - // plainCall is the one that succeeded (called with undefined/no args) - assert.equal(plainCall, undefined, "successful attempt should use plain enable()"); + // Success may come from requestPermissions() (array payload) or fallback enable(). + assert.ok( + plainCall === undefined || + (Array.isArray(plainCall) && + plainCall.includes("get_public_key") && + plainCall.includes("sign_event")), + "successful attempt should use a plain permission payload or plain enable()", + ); } finally { env.restore(); nostrClient.logout(); @@ -455,6 +461,37 @@ describe("NIP-07 Login Permissions", () => { } }); + it("NIP-07 login times out instead of hanging when getPublicKey stalls", async () => { + clearStoredPermissions(); + const env = setupLoginEnvironment({ + getPublicKey: new Promise(() => {}), + }); + + try { + const start = Date.now(); + await assert.rejects( + () => + nip07Provider.login({ + nostrClient, + options: { + getPublicKeyTimeoutMs: 80, + getPublicKeyRetryMultiplier: 1, + }, + }), + /Timed out waiting for the NIP-07 extension/, + ); + const duration = Date.now() - start; + assert.ok( + duration < 1000, + `getPublicKey timeout should fail quickly (duration: ${duration}ms)`, + ); + } finally { + env.restore(); + nostrClient.logout(); + clearStoredPermissions(); + } + }); + it("NIP-07 login does not wait for deferred permission grants", async () => { clearStoredPermissions(); const env = setupLoginEnvironment(); diff --git a/tests/nostr/nip07Permissions.test.js b/tests/nostr/nip07Permissions.test.js index 2558505c..2d7903cf 100644 --- a/tests/nostr/nip07Permissions.test.js +++ b/tests/nostr/nip07Permissions.test.js @@ -64,11 +64,47 @@ test("requestEnablePermissions retries explicit and fallback variants", async () assert.deepStrictEqual(calls, [ { permissions: [{ method: "sign_event" }] }, { permissions: ["sign_event"] }, - undefined, + ["sign_event"], ]); assert.deepStrictEqual(result, { ok: true }); }); +test("requestEnablePermissions supports requestPermissions-only extensions", async () => { + clearStoredNip07Permissions(); + const calls = []; + const extension = { + requestPermissions: (options) => { + calls.push(options); + return Promise.resolve({ granted: true }); + }, + }; + + const result = await requestEnablePermissions( + extension, + ["sign_event"], + { isDevMode: false }, + ); + + assert.deepStrictEqual(result, { ok: true }); + assert.deepStrictEqual(calls, [{ permissions: [{ method: "sign_event" }] }]); +}); + +test("requestEnablePermissions fails when requestPermissions exists but denies", async () => { + clearStoredNip07Permissions(); + const extension = { + requestPermissions: () => Promise.reject(new Error("permission denied")), + }; + + const result = await requestEnablePermissions( + extension, + ["sign_event"], + { isDevMode: false }, + ); + + assert.strictEqual(result.ok, false); + assert.ok(result.error instanceof Error); +}); + test("requestEnablePermissions reports unavailable extension", async () => { clearStoredNip07Permissions(); const result = await requestEnablePermissions(null, ["sign_event"]);