From 397ed7c8ceb35903ebff67cc7009f19f585e55f2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:48:07 -0400 Subject: [PATCH] Harden moderation trust handling --- docs/moderation/web-of-trust.md | 6 + js/feedEngine/stages.js | 133 +++++++++++- js/services/moderationService.js | 323 +++++++++++++++++++++++++++++- tests/moderation-service.test.mjs | 102 ++++++++++ tests/moderation-stage.test.mjs | 105 ++++++++++ 5 files changed, 661 insertions(+), 8 deletions(-) create mode 100644 tests/moderation-service.test.mjs create mode 100644 tests/moderation-stage.test.mjs diff --git a/docs/moderation/web-of-trust.md b/docs/moderation/web-of-trust.md index 2dcee580..0bbb2b08 100644 --- a/docs/moderation/web-of-trust.md +++ b/docs/moderation/web-of-trust.md @@ -28,6 +28,12 @@ - `['d','bitvid:admin:editors']` → trusted channel editors. - Users can subscribe/unsubscribe any time. +### Decision precedence + +1. **Personal blocks win first.** If a viewer blocks an author or reporter, we ignore their content and reports regardless of admin lists. +2. **Admin blacklist applies next.** Entries on `bitvid:admin:blacklist` are hard-hidden and their reports suppressed before looking at thresholds. +3. **F1 thresholds run last.** Blur/autoplay gating only evaluates trusted-report counts after personal blocks and admin blacklists. Admin whitelists may bypass Discovery gating, but never override a viewer block. + ## Pseudocode ```ts diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index d05a2cf6..901b5212 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -88,6 +88,7 @@ export function createBlacklistFilterStage({ : () => false; const options = { blacklistedEventIds: blacklist, isAuthorBlocked }; + const results = []; for (const item of items) { @@ -245,6 +246,85 @@ export function createModerationStage({ context?.log?.(`[${stageName}] Failed to prime report subscriptions`, error); } + const toStringSet = (input, { lowercase = false } = {}) => { + const output = new Set(); + if (input instanceof Set) { + for (const value of input) { + if (typeof value !== "string") { + continue; + } + const trimmed = value.trim(); + if (!trimmed) { + continue; + } + output.add(lowercase ? trimmed.toLowerCase() : trimmed); + } + return output; + } + if (Array.isArray(input)) { + for (const value of input) { + if (typeof value !== "string") { + continue; + } + const trimmed = value.trim(); + if (!trimmed) { + continue; + } + output.add(lowercase ? trimmed.toLowerCase() : trimmed); + } + } + return output; + }; + + let adminSnapshot = { + whitelist: new Set(), + whitelistHex: new Set(), + blacklist: new Set(), + blacklistHex: new Set(), + }; + + try { + if (typeof resolvedService.getAdminListSnapshot === "function") { + const snapshot = resolvedService.getAdminListSnapshot(); + if (snapshot && typeof snapshot === "object") { + adminSnapshot = { + whitelist: toStringSet(snapshot.whitelist), + whitelistHex: toStringSet(snapshot.whitelistHex, { lowercase: true }), + blacklist: toStringSet(snapshot.blacklist), + blacklistHex: toStringSet(snapshot.blacklistHex, { lowercase: true }), + }; + } + } + } catch (error) { + context?.log?.(`[${stageName}] Failed to load admin list snapshot`, error); + } + + const resolveAdminStatus = + typeof resolvedService.getAccessControlStatus === "function" + ? (identifier) => { + try { + return ( + resolvedService.getAccessControlStatus(identifier, adminSnapshot) || { + hex: "", + whitelisted: false, + blacklisted: false, + } + ); + } catch (error) { + context?.log?.(`[${stageName}] getAccessControlStatus threw`, error); + return { hex: "", whitelisted: false, blacklisted: false }; + } + } + : () => ({ hex: "", whitelisted: false, blacklisted: false }); + + const feedName = + typeof context?.feedName === "string" ? context.feedName.trim().toLowerCase() : ""; + const runtimeVariant = + typeof context?.runtime?.feedVariant === "string" + ? context.runtime.feedVariant.trim().toLowerCase() + : ""; + const isDiscoveryFeed = feedName === "discovery" || runtimeVariant === "discovery"; + const results = []; for (const item of items) { @@ -260,6 +340,46 @@ export function createModerationStage({ } const videoId = typeof video.id === "string" ? video.id : ""; + const authorPubkey = typeof video.pubkey === "string" ? video.pubkey : ""; + + let viewerBlocked = false; + if (authorPubkey && typeof context?.runtime?.isAuthorBlocked === "function") { + try { + viewerBlocked = !!context.runtime.isAuthorBlocked(authorPubkey); + } catch (error) { + context?.log?.(`[${stageName}] isAuthorBlocked threw`, error); + } + } + + const adminStatus = resolveAdminStatus(authorPubkey); + const authorHex = + (adminStatus?.hex && typeof adminStatus.hex === "string" + ? adminStatus.hex + : authorPubkey) + ?.trim() + .toLowerCase() || ""; + + if (viewerBlocked) { + context?.addWhy?.({ + stage: stageName, + type: "filter", + reason: "viewer-block", + videoId: videoId || null, + pubkey: authorHex || authorPubkey || null, + }); + continue; + } + + if (adminStatus?.blacklisted) { + context?.addWhy?.({ + stage: stageName, + type: "filter", + reason: "admin-blacklist", + videoId: videoId || null, + pubkey: authorHex || authorPubkey || null, + }); + continue; + } let summary = null; let trustedCount = 0; @@ -303,8 +423,13 @@ export function createModerationStage({ } } - const blockAutoplay = trustedCount >= normalizedAutoplayThreshold; - const blurThumbnail = trustedCount >= normalizedBlurThreshold; + let blockAutoplay = trustedCount >= normalizedAutoplayThreshold; + let blurThumbnail = trustedCount >= normalizedBlurThreshold; + const adminWhitelistBypass = isDiscoveryFeed && adminStatus?.whitelisted === true; + if (adminWhitelistBypass) { + blockAutoplay = false; + blurThumbnail = false; + } if (!item.metadata || typeof item.metadata !== "object") { item.metadata = {}; @@ -323,6 +448,8 @@ export function createModerationStage({ metadataModeration.trustedReporters = Array.isArray(trustedReporters) ? trustedReporters.slice() : []; + metadataModeration.adminWhitelist = adminStatus?.whitelisted === true; + metadataModeration.adminWhitelistBypass = adminWhitelistBypass; item.metadata.moderation = metadataModeration; if (!video.moderation || typeof video.moderation !== "object") { @@ -333,6 +460,8 @@ export function createModerationStage({ video.moderation.blurThumbnail = blurThumbnail; video.moderation.trustedCount = trustedCount; video.moderation.reportType = normalizedReportType; + video.moderation.adminWhitelist = adminStatus?.whitelisted === true; + video.moderation.adminWhitelistBypass = adminWhitelistBypass; if (Array.isArray(trustedReporters) && trustedReporters.length) { video.moderation.trustedReporters = trustedReporters.slice(); } else if (video.moderation.trustedReporters) { diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 4f838596..6de60801 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -1,5 +1,7 @@ import { nostrClient } from "../nostr.js"; import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js"; +import { accessControl } from "../accessControl.js"; +import { userBlocks } from "../userBlocks.js"; import logger from "../utils/logger.js"; class SimpleEventEmitter { @@ -115,6 +117,97 @@ function cloneSummary(summary) { }; } +function getNostrTools() { + if (typeof window !== "undefined" && window?.NostrTools) { + return window.NostrTools; + } + if (typeof globalThis !== "undefined" && globalThis?.NostrTools) { + return globalThis.NostrTools; + } + return null; +} + +function bytesToHex(bytes) { + if (!bytes || typeof bytes.length !== "number") { + return ""; + } + + let hex = ""; + for (let index = 0; index < bytes.length; index += 1) { + const value = bytes[index]; + if (typeof value !== "number") { + return ""; + } + const normalized = value & 0xff; + hex += normalized.toString(16).padStart(2, "0"); + } + return hex; +} + +function decodeToHex(candidate) { + if (typeof candidate !== "string") { + return ""; + } + + const trimmed = candidate.trim(); + if (!trimmed) { + return ""; + } + + try { + const tools = getNostrTools(); + const decoder = tools?.nip19?.decode; + if (typeof decoder !== "function") { + return ""; + } + const decoded = decoder(trimmed); + if (!decoded || decoded.type !== "npub") { + return ""; + } + const data = decoded.data; + if (typeof data === "string") { + return normalizeHex(data); + } + return bytesToHex(data); + } catch (error) { + return ""; + } +} + +function encodeToNpub(hex) { + const normalized = normalizeHex(hex); + if (!normalized) { + return ""; + } + try { + const tools = getNostrTools(); + const encoder = tools?.nip19?.npubEncode; + if (typeof encoder !== "function") { + return ""; + } + return encoder(normalized) || ""; + } catch (error) { + return ""; + } +} + +function normalizeToHex(candidate) { + const direct = normalizeHex(candidate); + if (direct) { + return direct; + } + return decodeToHex(candidate); +} + +function createEmptyAdminSnapshot() { + return { + whitelist: new Set(), + whitelistHex: new Set(), + blacklist: new Set(), + blacklistHex: new Set(), + }; +} + function resolveRelayList(client, { write = false } = {}) { if (!client) { return []; @@ -212,12 +305,19 @@ function ensureNumber(value) { } export class ModerationService { - constructor({ nostrClient: client = null, logger: log = null } = {}) { + constructor({ + nostrClient: client = null, + logger: log = null, + userBlocks: userBlockManager = null, + accessControl: accessControlService = null, + } = {}) { this.nostrClient = client; this.log = normalizeLogger(log); this.viewerPubkey = ""; this.trustedContacts = new Set(); + this.userBlocks = userBlockManager; + this.accessControl = accessControlService; this.contactSubscription = null; this.contactListPromise = null; @@ -246,6 +346,18 @@ export class ModerationService { } } + setUserBlocks(manager) { + if (manager && manager !== this.userBlocks) { + this.userBlocks = manager; + } + } + + setAccessControl(control) { + if (control && control !== this.accessControl) { + this.accessControl = control; + } + } + on(eventName, handler) { return this.emitter.on(eventName, handler); } @@ -254,6 +366,167 @@ export class ModerationService { this.emitter.emit(eventName, detail); } + async ensureUserBlocksLoaded(pubkey) { + if (!this.userBlocks || typeof this.userBlocks.ensureLoaded !== "function") { + return; + } + + const normalized = normalizeHex(pubkey); + if (!normalized) { + return; + } + + try { + await this.userBlocks.ensureLoaded(normalized); + } catch (error) { + this.log("[moderationService] failed to load user block list", error); + } + } + + isPubkeyBlockedByViewer(pubkey) { + if (!this.userBlocks || typeof this.userBlocks.isBlocked !== "function") { + return false; + } + + const normalized = normalizeHex(pubkey); + if (!normalized) { + return false; + } + + try { + return this.userBlocks.isBlocked(normalized) === true; + } catch (error) { + this.log("[moderationService] user block lookup failed", error); + return false; + } + } + + getAdminListSnapshot() { + const snapshot = createEmptyAdminSnapshot(); + + if (!this.accessControl) { + return snapshot; + } + + let whitelist = []; + let blacklist = []; + + if (typeof this.accessControl.getWhitelist === "function") { + try { + whitelist = this.accessControl.getWhitelist() || []; + } catch (error) { + this.log("[moderationService] accessControl.getWhitelist threw", error); + } + } + + if (typeof this.accessControl.getBlacklist === "function") { + try { + blacklist = this.accessControl.getBlacklist() || []; + } catch (error) { + this.log("[moderationService] accessControl.getBlacklist threw", error); + } + } + + for (const entry of whitelist) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + snapshot.whitelist.add(trimmed); + const decoded = decodeToHex(trimmed); + if (decoded) { + snapshot.whitelistHex.add(decoded); + } + } + + for (const entry of blacklist) { + if (typeof entry !== "string") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + snapshot.blacklist.add(trimmed); + const decoded = decodeToHex(trimmed); + if (decoded) { + snapshot.blacklistHex.add(decoded); + } + } + + return snapshot; + } + + getAccessControlStatus(candidate, snapshot = null) { + const resolvedSnapshot = snapshot && typeof snapshot === "object" ? snapshot : this.getAdminListSnapshot(); + const whitelist = + resolvedSnapshot?.whitelist instanceof Set + ? resolvedSnapshot.whitelist + : new Set(); + const whitelistHex = + resolvedSnapshot?.whitelistHex instanceof Set + ? resolvedSnapshot.whitelistHex + : new Set(); + const blacklist = + resolvedSnapshot?.blacklist instanceof Set + ? resolvedSnapshot.blacklist + : new Set(); + const blacklistHex = + resolvedSnapshot?.blacklistHex instanceof Set + ? resolvedSnapshot.blacklistHex + : new Set(); + + const hex = normalizeToHex(candidate); + const status = { + hex, + whitelisted: false, + blacklisted: false, + npub: "", + }; + + if (hex) { + if (blacklistHex.has(hex)) { + status.blacklisted = true; + return status; + } + if (whitelistHex.has(hex)) { + status.whitelisted = true; + status.npub = encodeToNpub(hex) || ""; + return status; + } + } + + const encoded = hex ? encodeToNpub(hex) : ""; + if (encoded) { + status.npub = encoded; + if (blacklist.has(encoded)) { + status.blacklisted = true; + return status; + } + if (whitelist.has(encoded)) { + status.whitelisted = true; + return status; + } + } + + if (typeof candidate === "string" && !status.npub) { + const trimmed = candidate.trim(); + if (trimmed && (blacklist.has(trimmed) || whitelist.has(trimmed))) { + status.npub = trimmed; + if (blacklist.has(trimmed)) { + status.blacklisted = true; + } else if (whitelist.has(trimmed)) { + status.whitelisted = true; + } + } + } + + return status; + } + async ensurePool() { if (!this.nostrClient || typeof this.nostrClient.ensurePool !== "function") { throw new Error("nostr-client-unavailable"); @@ -284,6 +557,7 @@ export class ModerationService { } catch (error) { this.log("[moderationService] contact hydration failed", error); } + await this.ensureUserBlocksLoaded(normalized); return this.viewerPubkey; } @@ -301,6 +575,7 @@ export class ModerationService { this.contactSubscription = null; if (!normalized) { + await this.ensureUserBlocksLoaded(normalized); return this.viewerPubkey; } @@ -323,6 +598,7 @@ export class ModerationService { this.log("[moderationService] failed to subscribe to contact list", error); }); + await this.ensureUserBlocksLoaded(normalized); return this.viewerPubkey; } @@ -611,9 +887,25 @@ export class ModerationService { const typeStats = new Map(); let totalTrusted = 0; + const adminSnapshot = this.getAdminListSnapshot(); for (const [reporter, typeMap] of eventReports.entries()) { - const isTrusted = this.trustedContacts.has(reporter); + const status = this.getAccessControlStatus(reporter, adminSnapshot); + const reporterHex = status.hex || normalizeHex(reporter); + if (!reporterHex) { + continue; + } + + if (this.isPubkeyBlockedByViewer(reporterHex)) { + continue; + } + + if (status.blacklisted) { + continue; + } + + const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex); + for (const [type, detail] of typeMap.entries()) { let stats = typeStats.get(type); if (!stats) { @@ -625,7 +917,7 @@ export class ModerationService { if (createdAt > stats.latest) { stats.latest = createdAt; } - if (isTrusted) { + if (isTrustedReporter) { stats.trusted += 1; totalTrusted += 1; } @@ -678,8 +970,25 @@ export class ModerationService { const normalizedType = normalizeReportType(type); const results = []; + const adminSnapshot = this.getAdminListSnapshot(); + for (const [reporter, typeMap] of eventReports.entries()) { - if (!this.trustedContacts.has(reporter)) { + const status = this.getAccessControlStatus(reporter, adminSnapshot); + const reporterHex = status.hex || normalizeHex(reporter); + if (!reporterHex) { + continue; + } + + if (this.isPubkeyBlockedByViewer(reporterHex)) { + continue; + } + + if (status.blacklisted) { + continue; + } + + const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex); + if (!isTrustedReporter) { continue; } @@ -689,7 +998,7 @@ export class ModerationService { continue; } results.push({ - pubkey: reporter, + pubkey: reporterHex, latest: ensureNumber(detail?.created_at), }); continue; @@ -702,7 +1011,7 @@ export class ModerationService { latest = candidate; } } - results.push({ pubkey: reporter, latest }); + results.push({ pubkey: reporterHex, latest }); } if (!results.length) { @@ -831,6 +1140,8 @@ export class ModerationService { const moderationService = new ModerationService({ nostrClient, logger, + userBlocks, + accessControl, }); export default moderationService; diff --git a/tests/moderation-service.test.mjs b/tests/moderation-service.test.mjs new file mode 100644 index 00000000..9ba1ea49 --- /dev/null +++ b/tests/moderation-service.test.mjs @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ModerationService } from "../js/services/moderationService.js"; + +const nip19 = { + npubEncode(hex) { + if (typeof hex !== "string" || !hex) { + throw new Error("invalid hex"); + } + return `npub${hex}`; + }, + decode(value) { + if (typeof value !== "string" || !value.startsWith("npub")) { + throw new Error("invalid npub"); + } + return { type: "npub", data: value.slice(4) }; + }, +}; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +test("trusted report summaries respect personal blocks and admin lists", async (t) => { + const previousTools = globalThis.NostrTools; + const previousWindowTools = globalThis.window?.NostrTools; + + globalThis.NostrTools = { nip19 }; + if (globalThis.window) { + globalThis.window.NostrTools = globalThis.NostrTools; + } + + t.after(() => { + if (typeof previousTools === "undefined") { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = previousTools; + } + + if (globalThis.window) { + if (typeof previousWindowTools === "undefined") { + delete globalThis.window.NostrTools; + } else { + globalThis.window.NostrTools = previousWindowTools; + } + } + }); + + const trustedHex = "a".repeat(64); + const whitelistedHex = "b".repeat(64); + const blacklistedHex = "c".repeat(64); + const blockedHex = "d".repeat(64); + const randomHex = "e".repeat(64); + const eventId = "f".repeat(64); + + const userBlocksMock = { + async ensureLoaded() {}, + isBlocked(pubkey) { + return pubkey === blockedHex; + }, + }; + + const accessControlMock = { + getWhitelist() { + return [`npub${whitelistedHex}`]; + }, + getBlacklist() { + return [`npub${blacklistedHex}`]; + }, + }; + + const service = new ModerationService({ + logger: () => {}, + userBlocks: userBlocksMock, + accessControl: accessControlMock, + }); + + service.trustedContacts = new Set([trustedHex]); + + const reports = new Map(); + reports.set(trustedHex, new Map([["nudity", { created_at: 100 }]])); + reports.set(whitelistedHex, new Map([["nudity", { created_at: 400 }]])); + reports.set(blacklistedHex, new Map([["nudity", { created_at: 500 }]])); + reports.set(blockedHex, new Map([["nudity", { created_at: 600 }]])); + reports.set(randomHex, new Map([["nudity", { created_at: 300 }]])); + service.reportEvents.set(eventId, reports); + + service.recomputeSummaryForEvent(eventId); + + const summary = service.getTrustedReportSummary(eventId); + assert.equal(summary.totalTrusted, 2); + assert.equal(summary.types.nudity.total, 3); + assert.equal(summary.types.nudity.trusted, 2); + assert.equal(summary.types.nudity.latest, 400); + + const reporters = service.getTrustedReporters(eventId, "nudity"); + assert.deepEqual( + reporters.map((entry) => entry.pubkey), + [whitelistedHex, trustedHex] + ); + assert.equal(service.trustedReportCount(eventId, "nudity"), 2); +}); diff --git a/tests/moderation-stage.test.mjs b/tests/moderation-stage.test.mjs new file mode 100644 index 00000000..304745f5 --- /dev/null +++ b/tests/moderation-stage.test.mjs @@ -0,0 +1,105 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createModerationStage } from "../js/feedEngine/stages.js"; + +test("moderation stage enforces admin lists and whitelist bypass", async () => { + const whitelistedHex = "1".repeat(64); + const blockedHex = "2".repeat(64); + const blacklistedHex = "3".repeat(64); + const normalHex = "4".repeat(64); + + const service = { + async refreshViewerFromClient() {}, + async setActiveEventIds() {}, + getAdminListSnapshot() { + return { + whitelist: new Set([`npub${whitelistedHex}`]), + whitelistHex: new Set([whitelistedHex]), + blacklist: new Set([`npub${blacklistedHex}`]), + blacklistHex: new Set([blacklistedHex]), + }; + }, + getAccessControlStatus(identifier) { + const normalized = typeof identifier === "string" ? identifier.trim().toLowerCase() : ""; + if (normalized === whitelistedHex) { + return { hex: whitelistedHex, whitelisted: true, blacklisted: false }; + } + if (normalized === blacklistedHex) { + return { hex: blacklistedHex, whitelisted: false, blacklisted: true }; + } + if (normalized === blockedHex) { + return { hex: blockedHex, whitelisted: false, blacklisted: false }; + } + if (normalized === normalHex) { + return { hex: normalHex, whitelisted: false, blacklisted: false }; + } + return { hex: normalized, whitelisted: false, blacklisted: false }; + }, + getTrustedReportSummary() { + return null; + }, + trustedReportCount(videoId) { + if (videoId === "c") { + return 5; + } + if (videoId === "d") { + return 3; + } + return 0; + }, + getTrustedReporters() { + return []; + }, + }; + + const stage = createModerationStage({ + service, + reportType: "nudity", + autoplayThreshold: 2, + blurThreshold: 3, + }); + + const items = [ + { video: { id: "a", pubkey: blockedHex }, metadata: {} }, + { video: { id: "b", pubkey: blacklistedHex }, metadata: {} }, + { video: { id: "c", pubkey: whitelistedHex }, metadata: {} }, + { video: { id: "d", pubkey: normalHex }, metadata: {} }, + ]; + + const why = []; + const context = { + feedName: "discovery", + runtime: { + isAuthorBlocked: (pubkey) => pubkey === blockedHex, + }, + addWhy(detail) { + why.push(detail); + return detail; + }, + log() {}, + }; + + const result = await stage(items, context); + + assert.equal(result.length, 2); + assert.deepEqual( + result.map((entry) => entry.video.id), + ["c", "d"] + ); + + const whitelistedItem = result[0]; + assert.equal(whitelistedItem.video.moderation.blockAutoplay, false); + assert.equal(whitelistedItem.video.moderation.blurThumbnail, false); + assert.equal(whitelistedItem.video.moderation.adminWhitelist, true); + assert.equal(whitelistedItem.video.moderation.adminWhitelistBypass, true); + assert.equal(whitelistedItem.metadata.moderation.adminWhitelistBypass, true); + + const normalItem = result[1]; + assert.equal(normalItem.video.moderation.blockAutoplay, true); + assert.equal(normalItem.video.moderation.blurThumbnail, true); + + const reasons = why.map((entry) => entry.reason); + assert(reasons.includes("viewer-block")); + assert(reasons.includes("admin-blacklist")); + assert(reasons.includes("blur")); +});