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 = private val nip86Route =
Nip86HttpRoute( Nip86HttpRoute(
server = nip86Server, server = nip86Server,
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip86RelayManagement.server package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent 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] * Holds the [BanStore] (mutated by ban/allow methods), an [InfoHolder]
* for the live NIP-11 doc (mutated by `changerelay*` methods, which * for the live NIP-11 doc (mutated by `changerelay*` methods, which
* atomically swap it), and an optional [IEventStore] so `banevent` * atomically swap it), and an [onBan] hook so each ban method can
* can also delete the offending event from the store. * retroactively purge matching events from the relay's event store.
* *
* Transport-agnostic — relay implementations call [dispatch] from * Transport-agnostic — relay implementations call [dispatch] from
* whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`), * whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`),
@@ -67,7 +66,23 @@ class Nip86Server(
* every request, not cache it. * every request, not cache it.
*/ */
private val infoHolder: InfoHolder, 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. */ /** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */
interface InfoHolder { interface InfoHolder {
@@ -108,7 +123,12 @@ class Nip86Server(
} }
Nip86Method.BAN_PUBKEY -> { 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 -> { Nip86Method.UNBAN_PUBKEY -> {
@@ -134,8 +154,9 @@ class Nip86Server(
Nip86Method.BAN_EVENT -> { Nip86Method.BAN_EVENT -> {
withHexAndReason(req, "event_id") { id, reason -> withHexAndReason(req, "event_id") { id, reason ->
banStore.banEvent(id, reason) banStore.banEvent(id, reason)
// Also remove the event from the store if present. // Also remove the event from the relay's event
store?.delete(Filter(ids = listOf(id))) // store so REQ stops serving the offending copy.
onBan(Filter(ids = listOf(id)))
} }
} }
@@ -152,7 +173,11 @@ class Nip86Server(
} }
Nip86Method.DISALLOW_KIND -> { 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 -> { Nip86Method.LIST_ALLOWED_KINDS -> {
@@ -205,10 +230,10 @@ class Nip86Server(
return okTrue return okTrue
} }
private inline fun withInt( private suspend inline fun withInt(
req: Nip86Request, req: Nip86Request,
label: String, label: String,
action: (Int) -> Unit, action: suspend (Int) -> Unit,
): Nip86Response { ): Nip86Response {
val v = req.params.firstInt() ?: return malformed("expected [$label]") val v = req.params.firstInt() ?: return malformed("expected [$label]")
action(v) action(v)
@@ -42,7 +42,7 @@ class Nip86ServerTest {
private fun fixture(): Triple<Nip86Server, BanStore, Holder> { private fun fixture(): Triple<Nip86Server, BanStore, Holder> {
val store = BanStore() val store = BanStore()
val holder = Holder(Nip11RelayInformation(name = "before", description = "before-desc")) 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) return Triple(server, store, holder)
} }