From 72e7f37b6199b39239de7eee8d3d23c3bbffce2b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 12 May 2026 18:44:50 -0400 Subject: [PATCH] refactor(geode): move Nip86Server (+ adminPubkeys) into RelayEngine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KtorRelay was carrying state that wasn't HTTP-specific: the Nip86Server, its InfoHolder adapter, and the adminPubkeys allow-list. Push them into RelayEngine — it already owns the info doc, ban store, and event store the Nip86Server consults; the allow-list is a relay-level "who is admin?" decision, not a transport-level one. RelayEngine gains adminPubkeys: Set = emptySet() and exposes nip86Server as a public property. Future non-HTTP admin transports (in-process tools, hypothetical NIP-86-over-WS) read it directly without re-deriving the wiring. KtorRelay shrinks to mostly Ktor structures — routing block, engine config, lifecycle. It just builds the Nip86HttpHandler around relay.nip86Server. Main.kt and Nip86EndToEndTest pass adminPubkeys to RelayEngine instead of KtorRelay. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/vitorpamplona/geode/KtorRelay.kt | 45 +++++-------------- .../kotlin/com/vitorpamplona/geode/Main.kt | 2 +- .../com/vitorpamplona/geode/RelayEngine.kt | 33 ++++++++++++++ .../geode/admin/Nip86EndToEndTest.kt | 3 +- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index 8bf4bf970..0a1af895c 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -23,13 +23,10 @@ package com.vitorpamplona.geode import com.vitorpamplona.geode.server.Nip11HttpRoute import com.vitorpamplona.geode.server.Nip86HttpRoute import com.vitorpamplona.geode.server.WebSocketSessionPump -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession -import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler -import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server import io.ktor.server.application.install import io.ktor.server.application.serverConfig import io.ktor.server.cio.CIO @@ -70,14 +67,6 @@ class KtorRelay( /** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */ val port: Int = 0, val path: String = "/", - /** - * Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default) - * disables the admin endpoint entirely — POSTs return 403. - * Otherwise: HTTP POSTs to [path] with `Content-Type: - * application/nostr+json+rpc` are dispatched to [Nip86Server], - * gated by NIP-98 HTTP-Auth membership in this set. - */ - val adminPubkeys: Set = emptySet(), /** * Ktor CIO acceptor-thread count. `null` keeps Ktor's default. * Lift on machines with many cores when targeting 10k+ @@ -89,27 +78,12 @@ class KtorRelay( /** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */ val callGroupSize: Int? = null, ) { - private val infoHolder = - object : Nip86Server.InfoHolder { - override fun get(): Nip11RelayInformation = relay.info.document - - override fun set(info: Nip11RelayInformation) = relay.updateInfo { info } - } - - private val nip86Server = - Nip86Server( - banStore = relay.banStore, - infoHolder = infoHolder, - onBan = { filter -> relay.store.delete(filter) }, - allowList = adminPubkeys, - ) - /** - * Always assembled — when [adminPubkeys] is empty, the route - * still runs the canonical NIP-86 flow but every request fails - * the allow-list check ([Nip86HttpHandler.Response.NotAdmin] → - * 403). Uniform code path: the transport doesn't branch on - * "admin enabled?". + * NIP-86 HTTP adapter. Wraps the engine's [RelayEngine.nip86Server] + * with the NIP-98 HTTP flow ([Nip86HttpHandler]) and binds it to + * Ktor's request lifecycle. The admin pubkey allow-list lives on + * the engine, not here — turning admin on is a relay-level + * decision, not a transport-level one. * * The NIP-98 binding URL is derived from `relay.url` via [toHttp] * because NIP-86 mandates it: admin requests target the same URI @@ -117,10 +91,15 @@ class KtorRelay( * `ws(s)://`. This makes accidental misconfiguration impossible — * the operator's configured `[info].relay_url` is the single * source of truth. + * + * Always wired: when the engine's admin allow-list is empty, the + * handler runs the same flow and the allow-list check fails with + * [Nip86HttpHandler.Response.NotAdmin] → 403. No "admin enabled?" + * branch. */ private val nip86Route = Nip86HttpRoute( - handler = Nip86HttpHandler(server = nip86Server, publicUrl = relay.url.toHttp()), + handler = Nip86HttpHandler(server = relay.nip86Server, publicUrl = relay.url.toHttp()), ) private val nip11Route = Nip11HttpRoute(liveJson = { relay.info.json }) @@ -187,7 +166,7 @@ class KtorRelay( } // NIP-86: POST application/nostr+json+rpc with a NIP-98 // signed Authorization header → JSON-RPC dispatch. - // Always mounted; an empty [adminPubkeys] just means + // Always mounted; an empty admin allow-list on the engine just means // every request fails the allow-list check (403). post(path) { nip86Route.handle(call) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 7cc2ec577..ea89b437c 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -143,6 +143,7 @@ fun main(args: Array) { policyBuilder, parallelVerify = parallelVerify, negentropySettings = negentropySettings, + adminPubkeys = config.admin.pubkeys.toSet(), ) val server = KtorRelay( @@ -150,7 +151,6 @@ fun main(args: Array) { host = host, port = port, path = path, - adminPubkeys = config.admin.pubkeys.toSet(), connectionGroupSize = config.network.connection_group_size, workerGroupSize = config.network.worker_group_size, callGroupSize = config.network.call_group_size, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt index 8923521b2..c988d3cc7 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayEngine.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.geode import com.vitorpamplona.geode.config.BannedEntry import com.vitorpamplona.geode.config.RuntimeConfig import com.vitorpamplona.geode.config.RuntimeConfigData +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer @@ -33,6 +34,7 @@ import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore +import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server import kotlinx.coroutines.SupervisorJob import kotlin.coroutines.CoroutineContext @@ -92,6 +94,14 @@ class RelayEngine( * per-connection session cap). Defaults to strfry-parity values. */ negentropySettings: NegentropySettings = NegentropySettings.Default, + /** + * Pubkeys allowed to invoke NIP-86 admin RPCs. Empty (the default) + * effectively disables the admin API: every dispatch returns + * `not authorized`. Transports (e.g. [KtorRelay] over HTTP) read + * [nip86Server] and bind it to their wire protocol — the engine + * owns *who* is admin; the transport owns *how* admins authenticate. + */ + adminPubkeys: Set = emptySet(), ) : AutoCloseable { /** * The runtime state to install at boot: the persisted snapshot if @@ -145,6 +155,29 @@ class RelayEngine( ) } + /** + * NIP-86 admin RPC dispatcher. Owns the ban / allow / kind list + * mutations, the NIP-11 `changerelay*` mutations (via an + * [Nip86Server.InfoHolder] adapter that wraps [info] / [updateInfo]), + * the event-store purge on each ban method (via `store.delete`), + * and the admin allow-list ([adminPubkeys]). Transport-agnostic: + * `KtorRelay` wraps it with `Nip86HttpHandler` for HTTP/NIP-98; + * an in-process tool could call `dispatch(adminPubkey, req)` + * directly. + */ + val nip86Server: Nip86Server = + Nip86Server( + banStore = banStore, + infoHolder = + object : Nip86Server.InfoHolder { + override fun get(): Nip11RelayInformation = info.document + + override fun set(info: Nip11RelayInformation) = updateInfo { info } + }, + onBan = { filter -> store.delete(filter) }, + allowList = adminPubkeys, + ) + /** * Writes the current state (NIP-11 doc + ban lists) to disk. * No-op when no `stateFile` was configured (see [RuntimeConfig.save]). diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt index 12bddfdfa..5c119e1cd 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt @@ -79,13 +79,12 @@ class Nip86EndToEndTest { // server expects to differ from where it actually listens. val freePort = java.net.ServerSocket(0).use { it.localPort } val url = "ws://127.0.0.1:$freePort/".normalizeRelayUrl() - relay = RelayEngine(url = url) + relay = RelayEngine(url = url, adminPubkeys = setOf(admin.pubKey)) server = KtorRelay( relay = relay, host = "127.0.0.1", port = freePort, - adminPubkeys = setOf(admin.pubKey), ).start() scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }