diff --git a/.changeset/nip56-hide-deprioritize.md b/.changeset/nip56-hide-deprioritize.md new file mode 100644 index 00000000..6edad980 --- /dev/null +++ b/.changeset/nip56-hide-deprioritize.md @@ -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. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 6ee8fcb6..7fc486b1 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -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('', 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. | diff --git a/migrations/20260926_120000_add_actionable_reports_indexes.js b/migrations/20260926_120000_add_actionable_reports_indexes.js new file mode 100644 index 00000000..0ca00f7c --- /dev/null +++ b/migrations/20260926_120000_add_actionable_reports_indexes.js @@ -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') +} diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 65f9ae10..c91d3c4f 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -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. diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 8368444e..7c5e93b3 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -97,6 +97,8 @@ export interface IReportRepository { createMany(reports: Omit[]): Promise findByEventId(eventId: EventId): Promise findActionable(limit?: number): Promise + /** 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 { diff --git a/src/@types/settings.ts b/src/@types/settings.ts index c61833af..1a5db8c1 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -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 { diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index ac613e49..cd32d7a9 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -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' @@ -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) diff --git a/src/factories/worker-factory.ts b/src/factories/worker-factory.ts index 6aa8652c..28429675 100644 --- a/src/factories/worker-factory.ts +++ b/src/factories/worker-factory.ts @@ -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' @@ -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 diff --git a/src/handlers/event-strategies/report-event-strategy.ts b/src/handlers/event-strategies/report-event-strategy.ts index 63c6300d..7fe57369 100644 --- a/src/handlers/event-strategies/report-event-strategy.ts +++ b/src/handlers/event-strategies/report-event-strategy.ts @@ -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' @@ -77,6 +78,14 @@ export class ReportEventStrategy implements IEventStrategy> // 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 diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 822b7fa0..c7f3d87c 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -142,6 +142,7 @@ export class EventRepository implements IEventRepository { private applyFilterConditions(builder: any, currentFilter: SubscriptionFilter): FilterConditionFlags { this.applyHexFilterConditions(builder, currentFilter) + this.applyActionableReportExclusion(builder) if (Array.isArray(currentFilter.kinds)) { builder.whereIn('event_kind', currentFilter.kinds) @@ -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 diff --git a/src/repositories/report-repository.ts b/src/repositories/report-repository.ts index bdd01fe9..4fc2a6a8 100644 --- a/src/repositories/report-repository.ts +++ b/src/repositories/report-repository.ts @@ -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('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, + })) + } } diff --git a/src/utils/hidden-content-cache.ts b/src/utils/hidden-content-cache.ts new file mode 100644 index 00000000..a6c1735b --- /dev/null +++ b/src/utils/hidden-content-cache.ts @@ -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() +const hiddenEventIds = new Set() + +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): 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 => { + const targets = await reportRepository.findActionableTargets() + targets.forEach(markActionableTarget) +} + +export const resetHiddenContentCache = (): void => { + hiddenPubkeys.clear() + hiddenEventIds.clear() +} diff --git a/src/utils/settings-guided-schema.ts b/src/utils/settings-guided-schema.ts index bb6b65ad..109e5135 100644 --- a/src/utils/settings-guided-schema.ts +++ b/src/utils/settings-guided-schema.ts @@ -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', + }, ], }, { diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index cec50035..fd09b2bb 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -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 @@ -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', () => { diff --git a/test/unit/factories/worker-factory.spec.ts b/test/unit/factories/worker-factory.spec.ts index 5a32096c..5b464e97 100644 --- a/test/unit/factories/worker-factory.spec.ts +++ b/test/unit/factories/worker-factory.spec.ts @@ -4,6 +4,7 @@ import { AppWorker } from '../../../src/app/worker' import * as cacheClientModule from '../../../src/cache/client' import * as databaseClientModule from '../../../src/database/client' import { workerFactory } from '../../../src/factories/worker-factory' +import { ReportRepository } from '../../../src/repositories/report-repository' import { SettingsStatic } from '../../../src/utils/settings' describe('workerFactory', () => { @@ -42,4 +43,57 @@ describe('workerFactory', () => { expect(worker).to.be.an.instanceOf(AppWorker) worker.close() }) + + describe('NIP-56 hidden-content cache warm-up', () => { + let findActionableTargetsStub: Sinon.SinonStub + + beforeEach(() => { + findActionableTargetsStub = Sinon.stub(ReportRepository.prototype, 'findActionableTargets').resolves([]) + }) + + afterEach(() => { + findActionableTargetsStub.restore() + }) + + it('warms the cache at boot when nip56.hideActionableReports is enabled', async () => { + createSettingsStub.returns({ + info: { relay_url: 'url' }, + network: {}, + nip56: { enabled: true, trustedModerators: [], hideActionableReports: true }, + }) + + const worker = workerFactory() + await new Promise((resolve) => setImmediate(resolve)) + + expect(findActionableTargetsStub.callCount).to.equal(1) + worker.close() + }) + + it('does not warm the cache when hideActionableReports is disabled', async () => { + createSettingsStub.returns({ + info: { relay_url: 'url' }, + network: {}, + nip56: { enabled: true, trustedModerators: [], hideActionableReports: false }, + }) + + const worker = workerFactory() + await new Promise((resolve) => setImmediate(resolve)) + + expect(findActionableTargetsStub.called).to.be.false + worker.close() + }) + + it('does not warm the cache when nip56 is unset', async () => { + createSettingsStub.returns({ + info: { relay_url: 'url' }, + network: {}, + }) + + const worker = workerFactory() + await new Promise((resolve) => setImmediate(resolve)) + + expect(findActionableTargetsStub.called).to.be.false + worker.close() + }) + }) }) diff --git a/test/unit/handlers/event-strategies/report-event-strategy.spec.ts b/test/unit/handlers/event-strategies/report-event-strategy.spec.ts index c0999d5b..2de1df83 100644 --- a/test/unit/handlers/event-strategies/report-event-strategy.spec.ts +++ b/test/unit/handlers/event-strategies/report-event-strategy.spec.ts @@ -16,6 +16,7 @@ import { IWotGraphService } from '../../../../src/@types/services' import { Settings } from '../../../../src/@types/settings' import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' import { ReportEventStrategy } from '../../../../src/handlers/event-strategies/report-event-strategy' +import { isHidden, resetHiddenContentCache } from '../../../../src/utils/hidden-content-cache' describe('ReportEventStrategy', () => { const reporterPubkey = '2'.repeat(64) @@ -74,6 +75,7 @@ describe('ReportEventStrategy', () => { afterEach(() => { sandbox.restore() + resetHiddenContentCache() }) describe('execute', () => { @@ -178,6 +180,40 @@ describe('ReportEventStrategy', () => { ]) }) + it('marks the target in the hidden-content cache when hideActionableReports is enabled', async () => { + settings = () => ({ nip56: { enabled: true, trustedModerators: [reporterPubkey], hideActionableReports: true } }) as any + strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateManyStub.resolves([{}]) + + await strategy.execute(event) + + expect(isHidden({ id: 'unrelated-id', pubkey: reportedPubkey })).to.be.true + }) + + it('does not mark the cache when hideActionableReports is disabled', async () => { + settings = () => ({ nip56: { enabled: true, trustedModerators: [reporterPubkey], hideActionableReports: false } }) as any + strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateManyStub.resolves([{}]) + + await strategy.execute(event) + + expect(isHidden({ id: 'unrelated-id', pubkey: reportedPubkey })).to.be.false + }) + + it('does not mark the cache for a non-actionable (non-moderator) report even when hideActionableReports is enabled', async () => { + settings = () => ({ nip56: { enabled: true, trustedModerators: [], hideActionableReports: true } }) as any + strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) + eventRepositoryCreateStub.resolves(1) + reportRepositoryCreateManyStub.resolves([{}]) + getDistanceStub.resolves(1) + + await strategy.execute(event) + + expect(isHidden({ id: 'unrelated-id', pubkey: reportedPubkey })).to.be.false + }) + it('does not consult the WoT graph for a trusted moderator', async () => { settings = () => ({ nip56: { enabled: true, trustedModerators: [reporterPubkey] } }) as any strategy = new ReportEventStrategy(webSocket, eventRepository, reportRepository, wotGraphService, settings) diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index d36294a7..104d4e49 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -559,6 +559,82 @@ describe('EventRepository', () => { expect(query).to.include("plainto_tsquery('simple'::regconfig, 'bitco')") }) }) + + describe('NIP-56: hideActionableReports', () => { + let hideEnabledRepository: IEventRepository + + beforeEach(() => { + hideEnabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip56: { enabled: true, trustedModerators: [], hideActionableReports: true }, + }) as any) + }) + + it('adds a NOT EXISTS clause against reports when enabled', () => { + const filters = [{ kinds: [1] }] + + const query = hideEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include('not exists') + expect(query).to.include('from "reports"') + expect(query).to.include('"reports"."actionable" = true') + expect(query).to.include('reports.reported_event_id = events.event_id') + expect(query).to.include('reports.reported_pubkey = events.event_pubkey') + }) + + it('applies the exclusion to countByFilters too', async () => { + const fromStub = sandbox.stub(rrDbClient, 'from').returns({ + countDistinct: () => ({ + first: async () => ({ count: '0' }), + }), + } as any) + + await hideEnabledRepository.countByFilters([{ kinds: [1] }]) + + const sql = fromStub.firstCall.args[0].toString() + expect(sql).to.include('not exists') + expect(sql).to.include('from "reports"') + }) + + it('omits the clause when hideActionableReports is false', () => { + const disabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip56: { enabled: true, trustedModerators: [], hideActionableReports: false }, + }) as any) + const filters = [{ kinds: [1] }] + + const query = disabledRepository.findByFilters(filters).toString() + + expect(query).to.not.include('reports') + }) + + it('omits the clause when nip56 is disabled even if hideActionableReports is true', () => { + const disabledRepository = new EventRepository(dbClient, rrDbClient, () => ({ + nip56: { enabled: false, trustedModerators: [], hideActionableReports: true }, + }) as any) + const filters = [{ kinds: [1] }] + + const query = disabledRepository.findByFilters(filters).toString() + + expect(query).to.not.include('reports') + }) + + it('omits the clause when no settings are provided', () => { + const noSettingsRepository = new EventRepository(dbClient, rrDbClient) + const filters = [{ kinds: [1] }] + + const query = noSettingsRepository.findByFilters(filters).toString() + + expect(query).to.not.include('reports') + }) + + it('composes with other filter conditions', () => { + const filters = [{ authors: ['a'.repeat(64)], kinds: [1] }] + + const query = hideEnabledRepository.findByFilters(filters).toString() + + expect(query).to.include('"event_kind" in (1)') + expect(query).to.include('not exists') + }) + }) }) describe('.countByFilters', () => { diff --git a/test/unit/repositories/report-repository.spec.ts b/test/unit/repositories/report-repository.spec.ts index 33a34562..e1f55e8f 100644 --- a/test/unit/repositories/report-repository.spec.ts +++ b/test/unit/repositories/report-repository.spec.ts @@ -282,4 +282,25 @@ describe('ReportRepository', () => { expect(limitStub).to.have.been.calledWith(100) }) }) + + describe('.findActionableTargets', () => { + it('filters by actionable and selects distinct targets', async () => { + const selectStub = sandbox.stub().resolves([ + { reported_pubkey: Buffer.from(reportedPubkey, 'hex'), reported_event_id: null }, + { reported_pubkey: null, reported_event_id: Buffer.from(reportedEventId, 'hex') }, + ]) + const distinctStub = sandbox.stub().returns({ select: selectStub }) + const whereStub = sandbox.stub().returns({ distinct: distinctStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + const result = await repository.findActionableTargets(client) + + expect(whereStub).to.have.been.calledWith('actionable', true) + expect(distinctStub).to.have.been.calledWith('reported_pubkey', 'reported_event_id') + expect(result).to.deep.equal([ + { reportedPubkey, reportedEventId: null }, + { reportedPubkey: null, reportedEventId }, + ]) + }) + }) }) diff --git a/test/unit/utils/hidden-content-cache.spec.ts b/test/unit/utils/hidden-content-cache.spec.ts new file mode 100644 index 00000000..e35c1a2d --- /dev/null +++ b/test/unit/utils/hidden-content-cache.spec.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { IReportRepository } from '../../../src/@types/repositories' +import { + isHidden, + markActionableTarget, + resetHiddenContentCache, + warmHiddenContentCache, +} from '../../../src/utils/hidden-content-cache' + +describe('hidden-content-cache', () => { + afterEach(() => { + resetHiddenContentCache() + }) + + describe('markActionableTarget/isHidden', () => { + it('hides an event whose pubkey was marked', () => { + markActionableTarget({ reportedPubkey: 'a'.repeat(64), reportedEventId: null }) + + expect(isHidden({ id: 'b'.repeat(64), pubkey: 'a'.repeat(64) })).to.be.true + }) + + it('hides an event whose id was marked', () => { + markActionableTarget({ reportedPubkey: null, reportedEventId: 'c'.repeat(64) }) + + expect(isHidden({ id: 'c'.repeat(64), pubkey: 'd'.repeat(64) })).to.be.true + }) + + it('does not hide an unmarked event', () => { + markActionableTarget({ reportedPubkey: 'a'.repeat(64), reportedEventId: null }) + + expect(isHidden({ id: 'e'.repeat(64), pubkey: 'f'.repeat(64) })).to.be.false + }) + + it('ignores a target with both fields null', () => { + markActionableTarget({ reportedPubkey: null, reportedEventId: null }) + + expect(isHidden({ id: 'g'.repeat(64), pubkey: 'h'.repeat(64) })).to.be.false + }) + }) + + describe('warmHiddenContentCache', () => { + it('marks every actionable target returned by the repository', async () => { + const reportRepository = { + findActionableTargets: Sinon.stub().resolves([ + { reportedPubkey: 'a'.repeat(64), reportedEventId: null }, + { reportedPubkey: null, reportedEventId: 'b'.repeat(64) }, + ]), + } as unknown as IReportRepository + + await warmHiddenContentCache(reportRepository) + + expect(isHidden({ id: 'x'.repeat(64), pubkey: 'a'.repeat(64) })).to.be.true + expect(isHidden({ id: 'b'.repeat(64), pubkey: 'y'.repeat(64) })).to.be.true + }) + }) + + describe('resetHiddenContentCache', () => { + it('clears previously marked targets', () => { + markActionableTarget({ reportedPubkey: 'a'.repeat(64), reportedEventId: null }) + resetHiddenContentCache() + + expect(isHidden({ id: 'z'.repeat(64), pubkey: 'a'.repeat(64) })).to.be.false + }) + }) +})