From 875cee44288f7d4b7253b22e173cba6b2a9bc59d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Sun, 28 Sep 2025 18:38:10 -0400
Subject: [PATCH] Add admin governance roles and UI
---
components/profile-modal.html | 145 ++++++++
js/accessControl.js | 486 ++++++++++++++++-----------
js/app.js | 599 ++++++++++++++++++++++++++++++++--
js/channelProfile.js | 40 ++-
js/config.js | 37 ++-
js/lists.js | 6 +-
6 files changed, 1086 insertions(+), 227 deletions(-)
diff --git a/components/profile-modal.html b/components/profile-modal.html
index 5a3f0f85..dc4c80e9 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -88,6 +88,15 @@
>
Blocked
+
@@ -176,6 +185,142 @@
+
+
+
Platform moderation
+
+ Manage BitVid’s moderator roles and creator allow/block lists. Changes apply instantly across the app.
+
+
+
+
+
+
+
+
Whitelist
+
+
+
+ Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist.
+
+
+
+
+
+
+ No whitelisted creators yet.
+
+
+
+
+
+
+
Blacklist
+
+
+ Creators on the blacklist are hidden from BitVid everywhere. Adding someone removes them from the whitelist.
+
+
+
+
+
+
+ No blacklisted creators yet.
+
+
+
+
diff --git a/js/accessControl.js b/js/accessControl.js
index 4cc051ba..6e7c40ee 100644
--- a/js/accessControl.js
+++ b/js/accessControl.js
@@ -1,228 +1,334 @@
// js/accessControl.js
-import { isDevMode, isWhitelistEnabled } from "./config.js";
-import { initialWhitelist, initialBlacklist } from "./lists.js";
+import {
+ ADMIN_EDITORS_NPUBS,
+ ADMIN_SUPER_NPUB,
+ getWhitelistMode,
+ setWhitelistMode as persistWhitelistMode,
+ ADMIN_WHITELIST_MODE_STORAGE_KEY,
+} from "./config.js";
+import {
+ ADMIN_INITIAL_BLACKLIST,
+ ADMIN_INITIAL_WHITELIST,
+} from "./lists.js";
-class AccessControl {
- constructor() {
- // Debug logging for initialization
- console.log("DEBUG: AccessControl constructor called");
+const ADMIN_EDITORS_KEY = "bitvid_admin_editors";
+const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist";
+const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist";
- const { data: storedWhitelist, status: whitelistStatus } = this.loadWhitelist();
- const { data: storedBlacklist, status: blacklistStatus } = this.loadBlacklist();
+const LEGACY_WHITELIST_KEY = "bitvid_whitelist";
+const LEGACY_BLACKLIST_KEY = "bitvid_blacklist";
- if (storedWhitelist !== null) {
- this.whitelist = new Set(storedWhitelist);
- } else {
- this.whitelist = new Set(initialWhitelist);
- this.saveWhitelist();
- if (whitelistStatus && whitelistStatus !== "missing") {
- console.warn(
- `Whitelist storage ${whitelistStatus}. Falling back to initial whitelist.`
- );
- }
- }
-
- if (storedBlacklist !== null) {
- this.blacklist = new Set(storedBlacklist);
- } else {
- this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
- this.saveBlacklist();
- if (blacklistStatus && blacklistStatus !== "missing") {
- console.warn(
- `Blacklist storage ${blacklistStatus}. Falling back to initial blacklist.`
- );
- }
- }
-
- // Debug the sets
- console.log("DEBUG: Whitelist after initialization:", [...this.whitelist]);
- console.log("DEBUG: Blacklist after initialization:", [...this.blacklist]);
- }
-
- // Rest of the class remains the same...
- loadWhitelist() {
- try {
- const stored = localStorage.getItem("bitvid_whitelist");
- if (!stored) {
- if (isDevMode) console.log("No stored whitelist found in localStorage.");
- return { data: null, status: "missing" };
- }
-
- const parsed = JSON.parse(stored);
- const sanitized = this.sanitizeList(parsed, "whitelist");
- return {
- data: sanitized,
- status: sanitized === null ? "invalid" : "ok",
- };
- } catch (error) {
- console.error("Error loading whitelist, using defaults:", error);
- return { data: null, status: "error" };
- }
- }
-
- loadBlacklist() {
- try {
- const stored = localStorage.getItem("bitvid_blacklist");
- if (!stored) {
- if (isDevMode) console.log("No stored blacklist found in localStorage.");
- return { data: null, status: "missing" };
- }
-
- const parsed = JSON.parse(stored);
- const sanitized = this.sanitizeList(parsed, "blacklist");
- return {
- data: sanitized,
- status: sanitized === null ? "invalid" : "ok",
- };
- } catch (error) {
- console.error("Error loading blacklist, using defaults:", error);
- return { data: null, status: "error" };
- }
- }
-
- sanitizeList(list, listName) {
- if (!Array.isArray(list)) {
- console.warn(
- `Stored ${listName} is not an array. Received:`,
- list
- );
+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;
+ }
+}
- const sanitized = [];
- let invalidEntries = 0;
+function saveJSONList(key, values) {
+ try {
+ localStorage.setItem(key, JSON.stringify(values));
+ } catch (error) {
+ console.warn(`Failed to persist list for ${key}:`, error);
+ }
+}
- list.forEach((value) => {
- if (typeof value !== "string") {
- invalidEntries += 1;
- return;
+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())
+ );
+}
- const trimmed = value.trim();
- if (trimmed) {
- sanitized.push(trimmed);
- } else {
- invalidEntries += 1;
- }
- });
-
- if (invalidEntries > 0) {
- console.warn(
- `Stored ${listName} contained ${invalidEntries} invalid entr${
- invalidEntries === 1 ? "y" : "ies"
- }. Sanitized list:`,
- sanitized
- );
- }
+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;
}
- saveWhitelist() {
- try {
- localStorage.setItem(
- "bitvid_whitelist",
- JSON.stringify([...this.whitelist])
- );
- } catch (error) {
- console.error("Error saving whitelist:", error);
- }
+ const sanitizedFallback = dedupeNpubs(fallback);
+ if (sanitizedFallback.length) {
+ saveJSONList(targetKey, sanitizedFallback);
+ }
+ return sanitizedFallback;
+}
+
+function isValidNpub(npub) {
+ if (typeof npub !== "string") {
+ return false;
+ }
+ const trimmed = npub.trim();
+ if (!trimmed) {
+ return false;
}
- saveBlacklist() {
- try {
- localStorage.setItem(
- "bitvid_blacklist",
- JSON.stringify([...this.blacklist])
- );
- } catch (error) {
- console.error("Error saving blacklist:", error);
- }
+ try {
+ const decoded = window?.NostrTools?.nip19?.decode(trimmed);
+ return decoded?.type === "npub";
+ } catch (error) {
+ return false;
+ }
+}
+
+class AccessControl {
+ constructor() {
+ this.editors = new Set();
+ this.whitelist = new Set();
+ this.blacklist = new Set();
+ this.whitelistEnabled = getWhitelistMode();
+ this.refresh();
}
- addToWhitelist(npub) {
- if (!this.isValidNpub(npub)) {
- throw new Error("Invalid npub format");
- }
- this.whitelist.add(npub);
- this.saveWhitelist();
- if (isDevMode) console.log(`Added ${npub} to whitelist`);
+ refresh() {
+ this.editors = new Set(
+ dedupeNpubs([
+ ...ADMIN_EDITORS_NPUBS,
+ ...migrateLegacyList(ADMIN_EDITORS_KEY, null, []),
+ ])
+ );
+
+ 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();
}
- removeFromWhitelist(npub) {
- this.whitelist.delete(npub);
- this.saveWhitelist();
- if (isDevMode) console.log(`Removed ${npub} from whitelist`);
+ whitelistMode() {
+ return this.whitelistEnabled;
}
- addToBlacklist(npub) {
- if (!this.isValidNpub(npub)) {
- throw new Error("Invalid npub format");
- }
- this.blacklist.add(npub);
- this.saveBlacklist();
- if (isDevMode) console.log(`Added ${npub} to blacklist`);
+ isSuperAdmin(npub) {
+ const normalized = normalizeNpub(npub);
+ return normalized ? normalized === ADMIN_SUPER_NPUB : false;
}
- removeFromBlacklist(npub) {
- this.blacklist.delete(npub);
- this.saveBlacklist();
- if (isDevMode) console.log(`Removed ${npub} from blacklist`);
- }
-
- isWhitelisted(npub) {
- const result = this.whitelist.has(npub);
- if (isDevMode)
- console.log(
- `Checking if ${npub} is whitelisted:`,
- result,
- "Current whitelist:",
- [...this.whitelist]
- );
- return result;
- }
-
- isBlacklisted(npub) {
- return this.blacklist.has(npub);
- }
-
- canAccess(npub) {
- if (this.isBlacklisted(npub)) {
+ isAdminEditor(npub) {
+ const normalized = normalizeNpub(npub);
+ if (!normalized) {
return false;
}
- const canAccess = !isWhitelistEnabled || this.isWhitelisted(npub);
- if (isDevMode) console.log(`Checking access for ${npub}:`, canAccess);
- return canAccess;
- }
-
- filterVideos(videos) {
- return videos.filter((video) => {
- try {
- const npub = window.NostrTools.nip19.npubEncode(video.pubkey);
- return !this.isBlacklisted(npub);
- } catch (error) {
- console.error("Error filtering video:", error);
- return false;
- }
- });
- }
-
- isValidNpub(npub) {
- try {
- return npub.startsWith("npub1") && npub.length === 63;
- } catch (error) {
- return false;
+ if (this.isSuperAdmin(normalized)) {
+ return true;
}
+ return this.editors.has(normalized);
+ }
+
+ canEditAdminLists(npub) {
+ return this.isAdminEditor(npub);
}
getWhitelist() {
- return [...this.whitelist];
+ return Array.from(this.whitelist);
}
getBlacklist() {
- return [...this.blacklist];
+ return Array.from(this.blacklist);
+ }
+
+ getEditors() {
+ return Array.from(this.editors);
+ }
+
+ addModerator(requestorNpub, moderatorNpub) {
+ if (!this.isSuperAdmin(requestorNpub)) {
+ return { ok: false, error: "forbidden" };
+ }
+ if (!isValidNpub(moderatorNpub)) {
+ return { ok: false, error: "invalid npub" };
+ }
+
+ const normalized = normalizeNpub(moderatorNpub);
+ if (!normalized || normalized === ADMIN_SUPER_NPUB) {
+ return { ok: false, error: "immutable" };
+ }
+
+ this.editors.add(normalized);
+ saveJSONList(ADMIN_EDITORS_KEY, this.getEditors());
+ this.refresh();
+ return { ok: true };
+ }
+
+ removeModerator(requestorNpub, moderatorNpub) {
+ if (!this.isSuperAdmin(requestorNpub)) {
+ return { ok: false, error: "forbidden" };
+ }
+
+ const normalized = normalizeNpub(moderatorNpub);
+ if (!normalized || normalized === ADMIN_SUPER_NPUB) {
+ return { ok: false, error: "immutable" };
+ }
+
+ this.editors.delete(normalized);
+ saveJSONList(ADMIN_EDITORS_KEY, this.getEditors());
+ this.refresh();
+ return { ok: true };
+ }
+
+ addToWhitelist(actorNpub, targetNpub) {
+ if (!this.canEditAdminLists(actorNpub)) {
+ return { ok: false, error: "forbidden" };
+ }
+ if (!isValidNpub(targetNpub)) {
+ return { ok: false, error: "invalid npub" };
+ }
+
+ const normalized = normalizeNpub(targetNpub);
+ if (!normalized) {
+ 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 };
+ }
+
+ removeFromWhitelist(actorNpub, targetNpub) {
+ if (!this.canEditAdminLists(actorNpub)) {
+ return { ok: false, error: "forbidden" };
+ }
+
+ const normalized = normalizeNpub(targetNpub);
+ if (!normalized) {
+ return { ok: false, error: "invalid npub" };
+ }
+
+ this.whitelist.delete(normalized);
+ saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist());
+ this.refresh();
+ return { ok: true };
+ }
+
+ addToBlacklist(actorNpub, targetNpub) {
+ if (!this.canEditAdminLists(actorNpub)) {
+ return { ok: false, error: "forbidden" };
+ }
+ if (!isValidNpub(targetNpub)) {
+ return { ok: false, error: "invalid npub" };
+ }
+
+ const normalized = normalizeNpub(targetNpub);
+ if (!normalized) {
+ return { ok: false, error: "invalid npub" };
+ }
+
+ if (this.isSuperAdmin(normalized) || this.isAdminEditor(normalized)) {
+ 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 };
+ }
+
+ removeFromBlacklist(actorNpub, targetNpub) {
+ if (!this.canEditAdminLists(actorNpub)) {
+ return { ok: false, error: "forbidden" };
+ }
+
+ const normalized = normalizeNpub(targetNpub);
+ if (!normalized) {
+ return { ok: false, error: "invalid npub" };
+ }
+
+ this.blacklist.delete(normalized);
+ saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist());
+ this.refresh();
+ return { ok: true };
+ }
+
+ setWhitelistMode(actorNpub, enabled) {
+ if (!this.isSuperAdmin(actorNpub)) {
+ return { ok: false, error: "forbidden" };
+ }
+ persistWhitelistMode(!!enabled);
+ this.whitelistEnabled = !!enabled;
+ return { ok: true };
+ }
+
+ isBlacklisted(npub) {
+ const normalized = normalizeNpub(npub);
+ return normalized ? this.blacklist.has(normalized) : false;
+ }
+
+ canAccess(candidate) {
+ let npub = "";
+
+ if (typeof candidate === "string") {
+ npub = candidate;
+ } else if (candidate && typeof candidate === "object") {
+ if (typeof candidate.npub === "string") {
+ npub = candidate.npub;
+ } else if (typeof candidate.pubkey === "string") {
+ try {
+ npub = window.NostrTools.nip19.npubEncode(candidate.pubkey);
+ } catch (error) {
+ npub = candidate.pubkey;
+ }
+ }
+ }
+
+ const normalized = normalizeNpub(npub);
+ if (!normalized) {
+ return false;
+ }
+
+ if (this.blacklist.has(normalized)) {
+ return false;
+ }
+
+ if (this.whitelistEnabled && !this.whitelist.has(normalized)) {
+ return false;
+ }
+
+ return true;
}
}
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/app.js b/js/app.js
index 7509f987..b12d9236 100644
--- a/js/app.js
+++ b/js/app.js
@@ -3,8 +3,8 @@
import { loadView } from "./viewManager.js";
import { nostrClient } from "./nostr.js";
import { torrentClient } from "./webtorrent.js";
-import { isDevMode } from "./config.js";
-import { isWhitelistEnabled } from "./config.js";
+import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js";
+import { accessControl } from "./accessControl.js";
import { safeDecodeMagnet } from "./magnetUtils.js";
import { extractMagnetHints, normalizeAndAugmentMagnet } from "./magnet.js";
import { deriveTorrentPlaybackConfig } from "./playbackUtils.js";
@@ -12,11 +12,7 @@ import { URL_FIRST_ENABLED } from "./constants.js";
import { trackVideoView } from "./analytics.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
-import {
- initialWhitelist,
- initialBlacklist,
- initialEventBlacklist,
-} from "./lists.js";
+import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js";
import {
loadR2Settings,
saveR2Settings,
@@ -503,11 +499,13 @@ class bitvidApp {
account: null,
relays: null,
blocked: null,
+ admin: null,
};
this.profilePaneElements = {
account: null,
relays: null,
blocked: null,
+ admin: null,
};
this.profileRelayList = null;
this.profileBlockedList = null;
@@ -515,10 +513,28 @@ class bitvidApp {
this.profileRelayInput = null;
this.profileAddRelayBtn = null;
this.profileRestoreRelaysBtn = null;
+ this.adminModeratorInput = null;
+ this.adminAddModeratorBtn = null;
+ this.adminModeratorList = null;
+ this.adminModeratorsEmpty = null;
+ this.adminModeratorsSection = null;
+ this.adminWhitelistInput = null;
+ this.adminAddWhitelistBtn = null;
+ this.adminWhitelistList = null;
+ this.adminWhitelistEmpty = null;
+ this.adminWhitelistSection = null;
+ this.adminWhitelistModeToggle = null;
+ this.adminWhitelistModeWrapper = null;
+ this.adminBlacklistInput = null;
+ this.adminAddBlacklistBtn = null;
+ this.adminBlacklistList = null;
+ this.adminBlacklistEmpty = null;
+ this.adminBlacklistSection = null;
this.lastFocusedBeforeProfileModal = null;
this.boundProfileModalKeydown = null;
this.boundProfileModalFocusIn = null;
this.profileModalFocusables = [];
+ this.currentUserNpub = null;
// Upload modal elements
this.uploadButton = document.getElementById("uploadButton") || null;
@@ -655,7 +671,7 @@ class bitvidApp {
// Build a set of blacklisted event IDs (hex) from nevent strings, skipping empties
this.blacklistedEventIds = new Set();
- for (const neventStr of initialEventBlacklist) {
+ for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) {
// Skip any empty or obviously invalid strings
if (!neventStr || neventStr.trim().length < 8) {
continue;
@@ -3262,12 +3278,16 @@ class bitvidApp {
document.getElementById("profileNavRelays") || null;
this.profileNavButtons.blocked =
document.getElementById("profileNavBlocked") || null;
+ this.profileNavButtons.admin =
+ document.getElementById("profileNavAdmin") || null;
this.profilePaneElements.account =
document.getElementById("profilePaneAccount") || null;
this.profilePaneElements.relays =
document.getElementById("profilePaneRelays") || null;
this.profilePaneElements.blocked =
document.getElementById("profilePaneBlocked") || null;
+ this.profilePaneElements.admin =
+ document.getElementById("profilePaneAdmin") || null;
this.profileRelayList = document.getElementById("relayList") || null;
this.profileBlockedList = document.getElementById("blockedList") || null;
this.profileBlockedEmpty =
@@ -3276,6 +3296,40 @@ class bitvidApp {
this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null;
this.profileRestoreRelaysBtn =
document.getElementById("restoreRelaysBtn") || null;
+ this.adminModeratorsSection =
+ document.getElementById("adminModeratorsSection") || null;
+ this.adminModeratorsEmpty =
+ document.getElementById("adminModeratorsEmpty") || null;
+ this.adminModeratorList =
+ document.getElementById("adminModeratorList") || null;
+ this.adminModeratorInput =
+ document.getElementById("adminModeratorInput") || null;
+ this.adminAddModeratorBtn =
+ document.getElementById("adminAddModeratorBtn") || null;
+ this.adminWhitelistSection =
+ document.getElementById("adminWhitelistSection") || null;
+ this.adminWhitelistEmpty =
+ document.getElementById("adminWhitelistEmpty") || null;
+ this.adminWhitelistList =
+ document.getElementById("adminWhitelistList") || null;
+ this.adminWhitelistInput =
+ 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 =
+ document.getElementById("adminBlacklistEmpty") || null;
+ this.adminBlacklistList =
+ document.getElementById("adminBlacklistList") || null;
+ this.adminBlacklistInput =
+ document.getElementById("adminBlacklistInput") || null;
+ this.adminAddBlacklistBtn =
+ document.getElementById("adminAddBlacklistBtn") || null;
// Wire up
if (this.closeProfileModal && !this.closeProfileModal.dataset.bound) {
@@ -3331,9 +3385,89 @@ class bitvidApp {
});
}
+ if (
+ this.adminAddModeratorBtn &&
+ this.adminAddModeratorBtn.dataset.bound !== "true"
+ ) {
+ this.adminAddModeratorBtn.dataset.bound = "true";
+ this.adminAddModeratorBtn.addEventListener("click", () => {
+ this.handleAddModerator();
+ });
+ }
+ if (
+ this.adminModeratorInput &&
+ this.adminModeratorInput.dataset.bound !== "true"
+ ) {
+ this.adminModeratorInput.dataset.bound = "true";
+ this.adminModeratorInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ this.handleAddModerator();
+ }
+ });
+ }
+
+ if (
+ this.adminAddWhitelistBtn &&
+ this.adminAddWhitelistBtn.dataset.bound !== "true"
+ ) {
+ this.adminAddWhitelistBtn.dataset.bound = "true";
+ this.adminAddWhitelistBtn.addEventListener("click", () => {
+ this.handleAdminListMutation("whitelist", "add");
+ });
+ }
+ if (
+ this.adminWhitelistInput &&
+ this.adminWhitelistInput.dataset.bound !== "true"
+ ) {
+ this.adminWhitelistInput.dataset.bound = "true";
+ this.adminWhitelistInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ this.handleAdminListMutation("whitelist", "add");
+ }
+ });
+ }
+
+ if (
+ this.adminAddBlacklistBtn &&
+ this.adminAddBlacklistBtn.dataset.bound !== "true"
+ ) {
+ this.adminAddBlacklistBtn.dataset.bound = "true";
+ this.adminAddBlacklistBtn.addEventListener("click", () => {
+ this.handleAdminListMutation("blacklist", "add");
+ });
+ }
+ if (
+ this.adminBlacklistInput &&
+ this.adminBlacklistInput.dataset.bound !== "true"
+ ) {
+ this.adminBlacklistInput.dataset.bound = "true";
+ this.adminBlacklistInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ this.handleAdminListMutation("blacklist", "add");
+ }
+ });
+ }
+
+ 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();
+ this.refreshAdminPaneState();
console.log("Profile modal initialization successful");
return true;
@@ -3346,12 +3480,27 @@ class bitvidApp {
selectProfilePane(name = "account") {
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
- const target = ["account", "relays", "blocked"].includes(normalized)
+ const availableKeys = Object.keys(this.profilePaneElements).filter((key) => {
+ const pane = this.profilePaneElements[key];
+ if (!(pane instanceof HTMLElement)) {
+ return false;
+ }
+ const button = this.profileNavButtons[key];
+ if (button instanceof HTMLElement && button.classList.contains("hidden")) {
+ return false;
+ }
+ return true;
+ });
+
+ const fallbackTarget = availableKeys.includes("account")
+ ? "account"
+ : availableKeys[0] || "account";
+ const target = availableKeys.includes(normalized)
? normalized
- : "account";
+ : fallbackTarget;
Object.entries(this.profilePaneElements).forEach(([key, pane]) => {
- if (!pane) {
+ if (!(pane instanceof HTMLElement)) {
return;
}
const isActive = key === target;
@@ -3360,7 +3509,7 @@ class bitvidApp {
});
Object.entries(this.profileNavButtons).forEach(([key, button]) => {
- if (!button) {
+ if (!(button instanceof HTMLElement)) {
return;
}
const isActive = key === target;
@@ -3407,6 +3556,8 @@ class bitvidApp {
return;
}
+ this.refreshAdminPaneState();
+
this.selectProfilePane("account");
this.populateProfileRelays();
this.populateBlockedList();
@@ -3670,6 +3821,384 @@ class bitvidApp {
});
}
+ refreshAdminPaneState() {
+ accessControl.refresh();
+ const adminNav = this.profileNavButtons.admin;
+ const adminPane = this.profilePaneElements.admin;
+
+ const actorNpub = this.getCurrentUserNpub();
+ const canEdit = !!actorNpub && accessControl.canEditAdminLists(actorNpub);
+ const isSuperAdmin = !!actorNpub && accessControl.isSuperAdmin(actorNpub);
+
+ if (adminNav instanceof HTMLElement) {
+ adminNav.classList.toggle("hidden", !canEdit);
+ if (!canEdit) {
+ adminNav.setAttribute("aria-selected", "false");
+ }
+ }
+
+ if (adminPane instanceof HTMLElement) {
+ adminPane.classList.toggle("hidden", !canEdit);
+ adminPane.setAttribute("aria-hidden", (!canEdit).toString());
+ }
+
+ if (!canEdit) {
+ this.clearAdminLists();
+ if (typeof actorNpub !== "string" || !actorNpub) {
+ this.currentUserNpub = null;
+ }
+ if (adminNav instanceof HTMLElement && adminNav.classList.contains("bg-gray-800")) {
+ this.selectProfilePane("account");
+ }
+ return;
+ }
+
+ if (this.adminModeratorsSection instanceof HTMLElement) {
+ 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();
+ }
+
+ clearAdminLists() {
+ if (this.adminModeratorList) {
+ this.adminModeratorList.innerHTML = "";
+ }
+ if (this.adminWhitelistList) {
+ this.adminWhitelistList.innerHTML = "";
+ }
+ if (this.adminBlacklistList) {
+ this.adminBlacklistList.innerHTML = "";
+ }
+ if (this.adminModeratorsEmpty instanceof HTMLElement) {
+ this.adminModeratorsEmpty.classList.remove("hidden");
+ }
+ if (this.adminWhitelistEmpty instanceof HTMLElement) {
+ this.adminWhitelistEmpty.classList.remove("hidden");
+ }
+ if (this.adminBlacklistEmpty instanceof HTMLElement) {
+ this.adminBlacklistEmpty.classList.remove("hidden");
+ }
+ if (this.adminWhitelistModeToggle instanceof HTMLInputElement) {
+ this.adminWhitelistModeToggle.checked = accessControl.whitelistMode();
+ }
+ }
+
+ renderAdminList(listEl, emptyEl, entries, options = {}) {
+ if (!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement)) {
+ return;
+ }
+
+ const { onRemove, removeLabel = "Remove", confirmMessage, removable = true } =
+ options;
+
+ listEl.innerHTML = "";
+ const values = Array.isArray(entries) ? [...entries] : [];
+ values.sort((a, b) => a.localeCompare(b));
+
+ if (!values.length) {
+ emptyEl.classList.remove("hidden");
+ listEl.classList.add("hidden");
+ return;
+ }
+
+ emptyEl.classList.add("hidden");
+ listEl.classList.remove("hidden");
+
+ values.forEach((npub) => {
+ const item = document.createElement("li");
+ item.className =
+ "flex flex-col gap-2 rounded-lg bg-gray-800 px-4 py-3 sm:flex-row sm:items-center sm:justify-between";
+
+ const label = document.createElement("p");
+ label.className = "text-sm font-medium text-gray-100 break-all";
+ label.textContent = npub;
+ item.appendChild(label);
+
+ if (removable && typeof onRemove === "function") {
+ const removeBtn = document.createElement("button");
+ removeBtn.type = "button";
+ removeBtn.className =
+ "self-start rounded-md bg-gray-700 px-3 py-1 text-xs font-medium text-gray-100 transition hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900";
+ removeBtn.textContent = removeLabel;
+ removeBtn.addEventListener("click", () => {
+ if (confirmMessage) {
+ const message = confirmMessage.replace("{npub}", npub);
+ if (!window.confirm(message)) {
+ return;
+ }
+ }
+ removeBtn.disabled = true;
+ removeBtn.setAttribute("aria-busy", "true");
+ onRemove(npub, removeBtn);
+ });
+ item.appendChild(removeBtn);
+ }
+
+ listEl.appendChild(item);
+ });
+ }
+
+ populateAdminLists() {
+ const actorNpub = this.getCurrentUserNpub();
+ if (!actorNpub || !accessControl.canEditAdminLists(actorNpub)) {
+ this.clearAdminLists();
+ return;
+ }
+
+ const isSuperAdmin = accessControl.isSuperAdmin(actorNpub);
+ const editors = accessControl
+ .getEditors()
+ .filter((npub) => npub && npub !== ADMIN_SUPER_NPUB);
+ const whitelist = accessControl.getWhitelist();
+ const blacklist = accessControl.getBlacklist();
+
+ this.renderAdminList(this.adminModeratorList, this.adminModeratorsEmpty, editors, {
+ onRemove: (npub, button) => this.handleRemoveModerator(npub, button),
+ removeLabel: "Remove",
+ confirmMessage:
+ "Remove moderator {npub}? They will immediately lose access to the admin panel.",
+ removable: isSuperAdmin,
+ });
+
+ this.renderAdminList(this.adminWhitelistList, this.adminWhitelistEmpty, whitelist, {
+ onRemove: (npub, button) =>
+ this.handleAdminListMutation("whitelist", "remove", npub, button),
+ removeLabel: "Remove",
+ confirmMessage: "Remove {npub} from the whitelist?",
+ removable: true,
+ });
+
+ this.renderAdminList(this.adminBlacklistList, this.adminBlacklistEmpty, blacklist, {
+ onRemove: (npub, button) =>
+ this.handleAdminListMutation("blacklist", "remove", npub, button),
+ removeLabel: "Unblock",
+ confirmMessage: "Remove {npub} from the blacklist?",
+ removable: true,
+ });
+ }
+
+ getCurrentUserNpub() {
+ if (typeof this.currentUserNpub === "string" && this.currentUserNpub) {
+ return this.currentUserNpub;
+ }
+
+ if (!this.pubkey) {
+ return null;
+ }
+
+ const encoded = this.safeEncodeNpub(this.pubkey);
+ if (encoded) {
+ this.currentUserNpub = encoded;
+ }
+ return this.currentUserNpub;
+ }
+
+ ensureAdminActor(requireSuperAdmin = false) {
+ const actorNpub = this.getCurrentUserNpub();
+ if (!actorNpub) {
+ this.showError("Please login with a Nostr account to manage admin settings.");
+ return null;
+ }
+ if (!accessControl.canEditAdminLists(actorNpub)) {
+ this.showError("You do not have permission to manage BitVid moderation lists.");
+ return null;
+ }
+ if (requireSuperAdmin && !accessControl.isSuperAdmin(actorNpub)) {
+ this.showError("Only the Super Admin can manage moderators or whitelist mode.");
+ return null;
+ }
+ return actorNpub;
+ }
+
+ handleAddModerator() {
+ const actorNpub = this.ensureAdminActor(true);
+ if (!actorNpub || !this.adminModeratorInput) {
+ return;
+ }
+
+ const value = this.adminModeratorInput.value.trim();
+ if (!value) {
+ this.showError("Enter an npub to add as a moderator.");
+ return;
+ }
+
+ if (this.adminAddModeratorBtn) {
+ this.adminAddModeratorBtn.disabled = true;
+ this.adminAddModeratorBtn.setAttribute("aria-busy", "true");
+ }
+
+ try {
+ const result = accessControl.addModerator(actorNpub, value);
+ if (!result.ok) {
+ this.showError(this.describeAdminError(result.error));
+ return;
+ }
+
+ this.adminModeratorInput.value = "";
+ this.showSuccess("Moderator added successfully.");
+ this.onAccessControlUpdated();
+ } finally {
+ if (this.adminAddModeratorBtn) {
+ this.adminAddModeratorBtn.disabled = false;
+ this.adminAddModeratorBtn.removeAttribute("aria-busy");
+ }
+ }
+ }
+
+ handleRemoveModerator(npub, button) {
+ const actorNpub = this.ensureAdminActor(true);
+ if (!actorNpub) {
+ if (button instanceof HTMLElement) {
+ button.disabled = false;
+ button.removeAttribute("aria-busy");
+ }
+ return;
+ }
+
+ const result = accessControl.removeModerator(actorNpub, npub);
+ if (!result.ok) {
+ this.showError(this.describeAdminError(result.error));
+ if (button instanceof HTMLElement) {
+ button.disabled = false;
+ button.removeAttribute("aria-busy");
+ }
+ return;
+ }
+
+ this.showSuccess("Moderator removed.");
+ this.onAccessControlUpdated();
+ }
+
+ handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) {
+ const actorNpub = this.ensureAdminActor(false);
+ if (!actorNpub) {
+ if (sourceButton instanceof HTMLElement) {
+ sourceButton.disabled = false;
+ sourceButton.removeAttribute("aria-busy");
+ }
+ return;
+ }
+
+ const isWhitelist = listType === "whitelist";
+ const input = isWhitelist ? this.adminWhitelistInput : this.adminBlacklistInput;
+ const addButton = isWhitelist ? this.adminAddWhitelistBtn : this.adminAddBlacklistBtn;
+ const isAdd = action === "add";
+
+ let target = typeof explicitNpub === "string" ? explicitNpub.trim() : "";
+ if (!target && input instanceof HTMLInputElement) {
+ target = input.value.trim();
+ }
+
+ if (isAdd && !target) {
+ this.showError("Enter an npub before adding it to the list.");
+ if (sourceButton instanceof HTMLElement) {
+ sourceButton.disabled = false;
+ sourceButton.removeAttribute("aria-busy");
+ }
+ return;
+ }
+
+ const buttonToToggle = sourceButton || (isAdd ? addButton : null);
+ if (buttonToToggle instanceof HTMLElement) {
+ buttonToToggle.disabled = true;
+ buttonToToggle.setAttribute("aria-busy", "true");
+ }
+
+ let result;
+ if (isWhitelist) {
+ result = isAdd
+ ? accessControl.addToWhitelist(actorNpub, target)
+ : accessControl.removeFromWhitelist(actorNpub, target);
+ } else {
+ result = isAdd
+ ? accessControl.addToBlacklist(actorNpub, target)
+ : accessControl.removeFromBlacklist(actorNpub, target);
+ }
+
+ if (!result.ok) {
+ this.showError(this.describeAdminError(result.error));
+ if (buttonToToggle instanceof HTMLElement) {
+ buttonToToggle.disabled = false;
+ buttonToToggle.removeAttribute("aria-busy");
+ }
+ return;
+ }
+
+ if (isAdd && input instanceof HTMLInputElement) {
+ input.value = "";
+ }
+
+ const successMessage = isWhitelist
+ ? isAdd
+ ? "Added to the whitelist."
+ : "Removed from the whitelist."
+ : isAdd
+ ? "Added to the blacklist."
+ : "Removed from the blacklist.";
+ this.showSuccess(successMessage);
+ this.onAccessControlUpdated();
+
+ if (buttonToToggle instanceof HTMLElement) {
+ buttonToToggle.disabled = false;
+ buttonToToggle.removeAttribute("aria-busy");
+ }
+ }
+
+ describeAdminError(code) {
+ switch (code) {
+ case "invalid npub":
+ return "Please provide a valid npub address.";
+ case "immutable":
+ return "That account cannot be modified.";
+ case "forbidden":
+ return "You do not have permission to perform that action.";
+ default:
+ return "Unable to update moderation settings. Please try again.";
+ }
+ }
+
+ handleWhitelistModeToggle(enabled) {
+ 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."
+ );
+ this.onAccessControlUpdated();
+ }
+
+ onAccessControlUpdated() {
+ this.refreshAdminPaneState();
+ this.loadVideos(true).catch((error) => {
+ console.error("Failed to refresh videos after admin update:", error);
+ });
+ window.dispatchEvent(new CustomEvent("bitvid:access-control-updated"));
+ }
+
/**
* Setup general event listeners for logout, modals, etc.
*/
@@ -4277,6 +4806,7 @@ class bitvidApp {
console.log("[app.js] login() called with pubkey =", pubkey);
this.pubkey = pubkey;
+ this.currentUserNpub = this.safeEncodeNpub(pubkey);
let reloadScheduled = false;
if (saveToStorage) {
@@ -4293,6 +4823,8 @@ class bitvidApp {
return;
}
+ this.refreshAdminPaneState();
+
// Hide login button if present
if (this.loginButton) {
this.loginButton.classList.add("hidden");
@@ -4336,6 +4868,7 @@ class bitvidApp {
async logout() {
nostrClient.logout();
this.pubkey = null;
+ this.currentUserNpub = null;
// Show the login button again
if (this.loginButton) {
@@ -4374,6 +4907,8 @@ class bitvidApp {
// Clear localStorage
localStorage.removeItem("userPubKey");
+ this.refreshAdminPaneState();
+
// Refresh the video list so user sees only public videos again
await this.loadVideos();
@@ -4811,6 +5346,8 @@ class bitvidApp {
async loadVideos(forceFetch = false) {
console.log("Starting loadVideos... (forceFetch =", forceFetch, ")");
+ accessControl.refresh();
+
const shouldIncludeVideo = (video) => {
if (!video || typeof video !== "object") {
return false;
@@ -4820,12 +5357,7 @@ class bitvidApp {
return false;
}
- const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
- if (initialBlacklist.includes(authorNpub)) {
- return false;
- }
-
- if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
+ if (!accessControl.canAccess(video)) {
return false;
}
@@ -7117,14 +7649,16 @@ class bitvidApp {
return;
}
+ accessControl.refresh();
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
- if (initialBlacklist.includes(authorNpub)) {
- this.showError("This content has been removed or is not allowed.");
- return;
- }
-
- if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
- this.showError("This content is not from a whitelisted author.");
+ if (!accessControl.canAccess(authorNpub)) {
+ if (accessControl.isBlacklisted(authorNpub)) {
+ this.showError("This content has been removed or is not allowed.");
+ } else if (accessControl.whitelistMode()) {
+ this.showError("This content is not from a whitelisted author.");
+ } else {
+ this.showError("This content has been removed or is not allowed.");
+ }
return;
}
@@ -7326,8 +7860,21 @@ class bitvidApp {
* Simple helper to safely encode an npub.
*/
safeEncodeNpub(pubkey) {
+ if (typeof pubkey !== "string") {
+ return null;
+ }
+
+ const trimmed = pubkey.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ if (trimmed.startsWith("npub1")) {
+ return trimmed;
+ }
+
try {
- return window.NostrTools.nip19.npubEncode(pubkey);
+ return window.NostrTools.nip19.npubEncode(trimmed);
} catch (err) {
return null;
}
diff --git a/js/channelProfile.js b/js/channelProfile.js
index 6f5105a6..51719d31 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -8,8 +8,10 @@ import { app } from "./app.js";
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
-import { initialBlacklist, initialWhitelist } from "./lists.js";
-import { isWhitelistEnabled } from "./config.js";
+import { accessControl } from "./accessControl.js";
+
+let currentChannelHex = null;
+let currentChannelNpub = null;
let cachedZapButton = null;
@@ -55,6 +57,9 @@ export async function initChannelProfileView() {
return;
}
+ currentChannelHex = null;
+ currentChannelNpub = null;
+
// 2) Decode npub => hex pubkey
let hexPub;
try {
@@ -69,6 +74,9 @@ export async function initChannelProfileView() {
return;
}
+ currentChannelHex = hexPub;
+ currentChannelNpub = npub;
+
// 3) If user is logged in, load subscriptions and show sub/unsub button
if (app.pubkey) {
await subscriptions.loadSubscriptions(app.pubkey);
@@ -243,6 +251,8 @@ async function loadUserProfile(pubkey) {
*/
async function loadUserVideos(pubkey) {
try {
+ accessControl.refresh();
+
// 1) Build filter for videos from this pubkey
const filter = {
kinds: [30078],
@@ -279,11 +289,7 @@ async function loadUserVideos(pubkey) {
if (app.blacklistedEventIds.has(video.id)) return false;
// Author-level
- const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey;
- if (initialBlacklist.includes(authorNpub)) return false;
- if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
- return false;
- }
+ if (!accessControl.canAccess(video)) return false;
return true;
});
@@ -688,6 +694,26 @@ async function loadUserVideos(pubkey) {
}
}
+window.addEventListener("bitvid:access-control-updated", () => {
+ const hashParams = new URLSearchParams(window.location.hash.slice(1));
+ if (hashParams.get("view") !== "channel-profile") {
+ return;
+ }
+
+ if (!currentChannelHex) {
+ return;
+ }
+
+ const activeNpub = hashParams.get("npub");
+ if (currentChannelNpub && activeNpub && activeNpub !== currentChannelNpub) {
+ return;
+ }
+
+ loadUserVideos(currentChannelHex).catch((error) => {
+ console.error("Failed to refresh channel videos after admin update:", error);
+ });
+});
+
/**
* Minimal placeholder for private video decryption.
*/
diff --git a/js/config.js b/js/config.js
index 7363993b..f35124bc 100644
--- a/js/config.js
+++ b/js/config.js
@@ -1,4 +1,39 @@
// js/config.js
export const isDevMode = true; // Set to false for production
-export const isWhitelistEnabled = true; // Set to false to allow all non-blacklisted users
+
+// -----------------------------------------------------------------------------
+// Admin governance (v1 local mode)
+// -----------------------------------------------------------------------------
+
+export const ADMIN_LIST_MODE = "local"; // Future: "nostr"
+export const ADMIN_SUPER_NPUB =
+ "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
+export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional)
+export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
+
+const WHITELIST_MODE_KEY = "bitvid_admin_whitelist_mode";
+const DEFAULT_WHITELIST_ENABLED = false;
+
+export function getWhitelistMode() {
+ try {
+ const raw = localStorage.getItem(WHITELIST_MODE_KEY);
+ if (raw === null) {
+ return DEFAULT_WHITELIST_ENABLED;
+ }
+ return raw === "true";
+ } catch (error) {
+ console.warn("Failed to read whitelist mode from storage:", error);
+ return DEFAULT_WHITELIST_ENABLED;
+ }
+}
+
+export function setWhitelistMode(enabled) {
+ try {
+ localStorage.setItem(WHITELIST_MODE_KEY, enabled ? "true" : "false");
+ } catch (error) {
+ console.warn("Failed to persist whitelist mode to storage:", error);
+ }
+}
+
+export const ADMIN_WHITELIST_MODE_STORAGE_KEY = WHITELIST_MODE_KEY;
diff --git a/js/lists.js b/js/lists.js
index 2abc968e..8f77332b 100644
--- a/js/lists.js
+++ b/js/lists.js
@@ -19,8 +19,8 @@ const npubs = [
console.log("DEBUG: lists.js loaded, npubs:", npubs);
// Blacklist of npubs that events will not be displayed in the bitvid official client
-export const initialWhitelist = npubs;
-export const initialBlacklist = [""];
+export const ADMIN_INITIAL_WHITELIST = npubs;
+export const ADMIN_INITIAL_BLACKLIST = [""];
// Block specific events with the nevent
-export const initialEventBlacklist = [""];
+export const ADMIN_INITIAL_EVENT_BLACKLIST = [""];