diff --git a/js/accessControl.js b/js/accessControl.js index 54652aed..99e09adc 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -31,6 +31,27 @@ function dedupeNpubs(values) { ); } +function areSetsEqual(first, second) { + if (first === second) { + return true; + } + + const a = first instanceof Set ? first : new Set(); + const b = second instanceof Set ? second : new Set(); + + if (a.size !== b.size) { + return false; + } + + for (const value of a) { + if (!b.has(value)) { + return false; + } + } + + return true; +} + function isValidNpub(npub) { if (typeof npub !== "string") { return false; @@ -59,6 +80,7 @@ class AccessControl { this._isRefreshing = false; this._refreshPromise = Promise.resolve(); this._hydratedFromCache = false; + this._whitelistListeners = new Set(); this._hydrateFromCache(); } @@ -78,11 +100,15 @@ class AccessControl { const whitelist = Array.isArray(state?.whitelist) ? state.whitelist : []; const blacklist = Array.isArray(state?.blacklist) ? state.blacklist : []; + const previousWhitelist = + this.whitelist instanceof Set ? new Set(this.whitelist) : new Set(); + this.editors = new Set( dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors]) ); const normalizedWhitelist = dedupeNpubs(whitelist); this.whitelist = new Set(normalizedWhitelist); + const whitelistChanged = !areSetsEqual(previousWhitelist, this.whitelist); const blacklistDedupe = dedupeNpubs(blacklist); const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub)); @@ -112,6 +138,28 @@ class AccessControl { this.hasLoaded = true; this._hydratedFromCache = false; } + + if (whitelistChanged) { + this._emitWhitelistChange(Array.from(this.whitelist)); + } + } + + _emitWhitelistChange(whitelistValues) { + if (!this._whitelistListeners.size) { + return; + } + + const snapshot = Array.isArray(whitelistValues) + ? [...whitelistValues] + : this.getWhitelist(); + + for (const listener of Array.from(this._whitelistListeners)) { + try { + listener(snapshot); + } catch (error) { + userLogger.error("accessControl whitelist listener failed", error); + } + } } _performRefresh() { @@ -209,6 +257,18 @@ class AccessControl { return Array.from(this.editors); } + onWhitelistChange(listener) { + if (typeof listener !== "function") { + return () => {}; + } + + this._whitelistListeners.add(listener); + + return () => { + this._whitelistListeners.delete(listener); + }; + } + async addModerator(requestorNpub, moderatorNpub) { if (!this.isSuperAdmin(requestorNpub)) { return { ok: false, error: "forbidden" }; diff --git a/js/bootstrap.js b/js/bootstrap.js index d3f77c17..fb0e14d8 100644 --- a/js/bootstrap.js +++ b/js/bootstrap.js @@ -5,23 +5,71 @@ import { DEFAULT_TRUST_SEED_NPUBS, FEATURE_TRUST_SEEDS, } from "./constants.js"; +import { accessControl } from "./accessControl.js"; import moderationService from "./services/moderationService.js"; import nostrService from "./services/nostrService.js"; import r2Service from "./services/r2Service.js"; import { loadView } from "./viewManager.js"; -if ( - FEATURE_TRUST_SEEDS && - moderationService && - typeof moderationService.setTrustedSeeds === "function" -) { +function mergeSeedsWithWhitelist(source) { + const seeds = new Set(DEFAULT_TRUST_SEED_NPUBS); + + if (source && typeof source[Symbol.iterator] === "function") { + for (const value of source) { + if (typeof value !== "string") { + continue; + } + const trimmed = value.trim(); + if (trimmed) { + seeds.add(trimmed); + } + } + } + + return seeds; +} + +async function bootstrapTrustedSeeds() { + if ( + !( + FEATURE_TRUST_SEEDS && + moderationService && + typeof moderationService.setTrustedSeeds === "function" + ) + ) { + return; + } + + const applySeeds = (whitelist = null) => { + try { + const merged = mergeSeedsWithWhitelist( + whitelist ?? accessControl?.getWhitelist?.() + ); + moderationService.setTrustedSeeds(merged); + } catch { + // Swallow bootstrap errors so login flow can continue; flag enables quick rollback. + } + }; + try { - moderationService.setTrustedSeeds(DEFAULT_TRUST_SEED_NPUBS); + if (accessControl && typeof accessControl.ensureReady === "function") { + await accessControl.ensureReady(); + } } catch { // Swallow bootstrap errors so login flow can continue; flag enables quick rollback. } + + applySeeds(); + + if (accessControl && typeof accessControl.onWhitelistChange === "function") { + accessControl.onWhitelistChange((nextWhitelist) => { + applySeeds(nextWhitelist); + }); + } } +bootstrapTrustedSeeds(); + function mergeServices(overrides = {}) { const merged = { nostrService, r2Service }; diff --git a/tests/moderation/trust-seeds.test.mjs b/tests/moderation/trust-seeds.test.mjs index 33566e58..ff87be17 100644 --- a/tests/moderation/trust-seeds.test.mjs +++ b/tests/moderation/trust-seeds.test.mjs @@ -66,3 +66,23 @@ test("trusted seeds contribute to trusted report counts", (t) => { const reporters = service.getTrustedReporters(eventId, "nudity"); assert.deepEqual(reporters.map((entry) => entry.pubkey), [seedHex]); }); + +test("whitelist entries augment trusted seeds", (t) => { + withMockedNostrTools(t); + + const whitelistHex = "a".repeat(64); + const whitelistNpub = `npub${whitelistHex}`; + + const { service } = createModerationServiceHarness(t); + service.setTrustedSeeds([...DEFAULT_TRUST_SEED_NPUBS, whitelistNpub]); + + assert.ok( + service.trustedSeedContacts.has(whitelistHex), + "whitelist npubs should populate trusted seed contacts" + ); + + assert.ok( + service.trustedContacts.has(whitelistHex), + "whitelist npubs should populate trusted contacts" + ); +});