Merge pull request #745 from PR0M3TH3AN/codex/implement-caching-for-admin-state

Cache admin lists in localStorage for offline fallback
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-20 21:03:47 -04:00
commit 6ef8af9443
3 files changed
+220 -60

No files matched your search

+62 -40
View File
@@ -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,
+102 -20
View File
@@ -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) {
+56
View File
@@ -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");