Files
bitvid/js/services/moderationService.js
T
thePR0M3TH3ANandClaude Opus 4.8 25d3e83c46 refactor(moderation): batch kind-1984 report subs via SubscriptionManager (P2)
Replaces the per-video live report subscription (one pool.sub per visible
video × ~20 relays, re-fired on every reconnect — the ~1000 REQ/s storm) with
ONE batched SubscriptionManager subscription over the whole active id set,
updated in place as the set changes. Backfill fetches history only for
newly-added ids (one batched list()). Unchanged sets are a no-op (dedup guard).

MODERATION_REPORT_SUBS_ENABLED flipped back on. Trusted-mute subs were already
batched-by-author, so the relay cap + contact-loop fix already tamed them.

Legacy per-id path retained only for SubscriptionManager-less environments
(unit tests / mock clients). All 39 moderation tests pass; new cheat-resistant
batching test added; nip07-heat shows reports no longer storm (steady idle).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-14 15:04:40 -04:00

2291 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview
* Moderation Service - Decentralized Trust & Safety.
*
* Implements a "Web of Trust" model where content visibility and safety actions (blur, hide)
* are determined by aggregating reports (NIP-56) and mutes (NIP-51) from the user's
* trusted social graph (follows).
*
* Flow:
* 1. setViewerPubkey(pubkey) loads the user's Kind 3 contacts -> trustedContacts.
* 2. subscribeToReports(eventId) listens for Kind 1984 reports.
* 3. ingestReportEvent(event) stores reports in memory.
* 4. recomputeSummaryForEvent(eventId) checks if reporter is in trustedContacts.
* 5. If trusted reports > threshold, emits 'summary' event to update UI.
*/
import {
getActiveSigner,
nostrClient,
requestDefaultExtensionPermissions,
} from "../nostrClientFacade.js";
import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js";
import { accessControl } from "../accessControl.js";
import { userBlocks, USER_BLOCK_EVENTS } from "../userBlocks.js";
import { buildReportEvent, KIND_MUTE_LIST } from "../nostrEventSchemas.js";
import logger from "../utils/logger.js";
import { DEBOUNCE_DELAY_MS } from "../constants.js";
import {
normalizeUserLogger,
SimpleEventEmitter,
normalizeLogger,
normalizeHex,
normalizeEventId,
normalizeReportType,
normalizeMuteCategory,
extractMuteCategoryFromTag,
cloneSummary,
encodeToNpub,
normalizeToHex,
createEmptyAdminSnapshot,
resolveRelayList,
findReportedEventId,
extractReportType,
ensureNumber,
resolveStorage,
AUTOPLAY_TRUST_THRESHOLD,
BLUR_TRUST_THRESHOLD,
TRUSTED_MUTE_WINDOW_SECONDS,
decodeToHex
} from "./moderationUtils.js";
/**
* ModerationService
*
* Central authority for Trust & Safety.
* Implements a "Web of Trust" model where content visibility and safety actions (blur, hide)
* are determined by aggregating reports (NIP-56) and mutes (NIP-51) from the user's
* trusted social graph (follows).
*/
// Live kind-1984 report subscriptions. Previously one sub PER VIDEO fanned
// O(videos × relays) and stormed relays (~1000 REQ/s). Now batched into a single
// SubscriptionManager subscription over the whole active id set, so this is back
// on. (Set false to fall back to the disabled state for debugging.)
const MODERATION_REPORT_SUBS_ENABLED = true;
// Max event ids per kind-1984 filter chunk. nostr REQ allows multiple filters in
// one subscription, so a large active set becomes a few chunks in ONE sub —
// still O(1) subscriptions, not O(ids).
const REPORT_BATCH_SIZE = 200;
function setsEqual(a, b) {
if (a === b) return true;
if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size) return false;
for (const v of a) {
if (!b.has(v)) return false;
}
return true;
}
// Build kind-1984 report filters for a set of event ids, chunked so no single
// filter carries an unbounded "#e" array.
function buildReportFilters(ids) {
const list = Array.isArray(ids) ? ids : Array.from(ids || []);
const filters = [];
for (let i = 0; i < list.length; i += REPORT_BATCH_SIZE) {
filters.push({
kinds: [1984],
"#e": list.slice(i, i + REPORT_BATCH_SIZE),
limit: 500,
});
}
return filters;
}
export class ModerationService {
/**
* @param {object} options
* @param {object} [options.nostrClient] Nostr client.
* @param {object} [options.logger] System logger.
* @param {object} [options.userBlocks] User block manager.
* @param {object} [options.accessControl] Admin access control.
* @param {object} [options.userLogger] User-facing logger.
* @param {function} [options.requestExtensionPermissions] NIP-07 helper.
*/
constructor({
nostrClient: client = null,
logger: log = null,
userBlocks: userBlockManager = null,
accessControl: accessControlService = null,
userLogger: userChannel = null,
requestExtensionPermissions: permissionRequester = null,
} = {}) {
this.nostrClient = client;
this.log = normalizeLogger(log);
this.userLogger = normalizeUserLogger(userChannel);
this.requestExtensionPermissions = permissionRequester || requestDefaultExtensionPermissions;
this.viewerPubkey = "";
this.viewerIsSessionActor = false;
this.trustedContacts = new Set();
this.trustedSeedContacts = new Set();
this.viewerContacts = new Set();
this.userBlocks = null;
this.accessControl = accessControlService;
this.viewerMuteList = new Set();
this.viewerMuteEventId = "";
this.viewerMuteUpdatedAt = 0;
this.viewerMutePromise = null;
this.contactSubscription = null;
this.contactListPromise = null;
this.lastContactEventId = null;
this.reportEvents = new Map();
this.reportSummaries = new Map();
this.activeSubscriptions = new Map();
this.activeEventIds = new Set();
this.trustedMuteLists = new Map();
this.trustedMutedAuthors = new Map();
this.trustedMuteSubscriptions = new Map(); // Kept for legacy/individual manual subs if needed, but primary logic is now batched
this.trustedSeedOnly = false;
this.batchedMuteSubscriptions = [];
this.muteRefreshTimer = null;
this.emitter = new SimpleEventEmitter((message, error) => {
try {
this.log(message, error);
} catch (logError) {
logger.user.warn("[moderationService] logger threw", logError);
}
});
this.userBlockUnsubscribe = null;
this.userBlockRefreshQueue = Promise.resolve();
this.setUserBlocks(userBlockManager);
}
setUserLogger(channel) {
this.userLogger = normalizeUserLogger(channel);
}
mergeSeedsIntoSet(base = null) {
const merged = new Set();
if (base instanceof Set || Array.isArray(base)) {
for (const value of base) {
const normalized = normalizeHex(value);
if (normalized) {
merged.add(normalized);
}
}
} else if (base && typeof base[Symbol.iterator] === "function") {
for (const value of base) {
const normalized = normalizeHex(value);
if (normalized) {
merged.add(normalized);
}
}
}
if (this.trustedSeedContacts instanceof Set) {
const adminSnapshot = this.getAdminListSnapshot();
const blacklistHex = adminSnapshot.blacklistHex;
for (const seed of this.trustedSeedContacts) {
const normalized = normalizeHex(seed);
if (normalized && (!blacklistHex || !blacklistHex.has(normalized))) {
merged.add(normalized);
}
}
}
return merged;
}
computeTrustedSeedOnly() {
const trustedContacts =
this.trustedContacts instanceof Set ? this.trustedContacts : new Set();
const trustedSeedContacts =
this.trustedSeedContacts instanceof Set ? this.trustedSeedContacts : new Set();
if (!trustedSeedContacts.size || trustedContacts.size !== trustedSeedContacts.size) {
return false;
}
for (const value of trustedContacts) {
if (!trustedSeedContacts.has(value)) {
return false;
}
}
return true;
}
updateTrustedSeedOnlyStatus() {
const nextValue = this.computeTrustedSeedOnly();
if (nextValue === this.trustedSeedOnly) {
return;
}
this.trustedSeedOnly = nextValue;
this.emit("trusted-seed-only", { seedOnly: nextValue });
}
rebuildTrustedContacts(nextContacts = new Set(), { previous = null } = {}) {
// Snapshot the trusted set BEFORE we rebuild so we can detect whether it
// actually changed. Emitting "contacts" unconditionally caused a tight loop:
// profileModalController listens for "contacts" -> populateFriendsList ->
// ensureViewerContactsLoaded -> (empty contacts) -> fetchTrustedContacts ->
// clearTrustedMuteTracking -> rebuildTrustedContacts(empty) -> emit again,
// spinning the CPU and flooding relays with kind-3 REQs on every login.
const priorTrusted =
this.trustedContacts instanceof Set
? new Set(this.trustedContacts)
: new Set();
const sanitized = new Set();
if (nextContacts instanceof Set || Array.isArray(nextContacts)) {
for (const value of nextContacts) {
const normalized = normalizeHex(value);
if (normalized) {
sanitized.add(normalized);
}
}
} else if (nextContacts && typeof nextContacts[Symbol.iterator] === "function") {
for (const value of nextContacts) {
const normalized = normalizeHex(value);
if (normalized) {
sanitized.add(normalized);
}
}
}
this.viewerContacts = sanitized;
const previousContacts =
previous instanceof Set
? new Set(previous)
: this.trustedContacts instanceof Set
? new Set(this.trustedContacts)
: new Set();
const seededNext = this.mergeSeedsIntoSet(sanitized);
this.trustedContacts = seededNext;
this.updateTrustedSeedOnlyStatus();
// Only notify/recompute when the trusted set actually changed. This breaks
// the emit -> fetch -> emit loop above while still firing on real changes
// (first population, follows/unfollows, seed updates).
let changed = priorTrusted.size !== seededNext.size;
if (!changed) {
for (const value of seededNext) {
if (!priorTrusted.has(value)) {
changed = true;
break;
}
}
}
if (changed) {
this.emit("contacts", { size: seededNext.size });
this.recomputeAllSummaries();
}
this.reconcileTrustedMuteSubscriptions(previousContacts, seededNext);
}
getViewerContactsSnapshot({ includeViewer = false } = {}) {
const contacts =
this.viewerContacts instanceof Set ? this.viewerContacts : new Set();
const viewer = normalizeHex(this.viewerPubkey);
const list = [];
for (const value of contacts) {
const normalized = normalizeHex(value);
if (!normalized) {
continue;
}
if (!includeViewer && viewer && normalized === viewer) {
continue;
}
list.push({
pubkey: normalized,
npub: encodeToNpub(normalized) || "",
});
}
return list;
}
async ensureViewerContactsLoaded(pubkey = null) {
const target = normalizeHex(pubkey);
const viewer = normalizeHex(this.viewerPubkey);
if (target && target !== viewer) {
try {
await this.setViewerPubkey(target);
} catch (error) {
this.log(
"[moderationService] failed to set viewer pubkey while ensuring contacts",
error,
);
}
return this.getViewerContactsSnapshot();
}
if (this.contactListPromise) {
try {
await this.contactListPromise;
} catch (error) {
this.log(
"[moderationService] contact hydration failed while ensuring contacts",
error,
);
}
} else if (viewer) {
const hasContacts =
this.viewerContacts instanceof Set && this.viewerContacts.size > 0;
if (!hasContacts) {
try {
await this.fetchTrustedContacts(viewer);
} catch (error) {
this.log(
"[moderationService] failed to fetch viewer contacts while ensuring contacts",
error,
);
}
}
}
return this.getViewerContactsSnapshot();
}
setTrustedSeeds(seeds = []) {
this.log("[moderationService] setTrustedSeeds called", { count: seeds?.length || seeds?.size || 0 });
const sanitizedSeeds = new Set();
const adminSnapshot = this.getAdminListSnapshot();
if (seeds instanceof Set || Array.isArray(seeds)) {
for (const candidate of seeds) {
const normalized = normalizeToHex(candidate);
const status = this.getAccessControlStatus(candidate, adminSnapshot);
if (normalized && !status.blacklisted) {
sanitizedSeeds.add(normalized);
}
}
} else if (seeds && typeof seeds[Symbol.iterator] === "function") {
for (const candidate of seeds) {
const normalized = normalizeToHex(candidate);
const status = this.getAccessControlStatus(candidate, adminSnapshot);
if (normalized && !status.blacklisted) {
sanitizedSeeds.add(normalized);
}
}
}
const previousContacts =
this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set();
this.trustedSeedContacts = sanitizedSeeds;
this.rebuildTrustedContacts(this.viewerContacts, { previous: previousContacts });
}
isTrustedSeedOnly() {
return this.trustedSeedOnly === true;
}
logThresholdTransitions({
eventId = "",
reportType = "",
previousTrusted = 0,
nextTrusted = 0,
} = {}) {
const normalizedEventId = normalizeEventId(eventId);
const normalizedType = normalizeReportType(reportType);
if (!normalizedEventId || !normalizedType) {
return;
}
const previous = Number.isFinite(previousTrusted) ? previousTrusted : 0;
const next = Number.isFinite(nextTrusted) ? nextTrusted : 0;
const transitions = [];
if (AUTOPLAY_TRUST_THRESHOLD > 0) {
const previousMet = previous >= AUTOPLAY_TRUST_THRESHOLD;
const nextMet = next >= AUTOPLAY_TRUST_THRESHOLD;
if (previousMet !== nextMet) {
transitions.push({
action: nextMet ? "autoplay-block-enabled" : "autoplay-block-cleared",
});
}
}
if (BLUR_TRUST_THRESHOLD > 0) {
const previousMet = previous >= BLUR_TRUST_THRESHOLD;
const nextMet = next >= BLUR_TRUST_THRESHOLD;
if (previousMet !== nextMet) {
transitions.push({
action: nextMet ? "blur-enabled" : "blur-cleared",
});
}
}
if (!transitions.length) {
return;
}
const channel = this.userLogger || logger.user;
for (const entry of transitions) {
try {
channel.info("[moderationService] moderation threshold crossed", {
eventId: normalizedEventId,
reportType: normalizedType,
trustedCount: next,
action: entry.action,
});
} catch (error) {
logger.user.warn("[moderationService] failed to log threshold transition", error);
}
}
}
getTrustedMuteWindowCutoff(nowSeconds = Date.now() / 1000) {
const normalizedNow = ensureNumber(nowSeconds) || Date.now() / 1000;
return normalizedNow - TRUSTED_MUTE_WINDOW_SECONDS;
}
pruneTrustedMuteAggregates(targetAuthors = null, { nowSeconds = Date.now() / 1000 } = {}) {
const cutoff = this.getTrustedMuteWindowCutoff(nowSeconds);
const targets =
targetAuthors instanceof Set || Array.isArray(targetAuthors)
? Array.from(targetAuthors)
: targetAuthors && typeof targetAuthors[Symbol.iterator] === "function"
? Array.from(targetAuthors)
: null;
const entries = targets
? targets.map((author) => [author, this.trustedMutedAuthors.get(author)])
: Array.from(this.trustedMutedAuthors.entries());
for (const [author, aggregate] of entries) {
if (!aggregate || !(aggregate.muters instanceof Map)) {
continue;
}
// Throttle pruning: only run once per minute per author
if (aggregate.lastPrunedAt && nowSeconds - aggregate.lastPrunedAt < 60) continue;
aggregate.lastPrunedAt = nowSeconds;
const expiredOwners = new Set();
for (const [owner, updatedAt] of aggregate.muters.entries()) {
if (ensureNumber(updatedAt) < cutoff) {
aggregate.muters.delete(owner);
expiredOwners.add(owner);
}
}
if (aggregate.categories instanceof Map && expiredOwners.size) {
for (const [category, muters] of aggregate.categories.entries()) {
for (const owner of expiredOwners) {
muters.delete(owner);
}
if (!muters.size) {
aggregate.categories.delete(category);
}
}
}
aggregate.count = aggregate.muters.size;
if (!aggregate.muters.size) {
this.trustedMutedAuthors.delete(author);
}
}
}
getActiveTrustedMutersForAuthor(author) {
const normalized = normalizeHex(author);
if (!normalized) {
return [];
}
const entry = this.trustedMutedAuthors.get(normalized);
if (!entry || !(entry.muters instanceof Map)) {
return [];
}
this.pruneTrustedMuteAggregates([normalized]);
const refreshed = this.trustedMutedAuthors.get(normalized);
if (!refreshed || !(refreshed.muters instanceof Map)) {
return [];
}
return Array.from(refreshed.muters.keys());
}
getTrustedMuteCountsForAuthor(author) {
const normalized = normalizeHex(author);
if (!normalized) {
return { total: 0, categories: {} };
}
const entry = this.trustedMutedAuthors.get(normalized);
if (!entry || !(entry.muters instanceof Map)) {
return { total: 0, categories: {} };
}
this.pruneTrustedMuteAggregates([normalized]);
const refreshed = this.trustedMutedAuthors.get(normalized);
if (!refreshed || !(refreshed.muters instanceof Map)) {
return { total: 0, categories: {} };
}
const categories = {};
if (refreshed.categories instanceof Map) {
for (const [category, muters] of refreshed.categories.entries()) {
if (typeof category !== "string" || !category.trim()) {
continue;
}
categories[category] = muters instanceof Map ? muters.size : 0;
}
}
return {
total: refreshed.muters.size,
categories,
};
}
/**
* Batch retrieval of moderation info for multiple authors.
* Optimized to prune aggregates in one pass.
*
* @param {string[]} authors - List of author pubkeys.
* @returns {Map<string, object>} Map of author -> { trustedMuted, trustedMuters, trustedMuteCountTotal, trustedMuteCountsByCategory }
*/
getModerationInfoForAuthors(authors) {
const results = new Map();
const uniqueAuthors = new Set();
if (Array.isArray(authors)) {
for (const author of authors) {
const normalized = normalizeHex(author);
if (normalized) {
uniqueAuthors.add(normalized);
}
}
} else if (authors instanceof Set) {
for (const author of authors) {
const normalized = normalizeHex(author);
if (normalized) {
uniqueAuthors.add(normalized);
}
}
}
if (!uniqueAuthors.size) {
return results;
}
this.pruneTrustedMuteAggregates(Array.from(uniqueAuthors));
for (const author of uniqueAuthors) {
const entry = this.trustedMutedAuthors.get(author);
let trustedMuted = false;
let trustedMuters = [];
let trustedMuteCountTotal = 0;
let trustedMuteCountsByCategory = null;
if (entry && entry.muters instanceof Map && entry.muters.size > 0) {
trustedMuted = true;
trustedMuters = Array.from(entry.muters.keys());
trustedMuteCountTotal = entry.muters.size;
if (entry.categories instanceof Map) {
trustedMuteCountsByCategory = {};
for (const [category, muters] of entry.categories.entries()) {
if (typeof category === "string" && category.trim()) {
trustedMuteCountsByCategory[category] = muters.size;
}
}
}
}
results.set(author, {
trustedMuted,
trustedMuters,
trustedMuteCountTotal,
trustedMuteCountsByCategory,
});
}
return results;
}
setLogger(newLogger) {
this.log = normalizeLogger(newLogger);
}
setNostrClient(client) {
if (client && client !== this.nostrClient) {
this.nostrClient = client;
}
}
setUserBlocks(manager) {
if (manager === this.userBlocks) {
return;
}
if (typeof this.userBlockUnsubscribe === "function") {
try {
this.userBlockUnsubscribe();
} catch (error) {
this.log("[moderationService] failed to teardown user block listener", error);
}
}
this.userBlockUnsubscribe = null;
this.userBlocks = manager || null;
if (!this.userBlocks || typeof this.userBlocks.on !== "function") {
return;
}
try {
const unsubscribe = this.userBlocks.on(
USER_BLOCK_EVENTS.CHANGE,
(detail) => {
this.queueUserBlockRefresh(detail);
},
);
if (typeof unsubscribe === "function") {
this.userBlockUnsubscribe = () => {
try {
unsubscribe();
} catch (error) {
this.log(
"[moderationService] failed to remove user block listener",
error,
);
}
};
}
} catch (error) {
this.log("[moderationService] failed to subscribe to user block events", error);
}
}
setAccessControl(control) {
if (control && control !== this.accessControl) {
this.accessControl = control;
}
}
on(eventName, handler) {
return this.emitter.on(eventName, handler);
}
emit(eventName, detail) {
this.emitter.emit(eventName, detail);
}
queueUserBlockRefresh(detail = {}) {
const previous = this.userBlockRefreshQueue || Promise.resolve();
const action = typeof detail?.action === "string" ? detail.action : "";
const targetPubkey = detail?.targetPubkey || detail?.pubkey || "";
const normalizedTarget = normalizeHex(targetPubkey);
const next = previous
.catch(() => {})
.then(async () => {
this.recomputeAllSummaries();
if (this.activeEventIds && this.activeEventIds.size) {
await this.refreshActiveReportSubscriptions();
}
this.emit("user-blocks", {
action,
targetPubkey: normalizedTarget,
});
});
this.userBlockRefreshQueue = next.catch((error) => {
this.log("[moderationService] failed to refresh after user block update", error);
throw error;
});
return this.userBlockRefreshQueue;
}
clearTrustedMuteTracking({ previousContacts = null } = {}) {
void previousContacts;
// Clear legacy individual subscriptions
for (const [pubkey, entry] of this.trustedMuteSubscriptions.entries()) {
if (entry && typeof entry.unsub === "function") {
try {
entry.unsub();
} catch (error) {
this.log(`(moderationService) failed to teardown mute subscription for ${pubkey}`, error);
}
}
}
this.trustedMuteSubscriptions.clear();
// Clear batched subscriptions
this.teardownBatchedMuteSubscriptions();
if (this.muteRefreshTimer) {
clearTimeout(this.muteRefreshTimer);
this.muteRefreshTimer = null;
}
for (const [author, aggregate] of this.trustedMutedAuthors.entries()) {
if (aggregate && aggregate.muters instanceof Map) {
aggregate.muters.clear();
}
}
this.trustedMuteLists.clear();
this.trustedMutedAuthors.clear();
this.viewerMuteList.clear();
this.viewerMuteEventId = "";
this.viewerMuteUpdatedAt = 0;
this.emit("trusted-mutes", { total: 0 });
const emptyPrevious = new Set();
this.rebuildTrustedContacts(new Set(), { previous: emptyPrevious });
}
teardownBatchedMuteSubscriptions() {
if (Array.isArray(this.batchedMuteSubscriptions)) {
for (const sub of this.batchedMuteSubscriptions) {
if (sub && typeof sub.unsub === "function") {
try {
sub.unsub();
} catch (error) {
this.log("[moderationService] failed to unsub batched subscription", error);
}
}
}
}
this.batchedMuteSubscriptions = [];
}
isTrustedMuteOwner(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return false;
}
if (normalized === this.viewerPubkey) {
return true;
}
return this.trustedContacts.has(normalized);
}
reconcileTrustedMuteSubscriptions(previousSet, nextSet) {
this.scheduleTrustedMuteSubscriptionRefresh();
}
scheduleTrustedMuteSubscriptionRefresh() {
if (this.muteRefreshTimer) {
clearTimeout(this.muteRefreshTimer);
}
this.muteRefreshTimer = setTimeout(() => {
this.refreshTrustedMuteSubscriptions().catch((error) => {
this.log("[moderationService] refreshTrustedMuteSubscriptions failed", error);
});
}, DEBOUNCE_DELAY_MS);
}
async refreshTrustedMuteSubscriptions() {
this.muteRefreshTimer = null;
const targets = new Set();
if (this.trustedContacts instanceof Set) {
for (const contact of this.trustedContacts) {
targets.add(contact);
}
}
if (this.viewerPubkey) {
targets.add(this.viewerPubkey);
}
if (targets.size === 0) {
this.teardownBatchedMuteSubscriptions();
return;
}
try {
await this.ensurePool();
} catch (error) {
this.log("[moderationService] ensurePool failed during mute refresh", error);
return;
}
const relays = resolveRelayList(this.nostrClient);
if (!relays.length) {
this.log("[moderationService] no relays available for mute refresh");
return;
}
this.teardownBatchedMuteSubscriptions();
const authors = Array.from(targets);
const BATCH_SIZE = 400;
const chunks = [];
for (let i = 0; i < authors.length; i += BATCH_SIZE) {
chunks.push(authors.slice(i, i + BATCH_SIZE));
}
this.log(`[moderationService] Refreshing mute subscriptions for ${authors.length} authors in ${chunks.length} batches`);
for (const chunk of chunks) {
const filter = { kinds: [KIND_MUTE_LIST], authors: chunk };
try {
const sub = this.nostrClient.pool.sub(relays, [filter]);
sub.on("event", (event) => {
this.ingestTrustedMuteEvent(event);
});
sub.on("eose", () => {
// Optional: handle EOSE if needed, e.g., for loading indicators
});
this.batchedMuteSubscriptions.push(sub);
} catch (error) {
this.log("[moderationService] failed to create batched subscription", error);
}
}
}
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);
}
}
async subscribeToTrustedMuteList(pubkey) {
// Deprecated: Logic moved to batched subscription refresh
return this.scheduleTrustedMuteSubscriptionRefresh();
}
teardownTrustedMuteSubscription(pubkey) {
// Deprecated: Logic moved to batched subscription refresh
return this.scheduleTrustedMuteSubscriptionRefresh();
}
replaceTrustedMuteList(ownerPubkey, mutedAuthors, { createdAt = 0, eventId = "" } = {}) {
const owner = normalizeHex(ownerPubkey);
if (!owner) {
return;
}
const sanitizedAuthors = new Map();
const addAuthor = (candidate, category = "") => {
const normalized = normalizeToHex(candidate);
if (!normalized) {
return;
}
const normalizedCategory = normalizeMuteCategory(category);
sanitizedAuthors.set(normalized, normalizedCategory);
};
if (mutedAuthors instanceof Map) {
for (const [candidate, category] of mutedAuthors.entries()) {
addAuthor(candidate, category);
}
} else if (mutedAuthors instanceof Set || Array.isArray(mutedAuthors)) {
for (const candidate of mutedAuthors) {
if (!candidate) {
continue;
}
if (typeof candidate === "object") {
addAuthor(candidate.pubkey || candidate.author, candidate.category || candidate.reason);
continue;
}
addAuthor(candidate);
}
} else if (mutedAuthors && typeof mutedAuthors[Symbol.iterator] === "function") {
for (const candidate of mutedAuthors) {
if (!candidate) {
continue;
}
if (typeof candidate === "object") {
addAuthor(candidate.pubkey || candidate.author, candidate.category || candidate.reason);
continue;
}
addAuthor(candidate);
}
}
const touchedAuthors = new Set();
const previous = this.trustedMuteLists.get(owner);
if (previous && previous.authors instanceof Map) {
for (const [author, category] of previous.authors.entries()) {
const aggregate = this.trustedMutedAuthors.get(author);
if (!aggregate || !(aggregate.muters instanceof Map)) {
continue;
}
aggregate.muters.delete(owner);
if (aggregate.categories instanceof Map) {
if (category) {
const categoryMuters = aggregate.categories.get(category);
if (categoryMuters instanceof Map) {
categoryMuters.delete(owner);
if (!categoryMuters.size) {
aggregate.categories.delete(category);
}
}
} else {
for (const [existingCategory, muters] of aggregate.categories.entries()) {
muters.delete(owner);
if (!muters.size) {
aggregate.categories.delete(existingCategory);
}
}
}
}
touchedAuthors.add(author);
}
}
const normalizedEventId = normalizeEventId(eventId);
const normalizedCreatedAt = ensureNumber(createdAt);
if (owner === this.viewerPubkey) {
this.viewerMuteList = new Set(sanitizedAuthors);
this.viewerMuteEventId = normalizedEventId || "";
this.viewerMuteUpdatedAt = normalizedCreatedAt;
}
if (!sanitizedAuthors.size) {
this.trustedMuteLists.delete(owner);
} else {
this.trustedMuteLists.set(owner, {
authors: sanitizedAuthors,
updatedAt: normalizedCreatedAt,
eventId: normalizedEventId,
});
for (const [author, category] of sanitizedAuthors.entries()) {
let aggregate = this.trustedMutedAuthors.get(author);
if (!aggregate) {
aggregate = { muters: new Map(), categories: new Map(), count: 0 };
this.trustedMutedAuthors.set(author, aggregate);
}
aggregate.muters.set(owner, normalizedCreatedAt);
if (category) {
let categoryMuters = aggregate.categories.get(category);
if (!categoryMuters) {
categoryMuters = new Map();
aggregate.categories.set(category, categoryMuters);
}
categoryMuters.set(owner, normalizedCreatedAt);
}
touchedAuthors.add(author);
}
}
this.pruneTrustedMuteAggregates(touchedAuthors);
this.emit("trusted-mutes", { total: this.trustedMutedAuthors.size, owner });
}
applyTrustedMuteEvent(ownerPubkey, event) {
const owner = normalizeHex(ownerPubkey);
if (!owner || !this.isTrustedMuteOwner(owner)) {
return;
}
if (!event || event.kind !== KIND_MUTE_LIST) {
this.replaceTrustedMuteList(owner, new Set(), { createdAt: 0, eventId: "" });
return;
}
const createdAt = ensureNumber(event.created_at);
const eventId = typeof event.id === "string" ? event.id : "";
const existing = this.trustedMuteLists.get(owner);
if (existing) {
const existingEventId = typeof existing.eventId === "string" ? existing.eventId : "";
if (existingEventId && normalizeEventId(existingEventId) === normalizeEventId(eventId)) {
return;
}
if (ensureNumber(existing.updatedAt) > createdAt && normalizeEventId(eventId)) {
return;
}
}
const mutedAuthors = new Map();
if (Array.isArray(event.tags)) {
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] !== "p") {
continue;
}
const normalized = normalizeToHex(tag[1]);
if (normalized) {
const category = extractMuteCategoryFromTag(tag);
mutedAuthors.set(normalized, category);
}
}
}
this.replaceTrustedMuteList(owner, mutedAuthors, { createdAt, eventId });
}
ingestTrustedMuteEvent(event) {
if (!event || event.kind !== KIND_MUTE_LIST) {
return;
}
const owner = normalizeHex(event.pubkey);
if (!owner || !this.isTrustedMuteOwner(owner)) {
return;
}
this.applyTrustedMuteEvent(owner, event);
}
getViewerMutedAuthors() {
return Array.from(this.viewerMuteList);
}
isAuthorMutedByViewer(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return false;
}
return this.viewerMuteList.has(normalized);
}
loadLocalMutes(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return;
}
const storage = resolveStorage();
if (!storage) {
return;
}
const key = `bitvid:user-mutes:local:v1:${normalized}`;
let loaded = [];
try {
const raw = storage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
loaded = parsed;
}
}
} catch (error) {
this.log(
`[moderationService] failed to load local mutes for ${normalized}`,
error,
);
}
const mutes = new Set();
for (const item of loaded) {
const target = normalizeHex(item);
if (target && target !== normalized) {
mutes.add(target);
}
}
this.replaceTrustedMuteList(normalized, mutes, {
createdAt: Date.now() / 1000,
eventId: `local-${Date.now()}`,
});
}
saveLocalMutes(pubkey, muteSet) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return;
}
const storage = resolveStorage();
if (!storage) {
return;
}
const list = [];
if (muteSet instanceof Set || Array.isArray(muteSet)) {
for (const item of muteSet) {
const target = normalizeHex(item);
if (target && target !== normalized) {
list.push(target);
}
}
}
const key = `bitvid:user-mutes:local:v1:${normalized}`;
try {
if (!list.length) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(list));
}
} catch (error) {
this.log(
`[moderationService] failed to save local mutes for ${normalized}`,
error,
);
}
}
async ensureViewerMuteListLoaded(pubkey = this.viewerPubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
this.viewerMuteList.clear();
this.viewerMuteEventId = "";
this.viewerMuteUpdatedAt = 0;
return;
}
if (this.trustedMuteLists.has(normalized)) {
return;
}
if (this.viewerIsSessionActor && normalized === this.viewerPubkey) {
this.loadLocalMutes(normalized);
return;
}
if (this.viewerMutePromise) {
try {
await this.viewerMutePromise;
} catch (_) {
/* noop */
}
return;
}
this.viewerMutePromise = this.subscribeToTrustedMuteList(normalized)
.catch((error) => {
this.log("[moderationService] failed to load viewer mute list", error);
throw error;
})
.finally(() => {
this.viewerMutePromise = null;
});
try {
await this.viewerMutePromise;
} catch (_) {
/* noop */
}
}
async publishViewerMuteList() {
throw new Error("Mute list publishing is now handled by userBlocks.");
}
async addAuthorToViewerMuteList(pubkey) {
const viewer = normalizeHex(this.viewerPubkey);
if (!viewer) {
const error = new Error("viewer-not-logged-in");
error.code = "viewer-not-logged-in";
throw error;
}
if (this.userBlocks && typeof this.userBlocks.addMute === "function") {
return this.userBlocks.addMute(pubkey, viewer);
}
throw new Error("Mute list management is delegated to userBlocks.");
}
async removeAuthorFromViewerMuteList(pubkey) {
const viewer = normalizeHex(this.viewerPubkey);
if (!viewer) {
const error = new Error("viewer-not-logged-in");
error.code = "viewer-not-logged-in";
throw error;
}
if (this.userBlocks && typeof this.userBlocks.removeMute === "function") {
return this.userBlocks.removeMute(pubkey, viewer);
}
throw new Error("Mute list management is delegated to userBlocks.");
}
/**
* Checks if an author is muted by any of the trusted contacts.
*
* @param {string} pubkey - The author's pubkey.
* @returns {boolean} True if at least one trusted contact mutes this author.
*/
isAuthorMutedByTrusted(pubkey) {
const muters = this.getActiveTrustedMutersForAuthor(pubkey);
return muters.length > 0;
}
getTrustedMutersForAuthor(pubkey) {
return this.getActiveTrustedMutersForAuthor(pubkey);
}
/**
* Checks if a pubkey is blocked or muted by the current viewer.
* Delegates to the UserBlocks manager.
*
* @param {string} pubkey - The pubkey to check.
* @returns {boolean} True if blocked or muted by viewer.
*/
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");
}
return this.nostrClient.ensurePool();
}
/**
* Checks the current Nostr client state and updates the viewer if changed.
* Useful when the user logs in or switches accounts externally.
*
* @returns {Promise<string>} The active viewer pubkey.
*/
async refreshViewerFromClient() {
const clientPubkey = normalizeHex(
this.nostrClient?.pubkey || this.nostrClient?.sessionActor?.pubkey,
);
if (clientPubkey === this.viewerPubkey) {
if (this.contactListPromise) {
try {
await this.contactListPromise;
} catch (error) {
this.log("[moderationService] contact hydration failed", error);
}
}
return this.viewerPubkey;
}
return this.setViewerPubkey(clientPubkey);
}
/**
* Switches the active "Viewer" context.
* This reloads the trusted contact list (Kind 3) for the new user,
* effectively rebuilding the "Web of Trust" graph.
*
* @param {string} pubkey - The public key of the new viewer.
* @returns {Promise<string>} The normalized pubkey of the new viewer.
*/
async setViewerPubkey(pubkey) {
const normalized = normalizeHex(pubkey);
if (normalized === this.viewerPubkey && this.contactListPromise) {
try {
await this.contactListPromise;
} catch (error) {
this.log("[moderationService] contact hydration failed", error);
}
await this.ensureUserBlocksLoaded(normalized);
return this.viewerPubkey;
}
const previousContacts =
this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set();
this.viewerPubkey = normalized;
const loggedInPubkey = normalizeHex(this.nostrClient?.pubkey);
const sessionPubkey = normalizeHex(this.nostrClient?.sessionActor?.pubkey);
this.viewerIsSessionActor =
!!normalized && !loggedInPubkey && normalized === sessionPubkey;
this.clearTrustedMuteTracking({ previousContacts });
if (this.contactSubscription && typeof this.contactSubscription.unsub === "function") {
try {
this.contactSubscription.unsub();
} catch (error) {
this.log("[moderationService] failed to teardown contact subscription", error);
}
}
this.contactSubscription = null;
if (!normalized) {
await this.ensureUserBlocksLoaded(normalized);
return this.viewerPubkey;
}
this.contactListPromise = this.fetchTrustedContacts(normalized)
.catch((error) => {
this.log("[moderationService] failed to load contact list", error);
return null;
})
.finally(() => {
this.contactListPromise = null;
});
try {
await this.contactListPromise;
} catch (error) {
this.log("[moderationService] contact promise rejected", error);
}
this.subscribeToContactList(normalized).catch((error) => {
this.log("[moderationService] failed to subscribe to contact list", error);
});
await this.ensureUserBlocksLoaded(normalized);
await this.ensureViewerMuteListLoaded(normalized);
return this.viewerPubkey;
}
async fetchTrustedContacts(pubkey) {
const normalized = normalizeHex(pubkey);
const previousContacts =
this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set();
if (!normalized) {
this.clearTrustedMuteTracking({ previousContacts });
return;
}
try {
await this.ensurePool();
} catch (error) {
this.log("[moderationService] ensurePool failed while fetching contacts", error);
return;
}
const relays = resolveRelayList(this.nostrClient);
if (!relays.length) {
this.clearTrustedMuteTracking({ previousContacts });
return;
}
const filter = { kinds: [3], authors: [normalized], limit: 1 };
let events = [];
try {
events = await this.nostrClient.pool.list(relays, [filter]);
} catch (error) {
this.log("[moderationService] failed to list contact events", error);
events = [];
}
if (!Array.isArray(events) || !events.length) {
this.clearTrustedMuteTracking({ previousContacts });
return;
}
let latest = events[0];
for (const event of events) {
if (!event || typeof event.created_at !== "number") {
continue;
}
if (!latest || ensureNumber(event.created_at) > ensureNumber(latest.created_at)) {
latest = event;
}
}
this.applyContactEvent(latest, { previous: previousContacts });
}
applyContactEvent(event, { previous = null } = {}) {
const previousContacts =
previous instanceof Set
? new Set(previous)
: this.trustedContacts instanceof Set
? new Set(this.trustedContacts)
: new Set();
if (!event || !Array.isArray(event.tags)) {
this.clearTrustedMuteTracking({ previousContacts });
return;
}
if (event.id && event.id === this.lastContactEventId) {
return;
}
const nextSet = new Set();
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag[0] !== "p") {
continue;
}
const candidate = normalizeHex(tag[1]);
if (candidate) {
nextSet.add(candidate);
}
}
if (this.viewerContacts instanceof Set && this.viewerContacts.size === nextSet.size) {
let changed = false;
for (const value of nextSet) {
if (!this.viewerContacts.has(value)) {
changed = true;
break;
}
}
if (!changed) {
if (event.id) {
this.lastContactEventId = event.id;
}
return;
}
}
if (event.id) {
this.lastContactEventId = event.id;
}
this.rebuildTrustedContacts(nextSet, { previous: previousContacts });
}
async subscribeToContactList(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return;
}
try {
await this.ensurePool();
} catch (error) {
this.log("[moderationService] ensurePool failed while subscribing to contacts", error);
return;
}
const relays = resolveRelayList(this.nostrClient);
if (!relays.length) {
return;
}
const filter = { kinds: [3], authors: [normalized], limit: 1 };
try {
const sub = this.nostrClient.pool.sub(relays, [filter]);
sub.on("event", (event) => {
this.applyContactEvent(event);
});
sub.on("eose", () => {});
this.contactSubscription = {
unsub: typeof sub.unsub === "function" ? () => sub.unsub() : null,
};
} catch (error) {
this.log("[moderationService] failed to create contact subscription", error);
}
}
async setActiveEventIds(ids = []) {
if (!MODERATION_REPORT_SUBS_ENABLED) {
return;
}
const nextIds = new Set();
if (Array.isArray(ids) || ids instanceof Set) {
for (const value of ids) {
const normalized = normalizeEventId(value);
if (normalized) {
nextIds.add(normalized);
}
}
}
// Skip redundant work when the visible set is unchanged (the feed stage
// calls this on every run).
const prevIds = this.activeEventIds instanceof Set ? this.activeEventIds : new Set();
if (setsEqual(prevIds, nextIds)) {
return;
}
const manager =
typeof this.nostrClient?.getSubscriptionManager === "function"
? this.nostrClient.getSubscriptionManager()
: null;
// Fallback for environments without the SubscriptionManager (tests/mocks):
// keep the legacy per-id behavior so those paths are unaffected.
if (!manager) {
return this._setActiveEventIdsLegacy(nextIds, prevIds);
}
this.activeEventIds = nextIds;
if (nextIds.size === 0) {
if (this._reportSubHandle) {
this._reportSubHandle.close();
this._reportSubHandle = null;
}
return;
}
const idArray = Array.from(nextIds);
const filters = buildReportFilters(idArray);
// ONE live batched subscription over the whole active set, updated in place
// as the set changes (no teardown/resubscribe churn).
if (this._reportSubHandle) {
this._reportSubHandle.update({ filters });
} else {
this._reportSubHandle = manager.subscribe({
key: "moderation:reports",
label: "reports",
filters,
onEvent: (event) => this.ingestReportEvent(event),
});
}
// Backfill history for only the newly-added ids — one batched list().
const newIds = idArray.filter((id) => !prevIds.has(id));
if (newIds.length) {
try {
const events = await manager.list({ filters: buildReportFilters(newIds) });
if (Array.isArray(events)) {
for (const event of events) {
this.ingestReportEvent(event);
}
}
} catch (error) {
this.log("[moderationService] batched report backfill failed", error);
}
}
}
// Legacy per-id path, retained only for environments without a
// SubscriptionManager (unit tests / mock clients). The real app uses the
// batched path above.
async _setActiveEventIdsLegacy(nextIds, prevIds = null) {
const previous =
prevIds instanceof Set
? prevIds
: this.activeEventIds instanceof Set
? this.activeEventIds
: new Set();
void previous;
for (const existing of Array.from(this.activeSubscriptions.keys())) {
if (!nextIds.has(existing)) {
this.teardownReportSubscription(existing);
}
}
const tasks = [];
for (const id of nextIds) {
if (this.activeSubscriptions.has(id)) {
const entry = this.activeSubscriptions.get(id);
if (entry?.promise) {
tasks.push(entry.promise.catch(() => {}));
}
continue;
}
const promise = this.subscribeToReports(id);
tasks.push(
promise.catch((error) => {
this.log(`(moderationService) failed to subscribe to reports for ${id}`, error);
}),
);
}
this.activeEventIds = nextIds;
if (tasks.length) {
await Promise.all(tasks);
}
}
async refreshActiveReportSubscriptions() {
if (!this.activeEventIds || !this.activeEventIds.size) {
return;
}
// With the batched subscription the report *data* is unchanged by a trust
// graph update (same event ids); callers already recompute summaries. Just
// ensure the single live subscription is (re)issued for its current set.
if (this._reportSubHandle) {
this._reportSubHandle.update({ filters: buildReportFilters(Array.from(this.activeEventIds)) });
return;
}
// Legacy path (no SubscriptionManager): tear down + re-subscribe via the
// public (overridable) method. Clear activeEventIds first so the
// unchanged-set guard in setActiveEventIds doesn't short-circuit the rebuild.
const ids = Array.from(this.activeEventIds);
for (const id of ids) {
this.teardownReportSubscription(id);
}
this.activeEventIds = new Set();
await this.setActiveEventIds(ids);
}
teardownReportSubscription(eventId) {
const normalized = normalizeEventId(eventId);
const entry = this.activeSubscriptions.get(normalized);
if (!entry) {
return;
}
if (typeof entry.unsub === "function") {
try {
entry.unsub();
} catch (error) {
this.log("[moderationService] failed to unsubscribe from report stream", error);
}
}
this.activeSubscriptions.delete(normalized);
}
/**
* Subscribes to Kind 1984 reports for a specific event.
*
* This fetches recent history (backfill) and opens a live subscription.
* Reports are ingested and trigger summary recomputation.
*
* @param {string} eventId - The ID of the event to monitor.
* @returns {Promise<void>}
*/
async subscribeToReports(eventId) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
return;
}
const record = { unsub: null, promise: null };
this.activeSubscriptions.set(normalized, record);
record.promise = (async () => {
await this.ensurePool();
const relays = resolveRelayList(this.nostrClient);
if (!relays.length) {
return;
}
const filter = { kinds: [1984], "#e": [normalized], limit: 500 };
let events = [];
try {
events = await this.nostrClient.pool.list(relays, [filter]);
} catch (error) {
this.log(`(moderationService) failed to backfill reports for ${normalized}`, error);
events = [];
}
if (Array.isArray(events)) {
for (const event of events) {
this.ingestReportEvent(event);
}
}
try {
const sub = this.nostrClient.pool.sub(relays, [filter]);
sub.on("event", (event) => {
this.ingestReportEvent(event);
});
sub.on("eose", () => {});
record.unsub = typeof sub.unsub === "function" ? () => sub.unsub() : null;
} catch (error) {
this.log(`(moderationService) failed to subscribe to reports for ${normalized}`, error);
}
})();
try {
await record.promise;
} catch (error) {
this.log("[moderationService] report subscription promise rejected", error);
} finally {
record.promise = null;
}
}
/**
* Ingests a NIP-56 Report event (Kind 1984).
*
* The report is indexed by `targetEventId` -> `reporter` -> `reportType`.
* Note: This method only stores the report. Trust filtering happens during
* `recomputeSummaryForEvent`.
*
* @param {object} event - The Kind 1984 event.
*/
ingestReportEvent(event) {
if (!event || event.kind !== 1984) {
return;
}
const reporter = normalizeHex(event.pubkey);
if (!reporter) {
return;
}
const targetEventId = findReportedEventId(event);
const normalizedEventId = normalizeEventId(targetEventId);
if (!normalizedEventId) {
return;
}
const reportType = extractReportType(event, normalizedEventId);
if (!reportType) {
return;
}
let eventReports = this.reportEvents.get(normalizedEventId);
if (!eventReports) {
eventReports = new Map();
this.reportEvents.set(normalizedEventId, eventReports);
}
let reporterEntry = eventReports.get(reporter);
if (!reporterEntry) {
reporterEntry = new Map();
eventReports.set(reporter, reporterEntry);
}
const existing = reporterEntry.get(reportType);
const createdAt = ensureNumber(event.created_at);
if (!existing || createdAt >= ensureNumber(existing.created_at)) {
reporterEntry.set(reportType, {
created_at: createdAt,
id: typeof event.id === "string" ? event.id : "",
});
}
this.recomputeSummaryForEvent(normalizedEventId);
}
recomputeAllSummaries() {
for (const eventId of this.reportEvents.keys()) {
this.recomputeSummaryForEvent(eventId);
}
}
/**
* Aggregates all reports for a specific event and calculates the "Trust Score".
*
* Algorithm:
* 1. Iterate over all reports for the event.
* 2. Check if the reporter is in the `trustedContacts` set (or Admin Whitelist).
* 3. If trusted, increment the count for that report type (e.g., "nudity").
* 4. Update the summary state and emit change events if thresholds are crossed.
*
* @param {string} eventId - The ID of the event to recompute.
*/
recomputeSummaryForEvent(eventId) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
return;
}
const eventReports = this.reportEvents.get(normalized);
if (!eventReports || !eventReports.size) {
const previousSummary = this.reportSummaries.get(normalized);
if (previousSummary && previousSummary.types) {
for (const [type, detail] of Object.entries(previousSummary.types)) {
if (!detail || typeof detail !== "object") {
continue;
}
const previousTrusted = Number.isFinite(detail.trusted) ? detail.trusted : 0;
if (previousTrusted > 0) {
this.logThresholdTransitions({
eventId: normalized,
reportType: type,
previousTrusted,
nextTrusted: 0,
});
}
}
}
this.reportSummaries.delete(normalized);
this.emit("summary", { eventId: normalized, summary: null });
return;
}
const previousSummary = this.reportSummaries.get(normalized);
const typeStats = new Map();
let totalTrusted = 0;
const adminSnapshot = this.getAdminListSnapshot();
for (const [reporter, typeMap] of eventReports.entries()) {
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) {
stats = { trusted: 0, total: 0, latest: 0 };
typeStats.set(type, stats);
}
stats.total += 1;
const createdAt = ensureNumber(detail?.created_at);
if (createdAt > stats.latest) {
stats.latest = createdAt;
}
if (isTrustedReporter) {
stats.trusted += 1;
totalTrusted += 1;
}
}
}
const types = {};
for (const [type, stats] of typeStats.entries()) {
types[type] = {
trusted: stats.trusted,
total: stats.total,
latest: stats.latest,
};
}
if (previousSummary && previousSummary.types) {
const seen = new Set([
...Object.keys(previousSummary.types || {}),
...Object.keys(types),
]);
for (const type of seen) {
const previousEntry = previousSummary?.types?.[type];
const nextEntry = types?.[type];
const previousTrusted = Number.isFinite(previousEntry?.trusted)
? previousEntry.trusted
: 0;
const nextTrusted = Number.isFinite(nextEntry?.trusted)
? nextEntry.trusted
: 0;
if (previousTrusted !== nextTrusted) {
this.logThresholdTransitions({
eventId: normalized,
reportType: type,
previousTrusted,
nextTrusted,
});
}
}
} else {
for (const [type, stats] of Object.entries(types)) {
const nextTrusted = Number.isFinite(stats?.trusted) ? stats.trusted : 0;
if (nextTrusted > 0) {
this.logThresholdTransitions({
eventId: normalized,
reportType: type,
previousTrusted: 0,
nextTrusted,
});
}
}
}
const summary = {
eventId: normalized,
totalTrusted,
types,
updatedAt: Date.now(),
};
this.reportSummaries.set(normalized, summary);
this.emit("summary", { eventId: normalized, summary: cloneSummary(summary) });
}
/**
* Retrieves the current aggregated trust score for an event.
*
* @param {string} eventId - The event ID.
* @returns {object} The summary object ({ totalTrusted, types, ... }).
*/
getTrustedReportSummary(eventId) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
return { eventId: "", totalTrusted: 0, types: {}, updatedAt: 0 };
}
const summary = this.reportSummaries.get(normalized);
if (!summary) {
return { eventId: normalized, totalTrusted: 0, types: {}, updatedAt: 0 };
}
return cloneSummary(summary);
}
getTrustedReporters(eventId, type) {
const normalizedEventId = normalizeEventId(eventId);
if (!normalizedEventId) {
return [];
}
const eventReports = this.reportEvents.get(normalizedEventId);
if (!eventReports || !eventReports.size) {
return [];
}
const normalizedType = normalizeReportType(type);
const results = [];
const adminSnapshot = this.getAdminListSnapshot();
for (const [reporter, typeMap] of eventReports.entries()) {
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;
}
if (normalizedType) {
const detail = typeMap.get(normalizedType);
if (!detail) {
continue;
}
results.push({
pubkey: reporterHex,
latest: ensureNumber(detail?.created_at),
});
continue;
}
let latest = 0;
for (const entry of typeMap.values()) {
const candidate = ensureNumber(entry?.created_at);
if (candidate > latest) {
latest = candidate;
}
}
results.push({ pubkey: reporterHex, latest });
}
if (!results.length) {
return [];
}
results.sort((a, b) => ensureNumber(b.latest) - ensureNumber(a.latest));
return results.map((entry) => ({
pubkey: entry.pubkey,
latest: ensureNumber(entry.latest),
}));
}
trustedReportCount(eventId, type) {
const normalizedEventId = normalizeEventId(eventId);
if (!normalizedEventId) {
return 0;
}
const summary = this.reportSummaries.get(normalizedEventId);
if (!summary) {
return 0;
}
const normalizedType = normalizeReportType(type);
const entry = normalizedType ? summary.types[normalizedType] : null;
if (!entry) {
return 0;
}
return Number.isFinite(entry.trusted) ? entry.trusted : 0;
}
/**
* Publishes a NIP-56 Report (Kind 1984).
*
* @param {object} params
* @param {string} params.eventId - The ID of the content being reported.
* @param {string} params.type - The category (e.g., "nudity", "spam").
* @param {string} params.targetPubkey - The pubkey of the reported content's author.
* @param {string} [params.relayHint] - Optional relay URL hint.
* @param {string} [params.content] - Optional text description of the report.
* @returns {Promise<{ok: boolean, event: object, results: object[]}>}
*/
async submitReport({
eventId,
type,
targetPubkey = "",
relayHint = "",
content = "",
} = {}) {
const normalizedEventId = normalizeEventId(eventId);
if (!normalizedEventId) {
const error = new Error("invalid-event-id");
error.code = "invalid-event-id";
throw error;
}
const normalizedType = normalizeReportType(type);
if (!normalizedType) {
const error = new Error("invalid-type");
error.code = "invalid-type";
throw error;
}
const reporterPubkey = normalizeHex(
this.nostrClient?.pubkey || this.nostrClient?.sessionActor?.pubkey,
);
if (!reporterPubkey) {
const error = new Error("viewer-not-logged-in");
error.code = "viewer-not-logged-in";
throw error;
}
const normalizedTarget = normalizeHex(targetPubkey);
if (!normalizedTarget) {
const error = new Error("invalid-target-pubkey");
error.code = "invalid-target-pubkey";
throw error;
}
const signer = await this.nostrClient.ensureActiveSignerForPubkey(
reporterPubkey,
);
if (!signer || typeof signer.signEvent !== "function") {
const error = new Error("signer-unavailable");
error.code = "signer-unavailable";
throw error;
}
if (
(signer.type === "extension" || signer.type === "nip07") &&
typeof this.nostrClient?.ensureExtensionPermissions === "function"
) {
const permissionResult = await this.nostrClient.ensureExtensionPermissions([
"sign_event",
"get_public_key",
]);
if (!permissionResult?.ok) {
const error = new Error("extension-permission-denied");
error.code = "extension-permission-denied";
error.details = permissionResult?.error || null;
throw error;
}
}
const sanitizedRelayHint =
typeof relayHint === "string" ? relayHint.trim() : "";
const event = buildReportEvent({
pubkey: reporterPubkey,
created_at: Math.floor(Date.now() / 1000),
eventId: normalizedEventId,
userId: normalizedTarget,
reportType: normalizedType,
relayHint: sanitizedRelayHint,
content: typeof content === "string" ? content : "",
});
let signedEvent;
try {
signedEvent = await signer.signEvent(event);
} catch (error) {
const wrapped = new Error("signature-failed");
wrapped.code = "signature-failed";
wrapped.details = error;
throw wrapped;
}
await this.ensurePool();
const relays = resolveRelayList(this.nostrClient, { write: true });
if (!relays.length) {
const error = new Error("no-relays-configured");
error.code = "no-relays-configured";
throw error;
}
let results = [];
try {
results = await publishEventToRelays(this.nostrClient.pool, relays, signedEvent);
assertAnyRelayAccepted(results, { context: "report" });
} catch (error) {
const wrapped = new Error("report-publish-failed");
wrapped.code = "report-publish-failed";
wrapped.details = error;
throw wrapped;
}
this.ingestReportEvent(signedEvent);
return { ok: true, event: signedEvent, results };
}
async awaitUserBlockRefresh() {
if (!this.userBlockRefreshQueue) {
return;
}
try {
await this.userBlockRefreshQueue;
} catch {
/* already reported during queue */
}
}
}
const moderationService = new ModerationService({
nostrClient,
logger,
userBlocks,
accessControl,
});
export default moderationService;