diff --git a/js/accessControl.js b/js/accessControl.js index 901e94e1..67cc834b 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -460,6 +460,14 @@ class AccessControl { return this.whitelistEnabled; } + // True once we have usable lists to filter with — either a completed refresh + // or admin state hydrated synchronously from the localStorage cache at + // construction. Callers use this to avoid blocking the feed on a remote + // refresh when cached lists already allow accurate canAccess() checks. + isHydrated() { + return this.hasLoaded === true || this._hydratedFromCache === true; + } + isSuperAdmin(npub) { const normalized = normalizeNpub(npub); return normalized ? normalized === ADMIN_SUPER_NPUB : false; diff --git a/js/services/nostrService.js b/js/services/nostrService.js index ff1d9dc5..639f2713 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -1556,17 +1556,17 @@ export class NostrService { } async ensureAccessControlReady() { - if (!this.accessControl || typeof this.accessControl.waitForReady !== "function") { - return; - } - + const ac = this.accessControl; + if (!ac) return; + // Block on the remote admin fetch only on a first-ever cold whitelist load; + // else the hydrated cache is accurate, so refresh in bg (handlers re-filter). + const hydrated = ac.isHydrated?.() ?? ac.hasLoaded === true; + const block = !hydrated && (ac.whitelistMode?.() ?? false); + const ready = block ? ac.waitForReady : ac.ensureReady || ac.waitForReady; try { - await this.accessControl.waitForReady(); + if (typeof ready === "function") await ready.call(ac); } catch (error) { - userLogger.warn( - "[nostrService] Failed to ensure access control lists are ready:", - error - ); + userLogger.warn("[nostrService] access control ready failed:", error); } } diff --git a/tests/nostr-service-access-control-ready.test.mjs b/tests/nostr-service-access-control-ready.test.mjs new file mode 100644 index 00000000..217e690d --- /dev/null +++ b/tests/nostr-service-access-control-ready.test.mjs @@ -0,0 +1,70 @@ +// Verifies ensureAccessControlReady no longer blocks the feed on the remote +// admin-list fetch except on a first-ever cold load in whitelist mode. +// +// Scenario (SCN-access-control-non-blocking-feed): +// - admin state hydrated (cache or loaded) => background refresh (ensureReady) +// - cold + whitelist mode => block (waitForReady) +// - cold + blacklist-only mode => background refresh (ensureReady) + +import assert from "node:assert/strict"; +import test from "node:test"; + +import "./test-helpers/setup-localstorage.mjs"; +import moderationService from "../js/services/moderationService.js"; +import { NostrService } from "../js/services/nostrService.js"; + +// Stub background timers the service may schedule. +if (moderationService) { + moderationService.scheduleTrustedMuteSubscriptionRefresh = () => {}; + moderationService.refreshTrustedMuteSubscriptions = async () => {}; +} +if (typeof globalThis.window === "undefined") globalThis.window = {}; + +function makeAccessControl({ hydrated, whitelist }) { + const calls = { ensureReady: 0, waitForReady: 0 }; + return { + calls, + isHydrated: () => hydrated, + whitelistMode: () => whitelist, + ensureReady: async () => { + calls.ensureReady += 1; + }, + waitForReady: async () => { + calls.waitForReady += 1; + }, + }; +} + +test("hydrated => background refresh, never blocks on waitForReady", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: true, whitelist: true }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.ensureReady, 1); + assert.equal(ac.calls.waitForReady, 0); +}); + +test("cold + whitelist mode => blocks on waitForReady (correctness)", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: false, whitelist: true }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.waitForReady, 1); + assert.equal(ac.calls.ensureReady, 0); +}); + +test("cold + blacklist-only mode => background refresh (no block)", async () => { + const service = new NostrService(); + const ac = makeAccessControl({ hydrated: false, whitelist: false }); + service.accessControl = ac; + await service.ensureAccessControlReady(); + assert.equal(ac.calls.ensureReady, 1); + assert.equal(ac.calls.waitForReady, 0); +}); + +test("missing access control is a no-op", async () => { + const service = new NostrService(); + service.accessControl = null; + await service.ensureAccessControlReady(); // must not throw + assert.ok(true); +});