// js/adminListStore.js import { ADMIN_LIST_MODE, ADMIN_LIST_NAMESPACE, ADMIN_SUPER_NPUB, ADMIN_EDITORS_NPUBS, isDevMode, } from "./config.js"; import { nostrClient, requestDefaultExtensionPermissions, } from "./nostr.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { buildAdminListEvent, ADMIN_LIST_IDENTIFIERS, } from "./nostrEventSchemas.js"; import { publishEventToRelays } from "./nostrPublish.js"; const LEGACY_STORAGE_KEYS = { editors: "bitvid_admin_editors", whitelist: "bitvid_admin_whitelist", whitelistLegacy: "bitvid_whitelist", blacklist: "bitvid_admin_blacklist", blacklistLegacy: "bitvid_blacklist", }; function createError(code, message, cause) { const error = new Error(message); error.code = code; if (cause) { error.cause = cause; } return error; } function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; } 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 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) { devLogger.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) { devLogger.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."); } 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 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 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) { devLogger.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) { devLogger.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(ADMIN_LIST_IDENTIFIERS.editors), loadNostrList(ADMIN_LIST_IDENTIFIERS.whitelist), loadNostrList(ADMIN_LIST_IDENTIFIERS.blacklist), ]); return sanitizeAdminState({ editors, whitelist, blacklist }); } 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, }); } async function persistNostrState(actorNpub, updates = {}) { 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 whitelistSet = new Set( (sanitizedUpdates.whitelist || []).map(normalizeNpub) ); 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 (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 publishEventToRelays( nostrClient.pool, relays, signedEvent, ); if (isDevMode) { publishResults .filter((result) => !result.success) .forEach((result) => { userLogger.warn( `[adminListStore] Publish failed for ${listKey} on ${result.url}:`, result.error, ); }); } 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) { userLogger.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) { userLogger.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) { 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, });