Merge pull request #141 from PR0M3TH3AN/codex/implement-admin-list-state-management

Add async admin list adapters
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-09-28 20:18:48 -04:00
commit c9dff9d436
7 files changed
+911 -224

No files matched your search

-12
View File
@@ -240,18 +240,6 @@
<div id="adminWhitelistSection" class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 class="text-base font-semibold text-white">Whitelist</h4>
<label
id="adminWhitelistModeWrapper"
class="flex items-center gap-2 text-xs font-medium text-gray-300 hidden"
for="adminWhitelistModeToggle"
>
<input
id="adminWhitelistModeToggle"
type="checkbox"
class="h-4 w-4 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-400"
/>
<span>Enable whitelist-only mode</span>
</label>
</div>
<p class="text-sm text-gray-400">
Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist.
+173 -111
View File
@@ -7,39 +7,7 @@ import {
setWhitelistMode as persistWhitelistMode,
ADMIN_WHITELIST_MODE_STORAGE_KEY,
} from "./config.js";
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 { loadAdminState, persistAdminState } from "./adminListStore.js";
function normalizeNpub(value) {
return typeof value === "string" ? value.trim() : "";
@@ -57,26 +25,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 +48,94 @@ 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])
);
const normalizedWhitelist = dedupeNpubs(whitelist);
this.whitelist = new Set(normalizedWhitelist);
const blacklistDedupe = dedupeNpubs(blacklist);
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
const adminGuardSet = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
...Array.from(this.editors),
]);
const sanitizedBlacklist = blacklistDedupe.filter((npub) => {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (adminGuardSet.has(normalized)) {
return false;
}
return true;
});
this.blacklist = new Set(sanitizedBlacklist);
this.whitelistEnabled = getWhitelistMode();
this.hasLoaded = true;
this.lastError = null;
} 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 +174,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 +187,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 +207,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 +231,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 +257,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 +287,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 +313,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) {
@@ -286,7 +337,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) {
@@ -311,6 +368,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;
}
@@ -325,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,
+553
View File
@@ -0,0 +1,553 @@
// js/adminListStore.js
import {
ADMIN_LIST_MODE,
ADMIN_LIST_NAMESPACE,
ADMIN_SUPER_NPUB,
ADMIN_EDITORS_NPUBS,
isDevMode,
} from "./config.js";
import { nostrClient } from "./nostr.js";
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;
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 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 whitelistSet = new Set(sanitizedWhitelist.map(normalizeNpub));
const adminGuardSet = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
...ADMIN_EDITORS_NPUBS.map(normalizeNpub),
...sanitizedEditors.map(normalizeNpub),
]);
const sanitizedBlacklist = sanitizeNpubList(state.blacklist || []).filter(
(npub) => {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (adminGuardSet.has(normalized)) {
return false;
}
return true;
}
);
return {
editors: sanitizedEditors,
whitelist: sanitizedWhitelist,
blacklist: sanitizedBlacklist,
};
}
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;
}
try {
const raw = localStorage.getItem(key);
if (raw === null) {
return null;
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch (error) {
if (isDevMode) {
console.warn(`[adminListStore] Failed to parse legacy list for ${key}:`, error);
}
return null;
}
}
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;
}
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,
],
};
const keys = keyGroups[listKey];
if (!Array.isArray(keys)) {
return;
}
for (const key of keys) {
try {
localStorage.removeItem(key);
} catch (error) {
if (isDevMode) {
console.warn(`[adminListStore] Failed to clear legacy storage for ${key}:`, error);
}
}
}
}
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: 50,
};
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 [];
}
})
);
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, whitelist, blacklist] = await Promise.all([
loadNostrList(LIST_IDENTIFIERS.editors),
loadNostrList(LIST_IDENTIFIERS.whitelist),
loadNostrList(LIST_IDENTIFIERS.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 = {}) {
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 sanitizedUpdates = {};
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;
}
for (const [listKey, npubs] of entries) {
const event = buildListEvent(listKey, npubs, actorHex);
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
} catch (error) {
throw createError("signature-failed", "Failed to sign admin list event.", error);
}
const relays = ensureNostrReady();
const publishResults = await Promise.all(
relays.map((url) => publishToRelay(url, signedEvent, listKey))
);
if (!publishResults.some((result) => result.success)) {
throw createError(
"publish-failed",
`Failed to publish ${listKey} list to any relay.`
);
}
clearLegacyStorageFor(listKey);
}
}
export async function loadAdminState() {
if (ADMIN_LIST_MODE !== "nostr" && isDevMode) {
console.warn(
`[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Defaulting to remote Nostr lists.`
);
}
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" && isDevMode) {
console.warn(
`[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Remote lists are enforced.`
);
}
return persistNostrState(actorNpub, updates);
}
+172 -76
View File
@@ -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;
@@ -900,6 +898,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");
@@ -3316,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 =
@@ -3451,23 +3451,10 @@ 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();
this.refreshAdminPaneState();
await this.refreshAdminPaneState();
console.log("Profile modal initialization successful");
return true;
@@ -3551,12 +3538,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 +3812,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 +3840,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 +3860,7 @@ class bitvidApp {
if (adminNav instanceof HTMLElement && adminNav.classList.contains("bg-gray-800")) {
this.selectProfilePane("account");
}
this.setAdminLoading(false);
return;
}
@@ -3857,17 +3868,50 @@ 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);
}
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"
);
}
this.populateAdminLists();
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,17 +3922,23 @@ 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) {
this.adminWhitelistModeToggle.checked = accessControl.whitelistMode();
}
}
renderAdminList(listEl, emptyEl, entries, options = {}) {
@@ -4018,7 +4068,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 +4099,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 +4107,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 +4116,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 +4143,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 +4154,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 +4212,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 +4241,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,39 +4257,28 @@ 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) {
const actorNpub = this.ensureAdminActor(true);
if (!actorNpub) {
if (this.adminWhitelistModeToggle instanceof HTMLInputElement) {
this.adminWhitelistModeToggle.checked = accessControl.whitelistMode();
}
return;
async onAccessControlUpdated() {
try {
await this.refreshAdminPaneState();
} catch (error) {
console.error("Failed to refresh admin pane after update:", error);
}
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);
});
@@ -4213,7 +4299,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 +4911,7 @@ class bitvidApp {
return;
}
this.refreshAdminPaneState();
await this.refreshAdminPaneState();
// Hide login button if present
if (this.loginButton) {
@@ -4907,7 +4995,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 +5434,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 +7741,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)) {
+5 -1
View File
@@ -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 = {
+2 -2
View File
@@ -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)
+6 -22
View File
@@ -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 = [];