Persist cached NIP-07 permissions and extend login tests

This commit is contained in:
thePR0M3TH3AN committed 2025-10-20 08:32:58 -04:00
1 parent eb4dd22308
commit 615f1d815c
2 files changed
+226 -19

No files matched your search

+84 -5
View File
@@ -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);
}
}
+142 -14
View File
@@ -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();
}
});