diff --git a/.changeset/admin-moderation-634.md b/.changeset/admin-moderation-634.md new file mode 100644 index 000000000..9016953a7 --- /dev/null +++ b/.changeset/admin-moderation-634.md @@ -0,0 +1,7 @@ +--- +"nostream": minor +--- + +feat(admin): moderation tools for pubkey, kind, and IP limits (closes #634) + +Adds admin API and console view to read and update event pubkey/kind lists, rate-limit bypass lists, quick ban/unban actions, and activity stats for top publishers, client IPs, and rate-limit hits. diff --git a/resources/admin/assets/admin-moderation.js b/resources/admin/assets/admin-moderation.js new file mode 100644 index 000000000..78e55ad1f --- /dev/null +++ b/resources/admin/assets/admin-moderation.js @@ -0,0 +1,202 @@ +(() => { + const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' } + const adminBase = `${config.pathPrefix || ''}/admin` + + const syncLine = document.getElementById('moderation-sync') + if (!syncLine) { + return + } + + const elements = { + error: document.getElementById('moderation-error'), + success: document.getElementById('moderation-success'), + syncLine, + banPubkeyInput: document.getElementById('moderation-ban-pubkey'), + ipInput: document.getElementById('moderation-ip'), + banButton: document.getElementById('moderation-ban-button'), + unbanButton: document.getElementById('moderation-unban-button'), + whitelistIpButton: document.getElementById('moderation-whitelist-ip-button'), + unwhitelistIpButton: document.getElementById('moderation-unwhitelist-ip-button'), + refreshButton: document.getElementById('moderation-refresh-button'), + topPubkeysBody: document.getElementById('moderation-top-pubkeys-body'), + topIpsBody: document.getElementById('moderation-top-ips-body'), + ratePubkeysBody: document.getElementById('moderation-rate-pubkeys-body'), + pubkeyBlacklist: document.getElementById('moderation-pubkey-blacklist'), + pubkeyWhitelist: document.getElementById('moderation-pubkey-whitelist'), + kindBlacklist: document.getElementById('moderation-kind-blacklist'), + kindWhitelist: document.getElementById('moderation-kind-whitelist'), + bypassPubkeys: document.getElementById('moderation-bypass-pubkeys'), + bypassIps: document.getElementById('moderation-bypass-ips'), + } + + const escapeHtml = (value) => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + + const adminFetch = async (path, options = {}) => { + const response = await fetch(`${adminBase}${path}`, { + credentials: 'same-origin', + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, + ...options, + }) + if (response.status === 401) { + throw new Error('Unauthorized') + } + return response + } + + const showError = (message) => { + elements.error.textContent = message + elements.error.classList.remove('d-none') + elements.success.classList.add('d-none') + } + + const showSuccess = (message) => { + elements.success.textContent = message + elements.success.classList.remove('d-none') + elements.error.classList.add('d-none') + } + + const hideMessages = () => { + elements.error.classList.add('d-none') + elements.success.classList.add('d-none') + } + + const handleUnauthorized = () => { + globalThis.dispatchEvent(new CustomEvent('admin-unauthorized')) + } + + const renderRows = (body, rows, keyField, valueField, emptyLabel) => { + if (!body) { + return + } + if (!Array.isArray(rows) || !rows.length) { + body.innerHTML = `${escapeHtml(emptyLabel)}` + return + } + body.innerHTML = rows + .map( + (row) => + `${escapeHtml(row[keyField])}${escapeHtml(row[valueField])}`, + ) + .join('') + } + + const renderRateRows = (body, rows) => { + if (!body) { + return + } + if (!Array.isArray(rows) || !rows.length) { + body.innerHTML = 'No rate-limit hits recorded yet.' + return + } + body.innerHTML = rows + .map((row) => `${escapeHtml(row.key)}${escapeHtml(row.hits)}`) + .join('') + } + + const renderLimits = (limits) => { + const pretty = (value) => JSON.stringify(value ?? [], null, 2) + elements.pubkeyBlacklist.textContent = pretty(limits.pubkey?.blacklist) + elements.pubkeyWhitelist.textContent = pretty(limits.pubkey?.whitelist) + elements.kindBlacklist.textContent = pretty(limits.kind?.blacklist) + elements.kindWhitelist.textContent = pretty(limits.kind?.whitelist) + elements.bypassPubkeys.textContent = pretty(limits.rateLimitBypass?.pubkeys) + elements.bypassIps.textContent = pretty(limits.rateLimitBypass?.ipAddresses) + } + + const load = async () => { + hideMessages() + try { + const [limitsResponse, statsResponse] = await Promise.all([ + adminFetch('/moderation/limits'), + adminFetch('/moderation/stats?windowHours=168&limit=15'), + ]) + + if (!limitsResponse.ok || !statsResponse.ok) { + throw new Error('Failed to load moderation data') + } + + const limitsPayload = await limitsResponse.json() + const statsPayload = await statsResponse.json() + renderLimits(limitsPayload.limits) + renderRows(elements.topPubkeysBody, statsPayload.topPublishingPubkeys, 'pubkey', 'eventCount', 'No events in window.') + renderRows(elements.topIpsBody, statsPayload.topClientIps, 'ip', 'eventCount', 'No client IPs in window.') + renderRateRows(elements.ratePubkeysBody, statsPayload.rateLimitHits?.pubkeys) + elements.syncLine.innerHTML = `> moderation: updated ${new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC')}` + } catch (error) { + if (error instanceof Error && error.message === 'Unauthorized') { + handleUnauthorized() + return + } + showError('Unable to load moderation data.') + } + } + + const postAction = async (body) => { + hideMessages() + const response = await adminFetch('/moderation/actions', { method: 'POST', body: JSON.stringify(body) }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(payload.error || 'Moderation action failed') + } + renderLimits(payload.limits) + showSuccess('Limits updated.') + elements.syncLine.innerHTML = `> moderation: action ${body.action} applied` + void load() + } + + const bindAction = (button, handler) => { + button?.addEventListener('click', () => { + void handler().catch((error) => { + if (error instanceof Error && error.message === 'Unauthorized') { + handleUnauthorized() + return + } + showError(error instanceof Error ? error.message : 'Action failed.') + }) + }) + } + + bindAction(elements.banButton, async () => { + const pubkey = elements.banPubkeyInput?.value?.trim() + if (!pubkey) { + throw new Error('Enter a pubkey or npub to ban.') + } + await postAction({ action: 'ban-pubkey', pubkey }) + }) + + bindAction(elements.unbanButton, async () => { + const pubkey = elements.banPubkeyInput?.value?.trim() + if (!pubkey) { + throw new Error('Enter a pubkey or npub to unban.') + } + await postAction({ action: 'unban-pubkey', pubkey }) + }) + + bindAction(elements.whitelistIpButton, async () => { + const ip = elements.ipInput?.value?.trim() + if (!ip) { + throw new Error('Enter an IP address.') + } + await postAction({ action: 'whitelist-ip', ip }) + }) + + bindAction(elements.unwhitelistIpButton, async () => { + const ip = elements.ipInput?.value?.trim() + if (!ip) { + throw new Error('Enter an IP address.') + } + await postAction({ action: 'unwhitelist-ip', ip }) + }) + + elements.refreshButton?.addEventListener('click', () => { + void load() + }) + + window.__ADMIN_MODERATION__ = { load } +})() diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js index 4b82595b8..9f5b88abf 100644 --- a/resources/admin/assets/dashboard.js +++ b/resources/admin/assets/dashboard.js @@ -1571,6 +1571,14 @@ return } + if (viewId === 'moderation-view') { + stopNetworkHealthPolling() + stopMetricsStream() + window.__ADMIN_OPS__?.stop?.() + window.__ADMIN_MODERATION__?.load?.() + return + } + if (viewId === 'metrics-view') { stopNetworkHealthPolling() window.__ADMIN_OPS__?.stop?.() diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html index 33bb3d3b5..ef046457c 100644 --- a/resources/admin/dashboard.html +++ b/resources/admin/dashboard.html @@ -67,6 +67,7 @@

Sign in

+ @@ -524,6 +525,108 @@

Admin audit log

+
+
+

Quick actions

+

Ban or unban pubkeys and manage event rate-limit bypass lists without editing YAML by hand.

+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+

> moderation: not loaded

+
+ +
+
+

Activity (7 days)

+ +
+
+
+

Top publishing pubkeys

+
+ + + + + +
PubkeyEvents
Not loaded.
+
+
+
+

Top client IPs

+
+ + + + + +
IPEvents
Not loaded.
+
+
+
+

Rate-limit hits

+
+ + + + + +
PubkeyHits
Not loaded.
+
+
+
+
+ +
+

Current limits

+
+
+

Pubkey blacklist

+

+              
+
+

Pubkey whitelist

+

+              
+
+

Kind blacklist

+

+              
+
+

Kind whitelist

+

+              
+
+

Rate-limit bypass pubkeys

+

+              
+
+

Rate-limit bypass IPs

+

+              
+
+
+
+

Configuration

@@ -572,5 +675,6 @@

Preview diff

+ diff --git a/src/@types/admin-audit.ts b/src/@types/admin-audit.ts index d258594c0..707d6e85b 100644 --- a/src/@types/admin-audit.ts +++ b/src/@types/admin-audit.ts @@ -6,6 +6,8 @@ export type AdminAuditAction = | 'notifications.updated' | 'probe.manual_run' | 'nip05.reverify' + | 'moderation.limits.updated' + | 'moderation.action' export interface AdminAuditEntry { id: string diff --git a/src/controllers/admin/get-moderation-limits-controller.ts b/src/controllers/admin/get-moderation-limits-controller.ts new file mode 100644 index 000000000..27b99675d --- /dev/null +++ b/src/controllers/admin/get-moderation-limits-controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { readAdminModerationLimits } from '../../utils/admin-moderation' + +export class GetAdminModerationLimitsController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + response.status(200).setHeader('content-type', 'application/json').send({ + limits: readAdminModerationLimits(), + }) + } +} diff --git a/src/controllers/admin/get-moderation-stats-controller.ts b/src/controllers/admin/get-moderation-stats-controller.ts new file mode 100644 index 000000000..c4f7f6688 --- /dev/null +++ b/src/controllers/admin/get-moderation-stats-controller.ts @@ -0,0 +1,61 @@ +import { Request, Response } from 'express' + +import { RedisAdapter } from '../../adapters/redis-adapter' +import { IController } from '../../@types/controllers' +import { getCacheClient, isCacheConfigured } from '../../cache/client' +import { getReadReplicaDbClient } from '../../database/client' +import { createLogger } from '../../factories/logger-factory' +import { + parseModerationStatsLimit, + parseModerationStatsWindowHours, + queryTopClientIps, + queryTopPublishingPubkeys, +} from '../../utils/admin-moderation-stats' +import { listModerationRateLimitHits } from '../../utils/moderation-rate-limit-stats' + +const logger = createLogger('get-admin-moderation-stats-controller') + +const isStatsQueryTimeout = (error: unknown): boolean => + error instanceof Error && /query timeout|timed out/i.test(error.message) + +export class GetAdminModerationStatsController implements IController { + public async handleRequest(request: Request, response: Response): Promise { + const window = parseModerationStatsWindowHours(request.query.windowHours) + if (window.error || window.hours === undefined) { + response.status(400).setHeader('content-type', 'application/json').send({ error: window.error }) + return + } + + const limit = parseModerationStatsLimit(request.query.limit) + const sinceUnixSeconds = Math.floor(Date.now() / 1000) - window.hours * 3600 + + try { + const [topPubkeys, topIps] = await Promise.all([ + queryTopPublishingPubkeys(getReadReplicaDbClient(), sinceUnixSeconds, limit), + queryTopClientIps(getReadReplicaDbClient(), sinceUnixSeconds, limit), + ]) + + let rateLimitHits = { pubkeys: [] as { key: string; hits: number }[], ips: [] as { key: string; hits: number }[] } + if (isCacheConfigured()) { + const cache = new RedisAdapter(getCacheClient()) + rateLimitHits = await listModerationRateLimitHits(cache, limit, window.hours) + } + + response.status(200).setHeader('content-type', 'application/json').send({ + windowHours: window.hours, + since: sinceUnixSeconds, + topPublishingPubkeys: topPubkeys, + topClientIps: topIps, + rateLimitHits, + }) + } catch (error) { + if (isStatsQueryTimeout(error)) { + response.status(504).setHeader('content-type', 'application/json').send({ error: 'Moderation stats query timed out' }) + return + } + + logger.error('moderation stats query failed: %o', error) + response.status(500).setHeader('content-type', 'application/json').send({ error: 'Moderation stats query failed' }) + } + } +} diff --git a/src/controllers/admin/patch-moderation-limits-controller.ts b/src/controllers/admin/patch-moderation-limits-controller.ts new file mode 100644 index 000000000..415991af5 --- /dev/null +++ b/src/controllers/admin/patch-moderation-limits-controller.ts @@ -0,0 +1,52 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { INotificationOutboxRepository } from '../../@types/repositories' +import { adminModerationLimitsPatchSchema } from '../../schemas/admin-moderation-schema' +import { + mergeAdminModerationLimitsPatch, + readAdminModerationLimits, + ValidationError, +} from '../../utils/admin-moderation' +import { saveModerationLimitsFromRequest } from '../../utils/admin-moderation-persist' +import { validateSchema } from '../../utils/validation' + +export class PatchAdminModerationLimitsController implements IController { + public constructor(private readonly notificationOutboxRepository: INotificationOutboxRepository) {} + + public async handleRequest(request: Request, response: Response): Promise { + const validation = validateSchema(adminModerationLimitsPatchSchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) + return + } + + const next = mergeAdminModerationLimitsPatch(readAdminModerationLimits(), validation.value) + + try { + await saveModerationLimitsFromRequest( + next, + request, + this.notificationOutboxRepository, + 'moderation.limits.updated', + ) + } catch (error) { + if (error instanceof ValidationError) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: error.message, issues: error.issues }) + return + } + + if (error instanceof Error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: error.message }) + return + } + + throw error + } + + response.status(200).setHeader('content-type', 'application/json').send({ ok: true, limits: next }) + } +} diff --git a/src/controllers/admin/post-moderation-actions-controller.ts b/src/controllers/admin/post-moderation-actions-controller.ts new file mode 100644 index 000000000..f0af6d9c2 --- /dev/null +++ b/src/controllers/admin/post-moderation-actions-controller.ts @@ -0,0 +1,62 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { INotificationOutboxRepository } from '../../@types/repositories' +import { adminModerationActionSchema } from '../../schemas/admin-moderation-schema' +import { + applyAdminModerationAction, + readAdminModerationLimits, + ValidationError, +} from '../../utils/admin-moderation' +import { saveModerationLimitsFromRequest } from '../../utils/admin-moderation-persist' +import { validateSchema } from '../../utils/validation' + +export class PostAdminModerationActionsController implements IController { + public constructor(private readonly notificationOutboxRepository: INotificationOutboxRepository) {} + + public async handleRequest(request: Request, response: Response): Promise { + const validation = validateSchema(adminModerationActionSchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) + return + } + + const { action, pubkey, ip } = validation.value + let next + + try { + next = applyAdminModerationAction(readAdminModerationLimits(), action, { pubkey, ip }) + } catch (error) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: error instanceof Error ? error.message : 'Invalid action' }) + return + } + + try { + await saveModerationLimitsFromRequest(next, request, this.notificationOutboxRepository, 'moderation.action', { + action, + pubkey, + ip, + }) + } catch (error) { + if (error instanceof ValidationError) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: error.message, issues: error.issues }) + return + } + + if (error instanceof Error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: error.message }) + return + } + + throw error + } + + response.status(200).setHeader('content-type', 'application/json').send({ ok: true, limits: next }) + } +} diff --git a/src/factories/controllers/get-admin-moderation-limits-controller-factory.ts b/src/factories/controllers/get-admin-moderation-limits-controller-factory.ts new file mode 100644 index 000000000..bc7340381 --- /dev/null +++ b/src/factories/controllers/get-admin-moderation-limits-controller-factory.ts @@ -0,0 +1,4 @@ +import { GetAdminModerationLimitsController } from '../../controllers/admin/get-moderation-limits-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminModerationLimitsController = (): IController => new GetAdminModerationLimitsController() diff --git a/src/factories/controllers/get-admin-moderation-stats-controller-factory.ts b/src/factories/controllers/get-admin-moderation-stats-controller-factory.ts new file mode 100644 index 000000000..dd4e09316 --- /dev/null +++ b/src/factories/controllers/get-admin-moderation-stats-controller-factory.ts @@ -0,0 +1,4 @@ +import { GetAdminModerationStatsController } from '../../controllers/admin/get-moderation-stats-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminModerationStatsController = (): IController => new GetAdminModerationStatsController() diff --git a/src/factories/controllers/patch-admin-moderation-limits-controller-factory.ts b/src/factories/controllers/patch-admin-moderation-limits-controller-factory.ts new file mode 100644 index 000000000..cb3724f0e --- /dev/null +++ b/src/factories/controllers/patch-admin-moderation-limits-controller-factory.ts @@ -0,0 +1,9 @@ +import { PatchAdminModerationLimitsController } from '../../controllers/admin/patch-moderation-limits-controller' +import { IController } from '../../@types/controllers' +import { getMasterDbClient } from '../../database/client' +import { NotificationOutboxRepository } from '../../repositories/notification-outbox-repository' + +export const createPatchAdminModerationLimitsController = (): IController => { + const notificationOutboxRepository = new NotificationOutboxRepository(getMasterDbClient()) + return new PatchAdminModerationLimitsController(notificationOutboxRepository) +} diff --git a/src/factories/controllers/post-admin-moderation-actions-controller-factory.ts b/src/factories/controllers/post-admin-moderation-actions-controller-factory.ts new file mode 100644 index 000000000..7e6f8b5d7 --- /dev/null +++ b/src/factories/controllers/post-admin-moderation-actions-controller-factory.ts @@ -0,0 +1,9 @@ +import { PostAdminModerationActionsController } from '../../controllers/admin/post-moderation-actions-controller' +import { IController } from '../../@types/controllers' +import { getMasterDbClient } from '../../database/client' +import { NotificationOutboxRepository } from '../../repositories/notification-outbox-repository' + +export const createPostAdminModerationActionsController = (): IController => { + const notificationOutboxRepository = new NotificationOutboxRepository(getMasterDbClient()) + return new PostAdminModerationActionsController(notificationOutboxRepository) +} diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index ba362f990..8939fcaae 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -53,6 +53,7 @@ import { IWebSocketAdapter } from '../@types/adapters' import { IWotGraphService } from '../@types/services' import { Nip05Verification } from '../@types/nip05' import { WebSocketAdapterEvent } from '../constants/adapter' +import { recordModerationRateLimitHitSafely } from '../utils/moderation-rate-limit-stats' const logger = createLogger('event-message-handler') @@ -94,6 +95,10 @@ export class EventMessageHandler implements IMessageHandler { if (rateLimited) { logger('event %s rejected: rate-limited', event.id) const retryAfterMs = typeof rateLimited === 'number' ? String(rateLimited) : undefined + void recordModerationRateLimitHitSafely(this.cache, { + pubkey: event.pubkey, + ip: event[ContextMetadataKey]?.remoteAddress?.address, + }) this.webSocket.emit( WebSocketAdapterEvent.Message, createEventCommandResult(event.id, false, 'rate-limited: slow down', retryAfterMs), diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index fe26133e7..8dd5317a5 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -24,6 +24,10 @@ import { createPostAdminOpsNip05ReverifyController } from '../../factories/contr import { createGetAdminAuditLogController } from '../../factories/controllers/get-admin-audit-log-controller-factory' import { createGetAdminDiagnosticsExportController } from '../../factories/controllers/get-admin-diagnostics-export-controller-factory' import { createPostAdminNetworkHealthRunController } from '../../factories/controllers/post-admin-network-health-run-controller-factory' +import { createGetAdminModerationLimitsController } from '../../factories/controllers/get-admin-moderation-limits-controller-factory' +import { createGetAdminModerationStatsController } from '../../factories/controllers/get-admin-moderation-stats-controller-factory' +import { createPatchAdminModerationLimitsController } from '../../factories/controllers/patch-admin-moderation-limits-controller-factory' +import { createPostAdminModerationActionsController } from '../../factories/controllers/post-admin-moderation-actions-controller-factory' import { adminAuthGateMiddleware, adminAuthMiddleware } from '../../handlers/request-handlers/admin-auth-middleware' import { adminEnabledMiddleware } from '../../handlers/request-handlers/admin-enabled-middleware' import { adminJsonBodyMiddleware } from '../../handlers/request-handlers/admin-json-body-middleware' @@ -99,6 +103,34 @@ router.get( adminAuthMiddleware, withAdminController(createGetAdminDiagnosticsExportController), ) +router.get( + '/moderation/limits', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminModerationLimitsController), +) +router.patch( + '/moderation/limits', + adminRateLimitMiddleware, + adminAuthGateMiddleware, + adminJsonBodyMiddleware, + adminAuthMiddleware, + withAdminController(createPatchAdminModerationLimitsController), +) +router.post( + '/moderation/actions', + adminRateLimitMiddleware, + adminAuthGateMiddleware, + adminJsonBodyMiddleware, + adminAuthMiddleware, + withAdminController(createPostAdminModerationActionsController), +) +router.get( + '/moderation/stats', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminModerationStatsController), +) router.get( '/settings', adminRateLimitMiddleware, diff --git a/src/schemas/admin-moderation-schema.ts b/src/schemas/admin-moderation-schema.ts new file mode 100644 index 000000000..fe51155fd --- /dev/null +++ b/src/schemas/admin-moderation-schema.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' + +const kindRuleSchema = z.union([z.number().int().min(0).max(65535), z.tuple([z.number().int(), z.number().int()])]) + +export const adminModerationLimitsPatchSchema = z + .object({ + pubkey: z + .object({ + blacklist: z.array(z.string()).optional(), + whitelist: z.array(z.string()).optional(), + }) + .optional(), + kind: z + .object({ + blacklist: z.array(kindRuleSchema).optional(), + whitelist: z.array(kindRuleSchema).optional(), + }) + .optional(), + rateLimitBypass: z + .object({ + pubkeys: z.array(z.string()).optional(), + ipAddresses: z.array(z.string()).optional(), + }) + .optional(), + }) + .strict() + +export const adminModerationActionSchema = z + .object({ + action: z.enum([ + 'ban-pubkey', + 'unban-pubkey', + 'whitelist-pubkey', + 'unwhitelist-pubkey', + 'whitelist-ip', + 'unwhitelist-ip', + ]), + pubkey: z.string().optional(), + ip: z.string().optional(), + }) + .strict() diff --git a/src/utils/admin-moderation-persist.ts b/src/utils/admin-moderation-persist.ts new file mode 100644 index 000000000..ddfcc3dc5 --- /dev/null +++ b/src/utils/admin-moderation-persist.ts @@ -0,0 +1,53 @@ +import { Request } from 'express' + +import { OperatorNotificationEventType } from '../@types/operator-notifications' +import { INotificationOutboxRepository } from '../@types/repositories' +import { createLogger } from '../factories/logger-factory' +import { appendSettingsAuditLog } from './settings-config' +import { recordAdminAuditFromRequest } from './record-admin-audit' +import { AdminModerationLimits, persistAdminModerationLimits, ValidationError } from './admin-moderation' + +const logger = createLogger('admin-moderation-persist') + +const MODERATION_SETTINGS_PATHS = [ + 'limits.event.pubkey.blacklist', + 'limits.event.pubkey.whitelist', + 'limits.event.kind.blacklist', + 'limits.event.kind.whitelist', + 'limits.event.whitelists.pubkeys', + 'limits.event.whitelists.ipAddresses', +] as const + +export const saveModerationLimitsFromRequest = async ( + limits: AdminModerationLimits, + request: Request, + notificationOutboxRepository: INotificationOutboxRepository, + auditAction: 'moderation.limits.updated' | 'moderation.action', + auditDetail?: Record, +): Promise => { + try { + persistAdminModerationLimits(limits) + } catch (error) { + if (error instanceof ValidationError) { + throw error + } + throw error + } + + const changes = MODERATION_SETTINGS_PATHS.map((path) => ({ path, reload: 'hot-reload' as const })) + appendSettingsAuditLog({ + action: 'settings.updated', + changes, + remoteAddress: request.ip, + }) + await recordAdminAuditFromRequest(auditAction, request, auditDetail) + + try { + await notificationOutboxRepository.enqueue(OperatorNotificationEventType.SETTINGS_CHANGED, { + changes, + remoteAddress: request.ip, + }) + } catch (error) { + logger.error('Unable to enqueue moderation settings notification outbox event', error) + } +} diff --git a/src/utils/admin-moderation-stats.ts b/src/utils/admin-moderation-stats.ts new file mode 100644 index 000000000..97cbed21e --- /dev/null +++ b/src/utils/admin-moderation-stats.ts @@ -0,0 +1,86 @@ +import { Knex } from 'knex' + +export const ADMIN_MODERATION_STATS_DEFAULT_WINDOW_HOURS = 168 +export const ADMIN_MODERATION_STATS_MAX_WINDOW_HOURS = 720 +export const ADMIN_MODERATION_STATS_QUERY_TIMEOUT_MS = 5000 + +export interface ModerationPublisherRow { + pubkey: string + eventCount: number +} + +export interface ModerationClientIpRow { + ip: string + eventCount: number +} + +export const parseModerationStatsWindowHours = (raw: unknown): { hours?: number; error?: string } => { + if (raw === undefined || raw === '') { + return { hours: ADMIN_MODERATION_STATS_DEFAULT_WINDOW_HOURS } + } + + const hours = Number(raw) + if (!Number.isInteger(hours) || hours < 1 || hours > ADMIN_MODERATION_STATS_MAX_WINDOW_HOURS) { + return { + error: `windowHours must be an integer between 1 and ${ADMIN_MODERATION_STATS_MAX_WINDOW_HOURS}`, + } + } + + return { hours } +} + +export const parseModerationStatsLimit = (raw: unknown): number => { + if (raw === undefined || raw === '') { + return 20 + } + + const limit = Number(raw) + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + return 20 + } + + return limit +} + +export const queryTopPublishingPubkeys = async ( + db: Knex, + sinceUnixSeconds: number, + limit: number, +): Promise => { + const result = await db('events') + .select(db.raw(`encode(event_pubkey, 'hex') as pubkey`)) + .count('* as eventCount') + .whereNull('deleted_at') + .where('event_created_at', '>=', sinceUnixSeconds) + .groupBy('event_pubkey') + .orderBy('eventCount', 'desc') + .limit(limit) + .timeout(ADMIN_MODERATION_STATS_QUERY_TIMEOUT_MS, { cancel: true }) + + return (result as unknown as Array<{ pubkey: string; eventCount: string | number }>).map((row) => ({ + pubkey: String(row.pubkey), + eventCount: Number(row.eventCount ?? 0), + })) +} + +export const queryTopClientIps = async ( + db: Knex, + sinceUnixSeconds: number, + limit: number, +): Promise => { + const result = await db('events') + .select(db.raw(`host(remote_address) as ip`)) + .count('* as eventCount') + .whereNull('deleted_at') + .whereNotNull('remote_address') + .where('event_created_at', '>=', sinceUnixSeconds) + .groupBy('remote_address') + .orderBy('eventCount', 'desc') + .limit(limit) + .timeout(ADMIN_MODERATION_STATS_QUERY_TIMEOUT_MS, { cancel: true }) + + return (result as unknown as Array<{ ip: string; eventCount: string | number }>).map((row) => ({ + ip: String(row.ip), + eventCount: Number(row.eventCount ?? 0), + })) +} diff --git a/src/utils/admin-moderation.ts b/src/utils/admin-moderation.ts new file mode 100644 index 000000000..9020cb8fd --- /dev/null +++ b/src/utils/admin-moderation.ts @@ -0,0 +1,279 @@ +import { isIP } from 'node:net' +import { mergeDeepRight } from 'ramda' + +import { Settings } from '../@types/settings' +import { isHexPubkey, parseRelayPubkey } from './nip43-invites' +import { + loadDefaults, + loadMergedSettings, + loadUserSettings, + saveSettings, + validateSettings, +} from './settings-config' + +export interface AdminModerationLimits { + pubkey: { + blacklist: string[] + whitelist: string[] + } + kind: { + blacklist: NonNullable['event']>['kind']['blacklist'] + whitelist: NonNullable['event']>['kind']['whitelist'] + } + rateLimitBypass: { + pubkeys: string[] + ipAddresses: string[] + } +} + +export type AdminModerationAction = + | 'ban-pubkey' + | 'unban-pubkey' + | 'whitelist-pubkey' + | 'unwhitelist-pubkey' + | 'whitelist-ip' + | 'unwhitelist-ip' + +const normalizePubkeyList = (values: string[], label: string): string[] => { + const normalized: string[] = [] + const seen = new Set() + + for (const raw of values) { + const pubkey = parseRelayPubkey(raw) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error(`${label} must contain 64-character hex pubkeys or npubs`) + } + + if (!seen.has(pubkey)) { + seen.add(pubkey) + normalized.push(pubkey) + } + } + + return normalized +} + +const normalizeIpList = (values: string[], label: string): string[] => { + const normalized: string[] = [] + const seen = new Set() + + for (const raw of values) { + const ip = raw.trim() + if (!ip || isIP(ip) === 0) { + throw new Error(`${label} must contain valid IPv4 or IPv6 addresses`) + } + + if (!seen.has(ip)) { + seen.add(ip) + normalized.push(ip) + } + } + + return normalized +} + +export const readAdminModerationLimits = (): AdminModerationLimits => { + const settings = loadMergedSettings() + const eventLimits = settings.limits?.event + + return { + pubkey: { + blacklist: [...(eventLimits?.pubkey?.blacklist ?? [])], + whitelist: [...(eventLimits?.pubkey?.whitelist ?? [])], + }, + kind: { + blacklist: [...(eventLimits?.kind?.blacklist ?? [])], + whitelist: [...(eventLimits?.kind?.whitelist ?? [])], + }, + rateLimitBypass: { + pubkeys: [...(eventLimits?.whitelists?.pubkeys ?? [])], + ipAddresses: [...(eventLimits?.whitelists?.ipAddresses ?? [])], + }, + } +} + +const applyLimitsToUserSettings = (userSettings: Settings, limits: AdminModerationLimits): Settings => { + return { + ...userSettings, + limits: { + ...userSettings.limits, + event: { + ...userSettings.limits?.event, + pubkey: { + ...userSettings.limits?.event?.pubkey, + blacklist: limits.pubkey.blacklist, + whitelist: limits.pubkey.whitelist, + }, + kind: { + ...userSettings.limits?.event?.kind, + blacklist: limits.kind.blacklist, + whitelist: limits.kind.whitelist, + }, + whitelists: { + ...userSettings.limits?.event?.whitelists, + pubkeys: limits.rateLimitBypass.pubkeys, + ipAddresses: limits.rateLimitBypass.ipAddresses, + }, + }, + }, + } +} + +export const persistAdminModerationLimits = (next: AdminModerationLimits): void => { + const normalized: AdminModerationLimits = { + pubkey: { + blacklist: normalizePubkeyList(next.pubkey.blacklist, 'pubkey blacklist'), + whitelist: normalizePubkeyList(next.pubkey.whitelist, 'pubkey whitelist'), + }, + kind: { + blacklist: [...next.kind.blacklist], + whitelist: [...next.kind.whitelist], + }, + rateLimitBypass: { + pubkeys: normalizePubkeyList(next.rateLimitBypass.pubkeys, 'rate-limit bypass pubkeys'), + ipAddresses: normalizeIpList(next.rateLimitBypass.ipAddresses, 'rate-limit bypass IP list'), + }, + } + + const userSettings = loadUserSettings() as Settings + const nextUserSettings = applyLimitsToUserSettings(userSettings, normalized) + const mergedNext = mergeDeepRight(loadDefaults(), nextUserSettings) as Settings + + const issues = validateSettings(mergedNext) + if (issues.length > 0) { + throw new ValidationError('Validation failed', issues) + } + + saveSettings(nextUserSettings) +} + +export class ValidationError extends Error { + public constructor( + message: string, + public readonly issues: Array<{ path: string; message: string }>, + ) { + super(message) + this.name = 'ValidationError' + } +} + +export type AdminModerationLimitsPatch = { + pubkey?: Partial + kind?: Partial + rateLimitBypass?: Partial +} + +export const mergeAdminModerationLimitsPatch = ( + current: AdminModerationLimits, + patch: AdminModerationLimitsPatch, +): AdminModerationLimits => ({ + pubkey: { + blacklist: patch.pubkey?.blacklist ?? current.pubkey.blacklist, + whitelist: patch.pubkey?.whitelist ?? current.pubkey.whitelist, + }, + kind: { + blacklist: patch.kind?.blacklist ?? current.kind.blacklist, + whitelist: patch.kind?.whitelist ?? current.kind.whitelist, + }, + rateLimitBypass: { + pubkeys: patch.rateLimitBypass?.pubkeys ?? current.rateLimitBypass.pubkeys, + ipAddresses: patch.rateLimitBypass?.ipAddresses ?? current.rateLimitBypass.ipAddresses, + }, +}) + +const withoutValue = (list: T[], value: T): T[] => list.filter((entry) => entry !== value) + +export const applyAdminModerationAction = ( + current: AdminModerationLimits, + action: AdminModerationAction, + fields: { pubkey?: string; ip?: string }, +): AdminModerationLimits => { + switch (action) { + case 'ban-pubkey': { + const pubkey = parseRelayPubkey(fields.pubkey) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error('pubkey is required and must be a 64-character hex pubkey or npub') + } + return { + ...current, + pubkey: { + blacklist: [...new Set([...current.pubkey.blacklist.map((p) => p.toLowerCase()), pubkey])], + whitelist: current.pubkey.whitelist, + }, + } + } + case 'unban-pubkey': { + const pubkey = parseRelayPubkey(fields.pubkey) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error('pubkey is required and must be a 64-character hex pubkey or npub') + } + return { + ...current, + pubkey: { + ...current.pubkey, + blacklist: withoutValue( + current.pubkey.blacklist.map((p) => p.toLowerCase()), + pubkey, + ), + }, + } + } + case 'whitelist-pubkey': { + const pubkey = parseRelayPubkey(fields.pubkey) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error('pubkey is required and must be a 64-character hex pubkey or npub') + } + return { + ...current, + rateLimitBypass: { + ...current.rateLimitBypass, + pubkeys: [...new Set([...current.rateLimitBypass.pubkeys.map((p) => p.toLowerCase()), pubkey])], + }, + } + } + case 'unwhitelist-pubkey': { + const pubkey = parseRelayPubkey(fields.pubkey) + if (!pubkey || !isHexPubkey(pubkey)) { + throw new Error('pubkey is required and must be a 64-character hex pubkey or npub') + } + return { + ...current, + rateLimitBypass: { + ...current.rateLimitBypass, + pubkeys: withoutValue( + current.rateLimitBypass.pubkeys.map((p) => p.toLowerCase()), + pubkey, + ), + }, + } + } + case 'whitelist-ip': { + const ip = fields.ip?.trim() + if (!ip || isIP(ip) === 0) { + throw new Error('ip is required and must be a valid IPv4 or IPv6 address') + } + return { + ...current, + rateLimitBypass: { + ...current.rateLimitBypass, + ipAddresses: [...new Set([...current.rateLimitBypass.ipAddresses, ip])], + }, + } + } + case 'unwhitelist-ip': { + const ip = fields.ip?.trim() + if (!ip || isIP(ip) === 0) { + throw new Error('ip is required and must be a valid IPv4 or IPv6 address') + } + return { + ...current, + rateLimitBypass: { + ...current.rateLimitBypass, + ipAddresses: withoutValue(current.rateLimitBypass.ipAddresses, ip), + }, + } + } + default: + throw new Error('Unsupported moderation action') + } +} diff --git a/src/utils/moderation-rate-limit-stats.ts b/src/utils/moderation-rate-limit-stats.ts new file mode 100644 index 000000000..7933933c0 --- /dev/null +++ b/src/utils/moderation-rate-limit-stats.ts @@ -0,0 +1,140 @@ +import { randomBytes } from 'node:crypto' + +import { ICacheAdapter } from '../@types/adapters' +import { createLogger } from '../factories/logger-factory' + +const logger = createLogger('moderation-rate-limit-stats') + +const PUBKEY_HITS_PREFIX = 'admin:moderation:rate-limit:pubkeys' +const IP_HITS_PREFIX = 'admin:moderation:rate-limit:ips' +const STATS_RETENTION_DAYS = 7 + +const INCREMENT_BUCKET_SCRIPT = ` +redis.call('ZINCRBY', KEYS[1], 1, ARGV[1]) +redis.call('EXPIRE', KEYS[1], ARGV[2]) +return 1 +` + +const AGGREGATE_TOP_SCRIPT = ` +local dest = KEYS[1] +local sourceCount = #KEYS - 1 +if sourceCount < 1 then + return {} +end +redis.call('ZUNIONSTORE', dest, sourceCount, unpack(KEYS, 2)) +local rows = redis.call('ZREVRANGE', dest, 0, ARGV[1], 'WITHSCORES') +redis.call('DEL', dest) +return rows +` + +export interface ModerationRateLimitHitRow { + key: string + hits: number +} + +const hasEval = (cache: ICacheAdapter): cache is ICacheAdapter & { eval: ICacheAdapter['eval'] } => + typeof (cache as { eval?: unknown }).eval === 'function' + +export const utcDayBucketSuffix = (unixMs: number): string => { + const date = new Date(unixMs) + const year = date.getUTCFullYear() + const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const day = String(date.getUTCDate()).padStart(2, '0') + return `${year}${month}${day}` +} + +const bucketKey = (prefix: string, unixMs: number): string => `${prefix}:${utcDayBucketSuffix(unixMs)}` + +const bucketTtlSeconds = (): number => STATS_RETENTION_DAYS * 86400 + +const utcDayStartMs = (unixMs: number): number => { + const date = new Date(unixMs) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} + +/** UTC day buckets that overlap the rolling window (not just the last N calendar days from now). */ +const listDayBucketSuffixes = (windowHours: number, nowMs = Date.now()): string[] => { + const clampedWindowHours = Math.min(Math.max(Math.floor(windowHours), 1), STATS_RETENTION_DAYS * 24) + const windowStartMs = nowMs - clampedWindowHours * 3600000 + const retentionStartMs = nowMs - STATS_RETENTION_DAYS * 86400000 + + let cursorMs = utcDayStartMs(Math.max(windowStartMs, retentionStartMs)) + const endDayMs = utcDayStartMs(nowMs) + const suffixes: string[] = [] + + while (cursorMs <= endDayMs) { + suffixes.push(utcDayBucketSuffix(cursorMs)) + cursorMs += 86400000 + } + + return suffixes +} + +export const recordModerationRateLimitHitSafely = async ( + cache: ICacheAdapter | undefined, + fields: { pubkey: string; ip?: string }, +): Promise => { + if (!cache || !hasEval(cache)) { + return + } + + const bucket = bucketKey(PUBKEY_HITS_PREFIX, Date.now()) + const ttl = String(bucketTtlSeconds()) + + try { + await cache.eval(INCREMENT_BUCKET_SCRIPT, [bucket], [fields.pubkey.toLowerCase(), ttl]) + if (fields.ip) { + const ipBucket = bucketKey(IP_HITS_PREFIX, Date.now()) + await cache.eval(INCREMENT_BUCKET_SCRIPT, [ipBucket], [fields.ip, ttl]) + } + } catch (error) { + logger.warn('failed to record moderation rate-limit hit: %o', error) + } +} + +const parseBucketRows = (result: unknown): ModerationRateLimitHitRow[] => { + if (!Array.isArray(result) || !result.length) { + return [] + } + + const rows: ModerationRateLimitHitRow[] = [] + for (let index = 0; index < result.length; index += 2) { + rows.push({ key: String(result[index]), hits: Number(result[index + 1] ?? 0) }) + } + return rows +} + +const fetchAggregatedHits = async ( + cache: ICacheAdapter, + prefix: string, + limit: number, + windowHours: number, + nowMs = Date.now(), +): Promise => { + const clamped = Math.min(Math.max(Math.floor(limit), 1), 100) + const suffixes = listDayBucketSuffixes(windowHours, nowMs) + if (!suffixes.length) { + return [] + } + + const destKey = `${prefix}:__agg:${randomBytes(8).toString('hex')}` + const sourceKeys = suffixes.map((suffix) => `${prefix}:${suffix}`) + const result = await cache.eval(AGGREGATE_TOP_SCRIPT, [destKey, ...sourceKeys], [String(clamped - 1)]) + + return parseBucketRows(result).slice(0, clamped) +} + +/** Top rate-limit hits in Redis daily buckets within the requested window. */ +export const listModerationRateLimitHits = async ( + cache: ICacheAdapter, + limit: number, + windowHours = STATS_RETENTION_DAYS * 24, + nowMs = Date.now(), +): Promise<{ pubkeys: ModerationRateLimitHitRow[]; ips: ModerationRateLimitHitRow[] }> => { + const [pubkeys, ips] = await Promise.all([ + fetchAggregatedHits(cache, PUBKEY_HITS_PREFIX, limit, windowHours, nowMs), + fetchAggregatedHits(cache, IP_HITS_PREFIX, limit, windowHours, nowMs), + ]) + + return { pubkeys, ips } +} diff --git a/test/unit/controllers/admin/apply-moderation-actions-controller.spec.ts b/test/unit/controllers/admin/apply-moderation-actions-controller.spec.ts new file mode 100644 index 000000000..0f925cbc8 --- /dev/null +++ b/test/unit/controllers/admin/apply-moderation-actions-controller.spec.ts @@ -0,0 +1,96 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { PostAdminModerationActionsController } from '../../../../src/controllers/admin/post-moderation-actions-controller' +import { ValidationError } from '../../../../src/utils/admin-moderation' +import * as adminModeration from '../../../../src/utils/admin-moderation' +import * as adminModerationPersist from '../../../../src/utils/admin-moderation-persist' + +chai.use(sinonChai) + +const { expect } = chai + +const baseLimits = { + pubkey: { blacklist: [], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: [] }, +} + +describe('PostAdminModerationActionsController', () => { + let sandbox: Sinon.SinonSandbox + let outboxRepository: { enqueue: Sinon.SinonStub } + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(baseLimits) + sandbox.stub(adminModerationPersist, 'saveModerationLimitsFromRequest').resolves() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns 400 for invalid request bodies', async () => { + const controller = new PostAdminModerationActionsController(outboxRepository as any) + await controller.handleRequest({ body: { action: 'not-real' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send).to.have.been.calledOnceWithExactly({ error: 'Invalid request' }) + expect(adminModerationPersist.saveModerationLimitsFromRequest).to.not.have.been.called + }) + + it('returns 400 when action fields are invalid', async () => { + const controller = new PostAdminModerationActionsController(outboxRepository as any) + await controller.handleRequest({ body: { action: 'ban-pubkey' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send.firstCall.args[0].error).to.be.a('string') + expect(adminModerationPersist.saveModerationLimitsFromRequest).to.not.have.been.called + }) + + it('returns 400 when persistence validation fails', async () => { + sandbox.restore() + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(baseLimits) + sandbox + .stub(adminModerationPersist, 'saveModerationLimitsFromRequest') + .rejects(new ValidationError('Validation failed', [{ path: 'limits.event.pubkey.blacklist', message: 'bad' }])) + + const controller = new PostAdminModerationActionsController(outboxRepository as any) + await controller.handleRequest( + { body: { action: 'ban-pubkey', pubkey: 'b'.repeat(64) }, ip: '127.0.0.1' } as any, + response as any, + ) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send.firstCall.args[0].error).to.equal('Validation failed') + }) + + it('applies ban-pubkey and persists limits', async () => { + const controller = new PostAdminModerationActionsController(outboxRepository as any) + const pubkey = 'c'.repeat(64) + await controller.handleRequest( + { body: { action: 'ban-pubkey', pubkey }, ip: '127.0.0.1' } as any, + response as any, + ) + + expect(adminModerationPersist.saveModerationLimitsFromRequest).to.have.been.calledOnce + expect(response.status).to.have.been.calledOnceWithExactly(200) + expect(response.send.firstCall.args[0].ok).to.equal(true) + expect(response.send.firstCall.args[0].limits.pubkey.blacklist).to.include(pubkey) + }) +}) diff --git a/test/unit/controllers/admin/get-moderation-limits-controller.spec.ts b/test/unit/controllers/admin/get-moderation-limits-controller.spec.ts new file mode 100644 index 000000000..515667db0 --- /dev/null +++ b/test/unit/controllers/admin/get-moderation-limits-controller.spec.ts @@ -0,0 +1,44 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { GetAdminModerationLimitsController } from '../../../../src/controllers/admin/get-moderation-limits-controller' +import * as adminModeration from '../../../../src/utils/admin-moderation' + +chai.use(sinonChai) + +const { expect } = chai + +describe('GetAdminModerationLimitsController', () => { + let sandbox: Sinon.SinonSandbox + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + const limits = { + pubkey: { blacklist: ['a'.repeat(64)], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: ['127.0.0.1'] }, + } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(limits) + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns current moderation limits', async () => { + const controller = new GetAdminModerationLimitsController() + await controller.handleRequest({} as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(200) + expect(response.setHeader).to.have.been.calledWith('content-type', 'application/json') + expect(response.send).to.have.been.calledOnceWithExactly({ limits }) + }) +}) diff --git a/test/unit/controllers/admin/get-moderation-stats-controller.spec.ts b/test/unit/controllers/admin/get-moderation-stats-controller.spec.ts new file mode 100644 index 000000000..66c90223c --- /dev/null +++ b/test/unit/controllers/admin/get-moderation-stats-controller.spec.ts @@ -0,0 +1,134 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import * as RedisAdapterModule from '../../../../src/adapters/redis-adapter' +import { GetAdminModerationStatsController } from '../../../../src/controllers/admin/get-moderation-stats-controller' +import * as cacheClient from '../../../../src/cache/client' +import * as dbClient from '../../../../src/database/client' +import { + parseModerationStatsLimit, + parseModerationStatsWindowHours, +} from '../../../../src/utils/admin-moderation-stats' +import * as adminModerationStats from '../../../../src/utils/admin-moderation-stats' +import * as moderationRateLimitStats from '../../../../src/utils/moderation-rate-limit-stats' + +chai.use(sinonChai) + +const { expect } = chai + +describe('GetAdminModerationStatsController', () => { + let sandbox: Sinon.SinonSandbox + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(dbClient, 'getReadReplicaDbClient').returns({} as any) + sandbox.stub(adminModerationStats, 'queryTopPublishingPubkeys').resolves([ + { pubkey: 'd'.repeat(64), eventCount: 4 }, + ]) + sandbox.stub(adminModerationStats, 'queryTopClientIps').resolves([{ ip: '127.0.0.1', eventCount: 4 }]) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns stats without rate-limit hits when cache is not configured', async () => { + sandbox.stub(cacheClient, 'isCacheConfigured').returns(false) + + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: { windowHours: '168', limit: '10' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(200) + const payload = response.send.firstCall.args[0] + expect(payload.windowHours).to.equal(168) + expect(payload.topPublishingPubkeys).to.have.length(1) + expect(payload.rateLimitHits).to.deep.equal({ pubkeys: [], ips: [] }) + }) + + it('includes rate-limit hits when cache is configured', async () => { + sandbox.stub(cacheClient, 'isCacheConfigured').returns(true) + sandbox.stub(cacheClient, 'getCacheClient').returns({} as any) + sandbox.stub(RedisAdapterModule, 'RedisAdapter').callsFake(() => ({} as any)) + sandbox.stub(moderationRateLimitStats, 'listModerationRateLimitHits').resolves({ + pubkeys: [{ key: 'e'.repeat(64), hits: 2 }], + ips: [{ key: '127.0.0.1', hits: 2 }], + }) + + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: {} } as any, response as any) + + expect(moderationRateLimitStats.listModerationRateLimitHits).to.have.been.calledOnce + expect(response.send.firstCall.args[0].rateLimitHits.pubkeys).to.have.length(1) + }) + + it('returns 400 for invalid windowHours', async () => { + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: { windowHours: '0' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(adminModerationStats.queryTopPublishingPubkeys).to.not.have.been.called + }) + + + it('returns 500 when stats queries fail unexpectedly', async () => { + sandbox.restore() + sandbox = Sinon.createSandbox() + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(dbClient, 'getReadReplicaDbClient').returns({} as any) + sandbox.stub(cacheClient, 'isCacheConfigured').returns(false) + sandbox.stub(adminModerationStats, 'queryTopPublishingPubkeys').rejects(new Error('db exploded')) + sandbox.stub(adminModerationStats, 'queryTopClientIps').resolves([]) + + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: { windowHours: '24' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(500) + }) + it('returns 504 when stats queries time out', async () => { + sandbox.restore() + sandbox = Sinon.createSandbox() + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(dbClient, 'getReadReplicaDbClient').returns({} as any) + sandbox.stub(cacheClient, 'isCacheConfigured').returns(false) + sandbox.stub(adminModerationStats, 'queryTopPublishingPubkeys').rejects(new Error('Query timeout')) + sandbox.stub(adminModerationStats, 'queryTopClientIps').resolves([]) + + const controller = new GetAdminModerationStatsController() + await controller.handleRequest({ query: { windowHours: '24' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(504) + }) +}) + +describe('parseModerationStatsWindowHours', () => { + it('defaults when windowHours is omitted', () => { + expect(parseModerationStatsWindowHours(undefined)).to.deep.equal({ hours: 168 }) + }) + + it('rejects out-of-range values', () => { + expect(parseModerationStatsWindowHours('0').error).to.be.a('string') + expect(parseModerationStatsWindowHours('9999').error).to.be.a('string') + }) +}) + +describe('parseModerationStatsLimit', () => { + it('defaults invalid limits to 20', () => { + expect(parseModerationStatsLimit(undefined)).to.equal(20) + expect(parseModerationStatsLimit('abc')).to.equal(20) + }) +}) diff --git a/test/unit/controllers/admin/patch-moderation-limits-controller.spec.ts b/test/unit/controllers/admin/patch-moderation-limits-controller.spec.ts new file mode 100644 index 000000000..654061799 --- /dev/null +++ b/test/unit/controllers/admin/patch-moderation-limits-controller.spec.ts @@ -0,0 +1,86 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { PatchAdminModerationLimitsController } from '../../../../src/controllers/admin/patch-moderation-limits-controller' +import { ValidationError } from '../../../../src/utils/admin-moderation' +import * as adminModeration from '../../../../src/utils/admin-moderation' +import * as adminModerationPersist from '../../../../src/utils/admin-moderation-persist' + +chai.use(sinonChai) + +const { expect } = chai + +const baseLimits = { + pubkey: { blacklist: [], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: [] }, +} + +describe('PatchAdminModerationLimitsController', () => { + let sandbox: Sinon.SinonSandbox + let outboxRepository: { enqueue: Sinon.SinonStub } + let response: { status: Sinon.SinonStub; setHeader: Sinon.SinonStub; send: Sinon.SinonStub } + + beforeEach(() => { + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(baseLimits) + sandbox.stub(adminModerationPersist, 'saveModerationLimitsFromRequest').resolves() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('returns 400 for invalid request bodies', async () => { + const controller = new PatchAdminModerationLimitsController(outboxRepository as any) + await controller.handleRequest({ body: { pubkey: 'not-an-object' } } as any, response as any) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send).to.have.been.calledOnceWithExactly({ error: 'Invalid request' }) + }) + + it('returns 400 when persistence validation fails', async () => { + sandbox.restore() + sandbox = Sinon.createSandbox() + outboxRepository = { enqueue: sandbox.stub().resolves() } + response = { + status: sandbox.stub().returnsThis(), + setHeader: sandbox.stub().returnsThis(), + send: sandbox.stub().returnsThis(), + } + sandbox.stub(adminModeration, 'readAdminModerationLimits').returns(baseLimits) + sandbox + .stub(adminModerationPersist, 'saveModerationLimitsFromRequest') + .rejects(new ValidationError('Validation failed', [{ path: 'limits.event.kind.whitelist', message: 'bad' }])) + + const controller = new PatchAdminModerationLimitsController(outboxRepository as any) + await controller.handleRequest( + { body: { kind: { whitelist: [1, 2] } }, ip: '127.0.0.1' } as any, + response as any, + ) + + expect(response.status).to.have.been.calledOnceWithExactly(400) + expect(response.send.firstCall.args[0].error).to.equal('Validation failed') + expect(response.send.firstCall.args[0].issues).to.be.an('array').that.is.not.empty + }) + + it('merges patches and persists limits', async () => { + const controller = new PatchAdminModerationLimitsController(outboxRepository as any) + const pubkey = 'c'.repeat(64) + await controller.handleRequest( + { body: { pubkey: { blacklist: [pubkey] } }, ip: '127.0.0.1' } as any, + response as any, + ) + + expect(adminModerationPersist.saveModerationLimitsFromRequest).to.have.been.calledOnce + expect(response.status).to.have.been.calledOnceWithExactly(200) + expect(response.send.firstCall.args[0].limits.pubkey.blacklist).to.deep.equal([pubkey]) + }) +}) diff --git a/test/unit/factories/admin-moderation-controller-factories.spec.ts b/test/unit/factories/admin-moderation-controller-factories.spec.ts new file mode 100644 index 000000000..c89bad59b --- /dev/null +++ b/test/unit/factories/admin-moderation-controller-factories.spec.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { createGetAdminModerationLimitsController } from '../../../src/factories/controllers/get-admin-moderation-limits-controller-factory' +import { createGetAdminModerationStatsController } from '../../../src/factories/controllers/get-admin-moderation-stats-controller-factory' +import { createPatchAdminModerationLimitsController } from '../../../src/factories/controllers/patch-admin-moderation-limits-controller-factory' +import * as dbClient from '../../../src/database/client' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { createPostAdminModerationActionsController } = require('../../../src/factories/controllers/post-admin-moderation-actions-controller-factory') + +describe('admin moderation controller factories', () => { + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + sandbox.stub(dbClient, 'getMasterDbClient').returns({} as any) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('creates moderation admin controllers', () => { + expect(createGetAdminModerationLimitsController()).to.have.property('handleRequest') + expect(createGetAdminModerationStatsController()).to.have.property('handleRequest') + expect(createPatchAdminModerationLimitsController()).to.have.property('handleRequest') + expect(createPostAdminModerationActionsController()).to.have.property('handleRequest') + }) +}) diff --git a/test/unit/utils/admin-moderation-persist.spec.ts b/test/unit/utils/admin-moderation-persist.spec.ts new file mode 100644 index 000000000..9ebd88906 --- /dev/null +++ b/test/unit/utils/admin-moderation-persist.spec.ts @@ -0,0 +1,61 @@ +import chai from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import * as adminModeration from '../../../src/utils/admin-moderation' +import { saveModerationLimitsFromRequest } from '../../../src/utils/admin-moderation-persist' +import * as recordAdminAudit from '../../../src/utils/record-admin-audit' +import * as settingsConfig from '../../../src/utils/settings-config' + +chai.use(sinonChai) + +const { expect } = chai + +const limits = { + pubkey: { blacklist: [], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: ['127.0.0.1'] }, +} + +describe('saveModerationLimitsFromRequest', () => { + let sandbox: Sinon.SinonSandbox + + beforeEach(() => { + sandbox = Sinon.createSandbox() + sandbox.stub(adminModeration, 'persistAdminModerationLimits') + sandbox.stub(settingsConfig, 'appendSettingsAuditLog') + sandbox.stub(recordAdminAudit, 'recordAdminAuditFromRequest').resolves() + }) + + afterEach(() => { + sandbox.restore() + }) + + it('persists limits, audits, and enqueues notification', async () => { + const enqueue = sandbox.stub().resolves() + await saveModerationLimitsFromRequest( + limits, + { ip: '127.0.0.1' } as any, + { enqueue } as any, + 'moderation.limits.updated', + ) + + expect(adminModeration.persistAdminModerationLimits).to.have.been.calledOnceWithExactly(limits) + expect(settingsConfig.appendSettingsAuditLog).to.have.been.calledOnce + expect(recordAdminAudit.recordAdminAuditFromRequest).to.have.been.calledOnce + expect(enqueue).to.have.been.calledOnce + }) + + it('still completes when notification enqueue fails', async () => { + const enqueue = sandbox.stub().rejects(new Error('db unavailable')) + await saveModerationLimitsFromRequest( + limits, + { ip: '127.0.0.1' } as any, + { enqueue } as any, + 'moderation.action', + { action: 'ban-pubkey' }, + ) + + expect(adminModeration.persistAdminModerationLimits).to.have.been.calledOnce + }) +}) diff --git a/test/unit/utils/admin-moderation-stats.spec.ts b/test/unit/utils/admin-moderation-stats.spec.ts new file mode 100644 index 000000000..86410a387 --- /dev/null +++ b/test/unit/utils/admin-moderation-stats.spec.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { + parseModerationStatsLimit, + parseModerationStatsWindowHours, + queryTopClientIps, + queryTopPublishingPubkeys, +} from '../../../src/utils/admin-moderation-stats' + +describe('admin-moderation-stats parsing', () => { + it('parses windowHours', () => { + expect(parseModerationStatsWindowHours('24')).to.deep.equal({ hours: 24 }) + expect(parseModerationStatsWindowHours('720').hours).to.equal(720) + }) + + it('parses limit with bounds', () => { + expect(parseModerationStatsLimit('15')).to.equal(15) + expect(parseModerationStatsLimit('200')).to.equal(20) + expect(parseModerationStatsLimit('0')).to.equal(20) + expect(parseModerationStatsLimit(undefined)).to.equal(20) + }) +}) + +describe('admin-moderation-stats queries', () => { + const buildQueryChain = (rows: unknown[]) => { + const chain: Record = {} + chain.select = Sinon.stub().returns(chain) + chain.count = Sinon.stub().returns(chain) + chain.whereNull = Sinon.stub().returns(chain) + chain.where = Sinon.stub().returns(chain) + chain.whereNotNull = Sinon.stub().returns(chain) + chain.groupBy = Sinon.stub().returns(chain) + chain.orderBy = Sinon.stub().returns(chain) + chain.limit = Sinon.stub().returns(chain) + chain.timeout = Sinon.stub().resolves(rows) + return chain + } + + it('maps top publishing pubkeys from the database', async () => { + const chain = buildQueryChain([{ pubkey: 'e'.repeat(64), eventCount: '7' }]) + const db = Sinon.stub().returns(chain) as any + db.raw = Sinon.stub().returns('raw') + + const rows = await queryTopPublishingPubkeys(db, 1_700_000_000, 5) + + expect(rows).to.deep.equal([{ pubkey: 'e'.repeat(64), eventCount: 7 }]) + expect(chain.limit.callCount).to.equal(1) + }) + + it('maps top client IPs from the database', async () => { + const chain = buildQueryChain([{ ip: '203.0.113.4', eventCount: 2 }]) + const db = Sinon.stub().returns(chain) as any + db.raw = Sinon.stub().returns('raw') + + const rows = await queryTopClientIps(db, 1_700_000_000, 10) + + expect(rows).to.deep.equal([{ ip: '203.0.113.4', eventCount: 2 }]) + expect(chain.whereNotNull.callCount).to.equal(1) + }) +}) diff --git a/test/unit/utils/admin-moderation.spec.ts b/test/unit/utils/admin-moderation.spec.ts new file mode 100644 index 000000000..ee202b0f9 --- /dev/null +++ b/test/unit/utils/admin-moderation.spec.ts @@ -0,0 +1,132 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { + applyAdminModerationAction, + mergeAdminModerationLimitsPatch, + persistAdminModerationLimits, + readAdminModerationLimits, +} from '../../../src/utils/admin-moderation' +import * as settingsConfig from '../../../src/utils/settings-config' + +describe('admin-moderation', () => { + const baseLimits = { + pubkey: { blacklist: [], whitelist: [] }, + kind: { blacklist: [], whitelist: [1] }, + rateLimitBypass: { pubkeys: [], ipAddresses: ['::1'] }, + } + + let previousConfigDir: string | undefined + let tempConfigDir: string + + before(() => { + previousConfigDir = process.env.NOSTR_CONFIG_DIR + tempConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-admin-moderation-')) + process.env.NOSTR_CONFIG_DIR = tempConfigDir + }) + + after(() => { + if (previousConfigDir === undefined) { + delete process.env.NOSTR_CONFIG_DIR + } else { + process.env.NOSTR_CONFIG_DIR = previousConfigDir + } + fs.rmSync(tempConfigDir, { recursive: true, force: true }) + }) + + beforeEach(() => { + const settings = settingsConfig.loadDefaults() as any + settings.limits.event.pubkey.blacklist = [] + settings.limits.event.pubkey.whitelist = [] + settings.limits.event.kind.blacklist = [] + settings.limits.event.kind.whitelist = [1] + settings.limits.event.whitelists = { pubkeys: [], ipAddresses: ['::1'] } + settings.info = { relay_url: 'wss://relay.example', name: 'test' } + settingsConfig.saveSettings(settings) + }) + + it('reads merged moderation limits', () => { + const limits = readAdminModerationLimits() + expect(limits.kind.whitelist).to.deep.equal([1]) + expect(limits.rateLimitBypass.ipAddresses).to.include('::1') + }) + + it('bans and unbans a pubkey', () => { + const pubkey = 'a'.repeat(64) + const banned = applyAdminModerationAction(baseLimits, 'ban-pubkey', { pubkey }) + expect(banned.pubkey.blacklist).to.include(pubkey) + + const unbanned = applyAdminModerationAction(banned, 'unban-pubkey', { pubkey }) + expect(unbanned.pubkey.blacklist).to.not.include(pubkey) + }) + + it('does not remove a banned pubkey from the event pubkey whitelist', () => { + const pubkey = 'a'.repeat(64) + const withWhitelist = { + ...baseLimits, + pubkey: { blacklist: [], whitelist: [pubkey] }, + } + const banned = applyAdminModerationAction(withWhitelist, 'ban-pubkey', { pubkey }) + expect(banned.pubkey.whitelist).to.deep.equal([pubkey]) + expect(banned.pubkey.blacklist).to.include(pubkey) + }) + + it('merges partial limit patches', () => { + const merged = mergeAdminModerationLimitsPatch(baseLimits, { + pubkey: { blacklist: ['b'.repeat(64)] }, + }) + expect(merged.pubkey.blacklist).to.have.length(1) + expect(merged.kind.whitelist).to.deep.equal([1]) + }) + + it('whitelists and removes rate-limit bypass pubkeys', () => { + const pubkey = 'd'.repeat(64) + const withPubkey = applyAdminModerationAction(baseLimits, 'whitelist-pubkey', { pubkey }) + expect(withPubkey.rateLimitBypass.pubkeys).to.include(pubkey) + + const withoutPubkey = applyAdminModerationAction(withPubkey, 'unwhitelist-pubkey', { pubkey }) + expect(withoutPubkey.rateLimitBypass.pubkeys).to.not.include(pubkey) + }) + + it('rejects invalid pubkeys and IPs for moderation actions', () => { + expect(() => applyAdminModerationAction(baseLimits, 'ban-pubkey', { pubkey: 'short' })).to.throw( + /64-character hex pubkey/, + ) + expect(() => applyAdminModerationAction(baseLimits, 'whitelist-ip', { ip: 'not-an-ip' })).to.throw(/valid IPv4/) + }) + + it('whitelists and removes rate-limit bypass for IPs', () => { + const withIp = applyAdminModerationAction(baseLimits, 'whitelist-ip', { ip: '203.0.113.10' }) + expect(withIp.rateLimitBypass.ipAddresses).to.include('203.0.113.10') + + const withoutIp = applyAdminModerationAction(withIp, 'unwhitelist-ip', { ip: '203.0.113.10' }) + expect(withoutIp.rateLimitBypass.ipAddresses).to.not.include('203.0.113.10') + }) + + it('persists normalized moderation limits to settings', () => { + const pubkey = 'c'.repeat(64) + persistAdminModerationLimits({ + ...baseLimits, + pubkey: { blacklist: [pubkey], whitelist: [] }, + }) + + const limits = readAdminModerationLimits() + expect(limits.pubkey.blacklist).to.include(pubkey) + }) + + it('persists when user settings only override part of info', () => { + settingsConfig.saveSettings({ info: { relay_url: 'wss://relay.example' } } as any) + const pubkey = 'f'.repeat(64) + + expect(() => + persistAdminModerationLimits({ + ...baseLimits, + pubkey: { blacklist: [pubkey], whitelist: [] }, + }), + ).to.not.throw() + + expect(readAdminModerationLimits().pubkey.blacklist).to.include(pubkey) + }) +}) diff --git a/test/unit/utils/moderation-rate-limit-stats.spec.ts b/test/unit/utils/moderation-rate-limit-stats.spec.ts new file mode 100644 index 000000000..a3c75a448 --- /dev/null +++ b/test/unit/utils/moderation-rate-limit-stats.spec.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai' +import Sinon from 'sinon' + +import { ICacheAdapter } from '../../../src/@types/adapters' +import { + listModerationRateLimitHits, + recordModerationRateLimitHitSafely, + utcDayBucketSuffix, +} from '../../../src/utils/moderation-rate-limit-stats' + +describe('moderation-rate-limit-stats', () => { + it('formats UTC day bucket suffixes', () => { + const suffix = utcDayBucketSuffix(Date.UTC(2026, 9, 23, 15, 30, 0)) + expect(suffix).to.equal('20261023') + }) + + it('skips recording when cache has no eval', async () => { + const cache = {} as ICacheAdapter + await recordModerationRateLimitHitSafely(cache, { pubkey: 'a'.repeat(64), ip: '127.0.0.1' }) + }) + + it('swallows redis eval failures when recording hits', async () => { + const evalStub = Sinon.stub().rejects(new Error('redis unavailable')) + const cache = { eval: evalStub } as unknown as ICacheAdapter + + await recordModerationRateLimitHitSafely(cache, { pubkey: 'a'.repeat(64), ip: '127.0.0.1' }) + + expect(evalStub.callCount).to.be.greaterThan(0) + }) + + it('records pubkey and ip hits via eval', async () => { + const evalStub = Sinon.stub().resolves(1) + const cache = { eval: evalStub } as unknown as ICacheAdapter + + await recordModerationRateLimitHitSafely(cache, { pubkey: 'A'.repeat(64), ip: '127.0.0.1' }) + + expect(evalStub.callCount).to.equal(2) + expect(evalStub.firstCall.args[1][0]).to.match(/^admin:moderation:rate-limit:pubkeys:\d{8}$/) + expect(evalStub.firstCall.args[2][0]).to.equal('a'.repeat(64)) + }) + + it('aggregates every UTC day bucket touched by a 24h rolling window', async () => { + const evalStub = Sinon.stub().resolves([]) + const cache = { eval: evalStub } as unknown as ICacheAdapter + const nowMs = Date.UTC(2026, 9, 24, 15, 0, 0) + + await listModerationRateLimitHits(cache, 5, 24, nowMs) + + const dayBuckets = (evalStub.firstCall.args[1] as string[]).slice(1) + expect(dayBuckets).to.have.length(2) + expect(dayBuckets[0]).to.match(/:20261023$/) + expect(dayBuckets[1]).to.match(/:20261024$/) + }) + + it('lists aggregated top hits from redis eval', async () => { + const evalStub = Sinon.stub() + evalStub.onFirstCall().resolves(['pubkey-a', '3']) + evalStub.onSecondCall().resolves(['127.0.0.1', '2']) + const cache = { eval: evalStub } as unknown as ICacheAdapter + + const result = await listModerationRateLimitHits(cache, 5, 24) + + expect(result.pubkeys).to.deep.equal([{ key: 'pubkey-a', hits: 3 }]) + expect(result.ips).to.deep.equal([{ key: '127.0.0.1', hits: 2 }]) + expect(evalStub.callCount).to.equal(2) + }) +})