mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Harden moderation trust handling
This commit is contained in:
1 parent
0aa28a023d
commit
397ed7c8ce
5 files changed
+661
-8
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
Reference in new issue
Block a user