diff --git a/js/nostr.js b/js/nostr.js index f9e92491..f93ba777 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -72,6 +72,15 @@ const WATCH_HISTORY_REPUBLISH_MAX_DELAY_MS = 5 * 60 * 1000; const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8; const WATCH_HISTORY_REPUBLISH_JITTER = 0.25; +const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ + "get_public_key", + "sign_event", + "nip04.encrypt", + "nip04.decrypt", + "read_relays", + "write_relays", +]); + const viewEventPublishMemory = new Map(); const rebroadcastAttemptMemory = new Map(); @@ -2886,6 +2895,99 @@ export class NostrClient { this.watchHistoryLastCreatedAt = 0; this.countRequestCounter = 0; this.countUnsupportedRelays = new Set(); + this.extensionPermissionCache = new Set(); + } + + markExtensionPermissions(methods = []) { + if (!this.extensionPermissionCache) { + this.extensionPermissionCache = new Set(); + } + + if (!Array.isArray(methods)) { + return; + } + + for (const method of methods) { + if (typeof method !== "string") { + continue; + } + const normalized = method.trim(); + if (normalized) { + this.extensionPermissionCache.add(normalized); + } + } + } + + async ensureExtensionPermissions(methods = DEFAULT_NIP07_PERMISSION_METHODS) { + if (!Array.isArray(methods)) { + methods = []; + } + + const normalized = Array.from( + new Set( + methods + .map((method) => + typeof method === "string" && method.trim() ? method.trim() : "", + ) + .filter(Boolean), + ), + ); + + if (!normalized.length) { + return { ok: true }; + } + + const outstanding = normalized.filter((method) => + this.extensionPermissionCache ? !this.extensionPermissionCache.has(method) : true, + ); + + if (!outstanding.length) { + return { ok: true }; + } + + const extension = typeof window !== "undefined" ? window.nostr : null; + if (!extension) { + return { ok: false, error: new Error("extension-unavailable") }; + } + + if (typeof extension.enable !== "function") { + this.markExtensionPermissions(outstanding); + return { ok: true, code: "enable-unavailable" }; + } + + const permissionVariants = []; + if (outstanding.length) { + permissionVariants.push({ + permissions: outstanding.map((method) => ({ method })), + }); + permissionVariants.push({ permissions: outstanding }); + } + permissionVariants.push(null); + + let lastError = null; + for (const options of permissionVariants) { + try { + await runNip07WithRetry( + () => (options ? extension.enable(options) : extension.enable()), + { label: "extension.enable" }, + ); + this.markExtensionPermissions(outstanding); + return { ok: true }; + } catch (error) { + lastError = error; + if (options && isDevMode) { + console.warn( + "[nostr] extension.enable request with explicit permissions failed:", + error, + ); + } + } + } + + return { + ok: false, + error: lastError || new Error("permission-denied"), + }; } applyRootCreatedAt(video) { @@ -3813,6 +3915,10 @@ export class NostrClient { } } + if (useExtensionEncrypt) { + await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); + } + const canonicalItems = canonicalizeWatchHistoryItems( Array.isArray(rawItems) ? rawItems : [], WATCH_HISTORY_MAX_ITEMS, @@ -4590,6 +4696,7 @@ export class NostrClient { typeof extension?.nip04?.decrypt === "function" && normalizeActorKey(this.pubkey) === actorKey; if (extensionDecrypt) { + await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS); return extension.nip04.decrypt(resolvedActor, ciphertext); } if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) { @@ -5471,14 +5578,9 @@ export class NostrClient { if (isDevMode) { console.log("Requesting permissions from NIP-07 extension..."); } - const requestedPermissionMethods = [ - "get_public_key", - "sign_event", - "nip04.encrypt", - "nip04.decrypt", - "read_relays", - "write_relays", - ]; + const requestedPermissionMethods = Array.from( + DEFAULT_NIP07_PERMISSION_METHODS, + ); const permissionVariants = []; const objectPermissions = requestedPermissionMethods @@ -5529,6 +5631,8 @@ export class NostrClient { : "The NIP-07 extension denied the permission request.", ); } + + this.markExtensionPermissions(requestedPermissionMethods); } if (allowAccountSelection && typeof extension.selectAccounts === "function") { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index b913154e..cbbdf040 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -7,6 +7,8 @@ const { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_MAX_ITEMS, WATCH_HISTORY_CACHE_TTL_MS, + WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_BATCH_PAGE_SIZE, } = await import("../js/config.js"); const { @@ -58,6 +60,14 @@ const originalScheduleWatchHistoryRepublish = const originalResolveWatchHistory = nostrClient.resolveWatchHistory; const originalGetWatchHistoryFingerprint = nostrClient.getWatchHistoryFingerprint; +const originalExtensionPermissionCache = nostrClient.extensionPermissionCache; +const originalExtensionPermissionSnapshot = Array.isArray( + originalExtensionPermissionCache, +) + ? Array.from(originalExtensionPermissionCache) + : []; + +nostrClient.extensionPermissionCache = new Set(); nostrClient.watchHistoryCache = new Map(); nostrClient.watchHistoryStorage = { @@ -255,7 +265,12 @@ function installExtensionCrypto({ actor }) { let extensionEncrypts = 0; let extensionDecrypts = 0; let fallbackEncrypts = 0; + const enableCalls = []; window.nostr = { + enable: async (options) => { + enableCalls.push(options || null); + return { ok: true }; + }, signEvent: async (event) => ({ ...event, id: `ext-${event.kind}-${event.created_at}`, @@ -304,6 +319,7 @@ function installExtensionCrypto({ actor }) { getExtensionEncrypts: () => extensionEncrypts, getExtensionDecrypts: () => extensionDecrypts, getFallbackEncrypts: () => fallbackEncrypts, + getEnableCalls: () => [...enableCalls], }; } @@ -552,6 +568,11 @@ async function testPublishSnapshotUsesExtensionCrypto() { result.items.length, "decrypted payload should match canonical item count", ); + assert.equal( + extension.getEnableCalls().length, + 1, + "extension permissions should be requested once before encrypting", + ); } finally { extension.restore(); sessionRestore.restore(); @@ -561,6 +582,43 @@ async function testPublishSnapshotUsesExtensionCrypto() { } } +async function testEnsureExtensionPermissionCaching() { + const actor = "permission-actor"; + const extension = installExtensionCrypto({ actor }); + const previousCache = nostrClient.extensionPermissionCache; + nostrClient.extensionPermissionCache = new Set(); + + try { + const first = await nostrClient.ensureExtensionPermissions([ + "nip04.decrypt", + ]); + assert.equal( + first.ok, + true, + "ensureExtensionPermissions should resolve when extension is available", + ); + assert.equal( + extension.getEnableCalls().length > 0, + true, + "ensureExtensionPermissions should invoke extension.enable to request access", + ); + + const callsAfterFirst = extension.getEnableCalls().length; + const second = await nostrClient.ensureExtensionPermissions([ + "nip04.decrypt", + ]); + assert.equal(second.ok, true, "cached permission requests should still resolve"); + assert.equal( + extension.getEnableCalls().length, + callsAfterFirst, + "cached permission requests should not trigger duplicate enable calls", + ); + } finally { + extension.restore(); + nostrClient.extensionPermissionCache = previousCache; + } +} + async function testPublishSnapshotFailureRetry() { poolHarness.reset(); nostrClient.watchHistoryLastCreatedAt = 0; @@ -852,10 +910,22 @@ async function testResolveWatchHistoryBatchingWindow() { forceRefresh: true, }); + const batchPageSizeRaw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE); + const hasCustomBatchSize = + Boolean(WATCH_HISTORY_BATCH_RESOLVE) && + Number.isFinite(batchPageSizeRaw) && + batchPageSizeRaw > 0; + const expectedCount = hasCustomBatchSize + ? Math.min( + Math.floor(batchPageSizeRaw), + WATCH_HISTORY_MAX_ITEMS, + syntheticItems.length, + ) + : Math.min(WATCH_HISTORY_MAX_ITEMS, syntheticItems.length); assert.equal( resolved.length, - Math.min(WATCH_HISTORY_MAX_ITEMS, syntheticItems.length), - "batched resolve should return the full window when no page size override is configured", + expectedCount, + "batched resolve should honor the configured page size when batching is enabled", ); assert(resolved.length > 1, "resolved history should include multiple entries"); assert.equal( @@ -1321,6 +1391,7 @@ async function testWatchHistoryAppLoginFallback() { await testPublishSnapshotCanonicalizationAndChunking(); await testPublishSnapshotUsesExtensionCrypto(); +await testEnsureExtensionPermissionCaching(); await testPublishSnapshotFailureRetry(); await testWatchHistoryPartialRelayRetry(); await testResolveWatchHistoryBatchingWindow(); @@ -1351,6 +1422,14 @@ nostrClient.resolveWatchHistory = originalResolveWatchHistory; nostrClient.getWatchHistoryFingerprint = originalGetWatchHistoryFingerprint; +if (originalExtensionPermissionCache instanceof Set) { + originalExtensionPermissionCache.clear(); + for (const method of originalExtensionPermissionSnapshot) { + originalExtensionPermissionCache.add(method); + } +} +nostrClient.extensionPermissionCache = originalExtensionPermissionCache; + if (!originalFlag) { setWatchHistoryV2Enabled(false); }