Repository navigation
feat(deletions): let the relay owner delete any event - #133
Merged
Merged
Conversation
khatru only accepts a NIP-09 delete request when the target was written by the same pubkey, so the owner had no way to remove somebody else's event from their own relay. Override that outcome on all four relays: authors keep deleting their own events, and the owner can delete anything stored on the relay it is sent to. Deletions also have to stick, which needs two more things: - The owner's delete request is now stored on the chat and inbox relays, which would otherwise reject a kind 5 for not being chat related or for tagging no whitelisted pubkey. It is signed by the owner, so it doesn't need an AUTH round trip either. - Deleted events are refused on re-publish and skipped on import. khatru checks this itself before storing an event, but its filters ask for the tag keys "#e" and "#a" where go-nostr expects "e" and "a", so they never match, and the import paths write to the databases directly.
barrydeen
added a commit
that referenced
this pull request
Aug 24, 2026
> 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](https://github.com/nostr-protocol/nips/blob/master/51.md) 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. ```sh 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.
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
khatru only honours a NIP-09 delete request when the target event was written by the same pubkey, and HAVEN never overrides that outcome. The relay owner therefore had no way to remove somebody else's event from their own relay — spam in the inbox, a chat message from a WoT member, a note from a co-whitelisted npub. The owner's kind 5 came back as
blocked: you are not the author of this event.Change
OwnerCanDeleteAnyEventis wired intoOverwriteDeletionOutcomeon all four relays. Authors keep deleting their own events; the owner (OWNER_NPUB) can additionally delete anything stored on the relay the request is sent to. Everybody else is unaffected.Making the deletion stick needs two more pieces:
MustNotBeBlacklistedToPost— the event already carries the owner's signature, so there is nothing left for an AUTH round trip to prove.MustNotBeDeletedrejects a re-publish, and the three import paths skip deleted events. khatru does intend to check this itself before storing an event, but its filters ask for the tag keys"#e"and"#a"where go-nostr expects"e"and"a", so they never match; the import paths (importOwnerNotes,importTaggedNotes,subscribeInboxAndChat) write to the databases directly and bypass the relay pipeline entirely. Without this a deleted inbox note reappears on the next pull from the seed relays.Only delete requests signed by an event's author or by the owner count as deletions, so nobody can tombstone an event the relay hasn't seen yet and block it from ever being stored.
Scope
A delete request applies to the relay endpoint that receives it (
/,/private,/chat,/inbox), since each has its own database — publish it to the endpoint that holds the event:Blossom blob deletion (BUD-02) is unchanged.
Testing
Ran a real instance with fresh databases and an isolated config, driven by a go-nostr client. 23/23 checks pass:
blocked: this event has been deletedaaddressgo build,go vetandgofmtare clean.