From 48d6f51ab677c514faa33209256dd87e3a6e14e0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:03:07 -0400 Subject: [PATCH] Add caching for admin list state and hydrate access control --- js/accessControl.js | 102 +++++++++++++++----------- js/adminListStore.js | 122 ++++++++++++++++++++++++++------ tests/admin-list-store.test.mjs | 56 +++++++++++++++ 3 files changed, 220 insertions(+), 60 deletions(-) diff --git a/js/accessControl.js b/js/accessControl.js index 4addf648..54652aed 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -8,7 +8,11 @@ import { ADMIN_WHITELIST_MODE_STORAGE_KEY, isLockdownMode, } from "./config.js"; -import { loadAdminState, persistAdminState } from "./adminListStore.js"; +import { + loadAdminState, + persistAdminState, + readCachedAdminState, +} from "./adminListStore.js"; import { userLogger } from "./utils/logger.js"; function normalizeNpub(value) { @@ -54,6 +58,60 @@ class AccessControl { this.lastError = null; this._isRefreshing = false; this._refreshPromise = Promise.resolve(); + this._hydratedFromCache = false; + + this._hydrateFromCache(); + } + + _hydrateFromCache() { + this._hydratedFromCache = false; + const cachedState = readCachedAdminState(); + if (cachedState) { + this._applyState(cachedState, { markLoaded: false }); + this._hydratedFromCache = true; + } + } + + _applyState(state, options = {}) { + const markLoaded = options.markLoaded !== false; + const editors = Array.isArray(state?.editors) ? state.editors : []; + const whitelist = Array.isArray(state?.whitelist) ? state.whitelist : []; + const blacklist = Array.isArray(state?.blacklist) ? state.blacklist : []; + + this.editors = new Set( + dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors]) + ); + const normalizedWhitelist = dedupeNpubs(whitelist); + this.whitelist = new Set(normalizedWhitelist); + + const blacklistDedupe = dedupeNpubs(blacklist); + const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub)); + const adminGuardSet = new Set([ + normalizeNpub(ADMIN_SUPER_NPUB), + ...Array.from(this.editors), + ]); + const sanitizedBlacklist = blacklistDedupe.filter((npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return false; + } + if (whitelistSet.has(normalized)) { + return false; + } + if (adminGuardSet.has(normalized)) { + return false; + } + return true; + }); + this.blacklist = new Set(sanitizedBlacklist); + + this.whitelistEnabled = getWhitelistMode(); + this.lastError = null; + + if (markLoaded) { + this.hasLoaded = true; + this._hydratedFromCache = false; + } } _performRefresh() { @@ -66,47 +124,10 @@ class AccessControl { const operation = (async () => { try { const state = await loadAdminState(); - const editors = Array.isArray(state?.editors) ? state.editors : []; - const whitelist = Array.isArray(state?.whitelist) - ? state.whitelist - : []; - const blacklist = Array.isArray(state?.blacklist) - ? state.blacklist - : []; - - this.editors = new Set( - dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors]) - ); - const normalizedWhitelist = dedupeNpubs(whitelist); - this.whitelist = new Set(normalizedWhitelist); - - const blacklistDedupe = dedupeNpubs(blacklist); - const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub)); - const adminGuardSet = new Set([ - normalizeNpub(ADMIN_SUPER_NPUB), - ...Array.from(this.editors), - ]); - const sanitizedBlacklist = blacklistDedupe.filter((npub) => { - const normalized = normalizeNpub(npub); - if (!normalized) { - return false; - } - if (whitelistSet.has(normalized)) { - return false; - } - if (adminGuardSet.has(normalized)) { - return false; - } - return true; - }); - this.blacklist = new Set(sanitizedBlacklist); - - this.whitelistEnabled = getWhitelistMode(); - this.hasLoaded = true; - this.lastError = null; + this._applyState(state); } catch (error) { this.lastError = error; - if (!this.hasLoaded) { + if (!this.hasLoaded && !this._hydratedFromCache) { this.editors.clear(); this.whitelist.clear(); this.blacklist.clear(); @@ -417,6 +438,7 @@ class AccessControl { export const accessControl = new AccessControl(); export { + AccessControl, ADMIN_WHITELIST_MODE_STORAGE_KEY, normalizeNpub, isValidNpub, diff --git a/js/adminListStore.js b/js/adminListStore.js index aecdeb16..e7437336 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -28,6 +28,9 @@ const LEGACY_STORAGE_KEYS = { blacklistLegacy: "bitvid_blacklist", }; +const ADMIN_STATE_CACHE_VERSION = 1; +const ADMIN_STATE_CACHE_KEY = `bitvid_admin_state_v${ADMIN_STATE_CACHE_VERSION}`; + function createError(code, message, cause) { const error = new Error(message); error.code = code; @@ -264,6 +267,54 @@ function loadLegacyAdminState() { return hasAnyEntries(sanitized) ? sanitized : null; } +export function readCachedAdminState() { + if (typeof localStorage === "undefined") { + return null; + } + + let raw = null; + try { + raw = localStorage.getItem(ADMIN_STATE_CACHE_KEY); + } catch (error) { + devLogger.warn( + "[adminListStore] Failed to read admin state cache:", + error, + ); + return null; + } + + if (raw === null) { + return null; + } + + try { + const parsed = JSON.parse(raw); + return sanitizeAdminState(parsed || {}); + } catch (error) { + devLogger.warn( + "[adminListStore] Failed to parse admin state cache:", + error, + ); + return null; + } +} + +export function writeCachedAdminState(state) { + if (typeof localStorage === "undefined") { + return; + } + + try { + const sanitized = sanitizeAdminState(state || {}); + localStorage.setItem(ADMIN_STATE_CACHE_KEY, JSON.stringify(sanitized)); + } catch (error) { + devLogger.warn( + "[adminListStore] Failed to write admin state cache:", + error, + ); + } +} + function clearLegacyStorageFor(listKey) { if (typeof localStorage === "undefined") { return; @@ -958,33 +1009,64 @@ export async function loadAdminState() { ); } - const nostrState = await loadNostrState(); + let mergedState = null; + let cachedState = null; - let mergedState = nostrState; try { - const communityEntries = await loadCommunityBlacklistEntries(); - if (Array.isArray(communityEntries) && communityEntries.length) { - const baseEditors = Array.isArray(nostrState?.editors) - ? nostrState.editors - : []; - const baseWhitelist = Array.isArray(nostrState?.whitelist) - ? nostrState.whitelist - : []; - const baseBlacklist = Array.isArray(nostrState?.blacklist) - ? nostrState.blacklist - : []; + const nostrState = await loadNostrState(); - mergedState = sanitizeAdminState({ - editors: baseEditors, - whitelist: baseWhitelist, - blacklist: [...baseBlacklist, ...communityEntries], - }); + mergedState = nostrState; + try { + const communityEntries = await loadCommunityBlacklistEntries(); + if (Array.isArray(communityEntries) && communityEntries.length) { + const baseEditors = Array.isArray(nostrState?.editors) + ? nostrState.editors + : []; + const baseWhitelist = Array.isArray(nostrState?.whitelist) + ? nostrState.whitelist + : []; + const baseBlacklist = Array.isArray(nostrState?.blacklist) + ? nostrState.blacklist + : []; + + mergedState = sanitizeAdminState({ + editors: baseEditors, + whitelist: baseWhitelist, + blacklist: [...baseBlacklist, ...communityEntries], + }); + } + } catch (error) { + devLogger.warn( + "[adminListStore] Failed to merge community blacklist entries:", + error, + ); } } catch (error) { devLogger.warn( - "[adminListStore] Failed to merge community blacklist entries:", + "[adminListStore] Failed to load admin lists from relays:", error, ); + + cachedState = readCachedAdminState(); + if (cachedState) { + return cachedState; + } + + throw error; + } + + if (!cachedState) { + cachedState = readCachedAdminState(); + } + + if (mergedState) { + if (hasAnyEntries(mergedState)) { + writeCachedAdminState(mergedState); + } else if (cachedState && hasAnyEntries(cachedState)) { + return cachedState; + } else { + writeCachedAdminState(mergedState); + } } if (!hasAnyEntries(mergedState)) { @@ -997,7 +1079,7 @@ export async function loadAdminState() { } } - return mergedState; + return mergedState || sanitizeAdminState(); } export async function persistAdminState(actorNpub, updates) { diff --git a/tests/admin-list-store.test.mjs b/tests/admin-list-store.test.mjs index fdbdb7a9..3fd9996a 100644 --- a/tests/admin-list-store.test.mjs +++ b/tests/admin-list-store.test.mjs @@ -47,6 +47,7 @@ process.on("exit", () => { const { loadAdminState, persistAdminState, + readCachedAdminState, __adminListStoreTestHooks, } = await import( "../js/adminListStore.js" @@ -61,6 +62,7 @@ const { const { ADMIN_LIST_IDENTIFIERS } = await import( "../js/nostrEventSchemas.js" ); +const { AccessControl } = await import("../js/accessControl.js"); const { extractNpubsFromEvent, @@ -106,6 +108,10 @@ mockNip19.npubEncode = undefined; globalThis.window.NostrTools.nip19 = mockNip19; +if (typeof localStorage !== "undefined") { + localStorage.clear(); +} + const fallbackHex = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"; assert.equal( @@ -255,6 +261,10 @@ nostrClient.pool = { const dTag = dValues.length ? dValues[0] : ""; const authorKey = authors.length ? authors[0] : "*"; + if (listFailureMode === "throw-all") { + throw new Error("list failure for cache test"); + } + if (listFailureMode === "throw-authors" && authors.length) { throw new Error("list failure for authors"); } @@ -385,6 +395,52 @@ assert.ok( "should exclude editor members from merged blacklist", ); +const cachedSnapshot = readCachedAdminState(); +assert.deepEqual( + cachedSnapshot, + adminState, + "should cache the merged admin state after successful load", +); + +setListFailureMode("throw-all"); + +const cachedFallback = await loadAdminState(); +assert.deepEqual( + cachedFallback, + adminState, + "should return cached admin state when relay queries fail", +); + +const accessControlFromCache = new AccessControl(); + +assert.ok( + accessControlFromCache.getEditors().includes(editorNpub), + "should hydrate access control editors from cached state", +); +assert.deepEqual( + accessControlFromCache.getWhitelist(), + adminState.whitelist, + "should hydrate access control whitelist from cached state", +); +assert.deepEqual( + accessControlFromCache.getBlacklist(), + adminState.blacklist, + "should hydrate access control blacklist from cached state", +); + +await assert.doesNotReject( + () => accessControlFromCache.refresh(), + "should keep cached state available when refresh relies on cached data", +); + +assert.deepEqual( + accessControlFromCache.getBlacklist(), + adminState.blacklist, + "should preserve cached blacklist entries after a failed refresh", +); + +setListFailureMode(null); + resetListRegistry(); setListFailureMode("throw-authors");