From 615f1d815c989758e36c21ccdb33d00650b1a261 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:32:58 -0400 Subject: [PATCH] Persist cached NIP-07 permissions and extend login tests --- js/nostr.js | 89 +++++++++++++- tests/nostr-login-permissions.test.mjs | 156 ++++++++++++++++++++++--- 2 files changed, 226 insertions(+), 19 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 4061534a..45c9e580 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -83,6 +83,76 @@ function getEnableVariantTimeoutMs() { return DEFAULT_ENABLE_VARIANT_TIMEOUT_MS; } + +function normalizePermissionMethod(method) { + return typeof method === "string" && method.trim() ? method.trim() : ""; +} + +function readStoredNip07Permissions() { + if (typeof localStorage === "undefined" || !localStorage) { + return []; + } + + let rawValue; + try { + rawValue = localStorage.getItem(NIP07_PERMISSIONS_STORAGE_KEY); + } catch (error) { + return []; + } + + if (!rawValue) { + return []; + } + + try { + const parsed = JSON.parse(rawValue); + const storedMethods = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.grantedMethods) + ? parsed.grantedMethods + : Array.isArray(parsed?.methods) + ? parsed.methods + : []; + return storedMethods + .map((method) => normalizePermissionMethod(method)) + .filter(Boolean); + } catch (error) { + try { + localStorage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY); + } catch (cleanupError) { + // ignore cleanup errors + } + return []; + } +} + +function writeStoredNip07Permissions(methods) { + if (typeof localStorage === "undefined" || !localStorage) { + return; + } + + const normalized = Array.from( + new Set( + Array.from(methods || []) + .map((method) => normalizePermissionMethod(method)) + .filter(Boolean), + ), + ); + + try { + if (!normalized.length) { + localStorage.removeItem(NIP07_PERMISSIONS_STORAGE_KEY); + return; + } + + localStorage.setItem( + NIP07_PERMISSIONS_STORAGE_KEY, + JSON.stringify({ grantedMethods: normalized }), + ); + } catch (error) { + // ignore persistence failures + } +} const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed"; const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; @@ -93,12 +163,15 @@ const WATCH_HISTORY_REPUBLISH_BASE_DELAY_MS = 2000; 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 NIP07_PERMISSIONS_STORAGE_KEY = "bitvid:nip07:permissions"; export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ "get_public_key", "sign_event", "nip04.encrypt", "nip04.decrypt", + "nip44.encrypt", + "nip44.decrypt", "read_relays", "write_relays", ]); @@ -3066,7 +3139,7 @@ export class NostrClient { this.watchHistoryLastCreatedAt = 0; this.countRequestCounter = 0; this.countUnsupportedRelays = new Set(); - this.extensionPermissionCache = new Set(); + this.extensionPermissionCache = new Set(readStoredNip07Permissions()); } markExtensionPermissions(methods = []) { @@ -3078,14 +3151,20 @@ export class NostrClient { return; } + let didChange = false; for (const method of methods) { - if (typeof method !== "string") { + const normalized = normalizePermissionMethod(method); + if (!normalized) { continue; } - const normalized = method.trim(); - if (normalized) { - this.extensionPermissionCache.add(normalized); + if (!this.extensionPermissionCache.has(normalized)) { + didChange = true; } + this.extensionPermissionCache.add(normalized); + } + + if (didChange) { + writeStoredNip07Permissions(this.extensionPermissionCache); } } diff --git a/tests/nostr-login-permissions.test.mjs b/tests/nostr-login-permissions.test.mjs index 192b005a..18046989 100644 --- a/tests/nostr-login-permissions.test.mjs +++ b/tests/nostr-login-permissions.test.mjs @@ -2,12 +2,24 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; import test from "node:test"; -import { nostrClient, __testExports } from "../js/nostr.js"; +import { NostrClient, nostrClient, __testExports } from "../js/nostr.js"; import { accessControl } from "../js/accessControl.js"; const { runNip07WithRetry } = __testExports ?? {}; const HEX_PUBKEY = "f".repeat(64); +const PERMISSIONS_STORAGE_KEY = "bitvid:nip07:permissions"; + +function clearStoredPermissions() { + if (typeof localStorage === "undefined" || !localStorage) { + return; + } + try { + localStorage.removeItem(PERMISSIONS_STORAGE_KEY); + } catch (error) { + // Ignore storage cleanup issues in tests + } +} function setupLoginEnvironment({ enableImpl, getPublicKey = HEX_PUBKEY } = {}) { const previousWindow = typeof global.window === "undefined" ? undefined : global.window; @@ -116,6 +128,7 @@ function setupLoginEnvironment({ enableImpl, getPublicKey = HEX_PUBKEY } = {}) { } test("NIP-07 login requests decrypt permissions upfront", async () => { + clearStoredPermissions(); const env = setupLoginEnvironment(); try { const pubkey = await nostrClient.login(); @@ -133,13 +146,100 @@ test("NIP-07 login requests decrypt permissions upfront", async () => { nostrClient.extensionPermissionCache.has("sign_event"), "sign_event permission should be tracked as granted", ); + assert.ok( + nostrClient.extensionPermissionCache.has("nip44.encrypt"), + "nip44.encrypt permission should be tracked as granted", + ); + assert.ok( + nostrClient.extensionPermissionCache.has("nip44.decrypt"), + "nip44.decrypt permission should be tracked as granted", + ); } finally { env.restore(); nostrClient.logout(); + clearStoredPermissions(); + } +}); + +test("NIP-07 decrypt reuses cached extension permissions", async () => { + clearStoredPermissions(); + const env = setupLoginEnvironment(); + try { + let decryptCalls = 0; + window.nostr.nip04 = { + decrypt: async (actorKey, ciphertext) => { + decryptCalls += 1; + assert.equal(actorKey, HEX_PUBKEY); + return `plaintext:${ciphertext}`; + }, + }; + + const pubkey = await nostrClient.login(); + assert.equal(pubkey, HEX_PUBKEY); + + const storedRaw = localStorage.getItem(PERMISSIONS_STORAGE_KEY); + assert.ok(storedRaw, "extension permissions should persist to localStorage"); + let storedMethods = []; + try { + const parsed = JSON.parse(storedRaw); + storedMethods = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.grantedMethods) + ? parsed.grantedMethods + : []; + } catch (error) { + storedMethods = []; + } + const storedSet = new Set( + storedMethods + .filter((method) => typeof method === "string") + .map((method) => method.trim()) + .filter(Boolean), + ); + assert.ok( + storedSet.has("nip04.decrypt"), + "stored permissions should include nip04.decrypt", + ); + assert.ok( + storedSet.has("nip44.decrypt"), + "stored permissions should include nip44.decrypt", + ); + + const baselineEnableCalls = env.enableCalls.length; + const freshClient = new NostrClient(); + freshClient.pubkey = HEX_PUBKEY; + + assert.ok( + freshClient.extensionPermissionCache.has("nip04.decrypt"), + "fresh client should hydrate nip04.decrypt from storage", + ); + assert.ok( + freshClient.extensionPermissionCache.has("nip44.decrypt"), + "fresh client should hydrate nip44.decrypt from storage", + ); + + const permissionResult = await freshClient.ensureExtensionPermissions([ + "nip04.decrypt", + ]); + assert.equal(permissionResult.ok, true); + assert.equal( + env.enableCalls.length, + baselineEnableCalls, + "cached permissions should prevent extra enable() calls", + ); + + const plaintext = await window.nostr.nip04.decrypt(HEX_PUBKEY, "ciphertext"); + assert.equal(plaintext, "plaintext:ciphertext"); + assert.equal(decryptCalls, 1); + } finally { + env.restore(); + nostrClient.logout(); + clearStoredPermissions(); } }); test("NIP-07 login falls back when structured permissions fail", async () => { + clearStoredPermissions(); const env = setupLoginEnvironment({ enableImpl(options) { if ( @@ -160,28 +260,34 @@ test("NIP-07 login falls back when structured permissions fail", async () => { try { const pubkey = await nostrClient.login(); assert.equal(pubkey, HEX_PUBKEY); - assert.equal(env.enableCalls.length >= 3, true, "should retry with alternate payloads"); - const [firstCall, secondCall, thirdCall] = env.enableCalls; - assert.equal(firstCall, undefined, "first attempt should try plain enable() call"); - assert.ok(Array.isArray(secondCall?.permissions)); + assert.ok(env.enableCalls.length >= 2, "should retry with alternate payloads"); + const [objectCall, stringCall, plainCall] = env.enableCalls; + assert.ok(Array.isArray(objectCall?.permissions)); assert.equal( - typeof secondCall.permissions[0], + typeof objectCall.permissions[0], "object", - "second attempt should use structured permission objects", + "structured permission request should be attempted", ); - assert.ok(Array.isArray(thirdCall?.permissions)); + assert.ok(Array.isArray(stringCall?.permissions)); assert.equal( - typeof thirdCall.permissions[0], + typeof stringCall.permissions[0], "string", - "third attempt should fall back to string permissions", + "string-based permissions should be attempted after structured payloads", + ); + assert.equal( + plainCall, + undefined, + "plain enable() should only be attempted if prior payloads fail", ); } finally { env.restore(); nostrClient.logout(); + clearStoredPermissions(); } }); test("NIP-07 login supports extensions that only allow enable() without payload", async () => { + clearStoredPermissions(); const env = setupLoginEnvironment({ enableImpl(options) { if (options !== undefined) { @@ -194,11 +300,29 @@ test("NIP-07 login supports extensions that only allow enable() without payload" try { const pubkey = await nostrClient.login(); assert.equal(pubkey, HEX_PUBKEY); - assert.equal(env.enableCalls.length, 1, "should not need alternate payloads when plain enable works"); - assert.equal(env.enableCalls[0], undefined, "plain enable should be attempted first"); + assert.equal( + env.enableCalls.length, + 3, + "should fall back to plain enable() after payload rejections", + ); + const [objectCall, stringCall, plainCall] = env.enableCalls; + assert.ok(Array.isArray(objectCall?.permissions)); + assert.equal( + typeof objectCall.permissions[0], + "object", + "structured permissions should be attempted before falling back", + ); + assert.ok(Array.isArray(stringCall?.permissions)); + assert.equal( + typeof stringCall.permissions[0], + "string", + "string permissions should be attempted before plain enable()", + ); + assert.equal(plainCall, undefined, "final attempt should use plain enable()"); } finally { env.restore(); nostrClient.logout(); + clearStoredPermissions(); } }); @@ -206,6 +330,7 @@ test("NIP-07 login quickly retries when a permission payload stalls", async () = const originalOverride = global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__; global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__ = 80; + clearStoredPermissions(); const env = setupLoginEnvironment({ enableImpl(options) { if (options === undefined) { @@ -232,8 +357,8 @@ test("NIP-07 login quickly retries when a permission payload stalls", async () = duration < 500, `login should fall back quickly from stalled enable payloads (duration: ${duration}ms)`, ); - assert.equal(env.enableCalls.length, 3, "should attempt all payload variants"); - const [, objectCall, stringCall] = env.enableCalls; + assert.equal(env.enableCalls.length, 2, "should attempt structured and string payload variants"); + const [objectCall, stringCall] = env.enableCalls; assert.ok(Array.isArray(objectCall?.permissions)); assert.equal(typeof objectCall.permissions[0], "object"); assert.ok(Array.isArray(stringCall?.permissions)); @@ -246,10 +371,12 @@ test("NIP-07 login quickly retries when a permission payload stalls", async () = } else { global.__BITVID_NIP07_ENABLE_VARIANT_TIMEOUT_MS__ = originalOverride; } + clearStoredPermissions(); } }); test("NIP-07 login surfaces enable permission errors", async () => { + clearStoredPermissions(); const env = setupLoginEnvironment({ enableImpl() { return Promise.reject(new Error("permission denied")); @@ -261,6 +388,7 @@ test("NIP-07 login surfaces enable permission errors", async () => { } finally { env.restore(); nostrClient.logout(); + clearStoredPermissions(); } });