/** * @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). */ 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 } = {}) { 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(); 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} 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} 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} 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 = []) { 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); } } } 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; } const ids = Array.from(this.activeEventIds); for (const id of ids) { this.teardownReportSubscription(id); } 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} */ 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;