From 014b1369b07d36298a8f37bf4092aea9ea8d315c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 19:04:28 -0400 Subject: [PATCH 1/5] feat: add async admin list adapters --- js/accessControl.js | 250 +++++++++++++++++------------- js/adminListStore.js | 357 +++++++++++++++++++++++++++++++++++++++++++ js/app.js | 219 ++++++++++++++++++++++---- js/channelProfile.js | 6 +- 4 files changed, 702 insertions(+), 130 deletions(-) create mode 100644 js/adminListStore.js diff --git a/js/accessControl.js b/js/accessControl.js index 6e7c40ee..b733572a 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -11,35 +11,13 @@ import { ADMIN_INITIAL_BLACKLIST, ADMIN_INITIAL_WHITELIST, } from "./lists.js"; - -const ADMIN_EDITORS_KEY = "bitvid_admin_editors"; -const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist"; -const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist"; - -const LEGACY_WHITELIST_KEY = "bitvid_whitelist"; -const LEGACY_BLACKLIST_KEY = "bitvid_blacklist"; - -function loadJSONList(key) { - try { - const raw = localStorage.getItem(key); - if (raw === null) { - return null; - } - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : null; - } catch (error) { - console.warn(`Failed to parse list for ${key}:`, error); - return null; - } -} - -function saveJSONList(key, values) { - try { - localStorage.setItem(key, JSON.stringify(values)); - } catch (error) { - console.warn(`Failed to persist list for ${key}:`, error); - } -} +import { + ADMIN_BLACKLIST_KEY, + ADMIN_EDITORS_KEY, + ADMIN_WHITELIST_KEY, + loadAdminState, + persistAdminState, +} from "./adminListStore.js"; function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; @@ -57,26 +35,6 @@ function dedupeNpubs(values) { ); } -function migrateLegacyList(targetKey, legacyKey, fallback) { - const stored = loadJSONList(targetKey); - if (stored !== null) { - return dedupeNpubs(stored); - } - - const legacy = legacyKey ? loadJSONList(legacyKey) : null; - if (legacy !== null) { - const sanitized = dedupeNpubs(legacy); - saveJSONList(targetKey, sanitized); - return sanitized; - } - - const sanitizedFallback = dedupeNpubs(fallback); - if (sanitizedFallback.length) { - saveJSONList(targetKey, sanitizedFallback); - } - return sanitizedFallback; -} - function isValidNpub(npub) { if (typeof npub !== "string") { return false; @@ -100,30 +58,77 @@ class AccessControl { this.whitelist = new Set(); this.blacklist = new Set(); this.whitelistEnabled = getWhitelistMode(); - this.refresh(); + this.hasLoaded = false; + this.lastError = null; + this._isRefreshing = false; + this._refreshPromise = this._performRefresh(); + this._refreshPromise.catch((error) => { + console.error("Failed to load admin lists:", error); + }); + } + + async _performRefresh() { + if (this._isRefreshing) { + return this._refreshPromise; + } + + this._isRefreshing = true; + 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]) + ); + this.whitelist = new Set( + dedupeNpubs([...ADMIN_INITIAL_WHITELIST, ...whitelist]) + ); + this.blacklist = new Set( + dedupeNpubs([...ADMIN_INITIAL_BLACKLIST, ...blacklist]) + ); + + this.whitelistEnabled = getWhitelistMode(); + this.hasLoaded = true; + this.lastError = null; + } catch (error) { + this.lastError = error; + if (!this.hasLoaded) { + this.editors.clear(); + this.whitelist.clear(); + this.blacklist.clear(); + } + throw error; + } finally { + this._isRefreshing = false; + } } refresh() { - this.editors = new Set( - dedupeNpubs([ - ...ADMIN_EDITORS_NPUBS, - ...migrateLegacyList(ADMIN_EDITORS_KEY, null, []), - ]) - ); + const promise = this._performRefresh(); + this._refreshPromise = promise; + promise.catch((error) => { + console.error("Failed to refresh admin lists:", error); + }); + return promise; + } - this.whitelist = new Set( - migrateLegacyList(ADMIN_WHITELIST_KEY, LEGACY_WHITELIST_KEY, [ - ...ADMIN_INITIAL_WHITELIST, - ]) - ); - - this.blacklist = new Set( - migrateLegacyList(ADMIN_BLACKLIST_KEY, LEGACY_BLACKLIST_KEY, [ - ...ADMIN_INITIAL_BLACKLIST, - ]) - ); - - this.whitelistEnabled = getWhitelistMode(); + async ensureReady() { + try { + await this._refreshPromise; + } catch (error) { + if (!this.hasLoaded) { + await this.refresh(); + await this._refreshPromise; + } else { + throw error; + } + } } whitelistMode() { @@ -162,7 +167,7 @@ class AccessControl { return Array.from(this.editors); } - addModerator(requestorNpub, moderatorNpub) { + async addModerator(requestorNpub, moderatorNpub) { if (!this.isSuperAdmin(requestorNpub)) { return { ok: false, error: "forbidden" }; } @@ -175,13 +180,18 @@ class AccessControl { return { ok: false, error: "immutable" }; } - this.editors.add(normalized); - saveJSONList(ADMIN_EDITORS_KEY, this.getEditors()); - this.refresh(); - return { ok: true }; + const nextEditors = dedupeNpubs([...this.getEditors(), normalized]); + + try { + await persistAdminState(requestorNpub, { editors: nextEditors }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - removeModerator(requestorNpub, moderatorNpub) { + async removeModerator(requestorNpub, moderatorNpub) { if (!this.isSuperAdmin(requestorNpub)) { return { ok: false, error: "forbidden" }; } @@ -190,14 +200,18 @@ class AccessControl { if (!normalized || normalized === ADMIN_SUPER_NPUB) { return { ok: false, error: "immutable" }; } + const nextEditors = this.getEditors().filter((value) => value !== normalized); - this.editors.delete(normalized); - saveJSONList(ADMIN_EDITORS_KEY, this.getEditors()); - this.refresh(); - return { ok: true }; + try { + await persistAdminState(requestorNpub, { editors: nextEditors }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - addToWhitelist(actorNpub, targetNpub) { + async addToWhitelist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -210,15 +224,24 @@ class AccessControl { return { ok: false, error: "invalid npub" }; } - this.blacklist.delete(normalized); - this.whitelist.add(normalized); - saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); - saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); - this.refresh(); - return { ok: true }; + const nextWhitelist = dedupeNpubs([...this.getWhitelist(), normalized]); + const nextBlacklist = this.getBlacklist().filter( + (value) => value !== normalized + ); + + try { + await persistAdminState(actorNpub, { + whitelist: nextWhitelist, + blacklist: nextBlacklist, + }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - removeFromWhitelist(actorNpub, targetNpub) { + async removeFromWhitelist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -227,14 +250,20 @@ class AccessControl { if (!normalized) { return { ok: false, error: "invalid npub" }; } + const nextWhitelist = this.getWhitelist().filter( + (value) => value !== normalized + ); - this.whitelist.delete(normalized); - saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); - this.refresh(); - return { ok: true }; + try { + await persistAdminState(actorNpub, { whitelist: nextWhitelist }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - addToBlacklist(actorNpub, targetNpub) { + async addToBlacklist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -251,15 +280,24 @@ class AccessControl { return { ok: false, error: "immutable" }; } - this.whitelist.delete(normalized); - this.blacklist.add(normalized); - saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); - saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); - this.refresh(); - return { ok: true }; + const nextBlacklist = dedupeNpubs([...this.getBlacklist(), normalized]); + const nextWhitelist = this.getWhitelist().filter( + (value) => value !== normalized + ); + + try { + await persistAdminState(actorNpub, { + blacklist: nextBlacklist, + whitelist: nextWhitelist, + }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - removeFromBlacklist(actorNpub, targetNpub) { + async removeFromBlacklist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -268,11 +306,17 @@ class AccessControl { if (!normalized) { return { ok: false, error: "invalid npub" }; } + const nextBlacklist = this.getBlacklist().filter( + (value) => value !== normalized + ); - this.blacklist.delete(normalized); - saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); - this.refresh(); - return { ok: true }; + try { + await persistAdminState(actorNpub, { blacklist: nextBlacklist }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } setWhitelistMode(actorNpub, enabled) { diff --git a/js/adminListStore.js b/js/adminListStore.js new file mode 100644 index 00000000..b4b0b183 --- /dev/null +++ b/js/adminListStore.js @@ -0,0 +1,357 @@ +// js/adminListStore.js + +import { + ADMIN_LIST_MODE, + ADMIN_LIST_NAMESPACE, + ADMIN_SUPER_NPUB, + ADMIN_EDITORS_NPUBS, + isDevMode, +} from "./config.js"; +import { + ADMIN_INITIAL_BLACKLIST, + ADMIN_INITIAL_WHITELIST, +} from "./lists.js"; +import { nostrClient } from "./nostr.js"; + +export const ADMIN_EDITORS_KEY = "bitvid_admin_editors"; +export const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist"; +export const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist"; + +const LEGACY_WHITELIST_KEY = "bitvid_whitelist"; +const LEGACY_BLACKLIST_KEY = "bitvid_blacklist"; + +const LIST_IDENTIFIERS = { + editors: "editors", + whitelist: "whitelist", + blacklist: "blacklist", +}; + +function createError(code, message, cause) { + const error = new Error(message); + error.code = code; + if (cause) { + error.cause = cause; + } + return error; +} + +function normalizeNpub(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function dedupeNpubs(values) { + const normalized = Array.isArray(values) ? values.map(normalizeNpub) : []; + return Array.from( + normalized.reduce((set, npub) => { + if (npub) { + set.add(npub); + } + return set; + }, new Set()) + ); +} + +function loadJSONList(key) { + if (typeof localStorage === "undefined") { + return null; + } + + try { + const raw = localStorage.getItem(key); + if (raw === null) { + return null; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : null; + } catch (error) { + console.warn(`Failed to parse list for ${key}:`, error); + return null; + } +} + +function saveJSONList(key, values) { + if (typeof localStorage === "undefined") { + return; + } + + try { + localStorage.setItem(key, JSON.stringify(values)); + } catch (error) { + console.warn(`Failed to persist list for ${key}:`, error); + } +} + +function migrateLegacyList(targetKey, legacyKey, fallback) { + const stored = loadJSONList(targetKey); + if (stored !== null) { + return dedupeNpubs(stored); + } + + const legacy = legacyKey ? loadJSONList(legacyKey) : null; + if (legacy !== null) { + const sanitized = dedupeNpubs(legacy); + saveJSONList(targetKey, sanitized); + return sanitized; + } + + const sanitizedFallback = dedupeNpubs(fallback); + if (sanitizedFallback.length) { + saveJSONList(targetKey, sanitizedFallback); + } + return sanitizedFallback; +} + +async function loadLocalState() { + return { + editors: migrateLegacyList(ADMIN_EDITORS_KEY, null, ADMIN_EDITORS_NPUBS), + whitelist: migrateLegacyList( + ADMIN_WHITELIST_KEY, + LEGACY_WHITELIST_KEY, + ADMIN_INITIAL_WHITELIST + ), + blacklist: migrateLegacyList( + ADMIN_BLACKLIST_KEY, + LEGACY_BLACKLIST_KEY, + ADMIN_INITIAL_BLACKLIST + ), + }; +} + +async function persistLocalState(_actorNpub, updates = {}) { + if (updates.editors) { + saveJSONList(ADMIN_EDITORS_KEY, dedupeNpubs(updates.editors)); + } + if (updates.whitelist) { + saveJSONList(ADMIN_WHITELIST_KEY, dedupeNpubs(updates.whitelist)); + } + if (updates.blacklist) { + saveJSONList(ADMIN_BLACKLIST_KEY, dedupeNpubs(updates.blacklist)); + } +} + +function ensureNostrReady() { + if (!nostrClient || !nostrClient.pool) { + throw createError( + "nostr-unavailable", + "Nostr relay pool is not initialized." + ); + } + + const relays = Array.isArray(nostrClient.relays) ? nostrClient.relays : []; + if (!relays.length) { + throw createError("nostr-unavailable", "No Nostr relays configured."); + } + + return relays; +} + +function decodeNpubToHex(npub) { + const trimmed = normalizeNpub(npub); + if (!trimmed) { + throw createError("invalid npub", "Empty npub provided."); + } + + try { + const decoded = window?.NostrTools?.nip19?.decode(trimmed); + if (decoded?.type !== "npub" || !decoded?.data) { + throw new Error("Invalid npub format"); + } + return typeof decoded.data === "string" + ? decoded.data + : decoded.data?.pubkey || ""; + } catch (error) { + throw createError("invalid npub", "Unable to decode npub.", error); + } +} + +function encodeHexToNpub(hex) { + try { + return window?.NostrTools?.nip19?.npubEncode(hex) || ""; + } catch (error) { + if (isDevMode) { + console.warn("Failed to encode hex pubkey to npub:", error); + } + return ""; + } +} + +function extractNpubsFromEvent(event) { + if (!event || !Array.isArray(event.tags)) { + return []; + } + + const hexKeys = event.tags + .filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + + const npubs = hexKeys + .map((hex) => encodeHexToNpub(hex)) + .filter((npub) => typeof npub === "string" && npub); + + return dedupeNpubs(npubs); +} + +async function loadNostrList(identifier) { + const relays = ensureNostrReady(); + const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; + const filter = { + kinds: [30000], + "#d": [dTagValue], + limit: 1, + }; + + const perRelay = await Promise.all( + relays.map(async (url) => { + try { + const events = await nostrClient.pool.list([url], [filter]); + return Array.isArray(events) ? events : []; + } catch (error) { + if (isDevMode) { + console.warn(`[adminListStore] Relay fetch failed for ${url}:`, error); + } + return []; + } + }) + ); + + const events = perRelay.flat(); + if (!events.length) { + return []; + } + + const newest = events.reduce((latest, event) => { + if (!latest) { + return event; + } + if (event.created_at === latest.created_at) { + return event.id > latest.id ? event : latest; + } + return event.created_at > latest.created_at ? event : latest; + }, null); + + return extractNpubsFromEvent(newest); +} + +async function loadNostrState() { + const editors = await loadNostrList(LIST_IDENTIFIERS.editors); + const whitelist = await loadNostrList(LIST_IDENTIFIERS.whitelist); + const blacklist = await loadNostrList(LIST_IDENTIFIERS.blacklist); + + return { + editors, + whitelist, + blacklist, + }; +} + +async function persistNostrState(actorNpub, updates = {}) { + const relays = ensureNostrReady(); + const extension = window?.nostr; + if (!extension || typeof extension.signEvent !== "function") { + throw createError( + "nostr-extension-missing", + "A Nostr extension with signEvent support is required." + ); + } + + const actorHex = decodeNpubToHex(actorNpub); + if (!actorHex) { + throw createError("invalid npub", "Unable to decode the actor npub."); + } + + const entries = Object.entries(updates).filter(([key, value]) => { + return key in LIST_IDENTIFIERS && Array.isArray(value); + }); + + if (!entries.length) { + return; + } + + const now = Math.floor(Date.now() / 1000); + + for (const [listKey, npubs] of entries) { + const identifier = LIST_IDENTIFIERS[listKey]; + const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; + + const hexPubkeys = Array.from( + new Set( + npubs.map((npub) => decodeNpubToHex(npub)).filter((hex) => !!hex) + ) + ); + + const tags = [["d", dTagValue]]; + hexPubkeys.forEach((hex) => { + tags.push(["p", hex]); + }); + + // Explicitly keep the super admin on the whitelist and editors lists when publishing. + if (listKey === "whitelist" || listKey === "editors") { + try { + const superHex = decodeNpubToHex(ADMIN_SUPER_NPUB); + if (superHex && !hexPubkeys.includes(superHex)) { + tags.push(["p", superHex]); + } + } catch (error) { + if (isDevMode) { + console.warn("Failed to ensure super admin presence:", error); + } + } + } + + const event = { + kind: 30000, + pubkey: actorHex, + created_at: now, + tags, + content: "", + }; + + let signedEvent; + try { + signedEvent = await extension.signEvent(event); + } catch (error) { + throw createError("signature-failed", "Failed to sign admin list event.", error); + } + + const publishResults = await Promise.all( + relays.map(async (url) => { + try { + await nostrClient.pool.publish([url], signedEvent); + if (isDevMode) { + console.log(`[adminListStore] Published ${listKey} list to ${url}`); + } + return { url, success: true }; + } catch (error) { + if (isDevMode) { + console.warn( + `[adminListStore] Failed to publish ${listKey} list to ${url}:`, + error + ); + } + return { url, success: false, error }; + } + }) + ); + + if (!publishResults.some((result) => result.success)) { + throw createError( + "publish-failed", + `Failed to publish ${listKey} list to any relay.` + ); + } + } +} + +export async function loadAdminState() { + if (ADMIN_LIST_MODE === "nostr") { + return loadNostrState(); + } + return loadLocalState(); +} + +export async function persistAdminState(actorNpub, updates) { + if (ADMIN_LIST_MODE === "nostr") { + return persistNostrState(actorNpub, updates); + } + return persistLocalState(actorNpub, updates); +} diff --git a/js/app.js b/js/app.js index b12d9236..17cc4a13 100644 --- a/js/app.js +++ b/js/app.js @@ -900,6 +900,12 @@ class bitvidApp { // 4. Connect to Nostr await nostrClient.init(); + try { + await accessControl.refresh(); + } catch (error) { + console.warn("Failed to refresh admin lists after connecting to Nostr:", error); + } + // Grab the "Subscriptions" link by its id in the sidebar this.subscriptionsLink = document.getElementById("subscriptionsLink"); @@ -3467,7 +3473,7 @@ class bitvidApp { this.selectProfilePane("account"); this.populateProfileRelays(); this.populateBlockedList(); - this.refreshAdminPaneState(); + await this.refreshAdminPaneState(); console.log("Profile modal initialization successful"); return true; @@ -3551,12 +3557,16 @@ class bitvidApp { }); } - openProfileModal() { + async openProfileModal() { if (!this.profileModal) { return; } - this.refreshAdminPaneState(); + try { + await this.refreshAdminPaneState(); + } catch (error) { + console.error("Failed to refresh admin pane while opening profile modal:", error); + } this.selectProfilePane("account"); this.populateProfileRelays(); @@ -3821,11 +3831,18 @@ class bitvidApp { }); } - refreshAdminPaneState() { - accessControl.refresh(); + async refreshAdminPaneState() { const adminNav = this.profileNavButtons.admin; const adminPane = this.profilePaneElements.admin; + let loadError = null; + this.setAdminLoading(true); + try { + await accessControl.ensureReady(); + } catch (error) { + loadError = error; + } + const actorNpub = this.getCurrentUserNpub(); const canEdit = !!actorNpub && accessControl.canEditAdminLists(actorNpub); const isSuperAdmin = !!actorNpub && accessControl.isSuperAdmin(actorNpub); @@ -3842,6 +3859,18 @@ class bitvidApp { adminPane.setAttribute("aria-hidden", (!canEdit).toString()); } + if (loadError) { + console.error("Failed to load admin lists:", loadError); + const message = + loadError?.code === "nostr-unavailable" + ? "Unable to reach Nostr relays. Moderation lists may be out of date." + : "Unable to load moderation lists. Please try again."; + this.showError(message); + this.clearAdminLists(); + this.setAdminLoading(false); + return; + } + if (!canEdit) { this.clearAdminLists(); if (typeof actorNpub !== "string" || !actorNpub) { @@ -3850,6 +3879,7 @@ class bitvidApp { if (adminNav instanceof HTMLElement && adminNav.classList.contains("bg-gray-800")) { this.selectProfilePane("account"); } + this.setAdminLoading(false); return; } @@ -3865,9 +3895,49 @@ class bitvidApp { } this.populateAdminLists(); + this.setAdminLoading(false); + } + + storeAdminEmptyMessages() { + const capture = (element) => { + if (element instanceof HTMLElement && !element.dataset.defaultMessage) { + element.dataset.defaultMessage = element.textContent || ""; + } + }; + + capture(this.adminModeratorsEmpty); + capture(this.adminWhitelistEmpty); + capture(this.adminBlacklistEmpty); + } + + setAdminLoading(isLoading) { + this.storeAdminEmptyMessages(); + if (this.profilePaneElements.admin instanceof HTMLElement) { + this.profilePaneElements.admin.setAttribute( + "aria-busy", + isLoading ? "true" : "false" + ); + } + + const toggleMessage = (element, message) => { + if (!(element instanceof HTMLElement)) { + return; + } + if (isLoading) { + element.textContent = message; + element.classList.remove("hidden"); + } else { + element.textContent = element.dataset.defaultMessage || element.textContent; + } + }; + + toggleMessage(this.adminModeratorsEmpty, "Loading moderators…"); + toggleMessage(this.adminWhitelistEmpty, "Loading whitelist…"); + toggleMessage(this.adminBlacklistEmpty, "Loading blacklist…"); } clearAdminLists() { + this.storeAdminEmptyMessages(); if (this.adminModeratorList) { this.adminModeratorList.innerHTML = ""; } @@ -3878,12 +3948,21 @@ class bitvidApp { this.adminBlacklistList.innerHTML = ""; } if (this.adminModeratorsEmpty instanceof HTMLElement) { + this.adminModeratorsEmpty.textContent = + this.adminModeratorsEmpty.dataset.defaultMessage || + this.adminModeratorsEmpty.textContent; this.adminModeratorsEmpty.classList.remove("hidden"); } if (this.adminWhitelistEmpty instanceof HTMLElement) { + this.adminWhitelistEmpty.textContent = + this.adminWhitelistEmpty.dataset.defaultMessage || + this.adminWhitelistEmpty.textContent; this.adminWhitelistEmpty.classList.remove("hidden"); } if (this.adminBlacklistEmpty instanceof HTMLElement) { + this.adminBlacklistEmpty.textContent = + this.adminBlacklistEmpty.dataset.defaultMessage || + this.adminBlacklistEmpty.textContent; this.adminBlacklistEmpty.classList.remove("hidden"); } if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { @@ -4018,7 +4097,20 @@ class bitvidApp { return actorNpub; } - handleAddModerator() { + async handleAddModerator() { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before adding moderator:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + return; + } + const actorNpub = this.ensureAdminActor(true); if (!actorNpub || !this.adminModeratorInput) { return; @@ -4036,7 +4128,7 @@ class bitvidApp { } try { - const result = accessControl.addModerator(actorNpub, value); + const result = await accessControl.addModerator(actorNpub, value); if (!result.ok) { this.showError(this.describeAdminError(result.error)); return; @@ -4044,7 +4136,7 @@ class bitvidApp { this.adminModeratorInput.value = ""; this.showSuccess("Moderator added successfully."); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); } finally { if (this.adminAddModeratorBtn) { this.adminAddModeratorBtn.disabled = false; @@ -4053,7 +4145,24 @@ class bitvidApp { } } - handleRemoveModerator(npub, button) { + async handleRemoveModerator(npub, button) { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before removing moderator:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + if (button instanceof HTMLElement) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + const actorNpub = this.ensureAdminActor(true); if (!actorNpub) { if (button instanceof HTMLElement) { @@ -4063,7 +4172,7 @@ class bitvidApp { return; } - const result = accessControl.removeModerator(actorNpub, npub); + const result = await accessControl.removeModerator(actorNpub, npub); if (!result.ok) { this.showError(this.describeAdminError(result.error)); if (button instanceof HTMLElement) { @@ -4074,10 +4183,27 @@ class bitvidApp { } this.showSuccess("Moderator removed."); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); } - handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) { + async handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before updating entries:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + if (sourceButton instanceof HTMLElement) { + sourceButton.disabled = false; + sourceButton.removeAttribute("aria-busy"); + } + return; + } + const actorNpub = this.ensureAdminActor(false); if (!actorNpub) { if (sourceButton instanceof HTMLElement) { @@ -4115,12 +4241,12 @@ class bitvidApp { let result; if (isWhitelist) { result = isAdd - ? accessControl.addToWhitelist(actorNpub, target) - : accessControl.removeFromWhitelist(actorNpub, target); + ? await accessControl.addToWhitelist(actorNpub, target) + : await accessControl.removeFromWhitelist(actorNpub, target); } else { result = isAdd - ? accessControl.addToBlacklist(actorNpub, target) - : accessControl.removeFromBlacklist(actorNpub, target); + ? await accessControl.addToBlacklist(actorNpub, target) + : await accessControl.removeFromBlacklist(actorNpub, target); } if (!result.ok) { @@ -4144,7 +4270,7 @@ class bitvidApp { ? "Added to the blacklist." : "Removed from the blacklist."; this.showSuccess(successMessage); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); if (buttonToToggle instanceof HTMLElement) { buttonToToggle.disabled = false; @@ -4160,12 +4286,38 @@ class bitvidApp { return "That account cannot be modified."; case "forbidden": return "You do not have permission to perform that action."; + case "nostr-unavailable": + return "Unable to reach the configured Nostr relays. Please retry once your connection is restored."; + case "nostr-extension-missing": + return "Connect a Nostr extension before editing moderation lists."; + case "signature-failed": + return "We couldn’t sign the update with your Nostr key. Please reconnect your extension and try again."; + case "publish-failed": + return "Failed to publish the update to Nostr relays. Please try again."; + case "storage-error": + return "Unable to update moderation settings. Please try again."; default: return "Unable to update moderation settings. Please try again."; } } - handleWhitelistModeToggle(enabled) { + async handleWhitelistModeToggle(enabled) { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before toggling whitelist mode:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { + this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); + } + return; + } + const actorNpub = this.ensureAdminActor(true); if (!actorNpub) { if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { @@ -4188,11 +4340,16 @@ class bitvidApp { ? "Whitelist mode enabled. Only approved creators will appear in feeds." : "Whitelist mode disabled. All creators except those blacklisted will appear." ); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); } - onAccessControlUpdated() { - this.refreshAdminPaneState(); + async onAccessControlUpdated() { + try { + await this.refreshAdminPaneState(); + } catch (error) { + console.error("Failed to refresh admin pane after update:", error); + } + this.loadVideos(true).catch((error) => { console.error("Failed to refresh videos after admin update:", error); }); @@ -4213,7 +4370,9 @@ class bitvidApp { // 2) Profile button if (this.profileButton) { this.profileButton.addEventListener("click", () => { - this.openProfileModal(); + this.openProfileModal().catch((error) => { + console.error("Failed to open profile modal:", error); + }); }); } @@ -4823,7 +4982,7 @@ class bitvidApp { return; } - this.refreshAdminPaneState(); + await this.refreshAdminPaneState(); // Hide login button if present if (this.loginButton) { @@ -4907,7 +5066,7 @@ class bitvidApp { // Clear localStorage localStorage.removeItem("userPubKey"); - this.refreshAdminPaneState(); + await this.refreshAdminPaneState(); // Refresh the video list so user sees only public videos again await this.loadVideos(); @@ -5346,7 +5505,11 @@ class bitvidApp { async loadVideos(forceFetch = false) { console.log("Starting loadVideos... (forceFetch =", forceFetch, ")"); - accessControl.refresh(); + try { + await accessControl.ensureReady(); + } catch (error) { + console.warn("Failed to ensure admin lists were loaded before fetching videos:", error); + } const shouldIncludeVideo = (video) => { if (!video || typeof video !== "object") { @@ -7649,7 +7812,11 @@ class bitvidApp { return; } - accessControl.refresh(); + try { + await accessControl.ensureReady(); + } catch (error) { + console.warn("Failed to ensure admin lists were loaded before playback:", error); + } const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; if (!accessControl.canAccess(authorNpub)) { if (accessControl.isBlacklisted(authorNpub)) { diff --git a/js/channelProfile.js b/js/channelProfile.js index 51719d31..e448f3dd 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -251,7 +251,11 @@ async function loadUserProfile(pubkey) { */ async function loadUserVideos(pubkey) { try { - accessControl.refresh(); + try { + await accessControl.ensureReady(); + } catch (error) { + console.warn("Failed to ensure admin lists were loaded before channel fetch:", error); + } // 1) Build filter for videos from this pubkey const filter = { From a56568e3c7115ea5b51fdc4aa28b116c9d1eff9e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 19:12:19 -0400 Subject: [PATCH 2/5] Remove whitelist mode toggle from admin panel --- components/profile-modal.html | 12 ------ js/app.js | 71 ----------------------------------- 2 files changed, 83 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index dc4c80e9..937467e4 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -240,18 +240,6 @@
Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist. diff --git a/js/app.js b/js/app.js index 17cc4a13..e82acd1c 100644 --- a/js/app.js +++ b/js/app.js @@ -523,8 +523,6 @@ class bitvidApp { this.adminWhitelistList = null; this.adminWhitelistEmpty = null; this.adminWhitelistSection = null; - this.adminWhitelistModeToggle = null; - this.adminWhitelistModeWrapper = null; this.adminBlacklistInput = null; this.adminAddBlacklistBtn = null; this.adminBlacklistList = null; @@ -3322,10 +3320,6 @@ class bitvidApp { document.getElementById("adminWhitelistInput") || null; this.adminAddWhitelistBtn = document.getElementById("adminAddWhitelistBtn") || null; - this.adminWhitelistModeToggle = - document.getElementById("adminWhitelistModeToggle") || null; - this.adminWhitelistModeWrapper = - document.getElementById("adminWhitelistModeWrapper") || null; this.adminBlacklistSection = document.getElementById("adminBlacklistSection") || null; this.adminBlacklistEmpty = @@ -3457,19 +3451,6 @@ class bitvidApp { }); } - if ( - this.adminWhitelistModeToggle && - this.adminWhitelistModeToggle.dataset.bound !== "true" - ) { - this.adminWhitelistModeToggle.dataset.bound = "true"; - this.adminWhitelistModeToggle.addEventListener("change", (event) => { - const enabled = event.target instanceof HTMLInputElement - ? event.target.checked - : false; - this.handleWhitelistModeToggle(enabled); - }); - } - this.selectProfilePane("account"); this.populateProfileRelays(); this.populateBlockedList(); @@ -3887,13 +3868,6 @@ class bitvidApp { this.adminModeratorsSection.classList.toggle("hidden", !isSuperAdmin); this.adminModeratorsSection.setAttribute("aria-hidden", (!isSuperAdmin).toString()); } - if (this.adminWhitelistModeWrapper instanceof HTMLElement) { - this.adminWhitelistModeWrapper.classList.toggle("hidden", !isSuperAdmin); - } - if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { - this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); - } - this.populateAdminLists(); this.setAdminLoading(false); } @@ -3965,9 +3939,6 @@ class bitvidApp { this.adminBlacklistEmpty.textContent; this.adminBlacklistEmpty.classList.remove("hidden"); } - if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { - this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); - } } renderAdminList(listEl, emptyEl, entries, options = {}) { @@ -4301,48 +4272,6 @@ class bitvidApp { } } - async handleWhitelistModeToggle(enabled) { - let preloadError = null; - try { - await accessControl.ensureReady(); - } catch (error) { - preloadError = error; - console.error("Failed to load admin lists before toggling whitelist mode:", error); - } - - if (preloadError) { - this.showError(this.describeAdminError(preloadError.code || "storage-error")); - if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { - this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); - } - return; - } - - const actorNpub = this.ensureAdminActor(true); - if (!actorNpub) { - if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { - this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); - } - return; - } - - const result = accessControl.setWhitelistMode(actorNpub, enabled); - if (!result.ok) { - this.showError(this.describeAdminError(result.error)); - if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { - this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); - } - return; - } - - this.showSuccess( - enabled - ? "Whitelist mode enabled. Only approved creators will appear in feeds." - : "Whitelist mode disabled. All creators except those blacklisted will appear." - ); - await this.onAccessControlUpdated(); - } - async onAccessControlUpdated() { try { await this.refreshAdminPaneState(); From 66595edaf68442ebb555c6affcf1844b28609e9e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 19:17:31 -0400 Subject: [PATCH 3/5] Enforce admin list invariants --- js/accessControl.js | 51 ++++++++++++-- js/adminListStore.js | 156 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 191 insertions(+), 16 deletions(-) diff --git a/js/accessControl.js b/js/accessControl.js index b733572a..793dd381 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -86,12 +86,35 @@ class AccessControl { this.editors = new Set( dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors]) ); - this.whitelist = new Set( - dedupeNpubs([...ADMIN_INITIAL_WHITELIST, ...whitelist]) - ); - this.blacklist = new Set( - dedupeNpubs([...ADMIN_INITIAL_BLACKLIST, ...blacklist]) - ); + const normalizedWhitelist = dedupeNpubs([ + ...ADMIN_INITIAL_WHITELIST, + ...whitelist, + ]); + this.whitelist = new Set(normalizedWhitelist); + + const blacklistDedupe = dedupeNpubs([ + ...ADMIN_INITIAL_BLACKLIST, + ...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; @@ -330,7 +353,13 @@ class AccessControl { isBlacklisted(npub) { const normalized = normalizeNpub(npub); - return normalized ? this.blacklist.has(normalized) : false; + if (!normalized) { + return false; + } + if (this.whitelist.has(normalized)) { + return false; + } + return this.blacklist.has(normalized); } canAccess(candidate) { @@ -355,6 +384,14 @@ class AccessControl { return false; } + if (this.isAdminEditor(normalized)) { + return true; + } + + if (this.whitelist.has(normalized)) { + return true; + } + if (this.blacklist.has(normalized)) { return false; } diff --git a/js/adminListStore.js b/js/adminListStore.js index b4b0b183..77c78716 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -51,6 +51,77 @@ function dedupeNpubs(values) { ); } +function canDecodeNpub() { + return !!( + typeof window !== "undefined" && + window?.NostrTools?.nip19 && + typeof window.NostrTools.nip19.decode === "function" + ); +} + +function isLikelyNpub(value) { + if (typeof value !== "string") { + return false; + } + + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + + if (!canDecodeNpub()) { + return true; + } + + try { + const decoded = window.NostrTools.nip19.decode(trimmed); + return decoded?.type === "npub"; + } catch (error) { + return false; + } +} + +function sanitizeNpubList(values) { + return dedupeNpubs(values).filter(isLikelyNpub); +} + +function sanitizeAdminState(state = {}) { + const sanitizedEditors = sanitizeNpubList(state.editors || []); + const sanitizedWhitelist = sanitizeNpubList(state.whitelist || []); + const sanitizedWhitelistSet = new Set([ + ...ADMIN_INITIAL_WHITELIST.map(normalizeNpub), + ...sanitizedWhitelist, + ]); + + const adminGuardSet = new Set([ + normalizeNpub(ADMIN_SUPER_NPUB), + ...ADMIN_EDITORS_NPUBS.map(normalizeNpub), + ...sanitizedEditors, + ]); + + const sanitizedBlacklist = sanitizeNpubList(state.blacklist || []).filter( + (npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return false; + } + if (adminGuardSet.has(normalized)) { + return false; + } + if (sanitizedWhitelistSet.has(normalized)) { + return false; + } + return true; + } + ); + + return { + editors: sanitizedEditors, + whitelist: Array.from(sanitizedWhitelistSet), + blacklist: sanitizedBlacklist, + }; +} + function loadJSONList(key) { if (typeof localStorage === "undefined") { return null; @@ -102,7 +173,7 @@ function migrateLegacyList(targetKey, legacyKey, fallback) { } async function loadLocalState() { - return { + const state = { editors: migrateLegacyList(ADMIN_EDITORS_KEY, null, ADMIN_EDITORS_NPUBS), whitelist: migrateLegacyList( ADMIN_WHITELIST_KEY, @@ -115,17 +186,35 @@ async function loadLocalState() { ADMIN_INITIAL_BLACKLIST ), }; + + return sanitizeAdminState(state); } async function persistLocalState(_actorNpub, updates = {}) { + if ( + !updates || + (!updates.editors && !updates.whitelist && !updates.blacklist) + ) { + return; + } + + const currentState = await loadLocalState(); + const merged = { + editors: updates.editors ? updates.editors : currentState.editors, + whitelist: updates.whitelist ? updates.whitelist : currentState.whitelist, + blacklist: updates.blacklist ? updates.blacklist : currentState.blacklist, + }; + + const sanitized = sanitizeAdminState(merged); + if (updates.editors) { - saveJSONList(ADMIN_EDITORS_KEY, dedupeNpubs(updates.editors)); + saveJSONList(ADMIN_EDITORS_KEY, sanitized.editors); } if (updates.whitelist) { - saveJSONList(ADMIN_WHITELIST_KEY, dedupeNpubs(updates.whitelist)); + saveJSONList(ADMIN_WHITELIST_KEY, sanitized.whitelist); } if (updates.blacklist) { - saveJSONList(ADMIN_BLACKLIST_KEY, dedupeNpubs(updates.blacklist)); + saveJSONList(ADMIN_BLACKLIST_KEY, sanitized.blacklist); } } @@ -237,11 +326,11 @@ async function loadNostrState() { const whitelist = await loadNostrList(LIST_IDENTIFIERS.whitelist); const blacklist = await loadNostrList(LIST_IDENTIFIERS.blacklist); - return { + return sanitizeAdminState({ editors, whitelist, blacklist, - }; + }); } async function persistNostrState(actorNpub, updates = {}) { @@ -259,9 +348,58 @@ async function persistNostrState(actorNpub, updates = {}) { throw createError("invalid npub", "Unable to decode the actor npub."); } - const entries = Object.entries(updates).filter(([key, value]) => { - return key in LIST_IDENTIFIERS && Array.isArray(value); - }); + const entries = Object.entries(updates) + .filter(([key, value]) => { + return key in LIST_IDENTIFIERS && Array.isArray(value); + }) + .map(([key, value]) => { + const base = sanitizeNpubList(value); + if (key === "blacklist") { + const guardSet = new Set([ + normalizeNpub(ADMIN_SUPER_NPUB), + ...ADMIN_EDITORS_NPUBS.map(normalizeNpub), + ...sanitizeNpubList(updates.editors || []), + ]); + const whitelistSet = new Set( + sanitizeNpubList([ + ...ADMIN_INITIAL_WHITELIST, + ...(updates.whitelist || []), + ]) + ); + const sanitizedBlacklist = base.filter((npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return false; + } + if (guardSet.has(normalized)) { + return false; + } + if (whitelistSet.has(normalized)) { + return false; + } + return true; + }); + return [key, sanitizedBlacklist]; + } + + if (key === "editors") { + const sanitizedEditors = base.filter((npub) => { + const normalized = normalizeNpub(npub); + return normalized && normalized !== normalizeNpub(ADMIN_SUPER_NPUB); + }); + return [key, sanitizedEditors]; + } + + if (key === "whitelist") { + const mergedWhitelist = sanitizeNpubList([ + ...ADMIN_INITIAL_WHITELIST, + ...base, + ]); + return [key, mergedWhitelist]; + } + + return [key, base]; + }); if (!entries.length) { return; From c6c297e16be9bdef0c4938a7e535a0cb093cc2ca Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 19:21:51 -0400 Subject: [PATCH 4/5] Clear legacy admin defaults and trust adapter state --- js/accessControl.js | 14 ++------------ js/adminListStore.js | 23 ++++++----------------- js/lists.js | 28 ++++++---------------------- 3 files changed, 14 insertions(+), 51 deletions(-) diff --git a/js/accessControl.js b/js/accessControl.js index 793dd381..f08f9c93 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -7,10 +7,6 @@ import { setWhitelistMode as persistWhitelistMode, ADMIN_WHITELIST_MODE_STORAGE_KEY, } from "./config.js"; -import { - ADMIN_INITIAL_BLACKLIST, - ADMIN_INITIAL_WHITELIST, -} from "./lists.js"; import { ADMIN_BLACKLIST_KEY, ADMIN_EDITORS_KEY, @@ -86,16 +82,10 @@ class AccessControl { this.editors = new Set( dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors]) ); - const normalizedWhitelist = dedupeNpubs([ - ...ADMIN_INITIAL_WHITELIST, - ...whitelist, - ]); + const normalizedWhitelist = dedupeNpubs(whitelist); this.whitelist = new Set(normalizedWhitelist); - const blacklistDedupe = dedupeNpubs([ - ...ADMIN_INITIAL_BLACKLIST, - ...blacklist, - ]); + const blacklistDedupe = dedupeNpubs(blacklist); const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub)); const adminGuardSet = new Set([ normalizeNpub(ADMIN_SUPER_NPUB), diff --git a/js/adminListStore.js b/js/adminListStore.js index 77c78716..dd63eeab 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -7,10 +7,7 @@ import { ADMIN_EDITORS_NPUBS, isDevMode, } from "./config.js"; -import { - ADMIN_INITIAL_BLACKLIST, - ADMIN_INITIAL_WHITELIST, -} from "./lists.js"; +import { ADMIN_INITIAL_BLACKLIST, ADMIN_INITIAL_WHITELIST } from "./lists.js"; import { nostrClient } from "./nostr.js"; export const ADMIN_EDITORS_KEY = "bitvid_admin_editors"; @@ -87,11 +84,9 @@ function sanitizeNpubList(values) { function sanitizeAdminState(state = {}) { const sanitizedEditors = sanitizeNpubList(state.editors || []); - const sanitizedWhitelist = sanitizeNpubList(state.whitelist || []); - const sanitizedWhitelistSet = new Set([ - ...ADMIN_INITIAL_WHITELIST.map(normalizeNpub), - ...sanitizedWhitelist, - ]); + const sanitizedWhitelistSet = new Set( + sanitizeNpubList(state.whitelist || []) + ); const adminGuardSet = new Set([ normalizeNpub(ADMIN_SUPER_NPUB), @@ -361,10 +356,7 @@ async function persistNostrState(actorNpub, updates = {}) { ...sanitizeNpubList(updates.editors || []), ]); const whitelistSet = new Set( - sanitizeNpubList([ - ...ADMIN_INITIAL_WHITELIST, - ...(updates.whitelist || []), - ]) + sanitizeNpubList(updates.whitelist || []) ); const sanitizedBlacklist = base.filter((npub) => { const normalized = normalizeNpub(npub); @@ -391,10 +383,7 @@ async function persistNostrState(actorNpub, updates = {}) { } if (key === "whitelist") { - const mergedWhitelist = sanitizeNpubList([ - ...ADMIN_INITIAL_WHITELIST, - ...base, - ]); + const mergedWhitelist = sanitizeNpubList(base); return [key, mergedWhitelist]; } diff --git a/js/lists.js b/js/lists.js index 8f77332b..aaa15320 100644 --- a/js/lists.js +++ b/js/lists.js @@ -1,26 +1,10 @@ // js/lists.js -// Whitelist of npubs that can access the video upload functions -const npubs = [ - "npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe", // bitvid - "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx", // thePR0M3TH3AN - "npub1j37gc05qpqzyrmdc5vetsc9h5qtstas7tr25j0n9sdpqxghz6m4q2ej6n8", // Ghost Grid Network - "npub1epvnvv3kskvpnmpqgnm2atevsmdferhp7dg2s0yc7uc0hdmqmgssx09tu2", // Chain Reaction - "npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx", // ODELL - "npub1qxduthz4p8v5zsux524df569pt7lj0d36dyqadnta2val64dtnhsr50h64", // NosToons - "npub19ma2w9dmk3kat0nt0k5dwuqzvmg3va9ezwup0zkakhpwv0vcwvcsg8axkl", // vinney - "npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro - "npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster - "npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal - "npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox - "npub196rl3tls3c4y79pc3ptrrj2430z7p5uwetfhukhtkt69hph0fvwq08l43q", // ~Bordut-Nodlex -]; - -console.log("DEBUG: lists.js loaded, npubs:", npubs); - -// Blacklist of npubs that events will not be displayed in the bitvid official client -export const ADMIN_INITIAL_WHITELIST = npubs; -export const ADMIN_INITIAL_BLACKLIST = [""]; +// Operators must manage admin lists through the Admin panel UI or remote +// adapters. These legacy constants are intentionally empty so new installs do +// not ship with hard-coded accounts. +export const ADMIN_INITIAL_WHITELIST = []; +export const ADMIN_INITIAL_BLACKLIST = []; // Block specific events with the nevent -export const ADMIN_INITIAL_EVENT_BLACKLIST = [""]; +export const ADMIN_INITIAL_EVENT_BLACKLIST = []; From 84b5ef77c0d9c3318db0793c9d8612659797ce64 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 19:38:56 -0400 Subject: [PATCH 5/5] Enforce remote admin list storage --- js/accessControl.js | 11 +- js/adminListStore.js | 487 ++++++++++++++++++++++++------------------- js/config.js | 4 +- 3 files changed, 281 insertions(+), 221 deletions(-) diff --git a/js/accessControl.js b/js/accessControl.js index f08f9c93..5dea0ae4 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -7,13 +7,7 @@ import { setWhitelistMode as persistWhitelistMode, ADMIN_WHITELIST_MODE_STORAGE_KEY, } from "./config.js"; -import { - ADMIN_BLACKLIST_KEY, - ADMIN_EDITORS_KEY, - ADMIN_WHITELIST_KEY, - loadAdminState, - persistAdminState, -} from "./adminListStore.js"; +import { loadAdminState, persistAdminState } from "./adminListStore.js"; function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; @@ -396,9 +390,6 @@ class AccessControl { export const accessControl = new AccessControl(); export { - ADMIN_EDITORS_KEY, - ADMIN_WHITELIST_KEY, - ADMIN_BLACKLIST_KEY, ADMIN_WHITELIST_MODE_STORAGE_KEY, normalizeNpub, isValidNpub, diff --git a/js/adminListStore.js b/js/adminListStore.js index dd63eeab..706f893e 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -7,22 +7,24 @@ import { ADMIN_EDITORS_NPUBS, isDevMode, } from "./config.js"; -import { ADMIN_INITIAL_BLACKLIST, ADMIN_INITIAL_WHITELIST } from "./lists.js"; import { nostrClient } from "./nostr.js"; -export const ADMIN_EDITORS_KEY = "bitvid_admin_editors"; -export const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist"; -export const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist"; - -const LEGACY_WHITELIST_KEY = "bitvid_whitelist"; -const LEGACY_BLACKLIST_KEY = "bitvid_blacklist"; - const LIST_IDENTIFIERS = { editors: "editors", whitelist: "whitelist", blacklist: "blacklist", }; +const LEGACY_STORAGE_KEYS = { + editors: "bitvid_admin_editors", + whitelist: "bitvid_admin_whitelist", + whitelistLegacy: "bitvid_whitelist", + blacklist: "bitvid_admin_blacklist", + blacklistLegacy: "bitvid_blacklist", +}; + +const PUBLISH_TIMEOUT_MS = 7000; + function createError(code, message, cause) { const error = new Error(message); error.code = code; @@ -84,14 +86,13 @@ function sanitizeNpubList(values) { function sanitizeAdminState(state = {}) { const sanitizedEditors = sanitizeNpubList(state.editors || []); - const sanitizedWhitelistSet = new Set( - sanitizeNpubList(state.whitelist || []) - ); + const sanitizedWhitelist = sanitizeNpubList(state.whitelist || []); + const whitelistSet = new Set(sanitizedWhitelist.map(normalizeNpub)); const adminGuardSet = new Set([ normalizeNpub(ADMIN_SUPER_NPUB), ...ADMIN_EDITORS_NPUBS.map(normalizeNpub), - ...sanitizedEditors, + ...sanitizedEditors.map(normalizeNpub), ]); const sanitizedBlacklist = sanitizeNpubList(state.blacklist || []).filter( @@ -100,10 +101,10 @@ function sanitizeAdminState(state = {}) { if (!normalized) { return false; } - if (adminGuardSet.has(normalized)) { + if (whitelistSet.has(normalized)) { return false; } - if (sanitizedWhitelistSet.has(normalized)) { + if (adminGuardSet.has(normalized)) { return false; } return true; @@ -112,12 +113,20 @@ function sanitizeAdminState(state = {}) { return { editors: sanitizedEditors, - whitelist: Array.from(sanitizedWhitelistSet), + whitelist: sanitizedWhitelist, blacklist: sanitizedBlacklist, }; } -function loadJSONList(key) { +function hasAnyEntries(state = {}) { + return Boolean( + (Array.isArray(state.editors) && state.editors.length) || + (Array.isArray(state.whitelist) && state.whitelist.length) || + (Array.isArray(state.blacklist) && state.blacklist.length) + ); +} + +function readJsonListFromStorage(key) { if (typeof localStorage === "undefined") { return null; } @@ -130,86 +139,58 @@ function loadJSONList(key) { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : null; } catch (error) { - console.warn(`Failed to parse list for ${key}:`, error); + if (isDevMode) { + console.warn(`[adminListStore] Failed to parse legacy list for ${key}:`, error); + } return null; } } -function saveJSONList(key, values) { +function loadLegacyAdminState() { + const editors = readJsonListFromStorage(LEGACY_STORAGE_KEYS.editors) || []; + const whitelist = + readJsonListFromStorage(LEGACY_STORAGE_KEYS.whitelist) || + readJsonListFromStorage(LEGACY_STORAGE_KEYS.whitelistLegacy) || + []; + const blacklist = + readJsonListFromStorage(LEGACY_STORAGE_KEYS.blacklist) || + readJsonListFromStorage(LEGACY_STORAGE_KEYS.blacklistLegacy) || + []; + + const sanitized = sanitizeAdminState({ editors, whitelist, blacklist }); + return hasAnyEntries(sanitized) ? sanitized : null; +} + +function clearLegacyStorageFor(listKey) { if (typeof localStorage === "undefined") { return; } - try { - localStorage.setItem(key, JSON.stringify(values)); - } catch (error) { - console.warn(`Failed to persist list for ${key}:`, error); - } -} - -function migrateLegacyList(targetKey, legacyKey, fallback) { - const stored = loadJSONList(targetKey); - if (stored !== null) { - return dedupeNpubs(stored); - } - - const legacy = legacyKey ? loadJSONList(legacyKey) : null; - if (legacy !== null) { - const sanitized = dedupeNpubs(legacy); - saveJSONList(targetKey, sanitized); - return sanitized; - } - - const sanitizedFallback = dedupeNpubs(fallback); - if (sanitizedFallback.length) { - saveJSONList(targetKey, sanitizedFallback); - } - return sanitizedFallback; -} - -async function loadLocalState() { - const state = { - editors: migrateLegacyList(ADMIN_EDITORS_KEY, null, ADMIN_EDITORS_NPUBS), - whitelist: migrateLegacyList( - ADMIN_WHITELIST_KEY, - LEGACY_WHITELIST_KEY, - ADMIN_INITIAL_WHITELIST - ), - blacklist: migrateLegacyList( - ADMIN_BLACKLIST_KEY, - LEGACY_BLACKLIST_KEY, - ADMIN_INITIAL_BLACKLIST - ), + const keyGroups = { + editors: [LEGACY_STORAGE_KEYS.editors], + whitelist: [ + LEGACY_STORAGE_KEYS.whitelist, + LEGACY_STORAGE_KEYS.whitelistLegacy, + ], + blacklist: [ + LEGACY_STORAGE_KEYS.blacklist, + LEGACY_STORAGE_KEYS.blacklistLegacy, + ], }; - return sanitizeAdminState(state); -} - -async function persistLocalState(_actorNpub, updates = {}) { - if ( - !updates || - (!updates.editors && !updates.whitelist && !updates.blacklist) - ) { + const keys = keyGroups[listKey]; + if (!Array.isArray(keys)) { return; } - const currentState = await loadLocalState(); - const merged = { - editors: updates.editors ? updates.editors : currentState.editors, - whitelist: updates.whitelist ? updates.whitelist : currentState.whitelist, - blacklist: updates.blacklist ? updates.blacklist : currentState.blacklist, - }; - - const sanitized = sanitizeAdminState(merged); - - if (updates.editors) { - saveJSONList(ADMIN_EDITORS_KEY, sanitized.editors); - } - if (updates.whitelist) { - saveJSONList(ADMIN_WHITELIST_KEY, sanitized.whitelist); - } - if (updates.blacklist) { - saveJSONList(ADMIN_BLACKLIST_KEY, sanitized.blacklist); + for (const key of keys) { + try { + localStorage.removeItem(key); + } catch (error) { + if (isDevMode) { + console.warn(`[adminListStore] Failed to clear legacy storage for ${key}:`, error); + } + } } } @@ -281,24 +262,44 @@ async function loadNostrList(identifier) { const filter = { kinds: [30000], "#d": [dTagValue], - limit: 1, + limit: 50, }; - const perRelay = await Promise.all( - relays.map(async (url) => { - try { - const events = await nostrClient.pool.list([url], [filter]); - return Array.isArray(events) ? events : []; - } catch (error) { - if (isDevMode) { - console.warn(`[adminListStore] Relay fetch failed for ${url}:`, error); + let events = []; + try { + const combined = await nostrClient.pool.list(relays, [filter]); + if (Array.isArray(combined)) { + events = combined; + } + } catch (error) { + if (isDevMode) { + console.warn( + `[adminListStore] Combined relay fetch failed for ${identifier}:`, + error + ); + } + } + + if (!events.length) { + const perRelay = await Promise.all( + relays.map(async (url) => { + try { + const result = await nostrClient.pool.list([url], [filter]); + return Array.isArray(result) ? result : []; + } catch (error) { + if (isDevMode) { + console.warn( + `[adminListStore] Relay fetch failed for ${identifier} on ${url}:`, + error + ); + } + return []; } - return []; - } - }) - ); + }) + ); + events = perRelay.flat(); + } - const events = perRelay.flat(); if (!events.length) { return []; } @@ -317,19 +318,122 @@ async function loadNostrList(identifier) { } async function loadNostrState() { - const editors = await loadNostrList(LIST_IDENTIFIERS.editors); - const whitelist = await loadNostrList(LIST_IDENTIFIERS.whitelist); - const blacklist = await loadNostrList(LIST_IDENTIFIERS.blacklist); + const [editors, whitelist, blacklist] = await Promise.all([ + loadNostrList(LIST_IDENTIFIERS.editors), + loadNostrList(LIST_IDENTIFIERS.whitelist), + loadNostrList(LIST_IDENTIFIERS.blacklist), + ]); - return sanitizeAdminState({ - editors, - whitelist, - blacklist, + return sanitizeAdminState({ editors, whitelist, blacklist }); +} + +function buildListEvent(listKey, npubs, actorHex) { + const identifier = LIST_IDENTIFIERS[listKey]; + const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; + + const hexPubkeys = Array.from( + new Set(npubs.map((npub) => { + try { + return decodeNpubToHex(npub); + } catch (error) { + if (isDevMode) { + console.warn( + `[adminListStore] Failed to decode npub while publishing ${listKey}:`, + error + ); + } + return ""; + } + }).filter((hex) => !!hex)) + ); + + if (listKey === "whitelist" || listKey === "editors") { + try { + const superHex = decodeNpubToHex(ADMIN_SUPER_NPUB); + if (superHex && !hexPubkeys.includes(superHex)) { + hexPubkeys.push(superHex); + } + } catch (error) { + if (isDevMode) { + console.warn("Failed to ensure super admin presence:", error); + } + } + } + + const tags = [["d", dTagValue]]; + hexPubkeys.forEach((hex) => { + tags.push(["p", hex]); + }); + + return { + kind: 30000, + pubkey: actorHex, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", + }; +} + +function publishToRelay(url, signedEvent, listKey) { + return new Promise((resolve) => { + let settled = false; + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + resolve({ url, success: false, error: new Error("timeout") }); + } + }, PUBLISH_TIMEOUT_MS); + + const finalize = (success, error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve({ url, success, error }); + }; + + try { + const pub = nostrClient.pool.publish([url], signedEvent); + if (!pub) { + finalize(false, new Error("publish returned no result")); + return; + } + + if (typeof pub.on === "function") { + pub.on("ok", () => finalize(true)); + pub.on("seen", () => finalize(true)); + pub.on("failed", (reason) => { + const error = + reason instanceof Error ? reason : new Error(String(reason || "failed")); + finalize(false, error); + }); + return; + } + + if (typeof pub.then === "function") { + pub.then(() => finalize(true)).catch((error) => finalize(false, error)); + return; + } + } catch (error) { + finalize(false, error); + return; + } + + finalize(true); + }).then((result) => { + if (!result.success && isDevMode) { + console.warn( + `[adminListStore] Publish failed for ${listKey} on ${result.url}:`, + result.error + ); + } + return result; }); } async function persistNostrState(actorNpub, updates = {}) { - const relays = ensureNostrReady(); + ensureNostrReady(); const extension = window?.nostr; if (!extension || typeof extension.signEvent !== "function") { throw createError( @@ -343,95 +447,56 @@ async function persistNostrState(actorNpub, updates = {}) { throw createError("invalid npub", "Unable to decode the actor npub."); } - const entries = Object.entries(updates) - .filter(([key, value]) => { - return key in LIST_IDENTIFIERS && Array.isArray(value); - }) - .map(([key, value]) => { - const base = sanitizeNpubList(value); - if (key === "blacklist") { - const guardSet = new Set([ - normalizeNpub(ADMIN_SUPER_NPUB), - ...ADMIN_EDITORS_NPUBS.map(normalizeNpub), - ...sanitizeNpubList(updates.editors || []), - ]); - const whitelistSet = new Set( - sanitizeNpubList(updates.whitelist || []) - ); - const sanitizedBlacklist = base.filter((npub) => { - const normalized = normalizeNpub(npub); - if (!normalized) { - return false; - } - if (guardSet.has(normalized)) { - return false; - } - if (whitelistSet.has(normalized)) { - return false; - } - return true; - }); - return [key, sanitizedBlacklist]; - } + const sanitizedUpdates = {}; - if (key === "editors") { - const sanitizedEditors = base.filter((npub) => { - const normalized = normalizeNpub(npub); - return normalized && normalized !== normalizeNpub(ADMIN_SUPER_NPUB); - }); - return [key, sanitizedEditors]; - } - - if (key === "whitelist") { - const mergedWhitelist = sanitizeNpubList(base); - return [key, mergedWhitelist]; - } - - return [key, base]; + if (Array.isArray(updates.editors)) { + sanitizedUpdates.editors = sanitizeNpubList(updates.editors).filter((npub) => { + const normalized = normalizeNpub(npub); + return normalized && normalized !== normalizeNpub(ADMIN_SUPER_NPUB); }); + } + + if (Array.isArray(updates.whitelist)) { + sanitizedUpdates.whitelist = sanitizeNpubList(updates.whitelist); + } + + if (Array.isArray(updates.blacklist)) { + const whitelistSet = new Set( + (sanitizedUpdates.whitelist || []).map(normalizeNpub) + ); + const editorGuard = new Set([ + normalizeNpub(ADMIN_SUPER_NPUB), + ...ADMIN_EDITORS_NPUBS.map(normalizeNpub), + ...((sanitizedUpdates.editors || []).map(normalizeNpub) || []), + ]); + + sanitizedUpdates.blacklist = sanitizeNpubList(updates.blacklist).filter( + (npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return false; + } + if (whitelistSet.has(normalized)) { + return false; + } + if (editorGuard.has(normalized)) { + return false; + } + return true; + } + ); + } + + const entries = Object.entries(sanitizedUpdates).filter(([, value]) => + Array.isArray(value) + ); if (!entries.length) { return; } - const now = Math.floor(Date.now() / 1000); - for (const [listKey, npubs] of entries) { - const identifier = LIST_IDENTIFIERS[listKey]; - const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; - - const hexPubkeys = Array.from( - new Set( - npubs.map((npub) => decodeNpubToHex(npub)).filter((hex) => !!hex) - ) - ); - - const tags = [["d", dTagValue]]; - hexPubkeys.forEach((hex) => { - tags.push(["p", hex]); - }); - - // Explicitly keep the super admin on the whitelist and editors lists when publishing. - if (listKey === "whitelist" || listKey === "editors") { - try { - const superHex = decodeNpubToHex(ADMIN_SUPER_NPUB); - if (superHex && !hexPubkeys.includes(superHex)) { - tags.push(["p", superHex]); - } - } catch (error) { - if (isDevMode) { - console.warn("Failed to ensure super admin presence:", error); - } - } - } - - const event = { - kind: 30000, - pubkey: actorHex, - created_at: now, - tags, - content: "", - }; + const event = buildListEvent(listKey, npubs, actorHex); let signedEvent; try { @@ -440,24 +505,9 @@ async function persistNostrState(actorNpub, updates = {}) { throw createError("signature-failed", "Failed to sign admin list event.", error); } + const relays = ensureNostrReady(); const publishResults = await Promise.all( - relays.map(async (url) => { - try { - await nostrClient.pool.publish([url], signedEvent); - if (isDevMode) { - console.log(`[adminListStore] Published ${listKey} list to ${url}`); - } - return { url, success: true }; - } catch (error) { - if (isDevMode) { - console.warn( - `[adminListStore] Failed to publish ${listKey} list to ${url}:`, - error - ); - } - return { url, success: false, error }; - } - }) + relays.map((url) => publishToRelay(url, signedEvent, listKey)) ); if (!publishResults.some((result) => result.success)) { @@ -466,19 +516,38 @@ async function persistNostrState(actorNpub, updates = {}) { `Failed to publish ${listKey} list to any relay.` ); } + + clearLegacyStorageFor(listKey); } } export async function loadAdminState() { - if (ADMIN_LIST_MODE === "nostr") { - return loadNostrState(); + if (ADMIN_LIST_MODE !== "nostr" && isDevMode) { + console.warn( + `[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Defaulting to remote Nostr lists.` + ); } - return loadLocalState(); + + const state = await loadNostrState(); + if (!hasAnyEntries(state)) { + const legacy = loadLegacyAdminState(); + if (legacy && isDevMode) { + console.warn( + "[adminListStore] Ignoring legacy admin lists because remote mode is enforced. Publish them to Nostr to retain access.", + legacy + ); + } + } + + return state; } export async function persistAdminState(actorNpub, updates) { - if (ADMIN_LIST_MODE === "nostr") { - return persistNostrState(actorNpub, updates); + if (ADMIN_LIST_MODE !== "nostr" && isDevMode) { + console.warn( + `[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Remote lists are enforced.` + ); } - return persistLocalState(actorNpub, updates); + + return persistNostrState(actorNpub, updates); } diff --git a/js/config.js b/js/config.js index f35124bc..bffab224 100644 --- a/js/config.js +++ b/js/config.js @@ -3,10 +3,10 @@ export const isDevMode = true; // Set to false for production // ----------------------------------------------------------------------------- -// Admin governance (v1 local mode) +// Admin governance — production defaults rely on remote Nostr lists // ----------------------------------------------------------------------------- -export const ADMIN_LIST_MODE = "local"; // Future: "nostr" +export const ADMIN_LIST_MODE = "nostr"; export const ADMIN_SUPER_NPUB = "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx"; export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional)