Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/content-reporting.md
Original file line number Diff line number Diff line change
@@ -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`).
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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('<your_language>', 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. |
Expand Down
25 changes: 25 additions & 0 deletions migrations/20260910_120000_create_reports_table.js
Original file line number Diff line number Diff line change
@@ -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')
}
8 changes: 8 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions src/@types/report.ts
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -83,3 +84,9 @@ export interface IDvmJobRepository {
): Promise<DvmJob | undefined>
findPendingJobs(limit?: number, kinds?: number[]): Promise<DvmJob[]>
}

export interface IReportRepository {
create(report: Omit<Report, 'createdAt'>): Promise<Report>
findById(id: EventId): Promise<Report | undefined>
findActionable(limit?: number): Promise<Report[]>
}
13 changes: 13 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -422,6 +434,7 @@ export interface Settings {
nip43?: Nip43Settings
nip45?: Nip45Settings
nip50?: Nip50Settings
nip56?: Nip56Settings
nip66?: Nip66Settings
wot?: WoTSettings
}
2 changes: 2 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -40,6 +48,7 @@ export const eventStrategyFactory =
userRepository: IUserRepository,
inviteCodeRepository: IInviteCodeRepository,
dvmJobRepository: IDvmJobRepository,
reportRepository: IReportRepository,
cache: ICacheAdapter,
settings: () => Settings,
): Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]> =>
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/factories/message-handler-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
IEventRepository,
IInviteCodeRepository,
INip05VerificationRepository,
IReportRepository,
IUserRepository,
} from '../@types/repositories'
import { IncomingMessage, MessageType } from '../@types/messages'
Expand Down Expand Up @@ -33,6 +34,7 @@ export const messageHandlerFactory =
nip05VerificationRepository: INip05VerificationRepository,
inviteCodeRepository: IInviteCodeRepository,
dvmJobRepository: IDvmJobRepository,
reportRepository: IReportRepository,
) =>
([message, adapter]: [IncomingMessage, IWebSocketAdapter]) => {
switch (message[0]) {
Expand All @@ -44,6 +46,7 @@ export const messageHandlerFactory =
userRepository,
inviteCodeRepository,
dvmJobRepository,
reportRepository,
getCache(),
createSettings,
),
Expand Down
3 changes: 3 additions & 0 deletions src/factories/websocket-adapter-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IEventRepository,
IInviteCodeRepository,
INip05VerificationRepository,
IReportRepository,
IUserRepository,
} from '../@types/repositories'
import { createSettings } from './settings-factory'
Expand All @@ -21,6 +22,7 @@ export const webSocketAdapterFactory =
nip05VerificationRepository: INip05VerificationRepository,
inviteCodeRepository: IInviteCodeRepository,
dvmJobRepository: IDvmJobRepository,
reportRepository: IReportRepository,
) =>
([client, request, webSocketServerAdapter]: [WebSocket, IncomingMessage, IWebSocketServerAdapter]) =>
new WebSocketAdapter(
Expand All @@ -33,6 +35,7 @@ export const webSocketAdapterFactory =
nip05VerificationRepository,
inviteCodeRepository,
dvmJobRepository,
reportRepository,
),
rateLimiterFactory,
createSettings,
Expand Down
3 changes: 3 additions & 0 deletions src/factories/worker-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()

Expand Down Expand Up @@ -73,6 +75,7 @@ export const workerFactory = (): AppWorker => {
nip05VerificationRepository,
inviteCodeRepository,
dvmJobRepository,
reportRepository,
),
createSettings,
)
Expand Down
67 changes: 67 additions & 0 deletions src/handlers/event-strategies/report-event-strategy.ts
Original file line number Diff line number Diff line change
@@ -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<Event, Promise<void>> {
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<void> {
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)
}
}
}
Loading
Loading