Seed trusted moderation contacts

This commit is contained in:
thePR0M3TH3AN committed 2025-10-20 15:07:20 -04:00
1 parent 8775aceda7
commit 123431ea7f
5 files changed
+381 -37

No files matched your search

+1
View File
@@ -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**.
+17
View File
@@ -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 };
+34
View File
@@ -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);
}
+261 -37
View File
@@ -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) {
+68
View File
@@ -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]);
});