Skip to content

feat(admin): moderation tools for limits and activity stats - #941

Merged
cameri merged 11 commits into
mainfrom
feat/admin-moderation-634
Oct 24, 2026
Merged

cameri merged 11 commits into
mainfrom
feat/admin-moderation-634

Conversation

@Ferryx349

Copy link
Copy Markdown
Collaborator

Description

This PR adds Admin Console Moderation :- view/update pubkey & kind lists and rate-limit bypass lists, quick ban/unban and IP bypass actions, and 7-day stats (top publishers, client IPs, rate-limit hits). Persists limit changes to settings with audit logging.

Related Issue

Closes #634

Motivation and Context

How Has This Been Tested?

Screenshots (if appropriate):

Screen.Recording.2026-10-23.at.13.38.14.mov

Types of changes

  • Non-functional change (docs, style, minor refactor)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my code changes.
  • I added a changeset, or this is docs-only and I added an empty changeset.
  • All new and existing tests passed.

Closes #634. Adds admin API and Moderation console for pubkey/kind lists,
rate-limit bypass, ban/unban actions, and publisher/IP/rate-limit stats.
@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 48845e6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
nostream Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[High impact] The changes reach event publishing across the relay and activity data shown to admins.

Do not merge yet: concurrent moderation requests can still overwrite saved bans.

Findings

  1. P1 Concurrent saves undo bans ▶
  2. P2 Reversed kind ranges never match ▶
  3. P2 Returned limits differ from saves ▶
  4. P2 Recent uploads miss activity totals ▶
  5. P2 Valid IPv6 bypasses do nothing ▶

Summary

Adds moderation APIs and an Admin Console page.

  • Admins can change event limits from the Moderation view.
  • The Moderation view shows seven-day publishing and rate-limit activity.

Earlier unnumbered threads were manually resolved by Ferryx349 without explanatory replies.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Admin Console or API caller] --> B[Authenticated moderation routes]
  B --> C[Read limits]
  B --> D[Patch lists or apply action]
  D --> E[Normalize and check settings]
  E --> F[Save settings file]
  F --> G[Workers reload settings]
  F --> H[Audit logs and notification]
  B --> I[Read activity stats]
  I --> J[PostgreSQL event totals]
  I --> K[Redis daily hit buckets]
Loading

Reviews (11) · Last reviewed commit: "Merge branch 'main' into feat/admin-mode..." · Reviewed by Greptile

Comment thread src/utils/admin-moderation.ts Outdated
Comment thread test/unit/utils/admin-moderation.spec.ts
Comment thread src/utils/moderation-rate-limit-stats.ts Outdated
@coveralls

coveralls commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 40098699407

Coverage increased (+0.3%) to 75.733%

