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/nip56-hide-deprioritize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"nostream": minor
---

feat: execute hide action for actionable NIP-56 reports

Adds `nip56.hideActionableReports` (default `false`): when true, events matching an `actionable`
report (a trusted-moderator report against a valid target) are excluded from REQ/COUNT results. A
pubkey-targeted report hides every event from that pubkey; an event-targeted report hides just that
event. Requires `nip56.enabled` to have any effect. Previously an actionable report was only ever
recorded, never acted on.
3 changes: 2 additions & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,9 @@ 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. If `wot.enabled` is false, every non-moderator report is still stored, but its WoT distance is always undefined, so it scores weight 0 and never becomes actionable — this matches the "record, never act" design, but is easy to misread as a misconfiguration. `reports` rows have no automatic retention/pruning: they can outlive the kind-1984 event that produced them (which is itself subject to normal event retention/deletion) and accumulate indefinitely; operators who want bounded growth need to prune the table themselves. |
| 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. If `wot.enabled` is false, every non-moderator report is still stored, but its WoT distance is always undefined, so it scores weight 0 and never becomes actionable — easy to misread as a misconfiguration. `reports` rows have no automatic retention/pruning: they can outlive the kind-1984 event that produced them (which is itself subject to normal event retention/deletion) and accumulate indefinitely; operators who want bounded growth need to prune the table themselves. |
| 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 []. |
| nip56.hideActionableReports | When true, events matching an actionable report are excluded from REQ/COUNT results: a pubkey-targeted report hides every event from that pubkey, an event-targeted report hides just that event. Has no effect unless `nip56.enabled` is also true. Defaults to false, so enabling reporting alone never changes what existing subscribers see. Requires the `reports_actionable_reported_event_id_idx`/`reports_actionable_reported_pubkey_idx` partial indexes (added by migration) to stay efficient at scale. |
| 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
39 changes: 39 additions & 0 deletions migrations/20260926_120000_add_actionable_reports_indexes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Supports EventRepository.applyActionableReportExclusion's NOT EXISTS
* subquery (used by findByFilters/countByFilters when
* nip56.hideActionableReports is enabled):
*
* WHERE NOT EXISTS (
* SELECT 1 FROM reports
* WHERE actionable = true
* AND (reported_event_id = events.event_id OR reported_pubkey = events.event_pubkey)
* )
*
* Partial on actionable = true since non-actionable reports (the vast
* majority -- most reporters are not trusted moderators) never participate
* in this check, matching the existing events_deleted_at_partial_idx
* precedent for keeping partial indexes small.
*
* CREATE INDEX CONCURRENTLY cannot run inside a transaction.
*/

exports.config = { transaction: false }

exports.up = async function (knex) {
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS reports_actionable_reported_event_id_idx
ON reports (reported_event_id)
WHERE actionable = true AND reported_event_id IS NOT NULL
`)

await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS reports_actionable_reported_pubkey_idx
ON reports (reported_pubkey)
WHERE actionable = true AND reported_pubkey IS NOT NULL
`)
}

exports.down = async function (knex) {
await knex.raw('DROP INDEX CONCURRENTLY IF EXISTS reports_actionable_reported_event_id_idx')
await knex.raw('DROP INDEX CONCURRENTLY IF EXISTS reports_actionable_reported_pubkey_idx')
}
3 changes: 3 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ nip56:
# regardless of WoT distance. Reports from any other pubkey are stored
# and weighted, but never trigger automatic actions on their own.
trustedModerators: []
# When true, events matching an actionable report are excluded from
# REQ/COUNT results. Has no effect unless enabled is also true.
hideActionableReports: false
network:
maxPayloadSize: 524288
# Uncomment only when using a trusted reverse proxy and configuring trustedProxies.
Expand Down
2 changes: 2 additions & 0 deletions src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export interface IReportRepository {
createMany(reports: Omit<Report, 'id' | 'createdAt'>[]): Promise<Report[]>
findByEventId(eventId: EventId): Promise<Report[]>
findActionable(limit?: number): Promise<Report[]>
/** Every distinct actionable report target, unpaginated -- feeds the hidden-content cache's boot-time warm-up. */
findActionableTargets(): Promise<{ reportedPubkey: Pubkey | null; reportedEventId: EventId | null }[]>
}

