diff --git a/.changeset/content-reporting.md b/.changeset/content-reporting.md new file mode 100644 index 00000000..d35f4b4d --- /dev/null +++ b/.changeset/content-reporting.md @@ -0,0 +1,11 @@ +--- +"nostream": minor +--- + +feat: add WoT-weighted NIP-56 content reporting + +Accepts and stores kind-1984 report events, weighting each report by the reporter's WoT distance +from `wot.seedPubkey` (full weight for a direct follow, halving each additional hop, zero for a +pubkey outside the trust graph). Reports from a `nip56.trustedModerators` pubkey always get maximum +weight and are flagged actionable, ready for a future management-API surface to act on; every other +report is stored for manual review only. Disabled by default (`nip56.enabled: false`). diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 847e45a0..97d9bb02 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -213,6 +213,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. | | nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). **Note:** The GIN index migration is built with the `simple` configuration. If you change this value, you must manually rebuild the index: `DROP INDEX CONCURRENTLY events_content_fts_idx; CREATE INDEX CONCURRENTLY events_content_fts_idx ON events USING gin (to_tsvector('', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. | | nip50.maxQueryLength | Maximum length of the search query string. Queries exceeding this are truncated. Defaults to 256. | +| nip56.enabled | Enable NIP-56 content reporting. When true, kind-1984 report events are stored and scored by the reporter's WoT distance from `wot.seedPubkey`. Defaults to false. | +| nip56.trustedModerators | Pubkeys (hex) whose reports are always maximum-weight and actionable, regardless of WoT distance. Reports from any other pubkey are stored and weighted, but never trigger automatic actions on their own. Defaults to []. | | nip66.dnsCacheTtlSeconds | DNS cache TTL in seconds for repeated probe lookups of the same hostname. Defaults to 300. | | nip66.enabled | Enable NIP-66 relay monitoring. When true, starts a `relay-monitor` cluster worker that probes targets on an interval and stores the latest snapshot in Redis. Defaults to false. | | nip66.probeIntervalSeconds | Seconds between scheduled relay probe runs. Defaults to 3600. | diff --git a/migrations/20260910_120000_create_reports_table.js b/migrations/20260910_120000_create_reports_table.js new file mode 100644 index 00000000..4de113d8 --- /dev/null +++ b/migrations/20260910_120000_create_reports_table.js @@ -0,0 +1,31 @@ +exports.up = function (knex) { + return knex.schema.createTable('reports', (table) => { + // Auto-increment, not the report event's own id: a single kind-1984 event + // can carry a p tag and an e tag with different report types (two + // distinct claims), which needs two rows -- see event_id below for the + // link back to the source event. + table.increments('id').primary() + table.binary('event_id').notNullable() + table.binary('reporter_pubkey').notNullable() + table.binary('reported_pubkey').nullable() + table.binary('reported_event_id').nullable() + table + .enum('report_type', ['nudity', 'malware', 'profanity', 'illegal', 'spam', 'impersonation', 'other']) + .notNullable() + table.float('weight').notNullable() + table.boolean('actionable').notNullable().defaultTo(false) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + + table.index(['event_id'], 'idx_reports_event_id') + table.index(['reported_pubkey'], 'idx_reports_reported_pubkey') + table.index(['reported_event_id'], 'idx_reports_reported_event_id') + table.index(['reporter_pubkey'], 'idx_reports_reporter_pubkey') + // Serves the future Month-5 management API's "actionable reports needing + // review" query -- filter by actionable, ordered newest-first. + table.index(['actionable', 'created_at'], 'idx_reports_actionable_created_at') + }) +} + +exports.down = function (knex) { + return knex.schema.dropTable('reports') +} diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index bbb29dde..3a8e63e2 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -137,6 +137,14 @@ wot: maxDepth: 2 # Reserved for a future periodic rebuild; not yet read by any code. refreshIntervalHours: 24 +nip56: + # NIP-56 content reporting. When enabled, kind-1984 report events are + # stored and scored by the reporter's WoT distance from wot.seedPubkey. + enabled: false + # Pubkeys (hex) whose reports are always maximum-weight and actionable, + # regardless of WoT distance. Reports from any other pubkey are stored + # and weighted, but never trigger automatic actions on their own. + trustedModerators: [] network: maxPayloadSize: 524288 # Uncomment only when using a trusted reverse proxy and configuring trustedProxies. diff --git a/src/@types/report.ts b/src/@types/report.ts new file mode 100644 index 00000000..78fc74e1 --- /dev/null +++ b/src/@types/report.ts @@ -0,0 +1,37 @@ +import { EventId, Pubkey } from './base' + +// NIP-56 standard report types. +export enum ReportType { + NUDITY = 'nudity', + MALWARE = 'malware', + PROFANITY = 'profanity', + ILLEGAL = 'illegal', + SPAM = 'spam', + IMPERSONATION = 'impersonation', + OTHER = 'other', +} + +export interface Report { + id: number + /** The kind-1984 report event that produced this row. */ + eventId: EventId + reporterPubkey: Pubkey + reportedPubkey: Pubkey | null + reportedEventId: EventId | null + reportType: ReportType + weight: number + actionable: boolean + createdAt: Date +} + +export interface DBReport { + id: number + event_id: Buffer + reporter_pubkey: Buffer + reported_pubkey: Buffer | null + reported_event_id: Buffer | null + report_type: ReportType + weight: number + actionable: boolean + created_at: Date +} diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index d4c82a1a..d17257f5 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -6,6 +6,7 @@ import { DBEvent, Event } from './event' import { CreateInviteCodeOptions, InviteCode } from './invite-code' import { Invoice } from './invoice' import { Nip05Verification } from './nip05' +import { Report } from './report' import { EventKindsRange } from './settings' import { SubscriptionFilter } from './subscription' import { User } from './user' @@ -83,3 +84,9 @@ export interface IDvmJobRepository { ): Promise findPendingJobs(limit?: number, kinds?: number[]): Promise } + +export interface IReportRepository { + create(report: Omit): Promise + findByEventId(eventId: EventId): Promise + findActionable(limit?: number): Promise +} diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 7e91922f..c104ab1a 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -421,6 +421,18 @@ export interface Nip43Settings { inviteRequestWhitelist?: Pubkey[] } +export interface Nip56Settings { + enabled: boolean + /** + * Pubkeys (hex) whose kind-1984 reports are treated as coming from a + * trusted moderator: their reports get maximum weight and are flagged + * actionable, regardless of WoT graph distance. Reports from any other + * pubkey are scored purely by WoT distance from `wot.seedPubkey` and are + * never actionable on their own -- only stored for manual review. + */ + trustedModerators: Pubkey[] +} + export interface Settings { info: Info admin?: AdminSettings @@ -436,6 +448,7 @@ export interface Settings { nip43?: Nip43Settings nip45?: Nip45Settings nip50?: Nip50Settings + nip56?: Nip56Settings nip66?: Nip66Settings wot?: WoTSettings } diff --git a/src/constants/base.ts b/src/constants/base.ts index f8ebd71a..168a1236 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -32,6 +32,8 @@ export enum EventKinds { GIFT_WRAP = 1059, // NIP-03: OpenTimestamps attestation OPEN_TIMESTAMPS = 1040, + // NIP-56: Reporting + REPORT = 1984, // Relay-only RELAY_INVITE = 50, INVOICE_UPDATE = 402, diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index a89fb818..e1d99a00 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -1,5 +1,11 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters' -import { IDvmJobRepository, IEventRepository, IInviteCodeRepository, IUserRepository } from '../@types/repositories' +import { + IDvmJobRepository, + IEventRepository, + IInviteCodeRepository, + IReportRepository, + IUserRepository, +} from '../@types/repositories' import { isContactListEvent, isDeleteEvent, @@ -13,6 +19,7 @@ import { isRequestToVanishEvent, } from '../utils/event' import { isNip43InviteRequest, isNip43JoinRequest, isNip43LeaveRequest } from '../utils/nip43' +import { isReportEvent } from '../utils/nip56' import { isRelayListEvent } from '../utils/nip65' import { ContactListEventStrategy } from '../handlers/event-strategies/contact-list-event-strategy' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' @@ -29,6 +36,7 @@ import { JoinRequestEventStrategy } from '../handlers/event-strategies/join-requ import { LeaveRequestEventStrategy } from '../handlers/event-strategies/leave-request-event-strategy' import { ParameterizedReplaceableEventStrategy } from '../handlers/event-strategies/parameterized-replaceable-event-strategy' import { ReplaceableEventStrategy } from '../handlers/event-strategies/replaceable-event-strategy' +import { ReportEventStrategy } from '../handlers/event-strategies/report-event-strategy' import { Settings } from '../@types/settings' import { TimestampEventStrategy } from '../handlers/event-strategies/timestamp-event-strategy' import { VanishEventStrategy } from '../handlers/event-strategies/vanish-event-strategy' @@ -40,6 +48,7 @@ export const eventStrategyFactory = userRepository: IUserRepository, inviteCodeRepository: IInviteCodeRepository, dvmJobRepository: IDvmJobRepository, + reportRepository: IReportRepository, cache: ICacheAdapter, settings: () => Settings, ): Factory>, [Event, IWebSocketAdapter]> => @@ -61,6 +70,17 @@ export const eventStrategyFactory = eventRepository, wotGraphServiceFactory(cache, eventRepository, settings), ) + // NIP-56: reports (kind 1984) need WoT-weighted scoring against the + // same graph, and kind 1984 isn't in any special range, so it must be + // checked explicitly before falling through to DefaultEventStrategy. + } else if (isReportEvent(event)) { + return new ReportEventStrategy( + adapter, + eventRepository, + reportRepository, + wotGraphServiceFactory(cache, eventRepository, settings), + settings, + ) } else if (isRelayListEvent(event) || isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) // NIP-43: Join/Leave/Invite requests MUST be checked before the generic diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 3bf1a3f5..60292820 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -4,6 +4,7 @@ import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, + IReportRepository, IUserRepository, } from '../@types/repositories' import { IncomingMessage, MessageType } from '../@types/messages' @@ -33,6 +34,7 @@ export const messageHandlerFactory = nip05VerificationRepository: INip05VerificationRepository, inviteCodeRepository: IInviteCodeRepository, dvmJobRepository: IDvmJobRepository, + reportRepository: IReportRepository, ) => ([message, adapter]: [IncomingMessage, IWebSocketAdapter]) => { switch (message[0]) { @@ -44,6 +46,7 @@ export const messageHandlerFactory = userRepository, inviteCodeRepository, dvmJobRepository, + reportRepository, getCache(), createSettings, ), diff --git a/src/factories/websocket-adapter-factory.ts b/src/factories/websocket-adapter-factory.ts index 67cb8764..0d0246ff 100644 --- a/src/factories/websocket-adapter-factory.ts +++ b/src/factories/websocket-adapter-factory.ts @@ -6,6 +6,7 @@ import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, + IReportRepository, IUserRepository, } from '../@types/repositories' import { createSettings } from './settings-factory' @@ -21,6 +22,7 @@ export const webSocketAdapterFactory = nip05VerificationRepository: INip05VerificationRepository, inviteCodeRepository: IInviteCodeRepository, dvmJobRepository: IDvmJobRepository, + reportRepository: IReportRepository, ) => ([client, request, webSocketServerAdapter]: [WebSocket, IncomingMessage, IWebSocketServerAdapter]) => new WebSocketAdapter( @@ -33,6 +35,7 @@ export const webSocketAdapterFactory = nip05VerificationRepository, inviteCodeRepository, dvmJobRepository, + reportRepository, ), rateLimiterFactory, createSettings, diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index ce9f2f42..828b1991 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -12,6 +12,7 @@ import { DvmJobRepository } from '../repositories/dvm-job-repository' import { EventRepository } from '../repositories/event-repository' import { InviteCodeRepository } from '../repositories/invite-code-repository' import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository' +import { ReportRepository } from '../repositories/report-repository' import { UserRepository } from '../repositories/user-repository' import { webSocketAdapterFactory } from './websocket-adapter-factory' import { WebSocketServerAdapter } from '../adapters/web-socket-server-adapter' @@ -26,6 +27,7 @@ export const workerFactory = (): AppWorker => { const nip05VerificationRepository = new Nip05VerificationRepository(dbClient) const inviteCodeRepository = new InviteCodeRepository(dbClient) const dvmJobRepository = new DvmJobRepository(dbClient) + const reportRepository = new ReportRepository(dbClient) const settings = createSettings() @@ -73,6 +75,7 @@ export const workerFactory = (): AppWorker => { nip05VerificationRepository, inviteCodeRepository, dvmJobRepository, + reportRepository, ), createSettings, ) diff --git a/src/handlers/event-strategies/report-event-strategy.ts b/src/handlers/event-strategies/report-event-strategy.ts new file mode 100644 index 00000000..9905c47d --- /dev/null +++ b/src/handlers/event-strategies/report-event-strategy.ts @@ -0,0 +1,76 @@ +import { createEventCommandResult } from '../../telemetry/event-metrics' +import { createLogger } from '../../factories/logger-factory' +import { calculateReportWeight } from '../../utils/report-scoring' +import { Event } from '../../@types/event' +import { extractReportTargets } from '../../utils/nip56' +import { IEventRepository, IReportRepository } from '../../@types/repositories' +import { IEventStrategy } from '../../@types/message-handlers' +import { IWebSocketAdapter } from '../../@types/adapters' +import { IWotGraphService } from '../../@types/services' +import { Settings } from '../../@types/settings' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const logger = createLogger('report-event-strategy') + +export class ReportEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly eventRepository: IEventRepository, + private readonly reportRepository: IReportRepository, + private readonly wotGraphService: IWotGraphService, + private readonly settings: () => Settings, + ) {} + + public async execute(event: Event): Promise { + logger('received report event: %o', event) + + const count = await this.eventRepository.create(event) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createEventCommandResult(event.id, true, count ? '' : 'duplicate:'), + ) + + if (!count) { + return + } + + this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event) + + try { + const nip56 = this.settings().nip56 + if (!nip56?.enabled) { + return + } + + const trustedModerators = nip56.trustedModerators ?? [] + const isTrustedModerator = trustedModerators.includes(event.pubkey) + // A moderator's weight doesn't depend on distance at all (see + // calculateReportWeight), so skip the graph lookup entirely for them -- + // it can trigger a full WoT rebuild (Redis/DB reads) for a value that + // would just be discarded. + const distance = isTrustedModerator ? undefined : await this.wotGraphService.getDistance(event.pubkey) + const weight = calculateReportWeight(distance, isTrustedModerator) + + for (const target of extractReportTargets(event.tags)) { + const hasValidTarget = target.reportedPubkey !== null || target.reportedEventId !== null + + await this.reportRepository.create({ + eventId: event.id, + reporterPubkey: event.pubkey, + reportedPubkey: target.reportedPubkey, + reportedEventId: target.reportedEventId, + reportType: target.reportType, + weight, + // A moderator report with no valid target has nothing to act on -- + // never mark it actionable regardless of who sent it. + actionable: isTrustedModerator && hasValidTarget, + }) + } + } catch (error) { + // Report scoring/recording is best-effort: the report event itself is + // already stored and broadcast correctly, so a failure here must not + // surface as a rejection of a valid event. + logger.error('unable to record report for event %s: %o', event.id, error) + } + } +} diff --git a/src/repositories/report-repository.ts b/src/repositories/report-repository.ts new file mode 100644 index 00000000..a7416ec7 --- /dev/null +++ b/src/repositories/report-repository.ts @@ -0,0 +1,74 @@ +import { DatabaseClient, EventId } from '../@types/base' +import { DBReport, Report } from '../@types/report' +import { IReportRepository } from '../@types/repositories' +import { createLogger } from '../factories/logger-factory' +import { fromBuffer, toBuffer } from '../utils/transform' + +const logger = createLogger('report-repository') + +function fromDBReport(row: DBReport): Report { + return { + id: row.id, + eventId: fromBuffer(row.event_id), + reporterPubkey: fromBuffer(row.reporter_pubkey), + reportedPubkey: row.reported_pubkey ? fromBuffer(row.reported_pubkey) : null, + reportedEventId: row.reported_event_id ? fromBuffer(row.reported_event_id) : null, + reportType: row.report_type, + weight: row.weight, + actionable: row.actionable, + createdAt: row.created_at, + } +} + +export class ReportRepository implements IReportRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async create( + report: Omit, + client: DatabaseClient = this.dbClient, + ): Promise { + logger( + 'create report for event %s from %s (weight %d, actionable %s)', + report.eventId, + report.reporterPubkey, + report.weight, + report.actionable, + ) + + const now = new Date() + const row: Omit = { + event_id: toBuffer(report.eventId), + reporter_pubkey: toBuffer(report.reporterPubkey), + reported_pubkey: report.reportedPubkey ? toBuffer(report.reportedPubkey) : null, + reported_event_id: report.reportedEventId ? toBuffer(report.reportedEventId) : null, + report_type: report.reportType, + weight: report.weight, + actionable: report.actionable, + created_at: now, + } + + const [inserted] = await client('reports').insert(row).returning(['id']) + + return fromDBReport({ ...row, id: inserted.id }) + } + + public async findByEventId(eventId: EventId, client: DatabaseClient = this.dbClient): Promise { + logger('find reports for event %s', eventId) + + const rows = await client('reports').where('event_id', toBuffer(eventId)).select() + + return rows.map(fromDBReport) + } + + public async findActionable(limit = 100, client: DatabaseClient = this.dbClient): Promise { + logger('find actionable reports (limit %d)', limit) + + const rows = await client('reports') + .where('actionable', true) + .orderBy('created_at', 'desc') + .limit(limit) + .select() + + return rows.map(fromDBReport) + } +} diff --git a/src/utils/nip56.ts b/src/utils/nip56.ts new file mode 100644 index 00000000..6bee9c78 --- /dev/null +++ b/src/utils/nip56.ts @@ -0,0 +1,65 @@ +import { EventId, Pubkey, Tag } from '../@types/base' +import { EventKinds, EventTags } from '../constants/base' +import { Event } from '../@types/event' +import { ReportType } from '../@types/report' + +export const isReportEvent = (event: Event): boolean => event.kind === EventKinds.REPORT + +const REPORT_TYPES = new Set(Object.values(ReportType)) + +const isValidReportType = (value: string | undefined): value is ReportType => + typeof value === 'string' && REPORT_TYPES.has(value) + +const HEX64_PATTERN = /^[0-9a-f]{64}$/i + +// The persistence path hex-decodes this straight into a fixed-width binary +// column (toBuffer/Buffer.from(..., 'hex')) -- an arbitrary or malformed tag +// value would silently become a truncated/empty byte string instead of a +// queryable 64-character identifier, so it's rejected here instead. +const isValidHex64 = (value: string | undefined): value is string => + typeof value === 'string' && HEX64_PATTERN.test(value) + +export interface ReportTarget { + reportedPubkey: Pubkey | null + reportedEventId: EventId | null + reportType: ReportType +} + +// NIP-56: the report type is the 3rd element of the tag identifying what's +// being reported. A p tag and an e tag are separate claims (report this +// pubkey, report this event) that happen to travel in the same event -- when +// they share a type (the common case: reporting one piece of content and its +// author for the same reason) they collapse into a single target; when their +// types disagree, each keeps its own type as its own target rather than one +// silently overwriting the other's. +export const extractReportTargets = (tags: Tag[]): ReportTarget[] => { + const pTag = tags.find((tag) => tag[0] === EventTags.Pubkey && tag.length >= 2 && isValidHex64(tag[1])) + const eTag = tags.find((tag) => tag[0] === EventTags.Event && tag.length >= 2 && isValidHex64(tag[1])) + + const pType = isValidReportType(pTag?.[2]) ? pTag![2] : undefined + const eType = isValidReportType(eTag?.[2]) ? eTag![2] : undefined + + if (!pTag && !eTag) { + return [{ reportedPubkey: null, reportedEventId: null, reportType: ReportType.OTHER }] + } + + if (pTag && eTag) { + // Only a genuine disagreement (both sides carry an explicit, different + // type) splits into two targets; one side simply omitting a type isn't a + // conflict, so it falls back to whichever side did specify one. + if (pType !== undefined && eType !== undefined && pType !== eType) { + return [ + { reportedPubkey: null, reportedEventId: eTag[1], reportType: eType }, + { reportedPubkey: pTag[1], reportedEventId: null, reportType: pType }, + ] + } + + return [{ reportedPubkey: pTag[1], reportedEventId: eTag[1], reportType: eType ?? pType ?? ReportType.OTHER }] + } + + if (eTag) { + return [{ reportedPubkey: null, reportedEventId: eTag[1], reportType: eType ?? ReportType.OTHER }] + } + + return [{ reportedPubkey: pTag![1], reportedEventId: null, reportType: pType ?? ReportType.OTHER }] +} diff --git a/src/utils/report-scoring.ts b/src/utils/report-scoring.ts new file mode 100644 index 00000000..80f7ac71 --- /dev/null +++ b/src/utils/report-scoring.ts @@ -0,0 +1,16 @@ +// A trusted moderator's report always carries maximum weight, independent of +// their WoT graph distance -- moderators are an explicit, separate trust +// source from the follow graph. Everyone else is scored by exponential hop +// decay: a direct follow (distance 1) gets full weight, weight halves each +// additional hop (distance 2 -> 1/2, distance 3 -> 1/4, distance 4 -> 1/8, +// ...), and a pubkey outside the trust graph entirely (distance undefined) +// gets zero -- stored, per NIP-56/§1a, but "doesn't trigger anything". +export const calculateReportWeight = (distance: number | undefined, isTrustedModerator: boolean): number => { + if (isTrustedModerator) { + return 1 + } + if (distance === undefined) { + return 0 + } + return distance <= 1 ? 1 : 1 / 2 ** (distance - 1) +} diff --git a/src/utils/settings-guided-schema.ts b/src/utils/settings-guided-schema.ts index d3279d64..054a130f 100644 --- a/src/utils/settings-guided-schema.ts +++ b/src/utils/settings-guided-schema.ts @@ -242,6 +242,13 @@ export const guidedSettingCategories: GuidedSettingCategory[] = [ type: 'number', validate: requireSafeNonNegativeIntegerSettingValue, }, + { label: 'Enable NIP-56 content reporting', path: 'nip56.enabled', type: 'boolean' }, + { + label: 'NIP-56 trusted moderator pubkeys (hex)', + path: 'nip56.trustedModerators', + type: 'stringArray', + placeholder: 'One pubkey per line', + }, ], }, { diff --git a/test/unit/factories/event-strategy-factory.spec.ts b/test/unit/factories/event-strategy-factory.spec.ts index 603468e9..6d2a8db5 100644 --- a/test/unit/factories/event-strategy-factory.spec.ts +++ b/test/unit/factories/event-strategy-factory.spec.ts @@ -4,6 +4,7 @@ import { IDvmJobRepository, IEventRepository, IInviteCodeRepository, + IReportRepository, IUserRepository, } from '../../../src/@types/repositories' import { ContactListEventStrategy } from '../../../src/handlers/event-strategies/contact-list-event-strategy' @@ -24,6 +25,7 @@ import { JoinRequestEventStrategy } from '../../../src/handlers/event-strategies import { LeaveRequestEventStrategy } from '../../../src/handlers/event-strategies/leave-request-event-strategy' import { ParameterizedReplaceableEventStrategy } from '../../../src/handlers/event-strategies/parameterized-replaceable-event-strategy' import { ReplaceableEventStrategy } from '../../../src/handlers/event-strategies/replaceable-event-strategy' +import { ReportEventStrategy } from '../../../src/handlers/event-strategies/report-event-strategy' import { Settings } from '../../../src/@types/settings' import { TimestampEventStrategy } from '../../../src/handlers/event-strategies/timestamp-event-strategy' import { VanishEventStrategy } from '../../../src/handlers/event-strategies/vanish-event-strategy' @@ -33,6 +35,7 @@ describe('eventStrategyFactory', () => { let userRepository: IUserRepository let inviteCodeRepository: IInviteCodeRepository let dvmJobRepository: IDvmJobRepository + let reportRepository: IReportRepository let cache: ICacheAdapter let settings: () => Settings let event: Event @@ -44,6 +47,7 @@ describe('eventStrategyFactory', () => { userRepository = {} as any inviteCodeRepository = {} as any dvmJobRepository = {} as any + reportRepository = {} as any cache = {} as any settings = () => ({ info: { relay_url: 'wss://test.relay' }, wot: { enabled: false } }) as any event = {} as any @@ -54,6 +58,7 @@ describe('eventStrategyFactory', () => { userRepository, inviteCodeRepository, dvmJobRepository, + reportRepository, cache, settings, ) @@ -182,4 +187,9 @@ describe('eventStrategyFactory', () => { event.kind = EventKinds.HANDLER_INFORMATION expect(factory([event, adapter])).to.be.an.instanceOf(ParameterizedReplaceableEventStrategy) }) + + it('returns ReportEventStrategy given a report event (NIP-56, kind 1984)', () => { + event.kind = EventKinds.REPORT + expect(factory([event, adapter])).to.be.an.instanceOf(ReportEventStrategy) + }) }) diff --git a/test/unit/factories/message-handler-factory.spec.ts b/test/unit/factories/message-handler-factory.spec.ts index 9ce8a284..6e5374d9 100644 --- a/test/unit/factories/message-handler-factory.spec.ts +++ b/test/unit/factories/message-handler-factory.spec.ts @@ -5,6 +5,7 @@ import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, + IReportRepository, IUserRepository, } from '../../../src/@types/repositories' import { IncomingMessage, MessageType } from '../../../src/@types/messages' @@ -26,6 +27,7 @@ describe('messageHandlerFactory', () => { let nip05VerificationRepository: INip05VerificationRepository let inviteCodeRepository: IInviteCodeRepository let dvmJobRepository: IDvmJobRepository + let reportRepository: IReportRepository let message: IncomingMessage let adapter: IWebSocketAdapter let factory @@ -50,6 +52,7 @@ describe('messageHandlerFactory', () => { nip05VerificationRepository = {} as any inviteCodeRepository = {} as any dvmJobRepository = {} as any + reportRepository = {} as any adapter = {} as any event = { tags: [], @@ -60,6 +63,7 @@ describe('messageHandlerFactory', () => { nip05VerificationRepository, inviteCodeRepository, dvmJobRepository, + reportRepository, ) }) diff --git a/test/unit/factories/websocket-adapter-factory.spec.ts b/test/unit/factories/websocket-adapter-factory.spec.ts index 48c34ba6..9c3c3da4 100644 --- a/test/unit/factories/websocket-adapter-factory.spec.ts +++ b/test/unit/factories/websocket-adapter-factory.spec.ts @@ -8,6 +8,7 @@ import { IEventRepository, IInviteCodeRepository, INip05VerificationRepository, + IReportRepository, IUserRepository, } from '../../../src/@types/repositories' import { IWebSocketServerAdapter } from '../../../src/@types/adapters' @@ -40,6 +41,7 @@ describe('webSocketAdapterFactory', () => { const nip05VerificationRepository: INip05VerificationRepository = {} as any const inviteCodeRepository: IInviteCodeRepository = {} as any const dvmJobRepository: IDvmJobRepository = {} as any + const reportRepository: IReportRepository = {} as any const client: WebSocket = { on: onStub, @@ -61,6 +63,7 @@ describe('webSocketAdapterFactory', () => { nip05VerificationRepository, inviteCodeRepository, dvmJobRepository, + reportRepository, ) expect(factory([client, request, webSocketServerAdapter])).to.be.an.instanceOf(WebSocketAdapter) }) diff --git a/test/unit/handlers/event-strategies/report-event-strategy.spec.ts b/test/unit/handlers/event-strategies/report-event-strategy.spec.ts new file mode 100644 index 00000000..44533fdf --- /dev/null +++ b/test/unit/handlers/event-strategies/report-event-strategy.spec.ts @@ -0,0 +1,278 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' + +chai.use(chaiAsPromised) + +const { expect } = chai + +import { Event } from '../../../../src/@types/event' +import { IEventRepository, IReportRepository } from '../../../../src/@types/repositories' +import { IEventStrategy } from '../../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { IWotGraphService } from '../../../../src/@types/services' +import { MessageType } from '../../../../src/@types/messages' +import { ReportEventStrategy } from '../../../../src/handlers/event-strategies/report-event-strategy' +import { ReportType } from '../../../../src/@types/report' +import { Settings } from '../../../../src/@types/settings' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +describe('ReportEventStrategy', () => { + const reporterPubkey = '2'.repeat(64) + const reportedPubkey = '3'.repeat(64) + const reportedEventId = '4'.repeat(64) + + const event: Event = { + id: 'event-id', + pubkey: reporterPubkey, + kind: 1984, + tags: [['p', reportedPubkey, 'spam']], + } as any + + let webSocket: IWebSocketAdapter + let eventRepository: IEventRepository + let reportRepository: IReportRepository + let wotGraphService: IWotGraphService + let settings: () => Settings + + let webSocketEmitStub: Sinon.SinonStub + let eventRepositoryCreateStub: Sinon.SinonStub + let reportRepositoryCreateStub: Sinon.SinonStub + let getDistanceStub: Sinon.SinonStub + + let strategy: IEventStrategy> + + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + webSocketEmitStub = sandbox.stub() + webSocket = { + emit: webSocketEmitStub, + } as any + + eventRepositoryCreateStub = sandbox.stub() + eventRepository = { + create: eventRepositoryCreateStub, + } as any + + reportRepositoryCreateStub = sandbox.stub() + reportRepository = { + create: reportRepositoryCreateStub, + } as any + + getDistanceStub = sandbox.stub() + wotGraphService = { + getDistance: getDistanceStub, + } as any + + settings = () => ({ nip56: { enabled: true, trustedModerators: [] } }) as any + + strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('execute', () => { + it('creates the event', async () => { + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateStub.resolves({}) + getDistanceStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + }) + + it('broadcasts the event when newly created', async () => { + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateStub.resolves({}) + getDistanceStub.resolves(1) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + 'event-id', + true, + '', + ]) + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Broadcast, event) + }) + + it('does not broadcast or record a report when the event is a duplicate', async () => { + eventRepositoryCreateStub.resolves(0) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + 'event-id', + true, + 'duplicate:', + ]) + expect(reportRepositoryCreateStub).not.to.have.been.called + }) + + it('records a report with full weight for a direct follow (distance 1)', async () => { + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateStub.resolves({}) + getDistanceStub.resolves(1) + + await strategy.execute(event) + + expect(reportRepositoryCreateStub).to.have.been.calledOnceWithExactly({ + eventId: 'event-id', + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.SPAM, + weight: 1, + actionable: false, + }) + }) + + it('records a report with zero weight for a reporter outside the trust graph', async () => { + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateStub.resolves({}) + getDistanceStub.resolves(undefined) + + await strategy.execute(event) + + expect(reportRepositoryCreateStub).to.have.been.calledOnceWithExactly({ + eventId: 'event-id', + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.SPAM, + weight: 0, + actionable: false, + }) + }) + + it('records an actionable, max-weight report from a trusted moderator regardless of distance', async () => { + settings = () => ({ nip56: { enabled: true, trustedModerators: [reporterPubkey] } }) as any + strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateStub.resolves({}) + + await strategy.execute(event) + + expect(reportRepositoryCreateStub).to.have.been.calledOnceWithExactly({ + eventId: 'event-id', + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }) + }) + + it('does not consult the WoT graph for a trusted moderator', async () => { + settings = () => ({ nip56: { enabled: true, trustedModerators: [reporterPubkey] } }) as any + strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateStub.resolves({}) + + await strategy.execute(event) + + expect(getDistanceStub).not.to.have.been.called + }) + + it('does not mark a moderator report actionable when it has no valid target', async () => { + const noTargetEvent: Event = { ...event, tags: [] } as any + settings = () => ({ nip56: { enabled: true, trustedModerators: [reporterPubkey] } }) as any + strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateStub.resolves({}) + + await strategy.execute(noTargetEvent) + + expect(reportRepositoryCreateStub).to.have.been.calledOnceWithExactly({ + eventId: 'event-id', + reporterPubkey, + reportedPubkey: null, + reportedEventId: null, + reportType: ReportType.OTHER, + weight: 1, + actionable: false, + }) + }) + + it('records one row per target when p and e tags carry different report types', async () => { + const mixedEvent: Event = { + ...event, + tags: [ + ['p', reportedPubkey, 'impersonation'], + ['e', reportedEventId, 'nudity'], + ], + } as any + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateStub.resolves({}) + getDistanceStub.resolves(1) + + await strategy.execute(mixedEvent) + + expect(reportRepositoryCreateStub).to.have.been.calledTwice + expect(reportRepositoryCreateStub.firstCall).to.have.been.calledWithExactly({ + eventId: 'event-id', + reporterPubkey, + reportedPubkey: null, + reportedEventId, + reportType: ReportType.NUDITY, + weight: 1, + actionable: false, + }) + expect(reportRepositoryCreateStub.secondCall).to.have.been.calledWithExactly({ + eventId: 'event-id', + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.IMPERSONATION, + weight: 1, + actionable: false, + }) + }) + + it('stores the event but does not record a report when nip56 is disabled', async () => { + settings = () => ({ nip56: { enabled: false, trustedModerators: [] } }) as any + strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Broadcast, event) + expect(reportRepositoryCreateStub).not.to.have.been.called + expect(getDistanceStub).not.to.have.been.called + }) + + it('does not reject the event when report recording fails', async () => { + eventRepositoryCreateStub.resolves(1) + getDistanceStub.resolves(1) + reportRepositoryCreateStub.rejects(new Error('db unavailable')) + + await expect(strategy.execute(event)).to.eventually.be.fulfilled + + expect(webSocketEmitStub).to.have.been.calledWithExactly(WebSocketAdapterEvent.Message, [ + MessageType.OK, + 'event-id', + true, + '', + ]) + }) + + it('rejects if unable to create the event', async () => { + const error = new Error('event creation failed') + eventRepositoryCreateStub.rejects(error) + + await expect(strategy.execute(event)).to.eventually.be.rejectedWith(error) + + expect(reportRepositoryCreateStub).not.to.have.been.called + }) + }) +}) diff --git a/test/unit/repositories/report-repository.spec.ts b/test/unit/repositories/report-repository.spec.ts new file mode 100644 index 00000000..ad36bfd9 --- /dev/null +++ b/test/unit/repositories/report-repository.spec.ts @@ -0,0 +1,216 @@ +import * as chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import * as sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { DatabaseClient } from '../../../src/@types/base' +import { ReportRepository } from '../../../src/repositories/report-repository' +import { ReportType } from '../../../src/@types/report' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +describe('ReportRepository', () => { + let repository: ReportRepository + let sandbox: sinon.SinonSandbox + + const fixedDate = new Date('2026-09-10T00:00:00.000Z') + const eventId = 'a'.repeat(64) + const reporterPubkey = '2'.repeat(64) + const reportedPubkey = '3'.repeat(64) + const reportedEventId = '4'.repeat(64) + + const dbReportRow = { + id: 7, + event_id: Buffer.from(eventId, 'hex'), + reporter_pubkey: Buffer.from(reporterPubkey, 'hex'), + reported_pubkey: Buffer.from(reportedPubkey, 'hex'), + reported_event_id: Buffer.from(reportedEventId, 'hex'), + report_type: ReportType.SPAM, + weight: 1, + actionable: true, + created_at: fixedDate, + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + sandbox.useFakeTimers(fixedDate.getTime()) + + repository = new ReportRepository({} as DatabaseClient) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('.create', () => { + it('inserts into the reports table', async () => { + const returningStub = sandbox.stub().resolves([{ id: 7 }]) + const insertStub = sandbox.stub().returns({ returning: returningStub }) + const client = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + await repository.create( + { + eventId, + reporterPubkey, + reportedPubkey, + reportedEventId, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }, + client, + ) + + expect(client).to.have.been.calledWith('reports') + expect(returningStub).to.have.been.calledWith(['id']) + }) + + it('returns a Report reflecting the input, with the DB-generated id', async () => { + const returningStub = sandbox.stub().resolves([{ id: 7 }]) + const insertStub = sandbox.stub().returns({ returning: returningStub }) + const client = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + const result = await repository.create( + { + eventId, + reporterPubkey, + reportedPubkey, + reportedEventId, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }, + client, + ) + + expect(result).to.deep.include({ + id: 7, + eventId, + reporterPubkey, + reportedPubkey, + reportedEventId, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }) + expect(result.createdAt).to.be.instanceOf(Date) + }) + + it('stores hex fields as buffers', async () => { + const returningStub = sandbox.stub().resolves([{ id: 7 }]) + const insertStub = sandbox.stub().returns({ returning: returningStub }) + const client = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + await repository.create( + { + eventId, + reporterPubkey, + reportedPubkey, + reportedEventId, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }, + client, + ) + + const insertedRow = insertStub.firstCall.args[0] + expect(insertedRow.event_id).to.deep.equal(Buffer.from(eventId, 'hex')) + expect(insertedRow.reporter_pubkey).to.deep.equal(Buffer.from(reporterPubkey, 'hex')) + expect(insertedRow.reported_pubkey).to.deep.equal(Buffer.from(reportedPubkey, 'hex')) + expect(insertedRow.reported_event_id).to.deep.equal(Buffer.from(reportedEventId, 'hex')) + expect(insertedRow).to.not.have.property('id') + }) + + it('stores null reported_pubkey/reported_event_id when not provided', async () => { + const returningStub = sandbox.stub().resolves([{ id: 8 }]) + const insertStub = sandbox.stub().returns({ returning: returningStub }) + const client = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + await repository.create( + { + eventId, + reporterPubkey, + reportedPubkey: null, + reportedEventId: null, + reportType: ReportType.OTHER, + weight: 0, + actionable: false, + }, + client, + ) + + const insertedRow = insertStub.firstCall.args[0] + expect(insertedRow.reported_pubkey).to.be.null + expect(insertedRow.reported_event_id).to.be.null + }) + }) + + describe('.findByEventId', () => { + it('returns an empty array when no reports are found', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([]) }), + }) as unknown as DatabaseClient + + const result = await repository.findByEventId(eventId, client) + + expect(result).to.be.an('array').that.is.empty + }) + + it('returns transformed Report rows when found', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([dbReportRow]) }), + }) as unknown as DatabaseClient + + const result = await repository.findByEventId(eventId, client) + + expect(result).to.have.lengthOf(1) + expect(result[0].id).to.equal(7) + expect(result[0].eventId).to.equal(eventId) + expect(result[0].reporterPubkey).to.equal(reporterPubkey) + expect(result[0].actionable).to.equal(true) + }) + + it('queries by event_id', async () => { + const whereStub = sandbox.stub().returns({ select: sandbox.stub().resolves([]) }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + await repository.findByEventId(eventId, client) + + expect(whereStub).to.have.been.calledWith('event_id', Buffer.from(eventId, 'hex')) + }) + }) + + describe('.findActionable', () => { + it('filters by actionable and orders newest first', async () => { + const selectStub = sandbox.stub().resolves([dbReportRow]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereStub = sandbox.stub().returns({ orderBy: orderByStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + const result = await repository.findActionable(10, client) + + expect(whereStub).to.have.been.calledWith('actionable', true) + expect(orderByStub).to.have.been.calledWith('created_at', 'desc') + expect(limitStub).to.have.been.calledWith(10) + expect(result).to.have.lengthOf(1) + expect(result[0].id).to.equal(7) + }) + + it('defaults limit to 100', async () => { + const selectStub = sandbox.stub().resolves([]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereStub = sandbox.stub().returns({ orderBy: orderByStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + await repository.findActionable(undefined, client) + + expect(limitStub).to.have.been.calledWith(100) + }) + }) +}) diff --git a/test/unit/utils/nip56.spec.ts b/test/unit/utils/nip56.spec.ts new file mode 100644 index 00000000..1249f0c5 --- /dev/null +++ b/test/unit/utils/nip56.spec.ts @@ -0,0 +1,135 @@ +import { expect } from 'chai' +import { Event } from '../../../src/@types/event' +import { extractReportTargets, isReportEvent } from '../../../src/utils/nip56' +import { ReportType } from '../../../src/@types/report' +import { Tag } from '../../../src/@types/base' + +const baseEvent = (): Partial => ({ + kind: 1984, + tags: [], + content: '', +}) + +describe('NIP-56', () => { + describe('isReportEvent', () => { + it('returns true for kind 1984', () => { + expect(isReportEvent({ ...baseEvent(), kind: 1984 } as Event)).to.equal(true) + }) + + it('returns false for kind 1 (text_note)', () => { + expect(isReportEvent({ ...baseEvent(), kind: 1 } as Event)).to.equal(false) + }) + + it('returns false for kind 3 (contact_list)', () => { + expect(isReportEvent({ ...baseEvent(), kind: 3 } as Event)).to.equal(false) + }) + }) + + describe('extractReportTargets', () => { + it('returns a single null/OTHER target when no e/p tags are present', () => { + expect(extractReportTargets([])).to.deep.equal([ + { reportedPubkey: null, reportedEventId: null, reportType: ReportType.OTHER }, + ]) + }) + + it('extracts a reported pubkey and its report type from a p tag', () => { + const tags = [['p', 'a'.repeat(64), 'impersonation']] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: 'a'.repeat(64), reportedEventId: null, reportType: ReportType.IMPERSONATION }, + ]) + }) + + it('extracts a reported event id and its report type from an e tag', () => { + const tags = [['e', 'b'.repeat(64), 'spam']] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: null, reportedEventId: 'b'.repeat(64), reportType: ReportType.SPAM }, + ]) + }) + + it('merges p and e tags into one target when they share the same type', () => { + const tags = [ + ['p', 'a'.repeat(64), 'nudity'], + ['e', 'b'.repeat(64), 'nudity'], + ] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: 'a'.repeat(64), reportedEventId: 'b'.repeat(64), reportType: ReportType.NUDITY }, + ]) + }) + + it('splits into two targets, each keeping its own type, when p and e tags disagree', () => { + const tags = [ + ['p', 'a'.repeat(64), 'impersonation'], + ['e', 'b'.repeat(64), 'nudity'], + ] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: null, reportedEventId: 'b'.repeat(64), reportType: ReportType.NUDITY }, + { reportedPubkey: 'a'.repeat(64), reportedEventId: null, reportType: ReportType.IMPERSONATION }, + ]) + }) + + it('merges p and e tags when the e tag has no type but the p tag does', () => { + const tags = [ + ['p', 'a'.repeat(64), 'malware'], + ['e', 'b'.repeat(64)], + ] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: 'a'.repeat(64), reportedEventId: 'b'.repeat(64), reportType: ReportType.MALWARE }, + ]) + }) + + it('merges p and e tags when neither carries a type', () => { + const tags = [ + ['p', 'a'.repeat(64)], + ['e', 'b'.repeat(64)], + ] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: 'a'.repeat(64), reportedEventId: 'b'.repeat(64), reportType: ReportType.OTHER }, + ]) + }) + + it('falls back to OTHER for an unrecognized report type', () => { + const tags = [['p', 'a'.repeat(64), 'not-a-real-type']] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: 'a'.repeat(64), reportedEventId: null, reportType: ReportType.OTHER }, + ]) + }) + + it('ignores tags shorter than 2 elements', () => { + const tags = [['p'], ['e']] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: null, reportedEventId: null, reportType: ReportType.OTHER }, + ]) + }) + + it('rejects a p tag value that is not a 64-character hex string', () => { + const tags = [['p', 'not-hex', 'spam']] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: null, reportedEventId: null, reportType: ReportType.OTHER }, + ]) + }) + + it('rejects an e tag value that is too short to be a valid event id', () => { + const tags = [['e', 'a'.repeat(63), 'spam']] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: null, reportedEventId: null, reportType: ReportType.OTHER }, + ]) + }) + + it('accepts an uppercase-hex tag value', () => { + const tags = [['p', 'A'.repeat(64), 'spam']] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: 'A'.repeat(64), reportedEventId: null, reportType: ReportType.SPAM }, + ]) + }) + + it('falls back to a valid p tag when the e tag value is malformed', () => { + const tags = [ + ['p', 'a'.repeat(64), 'spam'], + ['e', 'not-hex', 'spam'], + ] as Tag[] + expect(extractReportTargets(tags)).to.deep.equal([ + { reportedPubkey: 'a'.repeat(64), reportedEventId: null, reportType: ReportType.SPAM }, + ]) + }) + }) +}) diff --git a/test/unit/utils/report-scoring.spec.ts b/test/unit/utils/report-scoring.spec.ts new file mode 100644 index 00000000..48d210bb --- /dev/null +++ b/test/unit/utils/report-scoring.spec.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai' +import { calculateReportWeight } from '../../../src/utils/report-scoring' + +describe('calculateReportWeight', () => { + it('returns maximum weight for a trusted moderator regardless of distance', () => { + expect(calculateReportWeight(undefined, true)).to.equal(1) + expect(calculateReportWeight(5, true)).to.equal(1) + }) + + it('returns 0 for a non-moderator with no WoT distance (outside the trust graph)', () => { + expect(calculateReportWeight(undefined, false)).to.equal(0) + }) + + it('returns full weight for a non-moderator direct follow (distance 1)', () => { + expect(calculateReportWeight(1, false)).to.equal(1) + }) + + it('returns half weight for a non-moderator at distance 2', () => { + expect(calculateReportWeight(2, false)).to.equal(0.5) + }) + + it('halves again for each additional hop (exponential decay, not linear)', () => { + expect(calculateReportWeight(3, false)).to.equal(0.25) + expect(calculateReportWeight(4, false)).to.equal(0.125) + }) + + it('treats distance 0 (the seed pubkey itself) as full weight', () => { + expect(calculateReportWeight(0, false)).to.equal(1) + }) +}) diff --git a/test/unit/utils/settings.spec.ts b/test/unit/utils/settings.spec.ts index acb8b954..7cbab5f0 100644 --- a/test/unit/utils/settings.spec.ts +++ b/test/unit/utils/settings.spec.ts @@ -322,4 +322,20 @@ describe('SettingsStatic', () => { expect(merged.wot?.refreshIntervalHours).to.equal(24) }) }) + + describe('NIP-56 settings defaults', () => { + it('default-settings.yaml contains a nip56 block with enabled: false', () => { + const defaults = SettingsStatic.loadAndParseYamlFile(SettingsStatic.getDefaultSettingsFilePath()) + expect(defaults).to.have.nested.property('nip56.enabled', false) + expect(defaults).to.have.deep.nested.property('nip56.trustedModerators', []) + }) + + it('user config nip56 block overrides defaults', () => { + const defaults = SettingsStatic.loadAndParseYamlFile(SettingsStatic.getDefaultSettingsFilePath()) + const userConfig = { nip56: { enabled: true, trustedModerators: ['a'.repeat(64)] } } + const merged = mergeDeepRight(defaults, userConfig) as Settings + expect(merged.nip56?.enabled).to.equal(true) + expect(merged.nip56?.trustedModerators).to.deep.equal(['a'.repeat(64)]) + }) + }) })