diff --git a/docs/moderation/README.md b/docs/moderation/README.md index 840515a9..f4f924c6 100644 --- a/docs/moderation/README.md +++ b/docs/moderation/README.md @@ -35,6 +35,16 @@ Thread new moderation behaviors through the same service → stage → app → U > You can override all defaults in **Settings → Safety & Moderation**. +## Community blacklist federation + +Operators can delegate hard-hide decisions to trusted curators without giving them full admin powers. The flow is: + +1. The super admin maintains a `kind 30000` list with `d=${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_SOURCES}`. Each `a` tag references a community curator’s blacklist as `30000::${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:`. +2. Every curator publishes their own `kind 30000` list using the referenced `d` tag (`${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:`) and fills it with `p` tags for accounts they want hidden. +3. bitvid automatically fetches each referenced list, merges the entries into the global blacklist, and removes any duplicates or guard-protected accounts (super admin, editors, or whitelist members). + +To add a community list, append a new `a` tag to the super-admin source list and ask the curator to publish their companion `p` list. To remove a list, delete the `a` tag or ask the curator to clear their `p` entries; the client stops ingesting it on the next refresh. + ### Safety & Moderation controls The profile modal now exposes the blur and autoplay thresholds so operators can dial in stricter or more permissive behavior. Enter a non-negative whole number to override the default or leave the field blank to fall back to the baseline values above. Adjustments are stored locally, applied immediately to the active feed, and rehydrate on every load. diff --git a/js/adminListStore.js b/js/adminListStore.js index d8a346a2..aecdeb16 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -5,6 +5,8 @@ import { ADMIN_LIST_NAMESPACE, ADMIN_SUPER_NPUB, ADMIN_EDITORS_NPUBS, + ADMIN_COMMUNITY_BLACKLIST_SOURCES, + ADMIN_COMMUNITY_BLACKLIST_PREFIX, isDevMode, } from "./config.js"; import { @@ -358,6 +360,88 @@ function decodeNpubToHex(npub) { 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; @@ -442,25 +526,40 @@ function extractNpubsFromEvent(event) { return dedupeNpubs(npubs); } -async function loadNostrList(identifier) { +async function fetchLatestListEvent(filter, contextLabel = "admin-list") { const relays = ensureNostrReady(); - const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; - const filter = { - kinds: [30000], - "#d": [dTagValue], - limit: 50, - }; + + const normalizedFilter = {}; + if (Array.isArray(filter?.kinds) && filter.kinds.length) { + normalizedFilter.kinds = [...filter.kinds]; + } else { + normalizedFilter.kinds = [30000]; + } + + if (typeof filter?.limit === "number") { + normalizedFilter.limit = filter.limit; + } else { + normalizedFilter.limit = 50; + } + + if (Array.isArray(filter?.["#d"]) && filter["#d"].length) { + normalizedFilter["#d"] = [...filter["#d"]]; + } + + if (Array.isArray(filter?.authors) && filter.authors.length) { + normalizedFilter.authors = [...filter.authors]; + } let events = []; try { - const combined = await nostrClient.pool.list(relays, [filter]); + const combined = await nostrClient.pool.list(relays, [normalizedFilter]); if (Array.isArray(combined)) { events = combined; } } catch (error) { devLogger.warn( - `[adminListStore] Combined relay fetch failed for ${identifier}:`, - error + `[adminListStore] Combined relay fetch failed for ${contextLabel}:`, + error, ); } @@ -468,12 +567,12 @@ async function loadNostrList(identifier) { const perRelay = await Promise.all( relays.map(async (url) => { try { - const result = await nostrClient.pool.list([url], [filter]); + const result = await nostrClient.pool.list([url], [normalizedFilter]); return Array.isArray(result) ? result : []; } catch (error) { devLogger.warn( - `[adminListStore] Relay fetch failed for ${identifier} on ${url}:`, - error + `[adminListStore] Relay fetch failed for ${contextLabel} on ${url}:`, + error, ); return []; } @@ -483,10 +582,10 @@ async function loadNostrList(identifier) { } if (!events.length) { - return []; + return null; } - const newest = events.reduce((latest, event) => { + return events.reduce((latest, event) => { if (!latest) { return event; } @@ -495,8 +594,12 @@ async function loadNostrList(identifier) { } return event.created_at > latest.created_at ? event : latest; }, null); +} - return extractNpubsFromEvent(newest); +async function loadNostrList(identifier) { + const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; + const event = await fetchLatestListEvent({ "#d": [dTagValue] }, identifier); + return event ? extractNpubsFromEvent(event) : []; } async function loadNostrState() { @@ -509,6 +612,64 @@ async function loadNostrState() { 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) => { @@ -797,8 +958,36 @@ export async function loadAdminState() { ); } - const state = await loadNostrState(); - if (!hasAnyEntries(state)) { + const nostrState = await loadNostrState(); + + let 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, + ); + } + + if (!hasAnyEntries(mergedState)) { const legacy = loadLegacyAdminState(); if (legacy && isDevMode) { userLogger.warn( @@ -808,7 +997,7 @@ export async function loadAdminState() { } } - return state; + return mergedState; } export async function persistAdminState(actorNpub, updates) { diff --git a/js/config.js b/js/config.js index d0a7e501..ffe22022 100644 --- a/js/config.js +++ b/js/config.js @@ -61,6 +61,8 @@ export { PLATFORM_LUD16_OVERRIDE }; export { DEFAULT_RELAY_URLS_OVERRIDE }; export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional) export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists +export const ADMIN_COMMUNITY_BLACKLIST_SOURCES = "community:blacklist:sources"; +export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist"; export { ALLOW_NSFW_CONTENT }; const WHITELIST_MODE_KEY = ADMIN_WHITELIST_MODE_STORAGE_KEY; diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 766f483d..f535671d 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -1,6 +1,8 @@ import { devLogger } from "./utils/logger.js"; import { ADMIN_LIST_NAMESPACE, + ADMIN_COMMUNITY_BLACKLIST_SOURCES, + ADMIN_COMMUNITY_BLACKLIST_PREFIX, isDevMode, WATCH_HISTORY_KIND, WATCH_HISTORY_LIST_IDENTIFIER, @@ -30,8 +32,12 @@ export const ADMIN_LIST_IDENTIFIERS = Object.freeze({ editors: "editors", whitelist: "whitelist", blacklist: "blacklist", + communityBlacklistSources: ADMIN_COMMUNITY_BLACKLIST_SOURCES, }); +export { ADMIN_COMMUNITY_BLACKLIST_SOURCES }; +export { ADMIN_COMMUNITY_BLACKLIST_PREFIX }; + const DEFAULT_APPEND_TAGS = []; let cachedUtf8Encoder = null; diff --git a/tests/admin-list-store.test.mjs b/tests/admin-list-store.test.mjs index dc42c515..fdbdb7a9 100644 --- a/tests/admin-list-store.test.mjs +++ b/tests/admin-list-store.test.mjs @@ -44,11 +44,23 @@ process.on("exit", () => { console.warn = originalConsoleWarn; }); -const { persistAdminState, __adminListStoreTestHooks } = await import( +const { + loadAdminState, + persistAdminState, + __adminListStoreTestHooks, +} = await import( "../js/adminListStore.js" ); const { nostrClient } = await import("../js/nostr.js"); -const { ADMIN_SUPER_NPUB } = await import("../js/config.js"); +const { + ADMIN_SUPER_NPUB, + ADMIN_LIST_NAMESPACE, + ADMIN_COMMUNITY_BLACKLIST_PREFIX, + ADMIN_COMMUNITY_BLACKLIST_SOURCES, +} = await import("../js/config.js"); +const { ADMIN_LIST_IDENTIFIERS } = await import( + "../js/nostrEventSchemas.js" +); const { extractNpubsFromEvent, @@ -137,11 +149,61 @@ nostrClient.writeRelays = [...relays]; let publishBehaviors = []; let publishCallIndex = 0; +const superHex = mockNip19.decode(ADMIN_SUPER_NPUB).data; +const editorNpub = "npub1editormember"; +const whitelistNpub = "npub1whitelistmember"; +const directBlacklistNpub = "npub1directblacklist"; +const communityMemberNpub = "npub1communityblacklist"; +const communitySecondMemberNpub = "npub1communitytwo"; +const communityCuratorOneNpub = "npub1communitycurator"; +const communityCuratorTwoNpub = "npub1communitycurator2"; +const communityCuratorOneHex = mockNip19.decode(communityCuratorOneNpub).data; +const communityCuratorTwoHex = mockNip19.decode(communityCuratorTwoNpub).data; +const communityCuratorOneAuthorHex = mockNip19.decode( + communityCuratorOneHex +).data; +const communityCuratorTwoAuthorHex = mockNip19.decode( + communityCuratorTwoHex +).data; +const editorsDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.editors}`; +const whitelistDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`; +const blacklistDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`; +const communitySourceDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_SOURCES}`; +const communityListOneDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:crew-alpha`; +const communityListTwoDTag = `${ADMIN_LIST_NAMESPACE}:${ADMIN_COMMUNITY_BLACKLIST_PREFIX}:crew-beta`; + function setPublishBehaviors(behaviors) { publishBehaviors = Array.isArray(behaviors) ? behaviors : []; publishCallIndex = 0; } +const listEventRegistry = new Map(); +let listFailureMode = null; + +function resetListRegistry() { + listEventRegistry.clear(); + listFailureMode = null; +} + +function registerListEvent({ dTag, authorHex = null, event }) { + const key = `${authorHex || "*"}::${dTag}`; + listEventRegistry.set(key, event); +} + +function createListEvent({ dTag, tags, pubkey, createdAt }) { + return { + kind: 30000, + pubkey: pubkey || "", + created_at: typeof createdAt === "number" ? createdAt : Math.floor(Date.now() / 1000), + id: `${dTag}:${pubkey || "anon"}:${createdAt || 0}`, + tags: Array.isArray(tags) ? tags : [], + }; +} + +function setListFailureMode(mode) { + listFailureMode = typeof mode === "string" ? mode : null; +} + nostrClient.pool = { publish(urls) { const url = Array.isArray(urls) && urls.length > 0 ? urls[0] : ""; @@ -186,6 +248,27 @@ nostrClient.pool = { }, }; }, + async list(relayUrls, filters) { + const filter = Array.isArray(filters) && filters.length ? filters[0] : {}; + const dValues = Array.isArray(filter?.["#d"]) ? filter["#d"] : []; + const authors = Array.isArray(filter?.authors) ? filter.authors : []; + const dTag = dValues.length ? dValues[0] : ""; + const authorKey = authors.length ? authors[0] : "*"; + + if (listFailureMode === "throw-authors" && authors.length) { + throw new Error("list failure for authors"); + } + + const key = `${authorKey}::${dTag}`; + const fallbackKey = `*::${dTag}`; + const event = listEventRegistry.get(key) || listEventRegistry.get(fallbackKey); + + if (!event) { + return []; + } + + return [event]; + }, }; if (!globalThis.window.nostr) { @@ -197,6 +280,153 @@ globalThis.window.nostr.signEvent = async (event) => ({ id: "signed-event", }); +resetListRegistry(); + +registerListEvent({ + dTag: editorsDTag, + event: createListEvent({ + dTag: editorsDTag, + pubkey: superHex, + createdAt: 1, + tags: [["p", editorNpub]], + }), +}); +registerListEvent({ + dTag: whitelistDTag, + event: createListEvent({ + dTag: whitelistDTag, + pubkey: superHex, + createdAt: 2, + tags: [["p", whitelistNpub]], + }), +}); +registerListEvent({ + dTag: blacklistDTag, + event: createListEvent({ + dTag: blacklistDTag, + pubkey: superHex, + createdAt: 3, + tags: [["p", directBlacklistNpub]], + }), +}); +registerListEvent({ + dTag: communitySourceDTag, + authorHex: superHex, + event: createListEvent({ + dTag: communitySourceDTag, + pubkey: superHex, + createdAt: 4, + tags: [ + ["a", `30000:${communityCuratorOneHex}:${communityListOneDTag}`], + ["a", `30000:${communityCuratorOneHex}:${communityListOneDTag}`], + ["a", `10000:${communityCuratorOneHex}:${communityListOneDTag}`], + ["a", `30000:${communityCuratorTwoHex}:${communityListTwoDTag}`], + ["p", editorNpub], + ], + }), +}); +registerListEvent({ + dTag: communityListOneDTag, + authorHex: communityCuratorOneAuthorHex, + event: createListEvent({ + dTag: communityListOneDTag, + pubkey: communityCuratorOneHex, + createdAt: 5, + tags: [ + ["p", communityMemberNpub], + ["p", directBlacklistNpub], + ["p", ADMIN_SUPER_NPUB], + ["p", whitelistNpub], + ["p", editorNpub], + ], + }), +}); +registerListEvent({ + dTag: communityListTwoDTag, + authorHex: communityCuratorTwoAuthorHex, + event: createListEvent({ + dTag: communityListTwoDTag, + pubkey: communityCuratorTwoHex, + createdAt: 6, + tags: [ + ["p", communitySecondMemberNpub], + ["p", communitySecondMemberNpub], + ], + }), +}); + +const adminState = await loadAdminState(); + +assert.deepEqual( + adminState.editors, + [editorNpub], + "should hydrate editors from the primary admin list", +); +assert.deepEqual( + adminState.whitelist, + [whitelistNpub], + "should hydrate whitelist entries from the primary admin list", +); +assert.deepEqual( + adminState.blacklist, + [directBlacklistNpub, communityMemberNpub, communitySecondMemberNpub], + "should merge community blacklists with base entries while deduping and respecting guards", +); +assert.ok( + !adminState.blacklist.includes(ADMIN_SUPER_NPUB), + "should exclude super admin from merged blacklist", +); +assert.ok( + !adminState.blacklist.includes(whitelistNpub), + "should exclude whitelist members from merged blacklist", +); +assert.ok( + !adminState.blacklist.includes(editorNpub), + "should exclude editor members from merged blacklist", +); + +resetListRegistry(); +setListFailureMode("throw-authors"); + +registerListEvent({ + dTag: editorsDTag, + event: createListEvent({ + dTag: editorsDTag, + pubkey: superHex, + createdAt: 11, + tags: [["p", editorNpub]], + }), +}); +registerListEvent({ + dTag: whitelistDTag, + event: createListEvent({ + dTag: whitelistDTag, + pubkey: superHex, + createdAt: 12, + tags: [["p", whitelistNpub]], + }), +}); +registerListEvent({ + dTag: blacklistDTag, + event: createListEvent({ + dTag: blacklistDTag, + pubkey: superHex, + createdAt: 13, + tags: [["p", directBlacklistNpub]], + }), +}); + +const fallbackState = await loadAdminState(); + +assert.deepEqual( + fallbackState.blacklist, + [directBlacklistNpub], + "should fall back to base blacklist when community list loading fails", +); + +resetListRegistry(); +setListFailureMode(null); + const additionBehaviors = [ { url: relays[0], success: false, error: new Error("relay1 add failure") }, { url: relays[1], success: true },