export interface INotificationDeliveryLogRepository {
Expand Down
9 changes: 9 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,15 @@ export interface Nip56Settings {
* never actionable on their own -- only stored for manual review.
*/
trustedModerators: Pubkey[]
/**
* When true, events matching an `actionable` report (a trusted-moderator
* report against a valid target) are excluded from REQ/COUNT results.
* A pubkey-targeted report hides every event from that pubkey; an
* event-targeted report hides just that event. Requires `enabled` to have
* any effect. Defaults to false so enabling reporting alone never changes
* what existing subscribers see.
*/
hideActionableReports: boolean
}

export interface Settings {
Expand Down
9 changes: 9 additions & 0 deletions src/adapters/web-socket-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { createReadAuthorizationGuard } from '../utils/nip42'
import { Nip42SessionManager } from '../utils/nip42-session'
import { IRateLimiter } from '../@types/utils'
import { isEventMatchingFilter } from '../utils/event'
import { isHidden } from '../utils/hidden-content-cache'
import { messageSchema } from '../schemas/message-schema'
import { Settings } from '../@types/settings'
import { SocketAddress } from 'net'
Expand Down Expand Up @@ -130,6 +131,14 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
return
}

// NIP-56: keep live subscriptions consistent with what a fresh REQ would
// now exclude -- EventRepository's hiding only applies to DB queries, so
// this in-memory check covers the broadcast path the same way.
const nip56 = this.settings().nip56
if (nip56?.enabled && nip56?.hideActionableReports && isHidden(event)) {
return
}

this.subscriptions.forEach((filters, subscriptionId) => {
if (filters.map(isEventMatchingFilter).some((isMatch) => isMatch(event))) {
logger('sending event to client %s: %o', this.clientId, event)
Expand Down
12 changes: 12 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 { 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 { warmHiddenContentCache } from '../utils/hidden-content-cache'
import { createLogger } from './logger-factory'
import { getCache } from './message-handler-factory'
import { createWebApp } from './web-app-factory'
Expand Down Expand Up @@ -39,6 +40,17 @@ export const workerFactory = (): AppWorker => {

const settings = createSettings()

// NIP-56: warms the in-memory hidden-content cache from every actionable
// report already in the DB, so the live-broadcast path (WebSocketAdapter)
// is consistent with query-time hiding from the moment the worker starts
// accepting connections, not just from the first report recorded after
// boot. Fire-and-forget: a failure here must not block worker startup.
if (settings.nip56?.enabled && settings.nip56?.hideActionableReports) {
warmHiddenContentCache(reportRepository).catch((error) =>
logger.error('failed to warm hidden content cache: %o', error),
)
}

const app = createWebApp()

// deepcode ignore HttpToHttps: we use proxies
Expand Down
9 changes: 9 additions & 0 deletions src/handlers/event-strategies/report-event-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Settings } from '../../@types/settings'
import { WebSocketAdapterEvent } from '../../constants/adapter'
import { createLogger } from '../../factories/logger-factory'
import { createEventCommandResult } from '../../telemetry/event-metrics'
import { markActionableTarget } from '../../utils/hidden-content-cache'
import { extractReportTargets } from '../../utils/nip56'
import { calculateReportWeight } from '../../utils/report-scoring'

Expand Down Expand Up @@ -77,6 +78,14 @@ export class ReportEventStrategy implements IEventStrategy<Event, Promise<void>>
// producing more than one row (see extractReportTargets) shouldn't be
// able to leave a partial set behind on a mid-batch failure.
await this.reportRepository.createMany(reports)

// Keeps the live-broadcast path (WebSocketAdapter.onSendEvent, which
// matches events against open subscriptions in-process) in sync with
// what a fresh REQ would now exclude -- without this, a freshly
// actionable pubkey/event could still reach existing subscribers.
if (isTrustedModerator && nip56.hideActionableReports) {
reports.forEach(markActionableTarget)
}
}
} catch (error) {
// Report scoring/recording is best-effort: the report event itself is
Expand Down
26 changes: 26 additions & 0 deletions src/repositories/event-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export class EventRepository implements IEventRepository {

private applyFilterConditions(builder: any, currentFilter: SubscriptionFilter): FilterConditionFlags {
this.applyHexFilterConditions(builder, currentFilter)
this.applyActionableReportExclusion(builder)
Comment thread
Priyanshubhartistm marked this conversation as resolved.

if (Array.isArray(currentFilter.kinds)) {
builder.whereIn('event_kind', currentFilter.kinds)
Expand Down Expand Up @@ -180,6 +181,31 @@ export class EventRepository implements IEventRepository {
return { isTagQuery, isSearchQuery }
}

/**
* NIP-56: excludes events matching an actionable report -- a pubkey-targeted
* report hides every event from that pubkey, an event-targeted report hides
* just that event. No-op unless both nip56.enabled and
* nip56.hideActionableReports are set, so relays not using this feature pay
* no extra query cost.
*/
private applyActionableReportExclusion(builder: any): void {
const nip56Settings = this.settings?.()?.nip56
if (!nip56Settings?.enabled || !nip56Settings?.hideActionableReports) {
return
}

builder.whereNotExists(function () {
this.select('id')
.from('reports')
.where('reports.actionable', true)
.andWhere((bd: any) => {
bd.whereRaw('reports.reported_event_id = events.event_id').orWhereRaw(
'reports.reported_pubkey = events.event_pubkey',
)
})
})
}

/** Resolve the PostgreSQL text-search configuration name from settings. */
private getNip50Language(): string {
return this.settings?.()?.nip50?.language ?? DEFAULT_TS_CONFIG
Expand Down
16 changes: 16 additions & 0 deletions src/repositories/report-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,20 @@ export class ReportRepository implements IReportRepository {

return rows.map(fromDBReport)
}

public async findActionableTargets(
client: DatabaseClient = this.dbClient,
): Promise<{ reportedPubkey: string | null; reportedEventId: string | null }[]> {
logger('find all actionable report targets')

const rows = await client<DBReport>('reports')
.where('actionable', true)
.distinct('reported_pubkey', 'reported_event_id')
.select()

return rows.map((row) => ({
reportedPubkey: row.reported_pubkey ? fromBuffer(row.reported_pubkey) : null,
reportedEventId: row.reported_event_id ? fromBuffer(row.reported_event_id) : null,
}))
}
}
35 changes: 35 additions & 0 deletions src/utils/hidden-content-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { EventId, Pubkey } from '../@types/base'
import { Event } from '../@types/event'
import { IReportRepository } from '../@types/repositories'

/**
* In-memory mirror of actionable-report targets, so EventRepository's
* NOT-EXISTS-based hiding (which only applies to fresh REQ/COUNT queries) can
* also be checked synchronously on the live-broadcast path, where events are
* matched against open subscriptions in-process without a DB round trip.
*/
const hiddenPubkeys = new Set<string>()
const hiddenEventIds = new Set<string>()

export const markActionableTarget = (target: { reportedPubkey: Pubkey | null; reportedEventId: EventId | null }): void => {
if (target.reportedPubkey) {
hiddenPubkeys.add(target.reportedPubkey)
}
if (target.reportedEventId) {
hiddenEventIds.add(target.reportedEventId)
}
}

export const isHidden = (event: Pick<Event, 'id' | 'pubkey'>): boolean =>
hiddenPubkeys.has(event.pubkey) || hiddenEventIds.has(event.id)

/** Populates the cache from every actionable report already in the DB, at worker boot. */
export const warmHiddenContentCache = async (reportRepository: IReportRepository): Promise<void> => {
const targets = await reportRepository.findActionableTargets()
targets.forEach(markActionableTarget)
}

export const resetHiddenContentCache = (): void => {
hiddenPubkeys.clear()
hiddenEventIds.clear()
}
5 changes: 5 additions & 0 deletions src/utils/settings-guided-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,11 @@ export const guidedSettingCategories: GuidedSettingCategory[] = [
type: 'stringArray',
placeholder: 'One pubkey per line',
},
{
label: 'Hide content matching actionable NIP-56 reports',
path: 'nip56.hideActionableReports',
type: 'boolean',
},
],
},
{
Expand Down
72 changes: 72 additions & 0 deletions test/unit/adapters/web-socket-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { expect } = chai
import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../../../src/constants/adapter'
import { IWebSocketServerAdapter } from '../../../src/@types/adapters'
import { WebSocketAdapter } from '../../../src/adapters/web-socket-adapter'
import { markActionableTarget, resetHiddenContentCache } from '../../../src/utils/hidden-content-cache'

describe('WebSocketAdapter', () => {
let sandbox: Sinon.SinonSandbox
Expand Down Expand Up @@ -381,6 +382,77 @@ describe('WebSocketAdapter', () => {

expect(client.send).to.have.been.calledOnce
})

describe('NIP-56: hidden content', () => {
afterEach(() => {
resetHiddenContentCache()
})

it('does not broadcast an event matching an actionable report when hideActionableReports is enabled', () => {
const reportedPubkey = 'a'.repeat(64)
markActionableTarget({ reportedPubkey, reportedEventId: null })
settingsFactory.returns({ nip56: { enabled: true, hideActionableReports: true } })
client.readyState = WebSocket.OPEN
adapter.onSubscribed('sub-1', [{ kinds: [1] }])

const event = {
id: 'a'.repeat(64),
pubkey: reportedPubkey,
kind: 1,
content: 'spam',
created_at: 1000000,
sig: 'c'.repeat(128),
tags: [],
}

adapter.emit(WebSocketAdapterEvent.Event, event)

expect(client.send).not.to.have.been.called
})

it('still broadcasts a hidden-target event when hideActionableReports is disabled', () => {
const reportedPubkey = 'a'.repeat(64)
markActionableTarget({ reportedPubkey, reportedEventId: null })
settingsFactory.returns({ nip56: { enabled: true, hideActionableReports: false } })
client.readyState = WebSocket.OPEN
adapter.onSubscribed('sub-1', [{ kinds: [1] }])

const event = {
id: 'a'.repeat(64),
pubkey: reportedPubkey,
kind: 1,
content: 'spam',
created_at: 1000000,
sig: 'c'.repeat(128),
tags: [],
}

adapter.emit(WebSocketAdapterEvent.Event, event)

expect(client.send).to.have.been.calledOnce
})

it('still broadcasts an unrelated event when hideActionableReports is enabled', () => {
markActionableTarget({ reportedPubkey: 'a'.repeat(64), reportedEventId: null })
settingsFactory.returns({ nip56: { enabled: true, hideActionableReports: true } })
client.readyState = WebSocket.OPEN
adapter.onSubscribed('sub-1', [{ kinds: [1] }])

const event = {
id: 'b'.repeat(64),
pubkey: 'c'.repeat(64),
kind: 1,
content: 'hello',
created_at: 1000000,
sig: 'c'.repeat(128),
tags: [],
}

adapter.emit(WebSocketAdapterEvent.Event, event)

expect(client.send).to.have.been.calledOnce
})
})
})

describe('onClientClose', () => {
Expand Down
Loading
Loading