From e4e9ae70433b590cc18a237e12c4d2b329e888ab Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 12 May 2026 16:55:38 -0400 Subject: [PATCH] refactor(quartz): NIP-86 bans purge matching events via onBan(filter) hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../com/vitorpamplona/geode/KtorRelay.kt | 7 ++- .../server/Nip86Server.kt | 45 ++++++++++++++----- .../server/Nip86ServerTest.kt | 2 +- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index c63162787..5b90bfcb9 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt index 7b5734f82..134058130 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt @@ -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) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt index 9911f1355..da5b84203 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt @@ -42,7 +42,7 @@ class Nip86ServerTest { private fun fixture(): Triple { 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) }