Skip to content

feat(banlist): ban pubkeys with a kind 10084 nostr list - #134

Merged
barrydeen merged 1 commit into
feat/owner-deletionsfrom
feat/ban-list
Aug 24, 2026
Merged

barrydeen merged 1 commit into
feat/owner-deletionsfrom
feat/ban-list

Conversation

@barrydeen

Copy link
Copy Markdown
Owner

Stacked on #133 — the base is feat/owner-deletions, so this diff shows only the ban list. GitHub retargets it to master when #133 merges.

Problem

Blacklisting a pubkey means editing a JSON file and restarting the relay, and MustNotBeBlacklistedToPost is 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 10084 that the owner publishes to their own outbox relay. Every p tag on it is a pubkey that may not write to the relay.

nak event -k 10084 -t p=<pubkey> --sec <owner-nsec> wss://your.relay
  • Cached, not queried per event. loadBanList reads the latest list from the outbox relay before the relays start serving, and refreshBanList (an OnEventSaved hook 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 an atomic.Pointer, swapped whole.
  • Only the owner's list counts. A kind 10084 from anybody else is stored like any other event and ignored.
  • An older list never overwrites a newer one, so a replayed version can't quietly unban somebody.
  • The owner is skipped when the list is read, so you can't lock yourself out of your own relay.
  • Enforced everywhere. MustNotBeBannedToPost is 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.
  • Delete requests too. khatru handles kind 5 before any reject policy runs, so the ban is checked in the deletion outcome as well — otherwise a banned pubkey could still send delete requests.

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 10084 is 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:

  • a pubkey that could write before the ban is rejected by the private, chat, outbox and inbox relays afterwards, with blocked: you are banned from this relay
  • a banned pubkey can't delete its own events, and the event it targeted is still there
  • other pubkeys are unaffected
  • the ban survives a relay restart, i.e. the list is picked up from the outbox relay on boot
  • publishing an updated list unbans immediately, and re-publishing the older version does not re-ban
  • a kind 10084 from a whitelisted user other than the owner has no effect

The delete-request suite from #133 was re-run against the same build and still passes. go build, go vet and gofmt are clean.

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
barrydeen merged commit 3c2f6a7 into feat/owner-deletions Aug 24, 2026
1 check passed
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>
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.

1 participant