Repository navigation
feat(banlist): ban pubkeys with a kind 10084 nostr list - #134
Merged
Merged
Conversation
Blacklisting a pubkey means editing a file and restarting the relay, and it only covers the chat and inbox relays. Add a ban list that lives on nostr instead: a NIP-51 style replaceable list of kind 10084 that the owner publishes to their own outbox relay, where every "p" tag is a pubkey that may not write to haven. The list is read from the outbox relay on startup and the cache is refreshed the moment the owner publishes a new version, so adding or removing a pubkey takes effect immediately. Only the owner's list counts, an older version never overwrites a newer one, and the owner is skipped when it is read so they can't lock themselves out. Banned pubkeys are rejected by all four relays and skipped on import. Delete requests are covered too: khatru handles those before any reject policy runs, so the ban is checked in the deletion outcome as well. Private (NIP-44 encrypted) list entries are not supported, since the relay has no key to decrypt them with.
barrydeen
added a commit
that referenced
this pull request
Aug 26, 2026
) ## Summary Adds the NIP-86 relay management API and the `/admin` console that drives it, then two new views on top: a read-only **notes browser** and an analytics **dashboard**. Also re-lands the kind 10084 ban list, and fixes an upstream `eventstore` bug that hangs the relay's `stats` call on the default database engine. > [!NOTE] > This includes the ban-list commit from #134. That PR was based on `feat/owner-deletions` and merged there *after* #133 had already gone to master, so the feature never actually reached master — `banlist.go` does not exist on the default branch today. This PR brings it along. ## Changes **Management API and console** - NIP-86 over `application/nostr+json+rpc` on each of the four relay URIs, authenticated per call with a NIP-98 event signed by the owner. Answered ahead of khatru, which reports auth failures as HTTP 200 where the NIP asks for 401 and has no way to tell the four relays apart. - Ban/allow pubkeys, ban events, block addresses, set kind rules, rename each relay, and manage the blossom store. State lives in `management.json` and is frozen read-only if that file cannot be parsed at boot. **Notes browser** — a read-only mini nostr client, per relay - `listevents` / `getevent` / `deleteevent` / `deleteevents`, scoped to the relay whose URI was signed. - Paged feed with kind chips, free client-side search/filter/sort, three densities, a detail drawer with tags and raw JSON, and delete. - Per-kind rendering: profiles, follow lists, reactions, deletions and long-form each render as themselves. Kinds 4/13/14/1059 render as a locked card — the relay cannot read them and the page does not pretend to. - Media served by this relay is inlined; anything hosted elsewhere stays behind a click, because fetching it tells that host the owner's IP address. **Dashboard** - Five stat tiles and seven charts: events over time, accepted vs rejected, requests, connections, kind mix, top authors, storage. - Traffic is counted, folded into hourly buckets every 15s and persisted, so history survives a restart. **Fix: `stats` hangs on lmdb** `eventstore` v0.17.5's lmdb `CountEvents` never advances its cursor once an event matches — both counting paths fall through to the top of the loop without calling `it.next()`. It loops forever on a goroutine that ignores the context. `DB_ENGINE` defaults to `lmdb`, and `eventCountCache.get` calls `CountEvents(Filter{})` on every database **while holding a mutex**. On a default-configured relay holding any events, the `stats` method and the admin statistics panel hang permanently on first use, pin a core, and block every later caller. ``` lmdb / filter matching nothing : returned 0 lmdb / empty filter : HUNG badger / empty filter : returned 5 ``` `countStoredEvents` now uses the backend's own counter where it terminates and a bounded walk where it does not. Two regression tests pin it. Worth reporting upstream. ## Technical details - **One call is one signature.** NIP-86 has no batching, so nothing fetches anything the user did not press a button for — no infinite scroll, no prefetch, no refetch when the relay switcher moves. The dashboard returns all three time ranges together so switching between them is free. - **No charting library is possible.** The admin page's CSP is `default-src 'none'` with `script-src 'self' https://cdn.tailwindcss.com`. Charts are hand-rolled inline SVG built with `createElementNS`; the page keeps its standing zero-`innerHTML` rule throughout. - **Author names cost nothing extra.** `connect-src 'self'` means the page cannot fetch a profile from anywhere, so `listevents` returns a `profiles` map resolved from locally stored kind 0 alongside the rows it belongs to. - **Event walking is shared.** `eachEvent` in `eventscan.go` generalises the blob-index walk and carries its hard-won rules: never leave `Limit` at zero (the backends drop it to `MaxLimit/4` rather than clamping), `Until` is inclusive so pages overlap and must be deduped, channels are unbuffered and never check the context so they must be drained, and a short page does not mean the end. `blossom.go` is refactored onto it. Its dedupe set is bounded by the busiest single second rather than by the database, which the previous version was not. - **`Filter.Search` is never set.** Both backends answer a filter carrying one by closing the channel with nothing in it, so passing it through would report an empty result as a real one. Content search is a bounded substring scan that reports how far it looked. - **Storage is measured from events, not the filesystem.** Badger preallocates sparse value logs; a directory walk reports 2.2 GB for a relay holding a handful of events. - **Deleting is not banning.** Haven holds no key, so no NIP-09 request goes out and the author can publish again. The API says so in the response and the confirm dialog says so to the operator. ## Testing Adds the repository's first tests — 23 of them, covering the event walker's cursor/dedupe logic, the listevents/search/delete paths, metrics persistence and corruption handling, and the management API end to end over HTTP including the owner check and a signature bound to a different service URL. Verified against a live relay running on **lmdb** with seeded events, driven in a browser: - `stats` returns in 6 ms where it previously hung forever - Dashboard load = exactly 1 signature; three range switches = 0 more, measured with an instrumented signer - Notes: 25 events rendered, author name resolved with no extra call, remote image gated, blossom blob inlined - Switching relay from Notes stays on Notes and offers a reload with its cost stated rather than spending a signature `go build`, `go vet`, `gofmt` and `node --check` on all four scripts are clean. To verify by hand: `./haven`, open `http://localhost:3355/admin`, sign in with a NIP-07 extension holding `OWNER_NPUB`. Blocking `cdn.tailwindcss.com` in devtools should leave both new views laid out and legible. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: barry <barry@pop-os.internal> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Blacklisting a pubkey means editing a JSON file and restarting the relay, and
MustNotBeBlacklistedToPostis only wired into the chat and inbox relays. There was no way to stop a pubkey from writing to Haven from a client, on the fly.Change
A ban list that lives on nostr: a NIP-51 style replaceable list of kind
10084that the owner publishes to their own outbox relay. Everyptag on it is a pubkey that may not write to the relay.loadBanListreads the latest list from the outbox relay before the relays start serving, andrefreshBanList(anOnEventSavedhook on the outbox relay) updates the cache the moment the owner publishes a new version, so an edit takes effect immediately with no restart. The cache is anatomic.Pointer, swapped whole.10084from anybody else is stored like any other event and ignored.MustNotBeBannedToPostis the first reject policy on all four relays and also checks the authenticated pubkey, which is what catches a banned user sending gift wraps under a throwaway key. The three import paths skip banned pubkeys like they skip blacklisted ones.A ban wins over whitelisting. Private (NIP-44 encrypted) list entries are not supported: the relay has no key to decrypt them with.
Note that kind
10084is not one of the list kinds NIP-51 assigns (its replaceable lists run 10000–10102, with 10084 unused); it is a Haven-specific list following the same shape.Testing
Ran a real instance with fresh databases and an isolated config, driven by a go-nostr client. 15/15 checks pass:
blocked: you are banned from this relay10084from a whitelisted user other than the owner has no effectThe delete-request suite from #133 was re-run against the same build and still passes.
go build,go vetandgofmtare clean.