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 af32cbd0..d5e33ea4 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -207,6 +207,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..e3c96b97 --- /dev/null +++ b/migrations/20260910_120000_create_reports_table.js @@ -0,0 +1,25 @@ +exports.up = function (knex) { + return knex.schema.createTable('reports', (table) => { + table.binary('id').primary() + 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(['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 b08e623c..9c9f6aea 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..0ea6ecf8 --- /dev/null +++ b/src/@types/report.ts @@ -0,0 +1,34 @@ +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: EventId + reporterPubkey: Pubkey + reportedPubkey: Pubkey | null + reportedEventId: EventId | null + reportType: ReportType + weight: number + actionable: boolean + createdAt: Date +} + +export interface DBReport { + 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..9417d49c 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 + findById(id: EventId): Promise + findActionable(limit?: number): Promise +} diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 9bcbd154..8d4275fe 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -407,6 +407,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 @@ -422,6 +434,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..73c95e4a --- /dev/null +++ b/src/handlers/event-strategies/report-event-strategy.ts @@ -0,0 +1,67 @@ +import { createEventCommandResult } from '../../telemetry/event-metrics' +import { createLogger } from '../../factories/logger-factory' +import { calculateReportWeight } from '../../utils/report-scoring' +import { Event } from '../../@types/event' +import { extractReportTarget } 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 { reportedPubkey, reportedEventId, reportType } = extractReportTarget(event.tags) + const trustedModerators = nip56.trustedModerators ?? [] + const isTrustedModerator = trustedModerators.includes(event.pubkey) + const distance = await this.wotGraphService.getDistance(event.pubkey) + const weight = calculateReportWeight(distance, isTrustedModerator) + + await this.reportRepository.create({ + id: event.id, + reporterPubkey: event.pubkey, + reportedPubkey, + reportedEventId, + reportType, + weight, + actionable: isTrustedModerator, + }) + } 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..2fe101d3 --- /dev/null +++ b/src/repositories/report-repository.ts @@ -0,0 +1,74 @@ +import { DatabaseClient } 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: fromBuffer(row.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 %s for %s (weight %d, actionable %s)', + report.id, + report.reporterPubkey, + report.weight, + report.actionable, + ) + + const now = new Date() + const row: DBReport = { + id: toBuffer(report.id), + 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, + } + + await client('reports').insert(row) + + return fromDBReport(row) + } + + public async findById(id: string, client: DatabaseClient = this.dbClient): Promise { + logger('find report %s', id) + + const [row] = await client('reports').where('id', toBuffer(id)).select() + + if (!row) { + return + } + + return fromDBReport(row) + } + + 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..e5e37d82 --- /dev/null +++ b/src/utils/nip56.ts @@ -0,0 +1,33 @@ +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) + +export interface ReportTarget { + reportedPubkey: Pubkey | null + reportedEventId: EventId | null + reportType: ReportType +} + +// NIP-56: a report event carries the report type as the 3rd element of the +// tag identifying what's being reported -- an "e" tag (specific event) takes +// precedence over a "p" tag (pubkey) when both are present and disagree, +// since reporting a specific event is the more precise target. +export const extractReportTarget = (tags: Tag[]): ReportTarget => { + const pTag = tags.find((tag) => tag[0] === EventTags.Pubkey && tag.length >= 2) + const eTag = tags.find((tag) => tag[0] === EventTags.Event && tag.length >= 2) + const rawType = eTag?.[2] ?? pTag?.[2] + + return { + reportedPubkey: pTag?.[1] ?? null, + reportedEventId: eTag?.[1] ?? null, + reportType: isValidReportType(rawType) ? rawType : ReportType.OTHER, + } +} diff --git a/src/utils/report-scoring.ts b/src/utils/report-scoring.ts new file mode 100644 index 00000000..f4133e4a --- /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 purely by distance: +// a direct follow (distance 1) gets full weight, weight halves each +// additional hop, 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 <= 0 ? 1 : 1 / distance +} 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..0c4d8aca --- /dev/null +++ b/test/unit/handlers/event-strategies/report-event-strategy.spec.ts @@ -0,0 +1,212 @@ +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 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({ + id: '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({ + id: '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({}) + getDistanceStub.resolves(undefined) + + await strategy.execute(event) + + expect(reportRepositoryCreateStub).to.have.been.calledOnceWithExactly({ + id: 'event-id', + reporterPubkey, + reportedPubkey, + reportedEventId: null, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }) + }) + + 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..e36f883f --- /dev/null +++ b/test/unit/repositories/report-repository.spec.ts @@ -0,0 +1,198 @@ +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 reportId = 'a'.repeat(64) + const reporterPubkey = '2'.repeat(64) + const reportedPubkey = '3'.repeat(64) + const reportedEventId = '4'.repeat(64) + + const dbReportRow = { + id: Buffer.from(reportId, '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 insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + await repository.create( + { + id: reportId, + reporterPubkey, + reportedPubkey, + reportedEventId, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }, + client, + ) + + expect(client).to.have.been.calledWith('reports') + }) + + it('returns a Report reflecting the input', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + const result = await repository.create( + { + id: reportId, + reporterPubkey, + reportedPubkey, + reportedEventId, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }, + client, + ) + + expect(result).to.deep.include({ + id: reportId, + 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 insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + await repository.create( + { + id: reportId, + reporterPubkey, + reportedPubkey, + reportedEventId, + reportType: ReportType.SPAM, + weight: 1, + actionable: true, + }, + client, + ) + + const insertedRow = insertStub.firstCall.args[0] + expect(insertedRow.id).to.deep.equal(Buffer.from(reportId, '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')) + }) + + it('stores null reported_pubkey/reported_event_id when not provided', async () => { + const insertStub = sandbox.stub().resolves() + const client = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + await repository.create( + { + id: reportId, + 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('.findById', () => { + it('returns undefined when no report is found', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([]) }), + }) as unknown as DatabaseClient + + const result = await repository.findById(reportId, client) + + expect(result).to.be.undefined + }) + + it('returns a transformed Report 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.findById(reportId, client) + + expect(result).to.not.be.undefined + expect(result!.id).to.equal(reportId) + expect(result!.reporterPubkey).to.equal(reporterPubkey) + expect(result!.actionable).to.equal(true) + }) + }) + + 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(reportId) + }) + + 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..fe99174d --- /dev/null +++ b/test/unit/utils/nip56.spec.ts @@ -0,0 +1,93 @@ +import { expect } from 'chai' +import { Event } from '../../../src/@types/event' +import { extractReportTarget, 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('extractReportTarget', () => { + it('returns nulls and OTHER type when no e/p tags are present', () => { + expect(extractReportTarget([])).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(extractReportTarget(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(extractReportTarget(tags)).to.deep.equal({ + reportedPubkey: null, + reportedEventId: 'b'.repeat(64), + reportType: ReportType.SPAM, + }) + }) + + it('extracts both when both e and p tags are present, preferring the e tag type', () => { + const tags = [ + ['p', 'a'.repeat(64), 'impersonation'], + ['e', 'b'.repeat(64), 'nudity'], + ] as Tag[] + expect(extractReportTarget(tags)).to.deep.equal({ + reportedPubkey: 'a'.repeat(64), + reportedEventId: 'b'.repeat(64), + reportType: ReportType.NUDITY, + }) + }) + + it('falls back to the p tag type when the e tag has none', () => { + const tags = [ + ['p', 'a'.repeat(64), 'malware'], + ['e', 'b'.repeat(64)], + ] as Tag[] + expect(extractReportTarget(tags)).to.deep.equal({ + reportedPubkey: 'a'.repeat(64), + reportedEventId: 'b'.repeat(64), + reportType: ReportType.MALWARE, + }) + }) + + it('falls back to OTHER for an unrecognized report type', () => { + const tags = [['p', 'a'.repeat(64), 'not-a-real-type']] as Tag[] + expect(extractReportTarget(tags).reportType).to.equal(ReportType.OTHER) + }) + + it('ignores tags shorter than 2 elements', () => { + const tags = [['p'], ['e']] as Tag[] + expect(extractReportTarget(tags)).to.deep.equal({ + reportedPubkey: null, + reportedEventId: null, + reportType: ReportType.OTHER, + }) + }) + }) +}) diff --git a/test/unit/utils/report-scoring.spec.ts b/test/unit/utils/report-scoring.spec.ts new file mode 100644 index 00000000..caed30a1 --- /dev/null +++ b/test/unit/utils/report-scoring.spec.ts @@ -0,0 +1,29 @@ +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', () => { + expect(calculateReportWeight(4, false)).to.equal(0.25) + }) + + 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)]) + }) + }) })