From 123431ea7f7a47ead8b79ef085e336365954331d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 15:07:20 -0400 Subject: [PATCH] Seed trusted moderation contacts --- docs/moderation/README.md | 1 + js/bootstrap.js | 17 ++ js/constants.js | 34 +++ js/services/moderationService.js | 298 ++++++++++++++++++++++---- tests/moderation/trust-seeds.test.mjs | 68 ++++++ 5 files changed, 381 insertions(+), 37 deletions(-) create mode 100644 tests/moderation/trust-seeds.test.mjs diff --git a/docs/moderation/README.md b/docs/moderation/README.md index c23dced7..84aa808d 100644 --- a/docs/moderation/README.md +++ b/docs/moderation/README.md @@ -32,6 +32,7 @@ Thread new moderation behaviors through the same service → stage → app → U - Disable autoplay preview if **≥ 2** F1 friends report `nudity`. - Downrank author when any F1 has them in mute list (10000). - Opt-in admin lists (30000 with `d=bitvid:admin:*`) can hard-hide content. +- Trust seeds from `DEFAULT_TRUST_SEED_NPUBS` count as baseline F1 contacts (toggle via `FEATURE_TRUST_SEEDS`). > You can override all defaults in **Settings → Safety & Moderation**. diff --git a/js/bootstrap.js b/js/bootstrap.js index f2f75243..d3f77c17 100644 --- a/js/bootstrap.js +++ b/js/bootstrap.js @@ -1,10 +1,27 @@ // js/bootstrap.js import Application from "./app.js"; +import { + DEFAULT_TRUST_SEED_NPUBS, + FEATURE_TRUST_SEEDS, +} from "./constants.js"; +import moderationService from "./services/moderationService.js"; import nostrService from "./services/nostrService.js"; import r2Service from "./services/r2Service.js"; import { loadView } from "./viewManager.js"; +if ( + FEATURE_TRUST_SEEDS && + moderationService && + typeof moderationService.setTrustedSeeds === "function" +) { + try { + moderationService.setTrustedSeeds(DEFAULT_TRUST_SEED_NPUBS); + } catch { + // Swallow bootstrap errors so login flow can continue; flag enables quick rollback. + } +} + function mergeServices(overrides = {}) { const merged = { nostrService, r2Service }; diff --git a/js/constants.js b/js/constants.js index cf4e6edb..e07b700b 100644 --- a/js/constants.js +++ b/js/constants.js @@ -1,9 +1,16 @@ +export const DEFAULT_TRUST_SEED_NPUBS = Object.freeze([ + // Rollout: trust seeds count as F1 contacts. Rollback: flip FEATURE_TRUST_SEEDS to false. + "npub1424242424242424242424242424242424242424242424242424qamrcaj", + "npub1hwamhwamhwamhwamhwamhwamhwamhwamhwamhwamhwamhwamhwasxw04hu", +]); + const DEFAULT_FLAGS = Object.freeze({ URL_FIRST_ENABLED: true, // try URL before magnet in the player ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false, FEATURE_WATCH_HISTORY_V2: true, FEATURE_PUBLISH_NIP71: false, + FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes. WSS_TRACKERS: Object.freeze([ "wss://tracker.openwebtorrent.com", "wss://tracker.fastcast.nz", @@ -25,6 +32,7 @@ const runtimeFlags = (() => { DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO, FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2, FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71, + FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS, WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS], }; if (globalScope) { @@ -115,6 +123,11 @@ export let FEATURE_PUBLISH_NIP71 = coerceBoolean( DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71 ); +export let FEATURE_TRUST_SEEDS = coerceBoolean( + runtimeFlags.FEATURE_TRUST_SEEDS, + DEFAULT_FLAGS.FEATURE_TRUST_SEEDS +); + export let WSS_TRACKERS = freezeTrackers( sanitizeTrackerList(runtimeFlags.WSS_TRACKERS) ); @@ -183,6 +196,20 @@ Object.defineProperty(runtimeFlags, "FEATURE_PUBLISH_NIP71", { }, }); +Object.defineProperty(runtimeFlags, "FEATURE_TRUST_SEEDS", { + configurable: true, + enumerable: true, + get() { + return FEATURE_TRUST_SEEDS; + }, + set(next) { + FEATURE_TRUST_SEEDS = coerceBoolean( + next, + DEFAULT_FLAGS.FEATURE_TRUST_SEEDS + ); + }, +}); + Object.defineProperty(runtimeFlags, "WSS_TRACKERS", { configurable: true, enumerable: true, @@ -200,6 +227,7 @@ runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1; runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO; runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2; runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71; +runtimeFlags.FEATURE_TRUST_SEEDS = FEATURE_TRUST_SEEDS; runtimeFlags.WSS_TRACKERS = WSS_TRACKERS; export function setUrlFirstEnabled(next) { @@ -226,6 +254,11 @@ export function setWssTrackers(next) { return WSS_TRACKERS; } +export function setTrustSeedsEnabled(next) { + runtimeFlags.FEATURE_TRUST_SEEDS = next; + return FEATURE_TRUST_SEEDS; +} + export function resetRuntimeFlags() { setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED); setAcceptLegacyV1(DEFAULT_FLAGS.ACCEPT_LEGACY_V1); @@ -233,6 +266,7 @@ export function resetRuntimeFlags() { DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO ); setWatchHistoryV2Enabled(DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2); + setTrustSeedsEnabled(DEFAULT_FLAGS.FEATURE_TRUST_SEEDS); setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS); } diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 05d9f62d..9a1f1ec9 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -158,6 +158,133 @@ function bytesToHex(bytes) { return hex; } +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_CHARSET_MAP = (() => { + const map = new Map(); + for (let index = 0; index < BECH32_CHARSET.length; index += 1) { + map.set(BECH32_CHARSET[index], index); + } + return map; +})(); + +function bech32Polymod(values) { + let chk = 1; + const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + + for (const value of values) { + const top = chk >>> 25; + chk = ((chk & 0x1ffffff) << 5) ^ value; + for (let bit = 0; bit < generators.length; bit += 1) { + if ((top >>> bit) & 1) { + chk ^= generators[bit]; + } + } + } + + return chk; +} + +function bech32HrpExpand(hrp) { + const expanded = []; + for (let index = 0; index < hrp.length; index += 1) { + const code = hrp.charCodeAt(index); + expanded.push(code >>> 5); + } + expanded.push(0); + for (let index = 0; index < hrp.length; index += 1) { + const code = hrp.charCodeAt(index); + expanded.push(code & 31); + } + return expanded; +} + +function bech32VerifyChecksum(hrp, data) { + return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1; +} + +function bech32Decode(value) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const lower = trimmed.toLowerCase(); + const upper = trimmed.toUpperCase(); + if (trimmed !== lower && trimmed !== upper) { + return null; + } + + const normalized = lower; + const separatorIndex = normalized.lastIndexOf("1"); + if (separatorIndex < 1 || separatorIndex + 7 > normalized.length) { + return null; + } + + const hrp = normalized.slice(0, separatorIndex); + const data = []; + for (let index = separatorIndex + 1; index < normalized.length; index += 1) { + const char = normalized[index]; + if (!BECH32_CHARSET_MAP.has(char)) { + return null; + } + data.push(BECH32_CHARSET_MAP.get(char)); + } + + if (!bech32VerifyChecksum(hrp, data)) { + return null; + } + + return { hrp, words: data.slice(0, -6) }; +} + +function convertBits(data, fromBits, toBits, pad = true) { + const result = []; + let acc = 0; + let bits = 0; + const maxv = (1 << toBits) - 1; + const maxAcc = (1 << (fromBits + toBits - 1)) - 1; + + for (const value of data) { + if (value < 0 || value >> fromBits) { + return null; + } + acc = ((acc << fromBits) | value) & maxAcc; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((acc >>> bits) & maxv); + } + } + + if (pad) { + if (bits) { + result.push((acc << (toBits - bits)) & maxv); + } + } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) { + return null; + } + + return result; +} + +function fallbackDecodeNpubToHex(value) { + const decoded = bech32Decode(value); + if (!decoded || decoded.hrp !== "npub") { + return ""; + } + + const bytes = convertBits(decoded.words, 5, 8, false); + if (!bytes || !bytes.length) { + return ""; + } + + return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + function decodeToHex(candidate) { if (typeof candidate !== "string") { return ""; @@ -171,20 +298,21 @@ function decodeToHex(candidate) { try { const tools = getNostrTools(); const decoder = tools?.nip19?.decode; - if (typeof decoder !== "function") { - return ""; + if (typeof decoder === "function") { + const decoded = decoder(trimmed); + if (!decoded || decoded.type !== "npub") { + return fallbackDecodeNpubToHex(trimmed); + } + const data = decoded.data; + if (typeof data === "string") { + const normalized = normalizeHex(data); + return normalized || fallbackDecodeNpubToHex(trimmed); + } + return bytesToHex(data); } - const decoded = decoder(trimmed); - if (!decoded || decoded.type !== "npub") { - return ""; - } - const data = decoded.data; - if (typeof data === "string") { - return normalizeHex(data); - } - return bytesToHex(data); + return fallbackDecodeNpubToHex(trimmed); } catch (error) { - return ""; + return fallbackDecodeNpubToHex(trimmed); } } @@ -332,6 +460,8 @@ export class ModerationService { this.viewerPubkey = ""; this.trustedContacts = new Set(); + this.trustedSeedContacts = new Set(); + this.viewerContacts = new Set(); this.userBlocks = null; this.accessControl = accessControlService; @@ -370,6 +500,99 @@ export class ModerationService { 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) { + for (const seed of this.trustedSeedContacts) { + const normalized = normalizeHex(seed); + if (normalized) { + merged.add(normalized); + } + } + } + + return merged; + } + + 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.emit("contacts", { size: seededNext.size }); + this.recomputeAllSummaries(); + this.reconcileTrustedMuteSubscriptions(previousContacts, seededNext); + } + + setTrustedSeeds(seeds = []) { + const sanitizedSeeds = new Set(); + + if (seeds instanceof Set || Array.isArray(seeds)) { + for (const candidate of seeds) { + const normalized = normalizeToHex(candidate); + if (normalized) { + sanitizedSeeds.add(normalized); + } + } + } else if (seeds && typeof seeds[Symbol.iterator] === "function") { + for (const candidate of seeds) { + const normalized = normalizeToHex(candidate); + if (normalized) { + sanitizedSeeds.add(normalized); + } + } + } + + const previousContacts = + this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set(); + + this.trustedSeedContacts = sanitizedSeeds; + this.rebuildTrustedContacts(this.viewerContacts, { previous: previousContacts }); + } + logThresholdTransitions({ eventId = "", reportType = "", @@ -522,7 +745,14 @@ export class ModerationService { return this.userBlockRefreshQueue; } - clearTrustedMuteTracking() { + clearTrustedMuteTracking({ previousContacts = null } = {}) { + const previous = + previousContacts instanceof Set + ? new Set(previousContacts) + : this.trustedContacts instanceof Set + ? new Set(this.trustedContacts) + : new Set(); + for (const [pubkey, entry] of this.trustedMuteSubscriptions.entries()) { if (entry && typeof entry.unsub === "function") { try { @@ -548,6 +778,8 @@ export class ModerationService { this.viewerMuteUpdatedAt = 0; this.emit("trusted-mutes", { total: 0 }); + + this.rebuildTrustedContacts(new Set(), { previous }); } isTrustedMuteOwner(pubkey) { @@ -1244,10 +1476,11 @@ export class ModerationService { return this.viewerPubkey; } + const previousContacts = + this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set(); + this.viewerPubkey = normalized; - this.clearTrustedMuteTracking(); - this.trustedContacts.clear(); - this.recomputeAllSummaries(); + this.clearTrustedMuteTracking({ previousContacts }); if (this.contactSubscription && typeof this.contactSubscription.unsub === "function") { try { @@ -1292,9 +1525,7 @@ export class ModerationService { const previousContacts = this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set(); if (!normalized) { - this.clearTrustedMuteTracking(); - this.trustedContacts.clear(); - this.emit("contacts", { size: 0 }); + this.clearTrustedMuteTracking({ previousContacts }); return; } @@ -1307,9 +1538,7 @@ export class ModerationService { const relays = resolveRelayList(this.nostrClient); if (!relays.length) { - this.clearTrustedMuteTracking(); - this.trustedContacts.clear(); - this.emit("contacts", { size: 0 }); + this.clearTrustedMuteTracking({ previousContacts }); return; } @@ -1324,11 +1553,7 @@ export class ModerationService { } if (!Array.isArray(events) || !events.length) { - this.clearTrustedMuteTracking(); - this.trustedContacts.clear(); - this.emit("contacts", { size: 0 }); - this.recomputeAllSummaries(); - this.reconcileTrustedMuteSubscriptions(previousContacts, new Set()); + this.clearTrustedMuteTracking({ previousContacts }); return; } @@ -1346,13 +1571,15 @@ export class ModerationService { } applyContactEvent(event, { previous = null } = {}) { - const previousContacts = previous instanceof Set ? previous : new Set(this.trustedContacts); + 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(); - this.trustedContacts.clear(); - this.emit("contacts", { size: 0 }); - this.recomputeAllSummaries(); - this.reconcileTrustedMuteSubscriptions(previousContacts, new Set()); + this.clearTrustedMuteTracking({ previousContacts }); return; } @@ -1367,10 +1594,7 @@ export class ModerationService { } } - this.trustedContacts = nextSet; - this.emit("contacts", { size: nextSet.size }); - this.recomputeAllSummaries(); - this.reconcileTrustedMuteSubscriptions(previousContacts, nextSet); + this.rebuildTrustedContacts(nextSet, { previous: previousContacts }); } async subscribeToContactList(pubkey) { diff --git a/tests/moderation/trust-seeds.test.mjs b/tests/moderation/trust-seeds.test.mjs new file mode 100644 index 00000000..33566e58 --- /dev/null +++ b/tests/moderation/trust-seeds.test.mjs @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DEFAULT_TRUST_SEED_NPUBS } from "../../js/constants.js"; +import { + withMockedNostrTools, + createModerationServiceHarness, + createReportEvent, +} from "../helpers/moderation-test-helpers.mjs"; + +const [PRIMARY_SEED_NPUB] = DEFAULT_TRUST_SEED_NPUBS; + +function createTrustedMuteEvent({ owner, muted, id = "m".repeat(64), createdAt = 1_700_000_000 } = {}) { + return { + kind: 10000, + id, + pubkey: owner, + created_at: createdAt, + tags: [["p", muted]], + content: "", + }; +} + +test("trusted seeds contribute to trusted mute counts", (t) => { + withMockedNostrTools(t); + + const mutedAuthor = "c".repeat(64); + + const { service } = createModerationServiceHarness(t); + service.setTrustedSeeds([PRIMARY_SEED_NPUB]); + + const [seedHex] = Array.from(service.trustedContacts); + assert.ok(seedHex, "trusted seeds should populate trustedContacts"); + + const muteEvent = createTrustedMuteEvent({ owner: seedHex, muted: mutedAuthor }); + service.applyTrustedMuteEvent(seedHex, muteEvent); + + assert.equal(service.isTrustedMuteOwner(seedHex), true); + assert.equal(service.isAuthorMutedByTrusted(mutedAuthor), true); + assert.deepEqual(service.getTrustedMutersForAuthor(mutedAuthor), [seedHex]); +}); + +test("trusted seeds contribute to trusted report counts", (t) => { + withMockedNostrTools(t); + + const eventId = "f".repeat(64); + + const { service } = createModerationServiceHarness(t); + service.setTrustedSeeds([PRIMARY_SEED_NPUB]); + + const [seedHex] = Array.from(service.trustedContacts); + assert.ok(seedHex, "trusted seeds should populate trustedContacts"); + + const report = createReportEvent({ + id: "r".repeat(64), + reporter: seedHex, + eventId, + createdAt: 1_700_000_111, + type: "nudity", + }); + + service.ingestReportEvent(report); + + assert.equal(service.trustedReportCount(eventId, "nudity"), 1); + const summary = service.getTrustedReportSummary(eventId); + assert.equal(summary?.totalTrusted ?? 0, 1); + const reporters = service.getTrustedReporters(eventId, "nudity"); + assert.deepEqual(reporters.map((entry) => entry.pubkey), [seedHex]); +});