Skip to content

Commit ce368e8

Browse files
feat(nip56): hide events matching an actionable report (#788)
* feat(nip56): hide events matching an actionable report Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * fix(nip56): hide live-broadcast content matching actionable reports Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * fix(nip56): make hidden-content cache cross-worker and boot-ready Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * fix(nip56): poll Postgres directly instead of a Redis mirror Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * fix(nip56): skip DB polling while hideActionableReports is off Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> --------- Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
1 parent 98997f8 commit ce368e8

19 files changed

Lines changed: 706 additions & 3 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat: execute hide action for actionable NIP-56 reports
6+
7+
Adds `nip56.hideActionableReports` (default `false`): when true, events matching an `actionable`
8+
report (a trusted-moderator report against a valid target) are excluded from REQ/COUNT results. A
9+
pubkey-targeted report hides every event from that pubkey; an event-targeted report hides just that
10+
event. Requires `nip56.enabled` to have any effect. Previously an actionable report was only ever
11+
recorded, never acted on.

‎CONFIGURATION.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta
215215
| 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. |
216216
| 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. |
217217
| nip50.maxQueryLength | Maximum length of the search query string. Queries exceeding this are truncated. Defaults to 256. |
218-
| 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. |
218+
| 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. |
219219
| 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 []. |
220+
| 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. |
220221
| nip66.dnsCacheTtlSeconds | DNS cache TTL in seconds for repeated probe lookups of the same hostname. Defaults to 300. |
221222
| 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. |
222223
| nip66.probeIntervalSeconds | Seconds between scheduled relay probe runs. Defaults to 3600. |
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Supports EventRepository.applyActionableReportExclusion's NOT EXISTS
3+
* subquery (used by findByFilters/countByFilters when
4+
* nip56.hideActionableReports is enabled):
5+
*
6+
* WHERE NOT EXISTS (
7+
* SELECT 1 FROM reports
8+
* WHERE actionable = true
9+
* AND (reported_event_id = events.event_id OR reported_pubkey = events.event_pubkey)
10+
* )
11+
*
12+
* Partial on actionable = true since non-actionable reports (the vast
13+
* majority -- most reporters are not trusted moderators) never participate
14+
* in this check, matching the existing events_deleted_at_partial_idx
15+
* precedent for keeping partial indexes small.
16+
*
17+
* CREATE INDEX CONCURRENTLY cannot run inside a transaction.
18+
*/
19+
20+
exports.config = { transaction: false }
21+
22+
exports.up = async function (knex) {
23+
await knex.raw(`
24+
CREATE INDEX CONCURRENTLY IF NOT EXISTS reports_actionable_reported_event_id_idx
25+
ON reports (reported_event_id)
26+
WHERE actionable = true AND reported_event_id IS NOT NULL
27+
`)
28+
29+
await knex.raw(`
30+
CREATE INDEX CONCURRENTLY IF NOT EXISTS reports_actionable_reported_pubkey_idx
31+
ON reports (reported_pubkey)
32+
WHERE actionable = true AND reported_pubkey IS NOT NULL
33+
`)
34+
}
35+
36+
exports.down = async function (knex) {
37+
await knex.raw('DROP INDEX CONCURRENTLY IF EXISTS reports_actionable_reported_event_id_idx')
38+
await knex.raw('DROP INDEX CONCURRENTLY IF EXISTS reports_actionable_reported_pubkey_idx')
39+
}

‎resources/default-settings.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ nip56:
145145
# regardless of WoT distance. Reports from any other pubkey are stored
146146
# and weighted, but never trigger automatic actions on their own.
147147
trustedModerators: []
148+
# When true, events matching an actionable report are excluded from
149+
# REQ/COUNT results. Has no effect unless enabled is also true.
150+
hideActionableReports: false
148151
network:
149152
maxPayloadSize: 524288
150153
# Uncomment only when using a trusted reverse proxy and configuring trustedProxies.

‎src/@types/repositories.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export interface IReportRepository {
9797
createMany(reports: Omit<Report, 'id' | 'createdAt'>[]): Promise<Report[]>
9898
findByEventId(eventId: EventId): Promise<Report[]>
9999
findActionable(limit?: number): Promise<Report[]>
100+
/** Every distinct actionable report target, unpaginated -- feeds the hidden-content cache's boot-time warm-up. */
101+
findActionableTargets(): Promise<{ reportedPubkey: Pubkey | null; reportedEventId: EventId | null }[]>
100102
}
101103

102104
export interface INotificationDeliveryLogRepository {

‎src/@types/settings.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,15 @@ export interface Nip56Settings {
453453
* never actionable on their own -- only stored for manual review.
454454
*/
455455
trustedModerators: Pubkey[]
456+
/**
457+
* When true, events matching an `actionable` report (a trusted-moderator
458+
* report against a valid target) are excluded from REQ/COUNT results.
459+
* A pubkey-targeted report hides every event from that pubkey; an
460+
* event-targeted report hides just that event. Requires `enabled` to have
461+
* any effect. Defaults to false so enabling reporting alone never changes
462+
* what existing subscribers see.
463+
*/
464+
hideActionableReports: boolean
456465
}
457466

458467
export interface Settings {

‎src/adapters/web-socket-adapter.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { createReadAuthorizationGuard } from '../utils/nip42'
2626
import { Nip42SessionManager } from '../utils/nip42-session'
2727
import { IRateLimiter } from '../@types/utils'
2828
import { isEventMatchingFilter } from '../utils/event'
29+
import { isHidden } from '../utils/hidden-content-cache'
2930
import { messageSchema } from '../schemas/message-schema'
3031
import { Settings } from '../@types/settings'
3132
import { SocketAddress } from 'net'
@@ -130,6 +131,14 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
130131
return
131132
}
132133

134+
// NIP-56: keep live subscriptions consistent with what a fresh REQ would
135+
// now exclude -- EventRepository's hiding only applies to DB queries, so
136+
// this in-memory check covers the broadcast path the same way.
137+
const nip56 = this.settings().nip56
138+
if (nip56?.enabled && nip56?.hideActionableReports && isHidden(event)) {
139+
return
140+
}
141+
133142
this.subscriptions.forEach((filters, subscriptionId) => {
134143
if (filters.map(isEventMatchingFilter).some((isMatch) => isMatch(event))) {
135144
logger('sending event to client %s: %o', this.clientId, event)

‎src/factories/worker-factory.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { InviteCodeRepository } from '../repositories/invite-code-repository'
1212
import { Nip05VerificationRepository } from '../repositories/nip05-verification-repository'
1313
import { ReportRepository } from '../repositories/report-repository'
1414
import { UserRepository } from '../repositories/user-repository'
15+
import { startHiddenContentCache } from '../utils/hidden-content-cache'
1516
import { createLogger } from './logger-factory'
1617
import { getCache } from './message-handler-factory'
1718
import { createWebApp } from './web-app-factory'
@@ -30,15 +31,31 @@ export const workerFactory = (): AppWorker => {
3031
const dvmJobRepository = new DvmJobRepository(dbClient)
3132
const reportRepository = new ReportRepository(dbClient)
3233

34+
const settings = createSettings()
35+
36+
// NIP-56: starts the hidden-content cache (polls Postgres directly, every
37+
// worker independently -- see hidden-content-cache.ts) as early in boot as
38+
// possible, before any other setup, so it has the largest possible head
39+
// start on server.listen() below. Fire-and-forget, same reasoning as
40+
// WotGraphService's warm-up: making worker boot itself wait on this would
41+
// require every IRunnable worker type's bootstrap to become async, which
42+
// is out of scope here. The narrow startup race this leaves (a live
43+
// subscriber could see unfiltered content in the brief window before this
44+
// resolves) self-heals on the cache's own periodic refresh, not a
45+
// dedicated retry path.
46+
if (settings.nip56?.enabled) {
47+
startHiddenContentCache(reportRepository, createSettings).catch((error) =>
48+
logger.error('failed to start hidden content cache: %o', error),
49+
)
50+
}
51+
3352
// Constructs the WoT graph singleton (and starts warming it up, if enabled)
3453
// right at worker boot -- before this call, the singleton was only ever
3554
// created lazily inside per-event handler wiring, so the very first
3655
// EVENT/report needing a WoT distance was also the thing paying for the
3756
// cold-start rebuild the warm-up was meant to avoid.
3857
wotGraphServiceFactory(getCache(), eventRepository, createSettings)
3958

40-
const settings = createSettings()
41-
4259
const app = createWebApp()
4360

4461
// deepcode ignore HttpToHttps: we use proxies

‎src/handlers/event-strategies/report-event-strategy.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Settings } from '../../@types/settings'
88
import { WebSocketAdapterEvent } from '../../constants/adapter'
99
import { createLogger } from '../../factories/logger-factory'
1010
import { createEventCommandResult } from '../../telemetry/event-metrics'
11+
import { markActionableTarget } from '../../utils/hidden-content-cache'
1112
import { extractReportTargets } from '../../utils/nip56'
1213
import { calculateReportWeight } from '../../utils/report-scoring'
1314

@@ -77,6 +78,14 @@ export class ReportEventStrategy implements IEventStrategy<Event, Promise<void>>
7778
// producing more than one row (see extractReportTargets) shouldn't be
7879
// able to leave a partial set behind on a mid-batch failure.
7980
await this.reportRepository.createMany(reports)
81+
82+
// Keeps the live-broadcast path (WebSocketAdapter.onSendEvent, which
83+
// matches events against open subscriptions in-process) in sync with
84+
// what a fresh REQ would now exclude -- without this, a freshly
85+
// actionable pubkey/event could still reach existing subscribers.
86+
if (isTrustedModerator && nip56.hideActionableReports) {
87+
reports.forEach(markActionableTarget)
88+
}
8089
}
8190
} catch (error) {
8291
// Report scoring/recording is best-effort: the report event itself is

‎src/repositories/event-repository.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export class EventRepository implements IEventRepository {
142142

143143
private applyFilterConditions(builder: any, currentFilter: SubscriptionFilter): FilterConditionFlags {
144144
this.applyHexFilterConditions(builder, currentFilter)
145+
this.applyActionableReportExclusion(builder)
145146

146147
if (Array.isArray(currentFilter.kinds)) {
147148
builder.whereIn('event_kind', currentFilter.kinds)
@@ -180,6 +181,31 @@ export class EventRepository implements IEventRepository {
180181
return { isTagQuery, isSearchQuery }
181182
}
182183

184+
/**
185+
* NIP-56: excludes events matching an actionable report -- a pubkey-targeted
186+
* report hides every event from that pubkey, an event-targeted report hides
187+
* just that event. No-op unless both nip56.enabled and
188+
* nip56.hideActionableReports are set, so relays not using this feature pay
189+
* no extra query cost.
190+
*/
191+
private applyActionableReportExclusion(builder: any): void {
192+
const nip56Settings = this.settings?.()?.nip56
193+
if (!nip56Settings?.enabled || !nip56Settings?.hideActionableReports) {
194+
return
195+
}
196+
197+
builder.whereNotExists(function () {
198+
this.select('id')
199+
.from('reports')
200+
.where('reports.actionable', true)
201+
.andWhere((bd: any) => {
202+
bd.whereRaw('reports.reported_event_id = events.event_id').orWhereRaw(
203+
'reports.reported_pubkey = events.event_pubkey',
204+
)
205+
})
206+
})
207+
}
208+
183209
/** Resolve the PostgreSQL text-search configuration name from settings. */
184210
private getNip50Language(): string {
185211
return this.settings?.()?.nip50?.language ?? DEFAULT_TS_CONFIG

0 commit comments

Comments
 (0)