// js/adminListStore.js import { ADMIN_LIST_MODE, ADMIN_LIST_NAMESPACE, ADMIN_SUPER_NPUB, ADMIN_EDITORS_NPUBS, ADMIN_COMMUNITY_BLACKLIST_SOURCES, ADMIN_COMMUNITY_BLACKLIST_PREFIX, isDevMode, } from "./config.js"; import { getRegisteredNostrClient, requestDefaultExtensionPermissions, } from "./nostrClientRegistry.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { buildAdminListEvent, ADMIN_LIST_IDENTIFIERS, NOTE_TYPES, } from "./nostrEventSchemas.js"; import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js"; import { publishEventToRelay } from "./nostrPublish.js"; const ADMIN_STATE_CACHE_VERSION = 1; const ADMIN_STATE_CACHE_KEY = `bitvid_admin_state_v${ADMIN_STATE_CACHE_VERSION}`; function createError(code, message, cause) { const error = new Error(message); error.code = code; if (cause) { error.cause = cause; } return error; } function requireNostrClient() { const client = getRegisteredNostrClient(); if (!client) { throw createError( "nostr-unavailable", "Nostr relay pool is not initialized.", ); } return client; } function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; } const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/i; function isHexPubkey(value) { if (typeof value !== "string") { return false; } const trimmed = value.trim(); if (!trimmed) { return false; } return HEX_PUBKEY_REGEX.test(trimmed); } 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 globalScope = typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : null; function readNostrToolsFromScope(scope = globalScope) { if (!scope || typeof scope !== "object") { return null; } const candidates = []; if (scope.__BITVID_CANONICAL_NOSTR_TOOLS__) { candidates.push(scope.__BITVID_CANONICAL_NOSTR_TOOLS__); } if (scope.NostrTools) { candidates.push(scope.NostrTools); } const nestedWindow = scope.window && scope.window !== scope && typeof scope.window === "object" ? scope.window : null; if (nestedWindow) { if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) { candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__); } if (nestedWindow.NostrTools) { candidates.push(nestedWindow.NostrTools); } } for (const candidate of candidates) { if (candidate && typeof candidate === "object") { const nip19 = candidate?.nip19; if ( nip19 && typeof nip19 === "object" && (typeof nip19.decode === "function" || typeof nip19.npubEncode === "function") ) { return candidate; } } } return null; } let cachedNostrTools = null; let cachedNip19 = null; function getNip19Tools() { const toolkit = readNostrToolsFromScope(); if (toolkit && toolkit !== cachedNostrTools) { cachedNostrTools = toolkit; cachedNip19 = toolkit?.nip19 && typeof toolkit.nip19 === "object" ? toolkit.nip19 : cachedNip19; } else if (!cachedNostrTools && toolkit) { cachedNostrTools = toolkit; cachedNip19 = toolkit?.nip19 && typeof toolkit.nip19 === "object" ? toolkit.nip19 : cachedNip19; } return cachedNip19 || null; } function canDecodeNpub() { const nip19 = getNip19Tools(); if (nip19 && typeof nip19.decode === "function") { return true; } 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 (isHexPubkey(trimmed)) { return true; } const nip19 = getNip19Tools(); const decoder = nip19?.decode; if (typeof decoder !== "function") { return trimmed.toLowerCase().startsWith("npub"); } try { const decoded = decoder(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 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 (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) ); } export const readCachedAdminState = () => { const policy = CACHE_POLICIES[NOTE_TYPES.ADMIN_MODERATION_LIST]; if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { return null; } if (typeof localStorage === "undefined") { return null; } let raw = null; try { raw = localStorage.getItem(ADMIN_STATE_CACHE_KEY); } catch (error) { devLogger.warn( "[adminListStore] Failed to read admin state cache:", error, ); return null; } if (raw === null) { return null; } try { const parsed = JSON.parse(raw); return sanitizeAdminState(parsed || {}); } catch (error) { devLogger.warn( "[adminListStore] Failed to parse admin state cache:", error, ); return null; } }; export const writeCachedAdminState = (state) => { const policy = CACHE_POLICIES[NOTE_TYPES.ADMIN_MODERATION_LIST]; if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { return; } if (typeof localStorage === "undefined") { return; } try { const sanitized = sanitizeAdminState(state || {}); localStorage.setItem(ADMIN_STATE_CACHE_KEY, JSON.stringify(sanitized)); } catch (error) { devLogger.warn( "[adminListStore] Failed to write admin state cache:", error, ); } }; function ensureNostrReady() { const nostrClient = requireNostrClient(); if (!nostrClient.pool) { throw createError( "nostr-unavailable", "Nostr relay pool is not initialized.", ); } const writeRelays = Array.isArray(nostrClient.writeRelays) && nostrClient.writeRelays.length ? nostrClient.writeRelays : []; const relays = writeRelays.length ? writeRelays : 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."); } const nip19 = getNip19Tools(); const decoder = nip19?.decode; try { if (typeof decoder === "function") { const decoded = decoder(trimmed); if (decoded?.type !== "npub" || !decoded?.data) { throw new Error("Invalid npub format"); } return typeof decoded.data === "string" ? decoded.data : decoded.data?.pubkey || ""; } const fallback = window?.NostrTools?.nip19?.decode; if (typeof fallback === "function") { const decoded = fallback(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); } throw createError("invalid npub", "Unable to decode npub."); } function parseCommunityBlacklistReferences(event) { if (!event || !Array.isArray(event.tags)) { return []; } const references = []; const seen = new Set(); for (const tag of event.tags) { if (!Array.isArray(tag) || tag.length < 2) { continue; } const tagName = typeof tag[0] === "string" ? tag[0] : ""; if (tagName !== "a") { continue; } const rawValue = typeof tag[1] === "string" ? tag[1].trim() : ""; if (!rawValue) { continue; } const [kindSegment, authorSegment, ...identifierSegments] = rawValue.split(":"); if (!identifierSegments.length) { continue; } const kind = Number.parseInt(kindSegment, 10); if (!Number.isFinite(kind) || kind !== 30000) { continue; } const dTagValue = identifierSegments.join(":").trim(); if (!dTagValue) { continue; } if ( !dTagValue.startsWith( `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}` ) ) { continue; } const authorCandidate = typeof authorSegment === "string" ? authorSegment.trim() : ""; if (!authorCandidate) { continue; } let authorHex = ""; if (isHexPubkey(authorCandidate)) { authorHex = authorCandidate.toLowerCase(); } else if (authorCandidate.toLowerCase().startsWith("npub")) { try { authorHex = decodeNpubToHex(authorCandidate); } catch (error) { devLogger.warn( `[adminListStore] Failed to decode community curator npub for ${dTagValue}:`, error, ); authorHex = ""; } } if (!authorHex) { continue; } const key = `${authorHex}::${dTagValue}`; if (seen.has(key)) { continue; } seen.add(key); references.push({ authorHex, dTag: dTagValue }); } return references; } function encodeHexToNpub(hex) { const nip19 = getNip19Tools(); const encoder = nip19?.npubEncode; if (typeof encoder === "function") { try { return encoder(hex) || ""; } catch (error) { devLogger.warn("Failed to encode hex pubkey to npub:", error); } } try { return window?.NostrTools?.nip19?.npubEncode(hex) || ""; } catch (error) { devLogger.warn("Failed to encode hex pubkey to npub:", error); return ""; } } function normalizeParticipantTagValue(value) { if (typeof value !== "string") { return ""; } const trimmed = value.trim(); if (!trimmed) { return ""; } const nip19 = getNip19Tools(); const lower = trimmed.toLowerCase(); if (lower.startsWith("npub")) { if (nip19?.decode) { try { const decoded = nip19.decode(trimmed); if (decoded?.type === "npub") { return trimmed; } } catch (error) { // Swallow decode errors so we can still fall back to the raw npub string. } } return trimmed; } if (isHexPubkey(trimmed)) { const encoded = encodeHexToNpub(trimmed); if (encoded) { return encoded; } return trimmed; } if (nip19?.decode) { try { const decoded = nip19.decode(trimmed); if (decoded?.type === "npub") { return trimmed; } } catch (error) { // Ignore decode failures; values that cannot be interpreted as npubs are dropped. } } return ""; } function extractNpubsFromEvent(event) { if (!event || !Array.isArray(event.tags)) { return []; } const candidates = event.tags .filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1]) .map((tag) => tag[1]); const npubs = candidates .map((value) => normalizeParticipantTagValue(value)) .filter((npub) => typeof npub === "string" && npub); return dedupeNpubs(npubs); } async function fetchLatestListEvent(filter, contextLabel = "admin-list") { const nostrClient = requireNostrClient(); const relays = ensureNostrReady(); const kind = (Array.isArray(filter?.kinds) && filter.kinds.length) ? filter.kinds[0] : 30000; const pubkey = (Array.isArray(filter?.authors) && filter.authors.length) ? filter.authors[0] : ""; const dTag = (Array.isArray(filter?.["#d"]) && filter["#d"].length) ? filter["#d"][0] : ""; // Note: fetchListIncrementally expects single kind, pubkey, dTag. // The existing fetchLatestListEvent was a bit generic but admin lists are specific. // If we can't extract specific identifiers, fall back to pool list (no incremental). if (!pubkey) { devLogger.warn(`[adminListStore] Cannot use incremental fetch for ${contextLabel} without author.`); // Fallback to old behavior if pubkey missing (e.g. search scenario? unlikely for admin lists) // Actually admin lists always have specific d-tag and author (editor list, whitelist, blacklist, community lists) // So we should be fine. } let events = []; try { if (pubkey) { events = await nostrClient.fetchListIncrementally({ kind, pubkey, dTag, relayUrls: relays }); } else { // Fallback for non-specific queries const normalizedFilter = { kinds: [kind], limit: 50 }; if (dTag) normalizedFilter["#d"] = [dTag]; if (filter.authors) normalizedFilter.authors = filter.authors; events = await nostrClient.pool.list(relays, [normalizedFilter]); } } catch (error) { devLogger.warn( `[adminListStore] Fetch failed for ${contextLabel}:`, error, ); } if (!events || !events.length) { return null; } return 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); } async function loadNostrList(identifier) { const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; const event = await fetchLatestListEvent({ "#d": [dTagValue] }, identifier); return event ? extractNpubsFromEvent(event) : []; } async function loadNostrState() { const [editors, whitelist, blacklist] = await Promise.all([ loadNostrList(ADMIN_LIST_IDENTIFIERS.editors), loadNostrList(ADMIN_LIST_IDENTIFIERS.whitelist), loadNostrList(ADMIN_LIST_IDENTIFIERS.blacklist), ]); return sanitizeAdminState({ editors, whitelist, blacklist }); } async function loadCommunityBlacklistEntries() { let superAdminHex = ""; try { superAdminHex = decodeNpubToHex(ADMIN_SUPER_NPUB); } catch (error) { devLogger.warn( "[adminListStore] Failed to decode super admin npub for community blacklist lookup:", error, ); return []; } const sourceIdentifier = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_SOURCES}`; let sourceEvent = null; try { sourceEvent = await fetchLatestListEvent( { "#d": [sourceIdentifier], authors: [superAdminHex] }, "community-blacklist-sources", ); } catch (error) { devLogger.warn( "[adminListStore] Failed to load community blacklist source list:", error, ); return []; } if (!sourceEvent) { return []; } const references = parseCommunityBlacklistReferences(sourceEvent); if (!references.length) { return []; } const results = await Promise.all( references.map(async (reference) => { try { const event = await fetchLatestListEvent( { "#d": [reference.dTag], authors: [reference.authorHex] }, reference.dTag, ); return event ? extractNpubsFromEvent(event) : []; } catch (error) { devLogger.warn( `[adminListStore] Failed to load community blacklist ${reference.dTag}:`, error, ); return []; } }) ); return results.flat(); } function buildListEvent(listKey, npubs, actorHex) { const hexPubkeys = Array.from( new Set(npubs.map((npub) => { try { return decodeNpubToHex(npub); } catch (error) { devLogger.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) { devLogger.warn("Failed to ensure super admin presence:", error); } } return buildAdminListEvent(listKey, { pubkey: actorHex, created_at: Math.floor(Date.now() / 1000), hexPubkeys, }); } function publishListWithFirstAcceptance(pool, relays, event, options = {}) { const publishRelay = typeof options.publishRelay === "function" ? options.publishRelay : publishEventToRelay; const listKey = typeof options.listKey === "string" ? options.listKey : ""; const publishOptions = options && typeof options.publishOptions === "object" ? options.publishOptions : undefined; const relayPromises = Array.isArray(relays) ? relays.map((url) => publishRelay(pool, url, event, publishOptions)) : []; const resultsBuffer = new Array(relayPromises.length); const resolveMessage = listKey ? `Failed to publish ${listKey} list to any relay.` : "Failed to publish admin list to any relay."; const normalizeFailureError = (value) => { if (value instanceof Error) { return value; } if (value && typeof value === "object") { if (value.error instanceof Error) { return value.error; } if (typeof value.error === "string" && value.error.trim()) { return new Error(value.error.trim()); } } if (typeof value === "string" && value.trim()) { return new Error(value.trim()); } if (value && typeof value === "object" && typeof value.reason === "string") { const trimmed = value.reason.trim(); if (trimmed) { return new Error(trimmed); } } return new Error("publish failed"); }; const normalizeResult = (result, fallbackUrl = "") => { if (result && typeof result === "object") { const url = typeof result.url === "string" ? result.url : fallbackUrl; const success = !!result.success; const error = success ? null : normalizeFailureError(result); return { url, success, error }; } return { url: fallbackUrl, success: false, error: normalizeFailureError(result), }; }; const acceptancePromises = relayPromises.map((promise, index) => promise .then((result) => { const normalized = normalizeResult( result, Array.isArray(relays) ? relays[index] : "", ); resultsBuffer[index] = normalized; if (normalized.success) { return normalized; } throw normalized; }) .catch((reason) => { const normalized = normalizeResult( reason, Array.isArray(relays) ? relays[index] : "", ); if (typeof resultsBuffer[index] === "undefined") { resultsBuffer[index] = normalized; } throw normalized; }) ); const firstAcceptance = (() => { if (!relayPromises.length) { return Promise.reject(createError("publish-failed", resolveMessage)); } return Promise.any(acceptancePromises).catch((aggregateError) => { const error = createError("publish-failed", resolveMessage, aggregateError); throw error; }); })(); const allResults = Promise.allSettled(relayPromises).then((entries) => { for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]; const fallbackUrl = Array.isArray(relays) ? relays[index] : ""; const result = entry?.status === "fulfilled" ? normalizeResult(entry.value, fallbackUrl) : normalizeResult(entry?.reason, fallbackUrl); if (typeof resultsBuffer[index] === "undefined") { resultsBuffer[index] = result; } } return resultsBuffer; }); return { firstAcceptance, allResults, resultsBuffer }; } async function persistNostrState(actorNpub, updates = {}) { const nostrClient = requireNostrClient(); ensureNostrReady(); const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult.ok) { throw createError( "extension-permission-denied", "The NIP-07 extension must allow signing before updating admin lists.", permissionResult.error, ); } 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 = {}; const actorNormalized = normalizeNpub(actorNpub); 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 editorGuard = new Set([ normalizeNpub(ADMIN_SUPER_NPUB), ...ADMIN_EDITORS_NPUBS.map(normalizeNpub), ...((sanitizedUpdates.editors || []).map(normalizeNpub) || []), ]); if (actorNormalized) { editorGuard.add(actorNormalized); } sanitizedUpdates.blacklist = sanitizeNpubList(updates.blacklist).filter( (npub) => { const normalized = normalizeNpub(npub); if (!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 { firstAcceptance, allResults } = publishListWithFirstAcceptance( nostrClient.pool, relays, signedEvent, { listKey }, ); const logSettledResults = allResults.then((results) => { if (isDevMode) { results .filter((result) => !result?.success) .forEach((result) => { userLogger.warn( `[adminListStore] Publish failed for ${listKey} on ${result.url}:`, result.error, ); }); } return results; }); try { await firstAcceptance; } catch (error) { await logSettledResults.catch(() => {}); throw error; } logSettledResults.catch((loggingError) => { devLogger.warn( `[adminListStore] Failed to summarize publish results for ${listKey}:`, loggingError, ); }); } } export async function loadAdminState() { if (ADMIN_LIST_MODE !== "nostr" && isDevMode) { userLogger.warn( `[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Defaulting to remote Nostr lists.` ); } let mergedState = null; let cachedState = null; try { const nostrState = await loadNostrState(); mergedState = nostrState; try { const communityEntries = await loadCommunityBlacklistEntries(); if (Array.isArray(communityEntries) && communityEntries.length) { const baseEditors = Array.isArray(nostrState?.editors) ? nostrState.editors : []; const baseWhitelist = Array.isArray(nostrState?.whitelist) ? nostrState.whitelist : []; const baseBlacklist = Array.isArray(nostrState?.blacklist) ? nostrState.blacklist : []; mergedState = sanitizeAdminState({ editors: baseEditors, whitelist: baseWhitelist, blacklist: [...baseBlacklist, ...communityEntries], }); } } catch (error) { devLogger.warn( "[adminListStore] Failed to merge community blacklist entries:", error, ); } } catch (error) { devLogger.warn( "[adminListStore] Failed to load admin lists from relays:", error, ); cachedState = readCachedAdminState(); if (cachedState) { return cachedState; } throw error; } if (!cachedState) { cachedState = readCachedAdminState(); } if (mergedState) { if (hasAnyEntries(mergedState)) { writeCachedAdminState(mergedState); } else if (cachedState && hasAnyEntries(cachedState)) { return cachedState; } else { writeCachedAdminState(mergedState); } } return mergedState || sanitizeAdminState(); } export async function persistAdminState(actorNpub, updates) { if (ADMIN_LIST_MODE !== "nostr" && isDevMode) { userLogger.warn( `[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Remote lists are enforced.` ); } return persistNostrState(actorNpub, updates); } export const __adminListStoreTestHooks = Object.freeze({ extractNpubsFromEvent, normalizeParticipantTagValue, publishListWithFirstAcceptance, });