Skip to content

feat(deletions): let the relay owner delete any event - #133

Merged
barrydeen merged 1 commit into
masterfrom
feat/owner-deletions
Aug 24, 2026
Merged

barrydeen merged 1 commit into
masterfrom
feat/owner-deletions

Conversation

@barrydeen

Copy link
Copy Markdown
Owner

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

OwnerCanDeleteAnyEvent is wired into OverwriteDeletionOutcome on 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:

  • The tombstone is stored. The chat relay rejected kind 5 for not being chat related, and the inbox relay for tagging no whitelisted pubkey, so the delete request itself was dropped. The owner's delete requests now pass those two policies, and the blanket AUTH requirement in MustNotBeBlacklistedToPost — the event already carries the owner's signature, so there is nothing left for an AUTH round trip to prove.
  • Deleted events don't come back. MustNotBeDeleted rejects 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:

nak event -k 5 -t e=<event-id> --sec <owner-nsec> wss://your.relay/inbox

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:

  • owner deletes a whitelisted user's note on the outbox relay, a stranger's note on the inbox relay, and a stranger's kind 9 on the chat relay — each event is gone, each delete request is stored, and re-publishing returns blocked: this event has been deleted
  • owner deletes an addressable event (kind 30023) by a address
  • a stranger's delete request for somebody else's note is still refused and the note survives
  • authors can still delete their own events

go build, go vet and gofmt are clean.

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
barrydeen merged commit b3d2e2a into master Aug 24, 2026
1 check passed
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>
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