From ca8bbcad9b9fcb7a2945a43302a073c8dea75190 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 25 Oct 2025 15:22:52 -0400 Subject: [PATCH] Publish trusted mute lists with block updates --- js/userBlocks.js | 148 ++++++++++++++++ tests/moderation/trusted-mute-lists.test.mjs | 172 +++++++++++++++++++ tests/nostr-publish-rejection.test.mjs | 6 + tests/user-blocks.test.mjs | 36 +++- 4 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 tests/moderation/trusted-mute-lists.test.mjs diff --git a/js/userBlocks.js b/js/userBlocks.js index 2a9c6cdf..6dbb0ab1 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -246,6 +246,8 @@ class UserBlockListManager { this.blockEventId = null; this.blockEventCreatedAt = null; this.lastPublishedCreatedAt = null; + this.muteEventId = null; + this.muteEventCreatedAt = null; this.loaded = false; this.emitter = new TinyEventEmitter(); this.seedStateCache = new Map(); @@ -256,6 +258,8 @@ class UserBlockListManager { this.blockEventId = null; this.blockEventCreatedAt = null; this.lastPublishedCreatedAt = null; + this.muteEventId = null; + this.muteEventCreatedAt = null; this.loaded = false; } @@ -941,6 +945,137 @@ class UserBlockListManager { } } + async publishMuteListSnapshot({ + signer, + ownerPubkey, + blockedPubkeys = [], + createdAt, + plaintext = "", + onStatus, + } = {}) { + const owner = normalizeHex(ownerPubkey); + if (!owner || !signer || typeof signer.signEvent !== "function") { + return null; + } + + const tagSet = new Set(); + const tags = []; + + const iterateBlocked = (input) => { + if (Array.isArray(input)) { + return input; + } + if (input instanceof Set) { + return Array.from(input); + } + if (input && typeof input[Symbol.iterator] === "function") { + return Array.from(input); + } + return []; + }; + + for (const value of iterateBlocked(blockedPubkeys)) { + const normalized = normalizeHex(value); + if (!normalized || normalized === owner || tagSet.has(normalized)) { + continue; + } + tagSet.add(normalized); + tags.push(["p", normalized]); + } + + const timestamp = Number.isFinite(createdAt) + ? Math.max(0, Math.floor(createdAt)) + : Math.floor(Date.now() / 1000); + + const event = { + kind: 10000, + pubkey: owner, + created_at: timestamp, + tags, + content: "", + }; + + const payloadText = typeof plaintext === "string" ? plaintext : ""; + let encryptedContent = ""; + let encryptionTagValue = ""; + + if (payloadText) { + const encryptionCandidates = []; + + if (typeof signer.nip44Encrypt === "function") { + encryptionCandidates.push({ tag: "nip44_v2", encrypt: signer.nip44Encrypt }); + } + + if (typeof signer.nip04Encrypt === "function") { + encryptionCandidates.push({ tag: "nip04", encrypt: signer.nip04Encrypt }); + } + + for (const candidate of encryptionCandidates) { + try { + const encrypted = await candidate.encrypt(owner, payloadText); + if (typeof encrypted === "string" && encrypted) { + encryptedContent = encrypted; + encryptionTagValue = candidate.tag; + break; + } + } catch (error) { + // Ignore encryption failures and fall back to the next candidate. + } + } + } + + if (encryptedContent) { + event.content = encryptedContent; + if (encryptionTagValue) { + event.tags.push(["encrypted", encryptionTagValue]); + } + } + + onStatus?.({ status: "mute-publishing" }); + + let signedEvent; + try { + signedEvent = await signer.signEvent(event); + } catch (error) { + const wrapped = + error instanceof Error ? error : new Error(String(error || "mute-signature-failed")); + wrapped.code = wrapped.code || "mute-signature-failed"; + throw wrapped; + } + + const publishResults = await publishEventToRelays( + nostrClient.pool, + nostrClient.relays, + signedEvent, + ); + + const summary = assertAnyRelayAccepted(publishResults, { context: "mute list" }); + + if (summary.failed.length) { + summary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + userLogger.warn( + `[UserBlockList] Mute list not accepted by ${url}: ${reason}`, + relayError, + ); + }); + } + + this.muteEventId = signedEvent.id; + this.muteEventCreatedAt = Number.isFinite(signedEvent?.created_at) + ? signedEvent.created_at + : event.created_at; + + onStatus?.({ status: "mute-published", event: signedEvent }); + + return signedEvent; + } + async publishBlockList(userPubkey, options = {}) { const onStatus = options && typeof options.onStatus === "function" ? options.onStatus : null; @@ -1062,6 +1197,19 @@ class UserBlockListManager { this.lastPublishedCreatedAt = this.blockEventCreatedAt; onStatus?.({ status: "published", event: signedEvent }); + try { + await this.publishMuteListSnapshot({ + signer, + ownerPubkey: normalized, + blockedPubkeys: payload.blockedPubkeys, + createdAt: event.created_at, + plaintext, + onStatus, + }); + } catch (muteError) { + userLogger.warn("[UserBlockList] Failed to publish mute list", muteError); + } + try { await this.loadBlocks(normalized, { since: this.lastPublishedCreatedAt ?? undefined, diff --git a/tests/moderation/trusted-mute-lists.test.mjs b/tests/moderation/trusted-mute-lists.test.mjs new file mode 100644 index 00000000..108d70c3 --- /dev/null +++ b/tests/moderation/trusted-mute-lists.test.mjs @@ -0,0 +1,172 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, + DEFAULT_BLUR_THRESHOLD, + DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, +} from "../../js/constants.js"; +import { createModerationStage } from "../../js/feedEngine/stages.js"; +import { withMockedNostrTools, createModerationServiceHarness } from "../helpers/moderation-test-helpers.mjs"; + +const { userBlocks } = await import("../../js/userBlocks.js"); +const { nostrClient } = await import("../../js/nostrClientFacade.js"); +const { setActiveSigner, clearActiveSigner } = await import("../../js/nostr.js"); + +test("trusted mute lists from seeds hide authors for anonymous viewers", async (t) => { + withMockedNostrTools(t); + + const seedHex = "1".repeat(64); + const mutedHex = "2".repeat(64); + + const originalBlocked = new Set(userBlocks.blockedPubkeys); + const originalBlockEventId = userBlocks.blockEventId; + const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt; + const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt; + const originalMuteEventId = userBlocks.muteEventId; + const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt; + const originalLoaded = userBlocks.loaded; + const originalLoadBlocks = userBlocks.loadBlocks; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalPool = nostrClient.pool; + + t.after(() => { + userBlocks.blockedPubkeys = originalBlocked; + userBlocks.blockEventId = originalBlockEventId; + userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; + userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt; + userBlocks.muteEventId = originalMuteEventId; + userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt; + userBlocks.loaded = originalLoaded; + userBlocks.loadBlocks = originalLoadBlocks; + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.pool = originalPool; + clearActiveSigner(); + }); + + const publishedEvents = []; + nostrClient.relays = ["wss://seed-relay.example"]; + nostrClient.writeRelays = nostrClient.relays; + nostrClient.pool = { + publish: (_targets, event) => { + publishedEvents.push(event); + return { + on(eventName, handler) { + if (eventName === "ok") { + setImmediate(handler); + return true; + } + return false; + }, + }; + }, + list: async () => [], + }; + + userBlocks.loadBlocks = async () => {}; + userBlocks.blockedPubkeys = new Set(); + userBlocks.blockEventId = null; + userBlocks.blockEventCreatedAt = null; + userBlocks.lastPublishedCreatedAt = null; + userBlocks.muteEventId = null; + userBlocks.muteEventCreatedAt = null; + userBlocks.loaded = true; + + const signedEvents = []; + setActiveSigner({ + type: "private-key", + pubkey: seedHex, + nip04Encrypt: async (pubkey, plaintext) => { + assert.equal(pubkey, seedHex, "nip04 encrypt should target the seed pubkey"); + return `nip04:${plaintext}`; + }, + nip44Encrypt: async (pubkey, plaintext) => { + assert.equal(pubkey, seedHex, "nip44 encrypt should target the seed pubkey"); + return `nip44:${plaintext}`; + }, + signEvent: async (event) => { + const signed = { + ...event, + id: `event-${signedEvents.length + 1}`, + created_at: event.created_at ?? Math.floor(Date.now() / 1000), + }; + signedEvents.push(signed); + return signed; + }, + }); + + await userBlocks.addBlock(mutedHex, seedHex); + + const muteEvent = signedEvents.find((event) => event.kind === 10000); + assert(muteEvent, "block list updates should publish a kind 10000 mute list event"); + assert( + Array.isArray(muteEvent.tags) && + muteEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === mutedHex), + "mute list event should include p-tags for blocked pubkeys", + ); + assert.equal( + publishedEvents.filter((event) => event?.kind === 10000).length, + 1, + "mute list event should be published to relays", + ); + + const { service } = createModerationServiceHarness(t, { userBlocks }); + service.refreshViewerFromClient = async () => {}; + service.setActiveEventIds = async () => {}; + service.subscribeToReports = async () => {}; + service.teardownReportSubscription = () => {}; + service.getAdminListSnapshot = () => ({ + whitelist: new Set(), + whitelistHex: new Set(), + blacklist: new Set(), + blacklistHex: new Set(), + }); + service.getAccessControlStatus = () => ({ whitelisted: false, blacklisted: false, hex: "" }); + + service.setTrustedSeeds([seedHex]); + service.ingestTrustedMuteEvent(muteEvent); + + assert.equal( + service.isAuthorMutedByTrusted(mutedHex), + true, + "trusted mute ingestion should flag the author as muted", + ); + + const stage = createModerationStage({ + service, + autoplayThreshold: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, + blurThreshold: DEFAULT_BLUR_THRESHOLD, + trustedMuteHideThreshold: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, + reportType: "nudity", + }); + + const items = [{ video: { id: "video-muted", pubkey: mutedHex }, metadata: {} }]; + const why = []; + const context = { + runtime: { isAuthorBlocked: () => false }, + addWhy(detail) { + why.push(detail); + return detail; + }, + log() {}, + }; + + const result = await stage(items, context); + + assert.equal(result.length, 0, "trusted mute threshold should hide the muted author"); + const reasons = why.map((entry) => entry.reason); + assert( + reasons.includes("trusted-mute"), + "moderation stage should record trusted mute metadata", + ); + assert( + reasons.includes("trusted-mute-hide"), + "moderation stage should record the trusted mute hide reason", + ); +}); diff --git a/tests/nostr-publish-rejection.test.mjs b/tests/nostr-publish-rejection.test.mjs index 116e3f9b..e97d8556 100644 --- a/tests/nostr-publish-rejection.test.mjs +++ b/tests/nostr-publish-rejection.test.mjs @@ -236,8 +236,12 @@ try { const originalBlocked = new Set(userBlocks.blockedPubkeys); const originalBlockEventId = userBlocks.blockEventId; + const originalMuteEventId = userBlocks.muteEventId; + const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt; userBlocks.blockedPubkeys = new Set(["abcd".repeat(16)]); userBlocks.blockEventId = null; + userBlocks.muteEventId = null; + userBlocks.muteEventCreatedAt = null; try { await assert.rejects( @@ -273,6 +277,8 @@ try { } finally { userBlocks.blockedPubkeys = originalBlocked; userBlocks.blockEventId = originalBlockEventId; + userBlocks.muteEventId = originalMuteEventId; + userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt; restoreRelayState(nostrClient, relaySnapshot); } } diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index bca89d04..30f71fee 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -32,6 +32,8 @@ await (async () => { : nostrClient.writeRelays; const originalBlocked = new Set(userBlocks.blockedPubkeys); const originalBlockEventId = userBlocks.blockEventId; + const originalMuteEventId = userBlocks.muteEventId; + const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt; const originalLoaded = userBlocks.loaded; const calls = []; @@ -124,6 +126,8 @@ await (async () => { } finally { userBlocks.blockedPubkeys = originalBlocked; userBlocks.blockEventId = originalBlockEventId; + userBlocks.muteEventId = originalMuteEventId; + userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt; userBlocks.loaded = originalLoaded; nostrClient.pool = originalPool; nostrClient.relays = originalRelays; @@ -154,6 +158,8 @@ await (async () => { const originalBlocked = new Set(userBlocks.blockedPubkeys); const originalBlockEventId = userBlocks.blockEventId; const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt; + const originalMuteEventId = userBlocks.muteEventId; + const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt; const originalLoaded = userBlocks.loaded; const calls = []; @@ -277,6 +283,8 @@ await (async () => { userBlocks.blockedPubkeys = originalBlocked; userBlocks.blockEventId = originalBlockEventId; userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; + userBlocks.muteEventId = originalMuteEventId; + userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt; userBlocks.loaded = originalLoaded; nostrClient.pool = originalPool; nostrClient.relays = originalRelays; @@ -298,6 +306,8 @@ await (async () => { const originalBlockEventId = userBlocks.blockEventId; const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt; const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt; + const originalMuteEventId = userBlocks.muteEventId; + const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt; const originalLoaded = userBlocks.loaded; const originalSeedStateCache = userBlocks.seedStateCache; const originalNostrTools = window.NostrTools; @@ -343,6 +353,8 @@ await (async () => { userBlocks.blockEventId = null; userBlocks.blockEventCreatedAt = null; userBlocks.lastPublishedCreatedAt = null; + userBlocks.muteEventId = null; + userBlocks.muteEventCreatedAt = null; userBlocks.loaded = false; userBlocks.seedStateCache = new Map(); @@ -435,6 +447,8 @@ await (async () => { userBlocks.blockEventId = originalBlockEventId; userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt; + userBlocks.muteEventId = originalMuteEventId; + userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt; userBlocks.loaded = originalLoaded; userBlocks.seedStateCache = originalSeedStateCache; window.NostrTools = originalNostrTools; @@ -450,6 +464,8 @@ await (async () => { const originalBlockEventId = userBlocks.blockEventId; const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt; const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt; + const originalMuteEventId = userBlocks.muteEventId; + const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt; const originalLoaded = userBlocks.loaded; const originalPool = nostrClient.pool; const originalRelays = Array.isArray(nostrClient.relays) @@ -478,6 +494,8 @@ await (async () => { [latestCiphertext, JSON.stringify({ blockedPubkeys: [] })], ]); + const signedEvents = []; + nostrClient.relays = ["wss://direct-signer.example"]; nostrClient.writeRelays = nostrClient.relays; nostrClient.pool = { @@ -530,13 +548,19 @@ await (async () => { decryptPayloads.set(cipher, plaintext); return cipher; }, + nip44Encrypt: async (pubkey, plaintext) => { + assert.equal(pubkey, actor, "nip44 encrypt should target the actor pubkey"); + return `nip44:${plaintext}`; + }, signEvent: async (event) => { eventCounter += 1; - return { + const signed = { ...event, id: `event-${eventCounter}`, created_at: event.created_at ?? Math.floor(Date.now() / 1000), }; + signedEvents.push(signed); + return signed; }, }); @@ -582,11 +606,21 @@ await (async () => { 0, "direct signer flows should never trigger extension permission prompts", ); + + const muteEvent = signedEvents.find((event) => event.kind === 10000); + assert(muteEvent, "block list updates should publish a kind 10000 mute list event"); + assert( + Array.isArray(muteEvent.tags) && + muteEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === target), + "mute list event should include p-tags for blocked pubkeys", + ); } finally { userBlocks.blockedPubkeys = originalBlocked; userBlocks.blockEventId = originalBlockEventId; userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt; + userBlocks.muteEventId = originalMuteEventId; + userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt; userBlocks.loaded = originalLoaded; nostrClient.pool = originalPool; nostrClient.relays = originalRelays;