Merge pull request #888 from PR0M3TH3AN/codex/implement-nip-51-mute-list-and-tests

Publish trusted mute lists with block updates
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-25 15:30:11 -04:00
commit da9c19607d
4 files changed
+361 -1

No files matched your search

+148
View File
@@ -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,
@@ -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",
);
});
+6
View File
@@ -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);
}
}
+35 -1
View File
@@ -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;