refactor(quartz): NIP-86 bans purge matching events via onBan(filter) hook

Nip86Server used to take an optional IEventStore and call store.delete()
only on banevent — banpubkey and disallowkind would update the BanStore
but leave existing matching events served by REQ. That's inconsistent
(an operator who bans a spam pubkey expects the spam to be gone) and
a real safety issue (banning a CSAM event wouldn't actually remove
existing copies if the store was null).

Replace the IEventStore param with onBan: suspend (Filter) -> Unit. The
dispatcher fires it after each ban method with the Filter that selects
the now-banned events:
  banpubkey   → Filter(authors = [pk])
  banevent    → Filter(ids = [id])
  disallowkind→ Filter(kinds = [k])

KtorRelay wires onBan = { f -> relay.store.delete(f) }, so the existing
SQLite delete-by-filter path handles every shape without the caller
having to know which field to populate. The default no-op keeps the
unit test for the dispatcher dependency-free.

withInt is now suspend so disallowKind's handler can call the suspending
onBan inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 16:55:38 -04:00
parent a010b37d6c
commit e4e9ae7043
3 changed files with 42 additions and 12 deletions
@@ -113,7 +113,12 @@ class KtorRelay(
}
}
private val nip86Server = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store)
private val nip86Server =
Nip86Server(
banStore = relay.banStore,
infoHolder = infoHolder,
onBan = { filter -> relay.store.delete(filter) },
)
private val nip86Route =
Nip86HttpRoute(
server = nip86Server,
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
@@ -46,8 +45,8 @@ import kotlinx.serialization.json.int
*
* Holds the [BanStore] (mutated by ban/allow methods), an [InfoHolder]
* for the live NIP-11 doc (mutated by `changerelay*` methods, which
* atomically swap it), and an optional [IEventStore] so `banevent`
* can also delete the offending event from the store.
* atomically swap it), and an [onBan] hook so each ban method can
* retroactively purge matching events from the relay's event store.
*
* Transport-agnostic — relay implementations call [dispatch] from
* whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`),
@@ -67,7 +66,23 @@ class Nip86Server(
* every request, not cache it.
*/
private val infoHolder: InfoHolder,
private val store: IEventStore? = null,
/**
* Called from `banpubkey` / `banevent` / `disallowkind` AFTER the
* [BanStore] has been updated, with a [Filter] selecting all events
* the operator just banned: respectively `Filter(authors=[pk])`,
* `Filter(ids=[id])`, `Filter(kinds=[k])`. Production callers wire
* this to `IEventStore.delete(filter)` so the existing offending
* events are removed from storage, not just blocked from re-ingest.
* Without it, a ban would only stop *future* matches while clients
* kept seeing whatever was already there — important for spam
* cleanup, and a real safety issue for `banevent` on illegal /
* leaked content.
*
* Defaults to a no-op so unit tests for the dispatcher don't need
* to stand up an event store. Production callers (e.g.
* `geode/KtorRelay`) wire it to `store.delete(filter)`.
*/
private val onBan: suspend (Filter) -> Unit = {},
) {
/** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */
interface InfoHolder {
@@ -108,7 +123,12 @@ class Nip86Server(
}
Nip86Method.BAN_PUBKEY -> {
withHexAndReason(req, "pubkey") { pk, reason -> banStore.banPubkey(pk, reason) }
withHexAndReason(req, "pubkey") { pk, reason ->
banStore.banPubkey(pk, reason)
// Purge everything the banned author had posted
// so REQ stops serving their history.
onBan(Filter(authors = listOf(pk)))
}
}
Nip86Method.UNBAN_PUBKEY -> {
@@ -134,8 +154,9 @@ class Nip86Server(
Nip86Method.BAN_EVENT -> {
withHexAndReason(req, "event_id") { id, reason ->
banStore.banEvent(id, reason)
// Also remove the event from the store if present.
store?.delete(Filter(ids = listOf(id)))
// Also remove the event from the relay's event
// store so REQ stops serving the offending copy.
onBan(Filter(ids = listOf(id)))
}
}
@@ -152,7 +173,11 @@ class Nip86Server(
}
Nip86Method.DISALLOW_KIND -> {
withInt(req, "kind") { k -> banStore.disallowKind(k) }
withInt(req, "kind") { k ->
banStore.disallowKind(k)
// Purge every event of the now-disallowed kind.
onBan(Filter(kinds = listOf(k)))
}
}
Nip86Method.LIST_ALLOWED_KINDS -> {
@@ -205,10 +230,10 @@ class Nip86Server(
return okTrue
}
private inline fun withInt(
private suspend inline fun withInt(
req: Nip86Request,
label: String,
action: (Int) -> Unit,
action: suspend (Int) -> Unit,
): Nip86Response {
val v = req.params.firstInt() ?: return malformed("expected [$label]")
action(v)
@@ -42,7 +42,7 @@ class Nip86ServerTest {
private fun fixture(): Triple<Nip86Server, BanStore, Holder> {
val store = BanStore()
val holder = Holder(Nip11RelayInformation(name = "before", description = "before-desc"))
val server = Nip86Server(banStore = store, infoHolder = holder, store = null)
val server = Nip86Server(banStore = store, infoHolder = holder)
return Triple(server, store, holder)
}