Details

  • Coverage increased (+0.3%) from the base build.
  • Patch coverage: 22 uncovered changes across 5 files (268 of 290 lines covered, 92.41%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/utils/admin-moderation.ts 79 69 87.34%
src/controllers/admin/patch-moderation-limits-controller.ts 21 17 80.95%
src/controllers/admin/post-moderation-actions-controller.ts 25 21 84.0%
src/utils/admin-moderation-persist.ts 19 16 84.21%
src/utils/moderation-rate-limit-stats.ts 62 61 98.39%
Total (15 files) 290 268 92.41%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 11576
Covered Lines: 9174
Line Coverage: 79.25%
Relevant Branches: 5983
Covered Branches: 4124
Branch Coverage: 68.93%
Branches in Coverage %: Yes
Coverage Strength: 786.83 hits per line

💛 - Coveralls

Keep pubkey whitelist on ban; use daily Redis buckets for rate-limit hits;
isolate moderation tests with temp NOSTR_CONFIG_DIR; skip stats when cache has no eval.
Comment thread src/utils/moderation-rate-limit-stats.ts Outdated
Use ZREVRANGE with the requested limit instead of scanning full daily sets.
Comment thread src/utils/moderation-rate-limit-stats.ts Outdated
Union day buckets in Redis, then return top N so multi-day totals stay correct.
Comment thread src/utils/moderation-rate-limit-stats.ts Outdated
Drop the temporary ZUNIONSTORE destination immediately so stats refresh
does not retain duplicate daily bucket copies in Redis.
Improve coverage for moderation limits, actions, stats, and rate-limit helpers.
Comment thread test/unit/utils/moderation-rate-limit-stats.spec.ts Outdated
greptile-apps[bot]
greptile-apps Bot previously approved these changes Oct 23, 2026
Use callCount in rate-limit stats tests, cover query helpers and factory wiring, and add error-path cases for moderation actions and stats.
@greptile-apps
greptile-apps Bot dismissed their stale review October 23, 2026 18:28

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Oct 23, 2026
Comment thread src/utils/admin-moderation.ts Outdated
Comment thread src/utils/moderation-rate-limit-stats.ts Outdated
…dows

Deep-merge defaults before validateSettings so partial info overrides work, and include every UTC day bucket touched by the requested stats window.
@greptile-apps
greptile-apps Bot dismissed their stale review October 24, 2026 03:45

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

Comment on lines +138 to +147
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent saves undo bans

On relays with multiple workers, two moderation actions can overwrite each other. Each request reads every list, changes its copy, then replaces all lists without a shared lock. If two workers read before either saves, banning different pubkeys leaves only the last ban, even though both requests succeed.

Lock the full read-change-save operation across workers and read the current lists inside that lock.

.select(db.raw(`encode(event_pubkey, 'hex') as pubkey`))
.count('* as eventCount')
.whereNull('deleted_at')
.where('event_created_at', '>=', sinceUnixSeconds)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Recent uploads miss activity totals

Both activity queries filter by event_created_at, which copies the publisher's created_at, rather than when the relay received the event. With the default maxNegativeDelta: 0, clients can upload older events today without appearing in today's activity totals. Admins can therefore miss recent heavy publishers and client IPs.

Use first_seen for the receipt window in both queries.

Comment on lines +250 to +259
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])],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Valid IPv6 bypasses do nothing

IP bypass actions accept any valid IPv6 spelling but save and remove it as an exact string. The socket uses SocketAddress, which returns a standard spelling, while the rate-limit check uses includes. Entering 2001:0db8:0:0:0:0:0:1 succeeds but does not match the client's 2001:db8::1. Another valid spelling can also fail to remove an existing bypass.

Normalize addresses the same way before adding, removing, and comparing them.

@@ -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()])])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Reversed kind ranges never match

kindRuleSchema accepts ranges such as [5000, 1000]. These pass validateSettings and are saved unchanged, but isEventKindOrRangeMatch can never match them. A blacklist rule silently does nothing; a whitelist containing only that range blocks every kind.

Check that both endpoints are within 0–65535 and that the start is no greater than the end.

Suggested change
const kindRuleSchema = z.union([z.number().int().min(0).max(65535), z.tuple([z.number().int(), z.number().int()])])
const kindValueSchema = z.number().int().min(0).max(65535)
const kindRuleSchema = z.union([
kindValueSchema,
z.tuple([kindValueSchema, kindValueSchema]).refine(([start, end]) => start <= end, {
message: 'Kind range start must not exceed its end',
}),
])

throw error
}

response.status(200).setHeader('content-type', 'application/json').send({ ok: true, limits: next })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Returned limits differ from saves

PATCH /moderation/limits returns the original next object, not the values actually saved. Uppercase hex pubkeys are saved lowercase, and duplicate entries are removed, but the response still contains the original values. Callers can display or keep settings that differ from a later GET.

Return the normalized saved limits instead.

Suggested change
response.status(200).setHeader('content-type', 'application/json').send({ ok: true, limits: next })
response.status(200).setHeader('content-type', 'application/json').send({ ok: true, limits: readAdminModerationLimits() })

@cameri
cameri merged commit 42ed39e into main Oct 24, 2026
14 of 16 checks passed
@cameri
cameri deleted the feat/admin-moderation-634 branch October 24, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Admin Console] Phase 4: Moderation Tools

3 participants