Harden moderation trust handling

This commit is contained in:
thePR0M3TH3AN committed 2025-10-17 16:48:07 -04:00
1 parent 0aa28a023d
commit 397ed7c8ce
5 files changed
+661 -8

No files matched your search

+6
View File
@@ -28,6 +28,12 @@
- `['d','bitvid:admin:editors']` → trusted channel editors.
- Users can subscribe/unsubscribe any time.
### Decision precedence
1. **Personal blocks win first.** If a viewer blocks an author or reporter, we ignore their content and reports regardless of admin lists.
2. **Admin blacklist applies next.** Entries on `bitvid:admin:blacklist` are hard-hidden and their reports suppressed before looking at thresholds.
3. **F1 thresholds run last.** Blur/autoplay gating only evaluates trusted-report counts after personal blocks and admin blacklists. Admin whitelists may bypass Discovery gating, but never override a viewer block.
## Pseudocode
```ts
+131 -2
View File
@@ -88,6 +88,7 @@ export function createBlacklistFilterStage({
: () => false;
const options = { blacklistedEventIds: blacklist, isAuthorBlocked };
const results = [];
for (const item of items) {
@@ -245,6 +246,85 @@ export function createModerationStage({
context?.log?.(`[${stageName}] Failed to prime report subscriptions`, error);
}
const toStringSet = (input, { lowercase = false } = {}) => {
const output = new Set();
if (input instanceof Set) {
for (const value of input) {
if (typeof value !== "string") {
continue;
}
const trimmed = value.trim();
if (!trimmed) {
continue;
}
output.add(lowercase ? trimmed.toLowerCase() : trimmed);
}
return output;
}
if (Array.isArray(input)) {
for (const value of input) {
if (typeof value !== "string") {
continue;
}
const trimmed = value.trim();
if (!trimmed) {
continue;
}
output.add(lowercase ? trimmed.toLowerCase() : trimmed);
}
}
return output;
};
let adminSnapshot = {
whitelist: new Set(),
whitelistHex: new Set(),
blacklist: new Set(),
blacklistHex: new Set(),
};
try {
if (typeof resolvedService.getAdminListSnapshot === "function") {
const snapshot = resolvedService.getAdminListSnapshot();
if (snapshot && typeof snapshot === "object") {
adminSnapshot = {
whitelist: toStringSet(snapshot.whitelist),
whitelistHex: toStringSet(snapshot.whitelistHex, { lowercase: true }),
blacklist: toStringSet(snapshot.blacklist),
blacklistHex: toStringSet(snapshot.blacklistHex, { lowercase: true }),
};
}
}
} catch (error) {
context?.log?.(`[${stageName}] Failed to load admin list snapshot`, error);
}
const resolveAdminStatus =
typeof resolvedService.getAccessControlStatus === "function"
? (identifier) => {
try {
return (
resolvedService.getAccessControlStatus(identifier, adminSnapshot) || {
hex: "",
whitelisted: false,
blacklisted: false,
}
);
} catch (error) {
context?.log?.(`[${stageName}] getAccessControlStatus threw`, error);
return { hex: "", whitelisted: false, blacklisted: false };
}
}
: () => ({ hex: "", whitelisted: false, blacklisted: false });
const feedName =
typeof context?.feedName === "string" ? context.feedName.trim().toLowerCase() : "";
const runtimeVariant =
typeof context?.runtime?.feedVariant === "string"
? context.runtime.feedVariant.trim().toLowerCase()
: "";
const isDiscoveryFeed = feedName === "discovery" || runtimeVariant === "discovery";
const results = [];
for (const item of items) {
@@ -260,6 +340,46 @@ export function createModerationStage({
}
const videoId = typeof video.id === "string" ? video.id : "";
const authorPubkey = typeof video.pubkey === "string" ? video.pubkey : "";
let viewerBlocked = false;
if (authorPubkey && typeof context?.runtime?.isAuthorBlocked === "function") {
try {
viewerBlocked = !!context.runtime.isAuthorBlocked(authorPubkey);
} catch (error) {
context?.log?.(`[${stageName}] isAuthorBlocked threw`, error);
}
}
const adminStatus = resolveAdminStatus(authorPubkey);
const authorHex =
(adminStatus?.hex && typeof adminStatus.hex === "string"
? adminStatus.hex
: authorPubkey)
?.trim()
.toLowerCase() || "";
if (viewerBlocked) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "viewer-block",
videoId: videoId || null,
pubkey: authorHex || authorPubkey || null,
});
continue;
}
if (adminStatus?.blacklisted) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "admin-blacklist",
videoId: videoId || null,
pubkey: authorHex || authorPubkey || null,
});
continue;
}
let summary = null;
let trustedCount = 0;
@@ -303,8 +423,13 @@ export function createModerationStage({
}
}
const blockAutoplay = trustedCount >= normalizedAutoplayThreshold;
const blurThumbnail = trustedCount >= normalizedBlurThreshold;
let blockAutoplay = trustedCount >= normalizedAutoplayThreshold;
let blurThumbnail = trustedCount >= normalizedBlurThreshold;
const adminWhitelistBypass = isDiscoveryFeed && adminStatus?.whitelisted === true;
if (adminWhitelistBypass) {
blockAutoplay = false;
blurThumbnail = false;
}
if (!item.metadata || typeof item.metadata !== "object") {
item.metadata = {};
@@ -323,6 +448,8 @@ export function createModerationStage({
metadataModeration.trustedReporters = Array.isArray(trustedReporters)
? trustedReporters.slice()
: [];
metadataModeration.adminWhitelist = adminStatus?.whitelisted === true;
metadataModeration.adminWhitelistBypass = adminWhitelistBypass;
item.metadata.moderation = metadataModeration;
if (!video.moderation || typeof video.moderation !== "object") {
@@ -333,6 +460,8 @@ export function createModerationStage({
video.moderation.blurThumbnail = blurThumbnail;
video.moderation.trustedCount = trustedCount;
video.moderation.reportType = normalizedReportType;
video.moderation.adminWhitelist = adminStatus?.whitelisted === true;
video.moderation.adminWhitelistBypass = adminWhitelistBypass;
if (Array.isArray(trustedReporters) && trustedReporters.length) {
video.moderation.trustedReporters = trustedReporters.slice();
} else if (video.moderation.trustedReporters) {
+317 -6
View File
@@ -1,5 +1,7 @@
import { nostrClient } from "../nostr.js";
import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js";
import { accessControl } from "../accessControl.js";
import { userBlocks } from "../userBlocks.js";
import logger from "../utils/logger.js";
class SimpleEventEmitter {
@@ -115,6 +117,97 @@ function cloneSummary(summary) {
};
}
function getNostrTools() {
if (typeof window !== "undefined" && window?.NostrTools) {
return window.NostrTools;
}
if (typeof globalThis !== "undefined" && globalThis?.NostrTools) {
return globalThis.NostrTools;
}
return null;
}
function bytesToHex(bytes) {
if (!bytes || typeof bytes.length !== "number") {
return "";
}
let hex = "";
for (let index = 0; index < bytes.length; index += 1) {
const value = bytes[index];
if (typeof value !== "number") {
return "";
}
const normalized = value & 0xff;
hex += normalized.toString(16).padStart(2, "0");
}
return hex;
}
function decodeToHex(candidate) {
if (typeof candidate !== "string") {
return "";
}
const trimmed = candidate.trim();
if (!trimmed) {
return "";
}
try {
const tools = getNostrTools();
const decoder = tools?.nip19?.decode;
if (typeof decoder !== "function") {
return "";
}
const decoded = decoder(trimmed);
if (!decoded || decoded.type !== "npub") {
return "";
}
const data = decoded.data;
if (typeof data === "string") {
return normalizeHex(data);
}
return bytesToHex(data);
} catch (error) {
return "";
}
}
function encodeToNpub(hex) {
const normalized = normalizeHex(hex);
if (!normalized) {
return "";
}
try {
const tools = getNostrTools();
const encoder = tools?.nip19?.npubEncode;
if (typeof encoder !== "function") {
return "";
}
return encoder(normalized) || "";
} catch (error) {
return "";
}
}
function normalizeToHex(candidate) {
const direct = normalizeHex(candidate);
if (direct) {
return direct;
}
return decodeToHex(candidate);
}
function createEmptyAdminSnapshot() {
return {
whitelist: new Set(),
whitelistHex: new Set(),
blacklist: new Set(),
blacklistHex: new Set(),
};
}
function resolveRelayList(client, { write = false } = {}) {
if (!client) {
return [];
@@ -212,12 +305,19 @@ function ensureNumber(value) {
}
export class ModerationService {
constructor({ nostrClient: client = null, logger: log = null } = {}) {
constructor({
nostrClient: client = null,
logger: log = null,
userBlocks: userBlockManager = null,
accessControl: accessControlService = null,
} = {}) {
this.nostrClient = client;
this.log = normalizeLogger(log);
this.viewerPubkey = "";
this.trustedContacts = new Set();
this.userBlocks = userBlockManager;
this.accessControl = accessControlService;
this.contactSubscription = null;
this.contactListPromise = null;
@@ -246,6 +346,18 @@ export class ModerationService {
}
}
setUserBlocks(manager) {
if (manager && manager !== this.userBlocks) {
this.userBlocks = manager;
}
}
setAccessControl(control) {
if (control && control !== this.accessControl) {
this.accessControl = control;
}
}
on(eventName, handler) {
return this.emitter.on(eventName, handler);
}
@@ -254,6 +366,167 @@ export class ModerationService {
this.emitter.emit(eventName, detail);
}
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);
}
}
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");
@@ -284,6 +557,7 @@ export class ModerationService {
} catch (error) {
this.log("[moderationService] contact hydration failed", error);
}
await this.ensureUserBlocksLoaded(normalized);
return this.viewerPubkey;
}
@@ -301,6 +575,7 @@ export class ModerationService {
this.contactSubscription = null;
if (!normalized) {
await this.ensureUserBlocksLoaded(normalized);
return this.viewerPubkey;
}
@@ -323,6 +598,7 @@ export class ModerationService {
this.log("[moderationService] failed to subscribe to contact list", error);
});
await this.ensureUserBlocksLoaded(normalized);
return this.viewerPubkey;
}
@@ -611,9 +887,25 @@ export class ModerationService {
const typeStats = new Map();
let totalTrusted = 0;
const adminSnapshot = this.getAdminListSnapshot();
for (const [reporter, typeMap] of eventReports.entries()) {
const isTrusted = this.trustedContacts.has(reporter);
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) {
@@ -625,7 +917,7 @@ export class ModerationService {
if (createdAt > stats.latest) {
stats.latest = createdAt;
}
if (isTrusted) {
if (isTrustedReporter) {
stats.trusted += 1;
totalTrusted += 1;
}
@@ -678,8 +970,25 @@ export class ModerationService {
const normalizedType = normalizeReportType(type);
const results = [];
const adminSnapshot = this.getAdminListSnapshot();
for (const [reporter, typeMap] of eventReports.entries()) {
if (!this.trustedContacts.has(reporter)) {
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;
}
@@ -689,7 +998,7 @@ export class ModerationService {
continue;
}
results.push({
pubkey: reporter,
pubkey: reporterHex,
latest: ensureNumber(detail?.created_at),
});
continue;
@@ -702,7 +1011,7 @@ export class ModerationService {
latest = candidate;
}
}
results.push({ pubkey: reporter, latest });
results.push({ pubkey: reporterHex, latest });
}
if (!results.length) {
@@ -831,6 +1140,8 @@ export class ModerationService {
const moderationService = new ModerationService({
nostrClient,
logger,
userBlocks,
accessControl,
});
export default moderationService;
+102
View File
@@ -0,0 +1,102 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ModerationService } from "../js/services/moderationService.js";
const nip19 = {
npubEncode(hex) {
if (typeof hex !== "string" || !hex) {
throw new Error("invalid hex");
}
return `npub${hex}`;
},
decode(value) {
if (typeof value !== "string" || !value.startsWith("npub")) {
throw new Error("invalid npub");
}
return { type: "npub", data: value.slice(4) };
},
};
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
test("trusted report summaries respect personal blocks and admin lists", async (t) => {
const previousTools = globalThis.NostrTools;
const previousWindowTools = globalThis.window?.NostrTools;
globalThis.NostrTools = { nip19 };
if (globalThis.window) {
globalThis.window.NostrTools = globalThis.NostrTools;
}
t.after(() => {
if (typeof previousTools === "undefined") {
delete globalThis.NostrTools;
} else {
globalThis.NostrTools = previousTools;
}
if (globalThis.window) {
if (typeof previousWindowTools === "undefined") {
delete globalThis.window.NostrTools;
} else {
globalThis.window.NostrTools = previousWindowTools;
}
}
});
const trustedHex = "a".repeat(64);
const whitelistedHex = "b".repeat(64);
const blacklistedHex = "c".repeat(64);
const blockedHex = "d".repeat(64);
const randomHex = "e".repeat(64);
const eventId = "f".repeat(64);
const userBlocksMock = {
async ensureLoaded() {},
isBlocked(pubkey) {
return pubkey === blockedHex;
},
};
const accessControlMock = {
getWhitelist() {
return [`npub${whitelistedHex}`];
},
getBlacklist() {
return [`npub${blacklistedHex}`];
},
};
const service = new ModerationService({
logger: () => {},
userBlocks: userBlocksMock,
accessControl: accessControlMock,
});
service.trustedContacts = new Set([trustedHex]);
const reports = new Map();
reports.set(trustedHex, new Map([["nudity", { created_at: 100 }]]));
reports.set(whitelistedHex, new Map([["nudity", { created_at: 400 }]]));
reports.set(blacklistedHex, new Map([["nudity", { created_at: 500 }]]));
reports.set(blockedHex, new Map([["nudity", { created_at: 600 }]]));
reports.set(randomHex, new Map([["nudity", { created_at: 300 }]]));
service.reportEvents.set(eventId, reports);
service.recomputeSummaryForEvent(eventId);
const summary = service.getTrustedReportSummary(eventId);
assert.equal(summary.totalTrusted, 2);
assert.equal(summary.types.nudity.total, 3);
assert.equal(summary.types.nudity.trusted, 2);
assert.equal(summary.types.nudity.latest, 400);
const reporters = service.getTrustedReporters(eventId, "nudity");
assert.deepEqual(
reporters.map((entry) => entry.pubkey),
[whitelistedHex, trustedHex]
);
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
});
+105
View File
@@ -0,0 +1,105 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createModerationStage } from "../js/feedEngine/stages.js";
test("moderation stage enforces admin lists and whitelist bypass", async () => {
const whitelistedHex = "1".repeat(64);
const blockedHex = "2".repeat(64);
const blacklistedHex = "3".repeat(64);
const normalHex = "4".repeat(64);
const service = {
async refreshViewerFromClient() {},
async setActiveEventIds() {},
getAdminListSnapshot() {
return {
whitelist: new Set([`npub${whitelistedHex}`]),
whitelistHex: new Set([whitelistedHex]),
blacklist: new Set([`npub${blacklistedHex}`]),
blacklistHex: new Set([blacklistedHex]),
};
},
getAccessControlStatus(identifier) {
const normalized = typeof identifier === "string" ? identifier.trim().toLowerCase() : "";
if (normalized === whitelistedHex) {
return { hex: whitelistedHex, whitelisted: true, blacklisted: false };
}
if (normalized === blacklistedHex) {
return { hex: blacklistedHex, whitelisted: false, blacklisted: true };
}
if (normalized === blockedHex) {
return { hex: blockedHex, whitelisted: false, blacklisted: false };
}
if (normalized === normalHex) {
return { hex: normalHex, whitelisted: false, blacklisted: false };
}
return { hex: normalized, whitelisted: false, blacklisted: false };
},
getTrustedReportSummary() {
return null;
},
trustedReportCount(videoId) {
if (videoId === "c") {
return 5;
}
if (videoId === "d") {
return 3;
}
return 0;
},
getTrustedReporters() {
return [];
},
};
const stage = createModerationStage({
service,
reportType: "nudity",
autoplayThreshold: 2,
blurThreshold: 3,
});
const items = [
{ video: { id: "a", pubkey: blockedHex }, metadata: {} },
{ video: { id: "b", pubkey: blacklistedHex }, metadata: {} },
{ video: { id: "c", pubkey: whitelistedHex }, metadata: {} },
{ video: { id: "d", pubkey: normalHex }, metadata: {} },
];
const why = [];
const context = {
feedName: "discovery",
runtime: {
isAuthorBlocked: (pubkey) => pubkey === blockedHex,
},
addWhy(detail) {
why.push(detail);
return detail;
},
log() {},
};
const result = await stage(items, context);
assert.equal(result.length, 2);
assert.deepEqual(
result.map((entry) => entry.video.id),
["c", "d"]
);
const whitelistedItem = result[0];
assert.equal(whitelistedItem.video.moderation.blockAutoplay, false);
assert.equal(whitelistedItem.video.moderation.blurThumbnail, false);
assert.equal(whitelistedItem.video.moderation.adminWhitelist, true);
assert.equal(whitelistedItem.video.moderation.adminWhitelistBypass, true);
assert.equal(whitelistedItem.metadata.moderation.adminWhitelistBypass, true);
const normalItem = result[1];
assert.equal(normalItem.video.moderation.blockAutoplay, true);
assert.equal(normalItem.video.moderation.blurThumbnail, true);
const reasons = why.map((entry) => entry.reason);
assert(reasons.includes("viewer-block"));
assert(reasons.includes("admin-blacklist"));
assert(reasons.includes("blur"));
});